From 7dc8c1a4f49b5c5e27bae90782d229a9ee783bb0 Mon Sep 17 00:00:00 2001 From: Cheng-Hsuan Tsai Date: Fri, 27 Jun 2025 23:23:05 +0000 Subject: [PATCH] feat(cdk-experimental/tree): add nav mode --- src/cdk-experimental/tree/tree.spec.ts | 29 ++++++++ src/cdk-experimental/tree/tree.ts | 9 +++ .../ui-patterns/tree/tree.spec.ts | 68 +++++++++++++++++++ src/cdk-experimental/ui-patterns/tree/tree.ts | 25 ++++++- .../tree/cdk-tree/cdk-tree-example.css | 10 +++ .../tree/cdk-tree/cdk-tree-example.html | 12 +++- .../tree/cdk-tree/cdk-tree-example.ts | 43 ++++++++++++ 7 files changed, 192 insertions(+), 4 deletions(-) diff --git a/src/cdk-experimental/tree/tree.spec.ts b/src/cdk-experimental/tree/tree.spec.ts index 22d33e0ede77..8728c8bf5005 100644 --- a/src/cdk-experimental/tree/tree.spec.ts +++ b/src/cdk-experimental/tree/tree.spec.ts @@ -88,6 +88,8 @@ describe('CdkTree', () => { skipDisabled?: boolean; focusMode?: 'roving' | 'activedescendant'; selectionMode?: 'follow' | 'explicit'; + nav?: boolean; + currentType?: 'page' | 'step' | 'location' | 'date' | 'time' | 'true' | 'false'; } = {}, ) { if (config.nodes !== undefined) testComponent.nodes.set(config.nodes); @@ -99,6 +101,8 @@ describe('CdkTree', () => { if (config.skipDisabled !== undefined) testComponent.skipDisabled.set(config.skipDisabled); if (config.focusMode !== undefined) testComponent.focusMode.set(config.focusMode); if (config.selectionMode !== undefined) testComponent.selectionMode.set(config.selectionMode); + if (config.nav !== undefined) testComponent.nav.set(config.nav); + if (config.currentType !== undefined) testComponent.currentType.set(config.currentType); fixture.detectChanges(); defineTestVariables(); @@ -305,6 +309,27 @@ describe('CdkTree', () => { const fruitsItem = getTreeItemElementByValue('fruits')!; expect(fruitsItem.getAttribute('aria-expanded')).toBe('true'); }); + + it('should set aria-current to specific current type when nav="true"', () => { + updateTree({nav: true, value: ['apple']}); + + const appleItem = getTreeItemElementByValue('apple')!; + const bananaItem = getTreeItemElementByValue('banana')!; + expect(appleItem.getAttribute('aria-current')).toBe('page'); + expect(bananaItem.hasAttribute('aria-current')).toBe(false); + + updateTree({currentType: 'location'}); + expect(appleItem.getAttribute('aria-current')).toBe('location'); + }); + + it('should not set aria-selected when nav="true"', () => { + updateTree({value: ['apple'], nav: true}); + const appleItem = getTreeItemElementByValue('apple')!; + expect(appleItem.hasAttribute('aria-selected')).toBe(false); + + updateTree({nav: false}); + expect(appleItem.getAttribute('aria-selected')).toBe('true'); + }); }); describe('roving focus mode (focusMode="roving")', () => { @@ -1310,6 +1335,8 @@ interface TestTreeNode { [orientation]="orientation()" [disabled]="disabled()" [(value)]="value" + [nav]="nav()" + [currentType]="currentType()" > @for (node of nodes(); track node.value) {
  • ('roving'); selectionMode = signal<'explicit' | 'follow'>('explicit'); + nav = signal(false); + currentType = signal('page'); } diff --git a/src/cdk-experimental/tree/tree.ts b/src/cdk-experimental/tree/tree.ts index e317a0da1322..77f3e2eccf56 100644 --- a/src/cdk-experimental/tree/tree.ts +++ b/src/cdk-experimental/tree/tree.ts @@ -112,6 +112,14 @@ export class CdkTree { /** Text direction. */ readonly textDirection = inject(Directionality).valueSignal; + /** Whether the tree is in navigation mode. */ + readonly nav = input(false); + + /** The aria-current type. */ + readonly currentType = input<'page' | 'step' | 'location' | 'date' | 'time' | 'true' | 'false'>( + 'page', + ); + /** The UI pattern for the tree. */ readonly pattern: TreePattern = new TreePattern({ ...this, @@ -174,6 +182,7 @@ export class CdkTree { '[id]': 'pattern.id()', '[attr.aria-expanded]': 'pattern.expandable() ? pattern.expanded() : null', '[attr.aria-selected]': 'pattern.selected()', + '[attr.aria-current]': 'pattern.current()', '[attr.aria-disabled]': 'pattern.disabled()', '[attr.aria-level]': 'pattern.level()', '[attr.aria-owns]': 'group()?.id', diff --git a/src/cdk-experimental/ui-patterns/tree/tree.spec.ts b/src/cdk-experimental/ui-patterns/tree/tree.spec.ts index 47d6370bf948..1f894bbf984a 100644 --- a/src/cdk-experimental/ui-patterns/tree/tree.spec.ts +++ b/src/cdk-experimental/ui-patterns/tree/tree.spec.ts @@ -146,6 +146,8 @@ describe('Tree Pattern', () => { typeaheadDelay: signal(0), value: signal([]), wrap: signal(false), + nav: signal(false), + currentType: signal('page'), }; }); @@ -179,6 +181,50 @@ describe('Tree Pattern', () => { expect(item0_0.posinset()).toBe(1); expect(item0_1.posinset()).toBe(2); }); + + describe('nav mode', () => { + let treeInputs: TestTreeInputs; + + beforeEach(() => { + treeInputs = { + activeIndex: signal(0), + disabled: signal(false), + focusMode: signal('roving'), + multi: signal(false), + orientation: signal('vertical'), + selectionMode: signal('follow'), + skipDisabled: signal(true), + textDirection: signal('ltr'), + typeaheadDelay: signal(0), + value: signal([]), + wrap: signal(false), + nav: signal(true), + currentType: signal('page'), + }; + }); + + it('should have undefined selected state', () => { + const {allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + treeInputs.value.set(['Item 0']); + expect(item0.selected()).toBeUndefined(); + }); + + it('should correctly compute current state', () => { + const {allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + const item1 = getItemByValue(allItems(), 'Item 1'); + + treeInputs.value.set(['Item 0']); + expect(item0.current()).toBe('page'); + expect(item1.current()).toBeUndefined(); + + treeInputs.value.set(['Item 1']); + treeInputs.currentType.set('step'); + expect(item0.current()).toBeUndefined(); + expect(item1.current()).toBe('step'); + }); + }); }); describe('Keyboard Navigation', () => { @@ -197,6 +243,8 @@ describe('Tree Pattern', () => { typeaheadDelay: signal(0), value: signal([]), wrap: signal(false), + nav: signal(false), + currentType: signal('page'), }; }); @@ -379,6 +427,8 @@ describe('Tree Pattern', () => { typeaheadDelay: signal(0), value: signal([]), wrap: signal(false), + nav: signal(false), + currentType: signal('page'), }; }); @@ -435,6 +485,8 @@ describe('Tree Pattern', () => { typeaheadDelay: signal(0), value: signal([]), wrap: signal(false), + nav: signal(false), + currentType: signal('page'), }; }); @@ -497,6 +549,8 @@ describe('Tree Pattern', () => { typeaheadDelay: signal(0), value: signal([]), wrap: signal(false), + nav: signal(false), + currentType: signal('page'), }; }); @@ -653,6 +707,8 @@ describe('Tree Pattern', () => { typeaheadDelay: signal(0), value: signal([]), wrap: signal(false), + nav: signal(false), + currentType: signal('page'), }; }); @@ -801,6 +857,8 @@ describe('Tree Pattern', () => { typeaheadDelay: signal(0), value: signal([]), wrap: signal(false), + nav: signal(false), + currentType: signal('page'), }; }); @@ -839,6 +897,8 @@ describe('Tree Pattern', () => { typeaheadDelay: signal(0), value: signal([]), wrap: signal(false), + nav: signal(false), + currentType: signal('page'), }; }); @@ -881,6 +941,8 @@ describe('Tree Pattern', () => { typeaheadDelay: signal(0), value: signal([]), wrap: signal(false), + nav: signal(false), + currentType: signal('page'), }; }); @@ -927,6 +989,8 @@ describe('Tree Pattern', () => { typeaheadDelay: signal(0), value: signal([]), wrap: signal(false), + nav: signal(false), + currentType: signal('page'), }; }); @@ -1007,6 +1071,8 @@ describe('Tree Pattern', () => { typeaheadDelay: signal(0), value: signal([]), wrap: signal(false), + nav: signal(false), + currentType: signal('page'), }; }); @@ -1167,6 +1233,8 @@ describe('Tree Pattern', () => { typeaheadDelay: signal(0), value: signal([]), wrap: signal(false), + nav: signal(false), + currentType: signal('page'), }; }); diff --git a/src/cdk-experimental/ui-patterns/tree/tree.ts b/src/cdk-experimental/ui-patterns/tree/tree.ts index d67b8930254c..9656e74a782f 100644 --- a/src/cdk-experimental/ui-patterns/tree/tree.ts +++ b/src/cdk-experimental/ui-patterns/tree/tree.ts @@ -87,7 +87,20 @@ export class TreeItemPattern implements ExpansionItem { readonly tabindex = computed(() => this.tree().focusManager.getItemTabindex(this)); /** Whether the item is selected. */ - readonly selected = computed(() => this.tree().value().includes(this.value())); + readonly selected = computed(() => { + if (this.tree().nav()) { + return undefined; + } + return this.tree().value().includes(this.value()); + }); + + /** The current type of this item. */ + readonly current = computed(() => { + if (!this.tree().nav()) { + return undefined; + } + return this.tree().value().includes(this.value()) ? this.tree().currentType() : undefined; + }); constructor(readonly inputs: TreeItemInputs) { this.id = inputs.id; @@ -136,6 +149,12 @@ export interface TreeInputs > { /** All items in the tree, in document order (DFS-like, a flattened list). */ allItems: SignalLike[]>; + + /** Whether the tree is in navigation mode. */ + nav: SignalLike; + + /** The aria-current type. */ + currentType: SignalLike<'page' | 'step' | 'location' | 'date' | 'time' | 'true' | 'false'>; } export interface TreePattern extends TreeInputs {} @@ -337,6 +356,8 @@ export class TreePattern { }); constructor(readonly inputs: TreeInputs) { + this.nav = inputs.nav; + this.currentType = inputs.currentType; this.allItems = inputs.allItems; this.focusMode = inputs.focusMode; this.disabled = inputs.disabled; @@ -345,7 +366,7 @@ export class TreePattern { this.wrap = inputs.wrap; this.orientation = inputs.orientation; this.textDirection = inputs.textDirection; - this.multi = inputs.multi; + this.multi = computed(() => (this.nav() ? false : this.inputs.multi())); this.value = inputs.value; this.selectionMode = inputs.selectionMode; this.typeaheadDelay = inputs.typeaheadDelay; diff --git a/src/components-examples/cdk-experimental/tree/cdk-tree/cdk-tree-example.css b/src/components-examples/cdk-experimental/tree/cdk-tree/cdk-tree-example.css index 004c5a8a48c6..a62a3fb86441 100644 --- a/src/components-examples/cdk-experimental/tree/cdk-tree/cdk-tree-example.css +++ b/src/components-examples/cdk-experimental/tree/cdk-tree/cdk-tree-example.css @@ -37,6 +37,16 @@ color: var(--mat-sys-on-surface-variant); } +.example-tree-item-content[aria-current] { + background-color: var(--mat-sys-inverse-primary); +} + +.example-tree-item-content[aria-disabled='true'] { + background-color: var(--mat-sys-surface-container); + color: var(--mat-sys-on-surface-variant); +} + + .example-tree-item-content { display: flex; align-items: center; diff --git a/src/components-examples/cdk-experimental/tree/cdk-tree/cdk-tree-example.html b/src/components-examples/cdk-experimental/tree/cdk-tree/cdk-tree-example.html index c2c1652ff335..09b4c3507352 100644 --- a/src/components-examples/cdk-experimental/tree/cdk-tree/cdk-tree-example.html +++ b/src/components-examples/cdk-experimental/tree/cdk-tree/cdk-tree-example.html @@ -3,6 +3,7 @@ Multi Disabled Skip Disabled + Nav Mode Orientation @@ -43,10 +44,17 @@ [focusMode]="focusMode" [wrap]="wrap.value" [skipDisabled]="skipDisabled.value" + [nav]="nav.value" [(value)]="selectedValues" #tree="cdkTree" > - @for (node of treeData; track node) { - + @if (nav.value) { + @for (node of treeData; track node) { + + } + } @else { + @for (node of treeData; track node) { + + } } diff --git a/src/components-examples/cdk-experimental/tree/cdk-tree/cdk-tree-example.ts b/src/components-examples/cdk-experimental/tree/cdk-tree/cdk-tree-example.ts index a56b82ab957c..b6c19cf545ca 100644 --- a/src/components-examples/cdk-experimental/tree/cdk-tree/cdk-tree-example.ts +++ b/src/components-examples/cdk-experimental/tree/cdk-tree/cdk-tree-example.ts @@ -60,6 +60,47 @@ export class ExampleNodeComponent { node = input.required(); } +@Component({ + selector: 'example-nav-node', + styleUrl: 'cdk-tree-example.css', + template: ` +
  • + + + {{ node().label }} + + + @if (node().children !== undefined && node().children!.length > 0) { +
      + + @for (child of node().children; track child) { + + } + +
    + } +
  • + `, + imports: [MatIconModule, CdkTreeItem, CdkTreeItemGroup, CdkTreeItemGroupContent], +}) +export class ExampleNavNodeComponent { + node = input.required(); +} + /** @title Tree using CdkTree and CdkTreeItem. */ @Component({ selector: 'cdk-tree-example', @@ -75,6 +116,7 @@ export class ExampleNodeComponent { MatIconModule, CdkTree, ExampleNodeComponent, + ExampleNavNodeComponent, ], }) export class CdkTreeExample { @@ -138,6 +180,7 @@ export class CdkTreeExample { disabled = new FormControl(false, {nonNullable: true}); wrap = new FormControl(true, {nonNullable: true}); skipDisabled = new FormControl(true, {nonNullable: true}); + nav = new FormControl(false, {nonNullable: true}); selectedValues = model(['books']); }