Skip to content

Commit 72e245d

Browse files
committed
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.
1 parent 0850981 commit 72e245d

File tree

11 files changed

+157
-75
lines changed

11 files changed

+157
-75
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, ListKeyManagerItem} 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 ListKeyManagerItem {
1717
setActiveStyles(): void;
1818
setInactiveStyles(): void;
1919
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,13 @@
77
*/
88

99
import {QueryList} from '@angular/core';
10-
import {ListKeyManager, CanDisable} from './list-key-manager';
10+
import {ListKeyManager, ListKeyManagerItem} from './list-key-manager';
1111

1212
/**
1313
* This is the interface for focusable items (used by the FocusKeyManager).
1414
* Each item must know how to focus itself and whether or not it is currently disabled.
1515
*/
16-
export interface Focusable extends CanDisable {
16+
export interface Focusable extends ListKeyManagerItem {
1717
focus(): void;
1818
}
1919

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

Lines changed: 84 additions & 28 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 {
@@ -25,6 +27,7 @@ class FakeQueryList<T> extends QueryList<T> {
2527
toArray() {
2628
return this.items;
2729
}
30+
get first() { return this.items[0]; }
2831
}
2932

3033

@@ -43,7 +46,7 @@ describe('Key managers', () => {
4346
downArrow: createKeyboardEvent('keydown', DOWN_ARROW),
4447
upArrow: createKeyboardEvent('keydown', UP_ARROW),
4548
tab: createKeyboardEvent('keydown', TAB),
46-
unsupported: createKeyboardEvent('keydown', 65) // corresponds to the letter "a"
49+
unsupported: createKeyboardEvent('keydown', 192) // corresponds to the tilde character (~)
4750
};
4851
});
4952

@@ -52,7 +55,11 @@ describe('Key managers', () => {
5255
let keyManager: ListKeyManager<FakeFocusable>;
5356

5457
beforeEach(() => {
55-
itemList.items = [new FakeFocusable(), new FakeFocusable(), new FakeFocusable()];
58+
itemList.items = [
59+
new FakeFocusable('one'),
60+
new FakeFocusable('two'),
61+
new FakeFocusable('three')
62+
];
5663
keyManager = new ListKeyManager<FakeFocusable>(itemList);
5764

5865
// first item is already focused
@@ -383,6 +390,55 @@ describe('Key managers', () => {
383390

384391
});
385392

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

388444
describe('FocusKeyManager', () => {
@@ -400,40 +456,40 @@ describe('Key managers', () => {
400456
spyOn(itemList.items[2], 'focus');
401457
});
402458

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

406-
expect(itemList.items[0].focus).not.toHaveBeenCalled();
407-
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1);
408-
expect(itemList.items[2].focus).not.toHaveBeenCalled();
462+
expect(itemList.items[0].focus).not.toHaveBeenCalled();
463+
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1);
464+
expect(itemList.items[2].focus).not.toHaveBeenCalled();
409465

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-
});
466+
keyManager.onKeydown(fakeKeyEvents.downArrow);
467+
expect(itemList.items[0].focus).not.toHaveBeenCalled();
468+
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1);
469+
expect(itemList.items[2].focus).toHaveBeenCalledTimes(1);
470+
});
415471

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

419-
expect(itemList.items[0].focus).not.toHaveBeenCalled();
420-
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1);
475+
expect(itemList.items[0].focus).not.toHaveBeenCalled();
476+
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1);
421477

422-
keyManager.onKeydown(fakeKeyEvents.upArrow);
478+
keyManager.onKeydown(fakeKeyEvents.upArrow);
423479

