Search Autocomplete

A complete autocomplete/typeahead search component with keyboard navigation, highlighting, and debouncing.

Features Demonstrated

  • Two-way data binding - Search input

  • Debouncing - Optimize API calls

  • Computed properties - Filtered results

  • Keyboard navigation - Arrow keys, Enter, Escape

  • Focus management - Keep track of selected item

  • Click outside - Close dropdown

  • Custom attributes - Auto-focus

  • Template references - Access DOM elements

  • Conditional rendering - Loading states, empty states

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

The queryChanged callback uses setTimeout to debounce API calls. When the user types, previous timers are cleared, so only the final query triggers a search after the specified delay.

Keyboard Navigation

The component handles arrow keys to navigate results, Enter to select, and Escape to close. The selected index tracks which item is highlighted, and scrollIntoView ensures it's visible.

Click Outside

A global click listener detects clicks outside the component and closes the dropdown. The listener is added in attached() and cleaned up in detaching().

Highlighting Matches

The highlightMatch method uses regex to wrap matching text in <mark> tags. The result is bound with innerhtml.bind to render the HTML.

Accessibility

  • role="combobox" on input

  • role="listbox" on dropdown

  • role="option" on results

  • aria-expanded indicates dropdown state

  • aria-activedescendant points to selected item

  • Keyboard navigation follows ARIA practices

Variations

Recent Searches

Store and show recent searches when input is focused but empty:

Grouped Results

Group results by category:

Infinite Scroll

Load more results as user scrolls:

Last updated

Was this helpful?