Skip to content

Commit aa57c91

Browse files
committed
feat(cdk/a11y): implement typeahead (needs test)
1 parent 69903ce commit aa57c91

File tree

1 file changed

+74
-2
lines changed

1 file changed

+74
-2
lines changed

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

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,17 @@ import {
2222
ZERO,
2323
} from '@angular/cdk/keycodes';
2424
import {QueryList} from '@angular/core';
25-
import {isObservable, of as observableOf, Observable, Subject} from 'rxjs';
26-
import {take} from 'rxjs/operators';
25+
import {
26+
of as observableOf,
27+
isObservable,
28+
Observable,
29+
Subject,
30+
Subscription,
31+
throwError,
32+
} from 'rxjs';
33+
import {debounceTime, filter, map, switchMap, take, tap} from 'rxjs/operators';
34+
35+
const DEFAULT_TYPEAHEAD_DEBOUNCE_INTERVAL_MS = 200;
2736

2837
function coerceObservable<T>(data: T | Observable<T>): Observable<T> {
2938
if (!isObservable(data)) {
@@ -115,6 +124,8 @@ export class TreeKeyManager<T extends TreeKeyManagerItem> {
115124
private _activeItem: T | null = null;
116125
private _activationFollowsFocus = false;
117126
private _horizontal: 'ltr' | 'rtl' = 'ltr';
127+
private readonly _letterKeyStream = new Subject<string>();
128+
private _typeaheadSubscription = Subscription.EMPTY;
118129

119130
/**
120131
* Predicate function that can be used to check whether an item should be skipped
@@ -125,6 +136,9 @@ export class TreeKeyManager<T extends TreeKeyManagerItem> {
125136
/** Function to determine equivalent items. */
126137
private _trackByFn: (item: T) => unknown = (item: T) => item;
127138

139+
/** Buffer for the letters that the user has pressed when the typeahead option is turned on. */
140+
private _pressedLetters: string[] = [];
141+
128142
private _items: T[] = [];
129143

130144
constructor({
@@ -147,6 +161,13 @@ export class TreeKeyManager<T extends TreeKeyManagerItem> {
147161
if (typeof activationFollowsFocus !== 'undefined') {
148162
this._activationFollowsFocus = activationFollowsFocus;
149163
}
164+
if (typeof typeAheadDebounceInterval !== 'undefined') {
165+
this._setTypeAhead(
166+
typeof typeAheadDebounceInterval === 'number'
167+
? typeAheadDebounceInterval
168+
: DEFAULT_TYPEAHEAD_DEBOUNCE_INTERVAL_MS,
169+
);
170+
}
150171

151172
// We allow for the items to be an array or Observable because, in some cases, the consumer may
152173
// not have access to a QueryList of the items they want to manage (e.g. when the
@@ -292,6 +313,57 @@ export class TreeKeyManager<T extends TreeKeyManagerItem> {
292313
}
293314
}
294315

316+
private _setTypeAhead(debounceInterval: number) {
317+
this._typeaheadSubscription.unsubscribe();
318+
319+
// Debounce the presses of non-navigational keys, collect the ones that correspond to letters
320+
// and convert those letters back into a string. Afterwards find the first item that starts
321+
// with that string and select it.
322+
this._typeaheadSubscription = this._getItems()
323+
.pipe(
324+
switchMap(items => {
325+
if (
326+
(typeof ngDevMode === 'undefined' || ngDevMode) &&
327+
items.length &&
328+
items.some(item => typeof item.getLabel !== 'function')
329+
) {
330+
return throwError(
331+
new Error(
332+
'TreeKeyManager items in typeahead mode must implement the `getLabel` method.',
333+
),
334+
);
335+
}
336+
return observableOf(items) as Observable<Array<T & {getLabel(): string}>>;
337+
}),
338+
switchMap(items => {
339+
return this._letterKeyStream.pipe(
340+
tap(letter => this._pressedLetters.push(letter)),
341+
debounceTime(debounceInterval),
342+
filter(() => this._pressedLetters.length > 0),
343+
map(() => [this._pressedLetters.join(''), items] as const),
344+
);
345+
}),
346+
)
347+
.subscribe(([inputString, items]) => {
348+
// Start at 1 because we want to start searching at the item immediately
349+
// following the current active item.
350+
for (let i = 1; i < items.length + 1; i++) {
351+
const index = (this._activeItemIndex + i) % items.length;
352+
const item = items[index];
353+
354+
if (
355+
!this._skipPredicateFn(item) &&
356+
item.getLabel().toUpperCase().trim().indexOf(inputString) === 0
357+
) {
358+
this._setActiveItem(index);
359+
break;
360+
}
361+
}
362+
363+
this._pressedLetters = [];
364+
});
365+
}
366+
295367
//// Navigational methods
296368

297369
private _focusFirstItem() {

0 commit comments

Comments
 (0)