// ============================================================================ // Stylescape | Autocomplete Manager // ============================================================================ // Provides autocomplete/search suggestions for input fields. // Supports data-ss-autocomplete attributes for declarative configuration. // ============================================================================ /** * Configuration options for AutocompleteManager */ export interface AutocompleteOptions { /** Static list of suggestions */ suggestions?: string[]; /** Async function to fetch suggestions */ fetchSuggestions?: (query: string) => Promise; /** Minimum characters before showing suggestions */ minChars?: number; /** Maximum suggestions to show */ maxResults?: number; /** Debounce delay in ms */ debounce?: number; /** Whether to highlight matching text */ highlight?: boolean; /** CSS class for the suggestions container */ containerClass?: string; /** CSS class for suggestion items */ itemClass?: string; /** CSS class for highlighted text */ highlightClass?: string; /** CSS class for active/selected item */ activeClass?: string; /** Callback when suggestion is selected */ onSelect?: (value: string, item: HTMLElement) => void; } /** * Autocomplete manager for input fields with keyboard navigation. * * @example JavaScript * ```typescript * const autocomplete = new AutocompleteManager("#search", { * suggestions: ["Apple", "Banana", "Cherry"], * onSelect: (value) => console.log(`Selected: ${value}`) * }) * ``` * * @example HTML with data-ss * ```html * * ``` */ export class AutocompleteManager { private input: HTMLInputElement | null; private container: HTMLElement | null = null; private options: Required; private activeIndex: number = -1; private isOpen: boolean = false; private debounceTimer: number | null = null; private currentSuggestions: string[] = []; constructor( inputSelectorOrElement: string | HTMLInputElement, options: AutocompleteOptions = {}, ) { this.input = typeof inputSelectorOrElement === "string" ? document.querySelector( inputSelectorOrElement, ) : inputSelectorOrElement; this.options = { suggestions: options.suggestions ?? [], fetchSuggestions: options.fetchSuggestions ?? (async () => []), minChars: options.minChars ?? 1, maxResults: options.maxResults ?? 10, debounce: options.debounce ?? 200, highlight: options.highlight !== false, containerClass: options.containerClass ?? "autocomplete", itemClass: options.itemClass ?? "autocomplete__item", highlightClass: options.highlightClass ?? "autocomplete__highlight", activeClass: options.activeClass ?? "autocomplete__item--active", onSelect: options.onSelect ?? (() => {}), }; if (!this.input) { console.warn("[Stylescape] AutocompleteManager input not found"); return; } this.init(); } // ======================================================================== // Public Methods // ======================================================================== /** * Update suggestions list */ public setSuggestions(suggestions: string[]): void { this.options.suggestions = suggestions; } /** * Open suggestions dropdown */ public open(): void { if (!this.container || this.currentSuggestions.length === 0) return; this.container.style.display = "block"; this.isOpen = true; this.input?.setAttribute("aria-expanded", "true"); } /** * Close suggestions dropdown */ public close(): void { if (!this.container) return; this.container.style.display = "none"; this.isOpen = false; this.activeIndex = -1; this.input?.setAttribute("aria-expanded", "false"); this.clearActive(); } /** * Destroy the autocomplete */ public destroy(): void { this.close(); this.container?.remove(); this.input?.removeEventListener("input", this.handleInput); this.input?.removeEventListener("keydown", this.handleKeyDown); this.input?.removeEventListener("blur", this.handleBlur); this.input = null; this.container = null; } // ======================================================================== // Private Methods // ======================================================================== private init(): void { if (!this.input) return; // Create suggestions container this.createContainer(); // Set ARIA attributes this.input.setAttribute("role", "combobox"); this.input.setAttribute("aria-autocomplete", "list"); this.input.setAttribute("aria-expanded", "false"); this.input.setAttribute("aria-haspopup", "listbox"); // Add event listeners this.input.addEventListener("input", this.handleInput); this.input.addEventListener("keydown", this.handleKeyDown); this.input.addEventListener("blur", this.handleBlur); this.input.addEventListener("focus", () => { if (this.currentSuggestions.length > 0) this.open(); }); } private createContainer(): void { this.container = document.createElement("div"); this.container.className = this.options.containerClass; this.container.setAttribute("role", "listbox"); this.container.style.display = "none"; this.container.style.position = "absolute"; // Position relative to input const wrapper = document.createElement("div"); wrapper.style.position = "relative"; if (this.input) { this.input.parentNode?.insertBefore(wrapper, this.input); wrapper.appendChild(this.input); } wrapper.appendChild(this.container); } private handleInput = async (): Promise => { if (this.debounceTimer) { clearTimeout(this.debounceTimer); } this.debounceTimer = window.setTimeout(async () => { const query = this.input?.value.trim() || ""; if (query.length < this.options.minChars) { this.close(); return; } await this.updateSuggestions(query); }, this.options.debounce); }; private async updateSuggestions(query: string): Promise { let suggestions: string[]; // Use fetch function if provided and no static suggestions match if ( this.options.fetchSuggestions && this.options.suggestions.length === 0 ) { suggestions = await this.options.fetchSuggestions(query); } else { // Filter static suggestions const lowerQuery = query.toLowerCase(); suggestions = this.options.suggestions.filter((s) => s.toLowerCase().includes(lowerQuery), ); } // Limit results this.currentSuggestions = suggestions.slice( 0, this.options.maxResults, ); this.renderSuggestions(query); if (this.currentSuggestions.length > 0) { this.open(); } else { this.close(); } } private renderSuggestions(query: string): void { if (!this.container) return; this.container.innerHTML = ""; this.activeIndex = -1; this.currentSuggestions.forEach((suggestion, index) => { const item = document.createElement("div"); item.className = this.options.itemClass; item.setAttribute("role", "option"); item.setAttribute("data-index", String(index)); if (this.options.highlight) { item.innerHTML = this.highlightMatch(suggestion, query); } else { item.textContent = suggestion; } item.addEventListener("mousedown", (e) => { e.preventDefault(); this.selectSuggestion(index); }); item.addEventListener("mouseenter", () => { this.setActive(index); }); this.container?.appendChild(item); }); } private highlightMatch(text: string, query: string): string { const regex = new RegExp(`(${this.escapeRegex(query)})`, "gi"); return text.replace( regex, `$1`, ); } private escapeRegex(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } private handleKeyDown = (e: KeyboardEvent): void => { if (!this.isOpen) { if (e.key === "ArrowDown" && this.currentSuggestions.length > 0) { e.preventDefault(); this.open(); } return; } switch (e.key) { case "ArrowDown": e.preventDefault(); this.moveActive(1); break; case "ArrowUp": e.preventDefault(); this.moveActive(-1); break; case "Enter": e.preventDefault(); if (this.activeIndex >= 0) { this.selectSuggestion(this.activeIndex); } break; case "Escape": this.close(); break; case "Tab": this.close(); break; } }; private handleBlur = (): void => { // Delay to allow click on suggestion setTimeout(() => this.close(), 150); }; private moveActive(delta: number): void { const newIndex = this.activeIndex + delta; const maxIndex = this.currentSuggestions.length - 1; if (newIndex < 0) { this.setActive(maxIndex); } else if (newIndex > maxIndex) { this.setActive(0); } else { this.setActive(newIndex); } } private setActive(index: number): void { this.clearActive(); this.activeIndex = index; const item = this.container?.querySelector(`[data-index="${index}"]`); if (item) { item.classList.add(this.options.activeClass); item.setAttribute("aria-selected", "true"); item.scrollIntoView({ block: "nearest" }); } } private clearActive(): void { this.container ?.querySelectorAll(`.${this.options.activeClass}`) .forEach((el) => { el.classList.remove(this.options.activeClass); el.setAttribute("aria-selected", "false"); }); } private selectSuggestion(index: number): void { const value = this.currentSuggestions[index]; if (!value || !this.input) return; this.input.value = value; this.close(); const item = this.container?.querySelector( `[data-index="${index}"]`, ) as HTMLElement; this.options.onSelect(value, item); // Dispatch change event this.input.dispatchEvent(new Event("change", { bubbles: true })); } } export default AutocompleteManager;