Skip to content

Commit 8cf7115

Browse files
committed
feat(cdk-experimental/ui-patterns): create combobox ui pattern
1 parent c1bf590 commit 8cf7115

File tree

15 files changed

+499
-33
lines changed

15 files changed

+499
-33
lines changed

src/cdk-experimental/combobox/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,6 @@ ng_project(
1111
deps = [
1212
"//:node_modules/@angular/core",
1313
"//src/cdk-experimental/deferred-content",
14+
"//src/cdk-experimental/ui-patterns",
1415
],
1516
)

src/cdk-experimental/combobox/combobox.ts

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

9-
import {contentChild, Directive, inject} from '@angular/core';
9+
import {
10+
afterRenderEffect,
11+
contentChild,
12+
Directive,
13+
ElementRef,
14+
inject,
15+
input,
16+
model,
17+
signal,
18+
WritableSignal,
19+
} from '@angular/core';
1020
import {DeferredContent, DeferredContentAware} from '@angular/cdk-experimental/deferred-content';
21+
import {ComboboxPattern, ComboboxPopupControls} from '../ui-patterns';
1122

1223
@Directive({
1324
selector: '[cdkCombobox]',
@@ -18,25 +29,75 @@ import {DeferredContent, DeferredContentAware} from '@angular/cdk-experimental/d
1829
inputs: ['preserveContent'],
1930
},
2031
],
32+
host: {
33+
'[attr.data-expanded]': 'pattern.expanded()',
34+
'(input)': 'pattern.onInput($event)',
35+
'(keydown)': 'pattern.onKeydown($event)',
36+
'(pointerup)': 'pattern.onPointerup($event)',
37+
'(focusin)': 'pattern.onFocusIn()',
38+
'(focusout)': 'pattern.onFocusOut($event)',
39+
},
2140
})
22-
export class CdkCombobox {
41+
export class CdkCombobox<V> {
42+
/** The element that the combobox is attached to. */
43+
private readonly _elementRef = inject(ElementRef);
44+
2345
/** The DeferredContentAware host directive. */
2446
private readonly _deferredContentAware = inject(DeferredContentAware, {optional: true});
2547

2648
/** The combobox popup. */
27-
readonly popup = contentChild(CdkComboboxPopup);
49+
readonly popup = contentChild<CdkComboboxPopup<V>>(CdkComboboxPopup);
50+
51+
/** The filter mode for the combobox. */
52+
filterMode = input<'manual' | 'auto-select' | 'highlight'>('manual');
53+
54+
/** Whether the combobox is focused. */
55+
readonly isFocused = signal(false);
56+
57+
/** The values of the current selected items. */
58+
value = model<V | undefined>(undefined);
59+
60+
/** The combobox ui pattern. */
61+
readonly pattern = new ComboboxPattern<any, V>({
62+
...this,
63+
inputEl: signal(undefined),
64+
containerEl: signal(undefined),
65+
popupControls: () => this.popup()?.actions(),
66+
});
2867

2968
constructor() {
30-
this._deferredContentAware?.contentVisible.set(true);
69+
(this.pattern.inputs.containerEl as WritableSignal<HTMLElement>).set(
70+
this._elementRef.nativeElement,
71+
);
72+
73+
afterRenderEffect(() => {
74+
this._deferredContentAware?.contentVisible.set(this.pattern.isFocused());
75+
});
3176
}
3277
}
3378

3479
@Directive({
3580
selector: 'input[cdkComboboxInput]',
3681
exportAs: 'cdkComboboxInput',
37-
host: {'role': 'combobox'},
82+
host: {
83+
'role': 'combobox',
84+
'[attr.aria-expanded]': 'combobox.pattern.expanded()',
85+
'[attr.aria-activedescendant]': 'combobox.pattern.activedescendant()',
86+
},
3887
})
39-
export class CdkComboboxInput {}
88+
export class CdkComboboxInput {
89+
/** The element that the combobox is attached to. */
90+
private readonly _elementRef = inject(ElementRef);
91+
92+
/** The combobox that the input belongs to. */
93+
readonly combobox = inject(CdkCombobox);
94+
95+
constructor() {
96+
(this.combobox.pattern.inputs.inputEl as WritableSignal<HTMLInputElement>).set(
97+
this._elementRef.nativeElement,
98+
);
99+
}
100+
}
40101

41102
@Directive({
42103
selector: 'ng-template[cdkComboboxPopupContent]',
@@ -49,7 +110,10 @@ export class CdkComboboxPopupContent {}
49110
selector: '[cdkComboboxPopup]',
50111
exportAs: 'cdkComboboxPopup',
51112
})
52-
export class CdkComboboxPopup {
113+
export class CdkComboboxPopup<V> {
53114
/** The combobox that the popup belongs to. */
54-
readonly combobox = inject(CdkCombobox, {optional: true});
115+
readonly combobox = inject<CdkCombobox<V>>(CdkCombobox, {optional: true});
116+
117+
/** The actions that the combobox can perform on the popup. */
118+
readonly actions = signal<ComboboxPopupControls<any, V> | undefined>(undefined);
55119
}

src/cdk-experimental/listbox/listbox.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import {CdkComboboxPopup} from '../combobox';
4444
host: {
4545
'role': 'listbox',
4646
'class': 'cdk-listbox',
47+
'[attr.id]': 'id()',
4748
'[attr.tabindex]': 'pattern.tabindex()',
4849
'[attr.aria-readonly]': 'pattern.readonly()',
4950
'[attr.aria-disabled]': 'pattern.disabled()',
@@ -57,7 +58,17 @@ import {CdkComboboxPopup} from '../combobox';
5758
hostDirectives: [{directive: CdkComboboxPopup}],
5859
})
5960
export class CdkListbox<V> {
60-
private readonly _popup = inject(CdkComboboxPopup, {optional: true});
61+
/** A unique identifier for the listbox. */
62+
private readonly _generatedId = inject(_IdGenerator).getId('cdk-listbox-');
63+
64+
// TODO(wagnermaciel): https://github.com/angular/components/pull/30495#discussion_r1972601144.
65+
/** A unique identifier for the option. */
66+
protected id = computed(() => this._generatedId);
67+
68+
/** A reference to the parent combobox popup, if one exists. */
69+
private readonly _popup = inject<CdkComboboxPopup<V>>(CdkComboboxPopup, {
70+
optional: true,
71+
});
6172

6273
/** A reference to the listbox element. */
6374
private readonly _elementRef = inject(ElementRef);
@@ -113,13 +124,15 @@ export class CdkListbox<V> {
113124
activeItem: signal(undefined),
114125
textDirection: this.textDirection,
115126
element: () => this._elementRef.nativeElement,
116-
isComboboxPopup: () => !!this._popup?.combobox,
127+
combobox: () => this._popup?.combobox?.pattern,
117128
});
118129

119130
/** Whether the listbox has received focus yet. */
120131
private _hasFocused = signal(false);
121132

122133
constructor() {
134+
this._popup?.actions?.set(this.pattern.comboboxActions);
135+
123136
afterRenderEffect(() => {
124137
if (typeof ngDevMode === 'undefined' || ngDevMode) {
125138
const violations = this.pattern.validate();
@@ -153,6 +166,7 @@ export class CdkListbox<V> {
153166
'[attr.tabindex]': 'pattern.tabindex()',
154167
'[attr.aria-selected]': 'pattern.selected()',
155168
'[attr.aria-disabled]': 'pattern.disabled()',
169+
'[attr.inert]': 'pattern.inert()',
156170
},
157171
})
158172
export class CdkOption<V> {

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ ts_project(
1212
"//:node_modules/@angular/core",
1313
"//src/cdk-experimental/ui-patterns/accordion",
1414
"//src/cdk-experimental/ui-patterns/behaviors/signal-like",
15+
"//src/cdk-experimental/ui-patterns/combobox",
1516
"//src/cdk-experimental/ui-patterns/listbox",
1617
"//src/cdk-experimental/ui-patterns/radio-group",
1718
"//src/cdk-experimental/ui-patterns/tabs",

src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ export interface ListFocusItem {
2222

2323
/** The index of the item in the list. */
2424
index: SignalLike<number>;
25+
26+
/** Whether the item is currently focused. */
27+
inert?: SignalLike<true | null>;
2528
}
2629

2730
/** Represents the required inputs for a collection that contains focusable items. */
@@ -112,6 +115,6 @@ export class ListFocus<T extends ListFocusItem> {
112115

113116
/** Returns true if the given item can be navigated to. */
114117
isFocusable(item: T): boolean {
115-
return !item.disabled() || !this.inputs.skipDisabled();
118+
return (!item.disabled() || !this.inputs.skipDisabled()) && !item.inert?.();
116119
}
117120
}

src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ export class ListSelection<T extends ListSelectionItem<V>, V> {
4747
select(item?: ListSelectionItem<V>, opts = {anchor: true}) {
4848
item = item ?? (this.inputs.focusManager.inputs.activeItem() as ListSelectionItem<V>);
4949

50-
if (item.disabled() || this.inputs.value().includes(item.value())) {
50+
if (!item || item.disabled() || this.inputs.value().includes(item.value())) {
5151
return;
5252
}
5353

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,11 @@ export class List<T extends ListItem<V>, V> {
129129
this._navigate(opts, () => this.navigationBehavior.goto(item));
130130
}
131131

132+
/** Removes focus from the list. */
133+
unfocus() {
134+
this.inputs.activeItem.set(undefined);
135+
}
136+
132137
/** Marks the given index as the potential start of a range selection. */
133138
anchor(index: number) {
134139
this._anchorIndex.set(index);
@@ -145,8 +150,8 @@ export class List<T extends ListItem<V>, V> {
145150
}
146151

147152
/** Selects the currently active item in the list. */
148-
select() {
149-
this.selectionBehavior.select();
153+
select(item?: T) {
154+
this.selectionBehavior.select(item);
150155
}
151156

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

0 commit comments

Comments
 (0)