From 5e53d9708b98a6c88e9cb692375201f9ec23b79a Mon Sep 17 00:00:00 2001 From: Cheng-Hsuan Tsai Date: Fri, 30 May 2025 00:54:25 +0000 Subject: [PATCH 1/2] feat(cdk-experimental/ui-patterns): Tree - preview --- src/cdk-experimental/tree/BUILD.bazel | 18 + src/cdk-experimental/tree/index.ts | 9 + src/cdk-experimental/tree/public-api.ts | 9 + src/cdk-experimental/tree/tree.ts | 271 +++++++++ src/cdk-experimental/ui-patterns/BUILD.bazel | 1 + .../ui-patterns/accordion/accordion.ts | 8 +- .../behaviors/expansion/expansion.ts | 61 +- src/cdk-experimental/ui-patterns/tabs/tabs.ts | 11 +- .../ui-patterns/tree/BUILD.bazel | 39 ++ .../ui-patterns/tree/tree.spec.ts | 0 src/cdk-experimental/ui-patterns/tree/tree.ts | 519 ++++++++++++++++++ .../cdk-experimental/tree/BUILD.bazel | 30 + .../tree/cdk-tree/cdk-tree-example.css | 48 ++ .../tree/cdk-tree/cdk-tree-example.html | 154 ++++++ .../tree/cdk-tree/cdk-tree-example.ts | 101 ++++ .../cdk-experimental/tree/index.ts | 1 + src/dev-app/BUILD.bazel | 1 + src/dev-app/cdk-experimental-tree/BUILD.bazel | 10 + .../cdk-experimental-tree/cdk-tree-demo.html | 4 + .../cdk-experimental-tree/cdk-tree-demo.ts | 17 + src/dev-app/dev-app/dev-app-layout.ts | 1 + src/dev-app/routes.ts | 5 + 22 files changed, 1279 insertions(+), 39 deletions(-) create mode 100644 src/cdk-experimental/tree/BUILD.bazel create mode 100644 src/cdk-experimental/tree/index.ts create mode 100644 src/cdk-experimental/tree/public-api.ts create mode 100644 src/cdk-experimental/tree/tree.ts create mode 100644 src/cdk-experimental/ui-patterns/tree/BUILD.bazel create mode 100644 src/cdk-experimental/ui-patterns/tree/tree.spec.ts create mode 100644 src/cdk-experimental/ui-patterns/tree/tree.ts create mode 100644 src/components-examples/cdk-experimental/tree/BUILD.bazel create mode 100644 src/components-examples/cdk-experimental/tree/cdk-tree/cdk-tree-example.css create mode 100644 src/components-examples/cdk-experimental/tree/cdk-tree/cdk-tree-example.html create mode 100644 src/components-examples/cdk-experimental/tree/cdk-tree/cdk-tree-example.ts create mode 100644 src/components-examples/cdk-experimental/tree/index.ts create mode 100644 src/dev-app/cdk-experimental-tree/BUILD.bazel create mode 100644 src/dev-app/cdk-experimental-tree/cdk-tree-demo.html create mode 100644 src/dev-app/cdk-experimental-tree/cdk-tree-demo.ts diff --git a/src/cdk-experimental/tree/BUILD.bazel b/src/cdk-experimental/tree/BUILD.bazel new file mode 100644 index 000000000000..e182879f88f5 --- /dev/null +++ b/src/cdk-experimental/tree/BUILD.bazel @@ -0,0 +1,18 @@ +load("//tools:defaults.bzl", "ng_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", + ], +) 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..d046a4636837 --- /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 {CdkGroup, CdkGroupContent, CdkTree, CdkTreeItem} from './tree'; diff --git a/src/cdk-experimental/tree/tree.ts b/src/cdk-experimental/tree/tree.ts new file mode 100644 index 000000000000..9d48ea22e2a5 --- /dev/null +++ b/src/cdk-experimental/tree/tree.ts @@ -0,0 +1,271 @@ +/** + * @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, + contentChildren, + forwardRef, + inject, + input, + model, + signal, + Signal, +} 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'; + +/** + * Base class to make a Cdk item groupable. + * + * Also need to add the following to the `@Directive` configuration: + * ``` + * providers: [ + * { provide: BaseGroupable, useExisting: forwardRef(() => CdkSomeItem) }, + * ], + * ``` + * + * TODO(ok7sai): Move it to a shared place. + */ +export class BaseGroupable { + /** The parent CdkGroup, if any. */ + groupParent = inject(CdkGroup, {optional: true}); +} + +/** + * Generic container that designates content as a group. + * + * TODO(ok7sai): Move it to a shared place. + */ +@Directive({ + selector: '[cdkGroup]', + exportAs: 'cdkGroup', + hostDirectives: [ + { + directive: DeferredContentAware, + inputs: ['preserveContent'], + }, + ], + host: { + 'class': 'cdk-group', + 'role': 'group', + '[id]': 'id', + '[attr.inert]': 'visible() ? null : true', + }, +}) +export class CdkGroup { + /** The DeferredContentAware host directive. */ + private readonly _deferredContentAware = inject(DeferredContentAware); + + /** All groupable items that are descendants of the group. */ + private readonly _items = contentChildren(BaseGroupable, {descendants: true}); + + /** Identifier for matching the group owner. */ + readonly value = input.required(); + + /** Whether the group is visible. */ + readonly visible = signal(true); + + /** Unique ID for the group. */ + readonly id = inject(_IdGenerator).getId('cdk-group-'); + + /** Child items within this group. */ + readonly children = signal([]); + + constructor() { + afterRenderEffect(() => { + this.children.set(this._items().filter(item => item.groupParent === this)); + }); + + // Connect the group's hidden state to the DeferredContentAware's visibility. + afterRenderEffect(() => { + this._deferredContentAware.contentVisible.set(this.visible()); + }); + } +} + +/** + * A structural directive that marks the `ng-template` to be used as the content + * for a `CdkGroup`. This content can be lazily loaded. + * + * TODO(ok7sai): Move it to a shared place. + */ +@Directive({ + selector: 'ng-template[cdkGroupContent]', + hostDirectives: [DeferredContent], +}) +export class CdkGroupContent {} + +/** + * Makes an element a tree and manages state (focus, selection, keyboard navigation). + */ +@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)', + }, +}) +export class CdkTree { + /** All CdkTreeItem instances within this tree. */ + private readonly _cdkTreeItems = contentChildren>(CdkTreeItem, { + descendants: true, + }); + + /** All TreeItemPattern instances within this tree. */ + private readonly _itemPatterns = computed(() => this._cdkTreeItems().map(item => item.pattern)); + + /** All CdkGroup instances within this tree. */ + private readonly _cdkGroups = contentChildren(CdkGroup, {descendants: true}); + + /** 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. */ + pattern: TreePattern = new TreePattern({ + ...this, + allItems: this._itemPatterns, + activeIndex: signal(0), + }); + + constructor() { + // Binds groups to tree items. + afterRenderEffect(() => { + const groups = this._cdkGroups(); + const treeItems = this._cdkTreeItems(); + for (const group of groups) { + const treeItem = treeItems.find(item => item.value() === group.value()); + treeItem?.group.set(group); + } + }); + } +} + +/** Makes an element a tree item within a `CdkTree`. */ +@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()', + }, + providers: [{provide: BaseGroupable, useExisting: forwardRef(() => CdkTreeItem)}], +}) +export class CdkTreeItem extends BaseGroupable { + /** A reference to the tree item element. */ + private readonly _elementRef = inject(ElementRef); + + /** The host native element. */ + private readonly _element = computed(() => this._elementRef.nativeElement); + + /** A unique identifier for the tree item. */ + private readonly _id = inject(_IdGenerator).getId('cdk-tree-item-'); + + /** The top level CdkTree. */ + private readonly _cdkTree = inject(CdkTree, {optional: true}); + + /** The parent CdkTreeItem. */ + private readonly _cdkTreeItem = inject(CdkTreeItem, {optional: true, skipSelf: true}); + + /** The top lavel TreePattern. */ + private readonly _treePattern = computed(() => this._cdkTree?.pattern); + + /** The parent TreeItemPattern. */ + private readonly _parentPattern: Signal | TreePattern | undefined> = + computed(() => this._cdkTreeItem?.pattern ?? this._treePattern()); + + /** 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. */ + pattern: TreeItemPattern = new TreeItemPattern({ + ...this, + id: () => this._id, + element: this._element, + tree: this._treePattern, + parent: this._parentPattern, + children: computed( + () => + this.group() + ?.children() + .map(item => (item as CdkTreeItem).pattern) ?? [], + ), + hasChilren: computed(() => !!this.group()), + }); + + constructor() { + super(); + + // Updates the visibility of the owned group. + afterRenderEffect(() => { + this.group()?.visible.set(this.pattern.expanded()); + }); + } +} diff --git a/src/cdk-experimental/ui-patterns/BUILD.bazel b/src/cdk-experimental/ui-patterns/BUILD.bazel index 29e68600535a..fa9ffec1b1e4 100644 --- a/src/cdk-experimental/ui-patterns/BUILD.bazel +++ b/src/cdk-experimental/ui-patterns/BUILD.bazel @@ -15,5 +15,6 @@ ts_project( "//src/cdk-experimental/ui-patterns/listbox", "//src/cdk-experimental/ui-patterns/radio", "//src/cdk-experimental/ui-patterns/tabs", + "//src/cdk-experimental/ui-patterns/tree", ], ) diff --git a/src/cdk-experimental/ui-patterns/accordion/accordion.ts b/src/cdk-experimental/ui-patterns/accordion/accordion.ts index 2d378148fe47..96c52f69423e 100644 --- a/src/cdk-experimental/ui-patterns/accordion/accordion.ts +++ b/src/cdk-experimental/ui-patterns/accordion/accordion.ts @@ -27,7 +27,7 @@ import {SignalLike} from '../behaviors/signal-like/signal-like'; export type AccordionGroupInputs = Omit< ListNavigationInputs & ListFocusInputs & - ListExpansionInputs, + Omit, 'focusMode' >; @@ -43,7 +43,7 @@ export class AccordionGroupPattern { focusManager: ListFocus; /** Controls expansion for the group. */ - expansionManager: ListExpansion; + expansionManager: ListExpansion; constructor(readonly inputs: AccordionGroupInputs) { this.wrap = inputs.wrap; @@ -66,8 +66,6 @@ export class AccordionGroupPattern { }); this.expansionManager = new ListExpansion({ ...inputs, - focusMode, - focusManager: this.focusManager, }); } } @@ -123,7 +121,7 @@ export class AccordionTriggerPattern { ...inputs, expansionId: inputs.value, expandable: () => true, - expansionManager: inputs.accordionGroup().expansionManager, + expansionManager: () => inputs.accordionGroup().expansionManager, }); this.expandable = this.expansionControl.isExpandable; this.expansionId = this.expansionControl.expansionId; diff --git a/src/cdk-experimental/ui-patterns/behaviors/expansion/expansion.ts b/src/cdk-experimental/ui-patterns/behaviors/expansion/expansion.ts index c2363c8e4447..5d569a172661 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/expansion/expansion.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/expansion/expansion.ts @@ -7,15 +7,17 @@ */ import {computed, signal} from '@angular/core'; import {SignalLike, WritableSignalLike} from '../signal-like/signal-like'; -import {ListFocus, ListFocusInputs, ListFocusItem} from '../list-focus/list-focus'; /** Represents an item that can be expanded or collapsed. */ -export interface ExpansionItem extends ListFocusItem { +export interface ExpansionItem { /** Whether the item is expandable. */ expandable: SignalLike; /** Used to uniquely identify an expansion item. */ expansionId: SignalLike; + + /** Whether the expansion is disabled. */ + disabled: SignalLike; } export interface ExpansionControl extends ExpansionItem {} @@ -25,57 +27,65 @@ export interface ExpansionControl extends ExpansionItem {} */ export class ExpansionControl { /** Whether this specific item is currently expanded. Derived from the Expansion manager. */ - readonly isExpanded = computed(() => this.inputs.expansionManager.isExpanded(this)); + readonly isExpanded: SignalLike = computed( + () => this.inputs.expansionManager()?.isExpanded(this) ?? false, + ); /** Whether this item can be expanded. */ - readonly isExpandable = computed(() => this.inputs.expansionManager.isExpandable(this)); + readonly isExpandable: SignalLike = computed( + () => this.inputs.expansionManager()?.isExpandable(this) ?? false, + ); - constructor(readonly inputs: ExpansionItem & {expansionManager: ListExpansion}) { + constructor( + readonly inputs: ExpansionItem & {expansionManager: SignalLike}, + ) { this.expansionId = inputs.expansionId; this.expandable = inputs.expandable; - this.element = inputs.element; this.disabled = inputs.disabled; } /** Requests the Expansopn manager to open this item. */ open() { - this.inputs.expansionManager.open(this); + this.inputs.expansionManager()?.open(this); } /** Requests the Expansion manager to close this item. */ close() { - this.inputs.expansionManager.close(this); + this.inputs.expansionManager()?.close(this); } /** Requests the Expansion manager to toggle this item. */ toggle() { - this.inputs.expansionManager.toggle(this); + this.inputs.expansionManager()?.toggle(this); } } /** Represents the required inputs for an expansion behavior. */ -export interface ListExpansionInputs extends ListFocusInputs { +export interface ListExpansionInputs { /** Whether multiple items can be expanded at once. */ multiExpandable: SignalLike; /** An array of ids of the currently expanded items. */ - expandedIds: WritableSignalLike; + expandedIds?: WritableSignalLike; + + /** An array of expansion items. */ + items: SignalLike; + + /** Whether all expansions are disabled. */ + disabled: SignalLike; } /** Manages the expansion state of a list of items. */ -export class ListExpansion { +export class ListExpansion { /** A signal holding an array of ids of the currently expanded items. */ expandedIds: WritableSignalLike; - /** The currently active (focused) item in the list. */ - activeItem = computed(() => this.inputs.focusManager.activeItem()); - - constructor(readonly inputs: ListExpansionInputs & {focusManager: ListFocus}) { + constructor(readonly inputs: ListExpansionInputs) { this.expandedIds = inputs.expandedIds ?? signal([]); } - /** Opens the specified item, or the currently active item if none is specified. */ - open(item: T = this.activeItem()) { + /** Opens the specified item. */ + open(item: ExpansionItem) { if (!this.isExpandable(item)) return; if (this.isExpanded(item)) return; if (!this.inputs.multiExpandable()) { @@ -84,18 +94,15 @@ export class ListExpansion { this.expandedIds.update(ids => ids.concat(item.expansionId())); } - /** Closes the specified item, or the currently active item if none is specified. */ - close(item: T = this.activeItem()) { + /** Closes the specified item. */ + close(item: ExpansionItem) { if (this.isExpandable(item)) { this.expandedIds.update(ids => ids.filter(id => id !== item.expansionId())); } } - /** - * Toggles the expansion state of the specified item, - * or the currently active item if none is specified. - */ - toggle(item: T = this.activeItem()) { + /** Toggles the expansion state of the specified item. */ + toggle(item: ExpansionItem) { this.expandedIds().includes(item.expansionId()) ? this.close(item) : this.open(item); } @@ -116,12 +123,12 @@ export class ListExpansion { } /** Checks whether the specified item is expandable / collapsible. */ - isExpandable(item: T) { + isExpandable(item: ExpansionItem) { return !this.inputs.disabled() && !item.disabled() && item.expandable(); } /** Checks whether the specified item is currently expanded. */ - isExpanded(item: T): boolean { + isExpanded(item: ExpansionItem): boolean { return this.expandedIds().includes(item.expansionId()); } } diff --git a/src/cdk-experimental/ui-patterns/tabs/tabs.ts b/src/cdk-experimental/ui-patterns/tabs/tabs.ts index 9f9b08f2804d..6fadf55cfc12 100644 --- a/src/cdk-experimental/ui-patterns/tabs/tabs.ts +++ b/src/cdk-experimental/ui-patterns/tabs/tabs.ts @@ -87,7 +87,7 @@ export class TabPattern { ...inputs, expansionId: inputs.value, expandable: () => true, - expansionManager: inputs.tablist().expansionManager, + expansionManager: () => inputs.tablist().expansionManager, }); this.expansionId = expansionControl.expansionId; this.expandable = expansionControl.isExpandable; @@ -128,9 +128,7 @@ interface SelectOptions { export type TabListInputs = ListNavigationInputs & Omit, 'multi'> & ListFocusInputs & - Omit, 'multiExpandable' | 'expandedIds'> & { - disabled: SignalLike; - }; + Omit; /** Controls the state of a tablist. */ export class TabListPattern { @@ -144,7 +142,7 @@ export class TabListPattern { focusManager: ListFocus; /** Controls expansion for the tablist. */ - expansionManager: ListExpansion; + expansionManager: ListExpansion; /** Whether the tablist is vertically or horizontally oriented. */ orientation: SignalLike<'vertical' | 'horizontal'>; @@ -210,7 +208,6 @@ export class TabListPattern { ...inputs, multiExpandable: () => false, expandedIds: this.inputs.value, - focusManager: this.focusManager, }); } @@ -266,7 +263,7 @@ export class TabListPattern { private _select(opts?: SelectOptions) { if (opts?.select) { this.selection.selectOne(); - this.expansionManager.open(); + this.expansionManager.open(this.focusManager.activeItem()); } } diff --git a/src/cdk-experimental/ui-patterns/tree/BUILD.bazel b/src/cdk-experimental/ui-patterns/tree/BUILD.bazel new file mode 100644 index 000000000000..1a53f80c42eb --- /dev/null +++ b/src/cdk-experimental/ui-patterns/tree/BUILD.bazel @@ -0,0 +1,39 @@ +load("//tools:defaults.bzl", "ng_web_test_suite", "ts_project") + +package(default_visibility = ["//visibility:public"]) + +ts_project( + name = "tree", + srcs = [ + "tree.ts", + ], + deps = [ + "//:node_modules/@angular/core", + "//src/cdk-experimental/ui-patterns/behaviors/event-manager", + "//src/cdk-experimental/ui-patterns/behaviors/expansion", + "//src/cdk-experimental/ui-patterns/behaviors/list-focus", + "//src/cdk-experimental/ui-patterns/behaviors/list-navigation", + "//src/cdk-experimental/ui-patterns/behaviors/list-selection", + "//src/cdk-experimental/ui-patterns/behaviors/list-typeahead", + "//src/cdk-experimental/ui-patterns/behaviors/signal-like", + ], +) + +ts_project( + name = "unit_test_sources", + testonly = True, + srcs = [ + "tree.spec.ts", + ], + deps = [ + ":tree", + "//:node_modules/@angular/core", + "//src/cdk/keycodes", + "//src/cdk/testing/private", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [":unit_test_sources"], +) diff --git a/src/cdk-experimental/ui-patterns/tree/tree.spec.ts b/src/cdk-experimental/ui-patterns/tree/tree.spec.ts new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/cdk-experimental/ui-patterns/tree/tree.ts b/src/cdk-experimental/ui-patterns/tree/tree.ts new file mode 100644 index 000000000000..54918646b530 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/tree/tree.ts @@ -0,0 +1,519 @@ +/** + * @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 {computed, signal} from '@angular/core'; +import {SignalLike} from '../behaviors/signal-like/signal-like'; +import {ListFocus, ListFocusInputs, ListFocusItem} from '../behaviors/list-focus/list-focus'; +import { + ListNavigation, + ListNavigationInputs, + ListNavigationItem, +} from '../behaviors/list-navigation/list-navigation'; +import { + ListSelection, + ListSelectionInputs, + ListSelectionItem, +} from '../behaviors/list-selection/list-selection'; +import { + ListTypeahead, + ListTypeaheadInputs, + ListTypeaheadItem, +} from '../behaviors/list-typeahead/list-typeahead'; +import {ExpansionItem, ExpansionControl, ListExpansion} from '../behaviors/expansion/expansion'; +import {KeyboardEventManager} from '../behaviors/event-manager/keyboard-event-manager'; +import {PointerEventManager} from '../behaviors/event-manager/pointer-event-manager'; +import {ModifierKey as Modifier} from '../behaviors/event-manager/event-manager'; + +/** Represents the required inputs for a tree item. */ +export interface TreeItemInputs + extends ListFocusItem, + ListNavigationItem, + ListSelectionItem, + ListTypeaheadItem { + /** The parent node. */ + parent: SignalLike | TreePattern | undefined>; + + /** Whether this node has children. Children can be lazily loaded. */ + hasChilren: SignalLike; + + /** The children nodes. */ + children: SignalLike[]>; + + /** The tree pattern this item belongs to. */ + tree: SignalLike | undefined>; +} + +export interface TreeItemPattern extends TreeItemInputs {} +/** + * Represents an item in a Tree. + */ +export class TreeItemPattern implements ExpansionItem { + /** The children nodes. */ + readonly children: SignalLike[]>; + + /** The unique identifier used by the expansion behavior. */ + readonly expansionId: SignalLike; + + /** Controls expansion for child items. */ + readonly expansionManager: ListExpansion; + + /** Controls expansion for this item. */ + readonly expansion: ExpansionControl; + + /** The level of the current node in a tree. */ + readonly level: SignalLike = computed(() => + this.parent() ? this.parent()!.level()! + 1 : undefined, + ); + + /** Whether the item is expandable. It's expandable if children nodes exist. */ + readonly expandable: SignalLike; + + /** Whether this item is currently expanded. */ + readonly expanded = computed(() => this.expansion.isExpanded()); + + /** Whether this item is visible. */ + readonly visible = computed(() => this.parent()?.expanded() ?? false); + + /** The number of items under the same parent at the same level. */ + readonly setsize = computed(() => this.parent()?.children().length); + + /** The position of this item among its siblings (1-based). */ + readonly posinset = computed(() => + this.parent() ? this.parent()!.children().indexOf(this) + 1 : undefined, + ); + + /** Whether the item is active. */ + readonly active = computed(() => this.tree()?.focusManager.activeItem() === this); + + /** The tabindex of the item. */ + readonly tabindex = computed(() => (this.tree()?.focusManager.isFocusable(this) ? 0 : -1)); + + /** Whether the item is selected. */ + readonly selected = computed(() => this.tree()?.value().includes(this.value())); + + constructor(readonly inputs: TreeItemInputs) { + this.id = inputs.id; + this.value = inputs.value; + this.element = inputs.element; + this.disabled = inputs.disabled; + this.searchTerm = inputs.searchTerm; + this.expansionId = inputs.id; + this.tree = inputs.tree; + this.parent = inputs.parent; + this.children = inputs.children; + this.expandable = inputs.hasChilren; + this.expansion = new ExpansionControl({ + ...inputs, + expandable: this.expandable, + expansionId: this.expansionId, + expansionManager: computed(() => this.parent()?.expansionManager), + }); + this.expansionManager = new ListExpansion({ + ...inputs, + multiExpandable: () => true, + items: this.children, + disabled: computed(() => this.tree()?.disabled() ?? false), + }); + } +} + +/** The selection operations that the tree can perform. */ +interface SelectOptions { + toggle?: boolean; + selectOne?: boolean; + selectRange?: boolean; + anchor?: boolean; +} + +/** Represents the required inputs for a tree. */ +export interface TreeInputs + extends Omit< + ListFocusInputs> & + ListNavigationInputs> & + ListSelectionInputs, V> & + ListTypeaheadInputs>, + 'items' + > { + /** All items in the tree, in document order (DFS-like, a flattened list). */ + allItems: SignalLike[]>; +} + +export interface TreePattern extends TreeInputs {} +/** Controls the state and interactions of a tree view. */ +export class TreePattern { + /** Controls focus for the all visible tree items. */ + readonly focusManager: ListFocus>; + + /** Controls navigation for all visible tree items. */ + readonly navigationManager: ListNavigation>; + + /** Controls selection for all visible tree items. */ + readonly selectionManager: ListSelection, V>; + + /** Controls typeahead for all visible tree items. */ + readonly typeaheadManager: ListTypeahead>; + + /** Controls expansion for direct children of the tree root (top-level items). */ + readonly expansionManager: ListExpansion; + + /** The root level is 0. */ + readonly level = () => 0; + + /** The root is always expanded. */ + readonly expanded = () => true; + + /** The tabindex of the tree. */ + readonly tabindex = computed(() => this.focusManager.getListTabindex()); + + /** The id of the current active item. */ + readonly activedescendant = computed(() => this.focusManager.getActiveDescendant()); + + /** Whether the tree is in multi selection mode. */ + readonly inSelection = signal(false); + + /** The direct children of the root (top-level tree items). */ + readonly children = computed(() => + this.inputs.allItems().filter(item => item.level() === this.level() + 1), + ); + + /** All currently visible/expanded tree items. */ + readonly visibleItems = computed(() => this.inputs.allItems().filter(item => item.visible())); + + /** Whether the tree selection follows focus. */ + readonly followFocus = computed(() => this.inputs.selectionMode() === 'follow'); + + /** The key for navigating to the previous item. */ + readonly prevKey = computed(() => { + if (this.inputs.orientation() === 'vertical') { + return 'ArrowUp'; + } + return this.inputs.textDirection() === 'rtl' ? 'ArrowRight' : 'ArrowLeft'; + }); + + /** The key for navigating to the next item. */ + readonly nextKey = computed(() => { + if (this.inputs.orientation() === 'vertical') { + return 'ArrowDown'; + } + return this.inputs.textDirection() === 'rtl' ? 'ArrowLeft' : 'ArrowRight'; + }); + + /** The key for collapsing a node or moving to its parent. */ + readonly collapseKey = computed(() => { + if (this.inputs.orientation() === 'horizontal') { + return 'ArrowUp'; + } + return this.inputs.textDirection() === 'rtl' ? 'ArrowRight' : 'ArrowLeft'; + }); + + /** The key for expanding a node or moving to its first child. */ + readonly expandKey = computed(() => { + if (this.inputs.orientation() === 'horizontal') { + return 'ArrowDown'; + } + return this.inputs.textDirection() === 'rtl' ? 'ArrowLeft' : 'ArrowRight'; + }); + + /** Represents the space key. Does nothing when the user is actively using typeahead. */ + readonly dynamicSpaceKey = computed(() => (this.typeaheadManager.isTyping() ? '' : ' ')); + + /** Regular expression to match characters for typeahead. */ + readonly typeaheadRegexp = /^.$/; + + /** Uncommitted tree item for selecting a range of tree items. */ + readonly anchorItem = signal | undefined>(undefined); + + /** + * Uncommitted tree item index for selecting a range of tree items. + * + * The index is computed in case the tree item position is changed caused by tree expansions. + */ + readonly anchorIndex = computed(() => + this.anchorItem() ? this.visibleItems().indexOf(this.anchorItem()!) : -1, + ); + + /** The keydown event manager for the tree. */ + readonly keydown = computed(() => { + const manager = new KeyboardEventManager(); + + if (!this.followFocus()) { + manager + .on(this.prevKey, () => this.prev()) + .on(this.nextKey, () => this.next()) + .on('Home', () => this.first()) + .on('End', () => this.last()) + .on(this.typeaheadRegexp, e => this.search(e.key)); + } + + if (this.followFocus()) { + manager + .on(this.prevKey, () => this.prev({selectOne: true})) + .on(this.nextKey, () => this.next({selectOne: true})) + .on('Home', () => this.first({selectOne: true})) + .on('End', () => this.last({selectOne: true})) + .on(this.typeaheadRegexp, e => this.search(e.key, {selectOne: true})); + } + + if (this.inputs.multi()) { + manager + .on(Modifier.Any, 'Shift', () => this.anchorItem.set(this.focusManager.activeItem())) + .on(Modifier.Shift, this.prevKey, () => this.prev({selectRange: true})) + .on(Modifier.Shift, this.nextKey, () => this.next({selectRange: true})) + .on([Modifier.Ctrl | Modifier.Shift, Modifier.Meta | Modifier.Shift], 'Home', () => + this.first({selectRange: true, anchor: false}), + ) + .on([Modifier.Ctrl | Modifier.Shift, Modifier.Meta | Modifier.Shift], 'End', () => + this.last({selectRange: true, anchor: false}), + ) + .on(Modifier.Shift, 'Enter', () => + this._updateSelection({selectRange: true, anchor: false}), + ) + .on(Modifier.Shift, this.dynamicSpaceKey, () => + this._updateSelection({selectRange: true, anchor: false}), + ); + } + + if (!this.followFocus() && this.inputs.multi()) { + manager + .on(this.dynamicSpaceKey, () => this.selectionManager.toggle()) + .on('Enter', () => this.selectionManager.toggle()) + .on([Modifier.Ctrl, Modifier.Meta], 'A', () => this.selectionManager.toggleAll()); + } + + if (!this.followFocus() && !this.inputs.multi()) { + manager.on(this.dynamicSpaceKey, () => this.selectionManager.toggleOne()); + manager.on('Enter', () => this.selectionManager.toggleOne()); + } + + if (this.inputs.multi() && this.followFocus()) { + manager + .on([Modifier.Ctrl, Modifier.Meta], this.prevKey, () => this.prev()) + .on([Modifier.Ctrl, Modifier.Meta], this.nextKey, () => this.next()) + .on([Modifier.Ctrl, Modifier.Meta], ' ', () => this.selectionManager.toggle()) + .on([Modifier.Ctrl, Modifier.Meta], 'Enter', () => this.selectionManager.toggle()) + .on([Modifier.Ctrl, Modifier.Meta], 'Home', () => this.first()) + .on([Modifier.Ctrl, Modifier.Meta], 'End', () => this.last()) + .on([Modifier.Ctrl, Modifier.Meta], 'A', () => { + this.selectionManager.toggleAll(); + this.selectionManager.select(); // Ensure the currect item remains selected. + }); + } + + manager + .on(this.expandKey, () => this.expand()) + .on(this.collapseKey, () => this.collapse()) + .on(Modifier.Shift, '*', () => this.expandSiblings()); + + return manager; + }); + + /** The pointerdown event manager for the tree. */ + pointerdown = computed(() => { + const manager = new PointerEventManager(); + + if (this.multi()) { + manager.on(Modifier.Shift, e => this.goto(e, {selectRange: true})); + } + + if (!this.multi() && this.followFocus()) { + return manager.on(e => this.goto(e, {selectOne: true})); + } + + if (!this.multi() && !this.followFocus()) { + return manager.on(e => this.goto(e, {toggle: true})); + } + + if (this.multi() && this.followFocus()) { + return manager + .on(e => this.goto(e, {selectOne: true})) + .on(Modifier.Ctrl, e => this.goto(e, {toggle: true})); + } + + if (this.multi() && !this.followFocus()) { + return manager.on(e => this.goto(e, {toggle: true})); + } + + return manager; + }); + + constructor(readonly inputs: TreeInputs) { + this.allItems = inputs.allItems; + this.focusMode = inputs.focusMode; + this.disabled = inputs.disabled; + this.activeIndex = inputs.activeIndex; + this.skipDisabled = inputs.skipDisabled; + this.wrap = inputs.wrap; + this.orientation = inputs.orientation; + this.textDirection = inputs.textDirection; + this.multi = inputs.multi; + this.value = inputs.value; + this.selectionMode = inputs.selectionMode; + this.typeaheadDelay = inputs.typeaheadDelay; + this.focusManager = new ListFocus({ + ...inputs, + items: this.visibleItems, + }); + this.navigationManager = new ListNavigation({ + ...inputs, + wrap: computed(() => this.inputs.wrap() && !this.inSelection()), + items: this.visibleItems, + focusManager: this.focusManager, + }); + this.selectionManager = new ListSelection({ + ...inputs, + items: this.visibleItems, + focusManager: this.focusManager, + }); + this.typeaheadManager = new ListTypeahead({ + ...inputs, + items: this.visibleItems, + focusManager: this.focusManager, + }); + this.expansionManager = new ListExpansion({ + multiExpandable: () => true, + items: this.children, + disabled: this.disabled, + }); + } + + /** Handles keydown events on the tree. */ + onKeydown(event: KeyboardEvent) { + if (!this.disabled()) { + this.keydown().handle(event); + } + } + + /** Handles pointerdown events on the tree. */ + onPointerdown(event: PointerEvent) { + if (!this.disabled()) { + this.pointerdown().handle(event); + } + } + + /** Navigates to the first visible tree item in the tree. */ + first(opts?: SelectOptions) { + this._navigate(opts, () => this.navigationManager.first()); + } + + /** Navigates to the last visible tree item in the tree. */ + last(opts?: SelectOptions) { + this._navigate(opts, () => this.navigationManager.last()); + } + + /** Navigates to the next visible tree item in the tree. */ + next(opts?: SelectOptions) { + this._navigate(opts, () => this.navigationManager.next()); + } + + /** Navigates to the previous visible tree item in the tree. */ + prev(opts?: SelectOptions) { + this._navigate(opts, () => this.navigationManager.prev()); + } + + /** Navigates to the given tree item in the tree. */ + goto(event: PointerEvent, opts?: SelectOptions) { + const item = this._getItem(event); + this._navigate(opts, () => this.navigationManager.goto(item)); + this.toggleExpansion(item); + } + + /** Handles typeahead search navigation for the tree. */ + search(char: string, opts?: SelectOptions) { + this._navigate(opts, () => this.typeaheadManager.search(char)); + } + + /** Toggles to expand or collapse a tree item. */ + toggleExpansion(item?: TreeItemPattern) { + item ??= this.focusManager.activeItem(); + if (!item || !this.focusManager.isFocusable(item)) return; + + if (!item.expandable()) return; + if (item.expanded()) { + this.collapse(); + } else { + item.expansion.open(); + } + } + + /** Expands a tree item. */ + expand(item?: TreeItemPattern) { + item ??= this.focusManager.activeItem(); + if (!item || !this.focusManager.isFocusable(item)) return; + + if (item.expandable() && !item.expanded()) { + item.expansion.open(); + } else if (item.expanded() && item.children().length > 0) { + const firstChild = item.children()[0]; + if (this.focusManager.isFocusable(firstChild)) { + this.navigationManager.goto(firstChild); + } + } + } + + /** Expands all sibling tree items including itself. */ + expandSiblings(item?: TreeItemPattern) { + item ??= this.focusManager.activeItem(); + const siblings = item.parent()?.children(); + siblings?.forEach(item => this.expand(item)); + } + + /** Collapses a tree item. */ + collapse(item?: TreeItemPattern) { + item ??= this.focusManager.activeItem(); + if (!item || !this.focusManager.isFocusable(item)) return; + + if (item.expandable() && item.expanded()) { + item.expansion.close(); + } else if (item.parent() && item.parent() !== this) { + const parentItem = item.parent(); + if (parentItem instanceof TreeItemPattern && this.focusManager.isFocusable(parentItem)) { + this.navigationManager.goto(parentItem); + } + } + } + + private _navigate(opts: SelectOptions = {}, operation: () => boolean) { + if (opts?.selectRange) { + this.inSelection.set(true); + this.selectionManager.rangeStartIndex.set(this.anchorIndex()); + } + + const moved = operation(); + + if (moved) { + this._updateSelection(opts); + } + + this.inSelection.set(false); + } + + /** Handles updating selection for the tree. */ + private _updateSelection(opts: SelectOptions = {anchor: true}) { + if (opts.toggle) { + this.selectionManager.toggle(); + } + if (opts.selectOne) { + this.selectionManager.selectOne(); + } + if (opts.selectRange) { + this.selectionManager.selectRange(); + } + if (!opts.anchor) { + this.anchorItem.set(this.visibleItems()[this.selectionManager.rangeStartIndex()]); + } + } + + /** Retrieves the TreeItemPattern associated with a DOM event, if any. */ + private _getItem(event: Event): TreeItemPattern | undefined { + if (!(event.target instanceof HTMLElement)) { + return; + } + const element = event.target.closest('[role="treeitem"]'); + return this.inputs.allItems().find(i => i.element() === element); + } +} 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..787aa8b85e70 --- /dev/null +++ b/src/components-examples/cdk-experimental/tree/cdk-tree/cdk-tree-example.html @@ -0,0 +1,154 @@ +
+ 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) { +
  • + + + @if (node.children && node.children.length > 0) { +
    + + @for (node of node.children; track node) { +
    + + + @if (node.children && node.children.length > 0) { +
    + + @for (node of node.children; track node) { +
    + +
    + } +
    +
    + } +
    + } +
    +
    + } +
  • + } + + +
+ + +
+ + {{ label }} +
+
+ + + +
  • + + + @if (node.children && node.children.length > 0) { +
    + @for (child of node.children; track child) { + + } +
    + } +
  • +
    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..0582fbac1ac1 --- /dev/null +++ b/src/components-examples/cdk-experimental/tree/cdk-tree/cdk-tree-example.ts @@ -0,0 +1,101 @@ +import {Component, model} from '@angular/core'; +import {NgTemplateOutlet} from '@angular/common'; +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, CdkGroup, CdkGroupContent} from '@angular/cdk-experimental/tree'; + +interface ExampleNode { + value: string; + label?: string; + disabled?: boolean; + children?: ExampleNode[]; +} + +/** @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, + NgTemplateOutlet, + CdkTree, + CdkTreeItem, + CdkGroup, + CdkGroupContent, + ], +}) +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'}, + ], + }, + ]; + + // CdkTree inputs + 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}); + + // Model for selected values + selectedValues = model(['headphones']); // Pre-select 'headphones' +} 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), From f29b0983ce59dce39fea4a07cb119e314a9843c2 Mon Sep 17 00:00:00 2001 From: Cheng-Hsuan Tsai Date: Wed, 4 Jun 2025 21:13:33 +0000 Subject: [PATCH 2/2] refactor: register/deregister children instead of using contentChildren --- src/cdk-experimental/tree/public-api.ts | 2 +- src/cdk-experimental/tree/tree.ts | 259 ++++++++++-------- .../ui-patterns/accordion/accordion.ts | 2 +- .../behaviors/expansion/expansion.ts | 18 +- src/cdk-experimental/ui-patterns/tabs/tabs.ts | 2 +- src/cdk-experimental/ui-patterns/tree/tree.ts | 24 +- .../tree/cdk-tree/cdk-tree-example.html | 117 ++------ .../tree/cdk-tree/cdk-tree-example.ts | 28 +- 8 files changed, 219 insertions(+), 233 deletions(-) diff --git a/src/cdk-experimental/tree/public-api.ts b/src/cdk-experimental/tree/public-api.ts index d046a4636837..7bc14acd4628 100644 --- a/src/cdk-experimental/tree/public-api.ts +++ b/src/cdk-experimental/tree/public-api.ts @@ -6,4 +6,4 @@ * found in the LICENSE file at https://angular.dev/license */ -export {CdkGroup, CdkGroupContent, CdkTree, CdkTreeItem} from './tree'; +export {CdkTreeGroup, CdkTreeGroupContent, CdkTree, CdkTreeItem} from './tree'; diff --git a/src/cdk-experimental/tree/tree.ts b/src/cdk-experimental/tree/tree.ts index 9d48ea22e2a5..4dc657cb1f18 100644 --- a/src/cdk-experimental/tree/tree.ts +++ b/src/cdk-experimental/tree/tree.ts @@ -12,100 +12,32 @@ import { afterRenderEffect, booleanAttribute, computed, - contentChildren, - forwardRef, 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'; -/** - * Base class to make a Cdk item groupable. - * - * Also need to add the following to the `@Directive` configuration: - * ``` - * providers: [ - * { provide: BaseGroupable, useExisting: forwardRef(() => CdkSomeItem) }, - * ], - * ``` - * - * TODO(ok7sai): Move it to a shared place. - */ -export class BaseGroupable { - /** The parent CdkGroup, if any. */ - groupParent = inject(CdkGroup, {optional: true}); +interface HasElement { + element: Signal; } /** - * Generic container that designates content as a group. - * - * TODO(ok7sai): Move it to a shared place. + * Sort directives by their document order. */ -@Directive({ - selector: '[cdkGroup]', - exportAs: 'cdkGroup', - hostDirectives: [ - { - directive: DeferredContentAware, - inputs: ['preserveContent'], - }, - ], - host: { - 'class': 'cdk-group', - 'role': 'group', - '[id]': 'id', - '[attr.inert]': 'visible() ? null : true', - }, -}) -export class CdkGroup { - /** The DeferredContentAware host directive. */ - private readonly _deferredContentAware = inject(DeferredContentAware); - - /** All groupable items that are descendants of the group. */ - private readonly _items = contentChildren(BaseGroupable, {descendants: true}); - - /** Identifier for matching the group owner. */ - readonly value = input.required(); - - /** Whether the group is visible. */ - readonly visible = signal(true); - - /** Unique ID for the group. */ - readonly id = inject(_IdGenerator).getId('cdk-group-'); - - /** Child items within this group. */ - readonly children = signal([]); - - constructor() { - afterRenderEffect(() => { - this.children.set(this._items().filter(item => item.groupParent === this)); - }); - - // Connect the group's hidden state to the DeferredContentAware's visibility. - afterRenderEffect(() => { - this._deferredContentAware.contentVisible.set(this.visible()); - }); - } +function sortDirectives(a: HasElement, b: HasElement) { + return (a.element().compareDocumentPosition(b.element()) & Node.DOCUMENT_POSITION_PRECEDING) > 0 + ? 1 + : -1; } -/** - * A structural directive that marks the `ng-template` to be used as the content - * for a `CdkGroup`. This content can be lazily loaded. - * - * TODO(ok7sai): Move it to a shared place. - */ -@Directive({ - selector: 'ng-template[cdkGroupContent]', - hostDirectives: [DeferredContent], -}) -export class CdkGroupContent {} - /** * Makes an element a tree and manages state (focus, selection, keyboard navigation). */ @@ -126,15 +58,10 @@ export class CdkGroupContent {} }) export class CdkTree { /** All CdkTreeItem instances within this tree. */ - private readonly _cdkTreeItems = contentChildren>(CdkTreeItem, { - descendants: true, - }); - - /** All TreeItemPattern instances within this tree. */ - private readonly _itemPatterns = computed(() => this._cdkTreeItems().map(item => item.pattern)); + private readonly _unorderedItems = signal(new Set>()); /** All CdkGroup instances within this tree. */ - private readonly _cdkGroups = contentChildren(CdkGroup, {descendants: true}); + readonly unorderedGroups = signal(new Set>()); /** Orientation of the tree. */ readonly orientation = input<'vertical' | 'horizontal'>('vertical'); @@ -169,20 +96,34 @@ export class CdkTree { /** The UI pattern for the tree. */ pattern: TreePattern = new TreePattern({ ...this, - allItems: this._itemPatterns, + allItems: computed(() => + [...this._unorderedItems()].sort(sortDirectives).map(item => item.pattern), + ), activeIndex: signal(0), }); - constructor() { - // Binds groups to tree items. - afterRenderEffect(() => { - const groups = this._cdkGroups(); - const treeItems = this._cdkTreeItems(); - for (const group of groups) { - const treeItem = treeItems.find(item => item.value() === group.value()); - treeItem?.group.set(group); - } - }); + register(child: CdkTreeGroup | CdkTreeItem) { + if (child instanceof CdkTreeGroup) { + 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: CdkTreeGroup | CdkTreeItem) { + if (child instanceof CdkTreeGroup) { + 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())); + } } } @@ -204,30 +145,33 @@ export class CdkTree { '[attr.aria-posinset]': 'pattern.posinset()', '[attr.tabindex]': 'pattern.tabindex()', }, - providers: [{provide: BaseGroupable, useExisting: forwardRef(() => CdkTreeItem)}], }) -export class CdkTreeItem extends BaseGroupable { +export class CdkTreeItem implements OnInit, OnDestroy, HasElement { /** A reference to the tree item element. */ private readonly _elementRef = inject(ElementRef); - /** The host native element. */ - private readonly _element = computed(() => this._elementRef.nativeElement); - /** A unique identifier for the tree item. */ private readonly _id = inject(_IdGenerator).getId('cdk-tree-item-'); /** The top level CdkTree. */ - private readonly _cdkTree = inject(CdkTree, {optional: true}); + private readonly _tree = inject(CdkTree); /** The parent CdkTreeItem. */ - private readonly _cdkTreeItem = inject(CdkTreeItem, {optional: true, skipSelf: true}); + private readonly _treeItem = inject(CdkTreeItem, {optional: true, skipSelf: true}); + + /** The parent CdkGroup, if any. */ + private readonly _parentGroup = inject(CdkTreeGroup, {optional: true}); /** The top lavel TreePattern. */ - private readonly _treePattern = computed(() => this._cdkTree?.pattern); + private readonly _treePattern = computed(() => this._tree.pattern); /** The parent TreeItemPattern. */ - private readonly _parentPattern: Signal | TreePattern | undefined> = - computed(() => this._cdkTreeItem?.pattern ?? this._treePattern()); + 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(); @@ -239,16 +183,15 @@ export class CdkTreeItem extends BaseGroupable { readonly label = input(); /** Search term for typeahead. */ - readonly searchTerm = computed(() => this.label() ?? this._element().textContent); + readonly searchTerm = computed(() => this.label() ?? this.element().textContent); /** Manual group assignment. */ - readonly group = signal | undefined>(undefined); + readonly group = signal | undefined>(undefined); /** The UI pattern for this item. */ pattern: TreeItemPattern = new TreeItemPattern({ ...this, id: () => this._id, - element: this._element, tree: this._treePattern, parent: this._parentPattern, children: computed( @@ -261,11 +204,109 @@ export class CdkTreeItem extends BaseGroupable { }); constructor() { - super(); + 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: '[cdkTreeGroup]', + exportAs: 'cdkTreeGroup', + hostDirectives: [ + { + directive: DeferredContentAware, + inputs: ['preserveContent'], + }, + ], + host: { + 'class': 'cdk-tree-group', + 'role': 'group', + '[id]': 'id', + '[attr.inert]': 'visible() ? null : true', + }, +}) +export class CdkTreeGroup 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 `CdkTreeGroup`. This content can be lazily loaded. + */ +@Directive({ + selector: 'ng-template[cdkTreeGroupContent]', + hostDirectives: [DeferredContent], +}) +export class CdkTreeGroupContent {} diff --git a/src/cdk-experimental/ui-patterns/accordion/accordion.ts b/src/cdk-experimental/ui-patterns/accordion/accordion.ts index 96c52f69423e..2d3a8ebe711c 100644 --- a/src/cdk-experimental/ui-patterns/accordion/accordion.ts +++ b/src/cdk-experimental/ui-patterns/accordion/accordion.ts @@ -121,7 +121,7 @@ export class AccordionTriggerPattern { ...inputs, expansionId: inputs.value, expandable: () => true, - expansionManager: () => inputs.accordionGroup().expansionManager, + expansionManager: inputs.accordionGroup().expansionManager, }); this.expandable = this.expansionControl.isExpandable; this.expansionId = this.expansionControl.expansionId; diff --git a/src/cdk-experimental/ui-patterns/behaviors/expansion/expansion.ts b/src/cdk-experimental/ui-patterns/behaviors/expansion/expansion.ts index 5d569a172661..2f8e7b7123ce 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/expansion/expansion.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/expansion/expansion.ts @@ -27,18 +27,12 @@ export interface ExpansionControl extends ExpansionItem {} */ export class ExpansionControl { /** Whether this specific item is currently expanded. Derived from the Expansion manager. */ - readonly isExpanded: SignalLike = computed( - () => this.inputs.expansionManager()?.isExpanded(this) ?? false, - ); + readonly isExpanded = computed(() => this.inputs.expansionManager.isExpanded(this)); /** Whether this item can be expanded. */ - readonly isExpandable: SignalLike = computed( - () => this.inputs.expansionManager()?.isExpandable(this) ?? false, - ); + readonly isExpandable = computed(() => this.inputs.expansionManager.isExpandable(this)); - constructor( - readonly inputs: ExpansionItem & {expansionManager: SignalLike}, - ) { + constructor(readonly inputs: ExpansionItem & {expansionManager: ListExpansion}) { this.expansionId = inputs.expansionId; this.expandable = inputs.expandable; this.disabled = inputs.disabled; @@ -46,17 +40,17 @@ export class ExpansionControl { /** Requests the Expansopn manager to open this item. */ open() { - this.inputs.expansionManager()?.open(this); + this.inputs.expansionManager.open(this); } /** Requests the Expansion manager to close this item. */ close() { - this.inputs.expansionManager()?.close(this); + this.inputs.expansionManager.close(this); } /** Requests the Expansion manager to toggle this item. */ toggle() { - this.inputs.expansionManager()?.toggle(this); + this.inputs.expansionManager.toggle(this); } } diff --git a/src/cdk-experimental/ui-patterns/tabs/tabs.ts b/src/cdk-experimental/ui-patterns/tabs/tabs.ts index 6fadf55cfc12..9342ba6aa01e 100644 --- a/src/cdk-experimental/ui-patterns/tabs/tabs.ts +++ b/src/cdk-experimental/ui-patterns/tabs/tabs.ts @@ -87,7 +87,7 @@ export class TabPattern { ...inputs, expansionId: inputs.value, expandable: () => true, - expansionManager: () => inputs.tablist().expansionManager, + expansionManager: inputs.tablist().expansionManager, }); this.expansionId = expansionControl.expansionId; this.expandable = expansionControl.isExpandable; diff --git a/src/cdk-experimental/ui-patterns/tree/tree.ts b/src/cdk-experimental/ui-patterns/tree/tree.ts index 54918646b530..d065cc00a127 100644 --- a/src/cdk-experimental/ui-patterns/tree/tree.ts +++ b/src/cdk-experimental/ui-patterns/tree/tree.ts @@ -36,7 +36,7 @@ export interface TreeItemInputs ListSelectionItem, ListTypeaheadItem { /** The parent node. */ - parent: SignalLike | TreePattern | undefined>; + parent: SignalLike | TreePattern>; /** Whether this node has children. Children can be lazily loaded. */ hasChilren: SignalLike; @@ -45,7 +45,7 @@ export interface TreeItemInputs children: SignalLike[]>; /** The tree pattern this item belongs to. */ - tree: SignalLike | undefined>; + tree: SignalLike>; } export interface TreeItemPattern extends TreeItemInputs {} @@ -66,9 +66,7 @@ export class TreeItemPattern implements ExpansionItem { readonly expansion: ExpansionControl; /** The level of the current node in a tree. */ - readonly level: SignalLike = computed(() => - this.parent() ? this.parent()!.level()! + 1 : undefined, - ); + readonly level: SignalLike = computed(() => this.parent().level() + 1); /** Whether the item is expandable. It's expandable if children nodes exist. */ readonly expandable: SignalLike; @@ -77,24 +75,22 @@ export class TreeItemPattern implements ExpansionItem { readonly expanded = computed(() => this.expansion.isExpanded()); /** Whether this item is visible. */ - readonly visible = computed(() => this.parent()?.expanded() ?? false); + readonly visible = computed(() => this.parent().expanded()); /** The number of items under the same parent at the same level. */ - readonly setsize = computed(() => this.parent()?.children().length); + readonly setsize = computed(() => this.parent().children().length); /** The position of this item among its siblings (1-based). */ - readonly posinset = computed(() => - this.parent() ? this.parent()!.children().indexOf(this) + 1 : undefined, - ); + readonly posinset = computed(() => this.parent().children().indexOf(this) + 1); /** Whether the item is active. */ - readonly active = computed(() => this.tree()?.focusManager.activeItem() === this); + readonly active = computed(() => this.tree().focusManager.activeItem() === this); /** The tabindex of the item. */ - readonly tabindex = computed(() => (this.tree()?.focusManager.isFocusable(this) ? 0 : -1)); + 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(() => this.tree().value().includes(this.value())); constructor(readonly inputs: TreeItemInputs) { this.id = inputs.id; @@ -111,7 +107,7 @@ export class TreeItemPattern implements ExpansionItem { ...inputs, expandable: this.expandable, expansionId: this.expansionId, - expansionManager: computed(() => this.parent()?.expansionManager), + expansionManager: this.parent().expansionManager, }); this.expansionManager = new ListExpansion({ ...inputs, 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 787aa8b85e70..2ea83c9329ef 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 @@ -35,6 +35,7 @@
      @for (node of treeData; track node) { -
    • - - - @if (node.children && node.children.length > 0) { -
      - - @for (node of node.children; track node) { -
      - - - @if (node.children && node.children.length > 0) { -
      - - @for (node of node.children; track node) { -
      - -
      - } -
      -
      - } -
      - } -
      -
      - } -
    • + } - -
    - -
    - - {{ label }} -
    -
    - -
  • - + + + {{ node.label }} + @if (node.children && node.children.length > 0) { -
    - @for (child of node.children; track child) { - - } +
    + + @for (child of node.children; track child) { + + } +
    }
  • 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 0582fbac1ac1..4f16c639d2c4 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 @@ -1,11 +1,25 @@ -import {Component, model} from '@angular/core'; +import {Component, Directive, model, inject, Injector} from '@angular/core'; import {NgTemplateOutlet} from '@angular/common'; 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, CdkGroup, CdkGroupContent} from '@angular/cdk-experimental/tree'; +import { + CdkTree, + CdkTreeItem, + CdkTreeGroup, + CdkTreeGroupContent, +} from '@angular/cdk-experimental/tree'; + +/** Helper directive to obtain a parent injector for NgTemplateOutlet. */ +@Directive({ + selector: '[hierarchicalInjector]', + exportAs: 'hierarchicalInjector', +}) +export class HierarchicalInjector { + readonly injector = inject(Injector); +} interface ExampleNode { value: string; @@ -30,8 +44,9 @@ interface ExampleNode { NgTemplateOutlet, CdkTree, CdkTreeItem, - CdkGroup, - CdkGroupContent, + CdkTreeGroup, + CdkTreeGroupContent, + HierarchicalInjector, ], }) export class CdkTreeExample { @@ -86,7 +101,7 @@ export class CdkTreeExample { }, ]; - // CdkTree inputs + // TODO(ok7sai): add styling to horizontal tree view. orientation: 'vertical' | 'horizontal' = 'vertical'; selectionMode: 'explicit' | 'follow' = 'explicit'; focusMode: 'roving' | 'activedescendant' = 'roving'; @@ -96,6 +111,5 @@ export class CdkTreeExample { wrap = new FormControl(true, {nonNullable: true}); skipDisabled = new FormControl(true, {nonNullable: true}); - // Model for selected values - selectedValues = model(['headphones']); // Pre-select 'headphones' + selectedValues = model(['headphones']); }