/** * Search Toolbar Component * Advanced search with filters, sorting, and autocomplete */ import { iconLoader } from "@ui/utils/icon-loader"; export interface SearchToolbarOptions { placeholder?: string; autocomplete?: string[] | ((query: string) => Promise); filters?: FilterOption[]; sortOptions?: SortOption[]; onSearch?: ( query: string, filters: Record, sort: string, ) => void; debounceMs?: number; } export interface FilterOption { id: string; label: string; values: { value: string; label: string }[]; default?: string; } export interface SortOption { value: string; label: string; } export interface AutocompleteItem { text: string; category?: string; meta?: string; } export class SearchToolbar { private container: HTMLElement; private options: Required; private inputElement: HTMLInputElement; private autocompleteElement: HTMLElement; private clearButton: HTMLButtonElement; private activeFilters: Record = {}; private activeSort: string = ""; private debounceTimer?: number; private currentQuery: string = ""; private autocompleteItems: AutocompleteItem[] = []; private activeItemIndex: number = -1; constructor( container: string | HTMLElement, options: SearchToolbarOptions = {}, ) { this.container = typeof container === "string" ? document.querySelector(container)! : container; if (!this.container) { throw new Error("Container element not found"); } this.options = { placeholder: "Search...", autocomplete: [], filters: [], sortOptions: [], onSearch: () => {}, debounceMs: 300, ...options, }; // Initialize default filter values this.options.filters.forEach((filter) => { this.activeFilters[filter.id] = filter.default || filter.values[0]?.value || ""; }); if (this.options.sortOptions.length > 0 && this.options.sortOptions[0]) { this.activeSort = this.options.sortOptions[0].value; } this.render(); this.inputElement = this.container.querySelector(".search-toolbar-input")!; this.autocompleteElement = this.container.querySelector( ".search-toolbar-autocomplete", )!; this.clearButton = this.container.querySelector(".search-toolbar-clear")!; this.attachEventListeners(); } private async render(): Promise { const hasControls = this.options.filters.length > 0 || this.options.sortOptions.length > 0; this.container.innerHTML = `
${ hasControls ? `
${this.renderFilters()} ${ this.options.filters.length > 0 && this.options.sortOptions.length > 0 ? '
' : "" } ${this.renderSortOptions()}
` : "" }
`; // Load icons dynamically await this.loadIcons(); } private async loadIcons(): Promise { try { const searchIcon = this.container.querySelector('[data-icon="search"]'); if (searchIcon) { const svg = await iconLoader.load("search", { width: 20, height: 20 }); searchIcon.innerHTML = svg; } const closeIcon = this.container.querySelector('[data-icon="close"]'); if (closeIcon) { const svg = await iconLoader.load("close", { width: 16, height: 16 }); closeIcon.innerHTML = svg; } } catch (error) { console.error("Failed to load icons:", error); } } private renderFilters(): string { return this.options.filters .map( (filter) => `
${filter.values .map( (value) => ` `, ) .join("")}
`, ) .join(""); } private renderSortOptions(): string { if (this.options.sortOptions.length === 0) return ""; return `
${this.options.sortOptions .map( (option) => ` `, ) .join("")}
`; } private attachEventListeners(): void { // Input events this.inputElement.addEventListener("input", () => this.handleInput()); this.inputElement.addEventListener("keydown", (e) => this.handleKeyDown(e)); this.inputElement.addEventListener("focus", () => this.handleInput()); this.inputElement.addEventListener("blur", () => { setTimeout(() => this.hideAutocomplete(), 200); }); // Clear button this.clearButton.addEventListener("click", () => this.clear()); // Filter buttons this.container.querySelectorAll("[data-filter-id]").forEach((group) => { const filterId = group.getAttribute("data-filter-id")!; group.querySelectorAll(".search-toolbar-filter-btn").forEach((btn) => { btn.addEventListener("click", () => { const value = btn.getAttribute("data-filter-value")!; this.setFilter(filterId, value); }); }); }); // Sort buttons this.container .querySelectorAll("[data-sort-group] .search-toolbar-filter-btn") .forEach((btn) => { btn.addEventListener("click", () => { const value = btn.getAttribute("data-sort-value")!; this.setSort(value); }); }); } private handleInput(): void { this.currentQuery = this.inputElement.value; if (this.debounceTimer) { clearTimeout(this.debounceTimer); } this.debounceTimer = window.setTimeout(async () => { await this.updateAutocomplete(); this.triggerSearch(); }, this.options.debounceMs); } private async updateAutocomplete(): Promise { const query = this.currentQuery.trim(); if (!query) { this.hideAutocomplete(); return; } let items: string[] | AutocompleteItem[]; if (typeof this.options.autocomplete === "function") { items = await this.options.autocomplete(query); } else { items = this.options.autocomplete.filter((item) => item.toLowerCase().includes(query.toLowerCase()), ); } // Convert strings to AutocompleteItem this.autocompleteItems = items.map((item) => typeof item === "string" ? { text: item } : item, ); if (this.autocompleteItems.length > 0) { this.renderAutocomplete(); this.showAutocomplete(); } else { this.hideAutocomplete(); } } private renderAutocomplete(): void { const query = this.currentQuery.toLowerCase(); const groupedItems = this.groupItemsByCategory(this.autocompleteItems); let html = ""; Object.entries(groupedItems).forEach(([category, items]) => { if (category) { html += `
${category}
`; } items.forEach((item, index) => { const highlightedText = this.highlightMatch(item.text, query); html += `
${highlightedText}
${item.meta ? `
${item.meta}
` : ""}
`; }); }); this.autocompleteElement.innerHTML = html; // Attach click handlers this.autocompleteElement .querySelectorAll(".search-toolbar-autocomplete-item") .forEach((item) => { item.addEventListener("click", () => { const index = parseInt(item.getAttribute("data-index")!); this.selectItem(index); }); }); } private groupItemsByCategory( items: AutocompleteItem[], ): Record { const grouped: Record = {}; items.forEach((item) => { const category = item.category || ""; if (!grouped[category]) { grouped[category] = []; } grouped[category].push(item); }); return grouped; } private highlightMatch(text: string, query: string): string { const index = text.toLowerCase().indexOf(query.toLowerCase()); if (index === -1) return text; const before = text.slice(0, index); const match = text.slice(index, index + query.length); const after = text.slice(index + query.length); return `${before}${match}${after}`; } private handleKeyDown(e: KeyboardEvent): void { const items = this.autocompleteElement.querySelectorAll( ".search-toolbar-autocomplete-item", ); if (items.length === 0) return; switch (e.key) { case "ArrowDown": e.preventDefault(); this.activeItemIndex = Math.min( this.activeItemIndex + 1, items.length - 1, ); this.updateActiveItem(items); break; case "ArrowUp": e.preventDefault(); this.activeItemIndex = Math.max(this.activeItemIndex - 1, -1); this.updateActiveItem(items); break; case "Enter": e.preventDefault(); if (this.activeItemIndex >= 0) { this.selectItem(this.activeItemIndex); } else { this.hideAutocomplete(); this.triggerSearch(); } break; case "Escape": this.hideAutocomplete(); break; } } private updateActiveItem(items: NodeListOf): void { items.forEach((item, index) => { item.classList.toggle("is-active", index === this.activeItemIndex); }); if (this.activeItemIndex >= 0) { const activeItem = items[this.activeItemIndex]; if (activeItem && activeItem instanceof HTMLElement) { activeItem.scrollIntoView({ block: "nearest" }); } } } private selectItem(index: number): void { const item = this.autocompleteItems[index]; if (item) { this.inputElement.value = item.text; this.currentQuery = item.text; this.hideAutocomplete(); this.triggerSearch(); } } private showAutocomplete(): void { this.autocompleteElement.classList.add("is-visible"); this.activeItemIndex = -1; } private hideAutocomplete(): void { this.autocompleteElement.classList.remove("is-visible"); this.activeItemIndex = -1; } private setFilter(filterId: string, value: string): void { this.activeFilters[filterId] = value; // Update button states const group = this.container.querySelector( `[data-filter-id="${filterId}"]`, ); if (group) { group.querySelectorAll(".search-toolbar-filter-btn").forEach((btn) => { btn.classList.toggle( "is-active", btn.getAttribute("data-filter-value") === value, ); }); } this.triggerSearch(); } private setSort(value: string): void { this.activeSort = value; // Update button states this.container .querySelectorAll("[data-sort-group] .search-toolbar-filter-btn") .forEach((btn) => { btn.classList.toggle( "is-active", btn.getAttribute("data-sort-value") === value, ); }); this.triggerSearch(); } private triggerSearch(): void { this.options.onSearch( this.currentQuery, this.activeFilters, this.activeSort, ); } public clear(): void { this.inputElement.value = ""; this.currentQuery = ""; this.hideAutocomplete(); this.triggerSearch(); } public getQuery(): string { return this.currentQuery; } public getFilters(): Record { return { ...this.activeFilters }; } public getSort(): string { return this.activeSort; } public setResultsCount(count: number): void { const resultsEl = this.container.querySelector(".search-toolbar-results"); if (resultsEl) { resultsEl.textContent = `${count} result${count !== 1 ? "s" : ""}`; } } public destroy(): void { if (this.debounceTimer) { clearTimeout(this.debounceTimer); } this.container.innerHTML = ""; } } // Export for use in browser if (typeof window !== "undefined") { (window as any).SearchToolbar = SearchToolbar; }