Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/cdk/a11y/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ ng_module(
deps = [
"//src:dev_mode_types",
"//src/cdk/coercion",
"//src/cdk/coercion/private",
"//src/cdk/keycodes",
"//src/cdk/layout",
"//src/cdk/observers",
Expand Down
26 changes: 25 additions & 1 deletion src/cdk/a11y/a11y.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ Navigation through options can be made to wrap via the `withWrap` method
this.keyManager = new FocusKeyManager(...).withWrap();
```

#### Types of key managers
#### Types of list key managers

There are two varieties of `ListKeyManager`, `FocusKeyManager` and `ActiveDescendantKeyManager`.

Expand Down Expand Up @@ -55,6 +55,30 @@ interface Highlightable extends ListKeyManagerOption {

Each item must also have an ID bound to the listbox's or menu's `aria-activedescendant`.

### TreeKeyManager

`TreeKeyManager` manages the active option in a tree view. Use this key manager for
components that implement a `role="tree"` pattern.

#### Basic usage

Any component that uses a `TreeKeyManager` should do three things:
* Create a `@ViewChildren` query for the tree items being managed.
* Initialize the `TreeKeyManager`, passing in the options.
* Forward keyboard events from the managed component to the `TreeKeyManager` via `onKeydown`.

Each tree item should implement the [`TreeKeyManagerItem`](/cdk/a11y/api#TreeKeyManagerItem) interface.

#### Focus management

The `TreeKeyManager` will handle focusing the appropriate item on keyboard interactions.

`tabindex` should also be set by the component when the active item changes. This can be listened to
via the `change` property on the `TreeKeyManager`. In particular, the tree should only have a
`tabindex` set if there is no active item, and should not have a `tabindex` set if there is an
active item. Only the HTML node corresponding to the active item should have a `tabindex` set to
`0`, with all other items set to `-1`.


### FocusTrap

Expand Down
16 changes: 16 additions & 0 deletions src/cdk/a11y/key-manager/list-key-manager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ describe('Key managers', () => {

keyManager.setActiveItem(0);
itemList.reset([new FakeFocusable('zero'), ...itemList.toArray()]);
itemList.notifyOnChanges();
keyManager.setActiveItem(0);

expect(spy).toHaveBeenCalledTimes(1);
Expand Down Expand Up @@ -369,6 +370,7 @@ describe('Key managers', () => {
const items = itemList.toArray();
items[1].disabled = true;
itemList.reset(items);
itemList.notifyOnChanges();

// Next event should skip past disabled item from 0 to 2
keyManager.onKeydown(this.nextKeyEvent);
Expand All @@ -394,6 +396,7 @@ describe('Key managers', () => {
items[1].disabled = undefined;
items[2].disabled = undefined;
itemList.reset(items);
itemList.notifyOnChanges();

keyManager.onKeydown(this.nextKeyEvent);
expect(keyManager.activeItemIndex)
Expand Down Expand Up @@ -443,6 +446,7 @@ describe('Key managers', () => {
const items = itemList.toArray();
items[2].disabled = true;
itemList.reset(items);
itemList.notifyOnChanges();

keyManager.onKeydown(this.nextKeyEvent);
expect(keyManager.activeItemIndex)
Expand Down Expand Up @@ -585,6 +589,7 @@ describe('Key managers', () => {
const items = itemList.toArray();
items[0].disabled = true;
itemList.reset(items);
itemList.notifyOnChanges();

keyManager.setFirstItemActive();
expect(keyManager.activeItemIndex)
Expand All @@ -607,6 +612,7 @@ describe('Key managers', () => {
const items = itemList.toArray();
items[2].disabled = true;
itemList.reset(items);
itemList.notifyOnChanges();

keyManager.setLastItemActive();
expect(keyManager.activeItemIndex)
Expand All @@ -629,6 +635,7 @@ describe('Key managers', () => {
const items = itemList.toArray();
items[1].disabled = true;
itemList.reset(items);
itemList.notifyOnChanges();

expect(keyManager.activeItemIndex)
.withContext(`Expected first item of the list to be active.`)
Expand Down Expand Up @@ -656,6 +663,7 @@ describe('Key managers', () => {
const items = itemList.toArray();
items[1].disabled = true;
itemList.reset(items);
itemList.notifyOnChanges();

keyManager.onKeydown(fakeKeyEvents.downArrow);
keyManager.onKeydown(fakeKeyEvents.downArrow);
Expand Down Expand Up @@ -733,6 +741,7 @@ describe('Key managers', () => {
const items = itemList.toArray();
items.forEach(item => (item.disabled = true));
itemList.reset(items);
itemList.notifyOnChanges();

keyManager.onKeydown(fakeKeyEvents.downArrow);
});
Expand All @@ -757,6 +766,7 @@ describe('Key managers', () => {
const items = itemList.toArray();
items[1].disabled = true;
itemList.reset(items);
itemList.notifyOnChanges();

expect(keyManager.activeItemIndex).toBe(0);

Expand All @@ -771,6 +781,7 @@ describe('Key managers', () => {
const items = itemList.toArray();
items[1].skipItem = true;
itemList.reset(items);
itemList.notifyOnChanges();

expect(keyManager.activeItemIndex).toBe(0);

Expand Down Expand Up @@ -866,6 +877,7 @@ describe('Key managers', () => {
new FakeFocusable('две'),
new FakeFocusable('три'),
]);
itemList.notifyOnChanges();

const keyboardEvent = createKeyboardEvent('keydown', 68, 'д');

Expand All @@ -881,6 +893,7 @@ describe('Key managers', () => {
new FakeFocusable('321'),
new FakeFocusable('`!?'),
]);
itemList.notifyOnChanges();

