From 5ebca5ee812fb604faad245542a3babd5968d60e Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Tue, 8 Aug 2017 22:21:03 +0200 Subject: [PATCH] feat(select): allow focusing items by typing (#2907) * feat(select): allow focusing items by typing Allows for users to skip to a select item by typing, similar to the native select. Fixes #2668. * refactor: address feedback * chore: linting errors --- src/cdk/a11y/activedescendant-key-manager.ts | 4 +- src/cdk/a11y/focus-key-manager.ts | 17 +-- src/cdk/a11y/list-key-manager.spec.ts | 129 +++++++++++++----- src/cdk/a11y/list-key-manager.ts | 85 ++++++++---- src/cdk/keyboard/keycodes.ts | 2 + src/lib/chips/chip.ts | 5 +- .../core/a11y/activedescendant-key-manager.ts | 4 +- src/lib/core/a11y/focus-key-manager.ts | 17 +-- src/lib/core/a11y/list-key-manager.ts | 4 +- src/lib/core/keyboard/keycodes.ts | 4 +- src/lib/core/option/option.ts | 7 +- src/lib/menu/menu-item.ts | 6 +- src/lib/select/select.ts | 2 +- 13 files changed, 191 insertions(+), 95 deletions(-) diff --git a/src/cdk/a11y/activedescendant-key-manager.ts b/src/cdk/a11y/activedescendant-key-manager.ts index ec5d19235cfd..07e60243ef1e 100644 --- a/src/cdk/a11y/activedescendant-key-manager.ts +++ b/src/cdk/a11y/activedescendant-key-manager.ts @@ -6,14 +6,14 @@ * found in the LICENSE file at https://angular.io/license */ -import {ListKeyManager, CanDisable} from './list-key-manager'; +import {ListKeyManager, ListKeyManagerOption} from './list-key-manager'; /** * This is the interface for highlightable items (used by the ActiveDescendantKeyManager). * Each item must know how to style itself as active or inactive and whether or not it is * currently disabled. */ -export interface Highlightable extends CanDisable { +export interface Highlightable extends ListKeyManagerOption { setActiveStyles(): void; setInactiveStyles(): void; } diff --git a/src/cdk/a11y/focus-key-manager.ts b/src/cdk/a11y/focus-key-manager.ts index 8473b84560ea..b1b2256991b3 100644 --- a/src/cdk/a11y/focus-key-manager.ts +++ b/src/cdk/a11y/focus-key-manager.ts @@ -6,24 +6,18 @@ * found in the LICENSE file at https://angular.io/license */ -import {QueryList} from '@angular/core'; -import {ListKeyManager, CanDisable} from './list-key-manager'; +import {ListKeyManager, ListKeyManagerOption} from './list-key-manager'; /** * This is the interface for focusable items (used by the FocusKeyManager). - * Each item must know how to focus itself and whether or not it is currently disabled. + * Each item must know how to focus itself, whether or not it is currently disabled + * and be able to supply it's label. */ -export interface Focusable extends CanDisable { +export interface FocusableOption extends ListKeyManagerOption { focus(): void; } - -export class FocusKeyManager extends ListKeyManager { - - constructor(items: QueryList) { - super(items); - } - +export class FocusKeyManager extends ListKeyManager { /** * This method sets the active item to the item at the specified index. * It also adds focuses the newly active item. @@ -35,5 +29,4 @@ export class FocusKeyManager extends ListKeyManager { this.activeItem.focus(); } } - } diff --git a/src/cdk/a11y/list-key-manager.spec.ts b/src/cdk/a11y/list-key-manager.spec.ts index 29e887a02b21..b6d444f7156b 100644 --- a/src/cdk/a11y/list-key-manager.spec.ts +++ b/src/cdk/a11y/list-key-manager.spec.ts @@ -9,8 +9,10 @@ import {first} from '../rxjs/index'; class FakeFocusable { + constructor(private _label = '') { } disabled = false; focus() {} + getLabel() { return this._label; } } class FakeHighlightable { @@ -20,11 +22,11 @@ class FakeHighlightable { } class FakeQueryList extends QueryList { - get length() { return this.items.length; } items: T[]; - toArray() { - return this.items; - } + get length() { return this.items.length; } + get first() { return this.items[0]; } + toArray() { return this.items; } + some() { return this.items.some.apply(this.items, arguments); } } @@ -43,7 +45,7 @@ describe('Key managers', () => { downArrow: createKeyboardEvent('keydown', DOWN_ARROW), upArrow: createKeyboardEvent('keydown', UP_ARROW), tab: createKeyboardEvent('keydown', TAB), - unsupported: createKeyboardEvent('keydown', 65) // corresponds to the letter "a" + unsupported: createKeyboardEvent('keydown', 192) // corresponds to the tilde character (~) }; }); @@ -52,7 +54,11 @@ describe('Key managers', () => { let keyManager: ListKeyManager; beforeEach(() => { - itemList.items = [new FakeFocusable(), new FakeFocusable(), new FakeFocusable()]; + itemList.items = [ + new FakeFocusable('one'), + new FakeFocusable('two'), + new FakeFocusable('three') + ]; keyManager = new ListKeyManager(itemList); // first item is already focused @@ -383,6 +389,65 @@ describe('Key managers', () => { }); + describe('typeahead mode', () => { + const debounceInterval = 300; + + beforeEach(() => { + keyManager.withTypeAhead(debounceInterval); + keyManager.setActiveItem(-1); + }); + + it('should throw if the items do not implement the getLabel method', () => { + const invalidQueryList = new FakeQueryList(); + + invalidQueryList.items = [{ disabled: false }]; + + const invalidManager = new ListKeyManager(invalidQueryList); + + expect(() => invalidManager.withTypeAhead()).toThrowError(/must implement/); + }); + + it('should debounce the input key presses', fakeAsync(() => { + keyManager.onKeydown(createKeyboardEvent('keydown', 79)); // types "o" + keyManager.onKeydown(createKeyboardEvent('keydown', 78)); // types "n" + keyManager.onKeydown(createKeyboardEvent('keydown', 69)); // types "e" + + expect(keyManager.activeItem).not.toBe(itemList.items[0]); + + tick(debounceInterval); + + expect(keyManager.activeItem).toBe(itemList.items[0]); + })); + + it('should focus the first item that starts with a letter', fakeAsync(() => { + keyManager.onKeydown(createKeyboardEvent('keydown', 84)); // types "t" + + tick(debounceInterval); + + expect(keyManager.activeItem).toBe(itemList.items[1]); + })); + + it('should focus the first item that starts with sequence of letters', fakeAsync(() => { + keyManager.onKeydown(createKeyboardEvent('keydown', 84)); // types "t" + keyManager.onKeydown(createKeyboardEvent('keydown', 72)); // types "h" + + tick(debounceInterval); + + expect(keyManager.activeItem).toBe(itemList.items[2]); + })); + + it('should cancel any pending timers if a navigation key is pressed', fakeAsync(() => { + keyManager.onKeydown(createKeyboardEvent('keydown', 84)); // types "t" + keyManager.onKeydown(createKeyboardEvent('keydown', 72)); // types "h" + keyManager.onKeydown(fakeKeyEvents.downArrow); + + tick(debounceInterval); + + expect(keyManager.activeItem).toBe(itemList.items[0]); + })); + + }); + }); describe('FocusKeyManager', () => { @@ -400,40 +465,40 @@ describe('Key managers', () => { spyOn(itemList.items[2], 'focus'); }); - it('should focus subsequent items when down arrow is pressed', () => { - keyManager.onKeydown(fakeKeyEvents.downArrow); + it('should focus subsequent items when down arrow is pressed', () => { + keyManager.onKeydown(fakeKeyEvents.downArrow); - expect(itemList.items[0].focus).not.toHaveBeenCalled(); - expect(itemList.items[1].focus).toHaveBeenCalledTimes(1); - expect(itemList.items[2].focus).not.toHaveBeenCalled(); + expect(itemList.items[0].focus).not.toHaveBeenCalled(); + expect(itemList.items[1].focus).toHaveBeenCalledTimes(1); + expect(itemList.items[2].focus).not.toHaveBeenCalled(); - keyManager.onKeydown(fakeKeyEvents.downArrow); - expect(itemList.items[0].focus).not.toHaveBeenCalled(); - expect(itemList.items[1].focus).toHaveBeenCalledTimes(1); - expect(itemList.items[2].focus).toHaveBeenCalledTimes(1); - }); + keyManager.onKeydown(fakeKeyEvents.downArrow); + expect(itemList.items[0].focus).not.toHaveBeenCalled(); + expect(itemList.items[1].focus).toHaveBeenCalledTimes(1); + expect(itemList.items[2].focus).toHaveBeenCalledTimes(1); + }); - it('should focus previous items when up arrow is pressed', () => { - keyManager.onKeydown(fakeKeyEvents.downArrow); + it('should focus previous items when up arrow is pressed', () => { + keyManager.onKeydown(fakeKeyEvents.downArrow); - expect(itemList.items[0].focus).not.toHaveBeenCalled(); - expect(itemList.items[1].focus).toHaveBeenCalledTimes(1); + expect(itemList.items[0].focus).not.toHaveBeenCalled(); + expect(itemList.items[1].focus).toHaveBeenCalledTimes(1); - keyManager.onKeydown(fakeKeyEvents.upArrow); + keyManager.onKeydown(fakeKeyEvents.upArrow); - expect(itemList.items[0].focus).toHaveBeenCalledTimes(1); - expect(itemList.items[1].focus).toHaveBeenCalledTimes(1); - }); + expect(itemList.items[0].focus).toHaveBeenCalledTimes(1); + expect(itemList.items[1].focus).toHaveBeenCalledTimes(1); + }); - it('should allow setting the focused item without calling focus', () => { - expect(keyManager.activeItemIndex) - .toBe(0, `Expected first item of the list to be active.`); + it('should allow setting the focused item without calling focus', () => { + expect(keyManager.activeItemIndex) + .toBe(0, `Expected first item of the list to be active.`); - keyManager.updateActiveItemIndex(1); - expect(keyManager.activeItemIndex) - .toBe(1, `Expected activeItemIndex to update after calling updateActiveItemIndex().`); - expect(itemList.items[1].focus).not.toHaveBeenCalledTimes(1); - }); + keyManager.updateActiveItemIndex(1); + expect(keyManager.activeItemIndex) + .toBe(1, `Expected activeItemIndex to update after calling updateActiveItemIndex().`); + expect(itemList.items[1].focus).not.toHaveBeenCalledTimes(1); + }); }); diff --git a/src/cdk/a11y/list-key-manager.ts b/src/cdk/a11y/list-key-manager.ts index 0a57b6484034..ef01a084f571 100644 --- a/src/cdk/a11y/list-key-manager.ts +++ b/src/cdk/a11y/list-key-manager.ts @@ -9,42 +9,83 @@ import {QueryList} from '@angular/core'; import {Observable} from 'rxjs/Observable'; import {Subject} from 'rxjs/Subject'; -import {UP_ARROW, DOWN_ARROW, TAB} from '@angular/cdk/keyboard'; +import {Subscription} from 'rxjs/Subscription'; +import {UP_ARROW, DOWN_ARROW, TAB, A, Z} from '@angular/cdk/keyboard'; +import {RxChain, debounceTime, filter, map, doOperator} from '@angular/cdk/rxjs'; /** - * This interface is for items that can be disabled. The type passed into - * ListKeyManager must extend this interface. + * This interface is for items that can be passed to a ListKeyManager. */ -export interface CanDisable { +export interface ListKeyManagerOption { disabled?: boolean; + getLabel?(): string; } /** * This class manages keyboard events for selectable lists. If you pass it a query list * of items, it will set the active item correctly when arrow events occur. */ -export class ListKeyManager { - private _activeItemIndex: number = -1; +export class ListKeyManager { + private _activeItemIndex = -1; private _activeItem: T; - private _tabOut = new Subject(); - private _wrap: boolean = false; + private _wrap = false; + private _nonNavigationKeyStream = new Subject(); + private _typeaheadSubscription: Subscription; + + // Buffer for the letters that the user has pressed when the typeahead option is turned on. + private _pressedInputKeys: number[] = []; constructor(private _items: QueryList) { } /** * Turns on wrapping mode, which ensures that the active item will wrap to * the other end of list when there are no more items in the given direction. - * - * @returns The ListKeyManager that the method was called on. */ withWrap(): this { this._wrap = true; return this; } + /** + * Turns on typeahead mode which allows users to set the active item by typing. + * @param debounceInterval Time to wait after the last keystroke before setting the active item. + */ + withTypeAhead(debounceInterval = 200): this { + if (this._items.length && this._items.some(item => typeof item.getLabel !== 'function')) { + throw Error('ListKeyManager items in typeahead mode must implement the `getLabel` method.'); + } + + if (this._typeaheadSubscription) { + 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 = RxChain.from(this._nonNavigationKeyStream) + .call(filter, keyCode => keyCode >= A && keyCode <= Z) + .call(doOperator, keyCode => this._pressedInputKeys.push(keyCode)) + .call(debounceTime, debounceInterval) + .call(filter, () => this._pressedInputKeys.length > 0) + .call(map, () => String.fromCharCode(...this._pressedInputKeys)) + .subscribe(inputString => { + const items = this._items.toArray(); + + for (let i = 0; i < items.length; i++) { + if (items[i].getLabel!().toUpperCase().trim().indexOf(inputString) === 0) { + this.setActiveItem(i); + break; + } + } + + this._pressedInputKeys = []; + }); + + return this; + } + /** * Sets the active item to the item at the index specified. - * * @param index The index of the item to be set as active. */ setActiveItem(index: number): void { @@ -58,20 +99,15 @@ export class ListKeyManager { */ onKeydown(event: KeyboardEvent): void { switch (event.keyCode) { - case DOWN_ARROW: - this.setNextItemActive(); - break; - case UP_ARROW: - this.setPreviousItemActive(); - break; - case TAB: - // Note that we shouldn't prevent the default action on tab. - this._tabOut.next(); - return; - default: - return; + case DOWN_ARROW: this.setNextItemActive(); break; + case UP_ARROW: this.setPreviousItemActive(); break; + + // Note that we return here, in order to avoid preventing + // the default action of unsupported keys. + default: this._nonNavigationKeyStream.next(event.keyCode); return; } + this._pressedInputKeys = []; event.preventDefault(); } @@ -119,7 +155,7 @@ export class ListKeyManager { * when focus is shifted off of the list. */ get tabOut(): Observable { - return this._tabOut.asObservable(); + return filter.call(this._nonNavigationKeyStream, keyCode => keyCode === TAB); } /** @@ -173,5 +209,4 @@ export class ListKeyManager { } this.setActiveItem(index); } - } diff --git a/src/cdk/keyboard/keycodes.ts b/src/cdk/keyboard/keycodes.ts index 31735087d7a7..312174fbd3de 100644 --- a/src/cdk/keyboard/keycodes.ts +++ b/src/cdk/keyboard/keycodes.ts @@ -20,3 +20,5 @@ export const TAB = 9; export const ESCAPE = 27; export const BACKSPACE = 8; export const DELETE = 46; +export const A = 65; +export const Z = 90; diff --git a/src/lib/chips/chip.ts b/src/lib/chips/chip.ts index c287b7534031..c150f572a24f 100644 --- a/src/lib/chips/chip.ts +++ b/src/lib/chips/chip.ts @@ -18,7 +18,7 @@ import { forwardRef, } from '@angular/core'; -import {Focusable} from '../core/a11y/focus-key-manager'; +import {FocusableOption} from '../core/a11y/focus-key-manager'; import {coerceBooleanProperty} from '@angular/cdk/coercion'; import {CanColor, mixinColor} from '../core/common-behaviors/color'; import {CanDisable, mixinDisabled} from '../core/common-behaviors/disabled'; @@ -68,7 +68,8 @@ export class MdBasicChip { } '(blur)': '_hasFocus = false', } }) -export class MdChip extends _MdChipMixinBase implements Focusable, OnDestroy, CanColor, CanDisable { +export class MdChip extends _MdChipMixinBase implements FocusableOption, OnDestroy, CanColor, + CanDisable { @ContentChild(forwardRef(() => MdChipRemove)) _chipRemove: MdChipRemove; diff --git a/src/lib/core/a11y/activedescendant-key-manager.ts b/src/lib/core/a11y/activedescendant-key-manager.ts index ec5d19235cfd..07e60243ef1e 100644 --- a/src/lib/core/a11y/activedescendant-key-manager.ts +++ b/src/lib/core/a11y/activedescendant-key-manager.ts @@ -6,14 +6,14 @@ * found in the LICENSE file at https://angular.io/license */ -import {ListKeyManager, CanDisable} from './list-key-manager'; +import {ListKeyManager, ListKeyManagerOption} from './list-key-manager'; /** * This is the interface for highlightable items (used by the ActiveDescendantKeyManager). * Each item must know how to style itself as active or inactive and whether or not it is * currently disabled. */ -export interface Highlightable extends CanDisable { +export interface Highlightable extends ListKeyManagerOption { setActiveStyles(): void; setInactiveStyles(): void; } diff --git a/src/lib/core/a11y/focus-key-manager.ts b/src/lib/core/a11y/focus-key-manager.ts index 8473b84560ea..b1b2256991b3 100644 --- a/src/lib/core/a11y/focus-key-manager.ts +++ b/src/lib/core/a11y/focus-key-manager.ts @@ -6,24 +6,18 @@ * found in the LICENSE file at https://angular.io/license */ -import {QueryList} from '@angular/core'; -import {ListKeyManager, CanDisable} from './list-key-manager'; +import {ListKeyManager, ListKeyManagerOption} from './list-key-manager'; /** * This is the interface for focusable items (used by the FocusKeyManager). - * Each item must know how to focus itself and whether or not it is currently disabled. + * Each item must know how to focus itself, whether or not it is currently disabled + * and be able to supply it's label. */ -export interface Focusable extends CanDisable { +export interface FocusableOption extends ListKeyManagerOption { focus(): void; } - -export class FocusKeyManager extends ListKeyManager { - - constructor(items: QueryList) { - super(items); - } - +export class FocusKeyManager extends ListKeyManager { /** * This method sets the active item to the item at the specified index. * It also adds focuses the newly active item. @@ -35,5 +29,4 @@ export class FocusKeyManager extends ListKeyManager { this.activeItem.focus(); } } - } diff --git a/src/lib/core/a11y/list-key-manager.ts b/src/lib/core/a11y/list-key-manager.ts index c02fd7f8bb1e..8710826427ee 100644 --- a/src/lib/core/a11y/list-key-manager.ts +++ b/src/lib/core/a11y/list-key-manager.ts @@ -6,6 +6,4 @@ * found in the LICENSE file at https://angular.io/license */ -export {CanDisable, ListKeyManager} from '@angular/cdk/a11y'; - - +export {ListKeyManagerOption, ListKeyManager} from '@angular/cdk/a11y'; diff --git a/src/lib/core/keyboard/keycodes.ts b/src/lib/core/keyboard/keycodes.ts index 97deda36b866..ae272e4d6f19 100644 --- a/src/lib/core/keyboard/keycodes.ts +++ b/src/lib/core/keyboard/keycodes.ts @@ -21,5 +21,7 @@ export { TAB, ESCAPE, BACKSPACE, - DELETE + DELETE, + A, + Z, } from '@angular/cdk/keyboard'; diff --git a/src/lib/core/option/option.ts b/src/lib/core/option/option.ts index f1820d346582..09a88598f476 100644 --- a/src/lib/core/option/option.ts +++ b/src/lib/core/option/option.ts @@ -174,6 +174,11 @@ export class MdOption { } } + /** Gets the label to be used when determining whether the option should be focused. */ + getLabel(): string { + return this.viewValue; + } + /** Ensures the option is selected when activated from the keyboard. */ _handleKeydown(event: KeyboardEvent): void { if (event.keyCode === ENTER || event.keyCode === SPACE) { @@ -201,7 +206,7 @@ export class MdOption { return this.disabled ? '-1' : '0'; } - /** Fetches the host DOM element. */ + /** Gets the host DOM element. */ _getHostElement(): HTMLElement { return this._element.nativeElement; } diff --git a/src/lib/menu/menu-item.ts b/src/lib/menu/menu-item.ts index f00b62cca332..cabe77a97dad 100644 --- a/src/lib/menu/menu-item.ts +++ b/src/lib/menu/menu-item.ts @@ -7,7 +7,7 @@ */ import {Component, ElementRef, OnDestroy, ChangeDetectionStrategy} from '@angular/core'; -import {Focusable} from '../core/a11y/focus-key-manager'; +import {FocusableOption} from '../core/a11y/focus-key-manager'; import {CanDisable, mixinDisabled} from '../core/common-behaviors/disabled'; import {Subject} from 'rxjs/Subject'; @@ -39,7 +39,9 @@ export const _MdMenuItemMixinBase = mixinDisabled(MdMenuItemBase); templateUrl: 'menu-item.html', exportAs: 'mdMenuItem', }) -export class MdMenuItem extends _MdMenuItemMixinBase implements Focusable, CanDisable, OnDestroy { +export class MdMenuItem extends _MdMenuItemMixinBase implements FocusableOption, CanDisable, + OnDestroy { + /** Stream that emits when the menu item is hovered. */ hover: Subject = new Subject(); diff --git a/src/lib/select/select.ts b/src/lib/select/select.ts index 7a53b46b53c9..6483c60c11ca 100644 --- a/src/lib/select/select.ts +++ b/src/lib/select/select.ts @@ -719,7 +719,7 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On /** Sets up a key manager to listen to keyboard events on the overlay panel. */ private _initKeyManager() { - this._keyManager = new FocusKeyManager(this.options); + this._keyManager = new FocusKeyManager(this.options).withTypeAhead(); this._tabSubscription = this._keyManager.tabOut.subscribe(() => this.close()); }