Skip to content

Commit e816828

Browse files
authored
feat(cdk/tree): general bug fixes, error handling, updating examples & docs (#27305)
* feat(cdk/a11y): add some missing focus functions to TreeKeyManager, fix tests * feat(cdk/tree): report an error when the API consumer tries to expand a non-expandable node. * fix(cdk/tree): set node role through component host * fix(material/tree): fix duplicate keydown events * fix(cdk/tree): make keyboard behaviour consistent across all configurations This also removes the need for specifying `nodeType` manually. * fix(cdk/tree): remove unnecessary change detection * fix(cdk/tree): update API goldens * refactor(cdk/tree): organize imports * fix(cdk/a11y): update API goldens * fix(cdk/tree): remove `_preFlattenedNodes` * fix(cdk/tree): lint * fix(cdk/tree): use `findIndex` instead of `indexOf`; fixes inconsistent aria-posinset * feat(cdk/tree): add complex redux-like demo * fix(cdk/tree): refactor rendering pipeline This also refactors the parent/level/group data processing in order to make it significantly more consistent in all tree configurations. * feat(cdk/tree): update tree documentation * feat(cdk/a11y): update docs for `TreeKeyManager`. * fix(cdk/tree): update API goldens, fix lint errors * fix(cdk/tree): empty commit; retry ci actions
1 parent e000372 commit e816828

File tree

23 files changed

+1256
-286
lines changed

23 files changed

+1256
-286
lines changed

src/cdk/a11y/a11y.md

Lines changed: 59 additions & 1 deletion
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,64 @@ 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. This is intended to be used with
61+
components that correspond to a `role="tree"` pattern.
62+
63+
#### Basic usage
64+
65+
Any component that uses a `TreeKeyManager` will generally 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` interface:
71+
```ts
72+
interface TreeKeyManagerItem {
73+
/** Whether the item is disabled. */
74+
isDisabled?: (() => boolean) | boolean;
75+
76+
/** The user-facing label for this item. */
77+
getLabel?(): string;
78+
79+
/** Perform the main action (i.e. selection) for this item. */
80+
activate(): void;
81+
82+
/** Retrieves the parent for this item. This is `null` if there is no parent. */
83+
getParent(): TreeKeyManagerItem | null;
84+
85+
/** Retrieves the children for this item. */
86+
getChildren(): TreeKeyManagerItem[] | Observable<TreeKeyManagerItem[]>;
87+
88+
/** Determines if the item is currently expanded. */
89+
isExpanded: (() => boolean) | boolean;
90+
91+
/** Collapses the item, hiding its children. */
92+
collapse(): void;
93+
94+
/** Expands the item, showing its children. */
95+
expand(): void;
96+
97+
/**
98+
* Focuses the item. This should provide some indication to the user that this item is focused.
99+
*/
100+
focus(): void;
101+
}
102+
```
103+
104+
#### Focus management
105+
106+
The `TreeKeyManager` will handle focusing the appropriate item on keyboard interactions. However,
107+
the component should call `onInitialFocus` when the component is focused for the first time (i.e.
108+
when there is no active item).
109+
110+
`tabindex` should also be set by the component when the active item changes. This can be listened to
111+
via the `change` property on the `TreeKeyManager`. In particular, the tree should only have a
112+
`tabindex` set if there is no active item, and should not have a `tabindex` set if there is an
113+
active item. Only the HTML node corresponding to the active item should have a `tabindex` set to
114+
`0`, with all other items set to `-1`.
115+
58116

59117
### FocusTrap
60118

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

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ class FakeBaseTreeKeyManagerItem {
2323
_children: FakeBaseTreeKeyManagerItem[] = [];
2424

2525
isDisabled?: boolean = false;
26+
skipItem?: boolean = false;
2627

2728
constructor(private _label: string) {}
2829

@@ -263,13 +264,33 @@ describe('TreeKeyManager', () => {
263264
expect(keyManager.getActiveItemIndex()).toBe(0);
264265
});
265266

267+
it('should focus the first non-disabled item when Home is pressed', () => {
268+
itemList.get(0)!.isDisabled = true;
269+
keyManager.onClick(itemList.get(2)!);
270+
expect(keyManager.getActiveItemIndex()).toBe(2);
271+
272+
keyManager.onKeydown(fakeKeyEvents.home);
273+
274+
expect(keyManager.getActiveItemIndex()).toBe(1);
275+
});
276+
266277
it('should focus the last item when End is pressed', () => {
267278
keyManager.onClick(itemList.get(0)!);
268279
expect(keyManager.getActiveItemIndex()).toBe(0);
269280

270281
keyManager.onKeydown(fakeKeyEvents.end);
271282
expect(keyManager.getActiveItemIndex()).toBe(itemList.length - 1);
272283
});
284+
285+
it('should focus the last non-disabled item when End is pressed', () => {
286+
itemList.get(itemList.length - 1)!.isDisabled = true;
287+
keyManager.onClick(itemList.get(0)!);
288+
expect(keyManager.getActiveItemIndex()).toBe(0);
289+
290+
keyManager.onKeydown(fakeKeyEvents.end);
291+
292+
expect(keyManager.getActiveItemIndex()).toBe(itemList.length - 2);
293+
});
273294
});
274295

275296
describe('up/down key events', () => {
@@ -946,6 +967,195 @@ describe('TreeKeyManager', () => {
946967
expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(-1);
947968
}));
948969
});
970+
971+
describe('focusItem', () => {
972+
beforeEach(() => {
973+
keyManager.onInitialFocus();
974+
});
975+
976+
it('should focus the provided index', () => {
977+
expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(0);
978+
979+
keyManager.focusItem(1);
980+
expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(1);
981+
});
982+
983+
it('should be able to set the active item by reference', () => {
984+
expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(0);
985+
986+
keyManager.focusItem(itemList.get(2)!);
987+
expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(2);
988+
});
989+
990+
it('should be able to set the active item without emitting an event', () => {
991+
const spy = jasmine.createSpy('change spy');
992+
const subscription = keyManager.change.subscribe(spy);
993+
994+
expect(keyManager.getActiveItemIndex()).toBe(0);
995+
996+
keyManager.focusItem(2, {emitChangeEvent: false});
997+
998+
expect(keyManager.getActiveItemIndex()).toBe(2);
999+
expect(spy).not.toHaveBeenCalled();
1000+
1001+
subscription.unsubscribe();
1002+
});
1003+
1004+
it('should not emit an event if the item did not change', () => {
1005+
const spy = jasmine.createSpy('change spy');
1006+
const subscription = keyManager.change.subscribe(spy);
1007+
1008+
keyManager.focusItem(2);
1009+
keyManager.focusItem(2);
1010+
1011+
expect(spy).toHaveBeenCalledTimes(1);
1012+
1013+
subscription.unsubscribe();
1014+
});
1015+
});
1016+
1017+
describe('focusFirstItem', () => {
1018+
beforeEach(() => {
1019+
keyManager.onInitialFocus();
1020+
});
1021+
1022+
it('should focus the first item', () => {
1023+
keyManager.onKeydown(fakeKeyEvents.downArrow);
1024+
keyManager.onKeydown(fakeKeyEvents.downArrow);
1025+
expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(2);
1026+
1027+
keyManager.focusFirstItem();
1028+
expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(0);
1029+
});
1030+
1031+
it('should set the active item to the second item if the first one is disabled', () => {
1032+
itemList.get(0)!.isDisabled = true;
1033+
1034+
keyManager.focusFirstItem();
1035+
expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(1);
1036+
});
1037+
});
1038+
1039+
describe('focusLastItem', () => {
1040+
beforeEach(() => {
1041+
keyManager.onInitialFocus();
1042+
});
1043+
1044+
it('should focus the last item', () => {
1045+
expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(0);
1046+
1047+
keyManager.focusLastItem();
1048+
expect(keyManager.getActiveItemIndex())
1049+
.withContext('active item index')
1050+
.toBe(itemList.length - 1);
1051+
});
1052+
1053+
it('should set the active item to the second-to-last item if the last is disabled', () => {
1054+
itemList.get(itemList.length - 1)!.isDisabled = true;
1055+
1056+
keyManager.focusLastItem();
1057+
expect(keyManager.getActiveItemIndex())
1058+
.withContext('active item index')
1059+
.toBe(itemList.length - 2);
1060+
});
1061+
});
1062+
1063+
describe('focusNextItem', () => {
1064+
beforeEach(() => {
1065+
keyManager.onInitialFocus();
1066+
});
1067+
1068+
it('should focus the next item', () => {
1069+
expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(0);
1070+
1071+
keyManager.focusNextItem();
1072+
expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(1);
1073+
});
1074+
1075+
it('should skip disabled items', () => {
1076+
itemList.get(1)!.isDisabled = true;
1077+
1078+
keyManager.focusNextItem();
1079+
expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(2);
1080+
});
1081+
});
1082+
1083+
describe('focusPreviousItem', () => {
1084+
beforeEach(() => {
1085+
keyManager.onInitialFocus();
1086+
});
1087+
1088+
it('should focus the previous item', () => {
1089+
keyManager.onKeydown(fakeKeyEvents.downArrow);
1090+
expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(1);
1091+
1092+
keyManager.focusPreviousItem();
1093+
expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(0);
1094+
});
1095+
1096+
it('should skip disabled items', () => {
1097+
itemList.get(1)!.isDisabled = true;
1098+
keyManager.onKeydown(fakeKeyEvents.downArrow);
1099+
expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(2);
1100+
1101+
keyManager.focusPreviousItem();
1102+
expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(0);
1103+
});
1104+
});
1105+
1106+
describe('skip predicate', () => {
1107+
beforeEach(() => {
1108+
keyManager = new TreeKeyManager({
1109+
items: itemList,
1110+
skipPredicate: item => item.skipItem ?? false,
1111+
});
1112+
keyManager.onInitialFocus();
1113+
});
1114+
1115+
it('should be able to skip items with a custom predicate', () => {
1116+
itemList.get(1)!.skipItem = true;
1117+
expect(keyManager.getActiveItemIndex()).toBe(0);
1118+
1119+
keyManager.onKeydown(fakeKeyEvents.downArrow);
1120+
1121+
expect(keyManager.getActiveItemIndex()).toBe(2);
1122+
});
1123+
});
1124+
1125+
describe('focus', () => {
1126+
beforeEach(() => {
1127+
keyManager.onInitialFocus();
1128+
1129+
for (const item of itemList) {
1130+
spyOn(item, 'focus');
1131+
}
1132+
});
1133+
1134+
it('calls .focus() on focused items', () => {
1135+
keyManager.onKeydown(fakeKeyEvents.downArrow);
1136+
1137+
expect(itemList.get(0)!.focus).not.toHaveBeenCalled();
1138+
expect(itemList.get(1)!.focus).toHaveBeenCalledTimes(1);
1139+
expect(itemList.get(2)!.focus).not.toHaveBeenCalled();
1140+
1141+
keyManager.onKeydown(fakeKeyEvents.downArrow);
1142+
expect(itemList.get(0)!.focus).not.toHaveBeenCalled();
1143+
expect(itemList.get(1)!.focus).toHaveBeenCalledTimes(1);
1144+
expect(itemList.get(2)!.focus).toHaveBeenCalledTimes(1);
1145+
});
1146+
1147+
it('calls .focus() on focused items, when pressing up key', () => {
1148+
keyManager.onKeydown(fakeKeyEvents.downArrow);
1149+
1150+
expect(itemList.get(0)!.focus).not.toHaveBeenCalled();
1151+
expect(itemList.get(1)!.focus).toHaveBeenCalledTimes(1);
1152+
1153+
keyManager.onKeydown(fakeKeyEvents.upArrow);
1154+
1155+
expect(itemList.get(0)!.focus).toHaveBeenCalledTimes(1);
1156+
expect(itemList.get(1)!.focus).toHaveBeenCalledTimes(1);
1157+
});
1158+
});
9491159
});
9501160
}
9511161
});

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

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,9 @@ export interface TreeKeyManagerItem {
6666
focus(): void;
6767
}
6868

69+
/**
70+
* Configuration for the TreeKeyManager.
71+
*/
6972
export interface TreeKeyManagerOptions<T extends TreeKeyManagerItem> {
7073
items: Observable<T[]> | QueryList<T> | T[];
7174

@@ -284,9 +287,49 @@ export class TreeKeyManager<T extends TreeKeyManagerItem> {
284287
this._focusFirstItem();
285288
}
286289

287-
private _setActiveItem(index: number): void;
288-
private _setActiveItem(item: T): void;
289-
private _setActiveItem(itemOrIndex: number | T) {
290+
/**
291+
* Focus the provided item by index.
292+
* @param index The index of the item to focus.
293+
* @param options Additional focusing options.
294+
*/
295+
focusItem(index: number, options?: {emitChangeEvent?: boolean}): void;
296+
/**
297+
* Focus the provided item.
298+
* @param item The item to focus. Equality is determined via the trackBy function.
299+
* @param options Additional focusing options.
300+
*/
301+
focusItem(item: T, options?: {emitChangeEvent?: boolean}): void;
302+
focusItem(itemOrIndex: number | T, options?: {emitChangeEvent?: boolean}): void {
303+
this._setActiveItem(itemOrIndex, options);
304+
}
305+
306+
/** Focus the first available item. */
307+
focusFirstItem(): void {
308+
this._focusFirstItem();
309+
}
310+
311+
/** Focus the last available item. */
312+
focusLastItem(): void {
313+
this._focusLastItem();
314+
}
315+
316+
/** Focus the next available item. */
317+
focusNextItem(): void {
318+
this._focusNextItem();
319+
}
320+
321+
/** Focus the previous available item. */
322+
focusPreviousItem(): void {
323+
this._focusPreviousItem();
324+
}
325+
326+
private _setActiveItem(index: number, options?: {emitChangeEvent?: boolean}): void;
327+
private _setActiveItem(item: T, options?: {emitChangeEvent?: boolean}): void;
328+
private _setActiveItem(itemOrIndex: number | T, options?: {emitChangeEvent?: boolean}): void;
329+
private _setActiveItem(itemOrIndex: number | T, options: {emitChangeEvent?: boolean} = {}) {
330+
// Set default options
331+
options.emitChangeEvent ??= true;
332+
290333
let index =
291334
typeof itemOrIndex === 'number'
292335
? itemOrIndex
@@ -307,16 +350,21 @@ export class TreeKeyManager<T extends TreeKeyManagerItem> {
307350
this._activeItem = activeItem ?? null;
308351
this._activeItemIndex = index;
309352

310-
this.change.next(this._activeItem);
353+
if (options.emitChangeEvent) {
354+
this.change.next(this._activeItem);
355+
}
311356
this._activeItem?.focus();
312357
if (this._activationFollowsFocus) {
313358
this._activateCurrentItem();
314359
}
315360
}
316361

317362
private _updateActiveItemIndex(newItems: T[]) {
318-
if (this._activeItem) {
319-
const newIndex = newItems.indexOf(this._activeItem);
363+
const activeItem = this._activeItem;
364+
if (activeItem) {
365+
const newIndex = newItems.findIndex(
366+
item => this._trackByFn(item) === this._trackByFn(activeItem),
367+
);
320368

321369
if (newIndex > -1 && newIndex !== this._activeItemIndex) {
322370
this._activeItemIndex = newIndex;

0 commit comments

Comments
 (0)