Skip to content

Commit 32bf09a

Browse files
committed
refactor(aria/combobox): alt approach WIP
1 parent eccaecd commit 32bf09a

27 files changed

+1631
-3
lines changed

src/aria/private/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ ts_project(
1717
"//src/aria/private/grid",
1818
"//src/aria/private/listbox",
1919
"//src/aria/private/menu",
20+
"//src/aria/private/simple-combobox",
2021
"//src/aria/private/tabs",
2122
"//src/aria/private/toolbar",
2223
"//src/aria/private/tree",

src/aria/private/behaviors/list-focus/list-focus.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,9 +106,9 @@ export class ListFocus<T extends ListFocusItem> {
106106
this.inputs.activeItem.set(item);
107107

108108
if (opts?.focusElement || opts?.focusElement === undefined) {
109-
this.inputs.focusMode() === 'roving'
110-
? item.element()?.focus()
111-
: this.inputs.element()?.focus();
109+
if (this.inputs.focusMode() === 'roving') {
110+
item.element()?.focus();
111+
}
112112
}
113113

114114
return true;

src/aria/private/public-api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,4 @@ export * from './grid/row';
2525
export * from './grid/cell';
2626
export * from './grid/widget';
2727
export * from './deferred-content';
28+
export * from './simple-combobox/simple-combobox';
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
load("//tools:defaults.bzl", "ts_project")
2+
3+
package(default_visibility = ["//visibility:public"])
4+
5+
ts_project(
6+
name = "simple-combobox",
7+
srcs = glob(
8+
["**/*.ts"],
9+
exclude = ["**/*.spec.ts"],
10+
),
11+
deps = [
12+
"//:node_modules/@angular/core",
13+
"//src/aria/private/behaviors/event-manager",
14+
"//src/aria/private/behaviors/expansion",
15+
"//src/aria/private/behaviors/list",
16+
"//src/aria/private/behaviors/signal-like",
17+
],
18+
)
Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
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 {KeyboardEventManager, PointerEventManager} from '../behaviors/event-manager';
10+
import {computed, signal, untracked} from '@angular/core';
11+
import {SignalLike, WritableSignalLike} from '../behaviors/signal-like/signal-like';
12+
import {ExpansionItem} from '../behaviors/expansion/expansion';
13+
14+
/** Represents the required inputs for a simple combobox. */
15+
export interface SimpleComboboxInputs extends ExpansionItem {
16+
/** The value of the combobox. */
17+
value: WritableSignalLike<string>;
18+
19+
/** The element that the combobox is attached to. */
20+
element: SignalLike<HTMLElement>;
21+
22+
/** The popup associated with the combobox. */
23+
popup: SignalLike<SimpleComboboxPopupPattern | undefined>;
24+
25+
/** An inline suggestion to be displayed in the input. */
26+
inlineSuggestion: SignalLike<string | undefined>;
27+
28+
/** Whether the combobox is disabled. */
29+
disabled: SignalLike<boolean>;
30+
}
31+
32+
/** Controls the state of a simple combobox. */
33+
export class SimpleComboboxPattern {
34+
/** Whether the combobox is expanded. */
35+
readonly expanded: WritableSignalLike<boolean>;
36+
37+
/** The value of the combobox. */
38+
readonly value: WritableSignalLike<string>;
39+
40+
/** The element that the combobox is attached to. */
41+
readonly element = () => this.inputs.element();
42+
43+
/** Whether the combobox is disabled. */
44+
readonly disabled = () => this.inputs.disabled();
45+
46+
/** An inline suggestion to be displayed in the input. */
47+
readonly inlineSuggestion = () => this.inputs.inlineSuggestion();
48+
49+
/** The ID of the active descendant in the popup. */
50+
readonly activeDescendant = computed(() => this.inputs.popup()?.activeDescendant());
51+
52+
/** The ID of the popup. */
53+
readonly popupId = computed(() => this.inputs.popup()?.popupId());
54+
55+
/** The type of the popup. */
56+
readonly popupType = computed(() => this.inputs.popup()?.popupType());
57+
58+
/** The autocomplete behavior of the combobox. */
59+
readonly autocomplete = computed<'none' | 'inline' | 'list' | 'both'>(() => {
60+
const hasPopup = !!this.inputs.popup();
61+
const hasInlineSuggestion = !!this.inlineSuggestion();
62+
if (hasPopup && hasInlineSuggestion) {
63+
return 'both';
64+
}
65+
if (hasPopup) {
66+
return 'list';
67+
}
68+
if (hasInlineSuggestion) {
69+
return 'inline';
70+
}
71+
return 'none';
72+
});
73+
74+
/** A relay for keyboard events to the popup. */
75+
readonly keyboardEventRelay = signal<KeyboardEvent | undefined>(undefined);
76+
77+
/** Whether the combobox is focused. */
78+
readonly isFocused = signal(false);
79+
80+
/** Whether the most recent input event was a deletion. */
81+
readonly isDeleting = signal(false);
82+
83+
/** Whether the combobox is editable (i.e., an input or textarea). */
84+
readonly isEditable = computed(
85+
() =>
86+
this.element().tagName.toLowerCase() === 'input' ||
87+
this.element().tagName.toLowerCase() === 'textarea',
88+
);
89+
90+
/** The keydown event manager for the combobox. */
91+
keydown = computed(() => {
92+
const manager = new KeyboardEventManager();
93+
94+
if (!this.expanded()) {
95+
manager.on('ArrowDown', () => this.expanded.set(true));
96+
97+
if (!this.isEditable()) {
98+
manager.on(/^(Enter| )$/, () => this.expanded.set(true));
99+
}
100+
101+
return manager;
102+
}
103+
104+
manager
105+
.on(
106+
'ArrowLeft',
107+
e => {
108+
this.keyboardEventRelay.set(e);
109+
},
110+
{preventDefault: this.popupType() !== 'listbox'},
111+
)
112+
.on(
113+
'ArrowRight',
114+
e => {
115+
this.keyboardEventRelay.set(e);
116+
},
117+
{preventDefault: this.popupType() !== 'listbox'},
118+
)
119+
.on('ArrowUp', e => this.keyboardEventRelay.set(e))
120+
.on('ArrowDown', e => this.keyboardEventRelay.set(e))
121+
.on('Home', e => this.keyboardEventRelay.set(e))
122+
.on('End', e => this.keyboardEventRelay.set(e))
123+
.on('Enter', e => this.keyboardEventRelay.set(e))
124+
.on('PageUp', e => this.keyboardEventRelay.set(e))
125+
.on('PageDown', e => this.keyboardEventRelay.set(e))
126+
.on('Escape', () => this.expanded.set(false));
127+
128+
if (!this.isEditable()) {
129+
manager
130+
.on(' ', e => this.keyboardEventRelay.set(e))
131+
.on(/^.$/, e => {
132+
this.keyboardEventRelay.set(e);
133+
});
134+
}
135+
136+
return manager;
137+
});
138+
139+
/** The pointerdown event manager for the combobox. */
140+
pointerdown = computed(() => {
141+
const manager = new PointerEventManager();
142+
143+
if (this.isEditable()) return manager;
144+
145+
manager.on(() => this.expanded.update(v => !v));
146+
147+
return manager;
148+
});
149+
150+
constructor(readonly inputs: SimpleComboboxInputs) {
151+
this.expanded = inputs.expanded;
152+
this.value = inputs.value;
153+
}
154+
155+
/** Handles keydown events for the combobox. */
156+
onKeydown(event: KeyboardEvent) {
157+
if (!this.inputs.disabled()) {
158+
this.keydown().handle(event);
159+
}
160+
}
161+
162+
/** Handles pointerdown events for the combobox. */
163+
onPointerdown(event: PointerEvent) {
164+
if (!this.disabled()) {
165+
this.pointerdown().handle(event);
166+
}
167+
}
168+
169+
/** Handles focus in events for the combobox. */
170+
onFocusin() {
171+
this.isFocused.set(true);
172+
}
173+
174+
/** Handles focus out events for the combobox. */
175+
onFocusout(event: FocusEvent) {
176+
const focusTarget = event.relatedTarget as Element | null;
177+
if (this.element().contains(focusTarget)) return;
178+
179+
this.isFocused.set(false);
180+
}
181+
182+
/** Handles input events for the combobox. */
183+
onInput(event: Event) {
184+
if (!(event.target instanceof HTMLInputElement)) return;
185+
if (this.disabled()) return;
186+
187+
this.expanded.set(true);
188+
this.value.set(event.target.value);
189+
this.isDeleting.set(event instanceof InputEvent && !!event.inputType.match(/^delete/));
190+
}
191+
192+
/** Highlights the currently selected item in the combobox. */
193+
highlightEffect() {
194+
const value = this.value();
195+
const inlineSuggestion = this.inlineSuggestion();
196+
197+
const isDeleting = untracked(() => this.isDeleting());
198+
const isFocused = untracked(() => this.isFocused());
199+
const isExpanded = untracked(() => this.expanded());
200+
201+
if (!inlineSuggestion || !isFocused || !isExpanded || isDeleting) return;
202+
203+
const inputEl = this.element() as HTMLInputElement;
204+
const isHighlightable = inlineSuggestion.toLowerCase().startsWith(value.toLowerCase());
205+
206+
if (isHighlightable) {
207+
inputEl.value = value + inlineSuggestion.slice(value.length);
208+
inputEl.setSelectionRange(value.length, inlineSuggestion.length);
209+
}
210+
}
211+
212+
/** Relays keyboard events to the popup. */
213+
keyboardEventRelayEffect() {
214+
const event = this.keyboardEventRelay();
215+
if (event === undefined) return;
216+
217+
const popup = untracked(() => this.inputs.popup());
218+
const popupExpanded = untracked(() => this.expanded());
219+
if (popupExpanded) {
220+
popup?.controlTarget()?.dispatchEvent(event);
221+
}
222+
}
223+
224+
/** Closes the popup when focus leaves the combobox and popup. */
225+
closePopupOnBlurEffect() {
226+
const expanded = this.expanded();
227+
const comboboxFocused = this.isFocused();
228+
const popupFocused = !!this.inputs.popup()?.isFocused();
229+
if (expanded && !comboboxFocused && !popupFocused) {
230+
this.expanded.set(false);
231+
}
232+
}
233+
}
234+
235+
/** Represents the required inputs for a simple combobox popup. */
236+
export interface SimpleComboboxPopupInputs {
237+
/** The type of the popup. */
238+
popupType: SignalLike<'listbox' | 'tree' | 'grid' | 'dialog'>;
239+
240+
/** The element that serves as the control target for the popup. */
241+
controlTarget: SignalLike<HTMLElement | undefined>;
242+
243+
/** The ID of the active descendant in the popup. */
244+
activeDescendant: SignalLike<string | undefined>;
245+
246+
/** The ID of the popup. */
247+
popupId: SignalLike<string | undefined>;
248+
}
249+
250+
/** Controls the state of a simple combobox popup. */
251+
export class SimpleComboboxPopupPattern {
252+
/** The type of the popup. */
253+
readonly popupType = () => this.inputs.popupType();
254+
255+
/** The element that serves as the control target for the popup. */
256+
readonly controlTarget = () => this.inputs.controlTarget();
257+
258+
/** The ID of the active descendant in the popup. */
259+
readonly activeDescendant = () => this.inputs.activeDescendant();
260+
261+
/** The ID of the popup. */
262+
readonly popupId = () => this.inputs.popupId();
263+
264+
/** Whether the popup is focused. */
265+
readonly isFocused = signal(false);
266+
267+
constructor(readonly inputs: SimpleComboboxPopupInputs) {}
268+
269+
/** Handles focus in events for the popup. */
270+
onFocusin() {
271+
this.isFocused.set(true);
272+
}
273+
274+
/** Handles focus out events for the popup. */
275+
onFocusout(event: FocusEvent) {
276+
const focusTarget = event.relatedTarget as Element | null;
277+
if (this.controlTarget()?.contains(focusTarget)) return;
278+
279+
this.isFocused.set(false);
280+
}
281+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
load("//tools:defaults.bzl", "ng_project")
2+
3+
package(default_visibility = ["//visibility:public"])
4+
5+
ng_project(
6+
name = "simple-combobox",
7+
srcs = glob(
8+
["**/*.ts"],
9+
exclude = ["**/*.spec.ts"],
10+
),
11+
deps = [
12+
"//:node_modules/@angular/core",
13+
"//src/aria/private",
14+
"//src/cdk/bidi",
15+
],
16+
)

src/aria/simple-combobox/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 {Combobox, ComboboxPopup, ComboboxWidget} from './simple-combobox';

0 commit comments

Comments
 (0)