Search Autocomplete
Features Demonstrated
Code
Component (search-autocomplete.ts)
// src/components/search-autocomplete.ts
import { bindable, INode, IPlatform } from '@aurelia/runtime-html';
import { resolve } from '@aurelia/kernel';
export interface SearchResult {
id: string | number;
title: string;
description?: string;
image?: string;
category?: string;
}
export class SearchAutocomplete {
@bindable placeholder = 'Search...';
@bindable minLength = 2;
@bindable debounceMs = 300;
@bindable maxResults = 10;
@bindable onSelect: (result: SearchResult) => void;
@bindable onSearch: (query: string) => Promise<SearchResult[]>;
private query = '';
private results: SearchResult[] = [];
private isOpen = false;
private isLoading = false;
private selectedIndex = -1;
private searchTimeout: any = null;
private inputElement?: HTMLInputElement;
private dropdownElement?: HTMLElement;
private clickOutsideListener?: (e: MouseEvent) => void;
private readonly platform = resolve(IPlatform);
private readonly element = resolve(INode);
attached() {
// Listen for clicks outside to close dropdown
this.clickOutsideListener = (e: MouseEvent) => {
if (!this.element.contains(e.target as Node)) {
this.close();
}
};
this.platform.document?.addEventListener('click', this.clickOutsideListener);
}
detaching() {
// Clean up event listener
if (this.clickOutsideListener) {
this.platform.document?.removeEventListener('click', this.clickOutsideListener);
}
// Clean up timeout
if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
}
}
private async performSearch() {
if (!this.query || this.query.length < this.minLength) {
this.results = [];
this.isOpen = false;
return;
}
this.isLoading = true;
this.isOpen = true;
try {
if (this.onSearch) {
// Use custom search function
this.results = await this.onSearch(this.query);
} else {
// Use default search (for demo purposes)
this.results = await this.defaultSearch(this.query);
}
// Limit results
this.results = this.results.slice(0, this.maxResults);
// Reset selection
this.selectedIndex = -1;
} catch (error) {
console.error('Search failed:', error);
this.results = [];
} finally {
this.isLoading = false;
}
}
// Default search implementation (replace with real API)
private async defaultSearch(query: string): Promise<SearchResult[]> {
// Simulate API delay
await new Promise(resolve => setTimeout(resolve, 500));
const mockData: SearchResult[] = [
{ id: 1, title: 'Getting Started with Aurelia', category: 'Tutorial' },
{ id: 2, title: 'Advanced Routing', category: 'Guide' },
{ id: 3, title: 'Dependency Injection', category: 'Concept' },
{ id: 4, title: 'Template Syntax', category: 'Reference' },
{ id: 5, title: 'Validation Plugin', category: 'Plugin' },
];
return mockData.filter(item =>
item.title.toLowerCase().includes(query.toLowerCase()) ||
item.category?.toLowerCase().includes(query.toLowerCase())
);
}
queryChanged(newValue: string, oldValue: string) {
// Clear existing timeout
if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
}
// Debounce the search
this.searchTimeout = setTimeout(() => {
this.performSearch();
}, this.debounceMs);
}
handleKeydown(event: KeyboardEvent) {
if (!this.isOpen || this.results.length === 0) {
return;
}
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
this.selectedIndex = Math.min(this.selectedIndex + 1, this.results.length - 1);
this.scrollToSelected();
break;
case 'ArrowUp':
event.preventDefault();
this.selectedIndex = Math.max(this.selectedIndex - 1, -1);
this.scrollToSelected();
break;
case 'Enter':
event.preventDefault();
if (this.selectedIndex >= 0) {
this.selectResult(this.results[this.selectedIndex]);
}
break;
case 'Escape':
event.preventDefault();
this.close();
break;
}
}
private scrollToSelected() {
if (!this.dropdownElement || this.selectedIndex < 0) {
return;
}
const selectedElement = this.dropdownElement.querySelector(
`.autocomplete-item[data-index="${this.selectedIndex}"]`
) as HTMLElement;
if (selectedElement) {
selectedElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
}
selectResult(result: SearchResult) {
if (this.onSelect) {
this.onSelect(result);
}
// Set input to selected title
this.query = result.title;
// Close dropdown
this.close();
}
close() {
this.isOpen = false;
this.selectedIndex = -1;
}
highlightMatch(text: string, query: string): string {
if (!query) return text;
const regex = new RegExp(`(${this.escapeRegex(query)})`, 'gi');
return text.replace(regex, '<mark>$1</mark>');
}
private escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
get showEmpty(): boolean {
return this.isOpen &&
!this.isLoading &&
this.query.length >= this.minLength &&
this.results.length === 0;
}
}Template (search-autocomplete.html)
Styles (search-autocomplete.css)
Usage Example
How It Works
Debouncing
Keyboard Navigation
Click Outside
Highlighting Matches
Accessibility
Variations
Recent Searches
Grouped Results
Infinite Scroll
Related
Last updated
Was this helpful?