Skip to content

Commit 01b8769

Browse files
committed
feat(cdk-experimental/ui-patterns): Tree - preview
1 parent 680dd6f commit 01b8769

File tree

22 files changed

+1279
-39
lines changed

22 files changed

+1279
-39
lines changed

src/cdk-experimental/tree/BUILD.bazel

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
load("//tools:defaults.bzl", "ng_project")
2+
3+
package(default_visibility = ["//visibility:public"])
4+
5+
ng_project(
6+
name = "tree",
7+
srcs = [
8+
"index.ts",
9+
"public-api.ts",
10+
"tree.ts",
11+
],
12+
deps = [
13+
"//src/cdk-experimental/deferred-content",
14+
"//src/cdk-experimental/ui-patterns",
15+
"//src/cdk/a11y",
16+
"//src/cdk/bidi",
17+
],
18+
)

src/cdk-experimental/tree/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
export * from './public-api';
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
export {CdkGroup, CdkGroupContent, CdkTree, CdkTreeItem} from './tree';

src/cdk-experimental/tree/tree.ts

Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import {
10+
Directive,
11+
ElementRef,
12+
afterRenderEffect,
13+
booleanAttribute,
14+
computed,
15+
contentChildren,
16+
forwardRef,
17+
inject,
18+
input,
19+
model,
20+
signal,
21+
Signal,
22+
} from '@angular/core';
23+
import {_IdGenerator} from '@angular/cdk/a11y';
24+
import {Directionality} from '@angular/cdk/bidi';
25+
import {DeferredContent, DeferredContentAware} from '@angular/cdk-experimental/deferred-content';
26+
import {TreeItemPattern, TreePattern} from '../ui-patterns/tree/tree';
27+
28+
/**
29+
* Base class to make a Cdk item groupable.
30+
*
31+
* Also need to add the following to the `@Directive` configuration:
32+
* ```
33+
* providers: [
34+
* { provide: BaseGroupable, useExisting: forwardRef(() => CdkSomeItem) },
35+
* ],
36+
* ```
37+
*
38+
* TODO(ok7sai): Move it to a shared place.
39+
*/
40+
export class BaseGroupable {
41+
/** The parent CdkGroup, if any. */
42+
groupParent = inject(CdkGroup, {optional: true});
43+
}
44+
45+
/**
46+
* Generic container that designates content as a group.
47+
*
48+
* TODO(ok7sai): Move it to a shared place.
49+
*/
50+
@Directive({
51+
selector: '[cdkGroup]',
52+
exportAs: 'cdkGroup',
53+
hostDirectives: [
54+
{
55+
directive: DeferredContentAware,
56+
inputs: ['preserveContent'],
57+
},
58+
],
59+
host: {
60+
'class': 'cdk-group',
61+
'role': 'group',
62+
'[id]': 'id',
63+
'[attr.inert]': 'visible() ? null : true',
64+
},
65+
})
66+
export class CdkGroup<V> {
67+
/** The DeferredContentAware host directive. */
68+
private readonly _deferredContentAware = inject(DeferredContentAware);
69+
70+
/** All groupable items that are descendants of the group. */
71+
private readonly _items = contentChildren(BaseGroupable, {descendants: true});
72+
73+
/** Identifier for matching the group owner. */
74+
readonly value = input.required<V>();
75+
76+
/** Whether the group is visible. */
77+
readonly visible = signal(true);
78+
79+
/** Unique ID for the group. */
80+
readonly id = inject(_IdGenerator).getId('cdk-group-');
81+
82+
/** Child items within this group. */
83+
readonly children = signal<BaseGroupable[]>([]);
84+
85+
constructor() {
86+
afterRenderEffect(() => {
87+
this.children.set(this._items().filter(item => item.groupParent === this));
88+
});
89+
90+
// Connect the group's hidden state to the DeferredContentAware's visibility.
91+
afterRenderEffect(() => {
92+
this._deferredContentAware.contentVisible.set(this.visible());
93+
});
94+
}
95+
}
96+
97+
/**
98+
* A structural directive that marks the `ng-template` to be used as the content
99+
* for a `CdkGroup`. This content can be lazily loaded.
100+
*
101+
* TODO(ok7sai): Move it to a shared place.
102+
*/
103+
@Directive({
104+
selector: 'ng-template[cdkGroupContent]',
105+
hostDirectives: [DeferredContent],
106+
})
107+
export class CdkGroupContent {}
108+
109+
/**
110+
* Makes an element a tree and manages state (focus, selection, keyboard navigation).
111+
*/
112+
@Directive({
113+
selector: '[cdkTree]',
114+
exportAs: 'cdkTree',
115+
host: {
116+
'class': 'cdk-tree',
117+
'role': 'tree',
118+
'[attr.aria-orientation]': 'pattern.orientation()',
119+
'[attr.aria-multiselectable]': 'pattern.multi()',
120+
'[attr.aria-disabled]': 'pattern.disabled()',
121+
'[attr.aria-activedescendant]': 'pattern.activedescendant()',
122+
'[tabindex]': 'pattern.tabindex()',
123+
'(keydown)': 'pattern.onKeydown($event)',
124+
'(pointerdown)': 'pattern.onPointerdown($event)',
125+
},
126+
})
127+
export class CdkTree<V> {
128+
/** All CdkTreeItem instances within this tree. */
129+
private readonly _cdkTreeItems = contentChildren<CdkTreeItem<V>>(CdkTreeItem, {
130+
descendants: true,
131+
});
132+
133+
/** All TreeItemPattern instances within this tree. */
134+
private readonly _itemPatterns = computed(() => this._cdkTreeItems().map(item => item.pattern));
135+
136+
/** All CdkGroup instances within this tree. */
137+
private readonly _cdkGroups = contentChildren(CdkGroup, {descendants: true});
138+
139+
/** Orientation of the tree. */
140+
readonly orientation = input<'vertical' | 'horizontal'>('vertical');
141+
142+
/** Whether multi-selection is allowed. */
143+
readonly multi = input(false, {transform: booleanAttribute});
144+
145+
/** Whether the tree is disabled. */
146+
readonly disabled = input(false, {transform: booleanAttribute});
147+
148+
/** The selection strategy used by the tree. */
149+
readonly selectionMode = input<'explicit' | 'follow'>('explicit');
150+
151+
/** The focus strategy used by the tree. */
152+
readonly focusMode = input<'roving' | 'activedescendant'>('roving');
153+
154+
/** Whether navigation wraps. */
155+
readonly wrap = input(true, {transform: booleanAttribute});
156+
157+
/** Whether to skip disabled items during navigation. */
158+
readonly skipDisabled = input(true, {transform: booleanAttribute});
159+
160+
/** Typeahead delay. */
161+
readonly typeaheadDelay = input(0.5);
162+
163+
/** Selected item values. */
164+
readonly value = model<V[]>([]);
165+
166+
/** Text direction. */
167+
readonly textDirection = inject(Directionality).valueSignal;
168+
169+
/** The UI pattern for the tree. */
170+
pattern: TreePattern<V> = new TreePattern<V>({
171+
...this,
172+
allItems: this._itemPatterns,
173+
activeIndex: signal(0),
174+
});
175+
176+
constructor() {
177+
// Binds groups to tree items.
178+
afterRenderEffect(() => {
179+
const groups = this._cdkGroups();
180+
const treeItems = this._cdkTreeItems();
181+
for (const group of groups) {
182+
const treeItem = treeItems.find(item => item.value() === group.value());
183+
treeItem?.group.set(group);
184+
}
185+
});
186+
}
187+
}
188+
189+
/** Makes an element a tree item within a `CdkTree`. */
190+
@Directive({
191+
selector: '[cdkTreeItem]',
192+
exportAs: 'cdkTreeItem',
193+
host: {
194+
'class': 'cdk-treeitem',
195+
'[class.cdk-active]': 'pattern.active()',
196+
'role': 'treeitem',
197+
'[id]': 'pattern.id()',
198+
'[attr.aria-expanded]': 'pattern.expandable() ? pattern.expanded() : null',
199+
'[attr.aria-selected]': 'pattern.selected()',
200+
'[attr.aria-disabled]': 'pattern.disabled()',
201+
'[attr.aria-level]': 'pattern.level()',
202+
'[attr.aria-owns]': 'group()?.id',
203+
'[attr.aria-setsize]': 'pattern.setsize()',
204+
'[attr.aria-posinset]': 'pattern.posinset()',
205+
'[attr.tabindex]': 'pattern.tabindex()',
206+
},
207+
providers: [{provide: BaseGroupable, useExisting: forwardRef(() => CdkTreeItem)}],
208+
})
209+
export class CdkTreeItem<V> extends BaseGroupable {
210+
/** A reference to the tree item element. */
211+
private readonly _elementRef = inject(ElementRef);
212+
213+
/** The host native element. */
214+
private readonly _element = computed(() => this._elementRef.nativeElement);
215+
216+
/** A unique identifier for the tree item. */
217+
private readonly _id = inject(_IdGenerator).getId('cdk-tree-item-');
218+
219+
/** The top level CdkTree. */
220+
private readonly _cdkTree = inject(CdkTree<V>, {optional: true});
221+
222+
/** The parent CdkTreeItem. */
223+
private readonly _cdkTreeItem = inject(CdkTreeItem<V>, {optional: true, skipSelf: true});
224+
225+
/** The top lavel TreePattern. */
226+
private readonly _treePattern = computed(() => this._cdkTree?.pattern);
227+
228+
/** The parent TreeItemPattern. */
229+
private readonly _parentPattern: Signal<TreeItemPattern<V> | TreePattern<V> | undefined> =
230+
computed(() => this._cdkTreeItem?.pattern ?? this._treePattern());
231+
232+
/** The value of the tree item. */
233+
readonly value = input.required<V>();
234+
235+
/** Whether the tree item is disabled. */
236+
readonly disabled = input(false, {transform: booleanAttribute});
237+
238+
/** Optional label for typeahead. Defaults to the element's textContent. */
239+
readonly label = input<string>();
240+
241+
/** Search term for typeahead. */
242+
readonly searchTerm = computed(() => this.label() ?? this._element().textContent);
243+
244+
/** Manual group assignment. */
245+
readonly group = signal<CdkGroup<V> | undefined>(undefined);
246+
247+
/** The UI pattern for this item. */
248+
pattern: TreeItemPattern<V> = new TreeItemPattern<V>({
249+
...this,
250+
id: () => this._id,
251+
element: this._element,
252+
tree: this._treePattern,
253+
parent: this._parentPattern,
254+
children: computed(
255+
() =>
256+
this.group()
257+
?.children()
258+
.map(item => (item as CdkTreeItem<V>).pattern) ?? [],
259+
),
260+
hasChilren: computed(() => !!this.group()),
261+
});
262+
263+
constructor() {
264+
super();
265+
266+
// Updates the visibility of the owned group.
267+
afterRenderEffect(() => {
268+
this.group()?.visible.set(this.pattern.expanded());
269+
});
270+
}
271+
}

