Skip to content

Commit 00261a9

Browse files
committed
feat(cdk-experimental/combobox): add tree integration
1 parent 0f4fb54 commit 00261a9

File tree

13 files changed

+263
-53
lines changed

13 files changed

+263
-53
lines changed

src/cdk-experimental/combobox/combobox.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
WritableSignal,
1919
} from '@angular/core';
2020
import {DeferredContent, DeferredContentAware} from '@angular/cdk-experimental/deferred-content';
21-
import {ComboboxPattern, ComboboxPopupControls} from '../ui-patterns';
21+
import {ComboboxPattern, ComboboxListboxControls, ComboboxTreeControls} from '../ui-patterns';
2222

2323
@Directive({
2424
selector: '[cdkCombobox]',
@@ -66,7 +66,7 @@ export class CdkCombobox<V> {
6666
...this,
6767
inputEl: signal(undefined),
6868
containerEl: signal(undefined),
69-
popupControls: () => this.popup()?.actions(),
69+
popupControls: () => this.popup()?.controls(),
7070
});
7171

7272
constructor() {
@@ -118,6 +118,8 @@ export class CdkComboboxPopup<V> {
118118
/** The combobox that the popup belongs to. */
119119
readonly combobox = inject<CdkCombobox<V>>(CdkCombobox, {optional: true});
120120

121-
/** The actions that the combobox can perform on the popup. */
122-
readonly actions = signal<ComboboxPopupControls<any, V> | undefined>(undefined);
121+
/** The controls the popup exposes to the combobox. */
122+
readonly controls = signal<
123+
ComboboxListboxControls<any, V> | ComboboxTreeControls<any, V> | undefined
124+
>(undefined);
123125
}

src/cdk-experimental/deferred-content/deferred-content.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
TemplateRef,
1515
signal,
1616
ViewContainerRef,
17+
model,
1718
} from '@angular/core';
1819

1920
/**
@@ -22,7 +23,7 @@ import {
2223
@Directive()
2324
export class DeferredContentAware {
2425
contentVisible = signal(false);
25-
readonly preserveContent = input(false);
26+
preserveContent = model(false);
2627
}
2728

2829
/**

src/cdk-experimental/listbox/listbox.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ export class CdkListbox<V> {
138138
: new ListboxPattern<V>(inputs);
139139

140140
if (this._popup) {
141-
this._popup.actions.set((this.pattern as ComboboxListboxPattern<V>).comboboxActions);
141+
this._popup.controls.set(this.pattern as ComboboxListboxPattern<V>);
142142
}
143143

144144
afterRenderEffect(() => {

src/cdk-experimental/tree/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ ng_project(
1010
"tree.ts",
1111
],
1212
deps = [
13+
"//src/cdk-experimental/combobox",
1314
"//src/cdk-experimental/deferred-content",
1415
"//src/cdk-experimental/ui-patterns",
1516
"//src/cdk/a11y",

src/cdk-experimental/tree/tree.ts

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ import {
2323
import {_IdGenerator} from '@angular/cdk/a11y';
2424
import {Directionality} from '@angular/cdk/bidi';
2525
import {DeferredContent, DeferredContentAware} from '@angular/cdk-experimental/deferred-content';
26-
import {TreeItemPattern, TreePattern} from '../ui-patterns/tree/tree';
26+
import {ComboboxTreePattern, TreeItemPattern, TreePattern} from '../ui-patterns';
27+
import {CdkCombobox, CdkComboboxPopup} from '@angular/cdk-experimental/combobox';
2728

2829
interface HasElement {
2930
element: Signal<HTMLElement>;
@@ -74,8 +75,14 @@ function sortDirectives(a: HasElement, b: HasElement) {
7475
'(pointerdown)': 'pattern.onPointerdown($event)',
7576
'(focusin)': 'onFocus()',
7677
},
78+
hostDirectives: [{directive: CdkComboboxPopup}],
7779
})
7880
export class CdkTree<V> {
81+
/** A reference to the parent combobox popup, if one exists. */
82+
private readonly _popup = inject<CdkComboboxPopup<V>>(CdkComboboxPopup, {
83+
optional: true,
84+
});
85+
7986
/** A reference to the tree element. */
8087
private readonly _elementRef = inject(ElementRef);
8188

@@ -121,19 +128,30 @@ export class CdkTree<V> {
121128
);
122129

123130
/** The UI pattern for the tree. */
124-
readonly pattern: TreePattern<V> = new TreePattern<V>({
125-
...this,
126-
allItems: computed(() =>
127-
[...this._unorderedItems()].sort(sortDirectives).map(item => item.pattern),
128-
),
129-
activeItem: signal(undefined),
130-
element: () => this._elementRef.nativeElement,
131-
});
131+
readonly pattern: TreePattern<V>;
132132

133133
/** Whether the tree has received focus yet. */
134134
private _hasFocused = signal(false);
135135

136136
constructor() {
137+
const inputs = {
138+
...this,
139+
allItems: computed(() =>
140+
[...this._unorderedItems()].sort(sortDirectives).map(item => item.pattern),
141+
),
142+
activeItem: signal(undefined),
143+
element: () => this._elementRef.nativeElement,
144+
combobox: () => this._popup?.combobox?.pattern,
145+
};
146+
147+
this.pattern = this._popup?.combobox
148+
? new ComboboxTreePattern<V>(inputs)
149+
: new TreePattern<V>(inputs);
150+
151+
if (this._popup?.combobox) {
152+
this._popup?.controls?.set(this.pattern as ComboboxTreePattern<V>);
153+
}
154+
137155
afterRenderEffect(() => {
138156
if (!this._hasFocused()) {
139157
this.pattern.setDefaultState();
@@ -176,7 +194,7 @@ export class CdkTree<V> {
176194
'[attr.aria-setsize]': 'pattern.setsize()',
177195
'[attr.aria-posinset]': 'pattern.posinset()',
178196
'[attr.tabindex]': 'pattern.tabindex()',
179-
'[attr.inert]': 'pattern.visible() ? null : true',
197+
'[attr.inert]': 'pattern.inert()',
180198
},
181199
})
182200
export class CdkTreeItem<V> implements OnInit, OnDestroy, HasElement {
@@ -312,10 +330,19 @@ export class CdkTreeItemGroup<V> implements OnInit, OnDestroy, HasElement {
312330
/** Tree item that owns the group. */
313331
readonly ownedBy = input.required<CdkTreeItem<V>>();
314332

333+
/** The combobox that the input belongs to. */
334+
// readonly combobox = inject(CdkCombobox);
335+
315336
constructor() {
337+
this._deferredContentAware.preserveContent.set(true);
316338
// Connect the group's hidden state to the DeferredContentAware's visibility.
317339
afterRenderEffect(() => {
318-
this._deferredContentAware.contentVisible.set(this.visible());
340+
const tree = this.ownedBy().tree();
341+
if (tree.pattern instanceof ComboboxTreePattern) {
342+
this._deferredContentAware.contentVisible.set(
343+
tree.pattern.inputs.combobox()?.isFocused() ?? false,
344+
);
345+
}
319346
});
320347
}
321348

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export class ExpansionControl {
3838
this.disabled = inputs.disabled;
3939
}
4040

41-
/** Requests the Expansopn manager to open this item. */
41+
/** Requests the Expansion manager to open this item. */
4242
open() {
4343
this.inputs.expansionManager.open(this);
4444
}

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

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export type ComboboxInputs<T extends ListItem<V>, V> = {
1717
value: WritableSignalLike<V | undefined>;
1818

1919
/** The controls for the popup associated with the combobox. */
20-
popupControls: SignalLike<ComboboxPopupControls<T, V> | undefined>;
20+
popupControls: SignalLike<ComboboxListboxControls<T, V> | ComboboxTreeControls<T, V> | undefined>;
2121

2222
/** The HTML input element that serves as the combobox input. */
2323
inputEl: SignalLike<HTMLInputElement | undefined>;
@@ -33,7 +33,10 @@ export type ComboboxInputs<T extends ListItem<V>, V> = {
3333
};
3434

3535
/** An interface that allows combobox popups to expose the necessary controls for the combobox. */
36-
export type ComboboxPopupControls<T extends ListItem<V>, V> = {
36+
export type ComboboxListboxControls<T extends ListItem<V>, V> = {
37+
/** The ARIA role for the popup. */
38+
role: SignalLike<'listbox' | 'tree' | 'grid'>;
39+
3740
/** The ID of the active item in the popup. */
3841
activeId: SignalLike<string | undefined>;
3942

@@ -71,6 +74,17 @@ export type ComboboxPopupControls<T extends ListItem<V>, V> = {
7174
setValue: (value: V | undefined) => void; // For re-setting the value if the popup was destroyed.
7275
};
7376

77+
export type ComboboxTreeControls<T extends ListItem<V>, V> = ComboboxListboxControls<T, V> & {
78+
/** Expands the currently active item in the popup. */
79+
expandItem: () => void;
80+
81+
/** Collapses the currently active item in the popup. */
82+
collapseItem: () => void;
83+
84+
/** Checks if the currently active item in the popup is expandable. */
85+
isItemExpandable: () => boolean;
86+
};
87+
7488
/** Controls the state of a combobox. */
7589
export class ComboboxPattern<T extends ListItem<V>, V> {
7690
/** Whether the combobox is expanded. */
@@ -88,6 +102,12 @@ export class ComboboxPattern<T extends ListItem<V>, V> {
88102
/** Whether the combobox is focused. */
89103
isFocused = signal(false);
90104

105+
/** The key used to navigate to the previous item in the list. */
106+
expandKey = computed(() => 'ArrowRight'); // TODO: RTL support.
107+
108+
/** The key used to navigate to the next item in the list. */
109+
collapseKey = computed(() => 'ArrowLeft'); // TODO: RTL support.
110+
91111
/** The keydown event manager for the combobox. */
92112
keydown = computed(() => {
93113
if (!this.expanded()) {
@@ -96,15 +116,21 @@ export class ComboboxPattern<T extends ListItem<V>, V> {
96116
.on('ArrowUp', () => this.open({last: true}));
97117
}
98118

99-
return new KeyboardEventManager()
119+
const popupControls = this.inputs.popupControls();
120+
121+
if (!popupControls) {
122+
return new KeyboardEventManager();
123+
}
124+
125+
const manager = new KeyboardEventManager()
100126
.on('ArrowDown', () => this.next())
101127
.on('ArrowUp', () => this.prev())
102128
.on('Home', () => this.first())
103129
.on('End', () => this.last())
104130
.on('Escape', () => {
105-
if (this.inputs.filterMode() === 'highlight' && this.inputs.popupControls()?.activeId()) {
106-
this.inputs.popupControls()?.unfocus();
107-
this.inputs.popupControls()?.clearSelection();
131+
if (this.inputs.filterMode() === 'highlight' && popupControls.activeId()) {
132+
popupControls.unfocus();
133+
popupControls.clearSelection();
108134

109135
const inputEl = this.inputs.inputEl();
110136

@@ -116,6 +142,18 @@ export class ComboboxPattern<T extends ListItem<V>, V> {
116142
}
117143
}) // TODO: When filter mode is 'highlight', escape should revert to the last committed value.
118144
.on('Enter', () => this.select({commit: true, close: true}));
145+
146+
if (popupControls.role() === 'tree') {
147+
const treeControls = popupControls as ComboboxTreeControls<T, V>;
148+
149+
if (treeControls.isItemExpandable()) {
150+
manager
151+
.on(this.expandKey(), () => treeControls.expandItem())
152+
.on(this.collapseKey(), () => treeControls.collapseItem());
153+
}
154+
}
155+
156+
return manager;
119157
});
120158

121159
/** The pointerup event manager for the combobox. */

src/cdk-experimental/ui-patterns/listbox/combobox-listbox.ts

Lines changed: 54 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,23 @@ import {computed} from '@angular/core';
22
import {ListboxInputs, ListboxPattern} from './listbox';
33
import {SignalLike} from '../behaviors/signal-like/signal-like';
44
import {OptionPattern} from './option';
5-
import {ComboboxPattern, ComboboxPopupControls} from '../combobox/combobox';
5+
import {ComboboxPattern, ComboboxListboxControls} from '../combobox/combobox';
66

77
export type ComboboxListboxInputs<V> = ListboxInputs<V> & {
88
/** The combobox controlling the listbox. */
99
combobox: SignalLike<ComboboxPattern<OptionPattern<V>, V> | undefined>;
1010
};
1111

12-
export class ComboboxListboxPattern<V> extends ListboxPattern<V> {
12+
export class ComboboxListboxPattern<V>
13+
extends ListboxPattern<V>
14+
implements ComboboxListboxControls<OptionPattern<V>, V>
15+
{
16+
role = () => 'listbox' as const;
17+
18+
/** The id of the active (focused) item in the listbox. */
19+
activeId = computed(() => this.listBehavior.activedescendant());
20+
21+
/** The tabindex for the listbox. Always -1 because the combobox handles focus. */
1322
override tabindex: SignalLike<-1 | 0> = () => -1;
1423

1524
constructor(override readonly inputs: ComboboxListboxInputs<V>) {
@@ -22,30 +31,52 @@ export class ComboboxListboxPattern<V> extends ListboxPattern<V> {
2231
super(inputs);
2332
}
2433

34+
/** Noop. The combobox handles keydown events. */
2535
override onKeydown(_: KeyboardEvent): void {}
36+
37+
/** Noop. The combobox handles pointerdown events. */
2638
override onPointerdown(_: PointerEvent): void {}
39+
40+
/** Noop. The combobox controls the open state. */
2741
override setDefaultState(): void {}
2842

29-
/** The actions that can be performed on a combobox popup listbox. */
30-
comboboxActions: ComboboxPopupControls<OptionPattern<V>, V> = {
31-
activeId: computed(() => this.listBehavior.activedescendant()),
32-
next: () => this.listBehavior.next(),
33-
prev: () => this.listBehavior.prev(),
34-
last: () => this.listBehavior.last(),
35-
first: () => this.listBehavior.first(),
36-
unfocus: () => this.listBehavior.unfocus(),
37-
select: item => this.listBehavior.select(item),
38-
clearSelection: () => this.listBehavior.deselectAll(),
39-
getItem: e => this._getItem(e),
40-
getSelectedItem: () => this.inputs.items().find(i => i.selected()),
41-
setValue: (value: V | undefined) => this.inputs.value.set(value ? [value] : []),
42-
filter: (text: string) => {
43-
const filterFn = this.inputs.combobox()!.inputs.filter();
44-
45-
this.inputs.items().forEach(i => {
46-
const isMatch = filterFn(text, i.searchTerm());
47-
i.inert.set(isMatch ? null : true);
48-
});
49-
},
43+
/** Navigates to the next focusable item in the listbox. */
44+
next = () => this.listBehavior.next();
45+
46+
/** Navigates to the previous focusable item in the listbox. */
47+
prev = () => this.listBehavior.prev();
48+
49+
/** Navigates to the last focusable item in the listbox. */
50+
last = () => this.listBehavior.last();
51+
52+
/** Navigates to the first focusable item in the listbox. */
53+
first = () => this.listBehavior.first();
54+
55+
/** Unfocuses the currently focused item in the listbox. */
56+
unfocus = () => this.listBehavior.unfocus();
57+
58+
/** Selects the specified item in the listbox. */
59+
select = (item?: OptionPattern<V>) => this.listBehavior.select(item);
60+
61+
/** Clears the selection in the listbox. */
62+
clearSelection = () => this.listBehavior.deselectAll();
63+
64+
/** Retrieves the OptionPattern associated with a pointer event. */
65+
getItem = (e: PointerEvent) => this._getItem(e);
66+
67+
/** Retrieves the currently selected item in the listbox. */
68+
getSelectedItem = () => this.inputs.items().find(i => i.selected());
69+
70+
/** Sets the value of the combobox listbox. */
71+
setValue = (value: V | undefined) => this.inputs.value.set(value ? [value] : []);
72+
73+
/** Filters the items in the listbox based on the provided text. */
74+
filter = (text: string) => {
75+
const filterFn = this.inputs.combobox()!.inputs.filter();
76+
77+
this.inputs.items().forEach(i => {
78+
const isMatch = filterFn(text, i.searchTerm());
79+
i.inert.set(isMatch ? null : true);
80+
});
5081
};
5182
}

src/cdk-experimental/ui-patterns/public-api.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,5 @@ export * from './behaviors/signal-like/signal-like';
1616
export * from './tabs/tabs';
1717
export * from './accordion/accordion';
1818
export * from './toolbar/toolbar';
19+
export * from './tree/tree';
20+
export * from './tree/combobox-tree';

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package(default_visibility = ["//visibility:public"])
55
ts_project(
66
name = "tree",
77
srcs = [
8+
"combobox-tree.ts",
89
"tree.ts",
910
],
1011
deps = [
@@ -13,6 +14,7 @@ ts_project(
1314
"//src/cdk-experimental/ui-patterns/behaviors/expansion",
1415
"//src/cdk-experimental/ui-patterns/behaviors/list",
1516
"//src/cdk-experimental/ui-patterns/behaviors/signal-like",
17+
"//src/cdk-experimental/ui-patterns/combobox",
1618
],
1719
)
1820

0 commit comments

Comments
 (0)