Skip to content

Commit 38cfe94

Browse files
authored
fix(cdk/tree): assorted bug fixes (#28305)
* fix(cdk/tree): fix errors from testing * fix(cdk/tree): tests * fix(cdk/tree): update api docs * fix(cdk/a11y): allows disabled items to receive initial focus * fix(cdk/tree): don't focus on click, corrected updating aria-sets * fix(cdk/tree): update api goldens
1 parent ee50445 commit 38cfe94

File tree

8 files changed

+69
-17
lines changed

8 files changed

+69
-17
lines changed

src/cdk/tree/tree-with-tree-control.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1174,6 +1174,7 @@ describe('CdkTree', () => {
11741174
it('maintains tabindex when component is blurred', () => {
11751175
// activate the second child by clicking on it
11761176
nodes[1].click();
1177+
nodes[1].focus();
11771178
fixture.detectChanges();
11781179

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

src/cdk/tree/tree.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1216,6 +1216,7 @@ describe('CdkTree', () => {
12161216
it('maintains tabindex when component is blurred', () => {
12171217
// activate the second child by clicking on it
12181218
nodes[1].click();
1219+
nodes[1].focus();
12191220
fixture.detectChanges();
12201221

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

src/cdk/tree/tree.ts

Lines changed: 51 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
import {
2424
AfterContentChecked,
2525
AfterContentInit,
26+
AfterViewInit,
2627
ChangeDetectionStrategy,
2728
ChangeDetectorRef,
2829
Component,
@@ -111,7 +112,13 @@ type RenderingData<T> =
111112
imports: [CdkTreeNodeOutlet],
112113
})
113114
export class CdkTree<T, K = T>
114-
implements AfterContentChecked, AfterContentInit, CollectionViewer, OnDestroy, OnInit
115+
implements
116+
AfterContentChecked,
117+
AfterContentInit,
118+
AfterViewInit,
119+
CollectionViewer,
120+
OnDestroy,
121+
OnInit
115122
{
116123
/** Subject that emits when the component has been destroyed. */
117124
private readonly _onDestroy = new Subject<void>();
@@ -248,6 +255,7 @@ export class CdkTree<T, K = T>
248255

249256
/** The key manager for this tree. Handles focus and activation based on user keyboard input. */
250257
_keyManager: TreeKeyManagerStrategy<CdkTreeNode<T, K>>;
258+
private _viewInit = false;
251259

252260
constructor(
253261
private _differs: IterableDiffers,
@@ -280,14 +288,20 @@ export class CdkTree<T, K = T>
280288
this._dataSubscription = null;
281289
}
282290

283-
this._keyManager.destroy();
291+
// In certain tests, the tree might be destroyed before this is initialized
292+
// in `ngAfterContentInit`.
293+
this._keyManager?.destroy();
284294
}
285295

286296
ngOnInit() {
287297
this._checkTreeControlUsage();
288298
this._initializeDataDiffer();
289299
}
290300

301+
ngAfterViewInit() {
302+
this._viewInit = true;
303+
}
304+
291305
private _updateDefaultNodeDefinition() {
292306
const defaultNodeDefs = this._nodeDefs.filter(def => !def.when);
293307
if (defaultNodeDefs.length > 1 && (typeof ngDevMode === 'undefined' || ngDevMode)) {
@@ -449,7 +463,9 @@ export class CdkTree<T, K = T>
449463
}
450464

451465
private _initializeDataDiffer() {
452-
this._dataDiffer = this._differs.find([]).create(this.trackBy);
466+
// Provide a default trackBy based on `_getExpansionKey` if one isn't provided.
467+
const trackBy = this.trackBy ?? ((_index: number, item: T) => this._getExpansionKey(item));
468+
this._dataDiffer = this._differs.find([]).create(trackBy);
453469
}
454470

455471
private _checkTreeControlUsage() {
@@ -484,11 +500,19 @@ export class CdkTree<T, K = T>
484500
parentData?: T,
485501
) {
486502
const changes = dataDiffer.diff(data);
487-
if (!changes) {
503+
504+
// Some tree consumers expect change detection to propagate to nodes
505+
// even when the array itself hasn't changed; we explicitly detect changes
506+
// anyways in order for nodes to update their data.
507+
//
508+
// However, if change detection is called while the component's view is
509+
// still initing, then the order of child views initing will be incorrect;
510+
// to prevent this, we only exit early if the view hasn't initialized yet.
511+
if (!changes && !this._viewInit) {
488512
return;
489513
}
490514

491-
changes.forEachOperation(
515+
changes?.forEachOperation(
492516
(
493517
item: IterableChangeRecord<T>,
494518
adjustedPreviousIndex: number | null,
@@ -498,12 +522,6 @@ export class CdkTree<T, K = T>
498522
this.insertNode(data[currentIndex!], currentIndex!, viewContainer, parentData);
499523
} else if (currentIndex == null) {
500524
viewContainer.remove(adjustedPreviousIndex!);
501-
const set = this._getAriaSet(item.item);
502-
const key = this._getExpansionKey(item.item);
503-
set.splice(
504-
set.findIndex(groupItem => this._getExpansionKey(groupItem) === key),
505-
1,
506-
);
507525
} else {
508526
const view = viewContainer.get(adjustedPreviousIndex!);
509527
viewContainer.move(view!, currentIndex);
@@ -682,12 +700,12 @@ export class CdkTree<T, K = T>
682700

683701
/** Level accessor, used for compatibility between the old Tree and new Tree */
684702
_getLevelAccessor() {
685-
return this.treeControl?.getLevel ?? this.levelAccessor;
703+
return this.treeControl?.getLevel?.bind(this.treeControl) ?? this.levelAccessor;
686704
}
687705

688706
/** Children accessor, used for compatibility between the old Tree and new Tree */
689707
_getChildrenAccessor() {
690-
return this.treeControl?.getChildren ?? this.childrenAccessor;
708+
return this.treeControl?.getChildren?.bind(this.treeControl) ?? this.childrenAccessor;
691709
}
692710

693711
/**
@@ -1094,7 +1112,7 @@ export class CdkTree<T, K = T>
10941112
'[attr.aria-setsize]': '_getSetSize()',
10951113
'[tabindex]': '_tabindex',
10961114
'role': 'treeitem',
1097-
'(click)': '_focusItem()',
1115+
'(click)': '_setActiveItem()',
10981116
'(focus)': '_focusItem()',
10991117
},
11001118
standalone: true,
@@ -1172,6 +1190,13 @@ export class CdkTreeNode<T, K = T> implements OnDestroy, OnInit, TreeKeyManagerI
11721190
readonly _dataChanges = new Subject<void>();
11731191

11741192
private _inputIsExpandable: boolean = false;
1193+
/**
1194+
* Flag used to determine whether or not we should be focusing the actual element based on
1195+
* some user interaction (click or focus). On click, we don't forcibly focus the element
1196+
* since the click could trigger some other component that wants to grab its own focus
1197+
* (e.g. menu, dialog).
1198+
*/
1199+
private _shouldFocus = true;
11751200
private _parentNodeAriaLevel: number;
11761201

11771202
/** The tree node's data. */
@@ -1273,7 +1298,9 @@ export class CdkTreeNode<T, K = T> implements OnDestroy, OnInit, TreeKeyManagerI
12731298
/** Focuses this data node. Implemented for TreeKeyManagerItem. */
12741299
focus(): void {
12751300
this._tabindex = 0;
1276-
this._elementRef.nativeElement.focus();
1301+
if (this._shouldFocus) {
1302+
this._elementRef.nativeElement.focus();
1303+
}
12771304

12781305
this._changeDetectorRef.markForCheck();
12791306
}
@@ -1314,6 +1341,15 @@ export class CdkTreeNode<T, K = T> implements OnDestroy, OnInit, TreeKeyManagerI
13141341
this._tree._keyManager.focusItem(this);
13151342
}
13161343

1344+
_setActiveItem() {
1345+
if (this.isDisabled) {
1346+
return;
1347+
}
1348+
this._shouldFocus = false;
1349+
this._tree._keyManager.focusItem(this);
1350+
this._shouldFocus = true;
1351+
}
1352+
13171353
_emitExpansionState(expanded: boolean) {
13181354
this.expandedChange.emit(expanded);
13191355
}

src/material/tree/testing/node-harness.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ export class MatTreeNodeHarness extends ContentContainerComponentHarness<string>
3535
return coerceBooleanProperty(await (await this.host()).getAttribute('aria-expanded'));
3636
}
3737

38+
/** Whether the tree node is expandable. */
39+
async isExpandable(): Promise<boolean> {
40+
return (await (await this.host()).getAttribute('aria-expanded')) !== null;
41+
}
42+
3843
/** Whether the tree node is disabled. */
3944
async isDisabled(): Promise<boolean> {
4045
return coerceBooleanProperty(await (await this.host()).getProperty('aria-disabled'));

src/material/tree/tree-using-tree-control.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -602,6 +602,7 @@ describe('MatTree', () => {
602602
it('maintains tabindex when component is blurred', () => {
603603
// activate the second child by clicking on it
604604
nodes[1].click();
605+
nodes[1].focus();
605606
fixture.detectChanges();
606607

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

src/material/tree/tree.spec.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -587,6 +587,7 @@ describe('MatTree', () => {
587587
it('maintains tabindex when component is blurred', () => {
588588
// activate the second child by clicking on it
589589
nodes[1].click();
590+
nodes[1].focus();
590591
fixture.detectChanges();
591592

592593
expect(nodes.map(x => x.getAttribute('tabindex')).join(', ')).toEqual(
@@ -604,11 +605,12 @@ describe('MatTree', () => {
604605
});
605606

606607
it('ignores clicks on disabled items', () => {
607-
underlyingDataSource.data[0].isDisabled = true;
608+
underlyingDataSource.data[1].isDisabled = true;
608609
fixture.detectChanges();
609610

610611
// attempt to click on the first child
611612
nodes[1].click();
613+
fixture.detectChanges();
612614

613615
expect(nodes.map(x => x.getAttribute('tabindex')).join(', ')).toEqual(
614616
'0, -1, -1, -1, -1, -1',

tools/public_api_guard/cdk/tree.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import { AfterContentChecked } from '@angular/core';
88
import { AfterContentInit } from '@angular/core';
9+
import { AfterViewInit } from '@angular/core';
910
import { BehaviorSubject } from 'rxjs';
1011
import { ChangeDetectorRef } from '@angular/core';
1112
import { CollectionViewer } from '@angular/cdk/collections';
@@ -76,7 +77,7 @@ export class CdkNestedTreeNode<T, K = T> extends CdkTreeNode<T, K> implements Af
7677
}
7778

7879
// @public
79-
export class CdkTree<T, K = T> implements AfterContentChecked, AfterContentInit, CollectionViewer, OnDestroy, OnInit {
80+
export class CdkTree<T, K = T> implements AfterContentChecked, AfterContentInit, AfterViewInit, CollectionViewer, OnDestroy, OnInit {
8081
constructor(_differs: IterableDiffers, _changeDetectorRef: ChangeDetectorRef, _dir: Directionality);
8182
childrenAccessor?: (dataNode: T) => T[] | Observable<T[]>;
8283
collapse(dataNode: T): void;
@@ -106,6 +107,8 @@ export class CdkTree<T, K = T> implements AfterContentChecked, AfterContentInit,
106107
// (undocumented)
107108
ngAfterContentInit(): void;
108109
// (undocumented)
110+
ngAfterViewInit(): void;
111+
// (undocumented)
109112
ngOnDestroy(): void;
110113
// (undocumented)
111114
ngOnInit(): void;
@@ -194,6 +197,8 @@ export class CdkTreeNode<T, K = T> implements OnDestroy, OnInit, TreeKeyManagerI
194197
get role(): 'treeitem' | 'group';
195198
set role(_role: 'treeitem' | 'group');
196199
// (undocumented)
200+
_setActiveItem(): void;
201+
// (undocumented)
197202
protected _tabindex: number | null;
198203
// (undocumented)
199204
protected _tree: CdkTree<T, K>;

tools/public_api_guard/material/tree-testing.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export class MatTreeNodeHarness extends ContentContainerComponentHarness<string>
2727
getText(): Promise<string>;
2828
static hostSelector: string;
2929
isDisabled(): Promise<boolean>;
30+
isExpandable(): Promise<boolean>;
3031
isExpanded(): Promise<boolean>;
3132
toggle(): Promise<void>;
3233
// (undocumented)

0 commit comments

Comments
 (0)