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 5d9cb4803330..cceea2b40dec 100644 --- a/src/cdk/a11y/key-manager/tree-key-manager.spec.ts +++ b/src/cdk/a11y/key-manager/tree-key-manager.spec.ts @@ -15,6 +15,7 @@ import {QueryList} from '@angular/core'; import {take} from 'rxjs/operators'; import {TreeKeyManager, TreeKeyManagerItem} from './tree-key-manager'; import {Observable, of as observableOf, Subscription} from 'rxjs'; +import {fakeAsync, tick} from '@angular/core/testing'; class FakeBaseTreeKeyManagerItem { _isExpanded = false; @@ -115,14 +116,19 @@ describe('TreeKeyManager', () => { FakeArrayTreeKeyManagerItem | FakeObservableTreeKeyManagerItem >; + let parentItem: FakeArrayTreeKeyManagerItem | FakeObservableTreeKeyManagerItem; // index 0 + let childItem: FakeArrayTreeKeyManagerItem | FakeObservableTreeKeyManagerItem; // index 1 + let childItemWithNoChildren: FakeArrayTreeKeyManagerItem | FakeObservableTreeKeyManagerItem; // index 3 + let lastItem: FakeArrayTreeKeyManagerItem | FakeObservableTreeKeyManagerItem; // index 5 + beforeEach(() => { itemList = new QueryList(); - const parent1 = new itemParam.constructor('parent1'); - const parent1Child1 = new itemParam.constructor('parent1Child1'); - const parent1Child1Child1 = new itemParam.constructor('parent1Child1Child1'); - const parent1Child2 = new itemParam.constructor('parent1Child2'); - const parent2 = new itemParam.constructor('parent2'); - const parent2Child1 = new itemParam.constructor('parent2Child1'); + const parent1 = new itemParam.constructor('one'); + const parent1Child1 = new itemParam.constructor('two'); + const parent1Child1Child1 = new itemParam.constructor('three'); + const parent1Child2 = new itemParam.constructor('four'); + const parent2 = new itemParam.constructor('five'); + const parent2Child1 = new itemParam.constructor('six'); parent1._children = [parent1Child1, parent1Child2]; parent1Child1._parent = parent1; @@ -132,6 +138,11 @@ describe('TreeKeyManager', () => { parent2._children = [parent2Child1]; parent2Child1._parent = parent2; + parentItem = parent1; + childItem = parent1Child1; + childItemWithNoChildren = parent1Child2; + lastItem = parent2Child1; + itemList.reset([ parent1, parent1Child1, @@ -155,16 +166,12 @@ describe('TreeKeyManager', () => { keyManager.onClick(itemList.get(0)!); expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(0); - expect(keyManager.getActiveItem()?.getLabel()) - .withContext('active item label') - .toBe('parent1'); + expect(keyManager.getActiveItem()?.getLabel()).withContext('active item label').toBe('one'); itemList.reset([new FakeObservableTreeKeyManagerItem('parent0'), ...itemList.toArray()]); itemList.notifyOnChanges(); expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(1); - expect(keyManager.getActiveItem()?.getLabel()) - .withContext('active item label') - .toBe('parent1'); + expect(keyManager.getActiveItem()?.getLabel()).withContext('active item label').toBe('one'); }); describe('Key events', () => { @@ -728,6 +735,217 @@ describe('TreeKeyManager', () => { }); } }); + + describe('typeahead mode', () => { + const debounceInterval = 300; + + beforeEach(() => { + keyManager = new TreeKeyManager({ + items: itemList, + typeAheadDebounceInterval: debounceInterval, + }); + }); + + it('should throw if the items do not implement the getLabel method', () => { + const invalidQueryList = new QueryList(); + invalidQueryList.reset([{disabled: false}]); + + expect( + () => + new TreeKeyManager({ + items: invalidQueryList, + typeAheadDebounceInterval: true, + }), + ).toThrowError(/must implement/); + }); + + it('should debounce the input key presses', fakeAsync(() => { + keyManager.onKeydown(createKeyboardEvent('keydown', 79, 'o')); // types "o" + tick(1); + keyManager.onKeydown(createKeyboardEvent('keydown', 78, 'n')); // types "n" + tick(1); + keyManager.onKeydown(createKeyboardEvent('keydown', 69, 'e')); // types "e" + + expect(keyManager.getActiveItemIndex()) + .withContext('active item index, before debounce interval') + .not.toBe(0); + + tick(debounceInterval - 1); + + expect(keyManager.getActiveItemIndex()) + .withContext('active item index, after partial debounce interval') + .not.toBe(0); + + tick(1); + + expect(keyManager.getActiveItemIndex()) + .withContext('active item index, after full debounce interval') + .toBe(0); + })); + + it('uses a default debounce interval', fakeAsync(() => { + const defaultInterval = 200; + keyManager = new TreeKeyManager({ + items: itemList, + typeAheadDebounceInterval: true, + }); + + keyManager.onKeydown(createKeyboardEvent('keydown', 79, 'o')); // types "o" + tick(1); + keyManager.onKeydown(createKeyboardEvent('keydown', 78, 'n')); // types "n" + tick(1); + keyManager.onKeydown(createKeyboardEvent('keydown', 69, 'e')); // types "e" + + expect(keyManager.getActiveItemIndex()) + .withContext('active item index, before debounce interval') + .not.toBe(0); + + tick(defaultInterval - 1); + + expect(keyManager.getActiveItemIndex()) + .withContext('active item index, after partial debounce interval') + .not.toBe(0); + + tick(1); + + expect(keyManager.getActiveItemIndex()) + .withContext('active item index, after full debounce interval') + .toBe(0); + })); + + it('should focus the first item that starts with a letter', fakeAsync(() => { + keyManager.onKeydown(createKeyboardEvent('keydown', 84, 't')); // types "t" + + tick(debounceInterval); + + expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(1); + })); + + it('should focus the first item that starts with sequence of letters', fakeAsync(() => { + keyManager.onKeydown(createKeyboardEvent('keydown', 84, 't')); // types "t" + keyManager.onKeydown(createKeyboardEvent('keydown', 72, 'h')); // types "h" + + tick(debounceInterval); + + expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(2); + })); + + it('should cancel any pending timers if a navigation key is pressed', fakeAsync(() => { + keyManager.onKeydown(createKeyboardEvent('keydown', 84, 't')); // types "t" + keyManager.onKeydown(createKeyboardEvent('keydown', 72, 'h')); // types "h" + keyManager.onKeydown(fakeKeyEvents.downArrow); + + tick(debounceInterval); + + expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(0); + })); + + it('should handle non-English input', fakeAsync(() => { + itemList.reset([ + new itemParam.constructor('едно'), + new itemParam.constructor('две'), + new itemParam.constructor('три'), + ]); + itemList.notifyOnChanges(); + + const keyboardEvent = createKeyboardEvent('keydown', 68, 'д'); + + keyManager.onKeydown(keyboardEvent); // types "д" + tick(debounceInterval); + + expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(1); + })); + + it('should handle non-letter characters', fakeAsync(() => { + itemList.reset([ + new itemParam.constructor('[]'), + new itemParam.constructor('321'), + new itemParam.constructor('`!?'), + ]); + itemList.notifyOnChanges(); + + keyManager.onKeydown(createKeyboardEvent('keydown', 192, '`')); // types "`" + tick(debounceInterval); + expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(2); + + keyManager.onKeydown(createKeyboardEvent('keydown', 51, '3')); // types "3" + tick(debounceInterval); + expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(1); + + keyManager.onKeydown(createKeyboardEvent('keydown', 219, '[')); // types "[" + tick(debounceInterval); + expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(0); + })); + + it('should not focus disabled items', fakeAsync(() => { + expect(keyManager.getActiveItemIndex()).withContext('initial active item index').toBe(-1); + + parentItem.isDisabled = true; + + keyManager.onKeydown(createKeyboardEvent('keydown', 79, 'o')); // types "o" + tick(debounceInterval); + + expect(keyManager.getActiveItemIndex()).withContext('initial active item index').toBe(-1); + })); + + it('should start looking for matches after the active item', fakeAsync(() => { + const frodo = new itemParam.constructor('Frodo'); + itemList.reset([ + new itemParam.constructor('Bilbo'), + frodo, + new itemParam.constructor('Pippin'), + new itemParam.constructor('Boromir'), + new itemParam.constructor('Aragorn'), + ]); + itemList.notifyOnChanges(); + + keyManager.onClick(frodo); + keyManager.onKeydown(createKeyboardEvent('keydown', 66, 'b')); + tick(debounceInterval); + + expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(3); + })); + + it('should wrap back around if there were no matches after the active item', fakeAsync(() => { + const boromir = new itemParam.constructor('Boromir'); + itemList.reset([ + new itemParam.constructor('Bilbo'), + new itemParam.constructor('Frodo'), + new itemParam.constructor('Pippin'), + boromir, + new itemParam.constructor('Aragorn'), + ]); + itemList.notifyOnChanges(); + + keyManager.onClick(boromir); + keyManager.onKeydown(createKeyboardEvent('keydown', 66, 'b')); + tick(debounceInterval); + + expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(0); + })); + + it('should wrap back around if the last item is active', fakeAsync(() => { + keyManager.onClick(lastItem); + keyManager.onKeydown(createKeyboardEvent('keydown', 79, 'o')); + tick(debounceInterval); + + expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(0); + })); + + it('should be able to select the first item', fakeAsync(() => { + keyManager.onKeydown(createKeyboardEvent('keydown', 79, 'o')); + tick(debounceInterval); + + expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(0); + })); + + it('should not do anything if there is no match', fakeAsync(() => { + keyManager.onKeydown(createKeyboardEvent('keydown', 87, 'w')); + tick(debounceInterval); + + expect(keyManager.getActiveItemIndex()).withContext('active item index').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 3d39eaabdc24..e998ece5d102 100644 --- a/src/cdk/a11y/key-manager/tree-key-manager.ts +++ b/src/cdk/a11y/key-manager/tree-key-manager.ts @@ -16,10 +16,16 @@ import { SPACE, TAB, UP_ARROW, + A, + Z, + ZERO, + NINE, } from '@angular/cdk/keycodes'; import {QueryList} from '@angular/core'; -import {isObservable, of as observableOf, Observable, Subject} from 'rxjs'; -import {take} from 'rxjs/operators'; +import {of as observableOf, isObservable, Observable, Subject, Subscription} from 'rxjs'; +import {debounceTime, filter, map, take, tap} from 'rxjs/operators'; + +const DEFAULT_TYPEAHEAD_DEBOUNCE_INTERVAL_MS = 200; function coerceObservable(data: T | Observable): Observable { if (!isObservable(data)) { @@ -111,6 +117,8 @@ export class TreeKeyManager { 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 @@ -121,6 +129,9 @@ export class TreeKeyManager { /** 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[] = []; constructor({ @@ -131,19 +142,6 @@ export class TreeKeyManager { activationFollowsFocus, typeAheadDebounceInterval, }: TreeKeyManagerOptions) { - if (typeof skipPredicate !== 'undefined') { - this._skipPredicateFn = skipPredicate; - } - if (typeof trackBy !== 'undefined') { - this._trackByFn = trackBy; - } - if (typeof horizontalOrientation !== 'undefined') { - this._horizontal = horizontalOrientation; - } - if (typeof activationFollowsFocus !== 'undefined') { - this._activationFollowsFocus = activationFollowsFocus; - } - // 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 // items aren't being collected via `ViewChildren` or `ContentChildren`). @@ -161,6 +159,26 @@ export class TreeKeyManager { } else { this._items = items; } + + if (typeof skipPredicate !== 'undefined') { + this._skipPredicateFn = skipPredicate; + } + if (typeof trackBy !== 'undefined') { + this._trackByFn = trackBy; + } + if (typeof horizontalOrientation !== 'undefined') { + this._horizontal = horizontalOrientation; + } + if (typeof activationFollowsFocus !== 'undefined') { + this._activationFollowsFocus = activationFollowsFocus; + } + if (typeof typeAheadDebounceInterval !== 'undefined') { + this._setTypeAhead( + typeof typeAheadDebounceInterval === 'number' + ? typeAheadDebounceInterval + : DEFAULT_TYPEAHEAD_DEBOUNCE_INTERVAL_MS, + ); + } } /** @@ -222,11 +240,21 @@ export class TreeKeyManager { 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 // keys or resetting the buffer of pressed letters. return; } + // Reset the typeahead since the user has used a navigational key. + this._pressedLetters = []; event.preventDefault(); } @@ -288,6 +316,49 @@ export class TreeKeyManager { } } + 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._setActiveItem(index); + break; + } + } + + this._pressedLetters = []; + }); + } + //// Navigational methods private _focusFirstItem() { diff --git a/src/cdk/a11y/public-api.ts b/src/cdk/a11y/public-api.ts index af4e24404387..ea64d62578b7 100644 --- a/src/cdk/a11y/public-api.ts +++ b/src/cdk/a11y/public-api.ts @@ -10,6 +10,7 @@ export * from './aria-describer/aria-reference'; export * from './key-manager/activedescendant-key-manager'; export * from './key-manager/focus-key-manager'; export * from './key-manager/list-key-manager'; +export * from './key-manager/tree-key-manager'; export * from './focus-trap/configurable-focus-trap'; export * from './focus-trap/configurable-focus-trap-config'; export * from './focus-trap/configurable-focus-trap-factory'; diff --git a/tools/public_api_guard/cdk/a11y.md b/tools/public_api_guard/cdk/a11y.md index a2b136168ad7..95ea88805610 100644 --- a/tools/public_api_guard/cdk/a11y.md +++ b/tools/public_api_guard/cdk/a11y.md @@ -424,6 +424,41 @@ export interface RegisteredMessage { // @public export function removeAriaReferencedId(el: Element, attr: `aria-${string}`, id: string): void; +// @public +export class TreeKeyManager { + constructor({ items, skipPredicate, trackBy, horizontalOrientation, activationFollowsFocus, typeAheadDebounceInterval, }: TreeKeyManagerOptions); + readonly change: Subject; + getActiveItem(): T | null; + getActiveItemIndex(): number | null; + onClick(treeItem: T): void; + onKeydown(event: KeyboardEvent): void; + readonly tabOut: Subject; +} + +// @public +export interface TreeKeyManagerItem { + activate(): void; + collapse(): void; + expand(): void; + focus(): void; + getChildren(): TreeKeyManagerItem[] | Observable; + getLabel?(): string; + getParent(): TreeKeyManagerItem | null; + isDisabled?: (() => boolean) | boolean; + isExpanded: (() => boolean) | boolean; +} + +// @public (undocumented) +export interface TreeKeyManagerOptions { + activationFollowsFocus?: boolean; + horizontalOrientation?: 'rtl' | 'ltr'; + // (undocumented) + items: Observable | QueryList | T[]; + skipPredicate?: (item: T) => boolean; + trackBy?: (treeItem: T) => unknown; + typeAheadDebounceInterval?: true | number; +} + // (No @packageDocumentation comment for this package) ```