Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

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 isDisabled = input(false);

/** Whether the combobox is read-only. */
readonly isReadonly = 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.isDisabled,
readonly: this.isReadonly,
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
37 changes: 33 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 Down Expand Up @@ -204,16 +215,24 @@ export class ComboboxPattern<T extends ListItem<V>, V> {

/** Handles keydown events for the combobox. */
onKeydown(event: KeyboardEvent) {
this.keydown().handle(event);
if (!this.inputs.disabled() && !this.inputs.readonly()) {
this.keydown().handle(event);
}
}

/** Handles pointerup events for the combobox. */
onPointerup(event: PointerEvent) {
this.pointerup().handle(event);
if (!this.inputs.disabled() && !this.inputs.readonly()) {
this.pointerup().handle(event);
}
}

/** Handles input events for the combobox. */
onInput(event: Event) {
if (this.inputs.disabled() || this.inputs.readonly()) {
return;
}

const inputEl = this.inputs.inputEl();

if (!inputEl) {
Expand All @@ -233,12 +252,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 +285,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 +300,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 +341,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 +401,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