Skip to content

Commit ff36c80

Browse files
authored
fix(material/tree): add levelAccessor, childrenAccessor, TreeKeyManager; a11y and docs improvements (#29062)
Update multiple facets of Tree component. Add APIs to manage data models, improve existing behaviors, add keyboard functionality and update documentation. Add APIs options to the Tree data model by introducing levelAccessor and childrenAccessor. See “Api Addition” for usage. Currently, Tree component use TreeControl to manage data model. When applied, add levelAccessor and childrenAccessor functions as alternatives to TreeControl. Add TreeKeyManager, which provides keyboard functionality. Currently Tree component allows developers to manage focus by setting tabindex on each tree node. When applied, Tree manages its own focus using key manager pattern. Keyboard commands match [WAI ARIA Tree View Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/treeview/). See “Deprecated” for adopting changes to existing applications. Correct the ARIA semantics of Tree and Tree node components. Document updated APIs and behaviors. Refine documentation of existing APIs and behaviors. Changes to Cdk Tree API also apply to Mat Tree API. See “Deprecated” for adopting changes to existing applications. Accessibility: * add CdkTreeKeyManager to provide keyboard navigation for CdkTree and MatTree * Improve keyboard usability of CdkTreeNodeToggle. * Improve ARIA semantics of CdkTree, CdkTreeNode, Tree and TreeNode components * Fix miscellaneous accessibility issues in tree and cdk-tree examples * Add accessibility instructions to documentation Documentation: * Add API and usage examples for TreeKeyManager * Update @angular/cdk/tree and @angular/material/tree to be more consistent * Update examples to use levelAccessor and childrenAccessor * Add example for (activation) on MatTreeNode and CdkTreeNode API ADDITION: add CdkTree#childrenAccessor and CdkTree#levelAccessor * Add CdkTree#childrenAccessor. Given a data node, childrenAccessor determines the children of that node. * Add CdkTree#levelAccessor. Given a data node, levelAccessor determines the level of the node in the parent hierarchy. * CdkTreeNode#levelAcessor and CdkTreeNode#childrenAccessor replace CdkTreeNode#treeControl. See “Deprecated” for updating apps using treeControl. API ADDITION: control expanded state of tree nodes using isExpandable and isExpanded * Add CdkTreeNode#isExpandable, determines if argument tree node can be expanded or collapsed. * CdkTreeNode#isExpanded to specify the expanded state. Has no effect if node is not expandable. * Add NestedTreeControlOptions#isExpandable function, determines if argument tree node can be expanded or collapsed. For trees using treeControl, recommend providing isExpandable if not already provided. See “Deprecated” for more information on updating applications. API ADDITION: use CdkTree to manage expansion state * Add CdkTree#isExpanded method. * Add CdkTree#toggle, CdkTree#expand and CdkTree#collapse methods. * Add CdkTree#toggleDescendants, CdkTree#expandDescendants, and CdkTree#collapseDescendants methods to CdkTree * Add CdkTree#expandAll and CdkTree#collapseAll methods * Add expandedChange Output to CdkTreeNode API ADDITION: add injection token for tree-key-manager * Add TREE_KEY_MANAGER injection token. When provided, tree uses given key manager * TreeKeyManagerStrategy interface, which defines API contract of TREE_KEY_MANAGER API ADDITION: add CdkTreeNode#cdkTreeNodeTypeaheadLabel and CdkTreeNode#getLabel * Add CdkTree#cdkTreeNodeTypeaheadLabel. This is an Input that specifies the label used for typeahead * Add CdkTree#getLabel. Gets the typeahead label for this node. BEHAVIOR CHANGE: MatTree and CdkTree components respond to keyboard navigation. * CdkTree and MatTree respond to arrow keys, page up, page down, etc.; Keyboard commands match [WAI ARIA Tree View Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/treeview/). * Can no longer set the tabindex on MatTreeNode. See “Deprecated” for adopting existing applications. * Add TreeKeyManager to cdk/a11y DEPRECATED: Tree controller deprecated. Use one of levelAccessor or childrenAccessor instead. To be removed in a future version. * BaseTreeControl, TreeControl, FlatTreeControl, and NestedTreeControl deprecated * CdkTree#treeControl deprecated. Provide one of CdkTree#levelAccessor or CdkTree#childrenAccessor instead. * MatTreeFlattener deprecated. Use MatTree#childrenAccessor and MatTreeNode#isExpandable instead. * MatTreeFlatDataSource deprecated. Use one of levelAccessor or childrenAccessor instead of TreeControl. Note when upgrading: isExpandable works differently on Trees using treeControl than trees using childrenAccessor or levelAccessor. Nodes on trees that have a treeControl are expandable by default. Nodes on trees using childrenAccessor or levelAccessor are *not* expandable by default. Provide isExpandable to override default behavior. DEPRECATED: Setting tabindex of tree nodes deprecated. By default, Tree ignores tabindex passed to tree nodes. * MatTreeNode#tabIndex deprecated. MatTreeNode ignores Input tabIndex and manages its own focus behavior. * MatTreeNode#defaultTabIndex deprecated. MatTreeNode ignores defaultTabIndex and manages its own focus behavior. * MatNestedTreeNode#tabIndex deprecated. MatTreeNode ignores Input defaultTabIndex and manages its own focus behavior. * LegacyTreeKeyManager and LEGACY_TREE_KEY_MANAGER_FACTORY_PROVIDER deprecated. Inject a TreeKeyManagerFactory to customize keyboard behavior. Note when upgrading: an opt-out is available for keyboard functionality changes. Provide LEGACY_TREE_KEY_MANAGER_FACTORY_PROVIDER to opt-out of Tree managing its own focus. When provided, Tree does not manage it’s own focus and respects tabindex passed to TreeNode. When provided, have the same focus behavior as before this commit is applied. Add Legacy Keyboard Interface demo, which shows usage of LEGACY_TREE_KEY_MANAGER_FACTORY_PROVIDER. Add Custom Key Manager, which shows usage of injecting a TreeKeyManagerStrategy DEPRECATED: disabled renamed to isDisabled. * CdkTreeNode#disabled deprecated and alias to CdkTreeNode#isDisabled
1 parent b116643 commit ff36c80

File tree

85 files changed

+9196
-765
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

85 files changed

+9196
-765
lines changed

src/cdk/a11y/BUILD.bazel

+1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ ng_module(
1919
deps = [
2020
"//src:dev_mode_types",
2121
"//src/cdk/coercion",
22+
"//src/cdk/coercion/private",
2223
"//src/cdk/keycodes",
2324
"//src/cdk/layout",
2425
"//src/cdk/observers",

src/cdk/a11y/a11y.md

+25-1
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ Navigation through options can be made to wrap via the `withWrap` method
2727
this.keyManager = new FocusKeyManager(...).withWrap();
2828
```
2929

30-
#### Types of key managers
30+
#### Types of list key managers
3131

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

@@ -55,6 +55,30 @@ interface Highlightable extends ListKeyManagerOption {
5555

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

58+
### TreeKeyManager
59+
60+
`TreeKeyManager` manages the active option in a tree view. Use this key manager for
61+
components that implement a `role="tree"` pattern.
62+
63+
#### Basic usage
64+
65+
Any component that uses a `TreeKeyManager` should do three things:
66+
* Create a `@ViewChildren` query for the tree items being managed.
67+
* Initialize the `TreeKeyManager`, passing in the options.
68+
* Forward keyboard events from the managed component to the `TreeKeyManager` via `onKeydown`.
69+
70+
Each tree item should implement the [`TreeKeyManagerItem`](/cdk/a11y/api#TreeKeyManagerItem) interface.
71+
72+
#### Focus management
73+
74+
The `TreeKeyManager` will handle focusing the appropriate item on keyboard interactions.
75+
76+
`tabindex` should also be set by the component when the active item changes. This can be listened to
77+
via the `change` property on the `TreeKeyManager`. In particular, the tree should only have a
78+
`tabindex` set if there is no active item, and should not have a `tabindex` set if there is an
79+
active item. Only the HTML node corresponding to the active item should have a `tabindex` set to
80+
`0`, with all other items set to `-1`.
81+
5882

5983
### FocusTrap
6084

src/cdk/a11y/key-manager/list-key-manager.spec.ts

+16
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ describe('Key managers', () => {
177177

178178
keyManager.setActiveItem(0);
179179
itemList.reset([new FakeFocusable('zero'), ...itemList.toArray()]);
180+
itemList.notifyOnChanges();
180181
keyManager.setActiveItem(0);
181182

182183
expect(spy).toHaveBeenCalledTimes(1);
@@ -369,6 +370,7 @@ describe('Key managers', () => {
369370
const items = itemList.toArray();
370371
items[1].disabled = true;
371372
itemList.reset(items);
373+
itemList.notifyOnChanges();
372374

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

398401
keyManager.onKeydown(this.nextKeyEvent);
399402
expect(keyManager.activeItemIndex)
@@ -443,6 +446,7 @@ describe('Key managers', () => {
443446
const items = itemList.toArray();
444447
items[2].disabled = true;
445448
itemList.reset(items);
449+
itemList.notifyOnChanges();
446450

447451
keyManager.onKeydown(this.nextKeyEvent);
448452
expect(keyManager.activeItemIndex)
@@ -585,6 +589,7 @@ describe('Key managers', () => {
585589
const items = itemList.toArray();
586590
items[0].disabled = true;
587591
itemList.reset(items);
592+
itemList.notifyOnChanges();
588593

589594
keyManager.setFirstItemActive();
590595
expect(keyManager.activeItemIndex)
@@ -607,6 +612,7 @@ describe('Key managers', () => {
607612
const items = itemList.toArray();
608613
items[2].disabled = true;
609614
itemList.reset(items);
615+
itemList.notifyOnChanges();
610616

611617
keyManager.setLastItemActive();
612618
expect(keyManager.activeItemIndex)
@@ -629,6 +635,7 @@ describe('Key managers', () => {
629635
const items = itemList.toArray();
630636
items[1].disabled = true;
631637
itemList.reset(items);
638+
itemList.notifyOnChanges();
632639

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

660668
keyManager.onKeydown(fakeKeyEvents.downArrow);
661669
keyManager.onKeydown(fakeKeyEvents.downArrow);
@@ -733,6 +741,7 @@ describe('Key managers', () => {
733741
const items = itemList.toArray();
734742
items.forEach(item => (item.disabled = true));
735743
itemList.reset(items);
744+
itemList.notifyOnChanges();
736745

737746
keyManager.onKeydown(fakeKeyEvents.downArrow);
738747
});
@@ -757,6 +766,7 @@ describe('Key managers', () => {
757766
const items = itemList.toArray();
758767
items[1].disabled = true;
759768
itemList.reset(items);
769+
itemList.notifyOnChanges();
760770

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

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

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

@@ -866,6 +877,7 @@ describe('Key managers', () => {
866877
new FakeFocusable('две'),
867878
new FakeFocusable('три'),
868879
]);
880+
itemList.notifyOnChanges();
869881

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

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

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

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

920935
keyManager.setActiveItem(1);
921936
keyManager.onKeydown(createKeyboardEvent('keydown', 66, 'b'));
@@ -932,6 +947,7 @@ describe('Key managers', () => {
932947
new FakeFocusable('Boromir'),
933948
new FakeFocusable('Aragorn'),
934949
]);
950+
itemList.notifyOnChanges();
935951

936952
keyManager.setActiveItem(3);
937953
keyManager.onKeydown(createKeyboardEvent('keydown', 66, 'b'));

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

+18-50
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,7 +42,6 @@ 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;
@@ -55,16 +50,14 @@ export class ListKeyManager<T extends ListKeyManagerOption> {
5550
private _homeAndEnd = false;
5651
private _pageUpAndDown = {enabled: false, delta: 10};
5752
private _effectRef: EffectRef | undefined;
53+
private _typeahead?: Typeahead<T>;
5854

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

65-
// Buffer for the letters that the user has pressed when the typeahead option is turned on.
66-
private _pressedLetters: string[] = [];
67-
6861
constructor(items: QueryList<T> | T[] | readonly T[]);
6962
constructor(items: Signal<T[]> | Signal<readonly T[]>, injector: Injector);
7063
constructor(
@@ -158,43 +151,22 @@ export class ListKeyManager<T extends ListKeyManagerOption> {
158151

159152
this._typeaheadSubscription.unsubscribe();
160153

161-
// Debounce the presses of non-navigational keys, collect the ones that correspond to letters
162-
// and convert those letters back into a string. Afterwards find the first item that starts
163-
// with that string and select it.
164-
this._typeaheadSubscription = this._letterKeyStream
165-
.pipe(
166-
tap(letter => this._pressedLetters.push(letter)),
167-
debounceTime(debounceInterval),
168-
filter(() => this._pressedLetters.length > 0),
169-
map(() => this._pressedLetters.join('')),
170-
)
171-
.subscribe(inputString => {
172-
const items = this._getItemsArray();
173-
174-
// Start at 1 because we want to start searching at the item immediately
175-
// following the current active item.
176-
for (let i = 1; i < items.length + 1; i++) {
177-
const index = (this._activeItemIndex + i) % items.length;
178-
const item = items[index];
179-
180-
if (
181-
!this._skipPredicateFn(item) &&
182-
item.getLabel!().toUpperCase().trim().indexOf(inputString) === 0
183-
) {
184-
this.setActiveItem(index);
185-
break;
186-
}
187-
}
154+
const items = this._getItemsArray();
155+
this._typeahead = new Typeahead(items, {
156+
debounceInterval: typeof debounceInterval === 'number' ? debounceInterval : undefined,
157+
skipPredicate: item => this._skipPredicateFn(item),
158+
});
188159

189-
this._pressedLetters = [];
190-
});
160+
this._typeaheadSubscription = this._typeahead.selectedItem.subscribe(item => {
161+
this.setActiveItem(item);
162+
});
191163

192164
return this;
193165
}
194166

195167
/** Cancels the current typeahead sequence. */
196168
cancelTypeahead(): this {
197-
this._pressedLetters = [];
169+
this._typeahead?.reset();
198170
return this;
199171
}
200172

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

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

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

343-
this._pressedLetters = [];
309+
this._typeahead?.reset();
344310
event.preventDefault();
345311
}
346312

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

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

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

406373
/** Cleans up the key manager. */
407374
destroy() {
408375
this._typeaheadSubscription.unsubscribe();
409376
this._itemChangesSubscription?.unsubscribe();
410377
this._effectRef?.destroy();
411-
this._letterKeyStream.complete();
378+
this._typeahead?.destroy();
412379
this.tabOut.complete();
413380
this.change.complete();
414-
this._pressedLetters = [];
415381
}
416382

417383
/**
@@ -485,11 +451,13 @@ export class ListKeyManager<T extends ListKeyManagerOption> {
485451

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

491458
if (newIndex > -1 && newIndex !== this._activeItemIndex) {
492459
this._activeItemIndex = newIndex;
460+
this._typeahead?.setCurrentSelectedItemIndex(newIndex);
493461
}
494462
}
495463
}

0 commit comments

Comments
 (0)