Skip to content

Commit f951bf4

Browse files
authored
fix(cdk/tree): fix level for nested tree nodes (#20226)
* fix(cdk/tree): fix level for flat and nested tree nodes * add test for material/tree * cache parent level instead of traversing DOM on ever level get * logging * set aria-level in ngOnInit, clean up * public api * change host binding to @HostBinding * use host:{} for mat tree and hostbinding for cdk tree * fix tslint comments, fix class inheritance for mat tree * clarify adding class directly to elementRef instead of on host property * minor fixes * fix isNodeElement method name/logic, guarantee boolean value * clean up duplicate @HostBinding and host:{} * replace isDevMode() * super life cycle hooks * public api update * comments explaining super life hook calls, add aria-expanded with setAttribute * set aria-expaded in ngDoCheck * remove duplicate input from MatTreeNode, add missing inputs:[] for classes inheriting CdkTreeNode * add super.ngDoCheck() * change isExpanded setter to private method * remove setting aria-expanded in constructor
1 parent ea628c9 commit f951bf4

File tree

8 files changed

+268
-48
lines changed

8 files changed

+268
-48
lines changed

src/cdk/tree/nested-node.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@ import {
99
AfterContentInit,
1010
ContentChildren,
1111
Directive,
12+
DoCheck,
1213
ElementRef,
1314
IterableDiffer,
1415
IterableDiffers,
1516
OnDestroy,
17+
OnInit,
1618
QueryList,
1719
} from '@angular/core';
1820
import {isObservable} from 'rxjs';
@@ -31,17 +33,15 @@ import {getTreeControlFunctionsMissingError} from './tree-errors';
3133
@Directive({
3234
selector: 'cdk-nested-tree-node',
3335
exportAs: 'cdkNestedTreeNode',
34-
host: {
35-
'[attr.aria-expanded]': 'isExpanded',
36-
'[attr.role]': 'role',
37-
'class': 'cdk-tree-node cdk-nested-tree-node',
38-
},
36+
inputs: ['role', 'disabled', 'tabIndex'],
3937
providers: [
4038
{provide: CdkTreeNode, useExisting: CdkNestedTreeNode},
4139
{provide: CDK_TREE_NODE_OUTLET_NODE, useExisting: CdkNestedTreeNode}
4240
]
4341
})
44-
export class CdkNestedTreeNode<T> extends CdkTreeNode<T> implements AfterContentInit, OnDestroy {
42+
export class CdkNestedTreeNode<T> extends CdkTreeNode<T> implements AfterContentInit, DoCheck,
43+
OnDestroy,
44+
OnInit {
4545
/** Differ used to find the changes in the data provided by the data source. */
4646
private _dataDiffer: IterableDiffer<T>;
4747

@@ -60,6 +60,11 @@ export class CdkNestedTreeNode<T> extends CdkTreeNode<T> implements AfterContent
6060
protected _tree: CdkTree<T>,
6161
protected _differs: IterableDiffers) {
6262
super(_elementRef, _tree);
63+
// The classes are directly added here instead of in the host property because classes on
64+
// the host property are not inherited with View Engine. It is not set as a @HostBinding because
65+
// it is not set by the time it's children nodes try to read the class from it.
66+
// TODO: move to host after View Engine deprecation
67+
this._elementRef.nativeElement.classList.add('cdk-nested-tree-node');
6368
}
6469

6570
ngAfterContentInit() {
@@ -78,6 +83,16 @@ export class CdkNestedTreeNode<T> extends CdkTreeNode<T> implements AfterContent
7883
.subscribe(() => this.updateChildrenNodes());
7984
}
8085

86+
// This is a workaround for https://github.com/angular/angular/issues/23091
87+
// In aot mode, the lifecycle hooks from parent class are not called.
88+
ngOnInit() {
89+
super.ngOnInit();
90+
}
91+
92+
ngDoCheck() {
93+
super.ngDoCheck();
94+
}
95+
8196
ngOnDestroy() {
8297
this._clear();
8398
super.ngOnDestroy();

src/cdk/tree/tree.spec.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,23 @@ describe('CdkTree', () => {
129129
expect(ariaLevels).toEqual(['2', '3', '2', '2']);
130130
});
131131

132+
it('with the right aria-expanded attrs', () => {
133+
// add a child to the first node
134+
let data = dataSource.data;
135+
dataSource.addChild(data[2]);
136+
fixture.detectChanges();
137+
expect(getNodes(treeElement).every(node => {
138+
return node.getAttribute('aria-expanded') === 'false';
139+
})).toBe(true);
140+
141+
component.treeControl.expandAll();
142+
fixture.detectChanges();
143+
144+
expect(getNodes(treeElement).every(node => {
145+
return node.getAttribute('aria-expanded') === 'true';
146+
})).toBe(true);
147+
});
148+
132149
it('with the right data', () => {
133150
expect(dataSource.data.length).toBe(3);
134151

@@ -595,6 +612,21 @@ describe('CdkTree', () => {
595612
[_, _, `topping_6 - cheese_6 + base_6`],
596613
[`topping_3 - cheese_3 + base_3`]);
597614
});
615+
616+
it('with correct aria-level on nodes', () => {
617+
expect(getNodes(treeElement).every(node => {
618+
return node.getAttribute('aria-level') === '1';
619+
})).toBe(true);
620+
621+
let data = dataSource.data;
622+
const child = dataSource.addChild(data[1], false);
623+
dataSource.addChild(child, false);
624+
fixture.detectChanges();
625+
626+
const nodes = getNodes(treeElement);
627+
const levels = nodes.map(n => n.getAttribute('aria-level'));
628+
expect(levels).toEqual(['1', '1', '2', '3', '1']);
629+
});
598630
});
599631

600632
describe('with static children', () => {
@@ -676,6 +708,24 @@ describe('CdkTree', () => {
676708
treeElement = fixture.nativeElement.querySelector('cdk-tree');
677709
});
678710

711+
it('with the right aria-expanded attrs', () => {
712+
expect(getNodes(treeElement).every(node => {
713+
return node.getAttribute('aria-expanded') === 'false';
714+
})).toBe(true);
715+
716+
component.toggleRecursively = false;
717+
let data = dataSource.data;
718+
const child = dataSource.addChild(data[1], false);
719+
dataSource.addChild(child, false);
720+
fixture.detectChanges();
721+
722+
(getNodes(treeElement)[1] as HTMLElement).click();
723+
fixture.detectChanges();
724+
725+
const ariaExpanded = getNodes(treeElement).map(n => n.getAttribute('aria-expanded'));
726+
expect(ariaExpanded).toEqual(['false', 'true', 'false', 'false']);
727+
});
728+
679729
it('should expand/collapse the node multiple times', () => {
680730
component.toggleRecursively = false;
681731
let data = dataSource.data;

src/cdk/tree/tree.ts

Lines changed: 75 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
Component,
1515
ContentChildren,
1616
Directive,
17+
DoCheck,
1718
ElementRef,
1819
Input,
1920
IterableChangeRecord,
@@ -46,6 +47,7 @@ import {
4647
getTreeMultipleDefaultNodeDefsError,
4748
getTreeNoValidDataSourceError
4849
} from './tree-errors';
50+
import {coerceNumberProperty} from '@angular/cdk/coercion';
4951

5052
/**
5153
* CDK tree component that connects with a data source to retrieve data of type `T` and renders
@@ -300,14 +302,21 @@ export class CdkTree<T> implements AfterContentChecked, CollectionViewer, OnDest
300302
@Directive({
301303
selector: 'cdk-tree-node',
302304
exportAs: 'cdkTreeNode',
303-
host: {
304-
'[attr.aria-expanded]': 'isExpanded',
305-
'[attr.aria-level]': 'level + 1',
306-
'[attr.role]': 'role',
307-
'class': 'cdk-tree-node',
308-
},
309305
})
310-
export class CdkTreeNode<T> implements FocusableOption, OnDestroy {
306+
export class CdkTreeNode<T> implements DoCheck, FocusableOption, OnDestroy, OnInit {
307+
/**
308+
* The role of the tree node.
309+
* @deprecated The correct role is 'treeitem', 'group' should not be used. This input will be
310+
* removed in a future version.
311+
* @breaking-change 12.0.0 Remove this input
312+
*/
313+
@Input() get role(): 'treeitem'|'group' { return 'treeitem'; }
314+
315+
set role(_role: 'treeitem'|'group') {
316+
// TODO: move to host after View Engine deprecation
317+
this._elementRef.nativeElement.setAttribute('role', _role);
318+
}
319+
311320
/**
312321
* The most recently created `CdkTreeNode`. We save it in static variable so we can retrieve it
313322
* in `CdkTree` and set the data to it.
@@ -320,6 +329,8 @@ export class CdkTreeNode<T> implements FocusableOption, OnDestroy {
320329
/** Emits when the node's data has changed. */
321330
_dataChanges = new Subject<void>();
322331

332+
private _parentNodeAriaLevel: number;
333+
323334
/** The tree node's data. */
324335
get data(): T { return this._data; }
325336
set data(value: T) {
@@ -335,19 +346,45 @@ export class CdkTreeNode<T> implements FocusableOption, OnDestroy {
335346
return this._tree.treeControl.isExpanded(this._data);
336347
}
337348

338-
get level(): number {
339-
return this._tree.treeControl.getLevel ? this._tree.treeControl.getLevel(this._data) : 0;
349+
private _setExpanded(_expanded: boolean) {
350+
this._isAriaExpanded = _expanded;
351+
this._elementRef.nativeElement.setAttribute('aria-expanded', `${_expanded}`);
340352
}
341353

342-
/**
343-
* The role of the node should always be 'treeitem'.
344-
*/
345-
// TODO: mark as deprecated
346-
@Input() role: 'treeitem' | 'group' = 'treeitem';
354+
protected _isAriaExpanded: boolean;
355+
356+
get level(): number {
357+
// If the treeControl has a getLevel method, use it to get the level. Otherwise read the
358+
// aria-level off the parent node and use it as the level for this node (note aria-level is
359+
// 1-indexed, while this property is 0-indexed, so we don't need to increment).
360+
return this._tree.treeControl.getLevel ?
361+
this._tree.treeControl.getLevel(this._data) : this._parentNodeAriaLevel;
362+
}
347363

348364
constructor(protected _elementRef: ElementRef<HTMLElement>,
349365
protected _tree: CdkTree<T>) {
350366
CdkTreeNode.mostRecentTreeNode = this as CdkTreeNode<T>;
367+
// The classes are directly added here instead of in the host property because classes on
368+
// the host property are not inherited with View Engine. It is not set as a @HostBinding because
369+
// it is not set by the time it's children nodes try to read the class from it.
370+
// TODO: move to host after View Engine deprecation
371+
this._elementRef.nativeElement.classList.add('cdk-tree-node');
372+
this.role = 'treeitem';
373+
}
374+
375+
ngOnInit(): void {
376+
this._parentNodeAriaLevel = getParentNodeAriaLevel(this._elementRef.nativeElement);
377+
this._elementRef.nativeElement.setAttribute('aria-level', `${this.level + 1}`);
378+
}
379+
380+
ngDoCheck() {
381+
// aria-expanded is be set here because the expanded state is stored in the tree control and
382+
// the node isn't aware when the state is changed.
383+
// It is not set using a @HostBinding because they sometimes get lost with Mixin based classes.
384+
// TODO: move to host after View Engine deprecation
385+
if (this.isExpanded != this._isAriaExpanded) {
386+
this._setExpanded(this.isExpanded);
387+
}
351388
}
352389

353390
ngOnDestroy() {
@@ -376,3 +413,27 @@ export class CdkTreeNode<T> implements FocusableOption, OnDestroy {
376413
this.role = 'treeitem';
377414
}
378415
}
416+
417+
function getParentNodeAriaLevel(nodeElement: HTMLElement): number {
418+
let parent = nodeElement.parentElement;
419+
while (parent && !isNodeElement(parent)) {
420+
parent = parent.parentElement;
421+
}
422+
if (!parent) {
423+
if (typeof ngDevMode === 'undefined' || ngDevMode) {
424+
throw Error('Incorrect tree structure containing detached node.');
425+
} else {
426+
return -1;
427+
}
428+
} else if (parent.classList.contains('cdk-nested-tree-node')) {
429+
return coerceNumberProperty(parent.getAttribute('aria-level')!);
430+
} else {
431+
// The ancestor element is the cdk-tree itself
432+
return 0;
433+
}
434+
}
435+
436+
function isNodeElement(element: HTMLElement) {
437+
const classList = element.classList;
438+
return !!(classList?.contains('cdk-nested-tree-node') || classList?.contains('cdk-tree'));
439+
}

src/material/tree/node.ts

Lines changed: 40 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,11 @@ import {
1717
AfterContentInit,
1818
Attribute,
1919
Directive,
20+
DoCheck,
2021
ElementRef,
2122
Input,
2223
IterableDiffers,
23-
OnDestroy,
24+
OnDestroy, OnInit,
2425
} from '@angular/core';
2526
import {
2627
CanDisable,
@@ -41,25 +42,38 @@ const _MatTreeNodeMixinBase: HasTabIndexCtor & CanDisableCtor & typeof CdkTreeNo
4142
@Directive({
4243
selector: 'mat-tree-node',
4344
exportAs: 'matTreeNode',
44-
inputs: ['disabled', 'tabIndex'],
45-
host: {
46-
'[attr.aria-expanded]': 'isExpanded',
47-
'[attr.aria-level]': 'level + 1',
48-
'[attr.role]': 'role',
49-
'class': 'mat-tree-node'
50-
},
45+
inputs: ['role', 'disabled', 'tabIndex'],
5146
providers: [{provide: CdkTreeNode, useExisting: MatTreeNode}]
5247
})
5348
export class MatTreeNode<T> extends _MatTreeNodeMixinBase<T>
54-
implements CanDisable, HasTabIndex {
55-
@Input() role: 'treeitem' | 'group' = 'treeitem';
49+
implements CanDisable, DoCheck, HasTabIndex, OnInit, OnDestroy {
50+
5651

5752
constructor(protected _elementRef: ElementRef<HTMLElement>,
5853
protected _tree: CdkTree<T>,
5954
@Attribute('tabindex') tabIndex: string) {
6055
super(_elementRef, _tree);
6156

6257
this.tabIndex = Number(tabIndex) || 0;
58+
// The classes are directly added here instead of in the host property because classes on
59+
// the host property are not inherited with View Engine. It is not set as a @HostBinding because
60+
// it is not set by the time it's children nodes try to read the class from it.
61+
// TODO: move to host after View Engine deprecation
62+
this._elementRef.nativeElement.classList.add('mat-tree-node');
63+
}
64+
65+
// This is a workaround for https://github.com/angular/angular/issues/23091
66+
// In aot mode, the lifecycle hooks from parent class are not called.
67+
ngOnInit() {
68+
super.ngOnInit();
69+
}
70+
71+
ngDoCheck() {
72+
super.ngDoCheck();
73+
}
74+
75+
ngOnDestroy() {
76+
super.ngOnDestroy();
6377
}
6478

6579
static ngAcceptInputType_disabled: BooleanInput;
@@ -86,19 +100,15 @@ export class MatTreeNodeDef<T> extends CdkTreeNodeDef<T> {
86100
@Directive({
87101
selector: 'mat-nested-tree-node',
88102
exportAs: 'matNestedTreeNode',
89-
host: {
90-
'[attr.aria-expanded]': 'isExpanded',
91-
'[attr.role]': 'role',
92-
'class': 'mat-nested-tree-node',
93-
},
103+
inputs: ['role', 'disabled', 'tabIndex'],
94104
providers: [
95105
{provide: CdkNestedTreeNode, useExisting: MatNestedTreeNode},
96106
{provide: CdkTreeNode, useExisting: MatNestedTreeNode},
97107
{provide: CDK_TREE_NODE_OUTLET_NODE, useExisting: MatNestedTreeNode}
98108
]
99109
})
100-
export class MatNestedTreeNode<T> extends CdkNestedTreeNode<T> implements AfterContentInit,
101-
OnDestroy {
110+
export class MatNestedTreeNode<T> extends CdkNestedTreeNode<T> implements AfterContentInit, DoCheck,
111+
OnDestroy, OnInit {
102112
@Input('matNestedTreeNode') node: T;
103113

104114
/** Whether the node is disabled. */
@@ -122,11 +132,24 @@ export class MatNestedTreeNode<T> extends CdkNestedTreeNode<T> implements AfterC
122132
@Attribute('tabindex') tabIndex: string) {
123133
super(_elementRef, _tree, _differs);
124134
this.tabIndex = Number(tabIndex) || 0;
135+
// The classes are directly added here instead of in the host property because classes on
136+
// the host property are not inherited with View Engine. It is not set as a @HostBinding because
137+
// it is not set by the time it's children nodes try to read the class from it.
138+
// TODO: move to host after View Engine deprecation
139+
this._elementRef.nativeElement.classList.add('mat-nested-tree-node');
125140
}
126141

127142
// This is a workaround for https://github.com/angular/angular/issues/23091
128143
// In aot mode, the lifecycle hooks from parent class are not called.
129144
// TODO(tinayuangao): Remove when the angular issue #23091 is fixed
145+
ngOnInit() {
146+
super.ngOnInit();
147+
}
148+
149+
ngDoCheck() {
150+
super.ngDoCheck();
151+
}
152+
130153
ngAfterContentInit() {
131154
super.ngAfterContentInit();
132155
}

0 commit comments

Comments
 (0)