/**
* @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