Skip to content

Commit 65ec31b

Browse files
committed
feat(cdk/a11y): add some missing focus functions to TreeKeyManager, fix tests
1 parent b6aa9eb commit 65ec31b

File tree

2 files changed

+259
-4
lines changed

2 files changed

+259
-4
lines changed

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: 49 additions & 4 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,7 +350,9 @@ 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();

0 commit comments

Comments
 (0)