keyManager.onKeydown(createKeyboardEvent('keydown', 192, '`')); // types "`"
tick(debounceInterval);
Expand All @@ -901,6 +914,7 @@ describe('Key managers', () => {
const items = itemList.toArray();
items[0].disabled = true;
itemList.reset(items);
itemList.notifyOnChanges();

keyManager.onKeydown(createKeyboardEvent('keydown', 79, 'o')); // types "o"
tick(debounceInterval);
Expand All @@ -916,6 +930,7 @@ describe('Key managers', () => {
new FakeFocusable('Boromir'),
new FakeFocusable('Aragorn'),
]);
itemList.notifyOnChanges();

keyManager.setActiveItem(1);
keyManager.onKeydown(createKeyboardEvent('keydown', 66, 'b'));
Expand All @@ -932,6 +947,7 @@ describe('Key managers', () => {
new FakeFocusable('Boromir'),
new FakeFocusable('Aragorn'),
]);
itemList.notifyOnChanges();

keyManager.setActiveItem(3);
keyManager.onKeydown(createKeyboardEvent('keydown', 66, 'b'));
Expand Down
68 changes: 18 additions & 50 deletions src/cdk/a11y/key-manager/list-key-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,13 @@ import {
LEFT_ARROW,
RIGHT_ARROW,
TAB,
A,
Z,
ZERO,
NINE,
hasModifierKey,
HOME,
END,
PAGE_UP,
PAGE_DOWN,
} from '@angular/cdk/keycodes';
import {debounceTime, filter, map, tap} from 'rxjs/operators';
import {Typeahead} from './typeahead';

/** This interface is for items that can be passed to a ListKeyManager. */
export interface ListKeyManagerOption {
Expand All @@ -46,7 +42,6 @@ export class ListKeyManager<T extends ListKeyManagerOption> {
private _activeItemIndex = -1;
private _activeItem: T | null = null;
private _wrap = false;
private readonly _letterKeyStream = new Subject<string>();
private _typeaheadSubscription = Subscription.EMPTY;
private _itemChangesSubscription?: Subscription;
private _vertical = true;
Expand All @@ -55,16 +50,14 @@ export class ListKeyManager<T extends ListKeyManagerOption> {
private _homeAndEnd = false;
private _pageUpAndDown = {enabled: false, delta: 10};
private _effectRef: EffectRef | undefined;
private _typeahead?: Typeahead<T>;

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

// Buffer for the letters that the user has pressed when the typeahead option is turned on.
private _pressedLetters: string[] = [];

constructor(items: QueryList<T> | T[] | readonly T[]);
constructor(items: Signal<T[]> | Signal<readonly T[]>, injector: Injector);
constructor(
Expand Down Expand Up @@ -158,43 +151,22 @@ export class ListKeyManager<T extends ListKeyManagerOption> {

this._typeaheadSubscription.unsubscribe();

// Debounce the presses of non-navigational keys, collect the ones that correspond to letters
// and convert those letters back into a string. Afterwards find the first item that starts
// with that string and select it.
this._typeaheadSubscription = this._letterKeyStream
.pipe(
tap(letter => this._pressedLetters.push(letter)),
debounceTime(debounceInterval),
filter(() => this._pressedLetters.length > 0),
map(() => this._pressedLetters.join('')),
)
.subscribe(inputString => {
const items = this._getItemsArray();

// Start at 1 because we want to start searching at the item immediately
// following the current active item.
for (let i = 1; i < items.length + 1; i++) {
const index = (this._activeItemIndex + i) % items.length;
const item = items[index];

if (
!this._skipPredicateFn(item) &&
item.getLabel!().toUpperCase().trim().indexOf(inputString) === 0
) {
this.setActiveItem(index);
break;
}
}
const items = this._getItemsArray();
this._typeahead = new Typeahead(items, {
debounceInterval: typeof debounceInterval === 'number' ? debounceInterval : undefined,
skipPredicate: item => this._skipPredicateFn(item),
});

this._pressedLetters = [];
});
this._typeaheadSubscription = this._typeahead.selectedItem.subscribe(item => {
this.setActiveItem(item);
});

return this;
}

/** Cancels the current typeahead sequence. */
cancelTypeahead(): this {
this._pressedLetters = [];
this._typeahead?.reset();
return this;
}

Expand Down Expand Up @@ -326,21 +298,15 @@ export class ListKeyManager<T extends ListKeyManagerOption> {

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

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

this._pressedLetters = [];
this._typeahead?.reset();
event.preventDefault();
}

Expand All @@ -356,7 +322,7 @@ export class ListKeyManager<T extends ListKeyManagerOption> {

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

/** Sets the active item to the first enabled item in the list. */
Expand Down Expand Up @@ -401,17 +367,17 @@ export class ListKeyManager<T extends ListKeyManagerOption> {
// Explicitly check for `null` and `undefined` because other falsy values are valid.
this._activeItem = activeItem == null ? null : activeItem;
this._activeItemIndex = index;
this._typeahead?.setCurrentSelectedItemIndex(index);
}

/** Cleans up the key manager. */
destroy() {
this._typeaheadSubscription.unsubscribe();
this._itemChangesSubscription?.unsubscribe();
this._effectRef?.destroy();
this._letterKeyStream.complete();
this._typeahead?.destroy();
this.tabOut.complete();
this.change.complete();
this._pressedLetters = [];
}

/**
Expand Down Expand Up @@ -485,11 +451,13 @@ export class ListKeyManager<T extends ListKeyManagerOption> {

/** Callback for when the items have changed. */
private _itemsChanged(newItems: T[] | readonly T[]) {
this._typeahead?.setItems(newItems);
if (this._activeItem) {
const newIndex = newItems.indexOf(this._activeItem);

if (newIndex > -1 && newIndex !== this._activeItemIndex) {
this._activeItemIndex = newIndex;
this._typeahead?.setCurrentSelectedItemIndex(newIndex);
}
}
}
Expand Down
Loading