diff --git a/src/aria/combobox/BUILD.bazel b/src/aria/combobox/BUILD.bazel index be11bac48c0c..410b5174c435 100644 --- a/src/aria/combobox/BUILD.bazel +++ b/src/aria/combobox/BUILD.bazel @@ -12,6 +12,7 @@ ng_project( "//:node_modules/@angular/core", "//src/aria/deferred-content", "//src/aria/ui-patterns", + "//src/cdk/bidi", ], ) diff --git a/src/aria/combobox/combobox.ts b/src/aria/combobox/combobox.ts index de5ed5d008d8..2c0af9581db7 100644 --- a/src/aria/combobox/combobox.ts +++ b/src/aria/combobox/combobox.ts @@ -24,6 +24,8 @@ import { ComboboxListboxControls, ComboboxTreeControls, } from '@angular/aria/ui-patterns'; +import {Directionality} from '@angular/cdk/bidi'; +import {toSignal} from '@angular/core/rxjs-interop'; @Directive({ selector: '[ngCombobox]', @@ -44,6 +46,14 @@ import { }, }) export class Combobox { + /** The directionality (LTR / RTL) context for the application (or a subtree of it). */ + private readonly _directionality = inject(Directionality); + + /** A signal wrapper for directionality. */ + protected textDirection = toSignal(this._directionality.change, { + initialValue: this._directionality.value, + }); + /** The element that the combobox is attached to. */ private readonly _elementRef = inject(ElementRef); @@ -59,15 +69,24 @@ export class Combobox { /** Whether the combobox is focused. */ readonly isFocused = signal(false); - /** The value of the first matching item in the popup. */ - firstMatch = input(undefined); - /** Whether the listbox has received focus yet. */ private _hasBeenFocused = signal(false); + /** Whether the combobox is disabled. */ + readonly disabled = input(false); + + /** Whether the combobox is read-only. */ + readonly readonly = input(false); + + /** The value of the first matching item in the popup. */ + readonly firstMatch = input(undefined); + /** The combobox ui pattern. */ readonly pattern = new ComboboxPattern({ ...this, + textDirection: this.textDirection, + disabled: this.disabled, + readonly: this.readonly, inputValue: signal(''), inputEl: signal(undefined), containerEl: () => this._elementRef.nativeElement, diff --git a/src/aria/ui-patterns/combobox/combobox.spec.ts b/src/aria/ui-patterns/combobox/combobox.spec.ts index 8a67d3f0ce70..2a68aac363c1 100644 --- a/src/aria/ui-patterns/combobox/combobox.spec.ts +++ b/src/aria/ui-patterns/combobox/combobox.spec.ts @@ -97,6 +97,9 @@ function getComboboxPattern( const inputValue = signal(''); const combobox = new ComboboxPattern({ + disabled: signal(inputs.disabled ?? false), + readonly: signal(inputs.readonly ?? false), + textDirection: signal(inputs.textDirection ?? 'ltr'), popupControls: signal(undefined), // will be set later inputEl, containerEl, @@ -349,6 +352,13 @@ describe('Combobox with Listbox Pattern', () => { expect(combobox.expanded()).toBe(true); }); + + it('should not expand when disabled', () => { + const {combobox, inputEl} = getPatterns({disabled: true}); + expect(combobox.expanded()).toBe(false); + combobox.onPointerup(clickInput(inputEl)); + expect(combobox.expanded()).toBe(false); + }); }); describe('Selection', () => { diff --git a/src/aria/ui-patterns/combobox/combobox.ts b/src/aria/ui-patterns/combobox/combobox.ts index cb2c356822cc..9d8163d68db5 100644 --- a/src/aria/ui-patterns/combobox/combobox.ts +++ b/src/aria/ui-patterns/combobox/combobox.ts @@ -30,6 +30,15 @@ export interface ComboboxInputs, V> { /** The value of the first matching item in the popup. */ firstMatch: SignalLike; + + /** Whether the combobox is disabled. */ + disabled: SignalLike; + + /** Whether the combobox is read-only. */ + readonly: SignalLike; + + /** Whether the combobox is in a right-to-left context. */ + textDirection: SignalLike<'rtl' | 'ltr'>; } /** An interface that allows combobox popups to expose the necessary controls for the combobox. */ @@ -119,10 +128,12 @@ export class ComboboxPattern, V> { isFocused = signal(false); /** The key used to navigate to the previous item in the list. */ - expandKey = computed(() => 'ArrowRight'); // TODO: RTL support. + expandKey = computed(() => (this.inputs.textDirection() === 'rtl' ? 'ArrowLeft' : 'ArrowRight')); /** The key used to navigate to the next item in the list. */ - collapseKey = computed(() => 'ArrowLeft'); // TODO: RTL support. + collapseKey = computed(() => + this.inputs.textDirection() === 'rtl' ? 'ArrowRight' : 'ArrowLeft', + ); /** The ID of the popup associated with the combobox. */ popupId = computed(() => this.inputs.popupControls()?.id() || null); @@ -133,6 +144,9 @@ export class ComboboxPattern, V> { /** The ARIA role of the popup associated with the combobox. */ hasPopup = computed(() => this.inputs.popupControls()?.role() || null); + /** Whether the combobox is interactive. */ + isInteractive = computed(() => !this.inputs.disabled() && !this.inputs.readonly()); + /** The keydown event manager for the combobox. */ keydown = computed(() => { if (!this.expanded()) { @@ -204,16 +218,24 @@ export class ComboboxPattern, V> { /** Handles keydown events for the combobox. */ onKeydown(event: KeyboardEvent) { - this.keydown().handle(event); + if (this.isInteractive()) { + this.keydown().handle(event); + } } /** Handles pointerup events for the combobox. */ onPointerup(event: PointerEvent) { - this.pointerup().handle(event); + if (this.isInteractive()) { + this.pointerup().handle(event); + } } /** Handles input events for the combobox. */ onInput(event: Event) { + if (!this.isInteractive()) { + return; + } + const inputEl = this.inputs.inputEl(); if (!inputEl) { @@ -233,12 +255,17 @@ export class ComboboxPattern, V> { } } + /** Handles focus in events for the combobox. */ onFocusIn() { this.isFocused.set(true); } /** Handles focus out events for the combobox. */ onFocusOut(event: FocusEvent) { + if (this.inputs.disabled() || this.inputs.readonly()) { + return; + } + if ( !(event.relatedTarget instanceof HTMLElement) || !this.inputs.containerEl()?.contains(event.relatedTarget) @@ -261,6 +288,7 @@ export class ComboboxPattern, V> { } } + /** The first matching item in the combobox. */ firstMatch = computed(() => { // TODO(wagnermaciel): Consider whether we should not provide this default behavior for the // 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, V> { .find(i => i.value() === this.inputs.firstMatch()); }); + /** Handles filtering logic for the combobox. */ onFilter() { // TODO(wagnermaciel) // When the user first interacts with the combobox, the popup will lazily render for the first @@ -315,6 +344,7 @@ export class ComboboxPattern, V> { } } + /** Highlights the currently selected item in the combobox. */ highlight() { const inputEl = this.inputs.inputEl(); const item = this.inputs.popupControls()?.getSelectedItem(); @@ -374,11 +404,13 @@ export class ComboboxPattern, V> { this._navigate(() => this.inputs.popupControls()?.last()); } + /** Collapses the currently focused item in the combobox. */ collapseItem() { const controls = this.inputs.popupControls() as ComboboxTreeControls; this._navigate(() => controls?.collapseItem()); } + /** Expands the currently focused item in the combobox. */ expandItem() { const controls = this.inputs.popupControls() as ComboboxTreeControls; this._navigate(() => controls?.expandItem());