Skip to content

Commit abfa4a3

Browse files
committed
refactor(cdk/a11y): factor out a typeahead class for key managers
1 parent 372f9df commit abfa4a3

File tree

6 files changed

+176
-131
lines changed

6 files changed

+176
-131
lines changed

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

Lines changed: 18 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,13 @@ import {
1414
LEFT_ARROW,
1515
RIGHT_ARROW,
1616
TAB,
17-
A,
18-
Z,
19-
ZERO,
20-
NINE,
2117
hasModifierKey,
2218
HOME,
2319
END,
2420
PAGE_UP,
2521
PAGE_DOWN,
2622
} from '@angular/cdk/keycodes';
27-
import {debounceTime, filter, map, tap} from 'rxjs/operators';
23+
import {Typeahead} from './typeahead';
2824

2925
/** This interface is for items that can be passed to a ListKeyManager. */
3026
export interface ListKeyManagerOption {
@@ -46,24 +42,21 @@ export class ListKeyManager<T extends ListKeyManagerOption> {
4642
private _activeItemIndex = -1;
4743
private _activeItem: T | null = null;
4844
private _wrap = false;
49-
private readonly _letterKeyStream = new Subject<string>();
5045
private _typeaheadSubscription = Subscription.EMPTY;
5146
private _itemChangesSubscription?: Subscription;
5247
private _vertical = true;
5348
private _horizontal: 'ltr' | 'rtl' | null;
5449
private _allowedModifierKeys: ListKeyManagerModifierKey[] = [];
5550
private _homeAndEnd = false;
5651
private _pageUpAndDown = {enabled: false, delta: 10};
52+
private _typeahead?: Typeahead<T>;
5753

5854
/**
5955
* Predicate function that can be used to check whether an item should be skipped
6056
* by the key manager. By default, disabled items are skipped.
6157
*/
6258
private _skipPredicateFn = (item: T) => item.disabled;
6359

64-
// Buffer for the letters that the user has pressed when the typeahead option is turned on.
65-
private _pressedLetters: string[] = [];
66-
6760
constructor(private _items: QueryList<T> | T[]) {
6861
// We allow for the items to be an array because, in some cases, the consumer may
6962
// not have access to a QueryList of the items they want to manage (e.g. when the
@@ -73,9 +66,11 @@ export class ListKeyManager<T extends ListKeyManagerOption> {
7366
if (this._activeItem) {
7467
const itemArray = newItems.toArray();
7568
const newIndex = itemArray.indexOf(this._activeItem);
69+
this._typeahead?.setItems(itemArray);
7670

7771
if (newIndex > -1 && newIndex !== this._activeItemIndex) {
7872
this._activeItemIndex = newIndex;
73+
this._typeahead?.setCurrentSelectedItemIndex(newIndex);
7974
}
8075
}
8176
});
@@ -144,53 +139,24 @@ export class ListKeyManager<T extends ListKeyManagerOption> {
144139
* @param debounceInterval Time to wait after the last keystroke before setting the active item.
145140
*/
146141
withTypeAhead(debounceInterval: number = 200): this {
147-
if (
148-
(typeof ngDevMode === 'undefined' || ngDevMode) &&
149-
this._items.length &&
150-
this._items.some(item => typeof item.getLabel !== 'function')
151-
) {
152-
throw Error('ListKeyManager items in typeahead mode must implement the `getLabel` method.');
153-
}
154-
155142
this._typeaheadSubscription.unsubscribe();
156143

157-
// Debounce the presses of non-navigational keys, collect the ones that correspond to letters
158-
// and convert those letters back into a string. Afterwards find the first item that starts
159-
// with that string and select it.
160-
this._typeaheadSubscription = this._letterKeyStream
161-
.pipe(
162-
tap(letter => this._pressedLetters.push(letter)),
163-
debounceTime(debounceInterval),
164-
filter(() => this._pressedLetters.length > 0),
165-
map(() => this._pressedLetters.join('')),
166-
)
167-
.subscribe(inputString => {
168-
const items = this._getItemsArray();
169-
170-
// Start at 1 because we want to start searching at the item immediately
171-
// following the current active item.
172-
for (let i = 1; i < items.length + 1; i++) {
173-
const index = (this._activeItemIndex + i) % items.length;
174-
const item = items[index];
175-
176-
if (
177-
!this._skipPredicateFn(item) &&
178-
item.getLabel!().toUpperCase().trim().indexOf(inputString) === 0
179-
) {
180-
this.setActiveItem(index);
181-
break;
182-
}
183-
}
144+
const items = this._getItemsArray();
145+
this._typeahead = new Typeahead(items, {
146+
debounceInterval: typeof debounceInterval === 'number' ? debounceInterval : undefined,
147+
skipPredicate: item => this._skipPredicateFn(item),
148+
});
184149

185-
this._pressedLetters = [];
186-
});
150+
this._typeaheadSubscription = this._typeahead.selectedItem.subscribe(item => {
151+
this.setActiveItem(item);
152+
});
187153

188154
return this;
189155
}
190156

191157
/** Cancels the current typeahead sequence. */
192158
cancelTypeahead(): this {
193-
this._pressedLetters = [];
159+
this._typeahead?.reset();
194160
return this;
195161
}
196162

@@ -322,21 +288,15 @@ export class ListKeyManager<T extends ListKeyManagerOption> {
322288

323289
default:
324290
if (isModifierAllowed || hasModifierKey(event, 'shiftKey')) {
325-
// Attempt to use the `event.key` which also maps it to the user's keyboard language,
326-
// otherwise fall back to resolving alphanumeric characters via the keyCode.
327-
if (event.key && event.key.length === 1) {
328-
this._letterKeyStream.next(event.key.toLocaleUpperCase());
329-
} else if ((keyCode >= A && keyCode <= Z) || (keyCode >= ZERO && keyCode <= NINE)) {
330-
this._letterKeyStream.next(String.fromCharCode(keyCode));
331-
}
291+
this._typeahead?.handleKey(event);
332292
}
333293

334294
// Note that we return here, in order to avoid preventing
335295
// the default action of non-navigational keys.
336296
return;
337297
}
338298

339-
this._pressedLetters = [];
299+
this._typeahead?.reset();
340300
event.preventDefault();
341301
}
342302

@@ -352,7 +312,7 @@ export class ListKeyManager<T extends ListKeyManagerOption> {
352312

353313
/** Gets whether the user is currently typing into the manager using the typeahead feature. */
354314
isTyping(): boolean {
355-
return this._pressedLetters.length > 0;
315+
return !!this._typeahead && this._typeahead.isTyping();
356316
}
357317

358318
/** Sets the active item to the first enabled item in the list. */
@@ -397,16 +357,16 @@ export class ListKeyManager<T extends ListKeyManagerOption> {
397357
// Explicitly check for `null` and `undefined` because other falsy values are valid.
398358
this._activeItem = activeItem == null ? null : activeItem;
399359
this._activeItemIndex = index;
360+
this._typeahead?.setCurrentSelectedItemIndex(index);
400361
}
401362

402363
/** Cleans up the key manager. */
403364
destroy() {
404365
this._typeaheadSubscription.unsubscribe();
405366
this._itemChangesSubscription?.unsubscribe();
406-
this._letterKeyStream.complete();
367+
this._typeahead?.destroy();
407368
this.tabOut.complete();
408369
this.change.complete();
409-
this._pressedLetters = [];
410370
}
411371

412372
/**

src/cdk/a11y/key-manager/noop-tree-key-manager.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ export class NoopTreeKeyManager<T extends TreeKeyManagerItem> implements TreeKey
3434
// implementation that does not emit to streams.
3535
readonly change = new Subject<T | null>();
3636

37+
destroy() {
38+
this.change.complete();
39+
}
40+
3741
onKeydown() {
3842
// noop
3943
}

src/cdk/a11y/key-manager/tree-key-manager-strategy.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,11 @@ export interface TreeKeyManagerStrategy<T extends TreeKeyManagerItem> {
8787
/** Stream that emits any time the focused item changes. */
8888
readonly change: Subject<T | null>;
8989

90+
/**
91+
* Cleans up the key manager.
92+
*/
93+
destroy(): void;
94+
9095
/**
9196
* Handles a keyboard event on the tree.
9297
*

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

Lines changed: 29 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -16,22 +16,17 @@ import {
1616
SPACE,
1717
TAB,
1818
UP_ARROW,
19-
A,
20-
Z,
21-
ZERO,
22-
NINE,
2319
} from '@angular/cdk/keycodes';
2420
import {InjectionToken, QueryList} from '@angular/core';
25-
import {of as observableOf, isObservable, Observable, Subject, Subscription} from 'rxjs';
26-
import {debounceTime, filter, map, take, tap} from 'rxjs/operators';
21+
import {Observable, Subject, Subscription, isObservable, of as observableOf} from 'rxjs';
22+
import {take} from 'rxjs/operators';
2723
import {
2824
TreeKeyManagerFactory,
2925
TreeKeyManagerItem,
3026
TreeKeyManagerOptions,
3127
TreeKeyManagerStrategy,
3228
} from './tree-key-manager-strategy';
33-
34-
const DEFAULT_TYPEAHEAD_DEBOUNCE_INTERVAL_MS = 200;
29+
import {Typeahead} from './typeahead';
3530

3631
function coerceObservable<T>(data: T | Observable<T>): Observable<T> {
3732
if (!isObservable(data)) {
@@ -50,8 +45,6 @@ export class TreeKeyManager<T extends TreeKeyManagerItem> implements TreeKeyMana
5045
private _activeItem: T | null = null;
5146
private _activationFollowsFocus = false;
5247
private _horizontal: 'ltr' | 'rtl' = 'ltr';
53-
private readonly _letterKeyStream = new Subject<string>();
54-
private _typeaheadSubscription = Subscription.EMPTY;
5548

5649
/**
5750
* Predicate function that can be used to check whether an item should be skipped
@@ -62,11 +55,11 @@ export class TreeKeyManager<T extends TreeKeyManagerItem> implements TreeKeyMana
6255
/** Function to determine equivalent items. */
6356
private _trackByFn: (item: T) => unknown = (item: T) => item;
6457

65-
/** Buffer for the letters that the user has pressed when the typeahead option is turned on. */
66-
private _pressedLetters: string[] = [];
67-
6858
private _items: T[] = [];
6959

60+
private _typeahead?: Typeahead<T>;
61+
private _typeaheadSubscription = Subscription.EMPTY;
62+
7063
constructor(items: Observable<T[]> | QueryList<T> | T[], config: TreeKeyManagerOptions<T>) {
7164
// We allow for the items to be an array or Observable because, in some cases, the consumer may
7265
// not have access to a QueryList of the items they want to manage (e.g. when the
@@ -75,11 +68,13 @@ export class TreeKeyManager<T extends TreeKeyManagerItem> implements TreeKeyMana
7568
this._items = items.toArray();
7669
items.changes.subscribe((newItems: QueryList<T>) => {
7770
this._items = newItems.toArray();
71+
this._typeahead?.setItems(this._items);
7872
this._updateActiveItemIndex(this._items);
7973
});
8074
} else if (isObservable(items)) {
8175
items.subscribe(newItems => {
8276
this._items = newItems;
77+
this._typeahead?.setItems(newItems);
8378
this._updateActiveItemIndex(newItems);
8479
});
8580
} else {
@@ -99,18 +94,20 @@ export class TreeKeyManager<T extends TreeKeyManagerItem> implements TreeKeyMana
9994
this._trackByFn = config.trackBy;
10095
}
10196
if (typeof config.typeAheadDebounceInterval !== 'undefined') {
102-
const typeAheadInterval =
103-
typeof config.typeAheadDebounceInterval === 'number'
104-
? config.typeAheadDebounceInterval
105-
: DEFAULT_TYPEAHEAD_DEBOUNCE_INTERVAL_MS;
106-
107-
this._setTypeAhead(typeAheadInterval);
97+
this._setTypeAhead(config.typeAheadDebounceInterval);
10898
}
10999
}
110100

111101
/** Stream that emits any time the focused item changes. */
112102
readonly change = new Subject<T | null>();
113103

104+
/** Cleans up the key manager. */
105+
destroy() {
106+
this._typeaheadSubscription.unsubscribe();
107+
this._typeahead?.destroy();
108+
this.change.complete();
109+
}
110+
114111
/**
115112
* Handles a keyboard event on the tree.
116113
* @param event Keyboard event that represents the user interaction with the tree.
@@ -160,21 +157,14 @@ export class TreeKeyManager<T extends TreeKeyManagerItem> implements TreeKeyMana
160157
break;
161158
}
162159

163-
// Attempt to use the `event.key` which also maps it to the user's keyboard language,
164-
// otherwise fall back to resolving alphanumeric characters via the keyCode.
165-
if (event.key && event.key.length === 1) {
166-
this._letterKeyStream.next(event.key.toLocaleUpperCase());
167-
} else if ((keyCode >= A && keyCode <= Z) || (keyCode >= ZERO && keyCode <= NINE)) {
168-
this._letterKeyStream.next(String.fromCharCode(keyCode));
169-
}
170-
171-
// NB: return here, in order to avoid preventing the default action of non-navigational
160+
this._typeahead?.handleKey(event);
161+
// Return here, in order to avoid preventing the default action of non-navigational
172162
// keys or resetting the buffer of pressed letters.
173163
return;
174164
}
175165

176166
// Reset the typeahead since the user has used a navigational key.
177-
this._pressedLetters = [];
167+
this._typeahead?.reset();
178168
event.preventDefault();
179169
}
180170

@@ -247,6 +237,7 @@ export class TreeKeyManager<T extends TreeKeyManagerItem> implements TreeKeyMana
247237

248238
this._activeItem = activeItem ?? null;
249239
this._activeItemIndex = index;
240+
this._typeahead?.setCurrentSelectedItemIndex(index);
250241

251242
if (options.emitChangeEvent) {
252243
// Emit to `change` stream as required by TreeKeyManagerStrategy interface.
@@ -270,50 +261,19 @@ export class TreeKeyManager<T extends TreeKeyManagerItem> implements TreeKeyMana
270261

271262
if (newIndex > -1 && newIndex !== this._activeItemIndex) {
272263
this._activeItemIndex = newIndex;
264+
this._typeahead?.setCurrentSelectedItemIndex(newIndex);
273265
}
274266
}
275267

276-
private _setTypeAhead(debounceInterval: number) {
277-
this._typeaheadSubscription.unsubscribe();
278-
279-
if (
280-
(typeof ngDevMode === 'undefined' || ngDevMode) &&
281-
this._items.length &&
282-
this._items.some(item => typeof item.getLabel !== 'function')
283-
) {
284-
throw new Error(
285-
'TreeKeyManager items in typeahead mode must implement the `getLabel` method.',
286-
);
287-
}
288-
289-
// Debounce the presses of non-navigational keys, collect the ones that correspond to letters
290-
// and convert those letters back into a string. Afterwards find the first item that starts
291-
// with that string and select it.
292-
this._typeaheadSubscription = this._letterKeyStream
293-
.pipe(
294-
tap(letter => this._pressedLetters.push(letter)),
295-
debounceTime(debounceInterval),
296-
filter(() => this._pressedLetters.length > 0),
297-
map(() => this._pressedLetters.join('').toLocaleUpperCase()),
298-
)
299-
.subscribe(inputString => {
300-
// Start at 1 because we want to start searching at the item immediately
301-
// following the current active item.
302-
for (let i = 1; i < this._items.length + 1; i++) {
303-
const index = (this._activeItemIndex + i) % this._items.length;
304-
const item = this._items[index];
305-
306-
if (
307-
!this._skipPredicateFn(item) &&
308-
item.getLabel?.().toLocaleUpperCase().trim().indexOf(inputString) === 0
309-
) {
310-
this.focusItem(index);
311-
break;
312-
}
313-
}
268+
private _setTypeAhead(debounceInterval: number | boolean) {
269+
this._typeahead = new Typeahead(this._items, {
270+
debounceInterval: typeof debounceInterval === 'number' ? debounceInterval : undefined,
271+
skipPredicate: item => this._skipPredicateFn(item),
272+
});
314273

315-
this._pressedLetters = [];
316-
});
274+
this._typeaheadSubscription = this._typeahead.selectedItem.subscribe(item => {
275+
this.focusItem(item);
276+
});
317277
}
318278

319279
private _findNextAvailableItemIndex(startingIndex: number) {
@@ -364,10 +324,6 @@ export class TreeKeyManager<T extends TreeKeyManagerItem> implements TreeKeyMana
364324
if (!this._isCurrentItemExpanded()) {
365325
this._activeItem.expand();
366326
} else {
367-
const children = this._activeItem.getChildren();
368-
369-
const children2 = isObservable(children) ? children : observableOf(children);
370-
371327
coerceObservable(this._activeItem.getChildren())
372328
.pipe(take(1))
373329
.subscribe(children => {

0 commit comments

Comments
 (0)