Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/aria/combobox/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ ng_project(
"//:node_modules/@angular/core",
"//src/aria/deferred-content",
"//src/aria/ui-patterns",
"//src/cdk/bidi",
],
)

Expand Down
25 changes: 22 additions & 3 deletions src/aria/combobox/combobox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]',
Expand All @@ -44,6 +46,14 @@ import {
},
})
export class Combobox<V> {
/** 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);

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

/** The value of the first matching item in the popup. */
firstMatch = input<V | undefined>(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<V | undefined>(undefined);

/** The combobox ui pattern. */
readonly pattern = new ComboboxPattern<any, V>({
...this,
textDirection: this.textDirection,
disabled: this.disabled,
readonly: this.readonly,
inputValue: signal(''),
inputEl: signal(undefined),
containerEl: () => this._elementRef.nativeElement,
Expand Down
10 changes: 10 additions & 0 deletions src/aria/ui-patterns/combobox/combobox.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,9 @@ function getComboboxPattern(
const inputValue = signal('');

const combobox = new ComboboxPattern<any, string>({
disabled: signal(inputs.disabled ?? false),
readonly: signal(inputs.readonly ?? false),
textDirection: signal(inputs.textDirection ?? 'ltr'),
popupControls: signal(undefined), // will be set later
inputEl,
containerEl,
Expand Down Expand Up @@ -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', () => {
Expand Down
40 changes: 36 additions & 4 deletions src/aria/ui-patterns/combobox/combobox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@ export interface ComboboxInputs<T extends ListItem<V>, V> {

/** The value of the first matching item in the popup. */
firstMatch: SignalLike<V | undefined>;

/** Whether the combobox is disabled. */
disabled: SignalLike<boolean>;

/** Whether the combobox is read-only. */
readonly: SignalLike<boolean>;

/** 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. */
Expand Down Expand Up @@ -119,10 +128,12 @@ export class ComboboxPattern<T extends ListItem<V>, 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);
Expand All @@ -133,6 +144,9 @@ export class ComboboxPattern<T extends ListItem<V>, 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()) {
Expand Down Expand Up @@ -204,16 +218,24 @@ export class ComboboxPattern<T extends ListItem<V>, 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) {
Expand All @@ -233,12 +255,17 @@ export class ComboboxPattern<T extends ListItem<V>, 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)
Expand All @@ -261,6 +288,7 @@ export class ComboboxPattern<T extends ListItem<V>, 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
Expand All @@ -275,6 +303,7 @@ export class ComboboxPattern<T extends ListItem<V>, 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
Expand Down Expand Up @@ -315,6 +344,7 @@ export class ComboboxPattern<T extends ListItem<V>, V> {
}
}

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

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

/** Expands the currently focused item in the combobox. */
expandItem() {
const controls = this.inputs.popupControls() as ComboboxTreeControls<T, V>;
this._navigate(() => controls?.expandItem());
Expand Down
Loading