Skip to content

Commit 94fbea5

Browse files
committed
feat(cdk-experimental/ui-patterns): tree
1 parent 3aece5d commit 94fbea5

File tree

8 files changed

+1770
-91
lines changed

8 files changed

+1770
-91
lines changed

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: 2 additions & 4 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
}

src/cdk-experimental/ui-patterns/behaviors/expansion/expansion.spec.ts

Lines changed: 31 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -6,35 +6,28 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import {Signal, WritableSignal, signal} from '@angular/core';
9+
import {WritableSignal, signal} from '@angular/core';
1010
import {ListExpansion, ListExpansionInputs, ExpansionItem} from './expansion';
11-
import {ListFocus, ListFocusInputs, ListFocusItem} from '../list-focus/list-focus';
12-
import {getListFocus as getListFocusManager} from '../list-focus/list-focus.spec';
13-
14-
type TestItem = ListFocusItem &
15-
ExpansionItem & {
16-
id: WritableSignal<string>;
17-
disabled: WritableSignal<boolean>;
18-
element: WritableSignal<HTMLElement>;
19-
expandable: WritableSignal<boolean>;
20-
expansionId: WritableSignal<string>;
21-
};
22-
23-
type TestInputs = Partial<Omit<ListExpansionInputs<TestItem>, 'items' | 'focusManager'>> &
24-
Partial<
25-
Pick<ListFocusInputs<TestItem>, 'focusMode' | 'disabled' | 'activeIndex' | 'skipDisabled'>
26-
> & {
27-
numItems?: number;
28-
initialExpandedIds?: string[];
29-
};
11+
12+
type TestItem = ExpansionItem & {
13+
id: WritableSignal<string>;
14+
disabled: WritableSignal<boolean>;
15+
expandable: WritableSignal<boolean>;
16+
expansionId: WritableSignal<string>;
17+
};
18+
19+
type TestInputs = Partial<Omit<ListExpansionInputs, 'items'>> & {
20+
numItems?: number;
21+
initialExpandedIds?: string[];
22+
expansionDisabled?: boolean;
23+
};
3024

