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..7bc14acd4628 --- /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 {CdkTreeGroup, CdkTreeGroupContent, 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..4dc657cb1f18 --- /dev/null +++ b/src/cdk-experimental/tree/tree.ts @@ -0,0 +1,312 @@ +/** + * @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; +} + +/** + * 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 _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. */ + pattern: TreePattern = new TreePattern({ + ...this, + allItems: computed(() => + [...this._unorderedItems()].sort(sortDirectives).map(item => item.pattern), + ), + activeIndex: signal(0), + }); + + 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())); + } + } +} + +/** 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()', + }, +}) +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(CdkTreeGroup, {optional: true}); + + /** The top lavel 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. */ + 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) ?? [], + ), + hasChilren: 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: '[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/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..2d3a8ebe711c 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, }); } } diff --git a/src/cdk-experimental/ui-patterns/behaviors/expansion/expansion.ts b/src/cdk-experimental/ui-patterns/behaviors/expansion/expansion.ts index c2363c8e4447..2f8e7b7123ce 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 {} @@ -30,10 +32,9 @@ export class ExpansionControl { /** Whether this item can be expanded. */ readonly isExpandable = computed(() => this.inputs.expansionManager.isExpandable(this)); - constructor(readonly inputs: ExpansionItem & {expansionManager: ListExpansion}) { + constructor(readonly inputs: ExpansionItem & {expansionManager: ListExpansion}) { this.expansionId = inputs.expansionId; this.expandable = inputs.expandable; - this.element = inputs.element; this.disabled = inputs.disabled; } @@ -54,28 +55,31 @@ export class ExpansionControl { } /** 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 +88,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 +117,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..9342ba6aa01e 100644 --- a/src/cdk-experimental/ui-patterns/tabs/tabs.ts +++ b/src/cdk-experimental/ui-patterns/tabs/tabs.ts @@ -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..d065cc00a127 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/tree/tree.ts @@ -0,0 +1,515 @@ +/** + * @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>; + + /** 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>; +} + +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().level() + 1); + + /** 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()); + + /** 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().children().indexOf(this) + 1); + + /** Whether the item is active. */ + readonly active = computed(() => this.tree().focusManager.activeItem() === this); + + /** The tabindex of the item. */ + readonly tabindex = computed(() => this.tree().focusManager.getItemTabindex(this)); + + /** 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: 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..2ea83c9329ef --- /dev/null +++ b/src/components-examples/cdk-experimental/tree/cdk-tree/cdk-tree-example.html @@ -0,0 +1,95 @@ +
+ 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) { + + } +
+ + +
  • + + + {{ 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..4f16c639d2c4 --- /dev/null +++ b/src/components-examples/cdk-experimental/tree/cdk-tree/cdk-tree-example.ts @@ -0,0 +1,115 @@ +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, + 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; + 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, + CdkTreeGroup, + CdkTreeGroupContent, + HierarchicalInjector, + ], +}) +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(['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),