Skip to content

Commit 7fe09fd

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

File tree

22 files changed

+1281
-39
lines changed

22 files changed

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

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)