diff --git a/src/cdk/a11y/key-manager/list-key-manager.ts b/src/cdk/a11y/key-manager/list-key-manager.ts index 49522b7d82ab..dfb908b7b87e 100644 --- a/src/cdk/a11y/key-manager/list-key-manager.ts +++ b/src/cdk/a11y/key-manager/list-key-manager.ts @@ -14,17 +14,13 @@ import { LEFT_ARROW, RIGHT_ARROW, TAB, - A, - Z, - ZERO, - NINE, hasModifierKey, HOME, END, PAGE_UP, PAGE_DOWN, } from '@angular/cdk/keycodes'; -import {debounceTime, filter, map, tap} from 'rxjs/operators'; +import {Typeahead} from './typeahead'; /** This interface is for items that can be passed to a ListKeyManager. */ export interface ListKeyManagerOption { @@ -46,7 +42,6 @@ export class ListKeyManager { private _activeItemIndex = -1; private _activeItem: T | null = null; private _wrap = false; - private readonly _letterKeyStream = new Subject(); private _typeaheadSubscription = Subscription.EMPTY; private _itemChangesSubscription?: Subscription; private _vertical = true; @@ -54,6 +49,7 @@ export class ListKeyManager { private _allowedModifierKeys: ListKeyManagerModifierKey[] = []; private _homeAndEnd = false; private _pageUpAndDown = {enabled: false, delta: 10}; + private _typeahead?: Typeahead; /** * Predicate function that can be used to check whether an item should be skipped @@ -61,9 +57,6 @@ export class ListKeyManager { */ private _skipPredicateFn = (item: T) => item.disabled; - // Buffer for the letters that the user has pressed when the typeahead option is turned on. - private _pressedLetters: string[] = []; - constructor(private _items: QueryList | T[]) { // We allow for the items to be an array because, in some cases, the consumer may // not have access to a QueryList of the items they want to manage (e.g. when the @@ -73,9 +66,11 @@ export class ListKeyManager { if (this._activeItem) { const itemArray = newItems.toArray(); const newIndex = itemArray.indexOf(this._activeItem); + this._typeahead?.setItems(itemArray); if (newIndex > -1 && newIndex !== this._activeItemIndex) { this._activeItemIndex = newIndex; + this._typeahead?.setCurrentSelectedItemIndex(newIndex); } } }); @@ -144,53 +139,24 @@ export class ListKeyManager { * @param debounceInterval Time to wait after the last keystroke before setting the active item. */ withTypeAhead(debounceInterval: number = 200): this { - if ( - (typeof ngDevMode === 'undefined' || ngDevMode) && - this._items.length && - this._items.some(item => typeof item.getLabel !== 'function') - ) { - throw Error('ListKeyManager items in typeahead mode must implement the `getLabel` method.'); - } - this._typeaheadSubscription.unsubscribe(); - // Debounce the presses of non-navigational keys, collect the ones that correspond to letters - // and convert those letters back into a string. Afterwards find the first item that starts - // with that string and select it. - this._typeaheadSubscription = this._letterKeyStream - .pipe( - tap(letter => this._pressedLetters.push(letter)), - debounceTime(debounceInterval), - filter(() => this._pressedLetters.length > 0), - map(() => this._pressedLetters.join('')), - ) - .subscribe(inputString => { - const items = this._getItemsArray(); - - // Start at 1 because we want to start searching at the item immediately - // following the current active item. - for (let i = 1; i < items.length + 1; i++) { - const index = (this._activeItemIndex + i) % items.length; - const item = items[index]; - - if ( - !this._skipPredicateFn(item) && - item.getLabel!().toUpperCase().trim().indexOf(inputString) === 0 - ) { - this.setActiveItem(index); - break; - } - } + const items = this._getItemsArray(); + this._typeahead = new Typeahead(items, { + debounceInterval: typeof debounceInterval === 'number' ? debounceInterval : undefined, + skipPredicate: item => this._skipPredicateFn(item), + }); - this._pressedLetters = []; - }); + this._typeaheadSubscription = this._typeahead.selectedItem.subscribe(item => { + this.setActiveItem(item); + }); return this; } /** Cancels the current typeahead sequence. */ cancelTypeahead(): this { - this._pressedLetters = []; + this._typeahead?.reset(); return this; } @@ -322,13 +288,7 @@ export class ListKeyManager { default: if (isModifierAllowed || hasModifierKey(event, 'shiftKey')) { - // Attempt to use the `event.key` which also maps it to the user's keyboard language, - // otherwise fall back to resolving alphanumeric characters via the keyCode. - if (event.key && event.key.length === 1) { - this._letterKeyStream.next(event.key.toLocaleUpperCase()); - } else if ((keyCode >= A && keyCode <= Z) || (keyCode >= ZERO && keyCode <= NINE)) { - this._letterKeyStream.next(String.fromCharCode(keyCode)); - } + this._typeahead?.handleKey(event); } // Note that we return here, in order to avoid preventing @@ -336,7 +296,7 @@ export class ListKeyManager { return; } - this._pressedLetters = []; + this._typeahead?.reset(); event.preventDefault(); } @@ -352,7 +312,7 @@ export class ListKeyManager { /** Gets whether the user is currently typing into the manager using the typeahead feature. */ isTyping(): boolean { - return this._pressedLetters.length > 0; + return !!this._typeahead && this._typeahead.isTyping(); } /** Sets the active item to the first enabled item in the list. */ @@ -397,16 +357,16 @@ export class ListKeyManager { // Explicitly check for `null` and `undefined` because other falsy values are valid. this._activeItem = activeItem == null ? null : activeItem; this._activeItemIndex = index; + this._typeahead?.setCurrentSelectedItemIndex(index); } /** Cleans up the key manager. */ destroy() { this._typeaheadSubscription.unsubscribe(); this._itemChangesSubscription?.unsubscribe(); - this._letterKeyStream.complete(); + this._typeahead?.destroy(); this.tabOut.complete(); this.change.complete(); - this._pressedLetters = []; } /** diff --git a/src/cdk/a11y/key-manager/tree-key-manager.spec.ts b/src/cdk/a11y/key-manager/tree-key-manager.spec.ts index fe9edccc829f..ae3604de8ba3 100644 --- a/src/cdk/a11y/key-manager/tree-key-manager.spec.ts +++ b/src/cdk/a11y/key-manager/tree-key-manager.spec.ts @@ -227,7 +227,7 @@ describe('TreeKeyManager', () => { expect(fakeKeyEvents.tab.defaultPrevented).toBe(false); }); - it('should not do anything for unsupported key presses', () => { + fit('should not do anything for unsupported key presses', () => { keyManager.focusItem(itemList.get(1)!); expect(keyManager.getActiveItemIndex()).toBe(1); diff --git a/src/cdk/a11y/key-manager/tree-key-manager.ts b/src/cdk/a11y/key-manager/tree-key-manager.ts index e719471255ac..fe07ce63585d 100644 --- a/src/cdk/a11y/key-manager/tree-key-manager.ts +++ b/src/cdk/a11y/key-manager/tree-key-manager.ts @@ -30,8 +30,7 @@ import { TreeKeyManagerOptions, TreeKeyManagerStrategy, } from './tree-key-manager-strategy'; - -const DEFAULT_TYPEAHEAD_DEBOUNCE_INTERVAL_MS = 200; +import {Typeahead} from './typeahead'; function coerceObservable(data: T | Observable): Observable { if (!isObservable(data)) { @@ -50,8 +49,6 @@ export class TreeKeyManager implements TreeKeyMana private _activeItem: T | null = null; private _activationFollowsFocus = false; private _horizontal: 'ltr' | 'rtl' = 'ltr'; - private readonly _letterKeyStream = new Subject(); - private _typeaheadSubscription = Subscription.EMPTY; /** * Predicate function that can be used to check whether an item should be skipped @@ -62,11 +59,11 @@ export class TreeKeyManager implements TreeKeyMana /** Function to determine equivalent items. */ private _trackByFn: (item: T) => unknown = (item: T) => item; - /** Buffer for the letters that the user has pressed when the typeahead option is turned on. */ - private _pressedLetters: string[] = []; - private _items: T[] = []; + private _typeahead?: Typeahead; + private _typeaheadSubscription = Subscription.EMPTY; + constructor(items: Observable | QueryList | T[], config: TreeKeyManagerOptions) { // We allow for the items to be an array or Observable because, in some cases, the consumer may // not have access to a QueryList of the items they want to manage (e.g. when the @@ -75,11 +72,13 @@ export class TreeKeyManager implements TreeKeyMana this._items = items.toArray(); items.changes.subscribe((newItems: QueryList) => { this._items = newItems.toArray(); + this._typeahead?.setItems(this._items); this._updateActiveItemIndex(this._items); }); } else if (isObservable(items)) { items.subscribe(newItems => { this._items = newItems; + this._typeahead?.setItems(newItems); this._updateActiveItemIndex(newItems); }); } else { @@ -99,12 +98,7 @@ export class TreeKeyManager implements TreeKeyMana this._trackByFn = config.trackBy; } if (typeof config.typeAheadDebounceInterval !== 'undefined') { - const typeAheadInterval = - typeof config.typeAheadDebounceInterval === 'number' - ? config.typeAheadDebounceInterval - : DEFAULT_TYPEAHEAD_DEBOUNCE_INTERVAL_MS; - - this._setTypeAhead(typeAheadInterval); + this._setTypeAhead(config.typeAheadDebounceInterval); } } @@ -160,21 +154,14 @@ export class TreeKeyManager implements TreeKeyMana break; } - // Attempt to use the `event.key` which also maps it to the user's keyboard language, - // otherwise fall back to resolving alphanumeric characters via the keyCode. - if (event.key && event.key.length === 1) { - this._letterKeyStream.next(event.key.toLocaleUpperCase()); - } else if ((keyCode >= A && keyCode <= Z) || (keyCode >= ZERO && keyCode <= NINE)) { - this._letterKeyStream.next(String.fromCharCode(keyCode)); - } - - // NB: return here, in order to avoid preventing the default action of non-navigational + this._typeahead?.handleKey(event); + // Return here, in order to avoid preventing the default action of non-navigational // keys or resetting the buffer of pressed letters. return; } // Reset the typeahead since the user has used a navigational key. - this._pressedLetters = []; + this._typeahead?.reset(); event.preventDefault(); } @@ -247,6 +234,7 @@ export class TreeKeyManager implements TreeKeyMana this._activeItem = activeItem ?? null; this._activeItemIndex = index; + this._typeahead?.setCurrentSelectedItemIndex(index); if (options.emitChangeEvent) { // Emit to `change` stream as required by TreeKeyManagerStrategy interface. @@ -270,50 +258,19 @@ export class TreeKeyManager implements TreeKeyMana if (newIndex > -1 && newIndex !== this._activeItemIndex) { this._activeItemIndex = newIndex; + this._typeahead?.setCurrentSelectedItemIndex(newIndex); } } - private _setTypeAhead(debounceInterval: number) { - this._typeaheadSubscription.unsubscribe(); - - if ( - (typeof ngDevMode === 'undefined' || ngDevMode) && - this._items.length && - this._items.some(item => typeof item.getLabel !== 'function') - ) { - throw new Error( - 'TreeKeyManager items in typeahead mode must implement the `getLabel` method.', - ); - } - - // Debounce the presses of non-navigational keys, collect the ones that correspond to letters - // and convert those letters back into a string. Afterwards find the first item that starts - // with that string and select it. - this._typeaheadSubscription = this._letterKeyStream - .pipe( - tap(letter => this._pressedLetters.push(letter)), - debounceTime(debounceInterval), - filter(() => this._pressedLetters.length > 0), - map(() => this._pressedLetters.join('').toLocaleUpperCase()), - ) - .subscribe(inputString => { - // Start at 1 because we want to start searching at the item immediately - // following the current active item. - for (let i = 1; i < this._items.length + 1; i++) { - const index = (this._activeItemIndex + i) % this._items.length; - const item = this._items[index]; - - if ( - !this._skipPredicateFn(item) && - item.getLabel?.().toLocaleUpperCase().trim().indexOf(inputString) === 0 - ) { - this.focusItem(index); - break; - } - } + private _setTypeAhead(debounceInterval: number | boolean) { + this._typeahead = new Typeahead(this._items, { + debounceInterval: typeof debounceInterval === 'number' ? debounceInterval : undefined, + skipPredicate: item => this._skipPredicateFn(item), + }); - this._pressedLetters = []; - }); + this._typeaheadSubscription = this._typeahead.selectedItem.subscribe(item => { + this.focusItem(item); + }); } private _findNextAvailableItemIndex(startingIndex: number) { @@ -364,10 +321,6 @@ export class TreeKeyManager implements TreeKeyMana if (!this._isCurrentItemExpanded()) { this._activeItem.expand(); } else { - const children = this._activeItem.getChildren(); - - const children2 = isObservable(children) ? children : observableOf(children); - coerceObservable(this._activeItem.getChildren()) .pipe(take(1)) .subscribe(children => { diff --git a/src/cdk/a11y/key-manager/typeahead.ts b/src/cdk/a11y/key-manager/typeahead.ts new file mode 100644 index 000000000000..ca921a94d547 --- /dev/null +++ b/src/cdk/a11y/key-manager/typeahead.ts @@ -0,0 +1,118 @@ +import {Subject, Subscription} from 'rxjs'; +import {A, Z, ZERO, NINE} from '@angular/cdk/keycodes'; +import {debounceTime, filter, map, take, tap} from 'rxjs/operators'; + +const DEFAULT_TYPEAHEAD_DEBOUNCE_INTERVAL_MS = 200; + +interface TypeaheadItem { + getLabel?(): string; +} + +interface TypeaheadConfig { + debounceInterval?: number; + skipPredicate?: (item: T) => boolean | undefined; +} + +export class Typeahead { + private readonly _letterKeyStream = new Subject(); + private _items: T[] = []; + private _selectedItemIndex = -1; + + /** Buffer for the letters that the user has pressed */ + private _pressedLetters: string[] = []; + + private _skipPredicateFn?: (item: T) => boolean | undefined; + + private readonly _selectedItem = new Subject(); + readonly selectedItem = this._selectedItem.asObservable(); + + constructor(initialItems: T[], config?: TypeaheadConfig) { + const typeAheadInterval = + typeof config?.debounceInterval === 'number' + ? config.debounceInterval + : DEFAULT_TYPEAHEAD_DEBOUNCE_INTERVAL_MS; + + if (config?.skipPredicate) { + this._skipPredicateFn = config.skipPredicate; + } + + this.setItems(initialItems); + this._setupKeyHandler(typeAheadInterval); + } + + destroy() { + this._pressedLetters = []; + this._letterKeyStream.complete(); + this._selectedItem.complete(); + } + + setCurrentSelectedItemIndex(index: number) { + this._selectedItemIndex = index; + } + + setItems(items: T[]) { + if ( + (typeof ngDevMode === 'undefined' || ngDevMode) && + items.length && + items.some(item => typeof item.getLabel !== 'function') + ) { + throw new Error('KeyManager items in typeahead mode must implement the `getLabel` method.'); + } + + this._items = items; + } + + handleKey(event: KeyboardEvent): void { + const keyCode = event.keyCode; + + // Attempt to use the `event.key` which also maps it to the user's keyboard language, + // otherwise fall back to resolving alphanumeric characters via the keyCode. + if (event.key && event.key.length === 1) { + this._letterKeyStream.next(event.key.toLocaleUpperCase()); + } else if ((keyCode >= A && keyCode <= Z) || (keyCode >= ZERO && keyCode <= NINE)) { + this._letterKeyStream.next(String.fromCharCode(keyCode)); + } + } + + /** Gets whether the user is currently typing into the manager using the typeahead feature. */ + isTyping(): boolean { + return this._pressedLetters.length > 0; + } + + // TODO: find a better name? + reset(): void { + this._pressedLetters = []; + } + + private _setupKeyHandler(typeAheadInterval: number) { + // TODO: handle unsubscription + // Debounce the presses of non-navigational keys, collect the ones that correspond to letters + // and convert those letters back into a string. Afterwards find the first item that starts + // with that string and select it. + this._letterKeyStream + .pipe( + tap(letter => this._pressedLetters.push(letter)), + debounceTime(typeAheadInterval), + filter(() => this._pressedLetters.length > 0), + map(() => this._pressedLetters.join('').toLocaleUpperCase()), + ) + .subscribe(inputString => { + // Start at 1 because we want to start searching at the item immediately + // following the current active item. + for (let i = 1; i < this._items.length + 1; i++) { + const index = (this._selectedItemIndex + i) % this._items.length; + const item = this._items[index]; + + if ( + !this._skipPredicateFn?.(item) && + item.getLabel?.().toLocaleUpperCase().trim().indexOf(inputString) === 0 + ) { + this._selectedItem.next(item); + break; + } + } + + this._pressedLetters = []; + }); + } +}