Skip to content
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

feat(cdk/tree): integrate TreeKeyManager with cdk/tree #27285

Merged
Merged
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
8 changes: 8 additions & 0 deletions src/cdk/a11y/key-manager/tree-key-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,14 @@ export class TreeKeyManager<T extends TreeKeyManagerItem> {
return this._activeItem;
}

/**
* Focus the initial element; this is intended to be called when the tree is focused for
* the first time.
*/
onInitialFocus(): void {
this._focusFirstItem();
}

private _setActiveItem(index: number): void;
private _setActiveItem(item: T): void;
private _setActiveItem(itemOrIndex: number | T) {
Expand Down
6 changes: 6 additions & 0 deletions src/cdk/tree/control/nested-tree-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {BaseTreeControl} from './base-tree-control';

/** Optional set of configuration that can be provided to the NestedTreeControl. */
export interface NestedTreeControlOptions<T, K> {
/** Function to determine if the provided node is expandable. */
isExpandable?: (dataNode: T) => boolean;
trackBy?: (dataNode: T) => K;
}

Expand All @@ -31,6 +33,10 @@ export class NestedTreeControl<T, K = T> extends BaseTreeControl<T, K> {
if (this.options) {
this.trackBy = this.options.trackBy;
}

if (this.options?.isExpandable) {
this.isExpandable = this.options.isExpandable;
}
}

/**
Expand Down
4 changes: 3 additions & 1 deletion src/cdk/tree/nested-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/
import {
AfterContentInit,
ChangeDetectorRef,
ContentChildren,
Directive,
ElementRef,
Expand Down Expand Up @@ -60,9 +61,10 @@ export class CdkNestedTreeNode<T, K = T>
constructor(
elementRef: ElementRef<HTMLElement>,
tree: CdkTree<T, K>,
changeDetectorRef: ChangeDetectorRef,
protected _differs: IterableDiffers,
) {
super(elementRef, tree);
super(elementRef, tree, changeDetectorRef);
}

ngAfterContentInit() {
Expand Down
2 changes: 1 addition & 1 deletion src/cdk/tree/toggle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {CdkTree, CdkTreeNode} from './tree';
selector: '[cdkTreeNodeToggle]',
host: {
'(click)': '_toggle($event)',
'tabindex': '0',
'tabindex': '-1',
},
})
export class CdkTreeNodeToggle<T, K = T> {
Expand Down
215 changes: 154 additions & 61 deletions src/cdk/tree/tree-redesign.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,46 +117,6 @@ describe('CdkTree redesign', () => {
expect(nodes[0].classList).toContain('customNodeClass');
});

it('with the right accessibility roles', () => {
expect(treeElement.getAttribute('role')).toBe('tree');

expect(
getNodes(treeElement).every(node => {
return node.getAttribute('role') === 'treeitem';
}),
).toBe(true);
});

it('with the right aria-levels', () => {
// add a child to the first node
let data = dataSource.data;
dataSource.addChild(data[0], true);

const ariaLevels = getNodes(treeElement).map(n => n.getAttribute('aria-level'));
expect(ariaLevels).toEqual(['2', '3', '2', '2']);
});

it('with the right aria-expanded attrs', () => {
// add a child to the first node
let data = dataSource.data;
dataSource.addChild(data[2]);
fixture.detectChanges();
expect(
getNodes(treeElement).every(node => {
return node.getAttribute('aria-expanded') === 'false';
}),
).toBe(true);

component.expandAll();
fixture.detectChanges();

expect(
getNodes(treeElement).every(node => {
return node.getAttribute('aria-expanded') === 'true';
}),
).toBe(true);
});

it('with the right data', () => {
expect(dataSource.data.length).toBe(3);

Expand Down Expand Up @@ -624,16 +584,6 @@ describe('CdkTree redesign', () => {
expect(nodes[0].classList).toContain('customNodeClass');
});

it('with the right accessibility roles', () => {
expect(treeElement.getAttribute('role')).toBe('tree');

expect(
getNodes(treeElement).every(node => {
return node.getAttribute('role') === 'treeitem';
}),
).toBe(true);
});

it('with the right data', () => {
expect(dataSource.data.length).toBe(3);

Expand Down Expand Up @@ -797,11 +747,9 @@ describe('CdkTree redesign', () => {
});

it('with the right aria-expanded attrs', () => {
expect(
getNodes(treeElement).every(node => {
return node.getAttribute('aria-expanded') === 'false';
}),
).toBe(true);
expect(getNodeAttributes(getNodes(treeElement), 'aria-expanded'))
.withContext('aria-expanded attributes')
.toEqual([null, null, null]);

component.toggleRecursively = false;
let data = dataSource.data;
Expand All @@ -812,8 +760,11 @@ describe('CdkTree redesign', () => {
(getNodes(treeElement)[1] as HTMLElement).click();
fixture.detectChanges();

const ariaExpanded = getNodes(treeElement).map(n => n.getAttribute('aria-expanded'));
expect(ariaExpanded).toEqual(['false', 'true', 'false', 'false']);
// NB: only four elements are present here; children are not present
// in DOM unless the parent node is expanded.
expect(getNodeAttributes(getNodes(treeElement), 'aria-expanded'))
.withContext('aria-expanded attributes')
.toEqual([null, 'true', 'false', null]);
});

it('should expand/collapse the node multiple times', () => {
Expand Down Expand Up @@ -1157,6 +1108,135 @@ describe('CdkTree redesign', () => {
expect(depthElements.length).toBe(5);
});
});

describe('accessibility', () => {
let fixture: ComponentFixture<StaticNestedCdkTreeApp>;
let component: StaticNestedCdkTreeApp;
let nodes: HTMLElement[];

beforeEach(() => {
configureCdkTreeTestingModule([StaticNestedCdkTreeApp]);
fixture = TestBed.createComponent(StaticNestedCdkTreeApp);
fixture.detectChanges();

component = fixture.componentInstance;
dataSource = component.dataSource as FakeDataSource;
tree = component.tree;
treeElement = fixture.nativeElement.querySelector('cdk-tree');
nodes = getNodes(treeElement);
});

describe('focus management', () => {
it('the tree is tabbable when no element is active', () => {
expect(treeElement.getAttribute('tabindex')).toBe('0');
});

it('the tree is not tabbable when an element is active', () => {
// activate the second child by clicking on it
nodes[1].click();

expect(treeElement.getAttribute('tabindex')).toBe(null);
});

it('sets tabindex on the latest activated item, with all others "-1"', () => {
// activate the second child by clicking on it
nodes[1].click();

expect(getNodeAttributes(nodes, 'tabindex')).toEqual(['-1', '0', '-1', '-1', '-1', '-1']);

// activate the first child by clicking on it
nodes[0].click();

expect(getNodeAttributes(nodes, 'tabindex')).toEqual(['0', '-1', '-1', '-1', '-1', '-1']);
});

it('maintains tabindex when component is blurred', () => {
// activate the second child by clicking on it
nodes[1].click();

expect(document.activeElement).toBe(nodes[1]);
// blur the currently active element (which we just checked is the above node)
nodes[1].blur();

expect(treeElement.getAttribute('tabindex')).toBe(null);
expect(getNodeAttributes(nodes, 'tabindex')).toEqual(['-1', '0', '-1', '-1', '-1', '-1']);
});

it('ignores clicks on disabled items', () => {
dataSource.data[0].isDisabled = true;
fixture.detectChanges();

// attempt to click on the first child
nodes[0].click();

expect(treeElement.getAttribute('tabindex')).toBe('0');
expect(getNodeAttributes(nodes, 'tabindex')).toEqual(['-1', '-1', '-1', '-1', '-1', '-1']);
});

describe('when no item is currently active', () => {
it('redirects focus to the first item when the tree is focused', () => {
treeElement.focus();

expect(document.activeElement).toBe(nodes[0]);
});

it('redirects focus to the first non-disabled item when the tree is focused', () => {
dataSource.data[0].isDisabled = true;
fixture.detectChanges();

treeElement.focus();

expect(document.activeElement).toBe(nodes[1]);
});
});
});

describe('tree role & attributes', () => {
it('sets the tree role on the tree element', () => {
expect(treeElement.getAttribute('role')).toBe('tree');
});

it('sets the treeitem role on all nodes', () => {
expect(getNodeAttributes(nodes, 'role')).toEqual([
'treeitem',
'treeitem',
'treeitem',
'treeitem',
'treeitem',
'treeitem',
]);
});

it('sets aria attributes for tree nodes', () => {
expect(getNodeAttributes(nodes, 'aria-expanded'))
.withContext('aria-expanded attributes')
.toEqual([null, 'false', 'false', null, null, null]);
expect(getNodeAttributes(nodes, 'aria-level'))
.withContext('aria-level attributes')
.toEqual(['1', '1', '2', '3', '3', '1']);
expect(getNodeAttributes(nodes, 'aria-posinset'))
.withContext('aria-posinset attributes')
.toEqual(['1', '2', '1', '1', '2', '3']);
expect(getNodeAttributes(nodes, 'aria-setsize'))
.withContext('aria-setsize attributes')
.toEqual(['3', '3', '1', '2', '2', '3']);
});

it('changes aria-expanded status when expanded or collapsed', () => {
tree.expand(dataSource.data[1]);
fixture.detectChanges();
expect(getNodeAttributes(nodes, 'aria-expanded'))
.withContext('aria-expanded attributes')
.toEqual([null, 'true', 'false', null, null, null]);

tree.collapse(dataSource.data[1]);
fixture.detectChanges();
expect(getNodeAttributes(nodes, 'aria-expanded'))
.withContext('aria-expanded attributes')
.toEqual([null, 'false', 'false', null, null, null]);
});
});
});
});

export class TestData {
Expand All @@ -1165,6 +1245,7 @@ export class TestData {
pizzaBase: string;
level: number;
children: TestData[];
isDisabled?: boolean;
readonly observableChildren: BehaviorSubject<TestData[]>;

constructor(pizzaTopping: string, pizzaCheese: string, pizzaBase: string, level: number = 1) {
Expand Down Expand Up @@ -1339,6 +1420,10 @@ function expectNestedTreeToMatch(treeElement: Element, ...expectedTree: any[]) {
}
}

function getNodeAttributes(nodes: HTMLElement[], attribute: string) {
return nodes.map(node => node.getAttribute(attribute));
}

@Component({
template: `
<cdk-tree [dataSource]="dataSource" [levelAccessor]="getLevel"
Expand Down Expand Up @@ -1407,9 +1492,13 @@ class NestedCdkTreeApp {
template: `
<cdk-tree [dataSource]="dataSource" [childrenAccessor]="getChildren"
nodeType="nested">
<cdk-nested-tree-node *cdkTreeNodeDef="let node" class="customNodeClass">
{{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}}
<ng-template cdkTreeNodeOutlet></ng-template>
<cdk-nested-tree-node
*cdkTreeNodeDef="let node"
class="customNodeClass"
[isExpandable]="node.children.length > 0"
[isDisabled]="node.isDisabled">
{{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}}
<ng-template cdkTreeNodeOutlet></ng-template>
</cdk-nested-tree-node>
</cdk-tree>
`,
Expand Down Expand Up @@ -1486,7 +1575,9 @@ class CdkTreeAppWithToggle {
<cdk-tree #tree [dataSource]="dataSource" [childrenAccessor]="getChildren"
nodeType="nested">
<cdk-nested-tree-node *cdkTreeNodeDef="let node" class="customNodeClass"
cdkTreeNodeToggle [cdkTreeNodeToggleRecursive]="toggleRecursively">
[isExpandable]="isExpandable(node) | async"
cdkTreeNodeToggle
[cdkTreeNodeToggleRecursive]="toggleRecursively">
{{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}}
<div *ngIf="tree.isExpanded(node)">
<ng-template cdkTreeNodeOutlet></ng-template>
Expand All @@ -1499,6 +1590,8 @@ class NestedCdkTreeAppWithToggle {
toggleRecursively: boolean = true;

getChildren = (node: TestData) => node.observableChildren;
isExpandable = (node: TestData) =>
node.observableChildren.pipe(map(children => children.length > 0));

dataSource: FakeDataSource | null = new FakeDataSource();

Expand Down
Loading