Skip to content

Commit 5ebca5e

Browse files
crisbetommalerba
authored andcommitted
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
1 parent 504b5df commit 5ebca5e

File tree

13 files changed

+191
-95
lines changed

13 files changed

+191
-95
lines changed

src/cdk/a11y/activedescendant-key-manager.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,14 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {ListKeyManager, CanDisable} from './list-key-manager';
9+
import {ListKeyManager, ListKeyManagerOption} from './list-key-manager';
1010

1111
/**
1212
* This is the interface for highlightable items (used by the ActiveDescendantKeyManager).
1313
* Each item must know how to style itself as active or inactive and whether or not it is
1414
* currently disabled.
1515
*/
16-
export interface Highlightable extends CanDisable {
16+
export interface Highlightable extends ListKeyManagerOption {
1717
setActiveStyles(): void;
1818
setInactiveStyles(): void;
1919
}

src/cdk/a11y/focus-key-manager.ts

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,24 +6,18 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {QueryList} from '@angular/core';
10-
import {ListKeyManager, CanDisable} from './list-key-manager';
9+
import {ListKeyManager, ListKeyManagerOption} from './list-key-manager';
1110

1211
/**
1312
* This is the interface for focusable items (used by the FocusKeyManager).
14-
* Each item must know how to focus itself and whether or not it is currently disabled.
13+
* Each item must know how to focus itself, whether or not it is currently disabled
14+
* and be able to supply it's label.
1515
*/
16-
export interface Focusable extends CanDisable {
16+
export interface FocusableOption extends ListKeyManagerOption {
1717
focus(): void;
1818
}
1919

20-
21-
export class FocusKeyManager extends ListKeyManager<Focusable> {
22-
23-
constructor(items: QueryList<Focusable>) {
24-
super(items);
25-
}
26-
20+
export class FocusKeyManager extends ListKeyManager<FocusableOption> {
2721
/**
2822
* This method sets the active item to the item at the specified index.
2923
* It also adds focuses the newly active item.
@@ -35,5 +29,4 @@ export class FocusKeyManager extends ListKeyManager<Focusable> {
3529
this.activeItem.focus();
3630
}
3731
}
38-
3932
}

src/cdk/a11y/list-key-manager.spec.ts

Lines changed: 97 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@ import {first} from '../rxjs/index';
99

1010

1111
class FakeFocusable {
12+
constructor(private _label = '') { }
1213
disabled = false;
1314
focus() {}
15+
getLabel() { return this._label; }
1416
}
1517

1618
class FakeHighlightable {
@@ -20,11 +22,11 @@ class FakeHighlightable {
2022
}
2123

2224
class FakeQueryList<T> extends QueryList<T> {
23-
get length() { return this.items.length; }
2425
items: T[];
25-
toArray() {
26-
return this.items;
27-
}
26+
get length() { return this.items.length; }
27+
get first() { return this.items[0]; }
28+
toArray() { return this.items; }
29+
some() { return this.items.some.apply(this.items, arguments); }
2830
}
2931

3032

@@ -43,7 +45,7 @@ describe('Key managers', () => {
4345
downArrow: createKeyboardEvent('keydown', DOWN_ARROW),
4446
upArrow: createKeyboardEvent('keydown', UP_ARROW),
4547
tab: createKeyboardEvent('keydown', TAB),
46-
unsupported: createKeyboardEvent('keydown', 65) // corresponds to the letter "a"
48+
unsupported: createKeyboardEvent('keydown', 192) // corresponds to the tilde character (~)
4749
};
4850
});
4951

@@ -52,7 +54,11 @@ describe('Key managers', () => {
5254
let keyManager: ListKeyManager<FakeFocusable>;
5355

5456
beforeEach(() => {
55-
itemList.items = [new FakeFocusable(), new FakeFocusable(), new FakeFocusable()];
57+
itemList.items = [
58+
new FakeFocusable('one'),
59+
new FakeFocusable('two'),
60+
new FakeFocusable('three')
61+
];
5662
keyManager = new ListKeyManager<FakeFocusable>(itemList);
5763

5864
// first item is already focused
@@ -383,6 +389,65 @@ describe('Key managers', () => {
383389

384390
});
385391

392+
describe('typeahead mode', () => {
393+
const debounceInterval = 300;
394+
395+
beforeEach(() => {
396+
keyManager.withTypeAhead(debounceInterval);
397+
keyManager.setActiveItem(-1);
398+
});
399+
400+
it('should throw if the items do not implement the getLabel method', () => {
401+
const invalidQueryList = new FakeQueryList();
402+
403+
invalidQueryList.items = [{ disabled: false }];
404+
405+
const invalidManager = new ListKeyManager(invalidQueryList);
406+
407+
expect(() => invalidManager.withTypeAhead()).toThrowError(/must implement/);
408+
});
409+
410+
it('should debounce the input key presses', fakeAsync(() => {
411+
keyManager.onKeydown(createKeyboardEvent('keydown', 79)); // types "o"
412+
keyManager.onKeydown(createKeyboardEvent('keydown', 78)); // types "n"
413+
keyManager.onKeydown(createKeyboardEvent('keydown', 69)); // types "e"
414+
415+
expect(keyManager.activeItem).not.toBe(itemList.items[0]);
416+
417+
tick(debounceInterval);
418+
419+
expect(keyManager.activeItem).toBe(itemList.items[0]);
420+
}));
421+
422+
it('should focus the first item that starts with a letter', fakeAsync(() => {
423+
keyManager.onKeydown(createKeyboardEvent('keydown', 84)); // types "t"
424+
425+
tick(debounceInterval);
426+
427+
expect(keyManager.activeItem).toBe(itemList.items[1]);
428+
}));
429+
430+
it('should focus the first item that starts with sequence of letters', fakeAsync(() => {
431+
keyManager.onKeydown(createKeyboardEvent('keydown', 84)); // types "t"
432+
keyManager.onKeydown(createKeyboardEvent('keydown', 72)); // types "h"
433+
434+
tick(debounceInterval);
435+
436+
expect(keyManager.activeItem).toBe(itemList.items[2]);
437+
}));
438+
439+
it('should cancel any pending timers if a navigation key is pressed', fakeAsync(() => {
440+
keyManager.onKeydown(createKeyboardEvent('keydown', 84)); // types "t"
441+
keyManager.onKeydown(createKeyboardEvent('keydown', 72)); // types "h"
442+
keyManager.onKeydown(fakeKeyEvents.downArrow);
443+
444+
tick(debounceInterval);
445+
446+
expect(keyManager.activeItem).toBe(itemList.items[0]);
447+
}));
448+
449+
});
450+
386451
});
387452

388453
describe('FocusKeyManager', () => {
@@ -400,40 +465,40 @@ describe('Key managers', () => {
400465
spyOn(itemList.items[2], 'focus');
401466
});
402467

403-
it('should focus subsequent items when down arrow is pressed', () => {
404-
keyManager.onKeydown(fakeKeyEvents.downArrow);
468+
it('should focus subsequent items when down arrow is pressed', () => {
469+
keyManager.onKeydown(fakeKeyEvents.downArrow);
405470

406-
expect(itemList.items[0].focus).not.toHaveBeenCalled();
407-
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1);
408-
expect(itemList.items[2].focus).not.toHaveBeenCalled();
471+
expect(itemList.items[0].focus).not.toHaveBeenCalled();
472+
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1);
473+
expect(itemList.items[2].focus).not.toHaveBeenCalled();
409474

410-
keyManager.onKeydown(fakeKeyEvents.downArrow);
411-
expect(itemList.items[0].focus).not.toHaveBeenCalled();
412-
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1);
413-
expect(itemList.items[2].focus).toHaveBeenCalledTimes(1);
414-
});
475+
keyManager.onKeydown(fakeKeyEvents.downArrow);
476+
expect(itemList.items[0].focus).not.toHaveBeenCalled();
477+
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1);
478+
expect(itemList.items[2].focus).toHaveBeenCalledTimes(1);
479+
});
415480

416-
it('should focus previous items when up arrow is pressed', () => {
417-
keyManager.onKeydown(fakeKeyEvents.downArrow);
481+
it('should focus previous items when up arrow is pressed', () => {
482+
keyManager.onKeydown(fakeKeyEvents.downArrow);
418483

419-
expect(itemList.items[0].focus).not.toHaveBeenCalled();
420-
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1);
484+
expect(itemList.items[0].focus).not.toHaveBeenCalled();
485+
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1);
421486

422-
keyManager.onKeydown(fakeKeyEvents.upArrow);
487+
keyManager.onKeydown(fakeKeyEvents.upArrow);
423488

424-
expect(itemList.items[0].focus).toHaveBeenCalledTimes(1);
425-
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1);
426-
});
489+
expect(itemList.items[0].focus).toHaveBeenCalledTimes(1);
490+
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1);
491+
});
427492

428-
it('should allow setting the focused item without calling focus', () => {
429-
expect(keyManager.activeItemIndex)
430-
.toBe(0, `Expected first item of the list to be active.`);
493+
it('should allow setting the focused item without calling focus', () => {
494+
expect(keyManager.activeItemIndex)
495+
.toBe(0, `Expected first item of the list to be active.`);
431496

432-
keyManager.updateActiveItemIndex(1);
433-
expect(keyManager.activeItemIndex)
434-
.toBe(1, `Expected activeItemIndex to update after calling updateActiveItemIndex().`);
435-
expect(itemList.items[1].focus).not.toHaveBeenCalledTimes(1);
436-
});
497+
keyManager.updateActiveItemIndex(1);
498+
expect(keyManager.activeItemIndex)
499+
.toBe(1, `Expected activeItemIndex to update after calling updateActiveItemIndex().`);
500+
expect(itemList.items[1].focus).not.toHaveBeenCalledTimes(1);
501+
});
437502

438503
});
439504

src/cdk/a11y/list-key-manager.ts

Lines changed: 60 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -9,42 +9,83 @@
99
import {QueryList} from '@angular/core';
1010
import {Observable} from 'rxjs/Observable';
1111
import {Subject} from 'rxjs/Subject';
12-
import {UP_ARROW, DOWN_ARROW, TAB} from '@angular/cdk/keyboard';
12+
import {Subscription} from 'rxjs/Subscription';
13+
import {UP_ARROW, DOWN_ARROW, TAB, A, Z} from '@angular/cdk/keyboard';
14+
import {RxChain, debounceTime, filter, map, doOperator} from '@angular/cdk/rxjs';
1315

1416
/**
15-
* This interface is for items that can be disabled. The type passed into
16-
* ListKeyManager must extend this interface.
17+
* This interface is for items that can be passed to a ListKeyManager.
1718
*/
18-
export interface CanDisable {
19+
export interface ListKeyManagerOption {
1920
disabled?: boolean;
21+
getLabel?(): string;
2022
}
2123

2224
/**
2325
* This class manages keyboard events for selectable lists. If you pass it a query list
2426
* of items, it will set the active item correctly when arrow events occur.
2527
*/
26-
export class ListKeyManager<T extends CanDisable> {
27-
private _activeItemIndex: number = -1;
28+
export class ListKeyManager<T extends ListKeyManagerOption> {
29+
private _activeItemIndex = -1;
2830
private _activeItem: T;
29-
private _tabOut = new Subject<void>();
30-
private _wrap: boolean = false;
31+
private _wrap = false;
32+
private _nonNavigationKeyStream = new Subject<number>();
33+
private _typeaheadSubscription: Subscription;
34+
35+
// Buffer for the letters that the user has pressed when the typeahead option is turned on.
36+
private _pressedInputKeys: number[] = [];
3137

3238
constructor(private _items: QueryList<T>) { }
3339

3440
/**
3541
* Turns on wrapping mode, which ensures that the active item will wrap to
3642
* the other end of list when there are no more items in the given direction.
37-
*
38-
* @returns The ListKeyManager that the method was called on.
3943
*/
4044
withWrap(): this {
4145
this._wrap = true;
4246
return this;
4347
}
4448

49+
/**
50+
* Turns on typeahead mode which allows users to set the active item by typing.
51+
* @param debounceInterval Time to wait after the last keystroke before setting the active item.
52+
*/
53+
withTypeAhead(debounceInterval = 200): this {
54+
if (this._items.length && this._items.some(item => typeof item.getLabel !== 'function')) {
55+
throw Error('ListKeyManager items in typeahead mode must implement the `getLabel` method.');
56+
}
57+
58+
if (this._typeaheadSubscription) {
59+
this._typeaheadSubscription.unsubscribe();
60+
}
61+
62+
// Debounce the presses of non-navigational keys, collect the ones that correspond to letters
63+
// and convert those letters back into a string. Afterwards find the first item that starts
64+
// with that string and select it.
65+
this._typeaheadSubscription = RxChain.from(this._nonNavigationKeyStream)
66+
.call(filter, keyCode => keyCode >= A && keyCode <= Z)
67+
.call(doOperator, keyCode => this._pressedInputKeys.push(keyCode))
68+
.call(debounceTime, debounceInterval)
69+
.call(filter, () => this._pressedInputKeys.length > 0)
70+
.call(map, () => String.fromCharCode(...this._pressedInputKeys))
71+
.subscribe(inputString => {
72+
const items = this._items.toArray();
73+
74+
for (let i = 0; i < items.length; i++) {
75+
if (items[i].getLabel!().toUpperCase().trim().indexOf(inputString) === 0) {
76+
this.setActiveItem(i);
77+
break;
78+
}
79+
}
80+
81+
this._pressedInputKeys = [];
82+
});
83+
84+
return this;
85+
}
86+
4587
/**
4688
* Sets the active item to the item at the index specified.
47-
*
4889
* @param index The index of the item to be set as active.
4990
*/
5091
setActiveItem(index: number): void {
@@ -58,20 +99,15 @@ export class ListKeyManager<T extends CanDisable> {
5899
*/
59100
onKeydown(event: KeyboardEvent): void {
60101
switch (event.keyCode) {
61-
case DOWN_ARROW:
62-
this.setNextItemActive();
63-
break;
64-
case UP_ARROW:
65-
this.setPreviousItemActive();
66-
break;
67-
case TAB:
68-
// Note that we shouldn't prevent the default action on tab.
69-
this._tabOut.next();
70-
return;
71-
default:
72-
return;
102+
case DOWN_ARROW: this.setNextItemActive(); break;
103+
case UP_ARROW: this.setPreviousItemActive(); break;
104+
105+
// Note that we return here, in order to avoid preventing
106+
// the default action of unsupported keys.
107+
default: this._nonNavigationKeyStream.next(event.keyCode); return;
73108
}
74109

110+
this._pressedInputKeys = [];
75111
event.preventDefault();
76112
}
77113

@@ -119,7 +155,7 @@ export class ListKeyManager<T extends CanDisable> {
119155
* when focus is shifted off of the list.
120156
*/
121157
get tabOut(): Observable<void> {
122-
return this._tabOut.asObservable();
158+
return filter.call(this._nonNavigationKeyStream, keyCode => keyCode === TAB);
123159
}
124160

125161
/**
@@ -173,5 +209,4 @@ export class ListKeyManager<T extends CanDisable> {
173209
}
174210
this.setActiveItem(index);
175211
}
176-
177212
}

src/cdk/keyboard/keycodes.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,5 @@ export const TAB = 9;
2020
export const ESCAPE = 27;
2121
export const BACKSPACE = 8;
2222
export const DELETE = 46;
23+
export const A = 65;
24+
export const Z = 90;

src/lib/chips/chip.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
forwardRef,
1919
} from '@angular/core';
2020

21-
import {Focusable} from '../core/a11y/focus-key-manager';
21+
import {FocusableOption} from '../core/a11y/focus-key-manager';
2222
import {coerceBooleanProperty} from '@angular/cdk/coercion';
2323
import {CanColor, mixinColor} from '../core/common-behaviors/color';
2424
import {CanDisable, mixinDisabled} from '../core/common-behaviors/disabled';
@@ -68,7 +68,8 @@ export class MdBasicChip { }
6868
'(blur)': '_hasFocus = false',
6969
}
7070
})
71-
export class MdChip extends _MdChipMixinBase implements Focusable, OnDestroy, CanColor, CanDisable {
71+
export class MdChip extends _MdChipMixinBase implements FocusableOption, OnDestroy, CanColor,
72+
CanDisable {
7273

7374
@ContentChild(forwardRef(() => MdChipRemove)) _chipRemove: MdChipRemove;
7475

0 commit comments

Comments
 (0)