Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
80 changes: 76 additions & 4 deletions src/aria/combobox/combobox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
ComboboxPattern,
ComboboxListboxControls,
ComboboxTreeControls,
ComboboxDialogPattern,
} from '@angular/aria/private';
import {Directionality} from '@angular/cdk/bidi';
import {toSignal} from '@angular/core/rxjs-interop';
Expand Down Expand Up @@ -84,7 +85,12 @@ export class Combobox<V> {
readonly firstMatch = input<V | undefined>(undefined);

/** Whether the combobox is expanded. */
readonly expanded = computed(() => this._pattern.expanded());
readonly expanded = computed(() => this.alwaysExpanded() || this._pattern.expanded());

// TODO: Maybe make expanded a signal that can be passed in?
// Or an "always expanded" option?

readonly alwaysExpanded = input(false);

/** Input element connected to the combobox, if any. */
readonly inputElement = computed(() => this._pattern.inputs.inputEl());
Expand All @@ -103,7 +109,16 @@ export class Combobox<V> {

constructor() {
afterRenderEffect(() => {
if (!this._deferredContentAware?.contentVisible() && this._pattern.isFocused()) {
if (this.alwaysExpanded()) {
this._pattern.expanded.set(true);
}
});

afterRenderEffect(() => {
if (
!this._deferredContentAware?.contentVisible() &&
(this._pattern.isFocused() || this.alwaysExpanded())
) {
this._deferredContentAware?.contentVisible.set(true);
}
});
Expand Down Expand Up @@ -146,10 +161,15 @@ export class ComboboxInput {
);
this.combobox._pattern.inputs.inputValue = this.value;

const controls = this.combobox.popup()?.controls();
if (controls instanceof ComboboxDialogPattern) {
return;
}

/** Focuses & selects the first item in the combobox if the user changes the input value. */
afterRenderEffect(() => {
this.value();
this.combobox.popup()?.controls()?.items();
controls?.items();
untracked(() => this.combobox._pattern.onFilter());
});
}
Expand All @@ -172,6 +192,58 @@ export class ComboboxPopup<V> {

/** The controls the popup exposes to the combobox. */
readonly controls = signal<
ComboboxListboxControls<any, V> | ComboboxTreeControls<any, V> | undefined
| ComboboxListboxControls<any, V>
| ComboboxTreeControls<any, V>
| ComboboxDialogPattern
| undefined
>(undefined);
}

@Directive({
selector: 'dialog[ngComboboxDialog]',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the dialog meant to be positioned relative to a trigger element or will it always be centered on the page?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dialog can either be centered or have special positioning depending on the use case. The positioning of the dialog is left up to developers

exportAs: 'ngComboboxDialog',
host: {
'[attr.data-open]': 'combobox._pattern.expanded()',
'(keydown)': '_pattern.onKeydown($event)',
'(click)': '_pattern.onClick($event)',
},
hostDirectives: [ComboboxPopup],
})
export class ComboboxDialog {
/** The dialog element. */
readonly element = inject(ElementRef<HTMLDialogElement>);

/** The combobox that the dialog belongs to. */
readonly combobox = inject(Combobox);

/** A reference to the parent combobox popup, if one exists. */
private readonly _popup = inject<ComboboxPopup<unknown>>(ComboboxPopup, {
optional: true,
});

_pattern: ComboboxDialogPattern;

constructor() {
this._pattern = new ComboboxDialogPattern({
id: () => '',
element: () => this.element.nativeElement,
combobox: this.combobox._pattern,
});

if (this._popup) {
this._popup.controls.set(this._pattern);
}

afterRenderEffect(() => {
if (this.element) {
this.combobox._pattern.expanded()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that by default native dialogs can be closed by clicking outside. I think we might end up in a situation where the expanded state of the pattern isn't in sync with the dialog's expanded state.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is handled by the ComboboxDialogPattern, but in an unintuitive way. I went back and added a comment to clarify. The ComboboxDialogPattern has an onClick listener that handles this case. If the user clicks outside of the dialog, the click event fires on the dialog element.

? this.element.nativeElement.showModal()
: this.element.nativeElement.close();
}
});
}

close() {
this._popup?.combobox?._pattern.close();
}
}
8 changes: 7 additions & 1 deletion src/aria/combobox/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,10 @@
* found in the LICENSE file at https://angular.dev/license
*/

export {Combobox, ComboboxInput, ComboboxPopup, ComboboxPopupContainer} from './combobox';
export {
Combobox,
ComboboxDialog,
ComboboxInput,
ComboboxPopup,
ComboboxPopupContainer,
} from './combobox';
7 changes: 6 additions & 1 deletion src/aria/listbox/listbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ import {ComboboxPopup} from '../combobox';
'(pointerdown)': '_pattern.onPointerdown($event)',
'(focusin)': 'onFocus()',
},
hostDirectives: [{directive: ComboboxPopup}],
hostDirectives: [ComboboxPopup],
})
export class Listbox<V> {
/** A unique identifier for the listbox. */
Expand Down Expand Up @@ -187,6 +187,11 @@ export class Listbox<V> {
scrollActiveItemIntoView(options: ScrollIntoViewOptions = {block: 'nearest'}) {
this._pattern.inputs.activeItem()?.element()?.scrollIntoView(options);
}

/** Navigates to the first item in the listbox. */
gotoFirst() {
this._pattern.listBehavior.first();
}
}

/** A selectable option in a Listbox. */
Expand Down
18 changes: 10 additions & 8 deletions src/aria/private/combobox/combobox.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ function getComboboxPattern(
filterMode: signal(inputs.filterMode ?? 'manual'),
firstMatch,
inputValue,
alwaysExpanded: signal(false),
});

return {combobox, inputEl, containerEl, firstMatch, inputValue};
Expand Down Expand Up @@ -395,14 +396,14 @@ describe('Combobox with Listbox Pattern', () => {
expect(listbox.inputs.value()).toEqual(['Apple']);
});

it('should deselect on backspace', () => {
it('should deselect on close if the input text does not match any options', () => {
combobox.onKeydown(down());
combobox.onKeydown(enter());

expect(listbox.inputs.value()).toEqual(['Apple']);
type('Appl', {backspace: true});
combobox.onInput(new InputEvent('input', {inputType: 'deleteContentBackward'}));

expect(listbox.getSelectedItems().length).toBe(0);
expect(listbox.inputs.value()).toEqual(['Apple']);
combobox.onKeydown(escape());
expect(listbox.inputs.value()).toEqual([]);
});

Expand Down Expand Up @@ -759,13 +760,14 @@ describe('Combobox with Tree Pattern', () => {
expect(tree.inputs.value()).toEqual(['Apple']);
});

it('should deselect on backspace', () => {
it('should deselect on close if the input text does not match any options', () => {
combobox.onKeydown(down());
combobox.onKeydown(enter());

type('Appl', {backspace: true});

expect(tree.getSelectedItems().length).toBe(0);
expect(tree.inputs.value()).toEqual(['Fruit']);
type('Frui', {backspace: true});
expect(tree.inputs.value()).toEqual(['Fruit']);
combobox.onKeydown(escape());
expect(tree.inputs.value()).toEqual([]);
});

Expand Down
Loading
Loading