3125
function createItems(length: number): WritableSignal<TestItem[]> {
3226
return signal(
3327
Array.from({length}).map((_, i) => {
3428
const itemId = `item-${i}`;
3529
return {
3630
id: signal(itemId),
37-
element: signal(document.createElement('div') as HTMLElement),
3831
disabled: signal(false),
3932
expandable: signal(true),
4033
expansionId: signal(itemId),
@@ -44,39 +37,24 @@ function createItems(length: number): WritableSignal<TestItem[]> {
4437
}
4538

4639
function getExpansion(inputs: TestInputs = {}): {
47-
expansion: ListExpansion<TestItem>;
40+
expansion: ListExpansion;
4841
items: TestItem[];
49-
focusManager: ListFocus<TestItem>;
5042
} {
5143
const numItems = inputs.numItems ?? 3;
5244
const items = createItems(numItems);
5345

54-
const listFocusManagerInputs: Partial<ListFocusInputs<TestItem>> & {items: Signal<TestItem[]>} = {
55-
items: items,
56-
activeIndex: inputs.activeIndex ?? signal(0),
57-
disabled: inputs.disabled ?? signal(false),
58-
skipDisabled: inputs.skipDisabled ?? signal(true),
59-
focusMode: inputs.focusMode ?? signal('roving'),
60-
};
61-
62-
const focusManager = getListFocusManager(listFocusManagerInputs as any) as ListFocus<TestItem>;
63-
64-
const expansion = new ListExpansion<TestItem>({
46+
const expansion = new ListExpansion({
6547
items: items,
66-
activeIndex: focusManager.inputs.activeIndex,
67-
disabled: focusManager.inputs.disabled,
68-
skipDisabled: focusManager.inputs.skipDisabled,
69-
focusMode: focusManager.inputs.focusMode,
70-
multiExpandable: inputs.multiExpandable ?? signal(false),
71-
expandedIds: signal([]),
72-
focusManager,
48+
disabled: signal(inputs.expansionDisabled ?? false),
49+
multiExpandable: signal(inputs.multiExpandable?.() ?? false),
50+
expandedIds: signal<string[]>([]),
7351
});
7452

7553
if (inputs.initialExpandedIds) {
7654
expansion.expandedIds.set(inputs.initialExpandedIds);
7755
}
7856

79-
return {expansion, items: items(), focusManager};
57+
return {expansion, items: items()};
8058
}
8159

8260
describe('Expansion', () => {
@@ -112,8 +90,8 @@ describe('Expansion', () => {
11290
expect(expansion.expandedIds()).toEqual([]);
11391
});
11492

115-
it('should not open an item if it is not focusable (disabled and skipDisabled is true)', () => {
116-
const {expansion, items} = getExpansion({skipDisabled: signal(true)});
93+
it('should not open an item if it is disabled', () => {
94+
const {expansion, items} = getExpansion();
11795
items[1].disabled.set(true);
11896
expansion.open(items[1]);
11997
expect(expansion.expandedIds()).toEqual([]);
@@ -134,11 +112,8 @@ describe('Expansion', () => {
134112
expect(expansion.expandedIds()).toEqual(['item-0']);
135113
});
136114

137-
it('should not close an item if it is not focusable (disabled and skipDisabled is true)', () => {
138-
const {expansion, items} = getExpansion({
139-
initialExpandedIds: ['item-0'],
140-
skipDisabled: signal(true),
141-
});
115+
it('should not close an item if it is disabled', () => {
116+
const {expansion, items} = getExpansion({initialExpandedIds: ['item-0']});
142117
items[0].disabled.set(true);
143118
expansion.close(items[0]);
144119
expect(expansion.expandedIds()).toEqual(['item-0']);
@@ -181,7 +156,7 @@ describe('Expansion', () => {
181156
expect(expansion.expandedIds()).toEqual(['item-0', 'item-2']);
182157
});
183158

184-
it('should not expand items that are not focusable (disabled and skipDisabled is true)', () => {
159+
it('should not expand items that are disabled', () => {
185160
const {expansion, items} = getExpansion({
186161
numItems: 3,
187162
multiExpandable: signal(true),
@@ -222,9 +197,8 @@ describe('Expansion', () => {
222197
expect(expansion.expandedIds()).toEqual(['item-1']);
223198
});
224199

225-
it('should not close items that are not focusable (disabled and skipDisabled is true)', () => {
200+
it('should not close items that are disabled', () => {
226201
const {expansion, items} = getExpansion({
227-
skipDisabled: signal(true),
228202
multiExpandable: signal(true),
229203
initialExpandedIds: ['item-0', 'item-1', 'item-2'],
230204
});
@@ -235,22 +209,21 @@ describe('Expansion', () => {
235209
});
236210

237211
describe('#isExpandable', () => {
238-
it('should return true if an item is focusable and expandable is true', () => {
212+
it('should return true if an item is not disabled and expandable is true', () => {
239213
const {expansion, items} = getExpansion();
240214
items[0].expandable.set(true);
241215
items[0].disabled.set(false);
242216
expect(expansion.isExpandable(items[0])).toBeTrue();
243217
});
244218

245-
it('should return false if an item is disabled and skipDisabled is false', () => {
246-
const {expansion, items} = getExpansion({skipDisabled: signal(false)});
219+
it('should return false if an item is disabled', () => {
220+
const {expansion, items} = getExpansion();
247221
items[0].disabled.set(true);
248222
expect(expansion.isExpandable(items[0])).toBeFalse();
249223
});
250224

251-
it('should return false if an item is disabled and skipDisabled is true', () => {
252-
const {expansion, items} = getExpansion({skipDisabled: signal(true)});
253-
items[0].disabled.set(true);
225+
it('should return false if the expansion behavior is disabled', () => {
226+
const {expansion, items} = getExpansion({expansionDisabled: true});
254227
expect(expansion.isExpandable(items[0])).toBeFalse();
255228
});
256229

src/cdk-experimental/ui-patterns/behaviors/expansion/expansion.ts

Lines changed: 24 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,19 @@
55
* Use of this source code is governed by an MIT-style license that can be
66
* found in the LICENSE file at https://angular.dev/license
77
*/
8-
import {computed, signal} from '@angular/core';
8+
import {computed} from '@angular/core';
99
import {SignalLike, WritableSignalLike} from '../signal-like/signal-like';
10-
import {ListFocus, ListFocusInputs, ListFocusItem} from '../list-focus/list-focus';
1110

1211
/** Represents an item that can be expanded or collapsed. */
13-
export interface ExpansionItem extends ListFocusItem {
12+
export interface ExpansionItem {
1413
/** Whether the item is expandable. */
1514
expandable: SignalLike<boolean>;
1615

1716
/** Used to uniquely identify an expansion item. */
1817
expansionId: SignalLike<string>;
18+
19+
/** Whether the expansion is disabled. */
20+
disabled: SignalLike<boolean>;
1921
}
2022

2123
export interface ExpansionControl extends ExpansionItem {}
@@ -30,10 +32,9 @@ export class ExpansionControl {
3032
/** Whether this item can be expanded. */
3133
readonly isExpandable = computed(() => this.inputs.expansionManager.isExpandable(this));
3234

33-
constructor(readonly inputs: ExpansionItem & {expansionManager: ListExpansion<ExpansionItem>}) {
35+
constructor(readonly inputs: ExpansionItem & {expansionManager: ListExpansion}) {
3436
this.expansionId = inputs.expansionId;
3537
this.expandable = inputs.expandable;
36-
this.element = inputs.element;
3738
this.disabled = inputs.disabled;
3839
}
3940

@@ -54,28 +55,31 @@ export class ExpansionControl {
5455
}
5556

5657
/** Represents the required inputs for an expansion behavior. */
57-
export interface ListExpansionInputs<T extends ExpansionItem> extends ListFocusInputs<T> {
58+
export interface ListExpansionInputs {
5859
/** Whether multiple items can be expanded at once. */
5960
multiExpandable: SignalLike<boolean>;
6061

6162
/** An array of ids of the currently expanded items. */
6263
expandedIds: WritableSignalLike<string[]>;
64+
65+
/** An array of expansion items. */
66+
items: SignalLike<ExpansionItem[]>;
67+
68+
/** Whether all expansions are disabled. */
69+
disabled: SignalLike<boolean>;
6370
}
6471

6572
/** Manages the expansion state of a list of items. */
66-
export class ListExpansion<T extends ExpansionItem> {
73+
export class ListExpansion {
6774
/** A signal holding an array of ids of the currently expanded items. */
6875
expandedIds: WritableSignalLike<string[]>;
6976

70-
/** The currently active (focused) item in the list. */
71-
activeItem = computed(() => this.inputs.focusManager.activeItem());
72-
73-
constructor(readonly inputs: ListExpansionInputs<T> & {focusManager: ListFocus<T>}) {
74-
this.expandedIds = inputs.expandedIds ?? signal([]);
77+
constructor(readonly inputs: ListExpansionInputs) {
78+
this.expandedIds = inputs.expandedIds;
7579
}
7680

77-
/** Opens the specified item, or the currently active item if none is specified. */
78-
open(item: T = this.activeItem()) {
81+
/** Opens the specified item. */
82+
open(item: ExpansionItem) {
7983
if (!this.isExpandable(item)) return;
8084
if (this.isExpanded(item)) return;
8185
if (!this.inputs.multiExpandable()) {
@@ -84,18 +88,15 @@ export class ListExpansion<T extends ExpansionItem> {
8488
this.expandedIds.update(ids => ids.concat(item.expansionId()));
8589
}
8690

87-
/** Closes the specified item, or the currently active item if none is specified. */
88-
close(item: T = this.activeItem()) {
91+
/** Closes the specified item. */
92+
close(item: ExpansionItem) {
8993
if (this.isExpandable(item)) {
9094
this.expandedIds.update(ids => ids.filter(id => id !== item.expansionId()));
9195
}
9296
}
9397

94-
/**
95-
* Toggles the expansion state of the specified item,
96-
* or the currently active item if none is specified.
97-
*/
98-
toggle(item: T = this.activeItem()) {
98+
/** Toggles the expansion state of the specified item. */
99+
toggle(item: ExpansionItem) {
99100
this.expandedIds().includes(item.expansionId()) ? this.close(item) : this.open(item);
100101
}
101102

@@ -116,12 +117,12 @@ export class ListExpansion<T extends ExpansionItem> {
116117
}
117118

118119
/** Checks whether the specified item is expandable / collapsible. */
119-
isExpandable(item: T) {
120+
isExpandable(item: ExpansionItem) {
120121
return !this.inputs.disabled() && !item.disabled() && item.expandable();
121122
}
122123

123124
/** Checks whether the specified item is currently expanded. */
124-
isExpanded(item: T): boolean {
125+
isExpanded(item: ExpansionItem): boolean {
125126
return this.expandedIds().includes(item.expansionId());
126127
}
127128
}

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

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -128,9 +128,7 @@ interface SelectOptions {
128128
export type TabListInputs = ListNavigationInputs<TabPattern> &
129129
Omit<ListSelectionInputs<TabPattern, string>, 'multi'> &
130130
ListFocusInputs<TabPattern> &
131-
Omit<ListExpansionInputs<TabPattern>, 'multiExpandable' | 'expandedIds'> & {
132-
disabled: SignalLike<boolean>;
133-
};
131+
Omit<ListExpansionInputs, 'multiExpandable' | 'expandedIds' | 'items'>;
134132

135133
/** Controls the state of a tablist. */
136134
export class TabListPattern {
@@ -144,7 +142,7 @@ export class TabListPattern {
144142
focusManager: ListFocus<TabPattern>;
145143

146144
/** Controls expansion for the tablist. */
147-
expansionManager: ListExpansion<TabPattern>;
145+
expansionManager: ListExpansion;
148146

149147
/** Whether the tablist is vertically or horizontally oriented. */
150148
orientation: SignalLike<'vertical' | 'horizontal'>;
@@ -210,7 +208,6 @@ export class TabListPattern {
210208
...inputs,
211209
multiExpandable: () => false,
212210
expandedIds: this.inputs.value,
213-
focusManager: this.focusManager,
214211
});
215212
}
216213

@@ -266,7 +263,7 @@ export class TabListPattern {
266263
private _select(opts?: SelectOptions) {
267264
if (opts?.select) {
268265
this.selection.selectOne();
269-
this.expansionManager.open();
266+
this.expansionManager.open(this.focusManager.activeItem());
270267
}
271268
}
272269

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
load("//tools:defaults.bzl", "ng_web_test_suite", "ts_project")
2+
3+
package(default_visibility = ["//visibility:public"])
4+
5+
ts_project(
6+
name = "tree",
7+
srcs = [
8+
"tree.ts",
9+
],
10+
deps = [
11+
"//:node_modules/@angular/core",
12+
"//src/cdk-experimental/ui-patterns/behaviors/event-manager",
13+
"//src/cdk-experimental/ui-patterns/behaviors/expansion",
14+
"//src/cdk-experimental/ui-patterns/behaviors/list-focus",
15+
"//src/cdk-experimental/ui-patterns/behaviors/list-navigation",
16+
"//src/cdk-experimental/ui-patterns/behaviors/list-selection",
17+
"//src/cdk-experimental/ui-patterns/behaviors/list-typeahead",
18+
"//src/cdk-experimental/ui-patterns/behaviors/signal-like",
19+
],
20+
)
21+
22+
ts_project(
23+
name = "unit_test_sources",
24+
testonly = True,
25+
srcs = [
26+
"tree.spec.ts",
27+
],
28+
deps = [
29+
":tree",
30+
"//:node_modules/@angular/core",
31+
"//src/cdk/keycodes",
32+
"//src/cdk/testing/private",
33+
],
34+
)
35+
36+
ng_web_test_suite(
37+
name = "unit_tests",
38+
deps = [":unit_test_sources"],
39+
)

0 commit comments

Comments
 (0)