Skip to content

Commit 8eea4c5

Browse files
committed
refactor(aria/combobox): include more comprehensive examples
Allow the different states of combobox to work with manual, auto-select, and highlight
1 parent af59a8e commit 8eea4c5

File tree

40 files changed

+2169
-141
lines changed

40 files changed

+2169
-141
lines changed

src/aria/combobox/combobox-popup.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@
77
*/
88

99
import {Directive, inject, signal} from '@angular/core';
10-
import {ComboboxListboxControls, ComboboxTreeControls, ComboboxDialogPattern} from '../private';
10+
import {
11+
ComboboxListboxControls,
12+
ComboboxTreeControls,
13+
ComboboxDialogPattern,
14+
ComboboxNavigation,
15+
} from '../private';
1116
import type {Combobox} from './combobox';
1217
import {COMBOBOX} from './combobox-tokens';
1318

@@ -41,4 +46,12 @@ export class ComboboxPopup<V> {
4146
| ComboboxDialogPattern
4247
| undefined
4348
>(undefined);
49+
50+
/** The navigation state to apply when the popup expands. */
51+
readonly pendingNavigation = signal<ComboboxNavigation | undefined>(undefined);
52+
53+
/** Sets the navigation state to be applied when the popup is ready. */
54+
focusOnReady(nav: ComboboxNavigation) {
55+
this.pendingNavigation.set(nav);
56+
}
4457
}

src/aria/listbox/listbox.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import {_IdGenerator} from '@angular/cdk/a11y';
2424
import {ComboboxListboxPattern, ListboxPattern, OptionPattern} from '../private';
2525
import {ComboboxPopup} from '../combobox';
2626
import {Option} from './option';
27-
import {LISTBOX} from './tokens';
27+
import {COMBOBOX_WIDGET, LISTBOX} from './tokens';
2828

2929
/**
3030
* Represents a container used to display a list of items for a user to select from.
@@ -78,6 +78,8 @@ export class Listbox<V> {
7878
optional: true,
7979
});
8080

81+
private readonly _widget = inject<any>(COMBOBOX_WIDGET, {optional: true});
82+
8183
/** A reference to the host element. */
8284
private readonly _elementRef = inject(ElementRef);
8385

@@ -151,6 +153,7 @@ export class Listbox<V> {
151153
textDirection: this.textDirection,
152154
element: () => this._elementRef.nativeElement,
153155
combobox: () => this._popup?.combobox?._pattern,
156+
hasPopup: () => !!this._popup?.combobox || !!this._widget,
154157
};
155158

156159
this._pattern = this._popup?.combobox
@@ -171,8 +174,14 @@ export class Listbox<V> {
171174
});
172175

173176
afterRenderEffect(() => {
174-
if (!this._hasFocused()) {
175-
this._pattern.setDefaultState();
177+
const active = this._pattern.inputs.activeItem();
178+
179+
if (!this._widget || (this._widget as any).filterMode() !== 'manual') {
180+
untracked(() => this._pattern.listBehavior.select());
181+
}
182+
183+
if (this._widget) {
184+
untracked(() => (this._widget as any).activeValue.set(active?.value()));
176185
}
177186
});
178187

@@ -192,7 +201,12 @@ export class Listbox<V> {
192201
const items = inputs.items();
193202
const values = untracked(() => this.values());
194203

195-
if (items && values.some(v => !items.some(i => i.value() === v))) {
204+
// If using simple combobx, the combobox should handle the value.
205+
if (this._popup?.combobox || this._widget) {
206+
return;
207+
}
208+
209+
if (items.length > 0 && values.some(v => !items.some(i => i.value() === v))) {
196210
this.values.set(values.filter(v => items.some(i => i.value() === v)));
197211
}
198212
});

src/aria/listbox/tokens.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,5 @@ import {InjectionToken} from '@angular/core';
1010
import type {Listbox} from './listbox';
1111

1212
export const LISTBOX = new InjectionToken<Listbox<any>>('LISTBOX');
13+
14+
export const COMBOBOX_WIDGET = new InjectionToken<any>('COMBOBOX_WIDGET');

src/aria/private/combobox/combobox.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ export interface ComboboxTreeControls<T extends ListItem<V>, V> extends Combobox
139139
}
140140

