Skip to content

Commit 0b03c6e

Browse files
authored
fix(aria/combobox): add missing apis (#32124)
* fix(aria/combobox): add missing apis * fixup! fix(aria/combobox): add missing apis
1 parent ba9f79b commit 0b03c6e

File tree

4 files changed

+69
-7
lines changed

4 files changed

+69
-7
lines changed

src/aria/combobox/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ ng_project(
1212
"//:node_modules/@angular/core",
1313
"//src/aria/deferred-content",
1414
"//src/aria/ui-patterns",
15+
"//src/cdk/bidi",
1516
],
1617
)
1718

src/aria/combobox/combobox.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import {
2424
ComboboxListboxControls,
2525
ComboboxTreeControls,
2626
} from '@angular/aria/ui-patterns';
27+
import {Directionality} from '@angular/cdk/bidi';
28+
import {toSignal} from '@angular/core/rxjs-interop';
2729

2830
@Directive({
2931
selector: '[ngCombobox]',
@@ -44,6 +46,14 @@ import {
4446
},
4547
})
4648
export class Combobox<V> {
49+
/** The directionality (LTR / RTL) context for the application (or a subtree of it). */
50+
private readonly _directionality = inject(Directionality);
51+
52+
/** A signal wrapper for directionality. */
53+
protected textDirection = toSignal(this._directionality.change, {
54+
initialValue: this._directionality.value,
55+
});
56+
4757
/** The element that the combobox is attached to. */
4858
private readonly _elementRef = inject(ElementRef);
4959

@@ -59,15 +69,24 @@ export class Combobox<V> {
5969
/** Whether the combobox is focused. */
6070
readonly isFocused = signal(false);
6171

62-
/** The value of the first matching item in the popup. */
63-
firstMatch = input<V | undefined>(undefined);
64-
6572
/** Whether the listbox has received focus yet. */
6673
private _hasBeenFocused = signal(false);
6774

75+
/** Whether the combobox is disabled. */
76+
readonly disabled = input(false);
77+
78+
/** Whether the combobox is read-only. */
79+
readonly readonly = input(false);
80+
81+
/** The value of the first matching item in the popup. */
82+
readonly firstMatch = input<V | undefined>(undefined);
83+
6884
/** The combobox ui pattern. */
6985
readonly pattern = new ComboboxPattern<any, V>({
7086
...this,
87+
textDirection: this.textDirection,
88+
disabled: this.disabled,
89+
readonly: this.readonly,
7190
inputValue: signal(''),
7291
inputEl: signal(undefined),
7392
containerEl: () => this._elementRef.nativeElement,

src/aria/ui-patterns/combobox/combobox.spec.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,9 @@ function getComboboxPattern(
9797
const inputValue = signal('');
9898

9999
const combobox = new ComboboxPattern<any, string>({
100+
disabled: signal(inputs.disabled ?? false),
101+
readonly: signal(inputs.readonly ?? false),
102+
textDirection: signal(inputs.textDirection ?? 'ltr'),
100103
popupControls: signal(undefined), // will be set later
101104
inputEl,
102105
containerEl,
@@ -349,6 +352,13 @@ describe('Combobox with Listbox Pattern', () => {
349352

350353
expect(combobox.expanded()).toBe(true);
351354
});
355+
356+
it('should not expand when disabled', () => {
357+
const {combobox, inputEl} = getPatterns({disabled: true});
358+
expect(combobox.expanded()).toBe(false);
359+
combobox.onPointerup(clickInput(inputEl));
360+
expect(combobox.expanded()).toBe(false);
361+
});
352362
});
353363

354364
describe('Selection', () => {

src/aria/ui-patterns/combobox/combobox.ts

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,15 @@ export interface ComboboxInputs<T extends ListItem<V>, V> {
3030

3131
/** The value of the first matching item in the popup. */
3232
firstMatch: SignalLike<V | undefined>;
33+
34+
/** Whether the combobox is disabled. */
35+
disabled: SignalLike<boolean>;
36+
37+
/** Whether the combobox is read-only. */
38+
readonly: SignalLike<boolean>;
39+
40+
/** Whether the combobox is in a right-to-left context. */
41+
textDirection: SignalLike<'rtl' | 'ltr'>;
3342
}
3443

3544
/** An interface that allows combobox popups to expose the necessary controls for the combobox. */
@@ -119,10 +128,12 @@ export class ComboboxPattern<T extends ListItem<V>, V> {
119128
isFocused = signal(false);
120129

121130
/** The key used to navigate to the previous item in the list. */
122-
expandKey = computed(() => 'ArrowRight'); // TODO: RTL support.
131+
expandKey = computed(() => (this.inputs.textDirection() === 'rtl' ? 'ArrowLeft' : 'ArrowRight'));
123132

124133
/** The key used to navigate to the next item in the list. */
125-
collapseKey = computed(() => 'ArrowLeft'); // TODO: RTL support.
134+
collapseKey = computed(() =>
135+
this.inputs.textDirection() === 'rtl' ? 'ArrowRight' : 'ArrowLeft',
136+
);
126137

127138
/** The ID of the popup associated with the combobox. */
128139
popupId = computed(() => this.inputs.popupControls()?.id() || null);
@@ -133,6 +144,9 @@ export class ComboboxPattern<T extends ListItem<V>, V> {
133144
/** The ARIA role of the popup associated with the combobox. */
134145
hasPopup = computed(() => this.inputs.popupControls()?.role() || null);
135146

147+
/** Whether the combobox is interactive. */
148+
isInteractive = computed(() => !this.inputs.disabled() && !this.inputs.readonly());
149+
136150
/** The keydown event manager for the combobox. */
137151
keydown = computed(() => {
138152
if (!this.expanded()) {
@@ -204,16 +218,24 @@ export class ComboboxPattern<T extends ListItem<V>, V> {
204218

205219
/** Handles keydown events for the combobox. */
206220
onKeydown(event: KeyboardEvent) {
207-
this.keydown().handle(event);
221+
if (this.isInteractive()) {
222+
this.keydown().handle(event);
223+
}
208224
}
209225

210226
/** Handles pointerup events for the combobox. */
211227
onPointerup(event: PointerEvent) {
212-
this.pointerup().handle(event);
228+
if (this.isInteractive()) {
229+
this.pointerup().handle(event);
230+
}
213231
}
214232

215233
/** Handles input events for the combobox. */
216234
onInput(event: Event) {
235+
if (!this.isInteractive()) {
236+
return;
237+
}
238+
217239
const inputEl = this.inputs.inputEl();
218240

219241
if (!inputEl) {
@@ -233,12 +255,17 @@ export class ComboboxPattern<T extends ListItem<V>, V> {
233255
}
234256
}
235257

258+
/** Handles focus in events for the combobox. */
236259
onFocusIn() {
237260
this.isFocused.set(true);
238261
}
239262

240263
/** Handles focus out events for the combobox. */
241264
onFocusOut(event: FocusEvent) {
265+
if (this.inputs.disabled() || this.inputs.readonly()) {
266+
return;
267+
}
268+
242269
if (
243270
!(event.relatedTarget instanceof HTMLElement) ||
244271
!this.inputs.containerEl()?.contains(event.relatedTarget)
@@ -261,6 +288,7 @@ export class ComboboxPattern<T extends ListItem<V>, V> {
261288
}
262289
}
263290

291+
/** The first matching item in the combobox. */
264292
firstMatch = computed(() => {
265293
// TODO(wagnermaciel): Consider whether we should not provide this default behavior for the
266294
// listbox. Instead, we may want to allow users to have no match so that typing does not focus
@@ -275,6 +303,7 @@ export class ComboboxPattern<T extends ListItem<V>, V> {
275303
.find(i => i.value() === this.inputs.firstMatch());
276304
});
277305

306+
/** Handles filtering logic for the combobox. */
278307
onFilter() {
279308
// TODO(wagnermaciel)
280309
// When the user first interacts with the combobox, the popup will lazily render for the first
@@ -315,6 +344,7 @@ export class ComboboxPattern<T extends ListItem<V>, V> {
315344
}
316345
}
317346

347+
/** Highlights the currently selected item in the combobox. */
318348
highlight() {
319349
const inputEl = this.inputs.inputEl();
320350
const item = this.inputs.popupControls()?.getSelectedItem();
@@ -374,11 +404,13 @@ export class ComboboxPattern<T extends ListItem<V>, V> {
374404
this._navigate(() => this.inputs.popupControls()?.last());
375405
}
376406

407+
/** Collapses the currently focused item in the combobox. */
377408
collapseItem() {
378409
const controls = this.inputs.popupControls() as ComboboxTreeControls<T, V>;
379410
this._navigate(() => controls?.collapseItem());
380411
}
381412

413+
/** Expands the currently focused item in the combobox. */
382414
expandItem() {
383415
const controls = this.inputs.popupControls() as ComboboxTreeControls<T, V>;
384416
this._navigate(() => controls?.expandItem());

0 commit comments

Comments
 (0)