Skip to content

fix(material/tree): add levelAccessor, childrenAccessor, TreeKeyManag… …er; a11y and docs improvements #27626

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 13 commits into from
Closed
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
60 changes: 59 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`.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this sentence also be updated?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure what needs updating here?


Expand Down Expand Up @@ -55,6 +55,64 @@ 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. This is intended to be used with
components that correspond to a `role="tree"` pattern.
Comment on lines +60 to +61
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
`TreeKeyManager` manages the active option in a tree view. This is intended to be used with
components that correspond to a `role="tree"` pattern.
`TreeKeyManager` manages the active option in a tree view. USe this key manager for
components that implement a `role="tree"` pattern.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done


#### Basic usage

Any component that uses a `TreeKeyManager` will generally do three things:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Any component that uses a `TreeKeyManager` will generally do three things:
Any component that uses a `TreeKeyManager` should do three things:

nit: technical docs should already be present tense. Also replaced "generally do" with the stronger "should" (which I think is accurate here)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

* 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` interface:
```ts
interface TreeKeyManagerItem {
/** Whether the item is disabled. */
isDisabled?: (() => boolean) | boolean;

/** The user-facing label for this item. */
getLabel?(): string;

/** Perform the main action (i.e. selection) for this item. */
activate(): void;

/** Retrieves the parent for this item. This is `null` if there is no parent. */
getParent(): TreeKeyManagerItem | null;

/** Retrieves the children for this item. */
getChildren(): TreeKeyManagerItem[] | Observable<TreeKeyManagerItem[]>;

/** Determines if the item is currently expanded. */
isExpanded: (() => boolean) | boolean;

/** Collapses the item, hiding its children. */
collapse(): void;

/** Expands the item, showing its children. */
expand(): void;

/**
* Focuses the item. This should provide some indication to the user that this item is focused.
*/
focus(): void;
}
```

#### Focus management

The `TreeKeyManager` will handle focusing the appropriate item on keyboard interactions. However,
the component should call `onInitialFocus` when the component is focused for the first time (i.e.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you expand on this a bit? What is the tabindex situation before it's focused for the first time? Will that initial focus land on the first item? the wrapper element? I guess I'm wondering where I need to add this call and if there's any special tabindex management I need to do myself before the key manager takes over

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MatTree implements the WAI ARIA Tree View Pattern. We intend to follow the keyboard behaviors in that document:

  • "If none of the nodes are selected before the tree receives focus, focus is set on the first node."
  • "If a node is selected before the tree receives focus, focus is set on the selected node."

TreeKeyManager#onInitialFocus is an implementation detail of achieve this behavior. When MatTree is focused for the very first time, it calls onInitialFocus. If needed, the key manager sets focus to the first node. onInitialFocus is an implementation detail that I think 90% of developers will not need to know about. All most use cases will need to know is that when the tree is focused for the first time, it picks the first node to focus.

If you are making your own key manager, then you need to be familiar with TreeKeyManagerStrategy#onInitialFocus. The contract is that MatTree calls this method the very first time it is focused. It is up to the key manager how to handle this. The default keymanager TreeKeyManagerStrategy implements the WAI ARIA Tree View Pattern (focus first node if no node has ever been focused before).

I don't think onInitialFocus needs to be mentioned in this part of the documentation.

The important part is that MatTree manages it's own focus. Inject a TreeKeyManager to change focus behavior.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

re: tabIndex; currently we do require MatTree (or other tree implementations using the key manager) to manage tabIndex

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is no longer relevant; we handle initial focus internally without API intervention

when there is no active item).

`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 @@ -150,6 +150,7 @@ describe('Key managers', () => {

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this manually calling notifyOnChanges? IIRC, this wasn't meant to be part of the public API of QueryList; tests would generally want to cause change detection instead (though it's possible I'm missing something here)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It does notifyOnChanges instead of change detection because there is no test fixture. This test file doesn't create any kind of test component or use TestBed at all. It's vanilla test, so change detection is not available.

keyManager.setActiveItem(0);

expect(spy).toHaveBeenCalledTimes(1);
Expand Down Expand Up @@ -342,6 +343,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 @@ -367,6 +369,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 @@ -416,6 +419,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 @@ -558,6 +562,7 @@ describe('Key managers', () => {
const items = itemList.toArray();
items[0].disabled = true;
itemList.reset(items);
itemList.notifyOnChanges();

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

keyManager.setLastItemActive();
expect(keyManager.activeItemIndex)
Expand All @@ -602,6 +608,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 @@ -629,6 +636,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 @@ -706,6 +714,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 @@ -730,6 +739,7 @@ describe('Key managers', () => {
const items = itemList.toArray();
items[1].disabled = true;
itemList.reset(items);
itemList.notifyOnChanges();

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

Expand All @@ -744,6 +754,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 @@ -839,6 +850,7 @@ describe('Key managers', () => {
new FakeFocusable('две'),
new FakeFocusable('три'),
]);
itemList.notifyOnChanges();

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

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

keyManager.onKeydown(createKeyboardEvent('keydown', 192, '`')); // types "`"
tick(debounceInterval);
Expand All @@ -874,6 +887,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 @@ -889,6 +903,7 @@ describe('Key managers', () => {
new FakeFocusable('Boromir'),
new FakeFocusable('Aragorn'),
]);
itemList.notifyOnChanges();

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

keyManager.setActiveItem(3);
keyManager.onKeydown(createKeyboardEvent('keydown', 66, 'b'));
Expand Down
78 changes: 19 additions & 59 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,36 +42,35 @@ 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;
private _horizontal: 'ltr' | 'rtl' | null;
private _allowedModifierKeys: ListKeyManagerModifierKey[] = [];
private _homeAndEnd = false;
private _pageUpAndDown = {enabled: false, delta: 10};
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(private _items: QueryList<T> | T[]) {
// We allow for the items to be an array because, in some cases, the consumer may
// not have access to a QueryList of the items they want to manage (e.g. when the
// items aren't being collected via `ViewChildren` or `ContentChildren`).
if (_items instanceof QueryList) {
this._itemChangesSubscription = _items.changes.subscribe((newItems: QueryList<T>) => {
const itemArray = newItems.toArray();
this._typeahead?.setItems(itemArray);
if (this._activeItem) {
const itemArray = newItems.toArray();
const newIndex = itemArray.indexOf(this._activeItem);

if (newIndex > -1 && newIndex !== this._activeItemIndex) {
this._activeItemIndex = newIndex;
this._typeahead?.setCurrentSelectedItemIndex(newIndex);
}
}
});
Expand Down Expand Up @@ -144,53 +139,24 @@ export class ListKeyManager<T extends ListKeyManagerOption> {
* @param debounceInterval Time to wait after the last keystroke before setting the active item.
*/
withTypeAhead(debounceInterval: number = 200): this {
if (
(typeof ngDevMode === 'undefined' || ngDevMode) &&
this._items.length &&
this._items.some(item => typeof item.getLabel !== 'function')
) {
throw Error('ListKeyManager items in typeahead mode must implement the `getLabel` method.');
}

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 @@ -322,21 +288,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 @@ -352,7 +312,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 @@ -397,16 +357,16 @@ 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._letterKeyStream.complete();
this._typeahead?.destroy();
this.tabOut.complete();
this.change.complete();
this._pressedLetters = [];
}

/**
Expand Down
Loading
Loading