141141
/** Controls the state of a combobox. */
142-
export class ComboboxPattern<T extends ListItem<V>, V> {
142+
export class ComboboxPattern<T extends ListItem<V>, V> implements ComboboxLike<T> {
143143
/** Whether the combobox is expanded. */
144144
expanded = signal(false);
145145

@@ -570,7 +570,7 @@ export class ComboboxPattern<T extends ListItem<V>, V> {
570570
}
571571

572572
/** Opens the combobox. */
573-
open(nav?: {first?: boolean; last?: boolean; selected?: boolean}) {
573+
open(nav?: ComboboxNavigation) {
574574
this.expanded.set(true);
575575
const popupControls = this.inputs.popupControls();
576576

@@ -737,3 +737,15 @@ export class ComboboxDialogPattern {
737737
}
738738
}
739739
}
740+
741+
export interface ComboboxNavigation {
742+
first?: boolean;
743+
last?: boolean;
744+
selected?: boolean;
745+
}
746+
747+
export interface ComboboxLike<T> {
748+
expanded: WritableSignalLike<boolean>;
749+
highlightedItem: WritableSignalLike<T | undefined>;
750+
open(nav?: ComboboxNavigation): void;
751+
}

src/aria/private/listbox/listbox.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ export type ListboxInputs<V> = ListInputs<OptionPattern<V>, V> & {
1818

1919
/** Whether the listbox is readonly. */
2020
readonly: SignalLike<boolean>;
21+
22+
/** Whether the listbox is in a popup or widget context. */
23+
hasPopup?: SignalLike<boolean>;
2124
};
2225

2326
/** Controls the state of a listbox. */
@@ -135,8 +138,12 @@ export class ListboxPattern<V> {
135138
}
136139

137140
if (!this.followFocus() && !this.inputs.multi()) {
138-
manager.on(this.dynamicSpaceKey, () => this.listBehavior.toggleOne());
139-
manager.on('Enter', () => this.listBehavior.toggleOne());
141+
manager.on(this.dynamicSpaceKey, () =>
142+
this.inputs.hasPopup?.() ? this.listBehavior.selectOne() : this.listBehavior.toggleOne(),
143+
);
144+
manager.on('Enter', () =>
145+
this.inputs.hasPopup?.() ? this.listBehavior.selectOne() : this.listBehavior.toggleOne(),
146+
);
140147
}
141148

142149
if (this.inputs.multi() && this.followFocus()) {

src/aria/private/simple-combobox/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,6 @@ ts_project(
1414
"//src/aria/private/behaviors/expansion",
1515
"//src/aria/private/behaviors/list",
1616
"//src/aria/private/behaviors/signal-like",
17+
"//src/aria/private/combobox",
1718
],
1819
)

src/aria/private/simple-combobox/simple-combobox.ts

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@
99
import {KeyboardEventManager, PointerEventManager} from '../behaviors/event-manager';
1010
import {computed, signal, untracked} from '@angular/core';
1111
import {SignalLike, WritableSignalLike} from '../behaviors/signal-like/signal-like';
12+
import {ComboboxLike, ComboboxNavigation} from '../combobox/combobox';
1213
import {ExpansionItem} from '../behaviors/expansion/expansion';
1314

1415
/** Represents the required inputs for a simple combobox. */
15-
export interface SimpleComboboxInputs extends ExpansionItem {
16+
export interface SimpleComboboxInputs<V> extends ExpansionItem {
1617
/** The value of the combobox. */
1718
value: WritableSignalLike<string>;
1819

@@ -25,18 +26,27 @@ export interface SimpleComboboxInputs extends ExpansionItem {
2526
/** An inline suggestion to be displayed in the input. */
2627
inlineSuggestion: SignalLike<string | undefined>;
2728

29+
/** The active value in the popup (the option's value). */
30+
activeValue: SignalLike<V | undefined>;
31+
2832
/** Whether the combobox is disabled. */
2933
disabled: SignalLike<boolean>;
34+
35+
/** The filtering mode for the combobox. */
36+
filterMode: SignalLike<'manual' | 'auto-select' | 'highlight'>;
3037
}
3138

3239
/** Controls the state of a simple combobox. */
33-
export class SimpleComboboxPattern {
40+
export class SimpleComboboxPattern<V> {
3441
/** Whether the combobox is expanded. */
3542
readonly expanded: WritableSignalLike<boolean>;
3643

3744
/** The value of the combobox. */
3845
readonly value: WritableSignalLike<string>;
3946

47+
/** The ID of the currently highlighted item in the popup. */
48+
readonly highlightedItem = signal<string | undefined>(undefined);
49+
4050
/** The element that the combobox is attached to. */
4151
readonly element = () => this.inputs.element();
4252

@@ -147,7 +157,7 @@ export class SimpleComboboxPattern {
147157
return manager;
148158
});
149159

150-
constructor(readonly inputs: SimpleComboboxInputs) {
160+
constructor(readonly inputs: SimpleComboboxInputs<V>) {
151161
this.expanded = inputs.expanded;
152162
this.value = inputs.value;
153163
}
@@ -227,6 +237,13 @@ export class SimpleComboboxPattern {
227237
const comboboxFocused = this.isFocused();
228238
const popupFocused = !!this.inputs.popup()?.isFocused();
229239
if (expanded && !comboboxFocused && !popupFocused) {
240+
const activeValue = untracked(() => this.inputs.activeValue?.());
241+
242+
// Auto-commit highlighted item on blur for non-manual modes (auto-select and highlight).
243+
// The Listbox pushes its highlighted value here, letting the headless directive sync it.
244+
if (activeValue && this.inputs.filterMode() !== 'manual') {
245+
this.value.set(activeValue as any); // Type assertion if needed
246+
}
230247
this.expanded.set(false);
231248
}
232249
}
@@ -245,6 +262,18 @@ export interface SimpleComboboxPopupInputs {
245262

246263
/** The ID of the popup. */
247264
popupId: SignalLike<string | undefined>;
265+
266+
/** Navigates to the first item in the popup. */
267+
first: () => void;
268+
269+
/** Navigates to the last item in the popup. */
270+
last: () => void;
271+
272+
/** Focuses the currently selected item in the popup. */
273+
focusSelected: () => void;
274+
275+
/** Focuses the popup with the given navigation instruction when ready. */
276+
focusOnReady: (nav: ComboboxNavigation) => void;
248277
}
249278

250279
/** Controls the state of a simple combobox popup. */
@@ -264,6 +293,29 @@ export class SimpleComboboxPopupPattern {
264293
/** Whether the popup is focused. */
265294
readonly isFocused = signal(false);
266295

296+
/** Navigates to the first item in the popup. */
297+
first() {
298+
this.inputs.first();
299+
}
300+
301+
/** Navigates to the last item in the popup. */
302+
last() {
303+
this.inputs.last();
304+
}
305+
306+
/** Focuses the currently selected item in the popup. */
307+
focusSelected() {
308+
this.inputs.focusSelected();
309+
}
310+
311+
/**
312+
* Focuses the popup with the given navigation instruction when ready.
313+
* This is used for lazy rendering (when the popup isn't in the DOM yet synchronously).
314+
*/
315+
focusOnReady(nav: ComboboxNavigation) {
316+
this.inputs.focusOnReady(nav);
317+
}
318+
267319
constructor(readonly inputs: SimpleComboboxPopupInputs) {}
268320

269321
/** Handles focus in events for the popup. */

src/aria/private/tree/combobox-tree.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,6 @@ export class ComboboxTreePattern<V>
5656
/** Noop. The combobox handles pointerdown events. */
5757
override onPointerdown(_: PointerEvent): void {}
5858

59-
/** Noop. The combobox controls the open state. */
60-
override setDefaultState(): void {}
61-
6259
/** Navigates to the specified item in the tree. */
6360
focus = (item: TreeItemPattern<V>) => this.treeBehavior.goto(item);
6461

@@ -75,7 +72,9 @@ export class ComboboxTreePattern<V>
7572
first = () => this.treeBehavior.first();
7673

7774
/** Unfocuses the currently focused item in the tree. */
78-
unfocus = () => this.treeBehavior.unfocus();
75+
unfocus = () => {
76+
this.treeBehavior.unfocus();
77+
};
7978

8079
// TODO: handle non-selectable parent nodes.
8180
/** Selects the specified item in the tree or the current active item if not provided. */

src/aria/simple-combobox/BUILD.bazel

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
load("//tools:defaults.bzl", "ng_project")
1+
load("//tools:defaults.bzl", "ng_project", "ng_web_test_suite", "ts_project")
22

33
package(default_visibility = ["//visibility:public"])
44

@@ -10,7 +10,31 @@ ng_project(
1010
),
1111
deps = [
1212
"//:node_modules/@angular/core",
13+
"//src/aria/listbox",
1314
"//src/aria/private",
1415
"//src/cdk/bidi",
1516
],
1617
)
18+
19+
ts_project(
20+
name = "unit_test_sources",
21+
testonly = True,
22+
srcs = glob(
23+
["**/*.spec.ts"],
24+
),
25+
deps = [
26+
":simple-combobox",
27+
"//:node_modules/@angular/common",
28+
"//:node_modules/@angular/core",
29+
"//:node_modules/@angular/platform-browser",
30+
"//src/aria/listbox",
31+
"//src/aria/tree",
32+
"//src/cdk/overlay",
33+
"//src/cdk/testing/private",
34+
],
35+
)
36+
37+
ng_web_test_suite(
38+
name = "unit_tests",
39+
deps = [":unit_test_sources"],
40+
)

0 commit comments

Comments
 (0)