UNPKG

@material/web

Version:
259 lines 8.82 kB
/** * @license * Copyright 2023 Google LLC * SPDX-License-Identifier: Apache-2.0 */ /** * Indicies to access the TypeaheadRecord tuple type. */ export const TYPEAHEAD_RECORD = { INDEX: 0, ITEM: 1, TEXT: 2, }; /** * 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` * <div * @keydown=${typeaheadController.onKeydown} * tabindex="0" * class="activeItemText"> * <!-- focusable element that will receive keydown events --> * Apple * </div> * <div> * <md-menu-item active header="Apple"></md-menu-item> * <md-menu-item header="Apricot"></md-menu-item> * <md-menu-item header="Banana"></md-menu-item> * <md-menu-item header="Olive"></md-menu-item> * <md-menu-item header="Orange"></md-menu-item> * </div> * `; * ``` */ export class TypeaheadController { /** * @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(getProperties) { this.getProperties = getProperties; /** * Array of tuples that helps with indexing. */ this.typeaheadRecords = []; /** * Currently-typed text since last buffer timeout */ this.typaheadBuffer = ''; /** * The timeout id from the current buffer's setTimeout */ this.cancelTypeaheadTimeout = 0; /** * If we are currently "typing" */ this.isTypingAhead = false; /** * The record of the last active item. */ this.lastActiveRecord = null; /** * 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. */ this.onKeydown = (event) => { if (this.isTypingAhead) { this.typeahead(event); } else { this.beginTypeahead(event); } }; /** * Ends the current typeahead and clears the buffer. */ this.endTypeahead = () => { this.isTypingAhead = false; this.typaheadBuffer = ''; this.typeaheadRecords = []; }; } get items() { return this.getProperties().getItems(); } get active() { return this.getProperties().active; } /** * Sets up typingahead */ beginTypeahead(event) { 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 */ typeahead(event) { 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, <reference>, 'apple'] * 1: [1, <reference>, 'apricot'] * 2: [2, <reference>, 'banana'] * 3: [3, <reference>, 'olive'] <-- lastActiveIndex * 4: [4, <reference>, 'orange'] * 5: [5, <reference>, 'strawberry'] * * this.typeaheadRecords.sort((a,b) => rebaseIndexOnActive(a) * - rebaseIndexOnActive(b)) === * 0: [3, <reference>, 'olive'] <-- lastActiveIndex * 1: [4, <reference>, 'orange'] * 2: [5, <reference>, 'strawberry'] * 3: [0, <reference>, 'apple'] * 4: [1, <reference>, 'apricot'] * 5: [2, <reference>, 'banana'] */ const rebaseIndexOnActive = (record) => { 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; // 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; } } //# sourceMappingURL=typeaheadController.js.map