Skip to content

Commit 3c6075a

Browse files
committed
fix(aria/combobox): add missing apis
1 parent f9d3cde commit 3c6075a

File tree

4 files changed

+66
-7
lines changed

4 files changed

+66
-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 isDisabled = input(false);
77+
78+
/** Whether the combobox is read-only. */
79+
readonly isReadonly = 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.isDisabled,
89+
readonly: this.isReadonly,
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: 33 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);
@@ -204,16 +215,24 @@ export class ComboboxPattern<T extends ListItem<V>, V> {
204215

205216
/** Handles keydown events for the combobox. */
206217
onKeydown(event: KeyboardEvent) {
207-
this.keydown().handle(event);
218+
if (!this.inputs.disabled() && !this.inputs.readonly()) {
219+
this.keydown().handle(event);
220+
}
208221
}
209222

210223
/** Handles pointerup events for the combobox. */
211224
onPointerup(event: PointerEvent) {
212-
this.pointerup().handle(event);
225+
if (!this.inputs.disabled() && !this.inputs.readonly()) {
226+
this.pointerup().handle(event);
227+
}
213228
}
214229

215230
/** Handles input events for the combobox. */
216231
onInput(event: Event) {
232+
if (this.inputs.disabled() || this.inputs.readonly()) {
233+
return;
234+
}
235+
217236
const inputEl = this.inputs.inputEl();
218237

219238
if (!inputEl) {
@@ -233,12 +252,17 @@ export class ComboboxPattern<T extends ListItem<V>, V> {
233252
}
234253
}
235254

255+
/** Handles focus in events for the combobox. */
236256
onFocusIn() {
237257
this.isFocused.set(true);
238258
}
239259

240260
/** Handles focus out events for the combobox. */
241261
onFocusOut(event: FocusEvent) {
262+
if (this.inputs.disabled() || this.inputs.readonly()) {
263+
return;
264+
}
265+
242266
if (
243267
!(event.relatedTarget instanceof HTMLElement) ||
244268
!this.inputs.containerEl()?.contains(event.relatedTarget)
@@ -261,6 +285,7 @@ export class ComboboxPattern<T extends ListItem<V>, V> {
261285
}
262286
}
263287

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

303+
/** Handles filtering logic for the combobox. */
278304
onFilter() {
279305
// TODO(wagnermaciel)
280306
// When the user first interacts with the combobox, the popup will lazily render for the first
@@ -315,6 +341,7 @@ export class ComboboxPattern<T extends ListItem<V>, V> {
315341
}
316342
}
317343

344+
/** Highlights the currently selected item in the combobox. */
318345
highlight() {
319346
const inputEl = this.inputs.inputEl();
320347
const item = this.inputs.popupControls()?.getSelectedItem();
@@ -374,11 +401,13 @@ export class ComboboxPattern<T extends ListItem<V>, V> {
374401
this._navigate(() => this.inputs.popupControls()?.last());
375402
}
376403

404+
/** Collapses the currently focused item in the combobox. */
377405
collapseItem() {
378406
const controls = this.inputs.popupControls() as ComboboxTreeControls<T, V>;
379407
this._navigate(() => controls?.collapseItem());
380408
}
381409

410+
/** Expands the currently focused item in the combobox. */
382411
expandItem() {
383412
const controls = this.inputs.popupControls() as ComboboxTreeControls<T, V>;
384413
this._navigate(() => controls?.expandItem());

0 commit comments

Comments
 (0)