diff --git a/src/cdk/tree/tree-with-tree-control.spec.ts b/src/cdk/tree/tree-with-tree-control.spec.ts index bb2a8495799a..41ab5d3aed09 100644 --- a/src/cdk/tree/tree-with-tree-control.spec.ts +++ b/src/cdk/tree/tree-with-tree-control.spec.ts @@ -1174,6 +1174,7 @@ describe('CdkTree', () => { it('maintains tabindex when component is blurred', () => { // activate the second child by clicking on it nodes[1].click(); + nodes[1].focus(); fixture.detectChanges(); expect(document.activeElement).toBe(nodes[1]); diff --git a/src/cdk/tree/tree.spec.ts b/src/cdk/tree/tree.spec.ts index 3e8e33d81daa..fac87f95adc4 100644 --- a/src/cdk/tree/tree.spec.ts +++ b/src/cdk/tree/tree.spec.ts @@ -1216,6 +1216,7 @@ describe('CdkTree', () => { it('maintains tabindex when component is blurred', () => { // activate the second child by clicking on it nodes[1].click(); + nodes[1].focus(); fixture.detectChanges(); expect(document.activeElement).toBe(nodes[1]); diff --git a/src/cdk/tree/tree.ts b/src/cdk/tree/tree.ts index 4631a4a16d2b..f90cee83207f 100644 --- a/src/cdk/tree/tree.ts +++ b/src/cdk/tree/tree.ts @@ -23,6 +23,7 @@ import { import { AfterContentChecked, AfterContentInit, + AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, @@ -111,7 +112,13 @@ type RenderingData = imports: [CdkTreeNodeOutlet], }) export class CdkTree - implements AfterContentChecked, AfterContentInit, CollectionViewer, OnDestroy, OnInit + implements + AfterContentChecked, + AfterContentInit, + AfterViewInit, + CollectionViewer, + OnDestroy, + OnInit { /** Subject that emits when the component has been destroyed. */ private readonly _onDestroy = new Subject(); @@ -248,6 +255,7 @@ export class CdkTree /** The key manager for this tree. Handles focus and activation based on user keyboard input. */ _keyManager: TreeKeyManagerStrategy>; + private _viewInit = false; constructor( private _differs: IterableDiffers, @@ -280,7 +288,9 @@ export class CdkTree this._dataSubscription = null; } - this._keyManager.destroy(); + // In certain tests, the tree might be destroyed before this is initialized + // in `ngAfterContentInit`. + this._keyManager?.destroy(); } ngOnInit() { @@ -288,6 +298,10 @@ export class CdkTree this._initializeDataDiffer(); } + ngAfterViewInit() { + this._viewInit = true; + } + private _updateDefaultNodeDefinition() { const defaultNodeDefs = this._nodeDefs.filter(def => !def.when); if (defaultNodeDefs.length > 1 && (typeof ngDevMode === 'undefined' || ngDevMode)) { @@ -449,7 +463,9 @@ export class CdkTree } private _initializeDataDiffer() { - this._dataDiffer = this._differs.find([]).create(this.trackBy); + // Provide a default trackBy based on `_getExpansionKey` if one isn't provided. + const trackBy = this.trackBy ?? ((_index: number, item: T) => this._getExpansionKey(item)); + this._dataDiffer = this._differs.find([]).create(trackBy); } private _checkTreeControlUsage() { @@ -484,11 +500,19 @@ export class CdkTree parentData?: T, ) { const changes = dataDiffer.diff(data); - if (!changes) { + + // Some tree consumers expect change detection to propagate to nodes + // even when the array itself hasn't changed; we explicitly detect changes + // anyways in order for nodes to update their data. + // + // However, if change detection is called while the component's view is + // still initing, then the order of child views initing will be incorrect; + // to prevent this, we only exit early if the view hasn't initialized yet. + if (!changes && !this._viewInit) { return; } - changes.forEachOperation( + changes?.forEachOperation( ( item: IterableChangeRecord, adjustedPreviousIndex: number | null, @@ -498,12 +522,6 @@ export class CdkTree this.insertNode(data[currentIndex!], currentIndex!, viewContainer, parentData); } else if (currentIndex == null) { viewContainer.remove(adjustedPreviousIndex!); - const set = this._getAriaSet(item.item); - const key = this._getExpansionKey(item.item); - set.splice( - set.findIndex(groupItem => this._getExpansionKey(groupItem) === key), - 1, - ); } else { const view = viewContainer.get(adjustedPreviousIndex!); viewContainer.move(view!, currentIndex); @@ -682,12 +700,12 @@ export class CdkTree /** Level accessor, used for compatibility between the old Tree and new Tree */ _getLevelAccessor() { - return this.treeControl?.getLevel ?? this.levelAccessor; + return this.treeControl?.getLevel?.bind(this.treeControl) ?? this.levelAccessor; } /** Children accessor, used for compatibility between the old Tree and new Tree */ _getChildrenAccessor() { - return this.treeControl?.getChildren ?? this.childrenAccessor; + return this.treeControl?.getChildren?.bind(this.treeControl) ?? this.childrenAccessor; } /** @@ -1094,7 +1112,7 @@ export class CdkTree '[attr.aria-setsize]': '_getSetSize()', '[tabindex]': '_tabindex', 'role': 'treeitem', - '(click)': '_focusItem()', + '(click)': '_setActiveItem()', '(focus)': '_focusItem()', }, standalone: true, @@ -1172,6 +1190,13 @@ export class CdkTreeNode implements OnDestroy, OnInit, TreeKeyManagerI readonly _dataChanges = new Subject(); private _inputIsExpandable: boolean = false; + /** + * Flag used to determine whether or not we should be focusing the actual element based on + * some user interaction (click or focus). On click, we don't forcibly focus the element + * since the click could trigger some other component that wants to grab its own focus + * (e.g. menu, dialog). + */ + private _shouldFocus = true; private _parentNodeAriaLevel: number; /** The tree node's data. */ @@ -1273,7 +1298,9 @@ export class CdkTreeNode implements OnDestroy, OnInit, TreeKeyManagerI /** Focuses this data node. Implemented for TreeKeyManagerItem. */ focus(): void { this._tabindex = 0; - this._elementRef.nativeElement.focus(); + if (this._shouldFocus) { + this._elementRef.nativeElement.focus(); + } this._changeDetectorRef.markForCheck(); } @@ -1314,6 +1341,15 @@ export class CdkTreeNode implements OnDestroy, OnInit, TreeKeyManagerI this._tree._keyManager.focusItem(this); } + _setActiveItem() { + if (this.isDisabled) { + return; + } + this._shouldFocus = false; + this._tree._keyManager.focusItem(this); + this._shouldFocus = true; + } + _emitExpansionState(expanded: boolean) { this.expandedChange.emit(expanded); } diff --git a/src/material/tree/testing/node-harness.ts b/src/material/tree/testing/node-harness.ts index b97fcf94906b..4a771f47e649 100644 --- a/src/material/tree/testing/node-harness.ts +++ b/src/material/tree/testing/node-harness.ts @@ -35,6 +35,11 @@ export class MatTreeNodeHarness extends ContentContainerComponentHarness return coerceBooleanProperty(await (await this.host()).getAttribute('aria-expanded')); } + /** Whether the tree node is expandable. */ + async isExpandable(): Promise { + return (await (await this.host()).getAttribute('aria-expanded')) !== null; + } + /** Whether the tree node is disabled. */ async isDisabled(): Promise { return coerceBooleanProperty(await (await this.host()).getProperty('aria-disabled')); diff --git a/src/material/tree/tree-using-tree-control.spec.ts b/src/material/tree/tree-using-tree-control.spec.ts index 909ddbf62c4a..5636c15c8761 100644 --- a/src/material/tree/tree-using-tree-control.spec.ts +++ b/src/material/tree/tree-using-tree-control.spec.ts @@ -602,6 +602,7 @@ describe('MatTree', () => { it('maintains tabindex when component is blurred', () => { // activate the second child by clicking on it nodes[1].click(); + nodes[1].focus(); fixture.detectChanges(); expect(document.activeElement).toBe(nodes[1]); diff --git a/src/material/tree/tree.spec.ts b/src/material/tree/tree.spec.ts index 46a72fca1f35..17cb229927a9 100644 --- a/src/material/tree/tree.spec.ts +++ b/src/material/tree/tree.spec.ts @@ -587,6 +587,7 @@ describe('MatTree', () => { it('maintains tabindex when component is blurred', () => { // activate the second child by clicking on it nodes[1].click(); + nodes[1].focus(); fixture.detectChanges(); expect(nodes.map(x => x.getAttribute('tabindex')).join(', ')).toEqual( @@ -604,11 +605,12 @@ describe('MatTree', () => { }); it('ignores clicks on disabled items', () => { - underlyingDataSource.data[0].isDisabled = true; + underlyingDataSource.data[1].isDisabled = true; fixture.detectChanges(); // attempt to click on the first child nodes[1].click(); + fixture.detectChanges(); expect(nodes.map(x => x.getAttribute('tabindex')).join(', ')).toEqual( '0, -1, -1, -1, -1, -1', diff --git a/tools/public_api_guard/cdk/tree.md b/tools/public_api_guard/cdk/tree.md index ed2db380b279..d21f0db7bb53 100644 --- a/tools/public_api_guard/cdk/tree.md +++ b/tools/public_api_guard/cdk/tree.md @@ -6,6 +6,7 @@ import { AfterContentChecked } from '@angular/core'; import { AfterContentInit } from '@angular/core'; +import { AfterViewInit } from '@angular/core'; import { BehaviorSubject } from 'rxjs'; import { ChangeDetectorRef } from '@angular/core'; import { CollectionViewer } from '@angular/cdk/collections'; @@ -76,7 +77,7 @@ export class CdkNestedTreeNode extends CdkTreeNode implements Af } // @public -export class CdkTree implements AfterContentChecked, AfterContentInit, CollectionViewer, OnDestroy, OnInit { +export class CdkTree implements AfterContentChecked, AfterContentInit, AfterViewInit, CollectionViewer, OnDestroy, OnInit { constructor(_differs: IterableDiffers, _changeDetectorRef: ChangeDetectorRef, _dir: Directionality); childrenAccessor?: (dataNode: T) => T[] | Observable; collapse(dataNode: T): void; @@ -106,6 +107,8 @@ export class CdkTree implements AfterContentChecked, AfterContentInit, // (undocumented) ngAfterContentInit(): void; // (undocumented) + ngAfterViewInit(): void; + // (undocumented) ngOnDestroy(): void; // (undocumented) ngOnInit(): void; @@ -194,6 +197,8 @@ export class CdkTreeNode implements OnDestroy, OnInit, TreeKeyManagerI get role(): 'treeitem' | 'group'; set role(_role: 'treeitem' | 'group'); // (undocumented) + _setActiveItem(): void; + // (undocumented) protected _tabindex: number | null; // (undocumented) protected _tree: CdkTree; diff --git a/tools/public_api_guard/material/tree-testing.md b/tools/public_api_guard/material/tree-testing.md index 84223f738ab0..86a25118e453 100644 --- a/tools/public_api_guard/material/tree-testing.md +++ b/tools/public_api_guard/material/tree-testing.md @@ -27,6 +27,7 @@ export class MatTreeNodeHarness extends ContentContainerComponentHarness getText(): Promise; static hostSelector: string; isDisabled(): Promise; + isExpandable(): Promise; isExpanded(): Promise; toggle(): Promise; // (undocumented)