diff --git a/.ng-dev/commit-message.mts b/.ng-dev/commit-message.mts index 4e15bbd10030..504053d9cd15 100644 --- a/.ng-dev/commit-message.mts +++ b/.ng-dev/commit-message.mts @@ -19,6 +19,7 @@ export const commitMessage: CommitMessageConfig = { 'cdk-experimental/selection', 'cdk-experimental/table-scroll-container', 'cdk-experimental/tabs', + 'cdk-experimental/tree', 'cdk-experimental/ui-patterns', 'cdk/a11y', 'cdk/accordion', diff --git a/src/cdk-experimental/config.bzl b/src/cdk-experimental/config.bzl index aa8124d0707c..141f7782d093 100644 --- a/src/cdk-experimental/config.bzl +++ b/src/cdk-experimental/config.bzl @@ -10,6 +10,7 @@ CDK_EXPERIMENTAL_ENTRYPOINTS = [ "scrolling", "selection", "tabs", + "tree", "table-scroll-container", "ui-patterns", ] diff --git a/src/cdk-experimental/tree/BUILD.bazel b/src/cdk-experimental/tree/BUILD.bazel new file mode 100644 index 000000000000..67822af2acd2 --- /dev/null +++ b/src/cdk-experimental/tree/BUILD.bazel @@ -0,0 +1,37 @@ +load("//tools:defaults.bzl", "ng_project", "ng_web_test_suite", "ts_project") + +package(default_visibility = ["//visibility:public"]) + +ng_project( + name = "tree", + srcs = [ + "index.ts", + "public-api.ts", + "tree.ts", + ], + deps = [ + "//src/cdk-experimental/deferred-content", + "//src/cdk-experimental/ui-patterns", + "//src/cdk/a11y", + "//src/cdk/bidi", + ], +) + +ts_project( + name = "unit_test_sources", + testonly = True, + srcs = [ + "tree.spec.ts", + ], + deps = [ + ":tree", + "//:node_modules/@angular/core", + "//:node_modules/@angular/platform-browser", + "//src/cdk/testing/private", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [":unit_test_sources"], +) diff --git a/src/cdk-experimental/tree/index.ts b/src/cdk-experimental/tree/index.ts new file mode 100644 index 000000000000..52b3c7a5156f --- /dev/null +++ b/src/cdk-experimental/tree/index.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export * from './public-api'; diff --git a/src/cdk-experimental/tree/public-api.ts b/src/cdk-experimental/tree/public-api.ts new file mode 100644 index 000000000000..e61d3b72d41d --- /dev/null +++ b/src/cdk-experimental/tree/public-api.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export {CdkTreeItemGroup, CdkTreeItemGroupContent, CdkTree, CdkTreeItem} from './tree'; diff --git a/src/cdk-experimental/tree/tree.spec.ts b/src/cdk-experimental/tree/tree.spec.ts new file mode 100644 index 000000000000..22d33e0ede77 --- /dev/null +++ b/src/cdk-experimental/tree/tree.spec.ts @@ -0,0 +1,1408 @@ +import {Component, signal} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {By} from '@angular/platform-browser'; +import {BidiModule, Direction} from '@angular/cdk/bidi'; +import {provideFakeDirectionality, runAccessibilityChecks} from '@angular/cdk/testing/private'; +import {CdkTree, CdkTreeItem, CdkTreeItemGroup, CdkTreeItemGroupContent} from './tree'; + +interface ModifierKeys { + ctrlKey?: boolean; + shiftKey?: boolean; + altKey?: boolean; + metaKey?: boolean; +} + +describe('CdkTree', () => { + let fixture: ComponentFixture; + let testComponent: TestTreeComponent; + let treeElement: HTMLElement; + let treeInstance: CdkTree; + let treeItemElements: HTMLElement[]; + let treeItemGroupElements: HTMLElement[]; + + const keydown = (key: string, modifierKeys: ModifierKeys = {}) => { + const event = new KeyboardEvent('keydown', {key, bubbles: true, ...modifierKeys}); + treeElement.dispatchEvent(event); + fixture.detectChanges(); + defineTestVariables(); + }; + + const pointerDown = (target: HTMLElement, eventInit: PointerEventInit = {}) => { + target.dispatchEvent(new PointerEvent('pointerdown', {bubbles: true, ...eventInit})); + fixture.detectChanges(); + defineTestVariables(); + }; + + const up = (modifierKeys?: ModifierKeys) => keydown('ArrowUp', modifierKeys); + const down = (modifierKeys?: ModifierKeys) => keydown('ArrowDown', modifierKeys); + const left = (modifierKeys?: ModifierKeys) => keydown('ArrowLeft', modifierKeys); + const right = (modifierKeys?: ModifierKeys) => keydown('ArrowRight', modifierKeys); + const home = (modifierKeys?: ModifierKeys) => keydown('Home', modifierKeys); + const end = (modifierKeys?: ModifierKeys) => keydown('End', modifierKeys); + const enter = (modifierKeys?: ModifierKeys) => keydown('Enter', modifierKeys); + const space = (modifierKeys?: ModifierKeys) => keydown(' ', modifierKeys); + const shift = () => keydown('Shift'); + const type = (chars: string) => { + for (const char of chars) { + keydown(char); + } + }; + const click = (target: HTMLElement) => pointerDown(target); + const shiftClick = (target: HTMLElement) => pointerDown(target, {shiftKey: true}); + const ctrlClick = (target: HTMLElement) => pointerDown(target, {ctrlKey: true}); + + function setupTestTree(textDirection: Direction = 'ltr') { + TestBed.configureTestingModule({ + imports: [TestTreeComponent, BidiModule], + providers: [provideFakeDirectionality(textDirection)], + }); + + fixture = TestBed.createComponent(TestTreeComponent); + testComponent = fixture.componentInstance; + + fixture.detectChanges(); + defineTestVariables(); + } + + function defineTestVariables() { + const treeDebugElement = fixture.debugElement.query(By.directive(CdkTree)); + const treeItemDebugElements = fixture.debugElement.queryAll(By.directive(CdkTreeItem)); + const treeItemGroupDebugElements = fixture.debugElement.queryAll( + By.directive(CdkTreeItemGroup), + ); + + treeElement = treeDebugElement.nativeElement as HTMLElement; + treeInstance = treeDebugElement.componentInstance as CdkTree; + treeItemElements = treeItemDebugElements.map(debugEl => debugEl.nativeElement); + treeItemGroupElements = treeItemGroupDebugElements.map(debugEl => debugEl.nativeElement); + } + + function updateTree( + config: { + nodes?: TestTreeNode[]; + value?: string[]; + disabled?: boolean; + orientation?: 'horizontal' | 'vertical'; + multi?: boolean; + wrap?: boolean; + skipDisabled?: boolean; + focusMode?: 'roving' | 'activedescendant'; + selectionMode?: 'follow' | 'explicit'; + } = {}, + ) { + if (config.nodes !== undefined) testComponent.nodes.set(config.nodes); + if (config.value !== undefined) treeInstance.value.set(config.value); + if (config.disabled !== undefined) testComponent.disabled.set(config.disabled); + if (config.orientation !== undefined) testComponent.orientation.set(config.orientation); + if (config.multi !== undefined) testComponent.multi.set(config.multi); + if (config.wrap !== undefined) testComponent.wrap.set(config.wrap); + 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); + + fixture.detectChanges(); + defineTestVariables(); + } + + function updateTreeItemByValue(value: string, config: Partial>) { + const newNodes = JSON.parse(JSON.stringify(testComponent.nodes())); + const childrenList = [newNodes]; + while (childrenList.length > 0) { + const list = childrenList.shift()!; + for (const node of list) { + if (node.value === value) { + if (config.value !== undefined) node.value = config.value; + if (config.label !== undefined) node.label = config.label; + if (config.children !== undefined) node.children = config.children; + if (config.disabled !== undefined) node.disabled = config.disabled; + if (config.preserveContent !== undefined) node.preserveContent = config.preserveContent; + updateTree({nodes: newNodes}); + return; + } + if (node.children) { + childrenList.push(node.children); + } + } + } + } + + function getTreeItemElementByValue(value: string): HTMLElement | undefined { + return treeItemElements.find(el => el.getAttribute('data-value') === String(value)); + } + + function getTreeItemGroupElementByValue(value: string): HTMLElement | undefined { + return treeItemGroupElements.find(el => el.getAttribute('data-group-for') === String(value)); + } + + function getFocusedTreeItemValue(): string | undefined { + let item: HTMLElement | undefined; + if (testComponent.focusMode() === 'roving') { + item = treeItemElements.find(el => el.getAttribute('tabindex') === '0'); + } else { + const itemId = treeElement.getAttribute('aria-activedescendant'); + if (itemId) { + item = treeItemElements.find(el => el.id === itemId); + } + } + return item?.getAttribute('data-value') ?? undefined; + } + + afterEach(async () => { + fixture.detectChanges(); + await runAccessibilityChecks(fixture.nativeElement); + }); + + describe('ARIA attributes and roles', () => { + describe('default configuration', () => { + beforeEach(() => { + setupTestTree(); + // Preserve collapsed children nodes for checking attributes. + updateTreeItemByValue('fruits', {preserveContent: true}); + updateTreeItemByValue('berries', {preserveContent: true}); + updateTreeItemByValue('vegetables', {preserveContent: true}); + }); + + it('should correctly set the role attribute to "tree" for CdkTree', () => { + expect(treeElement.getAttribute('role')).toBe('tree'); + }); + + it('should correctly set the role attribute to "treeitem" for CdkTreeItem', () => { + expect(getTreeItemElementByValue('fruits')!.getAttribute('role')).toBe('treeitem'); + expect(getTreeItemElementByValue('vegetables')!.getAttribute('role')).toBe('treeitem'); + expect(getTreeItemElementByValue('grains')!.getAttribute('role')).toBe('treeitem'); + expect(getTreeItemElementByValue('dairy')!.getAttribute('role')).toBe('treeitem'); + expect(getTreeItemElementByValue('apple')!.getAttribute('role')).toBe('treeitem'); + expect(getTreeItemElementByValue('banana')!.getAttribute('role')).toBe('treeitem'); + expect(getTreeItemElementByValue('berries')!.getAttribute('role')).toBe('treeitem'); + expect(getTreeItemElementByValue('strawberry')!.getAttribute('role')).toBe('treeitem'); + expect(getTreeItemElementByValue('blueberry')!.getAttribute('role')).toBe('treeitem'); + }); + + it('should correctly set the role attribute to "group" for CdkTreeItemGroup', () => { + expect(getTreeItemGroupElementByValue('fruits')!.getAttribute('role')).toBe('group'); + expect(getTreeItemGroupElementByValue('vegetables')!.getAttribute('role')).toBe('group'); + expect(getTreeItemGroupElementByValue('berries')!.getAttribute('role')).toBe('group'); + }); + + it('should set aria-orientation to "vertical" by default', () => { + expect(treeElement.getAttribute('aria-orientation')).toBe('vertical'); + }); + + it('should set aria-multiselectable to "false" by default', () => { + expect(treeElement.getAttribute('aria-multiselectable')).toBe('false'); + }); + + it('should set aria-disabled to "false" by default for the tree', () => { + expect(treeElement.getAttribute('aria-disabled')).toBe('false'); + }); + + it('should set aria-disabled to "false" by default for items', () => { + expect(treeItemElements[0].getAttribute('aria-disabled')).toBe('false'); + }); + + it('should not have aria-expanded for items without children', () => { + const grainsItem = getTreeItemElementByValue('grains')!; + expect(grainsItem.hasAttribute('aria-expanded')).toBe(false); + }); + + it('should set aria-expanded to "false" by default for items with children', () => { + const fruitsItem = getTreeItemElementByValue('fruits')!; + expect(fruitsItem.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should set aria-level, aria-setsize, and aria-posinset correctly', () => { + const fruits = getTreeItemElementByValue('fruits')!; + expect(fruits.getAttribute('aria-level')).toBe('1'); + expect(fruits.getAttribute('aria-setsize')).toBe('4'); + expect(fruits.getAttribute('aria-posinset')).toBe('1'); + expect(fruits.getAttribute('aria-expanded')).toBe('false'); + + const vegetables = getTreeItemElementByValue('vegetables')!; + expect(vegetables.getAttribute('aria-level')).toBe('1'); + expect(vegetables.getAttribute('aria-setsize')).toBe('4'); + expect(vegetables.getAttribute('aria-posinset')).toBe('2'); + expect(vegetables.getAttribute('aria-expanded')).toBe('false'); + + const grains = getTreeItemElementByValue('grains')!; + expect(grains.getAttribute('aria-level')).toBe('1'); + expect(grains.getAttribute('aria-setsize')).toBe('4'); + expect(grains.getAttribute('aria-posinset')).toBe('3'); + + const dairy = getTreeItemElementByValue('dairy')!; + expect(dairy.getAttribute('aria-level')).toBe('1'); + expect(dairy.getAttribute('aria-setsize')).toBe('4'); + expect(dairy.getAttribute('aria-posinset')).toBe('4'); + + const apple = getTreeItemElementByValue('apple')!; + expect(apple.getAttribute('aria-level')).toBe('2'); + expect(apple.getAttribute('aria-setsize')).toBe('3'); + expect(apple.getAttribute('aria-posinset')).toBe('1'); + + const berries = getTreeItemElementByValue('berries')!; + expect(berries.getAttribute('aria-level')).toBe('2'); + expect(berries.getAttribute('aria-setsize')).toBe('3'); + expect(berries.getAttribute('aria-posinset')).toBe('3'); + expect(berries.getAttribute('aria-expanded')).toBe('false'); + + const strawberry = getTreeItemElementByValue('strawberry')!; + expect(strawberry.getAttribute('aria-level')).toBe('3'); + expect(strawberry.getAttribute('aria-setsize')).toBe('2'); + expect(strawberry.getAttribute('aria-posinset')).toBe('1'); + }); + + it('should set aria-owns on expandable items pointing to their group id', () => { + const fruitsItem = getTreeItemElementByValue('fruits')!; + const group = getTreeItemGroupElementByValue('fruits')!; + expect(fruitsItem.getAttribute('aria-owns')).toBe(group!.id); + }); + }); + + describe('custom configuration', () => { + beforeEach(() => { + setupTestTree(); + // Preserve collapsed children nodes for checking attributes. + updateTreeItemByValue('fruits', {preserveContent: true}); + updateTreeItemByValue('berries', {preserveContent: true}); + updateTreeItemByValue('vegetables', {preserveContent: true}); + }); + + it('should set aria-orientation to "horizontal"', () => { + updateTree({orientation: 'horizontal'}); + + expect(treeElement.getAttribute('aria-orientation')).toBe('horizontal'); + }); + + it('should set aria-multiselectable to "true"', () => { + updateTree({multi: true}); + + expect(treeElement.getAttribute('aria-multiselectable')).toBe('true'); + }); + + it('should set aria-disabled to "true" for the tree', () => { + updateTree({disabled: true}); + + expect(treeElement.getAttribute('aria-disabled')).toBe('true'); + }); + + it('should set aria-disabled to "true" for disabled items', () => { + updateTreeItemByValue('fruits', {disabled: true}); + + const fruitsItem = getTreeItemElementByValue('fruits')!; + expect(fruitsItem.getAttribute('aria-disabled')).toBe('true'); + }); + + it('should set aria-selected to "true" for selected items', () => { + updateTree({value: ['apple']}); + + const appleItem = getTreeItemElementByValue('apple')!; + expect(appleItem.getAttribute('aria-selected')).toBe('true'); + const fruitsItem = getTreeItemElementByValue('fruits')!; + expect(fruitsItem.getAttribute('aria-selected')).toBe('false'); + }); + + it('should set aria-expanded to "true" for expanded items', () => { + right(); + const fruitsItem = getTreeItemElementByValue('fruits')!; + expect(fruitsItem.getAttribute('aria-expanded')).toBe('true'); + }); + }); + + describe('roving focus mode (focusMode="roving")', () => { + beforeEach(() => { + setupTestTree(); + updateTree({focusMode: 'roving'}); + }); + + it('should set tabindex="-1" for the tree', () => { + expect(treeElement.getAttribute('tabindex')).toBe('-1'); + }); + + it('should set tabindex="0" for the tree when disabled', () => { + updateTree({disabled: true, focusMode: 'roving'}); + + expect(treeElement.getAttribute('tabindex')).toBe('0'); + }); + + it('should set initial focus (tabindex="0") on the first non-disabled item if no value is set', () => { + const fruitsItem = getTreeItemElementByValue('fruits')!; + const vegetablesItem = getTreeItemElementByValue('vegetables')!; + const grainsItem = getTreeItemElementByValue('grains')!; + const dairyItem = getTreeItemElementByValue('dairy')!; + + expect(fruitsItem.getAttribute('tabindex')).toBe('0'); + expect(vegetablesItem.getAttribute('tabindex')).toBe('-1'); + expect(grainsItem.getAttribute('tabindex')).toBe('-1'); + expect(dairyItem.getAttribute('tabindex')).toBe('-1'); + }); + + it('should set initial focus (tabindex="0") on the first selected item', () => { + updateTree({value: ['vegetables', 'dairy'], focusMode: 'roving'}); + + const fruitsItem = getTreeItemElementByValue('fruits')!; + const vegetablesItem = getTreeItemElementByValue('vegetables')!; + const grainsItem = getTreeItemElementByValue('grains')!; + const dairyItem = getTreeItemElementByValue('dairy')!; + + expect(fruitsItem.getAttribute('tabindex')).toBe('-1'); + expect(vegetablesItem.getAttribute('tabindex')).toBe('0'); + expect(grainsItem.getAttribute('tabindex')).toBe('-1'); + expect(dairyItem.getAttribute('tabindex')).toBe('-1'); + }); + + it('should not have aria-activedescendant', () => { + expect(treeElement.hasAttribute('aria-activedescendant')).toBe(false); + }); + }); + + describe('activedescendant focus mode (focusMode="activedescendant")', () => { + beforeEach(() => { + setupTestTree(); + updateTree({focusMode: 'activedescendant'}); + }); + + it('should set tabindex="0" for the tree', () => { + expect(treeElement.getAttribute('tabindex')).toBe('0'); + }); + + it('should set aria-activedescendant to the ID of the first non-disabled item if no value is set', () => { + const fruitsItem = getTreeItemElementByValue('fruits')!; + expect(treeElement.getAttribute('aria-activedescendant')).toBe(fruitsItem.id); + }); + + it('should set aria-activedescendant to the ID of the first selected item', () => { + updateTree({value: ['vegetables', 'dairy'], focusMode: 'activedescendant'}); + + const vegetablesItem = getTreeItemElementByValue('vegetables')!; + expect(treeElement.getAttribute('aria-activedescendant')).toBe(vegetablesItem.id); + }); + + it('should set tabindex="-1" for all items', () => { + // Preserve collapsed children nodes for checking attributes. + updateTreeItemByValue('fruits', {preserveContent: true}); + updateTreeItemByValue('berries', {preserveContent: true}); + updateTreeItemByValue('vegetables', {preserveContent: true}); + + expect(getTreeItemElementByValue('fruits')!.getAttribute('tabindex')).toBe('-1'); + expect(getTreeItemElementByValue('apple')!.getAttribute('tabindex')).toBe('-1'); + expect(getTreeItemElementByValue('banana')!.getAttribute('tabindex')).toBe('-1'); + expect(getTreeItemElementByValue('berries')!.getAttribute('tabindex')).toBe('-1'); + expect(getTreeItemElementByValue('strawberry')!.getAttribute('tabindex')).toBe('-1'); + expect(getTreeItemElementByValue('blueberry')!.getAttribute('tabindex')).toBe('-1'); + expect(getTreeItemElementByValue('vegetables')!.getAttribute('tabindex')).toBe('-1'); + expect(getTreeItemElementByValue('carrot')!.getAttribute('tabindex')).toBe('-1'); + expect(getTreeItemElementByValue('broccoli')!.getAttribute('tabindex')).toBe('-1'); + expect(getTreeItemElementByValue('grains')!.getAttribute('tabindex')).toBe('-1'); + expect(getTreeItemElementByValue('dairy')!.getAttribute('tabindex')).toBe('-1'); + }); + }); + }); + + describe('value and selection', () => { + it('should select items based on the initial value input', () => { + setupTestTree(); + // Preserve collapsed children nodes for checking attributes. + updateTreeItemByValue('fruits', {preserveContent: true}); + updateTreeItemByValue('berries', {preserveContent: true}); + updateTreeItemByValue('vegetables', {preserveContent: true}); + updateTree({value: ['apple', 'strawberry', 'carrot']}); + + expect(getTreeItemElementByValue('apple')!.getAttribute('aria-selected')).toBe('true'); + expect(getTreeItemElementByValue('strawberry')!.getAttribute('aria-selected')).toBe('true'); + expect(getTreeItemElementByValue('carrot')!.getAttribute('aria-selected')).toBe('true'); + expect(getTreeItemElementByValue('banana')!.getAttribute('aria-selected')).toBe('false'); + }); + + describe('pointer interactions', () => { + describe('single select (multi=false, selectionMode="explicit")', () => { + beforeEach(() => { + setupTestTree(); + updateTree({multi: false, selectionMode: 'explicit'}); + }); + + it('should select an item on click and deselect others', () => { + right(); + const appleEl = getTreeItemElementByValue('apple')!; + const bananaEl = getTreeItemElementByValue('banana')!; + + click(appleEl); + expect(treeInstance.value()).toEqual(['apple']); + expect(appleEl.getAttribute('aria-selected')).toBe('true'); + expect(bananaEl.getAttribute('aria-selected')).toBe('false'); + + click(bananaEl); + expect(treeInstance.value()).toEqual(['banana']); + expect(appleEl.getAttribute('aria-selected')).toBe('false'); + expect(bananaEl.getAttribute('aria-selected')).toBe('true'); + }); + }); + + describe('multi select (multi=true)', () => { + beforeEach(() => { + setupTestTree(); + updateTree({multi: true}); + + // Expands vegetables and fruits + down(); + right(); + up(); + right(); + }); + + describe('selectionMode="explicit"', () => { + beforeEach(() => { + updateTree({selectionMode: 'explicit'}); + }); + + it('should select a range with shift+click', () => { + const appleEl = getTreeItemElementByValue('apple')!; + const carrotEl = getTreeItemElementByValue('carrot')!; + + click(appleEl); + expect(treeInstance.value()).toEqual(['apple']); + expect(appleEl.getAttribute('aria-selected')).toBe('true'); + + shiftClick(carrotEl); + expect(treeInstance.value()).toEqual([ + 'apple', + 'banana', + 'berries', + 'vegetables', + 'carrot', + ]); + }); + + it('should toggle selection of an item on simple click, leaving other selections intact', () => { + const appleEl = getTreeItemElementByValue('apple')!; + const bananaEl = getTreeItemElementByValue('banana')!; + + click(appleEl); + expect(treeInstance.value()).toEqual(['apple']); + + click(bananaEl); + expect(treeInstance.value()).toEqual(['apple', 'banana']); + + click(appleEl); + expect(treeInstance.value()).toEqual(['banana']); + }); + }); + + describe('selectionMode="follow"', () => { + beforeEach(() => { + updateTree({selectionMode: 'follow'}); + }); + + it('should select only the clicked item with a simple click (like single select), deselecting others', () => { + const appleEl = getTreeItemElementByValue('apple')!; + const bananaEl = getTreeItemElementByValue('banana')!; + const carrotEl = getTreeItemElementByValue('carrot')!; + + ctrlClick(appleEl); + ctrlClick(bananaEl); + expect(treeInstance.value()).toEqual(['apple', 'banana']); + + click(carrotEl); + expect(treeInstance.value()).toEqual(['carrot']); + + click(appleEl); + expect(treeInstance.value()).toEqual(['apple']); + }); + + it('should add to selection with ctrl+click and toggle individual items', () => { + const appleEl = getTreeItemElementByValue('apple')!; + const berriesEl = getTreeItemElementByValue('berries')!; + + click(appleEl); + expect(treeInstance.value()).toEqual(['apple']); + + ctrlClick(berriesEl); + expect(treeInstance.value()).toEqual(['apple', 'berries']); + + ctrlClick(appleEl); + expect(treeInstance.value()).toEqual(['berries']); + }); + + it('should select a range with shift+click, anchoring from last selected/focused', () => { + const appleEl = getTreeItemElementByValue('apple')!; + const berriesEl = getTreeItemElementByValue('berries')!; + const carrotEl = getTreeItemElementByValue('carrot')!; + const broccoliEl = getTreeItemElementByValue('broccoli')!; + + click(appleEl); + expect(treeInstance.value()).toEqual(['apple']); + + shiftClick(carrotEl); + expect(treeInstance.value()).toEqual([ + 'apple', + 'banana', + 'berries', + 'vegetables', + 'carrot', + ]); + + click(berriesEl); + expect(treeInstance.value()).toEqual(['berries']); + + shiftClick(broccoliEl); + expect(treeInstance.value()).toEqual([ + 'berries', + 'strawberry', + 'blueberry', + 'vegetables', + 'carrot', + 'broccoli', + ]); + }); + }); + }); + }); + + describe('keyboard interactions', () => { + describe('single select (multi=false)', () => { + beforeEach(() => { + setupTestTree(); + updateTree({multi: false}); + }); + + describe('selectionMode="explicit"', () => { + beforeEach(() => { + updateTree({selectionMode: 'explicit'}); + }); + + it('should select the focused item with Enter and deselect others', () => { + enter(); + expect(treeInstance.value()).toEqual(['fruits']); + + down(); + enter(); + expect(treeInstance.value()).toEqual(['vegetables']); + }); + + it('should select the focused item with Space and deselect others', () => { + space(); + expect(treeInstance.value()).toEqual(['fruits']); + + down(); + space(); + expect(treeInstance.value()).toEqual(['vegetables']); + }); + + it('should move focus with arrows without changing selection until Enter/Space', () => { + enter(); + expect(treeInstance.value()).toEqual(['fruits']); + + down(); + expect(treeInstance.value()).toEqual(['fruits']); + + down(); + expect(treeInstance.value()).toEqual(['fruits']); + + enter(); + expect(treeInstance.value()).toEqual(['grains']); + }); + }); + + describe('selectionMode="follow"', () => { + beforeEach(() => { + updateTree({selectionMode: 'follow'}); + }); + + it('should select an item when it becomes focused with ArrowDown and deselect others', () => { + updateTree({value: ['fruits']}); + expect(treeInstance.value()).toEqual(['fruits']); + + down(); + expect(treeInstance.value()).toEqual(['vegetables']); + + down(); + expect(treeInstance.value()).toEqual(['grains']); + }); + + it('should select an item when it becomes focused with ArrowUp and deselect others', () => { + updateTree({value: ['grains']}); + + up(); + expect(treeInstance.value()).toEqual(['vegetables']); + }); + + it('should select the first item with Home and deselect others', () => { + updateTree({value: ['grains']}); + expect(treeInstance.value()).toEqual(['grains']); + + home(); + expect(treeInstance.value()).toEqual(['fruits']); + }); + + it('should select the last visible item with End and deselect others', () => { + updateTree({value: ['fruits']}); + expect(treeInstance.value()).toEqual(['fruits']); + + end(); + expect(treeInstance.value()).toEqual(['dairy']); + }); + + it('should select an item via typeahead and deselect others', () => { + updateTree({value: ['fruits']}); + expect(treeInstance.value()).toEqual(['fruits']); + + type('V'); + expect(treeInstance.value()).toEqual(['vegetables']); + }); + }); + }); + + describe('multi select (multi=true)', () => { + beforeEach(() => { + setupTestTree(); + updateTree({multi: true}); + }); + + describe('selectionMode="explicit"', () => { + beforeEach(() => { + updateTree({selectionMode: 'explicit'}); + }); + + it('should toggle selection of the focused item with Space, leaving other selections intact', () => { + space(); + expect(treeInstance.value()).toEqual(['fruits']); + + down(); + space(); + expect(treeInstance.value().sort()).toEqual(['fruits', 'vegetables']); + }); + + it('should move focus with arrows without changing selection', () => { + space(); + expect(treeInstance.value()).toEqual(['fruits']); + + down(); + expect(treeInstance.value()).toEqual(['fruits']); + }); + + it('should extend selection downwards with Shift+ArrowDown', () => { + shift(); + down({shiftKey: true}); + expect(treeInstance.value().sort()).toEqual(['fruits', 'vegetables']); + + down({shiftKey: true}); + expect(treeInstance.value().sort()).toEqual(['fruits', 'grains', 'vegetables']); + }); + + it('should extend selection upwards with Shift+ArrowUp', () => { + end(); + shift(); + up({shiftKey: true}); + expect(treeInstance.value().sort()).toEqual(['dairy', 'grains']); + }); + + it('Ctrl+A should select all enabled visible items, then deselect all', () => { + // Expands vegetables and fruits + down(); + right(); + up(); + right(); + + updateTreeItemByValue('carrot', {disabled: true}); + updateTreeItemByValue('broccoli', {disabled: true}); + + keydown('A', {ctrlKey: true}); + expect(treeInstance.value().sort()).toEqual([ + 'apple', + 'banana', + 'berries', + 'dairy', + 'fruits', + 'grains', + 'vegetables', + ]); + + keydown('A', {ctrlKey: true}); + expect(treeInstance.value()).toEqual([]); + }); + + it('Ctrl+ArrowKey should move focus without changing selection', () => { + space(); + expect(treeInstance.value()).toEqual(['fruits']); + + down({ctrlKey: true}); + expect(treeInstance.value()).toEqual(['fruits']); + + up({ctrlKey: true}); + expect(treeInstance.value()).toEqual(['fruits']); + }); + }); + + describe('selectionMode="follow"', () => { + beforeEach(() => { + updateTree({selectionMode: 'follow'}); + }); + + it('should select the focused item and deselect others on ArrowDown', () => { + updateTree({value: ['fruits']}); + expect(treeInstance.value()).toEqual(['fruits']); + + down(); + expect(treeInstance.value()).toEqual(['vegetables']); + }); + + it('should select the focused item and deselect others on ArrowUp', () => { + updateTree({value: ['vegetables']}); + expect(treeInstance.value()).toEqual(['vegetables']); + + up(); + expect(treeInstance.value()).toEqual(['fruits']); + }); + + it('should move focus without changing selection on Ctrl+ArrowDown', () => { + updateTree({value: ['fruits']}); + expect(getFocusedTreeItemValue()).toBe('fruits'); + + down({ctrlKey: true}); + expect(treeInstance.value()).toEqual(['fruits']); + expect(getFocusedTreeItemValue()).toBe('vegetables'); + }); + + it('should move focus without changing selection on Ctrl+ArrowUp', () => { + updateTree({value: ['fruits']}); + + down({ctrlKey: true}); + expect(getFocusedTreeItemValue()).toBe('vegetables'); + + up({ctrlKey: true}); + expect(treeInstance.value()).toEqual(['fruits']); + expect(getFocusedTreeItemValue()).toBe('fruits'); + }); + + it('should toggle selection of the focused item on Ctrl+Space, adding to existing selection', () => { + updateTree({value: ['fruits']}); + down({ctrlKey: true}); + expect(getFocusedTreeItemValue()).toBe('vegetables'); + + space({ctrlKey: true}); + expect(treeInstance.value().sort()).toEqual(['fruits', 'vegetables']); + + space({ctrlKey: true}); + expect(treeInstance.value()).toEqual(['fruits']); + }); + + it('should toggle selection of the focused item on Ctrl+Enter, adding to existing selection', () => { + updateTree({value: ['fruits']}); + down({ctrlKey: true}); + expect(getFocusedTreeItemValue()).toBe('vegetables'); + + enter({ctrlKey: true}); + expect(treeInstance.value().sort()).toEqual(['fruits', 'vegetables']); + + enter({ctrlKey: true}); + expect(treeInstance.value()).toEqual(['fruits']); + }); + + it('should extend selection downwards with Shift+ArrowDown', () => { + right(); // Expands fruits + updateTree({value: ['fruits']}); + + shift(); + down({shiftKey: true}); + expect(treeInstance.value().sort()).toEqual(['apple', 'fruits']); + + down({shiftKey: true}); + expect(treeInstance.value().sort()).toEqual(['apple', 'banana', 'fruits']); + }); + + it('should extend selection upwards with Shift+ArrowUp', () => { + updateTree({value: ['grains']}); + + shift(); + up({shiftKey: true}); + expect(treeInstance.value().sort()).toEqual(['grains', 'vegetables']); + + up({shiftKey: true}); + expect(treeInstance.value().sort()).toEqual(['fruits', 'grains', 'vegetables']); + }); + + it('should select a range with Shift+Space, anchoring from last selected/focused item', () => { + right(); // Expands fruits + updateTree({value: ['fruits']}); + + down({ctrlKey: true}); + down({ctrlKey: true}); + expect(getFocusedTreeItemValue()).toBe('banana'); + + space({shiftKey: true}); + expect(treeInstance.value().sort()).toEqual(['apple', 'banana', 'fruits']); + }); + + it('Ctrl+A: select all enabled visible items; second Ctrl+A deselects all except focused', () => { + right(); // Expands fruits + updateTreeItemByValue('vegetables', {disabled: true}); + + keydown('A', {ctrlKey: true}); + expect(treeInstance.value().sort()).toEqual([ + 'apple', + 'banana', + 'berries', + 'dairy', + 'fruits', + 'grains', + ]); + + keydown('A', {ctrlKey: true}); + expect(treeInstance.value()).toEqual(['fruits']); + }); + + it('typeahead should select the focused item and deselect others', () => { + updateTree({value: ['fruits']}); + type('V'); + expect(treeInstance.value()).toEqual(['vegetables']); + expect(getFocusedTreeItemValue()).toBe('vegetables'); + }); + + it('should not select disabled items during Shift+ArrowKey navigation even if skipDisabled is false', () => { + right(); // Expands fruits + updateTreeItemByValue('banana', {disabled: true}); + updateTree({value: ['apple'], skipDisabled: false}); + expect(getFocusedTreeItemValue()).toBe('apple'); + + keydown('Shift'); + down({shiftKey: true}); + expect(getFocusedTreeItemValue()).toBe('banana'); + expect(treeInstance.value().sort()).toEqual(['apple']); + + down({shiftKey: true}); // Focus 'berries' + expect(getFocusedTreeItemValue()).toBe('berries'); + expect(treeInstance.value().sort()).toEqual(['apple', 'berries']); + }); + + it('should not change selection if tree is disabled', () => { + updateTree({value: ['fruits'], disabled: true}); + down(); + expect(treeInstance.value()).toEqual(['fruits']); + }); + }); + }); + }); + }); + + describe('expansion and collapse', () => { + describe('LTR', () => { + beforeEach(() => { + setupTestTree(); + }); + + describe('orientation="vertical"', () => { + beforeEach(() => { + updateTree({orientation: 'vertical'}); + }); + + it('should expand a collapsed item with ArrowRight', () => { + const fruitsEl = getTreeItemElementByValue('fruits')!; + expect(fruitsEl.getAttribute('aria-expanded')).toBe('false'); + + right(); + expect(fruitsEl.getAttribute('aria-expanded')).toBe('true'); + }); + + it('should move focus to first child if ArrowRight on an expanded item', () => { + right(); // Expands fruits + expect(getFocusedTreeItemValue()).toBe('fruits'); + + right(); + expect(getFocusedTreeItemValue()).toBe('apple'); + }); + + it('should collapse an expanded item with ArrowLeft', () => { + right(); // Expands fruits + const fruitsEl = getTreeItemElementByValue('fruits')!; + expect(fruitsEl.getAttribute('aria-expanded')).toBe('true'); + left(); + expect(fruitsEl.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should move focus to parent if ArrowLeft on a collapsed non-root item', () => { + right(); // Expands fruits + right(); // Focus apple (child of fruits) + expect(getFocusedTreeItemValue()).toBe('apple'); + + left(); + expect(getFocusedTreeItemValue()).toBe('fruits'); + }); + }); + + describe('orientation="horizontal"', () => { + beforeEach(() => { + updateTree({orientation: 'horizontal'}); + }); + + it('should expand a collapsed item with ArrowDown', () => { + updateTree({orientation: 'horizontal'}); + const fruitsEl = getTreeItemElementByValue('fruits')!; + expect(fruitsEl.getAttribute('aria-expanded')).toBe('false'); + down(); + expect(fruitsEl.getAttribute('aria-expanded')).toBe('true'); + }); + + it('should move focus to first child if ArrowDown on an expanded item', () => { + updateTree({orientation: 'horizontal'}); + expect(getFocusedTreeItemValue()).toBe('fruits'); + down(); + down(); + expect(getFocusedTreeItemValue()).toBe('apple'); + }); + + it('should collapse an expanded item with ArrowUp', () => { + updateTree({orientation: 'horizontal'}); + down(); // Expands fruits + const fruitsEl = getTreeItemElementByValue('fruits')!; + expect(fruitsEl.getAttribute('aria-expanded')).toBe('true'); + up(); + expect(fruitsEl.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should move focus to parent if ArrowUp on a collapsed non-root item', () => { + updateTree({orientation: 'horizontal'}); + down(); // Expands fruits + down(); + expect(getFocusedTreeItemValue()).toBe('apple'); + up(); + expect(getFocusedTreeItemValue()).toBe('fruits'); + }); + }); + + it('should expand all sibling items with Shift + *', () => { + const fruitsEl = getTreeItemElementByValue('fruits')!; + const vegetablesEl = getTreeItemElementByValue('vegetables')!; + const grainsEl = getTreeItemElementByValue('grains')!; + const dairyEl = getTreeItemElementByValue('dairy')!; + + expect(fruitsEl.getAttribute('aria-expanded')).toBe('false'); + expect(vegetablesEl.getAttribute('aria-expanded')).toBe('false'); + + keydown('*', {shiftKey: true}); + + expect(fruitsEl.getAttribute('aria-expanded')).toBe('true'); + expect(vegetablesEl.getAttribute('aria-expanded')).toBe('true'); + expect(grainsEl.hasAttribute('aria-expanded')).toBe(false); + expect(dairyEl.hasAttribute('aria-expanded')).toBe(false); + }); + + it('should toggle expansion on pointerdown (click) for an expandable item', () => { + const fruitsEl = getTreeItemElementByValue('fruits')!; + expect(fruitsEl.getAttribute('aria-expanded')).toBe('false'); + + click(fruitsEl); + expect(fruitsEl.getAttribute('aria-expanded')).toBe('true'); + expect(getFocusedTreeItemValue()).toBe('fruits'); + + click(fruitsEl); + expect(fruitsEl.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should not expand a non-expandable item on click', () => { + const grainsEl = getTreeItemElementByValue('grains')!; + expect(grainsEl.hasAttribute('aria-expanded')).toBe(false); + + click(grainsEl); + expect(grainsEl.hasAttribute('aria-expanded')).toBe(false); + expect(getFocusedTreeItemValue()).toBe('grains'); + }); + + it('should not expand a non-expandable item with expand key', () => { + const grainsEl = getTreeItemElementByValue('grains')!; + down(); + down(); + expect(getFocusedTreeItemValue()).toBe('grains'); + + right(); + expect(grainsEl.hasAttribute('aria-expanded')).toBe(false); + expect(getFocusedTreeItemValue()).toBe('grains'); + }); + + it('should not expand/collapse if item is disabled', () => { + updateTreeItemByValue('fruits', {disabled: true}); + const fruitsEl = getTreeItemElementByValue('fruits')!; + + click(fruitsEl); + expect(fruitsEl.getAttribute('aria-expanded')).toBe('false'); + + right(); + expect(fruitsEl.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should not expand/collapse if tree is disabled', () => { + updateTree({disabled: true}); + const fruitsEl = getTreeItemElementByValue('fruits')!; + + click(fruitsEl); + expect(fruitsEl.getAttribute('aria-expanded')).toBe('false'); + + right(); + expect(fruitsEl.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should do nothing on collapseKey if item is collapsed and is a root item', () => { + const fruitsEl = getTreeItemElementByValue('fruits')!; + expect(fruitsEl.getAttribute('aria-expanded')).toBe('false'); + expect(getFocusedTreeItemValue()).toBe('fruits'); + + left(); + expect(fruitsEl.getAttribute('aria-expanded')).toBe('false'); + expect(getFocusedTreeItemValue()).toBe('fruits'); + }); + }); + + describe('RTL', () => { + beforeEach(() => { + setupTestTree('rtl'); + }); + + describe('orientation="vertical"', () => { + it('should expand a collapsed item with ArrowLeft', () => { + const fruitsEl = getTreeItemElementByValue('fruits')!; + expect(fruitsEl.getAttribute('aria-expanded')).toBe('false'); + left(); + expect(fruitsEl.getAttribute('aria-expanded')).toBe('true'); + }); + + it('should collapse an expanded item with ArrowRight', () => { + left(); + const fruitsEl = getTreeItemElementByValue('fruits')!; + expect(fruitsEl.getAttribute('aria-expanded')).toBe('true'); + + right(); + expect(fruitsEl.getAttribute('aria-expanded')).toBe('false'); + }); + }); + }); + }); + + describe('keyboard navigation', () => { + for (const focusMode of ['roving', 'activedescendant'] as const) { + const isFocused = (value: string) => getFocusedTreeItemValue() === value; + + describe(`focusMode="${focusMode}"`, () => { + describe('LTR', () => { + beforeEach(() => { + setupTestTree('ltr'); + updateTree({focusMode}); + }); + + describe('vertical orientation', () => { + beforeEach(() => { + updateTree({orientation: 'vertical'}); + }); + + it('should move focus to the next visible item on ArrowDown', () => { + expect(isFocused('fruits')).toBe(true); + right(); // Expands fruits + down(); + expect(isFocused('apple')).toBe(true); + down(); + expect(isFocused('banana')).toBe(true); + }); + + it('should move focus to the previous visible item on ArrowUp', () => { + expect(isFocused('fruits')).toBe(true); + right(); // Expands fruits + down(); + down(); + expect(isFocused('banana')).toBe(true); + up(); + expect(isFocused('apple')).toBe(true); + up(); + expect(isFocused('fruits')).toBe(true); + }); + + it('should skip disabled items with ArrowDown if skipDisabled=true', () => { + right(); // Expands fruits + updateTreeItemByValue('apple', {disabled: true}); + updateTree({skipDisabled: true}); + + expect(isFocused('fruits')).toBe(true); + down(); + expect(isFocused('banana')).toBe(true); + }); + + it('should not skip disabled items with ArrowDown if skipDisabled=false', () => { + right(); // Expands fruits + updateTreeItemByValue('apple', {disabled: true}); + updateTree({skipDisabled: false}); + + expect(isFocused('fruits')).toBe(true); + down(); + expect(isFocused('apple')).toBe(true); + }); + + it('should wrap focus from last to first with ArrowDown when wrap is true', () => { + updateTree({wrap: true}); + end(); + expect(isFocused('dairy')).toBe(true); + down(); + expect(isFocused('fruits')).toBe(true); + }); + + it('should not wrap focus from last to first with ArrowDown when wrap is false', () => { + updateTree({wrap: false}); + end(); + expect(isFocused('dairy')).toBe(true); + down(); + expect(isFocused('dairy')).toBe(true); + }); + }); + + describe('horizontal orientation', () => { + beforeEach(() => { + updateTree({orientation: 'horizontal'}); + }); + + it('should move focus to the next visible item on ArrowRight', () => { + expect(isFocused('fruits')).toBe(true); + right(); + expect(isFocused('vegetables')).toBe(true); + }); + + it('should move focus to the previous visible item on ArrowLeft', () => { + right(); + expect(isFocused('vegetables')).toBe(true); + left(); + expect(isFocused('fruits')).toBe(true); + }); + }); + + it('should move focus to the last enabled visible item on End', () => { + right(); // Expands fruits + updateTreeItemByValue('dairy', {disabled: true}); + updateTreeItemByValue('grains', {disabled: true}); + updateTreeItemByValue('vegetables', {disabled: true}); + end(); + expect(isFocused('berries')).toBe(true); + }); + + it('should move focus to the first enabled visible item on Home', () => { + end(); + updateTreeItemByValue('fruits', {disabled: true}); + home(); + expect(isFocused('vegetables')).toBe(true); + }); + }); + + describe('RTL', () => { + beforeEach(() => { + setupTestTree('rtl'); + updateTree({focusMode}); + }); + + describe('vertical orientation', () => { + beforeEach(() => { + updateTree({orientation: 'vertical'}); + }); + + it('should move focus to the next visible item on ArrowDown', () => { + expect(isFocused('fruits')).toBe(true); + down(); + expect(isFocused('vegetables')).toBe(true); + }); + + it('should move focus to the previous visible item on ArrowUp', () => { + down(); + expect(isFocused('vegetables')).toBe(true); + up(); + expect(isFocused('fruits')).toBe(true); + }); + }); + + describe('horizontal orientation', () => { + beforeEach(() => { + updateTree({orientation: 'horizontal'}); + }); + + it('should move focus to the next visible item on ArrowLeft', () => { + expect(isFocused('fruits')).toBe(true); + left(); + expect(isFocused('vegetables')).toBe(true); + }); + + it('should move focus to the previous visible item on ArrowRight', () => { + left(); + expect(isFocused('vegetables')).toBe(true); + right(); + expect(isFocused('fruits')).toBe(true); + }); + }); + }); + + describe('pointer navigation', () => { + beforeEach(() => setupTestTree()); + + it('should move focus to the clicked item', () => { + const vegetablesEl = getTreeItemElementByValue('vegetables')!; + click(vegetablesEl); + expect(isFocused('vegetables')).toBe(true); + }); + + it('should move focus to the clicked disabled item if skipDisabled=false', () => { + updateTreeItemByValue('vegetables', {disabled: true}); + updateTree({skipDisabled: false}); + const vegetablesEl = getTreeItemElementByValue('vegetables')!; + click(vegetablesEl); + expect(isFocused('vegetables')).toBe(true); + }); + }); + + describe('typeahead functionality', () => { + beforeEach(() => setupTestTree()); // LTR by default + + it('should focus the first matching visible item when typing characters', () => { + right(); // Expands fruits + type('Ba'); + expect(isFocused('banana')).toBe(true); + }); + + it('should select the focused item if selectionMode is "follow"', () => { + updateTree({selectionMode: 'follow'}); + type('Gr'); + expect(isFocused('grains')).toBe(true); + expect(treeInstance.value()).toEqual(['grains']); + }); + + it('should not select the focused item if selectionMode is "explicit"', () => { + updateTree({selectionMode: 'explicit'}); + type('Gr'); + expect(isFocused('grains')).toBe(true); + expect(treeInstance.value()).toEqual([]); + }); + + it('should skip disabled items with typeahead if skipDisabled=true', () => { + right(); // Expands fruits + updateTreeItemByValue('banana', {disabled: true}); + updateTree({skipDisabled: true}); + type('B'); + expect(isFocused('berries')).toBe(true); + }); + + it('should focus disabled items with typeahead if skipDisabled=false', () => { + updateTreeItemByValue('vegetables', {disabled: true}); + updateTree({skipDisabled: false}); + type('V'); + expect(isFocused('vegetables')).toBe(true); + }); + }); + }); + } + }); +}); + +interface TestTreeNode { + value: V; + label: string; + disabled?: boolean; + children?: TestTreeNode[]; + preserveContent?: boolean; +} + +@Component({ + template: ` +
    + @for (node of nodes(); track node.value) { +
  • + {{ node.label }} + @if (node.children !== undefined && node.children!.length > 0) { +
      + + @for (node of node.children; track node.value) { +
    • + {{ node.label }} + @if (node.children !== undefined && node.children!.length > 0) { +
        + + @for (node of node.children; track node.value) { +
      • + {{ node.label }} +
      • + } +
        +
      + } +
    • + } +
      +
    + } +
  • + } +
+ `, + imports: [CdkTree, CdkTreeItem, CdkTreeItemGroup, CdkTreeItemGroupContent], +}) +class TestTreeComponent { + nodes = signal([ + { + value: 'fruits', + label: 'Fruits', + children: [ + {value: 'apple', label: 'Apple'}, + {value: 'banana', label: 'Banana'}, + { + value: 'berries', + label: 'Berries', + children: [ + {value: 'strawberry', label: 'Strawberry'}, + {value: 'blueberry', label: 'Blueberry'}, + ], + }, + ], + }, + { + value: 'vegetables', + label: 'Vegetables', + children: [ + {value: 'carrot', label: 'Carrot'}, + {value: 'broccoli', label: 'Broccoli'}, + ], + }, + {value: 'grains', label: 'Grains'}, + {value: 'dairy', label: 'Dairy'}, + ]); + value = signal([]); + disabled = signal(false); + orientation = signal<'vertical' | 'horizontal'>('vertical'); + multi = signal(false); + wrap = signal(true); + skipDisabled = signal(true); + focusMode = signal<'roving' | 'activedescendant'>('roving'); + selectionMode = signal<'explicit' | 'follow'>('explicit'); +} diff --git a/src/cdk-experimental/tree/tree.ts b/src/cdk-experimental/tree/tree.ts new file mode 100644 index 000000000000..e317a0da1322 --- /dev/null +++ b/src/cdk-experimental/tree/tree.ts @@ -0,0 +1,349 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { + Directive, + ElementRef, + afterRenderEffect, + booleanAttribute, + computed, + inject, + input, + model, + signal, + Signal, + OnInit, + OnDestroy, +} from '@angular/core'; +import {_IdGenerator} from '@angular/cdk/a11y'; +import {Directionality} from '@angular/cdk/bidi'; +import {DeferredContent, DeferredContentAware} from '@angular/cdk-experimental/deferred-content'; +import {TreeItemPattern, TreePattern} from '../ui-patterns/tree/tree'; + +interface HasElement { + element: Signal; +} + +/** + * Sort directives by their document order. + */ +function sortDirectives(a: HasElement, b: HasElement) { + return (a.element().compareDocumentPosition(b.element()) & Node.DOCUMENT_POSITION_PRECEDING) > 0 + ? 1 + : -1; +} + +/** + * A Tree container. + * + * Transforms nested lists into an accessible, ARIA-compliant tree structure. + * + * ```html + *
    + *
  • Leaf Item 1
  • + *
  • + * Parent Item 1 + *
      + * + *
    • Child Item 1.1
    • + *
    • Child Item 1.2
    • + *
      + *
    + *
  • + *
  • Disabled Leaf Item 2
  • + *
+ * ``` + */ +@Directive({ + selector: '[cdkTree]', + exportAs: 'cdkTree', + host: { + 'class': 'cdk-tree', + 'role': 'tree', + '[attr.aria-orientation]': 'pattern.orientation()', + '[attr.aria-multiselectable]': 'pattern.multi()', + '[attr.aria-disabled]': 'pattern.disabled()', + '[attr.aria-activedescendant]': 'pattern.activedescendant()', + '[tabindex]': 'pattern.tabindex()', + '(keydown)': 'pattern.onKeydown($event)', + '(pointerdown)': 'pattern.onPointerdown($event)', + '(focusin)': 'onFocus()', + }, +}) +export class CdkTree { + /** All CdkTreeItem instances within this tree. */ + private readonly _unorderedItems = signal(new Set>()); + + /** All CdkGroup instances within this tree. */ + readonly unorderedGroups = signal(new Set>()); + + /** Orientation of the tree. */ + readonly orientation = input<'vertical' | 'horizontal'>('vertical'); + + /** Whether multi-selection is allowed. */ + readonly multi = input(false, {transform: booleanAttribute}); + + /** Whether the tree is disabled. */ + readonly disabled = input(false, {transform: booleanAttribute}); + + /** The selection strategy used by the tree. */ + readonly selectionMode = input<'explicit' | 'follow'>('explicit'); + + /** The focus strategy used by the tree. */ + readonly focusMode = input<'roving' | 'activedescendant'>('roving'); + + /** Whether navigation wraps. */ + readonly wrap = input(true, {transform: booleanAttribute}); + + /** Whether to skip disabled items during navigation. */ + readonly skipDisabled = input(true, {transform: booleanAttribute}); + + /** Typeahead delay. */ + readonly typeaheadDelay = input(0.5); + + /** Selected item values. */ + readonly value = model([]); + + /** Text direction. */ + readonly textDirection = inject(Directionality).valueSignal; + + /** The UI pattern for the tree. */ + readonly pattern: TreePattern = new TreePattern({ + ...this, + allItems: computed(() => + [...this._unorderedItems()].sort(sortDirectives).map(item => item.pattern), + ), + activeIndex: signal(0), + }); + + /** Whether the tree has received focus yet. */ + private _hasFocused = signal(false); + + constructor() { + afterRenderEffect(() => { + if (!this._hasFocused()) { + this.pattern.setDefaultState(); + } + }); + } + + onFocus() { + this._hasFocused.set(true); + } + + register(child: CdkTreeItemGroup | CdkTreeItem) { + if (child instanceof CdkTreeItemGroup) { + this.unorderedGroups().add(child); + this.unorderedGroups.set(new Set(this.unorderedGroups())); + } + + if (child instanceof CdkTreeItem) { + this._unorderedItems().add(child); + this._unorderedItems.set(new Set(this._unorderedItems())); + } + } + + deregister(child: CdkTreeItemGroup | CdkTreeItem) { + if (child instanceof CdkTreeItemGroup) { + this.unorderedGroups().delete(child); + this.unorderedGroups.set(new Set(this.unorderedGroups())); + } + + if (child instanceof CdkTreeItem) { + this._unorderedItems().delete(child); + this._unorderedItems.set(new Set(this._unorderedItems())); + } + } +} + +/** + * A selectable and expandable Tree Item in a Tree. + */ +@Directive({ + selector: '[cdkTreeItem]', + exportAs: 'cdkTreeItem', + host: { + 'class': 'cdk-treeitem', + '[class.cdk-active]': 'pattern.active()', + 'role': 'treeitem', + '[id]': 'pattern.id()', + '[attr.aria-expanded]': 'pattern.expandable() ? pattern.expanded() : null', + '[attr.aria-selected]': 'pattern.selected()', + '[attr.aria-disabled]': 'pattern.disabled()', + '[attr.aria-level]': 'pattern.level()', + '[attr.aria-owns]': 'group()?.id', + '[attr.aria-setsize]': 'pattern.setsize()', + '[attr.aria-posinset]': 'pattern.posinset()', + '[attr.tabindex]': 'pattern.tabindex()', + '[attr.inert]': 'pattern.visible() ? null : true', + }, +}) +export class CdkTreeItem implements OnInit, OnDestroy, HasElement { + /** A reference to the tree item element. */ + private readonly _elementRef = inject(ElementRef); + + /** A unique identifier for the tree item. */ + private readonly _id = inject(_IdGenerator).getId('cdk-tree-item-'); + + /** The top level CdkTree. */ + private readonly _tree = inject(CdkTree); + + /** The parent CdkTreeItem. */ + private readonly _treeItem = inject(CdkTreeItem, {optional: true, skipSelf: true}); + + /** The parent CdkGroup, if any. */ + private readonly _parentGroup = inject(CdkTreeItemGroup, {optional: true}); + + /** The top level TreePattern. */ + private readonly _treePattern = computed(() => this._tree.pattern); + + /** The parent TreeItemPattern. */ + private readonly _parentPattern: Signal | TreePattern> = computed( + () => this._treeItem?.pattern ?? this._treePattern(), + ); + + /** The host native element. */ + readonly element = computed(() => this._elementRef.nativeElement); + + /** The value of the tree item. */ + readonly value = input.required(); + + /** Whether the tree item is disabled. */ + readonly disabled = input(false, {transform: booleanAttribute}); + + /** Optional label for typeahead. Defaults to the element's textContent. */ + readonly label = input(); + + /** Search term for typeahead. */ + readonly searchTerm = computed(() => this.label() ?? this.element().textContent); + + /** Manual group assignment. */ + readonly group = signal | undefined>(undefined); + + /** The UI pattern for this item. */ + readonly pattern: TreeItemPattern = new TreeItemPattern({ + ...this, + id: () => this._id, + tree: this._treePattern, + parent: this._parentPattern, + children: computed( + () => + this.group() + ?.children() + .map(item => (item as CdkTreeItem).pattern) ?? [], + ), + hasChildren: computed(() => !!this.group()), + }); + + constructor() { + afterRenderEffect(() => { + const group = [...this._tree.unorderedGroups()].find(group => group.value() === this.value()); + if (group) { + this.group.set(group); + } + }); + + // Updates the visibility of the owned group. + afterRenderEffect(() => { + this.group()?.visible.set(this.pattern.expanded()); + }); + } + + ngOnInit() { + this._tree.register(this); + this._parentGroup?.register(this); + } + + ngOnDestroy() { + this._tree.deregister(this); + this._parentGroup?.deregister(this); + } +} + +/** + * Container that designates content as a group. + */ +@Directive({ + selector: '[cdkTreeItemGroup]', + exportAs: 'cdkTreeItemGroup', + hostDirectives: [ + { + directive: DeferredContentAware, + inputs: ['preserveContent'], + }, + ], + host: { + 'class': 'cdk-treeitem-group', + 'role': 'group', + '[id]': 'id', + '[attr.inert]': 'visible() ? null : true', + }, +}) +export class CdkTreeItemGroup implements OnInit, OnDestroy, HasElement { + /** A reference to the group element. */ + private readonly _elementRef = inject(ElementRef); + + /** The DeferredContentAware host directive. */ + private readonly _deferredContentAware = inject(DeferredContentAware); + + /** The top level CdkTree. */ + private readonly _tree = inject(CdkTree); + + /** All groupable items that are descendants of the group. */ + private readonly _unorderedItems = signal(new Set>()); + + /** The host native element. */ + readonly element = computed(() => this._elementRef.nativeElement); + + /** Unique ID for the group. */ + readonly id = inject(_IdGenerator).getId('cdk-tree-group-'); + + /** Whether the group is visible. */ + readonly visible = signal(true); + + /** Child items within this group. */ + readonly children = computed(() => [...this._unorderedItems()].sort(sortDirectives)); + + /** Identifier for matching the group owner. */ + readonly value = input.required(); + + constructor() { + // Connect the group's hidden state to the DeferredContentAware's visibility. + afterRenderEffect(() => { + this._deferredContentAware.contentVisible.set(this.visible()); + }); + } + + ngOnInit() { + this._tree.register(this); + } + + ngOnDestroy() { + this._tree.deregister(this); + } + + register(child: CdkTreeItem) { + this._unorderedItems().add(child); + this._unorderedItems.set(new Set(this._unorderedItems())); + } + + deregister(child: CdkTreeItem) { + this._unorderedItems().delete(child); + this._unorderedItems.set(new Set(this._unorderedItems())); + } +} + +/** + * A structural directive that marks the `ng-template` to be used as the content + * for a `CdkTreeItemGroup`. This content can be lazily loaded. + */ +@Directive({ + selector: 'ng-template[cdkTreeItemGroupContent]', + hostDirectives: [DeferredContent], +}) +export class CdkTreeItemGroupContent {} diff --git a/src/cdk-experimental/ui-patterns/tree/tree.spec.ts b/src/cdk-experimental/ui-patterns/tree/tree.spec.ts index 9a6c7dbab8c9..47d6370bf948 100644 --- a/src/cdk-experimental/ui-patterns/tree/tree.spec.ts +++ b/src/cdk-experimental/ui-patterns/tree/tree.spec.ts @@ -1150,4 +1150,112 @@ describe('Tree Pattern', () => { expect(item0.expanded()).toBe(false); }); }); + + describe('#setDefaultState', () => { + let treeInputs: TestTreeInputs; + + beforeEach(() => { + treeInputs = { + activeIndex: signal(0), + disabled: signal(false), + focusMode: signal('roving'), + multi: signal(false), + orientation: signal('vertical'), + selectionMode: signal('explicit'), + skipDisabled: signal(true), + textDirection: signal('ltr'), + typeaheadDelay: signal(0), + value: signal([]), + wrap: signal(false), + }; + }); + + it('should set activeIndex to the first visible focusable item if no selection', () => { + const localTreeData: TestTreeItem[] = [ + {value: 'A', disabled: false}, + {value: 'B', disabled: false}, + ]; + const {tree} = createTree(localTreeData, treeInputs); + + tree.setDefaultState(); + expect(treeInputs.activeIndex()).toBe(0); + }); + + it('should set activeIndex to the first visible focusable disabled item if skipDisabled is false and no selection', () => { + const localTreeData: TestTreeItem[] = [ + {value: 'A', disabled: true}, + {value: 'B', disabled: false}, + ]; + treeInputs.skipDisabled.set(false); + const {tree} = createTree(localTreeData, treeInputs); + + tree.setDefaultState(); + expect(treeInputs.activeIndex()).toBe(0); + }); + + it('should set activeIndex to the first selected visible focusable item', () => { + const localTreeData: TestTreeItem[] = [ + {value: 'A', disabled: false}, + {value: 'B', disabled: false}, + {value: 'C', disabled: false}, + ]; + treeInputs.value.set(['B']); + const {tree} = createTree(localTreeData, treeInputs); + + tree.setDefaultState(); + expect(treeInputs.activeIndex()).toBe(1); + }); + + it('should prioritize the first selected item in visible order', () => { + const localTreeData: TestTreeItem[] = [ + {value: 'A', disabled: false}, + {value: 'B', disabled: false}, + {value: 'C', disabled: false}, + ]; + treeInputs.value.set(['C', 'A']); + const {tree} = createTree(localTreeData, treeInputs); + + tree.setDefaultState(); + expect(treeInputs.activeIndex()).toBe(0); + }); + + it('should skip a selected disabled item if skipDisabled is true', () => { + const localTreeData: TestTreeItem[] = [ + {value: 'A', disabled: false}, + {value: 'B', disabled: true}, + {value: 'C', disabled: false}, + ]; + treeInputs.value.set(['B']); + treeInputs.skipDisabled.set(true); + const {tree} = createTree(localTreeData, treeInputs); + + tree.setDefaultState(); + expect(treeInputs.activeIndex()).toBe(0); + }); + + it('should select a selected disabled item if skipDisabled is false', () => { + const localTreeData: TestTreeItem[] = [ + {value: 'A', disabled: false}, + {value: 'B', disabled: true}, + {value: 'C', disabled: false}, + ]; + treeInputs.value.set(['B']); + treeInputs.skipDisabled.set(false); + const {tree} = createTree(localTreeData, treeInputs); + + tree.setDefaultState(); + expect(treeInputs.activeIndex()).toBe(1); + }); + + it('should set activeIndex to first visible focusable item if selected item is not visible', () => { + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + treeInputs.value.set(['Item 0-0']); + + expect(item0.expanded()).toBe(false); + expect(getItemByValue(allItems(), 'Item 0-0').visible()).toBe(false); + tree.setDefaultState(); + expect(treeInputs.activeIndex()).toBe(0); + }); + }); }); diff --git a/src/cdk-experimental/ui-patterns/tree/tree.ts b/src/cdk-experimental/ui-patterns/tree/tree.ts index e7d6c48cda3e..d67b8930254c 100644 --- a/src/cdk-experimental/ui-patterns/tree/tree.ts +++ b/src/cdk-experimental/ui-patterns/tree/tree.ts @@ -378,7 +378,33 @@ export class TreePattern { }); } - // TODO(ok7sai): add `setDefaultState` method. + /** + * Sets the tree to it's default initial state. + * + * Sets the active index of the tree to the first focusable selected tree item if one exists. + * Otherwise, sets focus to the first focusable tree item. + */ + setDefaultState() { + let firstItemIndex: number | undefined; + + for (const [index, item] of this.allItems().entries()) { + if (!item.visible()) continue; + if (!this.focusManager.isFocusable(item)) continue; + + if (firstItemIndex === undefined) { + firstItemIndex = index; + } + + if (item.selected()) { + this.activeIndex.set(index); + return; + } + } + + if (firstItemIndex !== undefined) { + this.activeIndex.set(firstItemIndex); + } + } /** Handles keydown events on the tree. */ onKeydown(event: KeyboardEvent) { diff --git a/src/components-examples/cdk-experimental/tree/BUILD.bazel b/src/components-examples/cdk-experimental/tree/BUILD.bazel new file mode 100644 index 000000000000..2c349fb1e681 --- /dev/null +++ b/src/components-examples/cdk-experimental/tree/BUILD.bazel @@ -0,0 +1,30 @@ +load("//tools:defaults.bzl", "ng_project") + +package(default_visibility = ["//visibility:public"]) + +ng_project( + name = "tree", + srcs = glob(["**/*.ts"]), + assets = glob([ + "**/*.html", + "**/*.css", + ]), + deps = [ + "//:node_modules/@angular/core", + "//:node_modules/@angular/forms", + "//src/cdk-experimental/tree", + "//src/material/checkbox", + "//src/material/form-field", + "//src/material/icon", + "//src/material/select", + ], +) + +filegroup( + name = "source-files", + srcs = glob([ + "**/*.html", + "**/*.css", + "**/*.ts", + ]), +) 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 new file mode 100644 index 000000000000..004c5a8a48c6 --- /dev/null +++ b/src/components-examples/cdk-experimental/tree/cdk-tree/cdk-tree-example.css @@ -0,0 +1,48 @@ +.example-tree-controls { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 16px; +} + +.example-tree-output { + padding: 10px; + margin-bottom: 16px; + border: 1px solid var(--mat-sys-outline); + border-radius: var(--mat-sys-corner-extra-small); +} + +.example-tree { + padding: 10px; + border: 1px solid var(--mat-sys-outline); + border-radius: var(--mat-sys-corner-extra-small); +} + +.example-tree-item { + cursor: pointer; + user-select: none; + list-style: none; +} + +.example-tree-item[aria-selected='true'] { + background-color: var(--mat-sys-inverse-primary); +} + +.example-tree-item[aria-selected='false'] { + background-color: var(--mat-sys-background); +} + +.example-tree-item[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; + padding: 2px 0; /* Minimal padding for item itself */ +} + +.example-tree-item-icon { + margin-right: 8px; +} 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 new file mode 100644 index 000000000000..c2c1652ff335 --- /dev/null +++ b/src/components-examples/cdk-experimental/tree/cdk-tree/cdk-tree-example.html @@ -0,0 +1,52 @@ +
+ Wrap + Multi + Disabled + Skip Disabled + + + Orientation + + Vertical + Horizontal + + + + + Selection Strategy + + Explicit + Follow + + + + + Focus Strategy + + Roving + Active Descendant + + +
+ +
+ Selected Values: {{ selectedValues().join(', ') || 'None' }} +
+ +
    + @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 new file mode 100644 index 000000000000..a56b82ab957c --- /dev/null +++ b/src/components-examples/cdk-experimental/tree/cdk-tree/cdk-tree-example.ts @@ -0,0 +1,143 @@ +import {Component, model, input} from '@angular/core'; +import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms'; +import {MatCheckboxModule} from '@angular/material/checkbox'; +import {MatFormFieldModule} from '@angular/material/form-field'; +import {MatSelectModule} from '@angular/material/select'; +import {MatIconModule} from '@angular/material/icon'; +import { + CdkTree, + CdkTreeItem, + CdkTreeItemGroup, + CdkTreeItemGroupContent, +} from '@angular/cdk-experimental/tree'; + +interface ExampleNode { + value: string; + label?: string; + disabled?: boolean; + expanded?: boolean; + children?: ExampleNode[]; +} + +@Component({ + selector: 'example-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 ExampleNodeComponent { + node = input.required(); +} + +/** @title Tree using CdkTree and CdkTreeItem. */ +@Component({ + selector: 'cdk-tree-example', + exportAs: 'cdkTreeExample', + templateUrl: 'cdk-tree-example.html', + styleUrl: 'cdk-tree-example.css', + imports: [ + FormsModule, + ReactiveFormsModule, + MatCheckboxModule, + MatFormFieldModule, + MatSelectModule, + MatIconModule, + CdkTree, + ExampleNodeComponent, + ], +}) +export class CdkTreeExample { + // Tree data + treeData: ExampleNode[] = [ + { + value: 'electronics', + label: 'electronics', + children: [ + { + value: 'audio', + label: 'audio equipment', + children: [ + {value: 'headphones', label: 'headphones'}, + {value: 'speakers', label: 'speakers (disabled)', disabled: true}, + {value: 'amps', label: 'amplifiers'}, + ], + }, + { + value: 'computers', + label: 'computers & tablets', + children: [ + {value: 'laptops', label: 'laptops'}, + {value: 'desktops', label: 'desktops'}, + {value: 'tablets', label: 'tablets'}, + ], + }, + {value: 'cameras', label: 'cameras'}, + ], + }, + { + value: 'furniture', + label: 'furniture', + children: [ + {value: 'tables', label: 'tables'}, + {value: 'chairs', label: 'chairs'}, + {value: 'sofas', label: 'sofas'}, + ], + }, + { + value: 'books', + label: 'books (no children)', + }, + { + value: 'clothing', + label: 'clothing (disabled parent)', + disabled: true, + children: [ + {value: 'shirts', label: 'shirts'}, + {value: 'pants', label: 'pants'}, + ], + }, + ]; + + // TODO(ok7sai): add styling to horizontal tree view. + orientation: 'vertical' | 'horizontal' = 'vertical'; + selectionMode: 'explicit' | 'follow' = 'explicit'; + focusMode: 'roving' | 'activedescendant' = 'roving'; + + multi = new FormControl(false, {nonNullable: true}); + disabled = new FormControl(false, {nonNullable: true}); + wrap = new FormControl(true, {nonNullable: true}); + skipDisabled = new FormControl(true, {nonNullable: true}); + + selectedValues = model(['books']); +} diff --git a/src/components-examples/cdk-experimental/tree/index.ts b/src/components-examples/cdk-experimental/tree/index.ts new file mode 100644 index 000000000000..731d29286979 --- /dev/null +++ b/src/components-examples/cdk-experimental/tree/index.ts @@ -0,0 +1 @@ +export {CdkTreeExample} from './cdk-tree/cdk-tree-example'; diff --git a/src/dev-app/BUILD.bazel b/src/dev-app/BUILD.bazel index 4d18df841808..4ae093201d36 100644 --- a/src/dev-app/BUILD.bazel +++ b/src/dev-app/BUILD.bazel @@ -38,6 +38,7 @@ ng_project( "//src/dev-app/cdk-experimental-listbox", "//src/dev-app/cdk-experimental-radio", "//src/dev-app/cdk-experimental-tabs", + "//src/dev-app/cdk-experimental-tree", "//src/dev-app/cdk-listbox", "//src/dev-app/cdk-menu", "//src/dev-app/checkbox", diff --git a/src/dev-app/cdk-experimental-tree/BUILD.bazel b/src/dev-app/cdk-experimental-tree/BUILD.bazel new file mode 100644 index 000000000000..9091a214d35a --- /dev/null +++ b/src/dev-app/cdk-experimental-tree/BUILD.bazel @@ -0,0 +1,10 @@ +load("//tools:defaults.bzl", "ng_project") + +package(default_visibility = ["//visibility:public"]) + +ng_project( + name = "cdk-experimental-tree", + srcs = glob(["**/*.ts"]), + assets = ["cdk-tree-demo.html"], + deps = ["//src/components-examples/cdk-experimental/tree"], +) diff --git a/src/dev-app/cdk-experimental-tree/cdk-tree-demo.html b/src/dev-app/cdk-experimental-tree/cdk-tree-demo.html new file mode 100644 index 000000000000..76cfa8843aef --- /dev/null +++ b/src/dev-app/cdk-experimental-tree/cdk-tree-demo.html @@ -0,0 +1,4 @@ +
    +

    Tree View using UI Patterns

    + +
    diff --git a/src/dev-app/cdk-experimental-tree/cdk-tree-demo.ts b/src/dev-app/cdk-experimental-tree/cdk-tree-demo.ts new file mode 100644 index 000000000000..c9b973635b0a --- /dev/null +++ b/src/dev-app/cdk-experimental-tree/cdk-tree-demo.ts @@ -0,0 +1,17 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {ChangeDetectionStrategy, Component} from '@angular/core'; +import {CdkTreeExample} from '@angular/components-examples/cdk-experimental/tree'; + +@Component({ + templateUrl: 'cdk-tree-demo.html', + imports: [CdkTreeExample], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CdkExperimentalTreeDemo {} diff --git a/src/dev-app/dev-app/dev-app-layout.ts b/src/dev-app/dev-app/dev-app-layout.ts index e769dbfa8ecd..7f19c536ce34 100644 --- a/src/dev-app/dev-app/dev-app-layout.ts +++ b/src/dev-app/dev-app/dev-app-layout.ts @@ -64,6 +64,7 @@ export class DevAppLayout { {name: 'CDK Experimental Listbox', route: '/cdk-experimental-listbox'}, {name: 'CDK Experimental Tabs', route: '/cdk-experimental-tabs'}, {name: 'CDK Experimental Accordion', route: '/cdk-experimental-accordion'}, + {name: 'CDK Experimental Tree', route: '/cdk-experimental-tree'}, {name: 'CDK Listbox', route: '/cdk-listbox'}, {name: 'CDK Menu', route: '/cdk-menu'}, {name: 'Autocomplete', route: '/autocomplete'}, diff --git a/src/dev-app/routes.ts b/src/dev-app/routes.ts index 92ec9f59e71b..ece29855e3bb 100644 --- a/src/dev-app/routes.ts +++ b/src/dev-app/routes.ts @@ -67,6 +67,11 @@ export const DEV_APP_ROUTES: Routes = [ m => m.CdkExperimentalAccordionDemo, ), }, + { + path: 'cdk-experimental-tree', + loadComponent: () => + import('./cdk-experimental-tree/cdk-tree-demo').then(m => m.CdkExperimentalTreeDemo), + }, { path: 'cdk-dialog', loadComponent: () => import('./cdk-dialog/dialog-demo').then(m => m.DialogDemo),