src/cdk-experimental/ui-patterns/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,6 @@ ts_project(
1515
"//src/cdk-experimental/ui-patterns/listbox",
1616
"//src/cdk-experimental/ui-patterns/radio",
1717
"//src/cdk-experimental/ui-patterns/tabs",
18+
"//src/cdk-experimental/ui-patterns/tree",
1819
],
1920
)

src/cdk-experimental/ui-patterns/accordion/accordion.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import {SignalLike} from '../behaviors/signal-like/signal-like';
2727
export type AccordionGroupInputs = Omit<
2828
ListNavigationInputs<AccordionTriggerPattern> &
2929
ListFocusInputs<AccordionTriggerPattern> &
30-
ListExpansionInputs<AccordionTriggerPattern>,
30+
Omit<ListExpansionInputs, 'items'>,
3131
'focusMode'
3232
>;
3333

@@ -43,7 +43,7 @@ export class AccordionGroupPattern {
4343
focusManager: ListFocus<AccordionTriggerPattern>;
4444

4545
/** Controls expansion for the group. */
46-
expansionManager: ListExpansion<AccordionTriggerPattern>;
46+
expansionManager: ListExpansion;
4747

4848
constructor(readonly inputs: AccordionGroupInputs) {
4949
this.wrap = inputs.wrap;
@@ -66,8 +66,6 @@ export class AccordionGroupPattern {
6666
});
6767
this.expansionManager = new ListExpansion({
6868
...inputs,
69-
focusMode,
70-
focusManager: this.focusManager,
7169
});
7270
}
7371
}
@@ -123,7 +121,7 @@ export class AccordionTriggerPattern {
123121
...inputs,
124122
expansionId: inputs.value,
125123
expandable: () => true,
126-
expansionManager: inputs.accordionGroup().expansionManager,
124+
expansionManager: () => inputs.accordionGroup().expansionManager,
127125
});
128126
this.expandable = this.expansionControl.isExpandable;
129127
this.expansionId = this.expansionControl.expansionId;

0 commit comments

Comments
 (0)