Skip to content

Commit 4aefcc8

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

File tree

4 files changed

+157
-126
lines changed

4 files changed

+157
-126
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/tree-key-manager.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,7 @@ describe('TreeKeyManager', () => {
227227
expect(fakeKeyEvents.tab.defaultPrevented).toBe(false);
228228
});
229229

230-
it('should not do anything for unsupported key presses', () => {
230+
fit('should not do anything for unsupported key presses', () => {
231231
keyManager.focusItem(itemList.get(1)!);
232232

233233
expect(keyManager.getActiveItemIndex()).toBe(1);

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

Lines changed: 20 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,7 @@ import {
3030
TreeKeyManagerOptions,
3131
TreeKeyManagerStrategy,
3232
} from './tree-key-manager-strategy';
33-
34-
const DEFAULT_TYPEAHEAD_DEBOUNCE_INTERVAL_MS = 200;
33+
import {Typeahead} from './typeahead';
3534

3635
function coerceObservable<T>(data: T | Observable<T>): Observable<T> {
3736
if (!isObservable(data)) {
@@ -50,8 +49,6 @@ export class TreeKeyManager<T extends TreeKeyManagerItem> implements TreeKeyMana
5049
private _activeItem: T | null = null;
5150
private _activationFollowsFocus = false;
5251
private _horizontal: 'ltr' | 'rtl' = 'ltr';
53-
private readonly _letterKeyStream = new Subject<string>();
54-
private _typeaheadSubscription = Subscription.EMPTY;
5552

5653
/**
5754
* Predicate function that can be used to check whether an item should be skipped
@@ -62,11 +59,11 @@ export class TreeKeyManager<T extends TreeKeyManagerItem> implements TreeKeyMana
6259
/** Function to determine equivalent items. */
6360
private _trackByFn: (item: T) => unknown = (item: T) => item;
6461

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

64+
private _typeahead?: Typeahead<T>;
65+
private _typeaheadSubscription = Subscription.EMPTY;
66+
7067
constructor(items: Observable<T[]> | QueryList<T> | T[], config: TreeKeyManagerOptions<T>) {
7168
// We allow for the items to be an array or Observable because, in some cases, the consumer may
7269
// not have access to a QueryList of the items they want to manage (e.g. when the
@@ -75,11 +72,13 @@ export class TreeKeyManager<T extends TreeKeyManagerItem> implements TreeKeyMana
7572
this._items = items.toArray();
7673
items.changes.subscribe((newItems: QueryList<T>) => {
7774
this._items = newItems.toArray();
75+
this._typeahead?.setItems(this._items);
7876
this._updateActiveItemIndex(this._items);
7977
});
8078
} else if (isObservable(items)) {
8179
items.subscribe(newItems => {
8280
this._items = newItems;
81+
this._typeahead?.setItems(newItems);
8382
this._updateActiveItemIndex(newItems);
8483
});
8584
} else {
@@ -99,12 +98,7 @@ export class TreeKeyManager<T extends TreeKeyManagerItem> implements TreeKeyMana
9998
this._trackByFn = config.trackBy;
10099
}
101100
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);
101+
this._setTypeAhead(config.typeAheadDebounceInterval);
108102
}
109103
}
110104

@@ -160,21 +154,14 @@ export class TreeKeyManager<T extends TreeKeyManagerItem> implements TreeKeyMana
160154
break;
161155
}
162156

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
157+
this._typeahead?.handleKey(event);
158+
// Return here, in order to avoid preventing the default action of non-navigational
172159
// keys or resetting the buffer of pressed letters.
173160
return;
174161
}
175162

176163
// Reset the typeahead since the user has used a navigational key.
177-
this._pressedLetters = [];
164+
this._typeahead?.reset();
178165
event.preventDefault();
179166
}
180167

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

248235
this._activeItem = activeItem ?? null;
249236
this._activeItemIndex = index;
237+
this._typeahead?.setCurrentSelectedItemIndex(index);
250238

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

271259
if (newIndex > -1 && newIndex !== this._activeItemIndex) {
272260
this._activeItemIndex = newIndex;
261+
this._typeahead?.setCurrentSelectedItemIndex(newIndex);
273262
}
274263
}
275264

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-
}
265+
private _setTypeAhead(debounceInterval: number | boolean) {
266+
this._typeahead = new Typeahead(this._items, {
267+
debounceInterval: typeof debounceInterval === 'number' ? debounceInterval : undefined,
268+
skipPredicate: item => this._skipPredicateFn(item),
269+
});
314270

315-
this._pressedLetters = [];
316-
});
271+
this._typeaheadSubscription = this._typeahead.selectedItem.subscribe(item => {
272+
this.focusItem(item);
273+
});
317274
}
318275

319276
private _findNextAvailableItemIndex(startingIndex: number) {
@@ -364,10 +321,6 @@ export class TreeKeyManager<T extends TreeKeyManagerItem> implements TreeKeyMana
364321
if (!this._isCurrentItemExpanded()) {
365322
this._activeItem.expand();
366323
} else {
367-
const children = this._activeItem.getChildren();
368-
369-
const children2 = isObservable(children) ? children : observableOf(children);
370-
371324
coerceObservable(this._activeItem.getChildren())
372325
.pipe(take(1))
373326
.subscribe(children => {

0 commit comments

Comments
 (0)