Skip to content

Commit 38d2bd5

Browse files
committed
feat(cdk/a11y): implement typeahead (needs test)
1 parent ef8a166 commit 38d2bd5

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
@@ -18,8 +18,17 @@ import {
1818
UP_ARROW,
1919
} from '@angular/cdk/keycodes';
2020
import {QueryList} from '@angular/core';
21-
import {isObservable, of as observableOf, Observable, Subject} from 'rxjs';
22-
import {take} from 'rxjs/operators';
21+
import {
22+
of as observableOf,
23+
isObservable,
24+
Observable,
25+
Subject,
26+
Subscription,
27+
throwError,
28+
} from 'rxjs';
29+
import {debounceTime, filter, map, switchMap, take, tap} from 'rxjs/operators';
30+
31+
const DEFAULT_TYPEAHEAD_DEBOUNCE_INTERVAL_MS = 200;
2332

2433
function coerceObservable<T>(data: T | Observable<T>): Observable<T> {
2534
if (!isObservable(data)) {
@@ -111,6 +120,8 @@ export class TreeKeyManager<T extends TreeKeyManagerItem> {
111120
private _activeItem: T | null = null;
112121
private _activationFollowsFocus = false;
113122
private _horizontal: 'ltr' | 'rtl' = 'ltr';
123+
private readonly _letterKeyStream = new Subject<string>();
124+
private _typeaheadSubscription = Subscription.EMPTY;
114125

115126
/**
116127
* Predicate function that can be used to check whether an item should be skipped
@@ -121,6 +132,9 @@ export class TreeKeyManager<T extends TreeKeyManagerItem> {
121132
/** Function to determine equivalent items. */
122133
private _trackByFn: (item: T) => unknown = (item: T) => item;
123134

135+
/** Buffer for the letters that the user has pressed when the typeahead option is turned on. */
136+
private _pressedLetters: string[] = [];
137+
124138
private _items: T[] = [];
125139

126140
constructor({
@@ -143,6 +157,13 @@ export class TreeKeyManager<T extends TreeKeyManagerItem> {
143157
if (typeof activationFollowsFocus !== 'undefined') {
144158
this._activationFollowsFocus = activationFollowsFocus;
145159
}
160+
if (typeof typeAheadDebounceInterval !== 'undefined') {
161+
this._setTypeAhead(
162+
typeof typeAheadDebounceInterval === 'number'
163+
? typeAheadDebounceInterval
164+
: DEFAULT_TYPEAHEAD_DEBOUNCE_INTERVAL_MS,
165+
);
166+
}
146167

147168
// We allow for the items to be an array or Observable because, in some cases, the consumer may
148169
// not have access to a QueryList of the items they want to manage (e.g. when the
@@ -288,6 +309,57 @@ export class TreeKeyManager<T extends TreeKeyManagerItem> {
288309
}
289310
}
290311

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

293365
private _focusFirstItem() {

0 commit comments

Comments
 (0)