Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
c1fb19f
feat(cdk/a11y): add some missing focus functions to TreeKeyManager, f…
BobobUnicorn Apr 18, 2023
d3ac29d
feat(cdk/tree): report an error when the API consumer tries to expand…
BobobUnicorn Jun 14, 2023
467c427
fix(cdk/tree): set node role through component host
BobobUnicorn Jun 14, 2023
5e6d92f
fix(material/tree): fix duplicate keydown events
BobobUnicorn Jun 14, 2023
e8fbf4e
fix(cdk/tree): make keyboard behaviour consistent across all configur…
BobobUnicorn Jun 16, 2023
c9a2a36
fix(cdk/tree): remove unnecessary change detection
BobobUnicorn Jun 16, 2023
dead175
fix(cdk/tree): update API goldens
BobobUnicorn Jun 16, 2023
bb663a0
refactor(cdk/tree): organize imports
BobobUnicorn Jun 16, 2023
6699438
fix(cdk/a11y): update API goldens
BobobUnicorn Jun 16, 2023
408f542
fix(cdk/tree): remove `_preFlattenedNodes`
BobobUnicorn Jun 19, 2023
db6dc6f
fix(cdk/tree): lint
BobobUnicorn Jun 19, 2023
d58ff04
fix(cdk/tree): use `findIndex` instead of `indexOf`; fixes inconsiste…
BobobUnicorn Jun 20, 2023
5efed26
feat(cdk/tree): add complex redux-like demo
BobobUnicorn Jul 26, 2023
98a01e0
fix(cdk/tree): refactor rendering pipeline
BobobUnicorn Jul 26, 2023
c6a8892
feat(cdk/tree): update tree documentation
BobobUnicorn Jul 26, 2023
f0d949c
feat(cdk/a11y): update docs for `TreeKeyManager`.
BobobUnicorn Jul 26, 2023
6cf8176
fix(cdk/tree): update API goldens, fix lint errors
BobobUnicorn Jul 26, 2023
fe7e437
fix(cdk/tree): empty commit; retry ci actions
BobobUnicorn Jul 28, 2023
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`.

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.

#### Basic usage

Any component that uses a `TreeKeyManager` will generally 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` 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.
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
210 changes: 210 additions & 0 deletions src/cdk/a11y/key-manager/tree-key-manager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ class FakeBaseTreeKeyManagerItem {
_children: FakeBaseTreeKeyManagerItem[] = [];

isDisabled?: boolean = false;
skipItem?: boolean = false;

constructor(private _label: string) {}

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

it('should focus the first non-disabled item when Home is pressed', () => {
itemList.get(0)!.isDisabled = true;
keyManager.onClick(itemList.get(2)!);
expect(keyManager.getActiveItemIndex()).toBe(2);

keyManager.onKeydown(fakeKeyEvents.home);

expect(keyManager.getActiveItemIndex()).toBe(1);
});

it('should focus the last item when End is pressed', () => {
keyManager.onClick(itemList.get(0)!);
expect(keyManager.getActiveItemIndex()).toBe(0);

keyManager.onKeydown(fakeKeyEvents.end);
expect(keyManager.getActiveItemIndex()).toBe(itemList.length - 1);
});

it('should focus the last non-disabled item when End is pressed', () => {
itemList.get(itemList.length - 1)!.isDisabled = true;
keyManager.onClick(itemList.get(0)!);
expect(keyManager.getActiveItemIndex()).toBe(0);

keyManager.onKeydown(fakeKeyEvents.end);

expect(keyManager.getActiveItemIndex()).toBe(itemList.length - 2);
});
});

describe('up/down key events', () => {
Expand Down Expand Up @@ -946,6 +967,195 @@ describe('TreeKeyManager', () => {
expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(-1);
}));
});

describe('focusItem', () => {
beforeEach(() => {
keyManager.onInitialFocus();
});

it('should focus the provided index', () => {
expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(0);

keyManager.focusItem(1);
expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(1);
});

it('should be able to set the active item by reference', () => {
expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(0);

keyManager.focusItem(itemList.get(2)!);
expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(2);
});

it('should be able to set the active item without emitting an event', () => {
const spy = jasmine.createSpy('change spy');
const subscription = keyManager.change.subscribe(spy);

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

keyManager.focusItem(2, {emitChangeEvent: false});

expect(keyManager.getActiveItemIndex()).toBe(2);
expect(spy).not.toHaveBeenCalled();

subscription.unsubscribe();
});

it('should not emit an event if the item did not change', () => {
const spy = jasmine.createSpy('change spy');
const subscription = keyManager.change.subscribe(spy);

keyManager.focusItem(2);
keyManager.focusItem(2);

expect(spy).toHaveBeenCalledTimes(1);

subscription.unsubscribe();
});
});

describe('focusFirstItem', () => {
beforeEach(() => {
keyManager.onInitialFocus();
});

it('should focus the first item', () => {
keyManager.onKeydown(fakeKeyEvents.downArrow);
keyManager.onKeydown(fakeKeyEvents.downArrow);
expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(2);

keyManager.focusFirstItem();
expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(0);
});

it('should set the active item to the second item if the first one is disabled', () => {
itemList.get(0)!.isDisabled = true;

keyManager.focusFirstItem();
expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(1);
});
});

describe('focusLastItem', () => {
beforeEach(() => {
keyManager.onInitialFocus();
});

it('should focus the last item', () => {
expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(0);

keyManager.focusLastItem();
expect(keyManager.getActiveItemIndex())
.withContext('active item index')
.toBe(itemList.length - 1);
});

it('should set the active item to the second-to-last item if the last is disabled', () => {
itemList.get(itemList.length - 1)!.isDisabled = true;

keyManager.focusLastItem();
expect(keyManager.getActiveItemIndex())
.withContext('active item index')
.toBe(itemList.length - 2);
});
});

