/** * KTUI - Free & Open-Source Tailwind UI Components by Keenthemes * Copyright 2025 by Keenthemes Inc */ // utils.ts import { KTSelectConfigInterface } from './config'; /** * Format a number as a currency string */ export function formatCurrency(value: number): string { return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', }).format(value); } /** * Filter options based on a search query */ export function filterOptions( options: HTMLElement[], query: string, config: KTSelectConfigInterface, dropdownElement: HTMLElement, onVisibleCount?: (count: number) => void, ): number { let visibleOptionsCount = 0; // For empty query, make all options visible // The KTSelectSearch class is now responsible for restoring original content before calling this. if (!query || query.trim() === '') { for (const option of options) { option.classList.remove('hidden'); // Remove inline display style if it was used to hide if (option.style.display === 'none') { option.style.display = ''; } // At this point, option.innerHTML should be its original. visibleOptionsCount++; } if (onVisibleCount) { onVisibleCount(visibleOptionsCount); } return visibleOptionsCount; } const queryLower = query.toLowerCase(); for (const option of options) { // Use data-text for matching if available, otherwise fall back to textContent const optionText = ( option.dataset.text || option.textContent || '' ).toLowerCase(); const isMatch = optionText.includes(queryLower); if (isMatch) { option.classList.remove('hidden'); if (option.style.display === 'none') option.style.display = ''; // Ensure visible visibleOptionsCount++; } else { option.classList.add('hidden'); } // Early exit if maxItems limit is reached (optional) // if (config.searchMaxItems && visibleOptionsCount >= config.searchMaxItems) { // break; // } } if (onVisibleCount) { onVisibleCount(visibleOptionsCount); } return visibleOptionsCount; } /** * Focus manager for keyboard navigation * Consolidates redundant focus management logic into shared functions */ export class FocusManager { private _element: HTMLElement; private _optionsSelector: string; private _focusedOptionIndex: number | null = null; private _focusClass: string; private _hoverClass: string; private _eventManager: EventManager; private _onFocusChange: | ((option: HTMLElement | null, index: number | null) => void) | null = null; constructor( element: HTMLElement, optionsSelector: string = '[data-kt-select-option]', _config?: KTSelectConfigInterface, ) { this._element = element; this._optionsSelector = optionsSelector; this._eventManager = new EventManager(); // Add click handler to update focus state when options are clicked this._setupOptionClickHandlers(); this._focusClass = 'focus'; // or whatever your intended class is this._hoverClass = 'hover'; // or your intended class } /** * Set up click handlers for all options to update focus state */ private _setupOptionClickHandlers(): void { // Add click handler to the options container this._eventManager.addListener(this._element, 'click', (e: Event) => { const target = e.target as HTMLElement; const optionElement = target.closest(this._optionsSelector); void optionElement; }); } /** * Get all visible options */ public getVisibleOptions(): HTMLElement[] { return Array.from( this._element.querySelectorAll(this._optionsSelector), ).filter((option) => { const element = option as HTMLElement; // Check only for hidden class if (element.classList.contains('hidden')) { return false; } // Also check inline styles for backward compatibility if (element.style.display === 'none') { return false; } return true; }) as HTMLElement[]; } /** * Focus the first visible option */ public focusFirst(): HTMLElement | null { const options = this.getVisibleOptions(); if (options.length === 0) return null; for (let i = 0; i < options.length; i++) { const option = options[i]; if ( !option.classList.contains('disabled') && option.getAttribute('aria-disabled') !== 'true' ) { this.resetFocus(); this._focusedOptionIndex = i; this.applyFocus(option); this.scrollIntoView(option); return option; } } return null; } /** * Focus the last visible option */ public focusLast(): HTMLElement | null { const options = this.getVisibleOptions(); if (options.length === 0) return null; for (let i = options.length - 1; i >= 0; i--) { const option = options[i]; if ( !option.classList.contains('disabled') && option.getAttribute('aria-disabled') !== 'true' ) { this.resetFocus(); this._focusedOptionIndex = i; this.applyFocus(option); this.scrollIntoView(option); return option; } } return null; } /** * Focus the next visible option that matches the search string */ public focusByString(str: string): HTMLElement | null { const options = this.getVisibleOptions(); if (options.length === 0) return null; const lowerStr = str.toLowerCase(); const startIdx = (this._focusedOptionIndex ?? -1) + 1; for (let i = 0; i < options.length; i++) { const idx = (startIdx + i) % options.length; const option = options[idx]; if ( !option.classList.contains('disabled') && option.getAttribute('aria-disabled') !== 'true' && (option.textContent?.toLowerCase().startsWith(lowerStr) || option.dataset.value?.toLowerCase().startsWith(lowerStr)) ) { this.resetFocus(); this._focusedOptionIndex = idx; this.applyFocus(option); this.scrollIntoView(option); return option; } } return null; } /** * Focus the next visible option */ public focusNext(): HTMLElement | null { const options = this.getVisibleOptions(); if (options.length === 0) return null; let idx = this._focusedOptionIndex === null ? 0 : (this._focusedOptionIndex + 1) % options.length; const startIdx = idx; do { const option = options[idx]; if ( !option.classList.contains('disabled') && option.getAttribute('aria-disabled') !== 'true' ) { this.resetFocus(); this._focusedOptionIndex = idx; this.applyFocus(option); this.scrollIntoView(option); return option; } idx = (idx + 1) % options.length; } while (idx !== startIdx); return null; } /** * Focus the previous visible option */ public focusPrevious(): HTMLElement | null { const options = this.getVisibleOptions(); if (options.length === 0) return null; let idx = this._focusedOptionIndex === null ? options.length - 1 : (this._focusedOptionIndex - 1 + options.length) % options.length; const startIdx = idx; do { const option = options[idx]; if ( !option.classList.contains('disabled') && option.getAttribute('aria-disabled') !== 'true' ) { this.resetFocus(); this._focusedOptionIndex = idx; this.applyFocus(option); this.scrollIntoView(option); return option; } idx = (idx - 1 + options.length) % options.length; } while (idx !== startIdx); return null; } /** * Apply focus to a specific option */ public applyFocus(option: HTMLElement): void { if (!option) return; // Ensure it's not disabled if ( option.classList.contains('disabled') || option.getAttribute('aria-disabled') === 'true' ) { return; } // DO NOT CALL resetFocus() here. Caller's responsibility. option.classList.add(this._focusClass); option.classList.add(this._hoverClass); // _triggerFocusChange needs _focusedOptionIndex to be set by the caller before this. this._triggerFocusChange(); } /** * Reset focus on all options */ public resetFocus(): void { const focusedElements = this._element.querySelectorAll( `.${this._focusClass}, .${this._hoverClass}`, ); // Remove focus and hover classes from all options focusedElements.forEach((element) => { element.classList.remove(this._focusClass, this._hoverClass); }); this._focusedOptionIndex = null; // Always reset the index } /** * Ensure the focused option is visible in the scrollable container */ public scrollIntoView(option: HTMLElement): void { if (!option) return; const container = this._element.querySelector('[data-kt-select-options]'); if (!container) return; const optionRect = option.getBoundingClientRect(); const containerRect = container.getBoundingClientRect(); // Check if option is below the visible area if (optionRect.bottom > containerRect.bottom) { option.scrollIntoView({ block: 'end', behavior: 'smooth' }); } // Check if option is above the visible area else if (optionRect.top < containerRect.top) { option.scrollIntoView({ block: 'start', behavior: 'smooth' }); } } /** * Focus a specific option by its value */ public focusOptionByValue(value: string): boolean { const options = this.getVisibleOptions(); const index = options.findIndex((option) => option.dataset.value === value); if (index >= 0) { const optionToFocus = options[index]; if ( !optionToFocus.classList.contains('disabled') && optionToFocus.getAttribute('aria-disabled') !== 'true' ) { this.resetFocus(); this._focusedOptionIndex = index; this.applyFocus(optionToFocus); this.scrollIntoView(optionToFocus); return true; } } return false; } /** * Get the currently focused option */ public getFocusedOption(): HTMLElement | null { const options = this.getVisibleOptions(); if ( this._focusedOptionIndex !== null && this._focusedOptionIndex < options.length ) { return options[this._focusedOptionIndex]; } // Fallback: DOM may have focus class applied (e.g. by arrow keys from search input) // while _focusedOptionIndex is out of sync. Use the option that has the focus class. const focusedEl = this._element.querySelector( `${this._optionsSelector}.${this._focusClass}`, ) as HTMLElement | null; if ( focusedEl && !focusedEl.classList.contains('hidden') && focusedEl.style.display !== 'none' ) { const idx = options.indexOf(focusedEl); if (idx >= 0) { this._focusedOptionIndex = idx; return focusedEl; } } return null; } /** * Get the index of the currently focused option */ public getFocusedIndex(): number | null { return this._focusedOptionIndex; } /** * Set the focused option index directly */ public setFocusedIndex(index: number | null): void { this._focusedOptionIndex = index; } /** * Set a callback to be called when focus changes */ public setOnFocusChange( cb: (option: HTMLElement | null, index: number | null) => void, ) { this._onFocusChange = cb; } private _triggerFocusChange() { if (this._onFocusChange) { this._onFocusChange(this.getFocusedOption(), this._focusedOptionIndex); } } /** * Clean up event listeners */ public dispose(): void { if (this._eventManager) { this._eventManager.removeAllListeners(this._element); } } } /** * Centralized event listener management */ export class EventManager { private _boundHandlers: Map< string, Map > = new Map(); /** * Add an event listener with a bound context */ public addListener( element: HTMLElement, event: string, handler: EventListenerOrEventListenerObject, context?: unknown, ): void { if (!element) return; // Create a bound version of the handler if context provided const boundHandler: EventListenerOrEventListenerObject = context && typeof handler === 'function' ? handler.bind(context) : handler; // Store the relationship between original and bound handler if (!this._boundHandlers.has(event)) { this._boundHandlers.set(event, new Map()); } const eventMap = this._boundHandlers.get(event)!; eventMap.set(handler, boundHandler); // Add the event listener element.addEventListener(event, boundHandler); } /** * Remove an event listener */ public removeListener( element: HTMLElement, event: string, handler: EventListenerOrEventListenerObject, ): void { if (!element) return; const eventMap = this._boundHandlers.get(event); if (!eventMap) return; // Get the bound version of the handler const boundHandler = eventMap.get(handler); if (!boundHandler) return; // Remove the event listener element.removeEventListener(event, boundHandler); // Clean up the map eventMap.delete(handler); if (eventMap.size === 0) { this._boundHandlers.delete(event); } } /** * Remove all event listeners */ public removeAllListeners(element: HTMLElement): void { if (!element) return; // Go through each event type this._boundHandlers.forEach((eventMap, event) => { // For each event type, go through each handler eventMap.forEach((boundHandler) => { element.removeEventListener(event, boundHandler); }); }); // Clear the maps this._boundHandlers.clear(); } } /** * Debounce function to limit how often a function can be called */ export function debounce( func: (...args: unknown[]) => void, delay: number, ): (...args: unknown[]) => void { let timeout: ReturnType; return function (...args: unknown[]) { clearTimeout(timeout); timeout = setTimeout(() => func(...args), delay); }; } /** * Replaces all {{key}} in the template with the corresponding value from the data object. * If a key is missing in data, replaces with an empty string. */ export function renderTemplateString( template: string, data: Record, ): string { return template.replace(/{{(\w+)}}/g, (_, key) => data[key] !== undefined && data[key] !== null ? String(data[key]) : '', ); } // Type-to-search buffer utility for keyboard navigation export class TypeToSearchBuffer { private buffer: string = ''; private lastTime: number = 0; private timeout: number; constructor(timeout: number = 500) { this.timeout = timeout; } public push(char: string) { const now = Date.now(); if (now - this.lastTime > this.timeout) { this.buffer = ''; } this.buffer += char; this.lastTime = now; } public getBuffer() { return this.buffer; } public clear() { this.buffer = ''; } } export function stringToElement(html: string): HTMLElement { const template = document.createElement('template'); template.innerHTML = html.trim(); return template.content.firstElementChild as HTMLElement; }