From 415435021cbab35b7000a8a71bce48907205f570 Mon Sep 17 00:00:00 2001 From: Cheng-Hsuan Tsai Date: Thu, 30 Apr 2026 04:56:43 +0000 Subject: [PATCH] refactor(aria/tree): use SortedCollection --- goldens/aria/tree/index.api.md | 9 ++++----- src/aria/tree/BUILD.bazel | 1 + src/aria/tree/tree-item.ts | 12 ++++++++---- src/aria/tree/tree.spec.ts | 26 ++++++++++++++++++++++++ src/aria/tree/tree.ts | 36 ++++++++++++++++------------------ 5 files changed, 56 insertions(+), 28 deletions(-) diff --git a/goldens/aria/tree/index.api.md b/goldens/aria/tree/index.api.md index 5ba758c969b8..5867a4cb6e8a 100644 --- a/goldens/aria/tree/index.api.md +++ b/goldens/aria/tree/index.api.md @@ -11,9 +11,10 @@ import { OnInit } from '@angular/core'; import { Signal } from '@angular/core'; // @public -export class Tree { +export class Tree implements OnDestroy { constructor(); readonly activeDescendant: Signal; + readonly _collection: SortedCollection>; readonly currentType: _angular_core.InputSignal<"page" | "step" | "location" | "date" | "time" | "true" | "false">; readonly disabled: _angular_core.InputSignalWithTransform; readonly element: HTMLElement; @@ -21,19 +22,17 @@ export class Tree { readonly id: _angular_core.InputSignal; readonly multi: _angular_core.InputSignalWithTransform; readonly nav: _angular_core.InputSignalWithTransform; + // (undocumented) + ngOnDestroy(): void; readonly orientation: _angular_core.InputSignal<"vertical" | "horizontal">; readonly _pattern: TreePattern; // (undocumented) - _register(child: TreeItem): void; - // (undocumented) scrollActiveItemIntoView(options?: ScrollIntoViewOptions): void; readonly selectionMode: _angular_core.InputSignal<"follow" | "explicit">; readonly softDisabled: _angular_core.InputSignalWithTransform; readonly tabIndex: _angular_core.InputSignalWithTransform; readonly textDirection: _angular_core.WritableSignal<_angular_cdk_bidi.Direction>; readonly typeaheadDelay: _angular_core.InputSignal; - // (undocumented) - _unregister(child: TreeItem): void; readonly value: _angular_core.ModelSignal; readonly wrap: _angular_core.InputSignalWithTransform; // (undocumented) diff --git a/src/aria/tree/BUILD.bazel b/src/aria/tree/BUILD.bazel index c18b29d276f3..a07f63320f92 100644 --- a/src/aria/tree/BUILD.bazel +++ b/src/aria/tree/BUILD.bazel @@ -28,6 +28,7 @@ ng_project( "//:node_modules/@angular/common", "//:node_modules/@angular/core", "//:node_modules/@angular/platform-browser", + "//src/aria/private/testing", "//src/cdk/testing/private", ], ) diff --git a/src/aria/tree/tree-item.ts b/src/aria/tree/tree-item.ts index 52253ad75f86..f31be6495ae4 100644 --- a/src/aria/tree/tree-item.ts +++ b/src/aria/tree/tree-item.ts @@ -138,8 +138,10 @@ export class TreeItem extends DeferredContentAware implements OnInit, OnDestr } ngOnInit() { - this.parent()._register(this); - this.tree()._register(this); + if (this.parent() instanceof TreeItemGroup) { + (this.parent() as TreeItemGroup)._register(this); + } + this.tree()._collection.register(this); const treePattern = computed(() => this.tree()._pattern); const parentPattern = computed(() => { @@ -160,8 +162,10 @@ export class TreeItem extends DeferredContentAware implements OnInit, OnDestr } ngOnDestroy() { - this.parent()._unregister(this); - this.tree()._unregister(this); + if (this.parent() instanceof TreeItemGroup) { + (this.parent() as TreeItemGroup)._unregister(this); + } + this.tree()._collection.unregister(this); } _register(group: TreeItemGroup) { diff --git a/src/aria/tree/tree.spec.ts b/src/aria/tree/tree.spec.ts index 2b0d998c4eb5..b80bfbe4e566 100644 --- a/src/aria/tree/tree.spec.ts +++ b/src/aria/tree/tree.spec.ts @@ -7,6 +7,7 @@ import {provideFakeDirectionality, runAccessibilityChecks} from '@angular/cdk/te import {Tree} from './tree'; import {TreeItem} from './tree-item'; import {TreeItemGroup} from './tree-item-group'; +import {waitForMicrotasks} from '../private/testing/test-helpers'; interface ModifierKeys { ctrlKey?: boolean; @@ -165,6 +166,31 @@ describe('Tree', () => { await runAccessibilityChecks(fixture.nativeElement); }); + describe('dynamic updates', () => { + it('should update item order correctly after items are shuffled', async () => { + setupTestTree(); + expandAll(); + fixture.detectChanges(); + + const treeDirective = fixture.debugElement.query(By.directive(Tree)).injector.get(Tree); + const itemsBefore = treeDirective._pattern.inputs.items(); + expect(itemsBefore.length).toBe(11); + expect(itemsBefore[0].value()).toBe('fruits'); + + // Shuffle top-level nodes: move fruits to end + const nodes = testComponent.nodes(); + const firstNode = nodes.shift()!; + nodes.push(firstNode); + testComponent.nodes.set([...nodes]); + fixture.detectChanges(); + await waitForMicrotasks(); + + const itemsAfter = treeDirective._pattern.inputs.items(); + expect(itemsAfter.length).toBe(11); + expect(itemsAfter[0].value()).toBe('vegetables'); + }); + }); + describe('ARIA attributes and roles', () => { describe('default configuration', () => { beforeEach(() => { diff --git a/src/aria/tree/tree.ts b/src/aria/tree/tree.ts index 3b31b51eb3aa..e018099d6044 100644 --- a/src/aria/tree/tree.ts +++ b/src/aria/tree/tree.ts @@ -7,14 +7,16 @@ */ import { - Directive, - ElementRef, + afterNextRender, afterRenderEffect, booleanAttribute, computed, + Directive, + ElementRef, inject, input, model, + OnDestroy, signal, Signal, untracked, @@ -23,10 +25,10 @@ import {_IdGenerator} from '@angular/cdk/a11y'; import {Directionality} from '@angular/cdk/bidi'; import { ComboboxTreePattern, + SortedCollection, + tabIndexTransform, TreeItemPattern, TreePattern, - sortDirectives, - tabIndexTransform, } from '../private'; import {ComboboxPopup} from '../combobox'; import type {TreeItem} from './tree-item'; @@ -84,7 +86,7 @@ import type {TreeItem} from './tree-item'; }, hostDirectives: [ComboboxPopup], }) -export class Tree { +export class Tree implements OnDestroy { /** A reference to the host element. */ private readonly _elementRef = inject(ElementRef); @@ -96,8 +98,8 @@ export class Tree { optional: true, }); - /** All TreeItem instances within this tree. */ - private readonly _unorderedItems = signal(new Set>()); + /** The collection of tree items. */ + readonly _collection = new SortedCollection>(); /** A unique identifier for the tree. */ readonly id = input(inject(_IdGenerator).getId('ng-tree-', true)); @@ -170,9 +172,7 @@ export class Tree { const inputs = { ...this, id: this.id, - items: computed(() => - [...this._unorderedItems()].sort(sortDirectives).map(item => item._pattern), - ), + items: computed(() => this._collection.orderedItems().map(item => item._pattern)), activeItem: signal | undefined>(undefined), combobox: () => this._popup?.combobox?._pattern, element: () => this.element, @@ -184,11 +184,15 @@ export class Tree { this.activeDescendant = computed(() => this._pattern.activeDescendant()); + afterNextRender(() => { + this._collection.startObserving(this.element); + }); + if (this._popup?.combobox) { this._popup?._controls?.set(this._pattern as ComboboxTreePattern); } - // Check for any violationns after the DOM has been updated. + // Check for any violations after the DOM has been updated. afterRenderEffect({ read: () => { if (typeof ngDevMode === 'undefined' || ngDevMode) { @@ -228,14 +232,8 @@ export class Tree { }); } - _register(child: TreeItem) { - this._unorderedItems().add(child); - this._unorderedItems.set(new Set(this._unorderedItems())); - } - - _unregister(child: TreeItem) { - this._unorderedItems().delete(child); - this._unorderedItems.set(new Set(this._unorderedItems())); + ngOnDestroy() { + this._collection.stopObserving(); } scrollActiveItemIntoView(options: ScrollIntoViewOptions = {block: 'nearest'}) {