describe('focusNextItem', () => {
beforeEach(() => {
keyManager.onInitialFocus();
});

it('should focus the next item', () => {
expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(0);

keyManager.focusNextItem();
expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(1);
});

it('should skip disabled items', () => {
itemList.get(1)!.isDisabled = true;

keyManager.focusNextItem();
expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(2);
});
});

describe('focusPreviousItem', () => {
beforeEach(() => {
keyManager.onInitialFocus();
});

it('should focus the previous item', () => {
keyManager.onKeydown(fakeKeyEvents.downArrow);
expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(1);

keyManager.focusPreviousItem();
expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(0);
});

it('should skip disabled items', () => {
itemList.get(1)!.isDisabled = true;
keyManager.onKeydown(fakeKeyEvents.downArrow);
expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(2);

keyManager.focusPreviousItem();
expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(0);
});
});

describe('skip predicate', () => {
beforeEach(() => {
keyManager = new TreeKeyManager({
items: itemList,
skipPredicate: item => item.skipItem ?? false,
});
keyManager.onInitialFocus();
});

it('should be able to skip items with a custom predicate', () => {
itemList.get(1)!.skipItem = true;
expect(keyManager.getActiveItemIndex()).toBe(0);

keyManager.onKeydown(fakeKeyEvents.downArrow);

expect(keyManager.getActiveItemIndex()).toBe(2);
});
});

describe('focus', () => {
beforeEach(() => {
keyManager.onInitialFocus();

for (const item of itemList) {
spyOn(item, 'focus');
}
});

it('calls .focus() on focused items', () => {
keyManager.onKeydown(fakeKeyEvents.downArrow);

expect(itemList.get(0)!.focus).not.toHaveBeenCalled();
expect(itemList.get(1)!.focus).toHaveBeenCalledTimes(1);
expect(itemList.get(2)!.focus).not.toHaveBeenCalled();

keyManager.onKeydown(fakeKeyEvents.downArrow);
expect(itemList.get(0)!.focus).not.toHaveBeenCalled();
expect(itemList.get(1)!.focus).toHaveBeenCalledTimes(1);
expect(itemList.get(2)!.focus).toHaveBeenCalledTimes(1);
});

it('calls .focus() on focused items, when pressing up key', () => {
keyManager.onKeydown(fakeKeyEvents.downArrow);

expect(itemList.get(0)!.focus).not.toHaveBeenCalled();
expect(itemList.get(1)!.focus).toHaveBeenCalledTimes(1);

keyManager.onKeydown(fakeKeyEvents.upArrow);

expect(itemList.get(0)!.focus).toHaveBeenCalledTimes(1);
expect(itemList.get(1)!.focus).toHaveBeenCalledTimes(1);
});
});
});
}
});
60 changes: 54 additions & 6 deletions src/cdk/a11y/key-manager/tree-key-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ export interface TreeKeyManagerItem {
focus(): void;
}

/**
* Configuration for the TreeKeyManager.
*/
export interface TreeKeyManagerOptions<T extends TreeKeyManagerItem> {
items: Observable<T[]> | QueryList<T> | T[];

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

private _setActiveItem(index: number): void;
private _setActiveItem(item: T): void;
private _setActiveItem(itemOrIndex: number | T) {
/**
* Focus the provided item by index.
* @param index The index of the item to focus.
* @param options Additional focusing options.
*/
focusItem(index: number, options?: {emitChangeEvent?: boolean}): void;
/**
* Focus the provided item.
* @param item The item to focus. Equality is determined via the trackBy function.
* @param options Additional focusing options.
*/
focusItem(item: T, options?: {emitChangeEvent?: boolean}): void;
focusItem(itemOrIndex: number | T, options?: {emitChangeEvent?: boolean}): void {
this._setActiveItem(itemOrIndex, options);
}

/** Focus the first available item. */
focusFirstItem(): void {
this._focusFirstItem();
}

/** Focus the last available item. */
focusLastItem(): void {
this._focusLastItem();
}

/** Focus the next available item. */
focusNextItem(): void {
this._focusNextItem();
}

/** Focus the previous available item. */
focusPreviousItem(): void {
this._focusPreviousItem();
}

private _setActiveItem(index: number, options?: {emitChangeEvent?: boolean}): void;
private _setActiveItem(item: T, options?: {emitChangeEvent?: boolean}): void;
private _setActiveItem(itemOrIndex: number | T, options?: {emitChangeEvent?: boolean}): void;
private _setActiveItem(itemOrIndex: number | T, options: {emitChangeEvent?: boolean} = {}) {
// Set default options
options.emitChangeEvent ??= true;

let index =
typeof itemOrIndex === 'number'
? itemOrIndex
Expand All @@ -307,16 +350,21 @@ export class TreeKeyManager<T extends TreeKeyManagerItem> {
this._activeItem = activeItem ?? null;
this._activeItemIndex = index;

this.change.next(this._activeItem);
if (options.emitChangeEvent) {
this.change.next(this._activeItem);
}
this._activeItem?.focus();
if (this._activationFollowsFocus) {
this._activateCurrentItem();
}
}

private _updateActiveItemIndex(newItems: T[]) {
if (this._activeItem) {
const newIndex = newItems.indexOf(this._activeItem);
const activeItem = this._activeItem;
if (activeItem) {
const newIndex = newItems.findIndex(
item => this._trackByFn(item) === this._trackByFn(activeItem),
);

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