424-
expect(itemList.items[0].focus).toHaveBeenCalledTimes(1);
425-
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1);
426-
});
480+
expect(itemList.items[0].focus).toHaveBeenCalledTimes(1);
481+
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1);
482+
});
427483

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.`);
484+
it('should allow setting the focused item without calling focus', () => {
485+
expect(keyManager.activeItemIndex)
486+
.toBe(0, `Expected first item of the list to be active.`);
431487

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-
});
488+
keyManager.updateActiveItemIndex(1);
489+
expect(keyManager.activeItemIndex)
490+
.toBe(1, `Expected activeItemIndex to update after calling updateActiveItemIndex().`);
491+
expect(itemList.items[1].focus).not.toHaveBeenCalledTimes(1);
492+
});
437493

438494
});
439495

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

Lines changed: 51 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -9,42 +9,77 @@
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 ListKeyManagerItem {
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 ListKeyManagerItem> {
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 _pressedInputKeys: number[] = [];
33+
private _nonNavigationKeyStream = new Subject<number>();
34+
private _typeaheadSubscription: Subscription;
3135

3236
constructor(private _items: QueryList<T>) { }
3337

3438
/**
3539
* Turns on wrapping mode, which ensures that the active item will wrap to
3640
* 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.
3941
*/
4042
withWrap(): this {
4143
this._wrap = true;
4244
return this;
4345
}
4446

47+
/**
48+
* Turns on typeahead mode which allows users to set the active item by typing.
49+
* @param debounceInterval Time to wait after the last keystroke before setting the active item.
50+
*/
51+
withTypeAhead(debounceInterval = 200): this {
52+
if (this._items.length && this._items.some(item => typeof item.getLabel !== 'function')) {
53+
throw Error('ListKeyManager items in typeahead mode must implement the `getLabel` method.');
54+
}
55+
56+
if (this._typeaheadSubscription) {
57+
this._typeaheadSubscription.unsubscribe();
58+
}
59+
60+
this._typeaheadSubscription = RxChain.from(this._nonNavigationKeyStream)
61+
.call(filter, keyCode => keyCode >= A && keyCode <= Z)
62+
.call(doOperator, keyCode => this._pressedInputKeys.push(keyCode))
63+
.call(debounceTime, debounceInterval)
64+
.call(filter, () => this._pressedInputKeys.length > 0)
65+
.call(map, () => String.fromCharCode(...this._pressedInputKeys))
66+
.subscribe(inputString => {
67+
const activeItemIndex = this._items.toArray().findIndex(item => {
68+
return item.getLabel!().toUpperCase().trim().startsWith(inputString);
69+
});
70+
71+
if (activeItemIndex > -1) {
72+
this.setActiveItem(activeItemIndex);
73+
}
74+
75+
this._pressedInputKeys = [];
76+
});
77+
78+
return this;
79+
}
80+
4581
/**
4682
* Sets the active item to the item at the index specified.
47-
*
4883
* @param index The index of the item to be set as active.
4984
*/
5085
setActiveItem(index: number): void {
@@ -58,20 +93,12 @@ export class ListKeyManager<T extends CanDisable> {
5893
*/
5994
onKeydown(event: KeyboardEvent): void {
6095
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;
96+
case DOWN_ARROW: this.setNextItemActive(); break;
97+
case UP_ARROW: this.setPreviousItemActive(); break;
98+
default: this._nonNavigationKeyStream.next(event.keyCode); return;
7399
}
74100

101+
this._pressedInputKeys = [];
75102
event.preventDefault();
76103
}
77104

@@ -119,7 +146,7 @@ export class ListKeyManager<T extends CanDisable> {
119146
* when focus is shifted off of the list.
120147
*/
121148
get tabOut(): Observable<void> {
122-
return this._tabOut.asObservable();
149+
return filter.call(this._nonNavigationKeyStream, keyCode => keyCode === TAB);
123150
}
124151

125152
/**
@@ -173,5 +200,4 @@ export class ListKeyManager<T extends CanDisable> {
173200
}
174201
this.setActiveItem(index);
175202
}
176-
177203
}

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/core/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, ListKeyManagerItem} 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 ListKeyManagerItem {
1717
setActiveStyles(): void;
1818
setInactiveStyles(): void;
1919
}

src/lib/core/a11y/focus-key-manager.ts

Lines changed: 4 additions & 11 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, ListKeyManagerItem} 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 Focusable extends ListKeyManagerItem {
1717
focus(): void;
1818
}
1919

20-
2120
export class FocusKeyManager extends ListKeyManager<Focusable> {
22-
23-
constructor(items: QueryList<Focusable>) {
24-
super(items);
25-
}
26-
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/lib/core/a11y/list-key-manager.ts

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

9-
export {CanDisable, ListKeyManager} from '@angular/cdk/a11y';
10-
11-
9+
export {ListKeyManagerItem, ListKeyManager} from '@angular/cdk/a11y';

0 commit comments

Comments
 (0)