/** * @license * Copyright 2023 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import {MenuItem} from './menuItemController.js'; /** * The options that are passed to the typeahead controller. */ export interface TypeaheadControllerProperties { /** * A function that returns an array of menu items to be searched. * @return An array of menu items to be searched by typing. */ getItems: () => MenuItem[]; /** * The maximum time between each keystroke to keep the current type buffer * alive. */ typeaheadBufferTime: number; /** * Whether or not the typeahead should listen for keystrokes or not. */ active: boolean; } /** * Data structure tuple that helps with indexing. * * [index, item, normalized header text] */ type TypeaheadRecord = [number, MenuItem, string]; /** * Indicies to access the TypeaheadRecord tuple type. */ export const TYPEAHEAD_RECORD = { INDEX: 0, ITEM: 1, TEXT: 2, } as const; /** * This controller listens to `keydown` events and searches the header text of * an array of `MenuItem`s with the corresponding entered keys within the buffer * time and activates the item. * * @example * ```ts * const typeaheadController = new TypeaheadController(() => ({ * typeaheadBufferTime: 50, * getItems: () => Array.from(document.querySelectorAll('md-menu-item')) * })); * html` *
* * Apple *
*
* * * * * *
* `; * ``` */ export class TypeaheadController { /** * Array of tuples that helps with indexing. */ private typeaheadRecords: TypeaheadRecord[] = []; /** * Currently-typed text since last buffer timeout */ private typaheadBuffer = ''; /** * The timeout id from the current buffer's setTimeout */ private cancelTypeaheadTimeout = 0; /** * If we are currently "typing" */ isTypingAhead = false; /** * The record of the last active item. */ lastActiveRecord: TypeaheadRecord | null = null; /** * @param getProperties A function that returns the options of the typeahead * controller: * * { * getItems: A function that returns an array of menu items to be searched. * typeaheadBufferTime: The maximum time between each keystroke to keep the * current type buffer alive. * } */ constructor( private readonly getProperties: () => TypeaheadControllerProperties, ) {} private get items() { return this.getProperties().getItems(); } private get active() { return this.getProperties().active; } /** * Apply this listener to the element that will receive `keydown` events that * should trigger this controller. * * @param event The native browser `KeyboardEvent` from the `keydown` event. */ readonly onKeydown = (event: KeyboardEvent) => { if (this.isTypingAhead) { this.typeahead(event); } else { this.beginTypeahead(event); } }; /** * Sets up typingahead */ private beginTypeahead(event: KeyboardEvent) { if (!this.active) { return; } // We don't want to typeahead if the _beginning_ of the typeahead is a menu // navigation, or a selection. We will handle "Space" only if it's in the // middle of a typeahead if ( event.code === 'Space' || event.code === 'Enter' || event.code.startsWith('Arrow') || event.code === 'Escape' ) { return; } this.isTypingAhead = true; // Generates the record array data structure which is the index, the element // and a normalized header. this.typeaheadRecords = this.items.map((el, index) => [ index, el, el.typeaheadText.trim().toLowerCase(), ]); this.lastActiveRecord = this.typeaheadRecords.find( (record) => record[TYPEAHEAD_RECORD.ITEM].tabIndex === 0, ) ?? null; if (this.lastActiveRecord) { this.lastActiveRecord[TYPEAHEAD_RECORD.ITEM].tabIndex = -1; } this.typeahead(event); } /** * Performs the typeahead. Based on the normalized items and the current text * buffer, finds the _next_ item with matching text and activates it. * * @example * * items: Apple, Banana, Olive, Orange, Cucumber * buffer: '' * user types: o * * activates Olive * * @example * * items: Apple, Banana, Olive (active), Orange, Cucumber * buffer: 'o' * user types: l * * activates Olive * * @example * * items: Apple, Banana, Olive (active), Orange, Cucumber * buffer: '' * user types: o * * activates Orange * * @example * * items: Apple, Banana, Olive, Orange (active), Cucumber * buffer: '' * user types: o * * activates Olive */ private typeahead(event: KeyboardEvent) { if (event.defaultPrevented) return; clearTimeout(this.cancelTypeaheadTimeout); // Stop typingahead if one of the navigation or selection keys (except for // Space) are pressed if ( event.code === 'Enter' || event.code.startsWith('Arrow') || event.code === 'Escape' ) { this.endTypeahead(); if (this.lastActiveRecord) { this.lastActiveRecord[TYPEAHEAD_RECORD.ITEM].tabIndex = -1; } return; } // If Space is pressed, prevent it from selecting and closing the menu if (event.code === 'Space') { event.preventDefault(); } // Start up a new keystroke buffer timeout this.cancelTypeaheadTimeout = setTimeout( this.endTypeahead, this.getProperties().typeaheadBufferTime, ); this.typaheadBuffer += event.key.toLowerCase(); const lastActiveIndex = this.lastActiveRecord ? this.lastActiveRecord[TYPEAHEAD_RECORD.INDEX] : -1; const numRecords = this.typeaheadRecords.length; /** * Sorting function that will resort the items starting with the given index * * @example * * this.typeaheadRecords = * 0: [0, , 'apple'] * 1: [1, , 'apricot'] * 2: [2, , 'banana'] * 3: [3, , 'olive'] <-- lastActiveIndex * 4: [4, , 'orange'] * 5: [5, , 'strawberry'] * * this.typeaheadRecords.sort((a,b) => rebaseIndexOnActive(a) * - rebaseIndexOnActive(b)) === * 0: [3, , 'olive'] <-- lastActiveIndex * 1: [4, , 'orange'] * 2: [5, , 'strawberry'] * 3: [0, , 'apple'] * 4: [1, , 'apricot'] * 5: [2, , 'banana'] */ const rebaseIndexOnActive = (record: TypeaheadRecord) => { return ( (record[TYPEAHEAD_RECORD.INDEX] + numRecords - lastActiveIndex) % numRecords ); }; // records filtered and sorted / rebased around the last active index const matchingRecords = this.typeaheadRecords .filter( (record) => !record[TYPEAHEAD_RECORD.ITEM].disabled && record[TYPEAHEAD_RECORD.TEXT].startsWith(this.typaheadBuffer), ) .sort((a, b) => rebaseIndexOnActive(a) - rebaseIndexOnActive(b)); // Just leave if there's nothing that matches. Native select will just // choose the first thing that starts with the next letter in the alphabet // but that's out of scope and hard to localize if (matchingRecords.length === 0) { clearTimeout(this.cancelTypeaheadTimeout); if (this.lastActiveRecord) { this.lastActiveRecord[TYPEAHEAD_RECORD.ITEM].tabIndex = -1; } this.endTypeahead(); return; } const isNewQuery = this.typaheadBuffer.length === 1; let nextRecord: TypeaheadRecord; // This is likely the case that someone is trying to "tab" through different // entries that start with the same letter if (this.lastActiveRecord === matchingRecords[0] && isNewQuery) { nextRecord = matchingRecords[1] ?? matchingRecords[0]; } else { nextRecord = matchingRecords[0]; } if (this.lastActiveRecord) { this.lastActiveRecord[TYPEAHEAD_RECORD.ITEM].tabIndex = -1; } this.lastActiveRecord = nextRecord; nextRecord[TYPEAHEAD_RECORD.ITEM].tabIndex = 0; nextRecord[TYPEAHEAD_RECORD.ITEM].focus(); return; } /** * Ends the current typeahead and clears the buffer. */ private readonly endTypeahead = () => { this.isTypingAhead = false; this.typaheadBuffer = ''; this.typeaheadRecords = []; }; }