Skip to content

Commit

Permalink
feat(cdk/tree): integrate TreeKeyManager with cdk/tree (#27285)
Browse files Browse the repository at this point in the history
* feat(cdk/tree): add cache of nodes to the tree

* feat(cdk/tree): bug fixes for tree and key manager

* feat(cdk/tree): flatten data that uses childrenAccessor

* feat(cdk/tree): add examples, fix bugs

* fix(cdk/tree): fix build errors

* fix(cdk/tree): restoring incorrectly removed code

* fix(cdk/tree): fix minor typo

* fix(cdk/tree): fix lint

* feat(cdk/tree): add additional tests, fix bug with aria-posinset

* fix(cdk/tree): update goldens, remove fdescribe

* fix(cdk/tree): dev app compilation

* fix(cdk/tree): fix tests

* fix(material/tree): update tests & API

* fix(material/tree): api goldens

* feat(cdk/tree): add `isExpandable` parameter to NestedTreeControl, fix some tests

* fix(cdk/tree): update api goldens

* fix(material/tree): lint

* fix(cdk/tree): update API goldens
  • Loading branch information
BobobUnicorn authored Jun 21, 2023
1 parent 5f2c7bc commit e000372
Show file tree
Hide file tree
Showing 20 changed files with 909 additions and 256 deletions.
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

0 comments on commit e000372

Please sign in to comment.