Skip to content

Commit f287cd6

Browse files
authored
fix(aria/combobox): dialog popup support (#32279)
* fix(aria/combobox): dialog popup support * fixup! fix(aria/combobox): dialog popup support * fixup! fix(aria/combobox): dialog popup support * fixup! fix(aria/combobox): dialog popup support * fixup! fix(aria/combobox): dialog popup support * fixup! fix(aria/combobox): dialog popup support * fixup! fix(aria/combobox): dialog popup support
1 parent e093077 commit f287cd6

File tree

14 files changed

+737
-86
lines changed

14 files changed

+737
-86
lines changed

src/aria/combobox/combobox.ts

Lines changed: 76 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
ComboboxPattern,
2626
ComboboxListboxControls,
2727
ComboboxTreeControls,
28+
ComboboxDialogPattern,
2829
} from '@angular/aria/private';
2930
import {Directionality} from '@angular/cdk/bidi';
3031
import {toSignal} from '@angular/core/rxjs-interop';
@@ -84,7 +85,12 @@ export class Combobox<V> {
8485
readonly firstMatch = input<V | undefined>(undefined);
8586

8687
/** Whether the combobox is expanded. */
87-
readonly expanded = computed(() => this._pattern.expanded());
88+
readonly expanded = computed(() => this.alwaysExpanded() || this._pattern.expanded());
89+
90+
// TODO: Maybe make expanded a signal that can be passed in?
91+
// Or an "always expanded" option?
92+
93+
readonly alwaysExpanded = input(false);
8894

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

104110
constructor() {
105111
afterRenderEffect(() => {
106-
if (!this._deferredContentAware?.contentVisible() && this._pattern.isFocused()) {
112+
if (this.alwaysExpanded()) {
113+
this._pattern.expanded.set(true);
114+
}
115+
});
116+
117+
afterRenderEffect(() => {
118+
if (
119+
!this._deferredContentAware?.contentVisible() &&
120+
(this._pattern.isFocused() || this.alwaysExpanded())
121+
) {
107122
this._deferredContentAware?.contentVisible.set(true);
108123
}
109124
});
@@ -146,10 +161,15 @@ export class ComboboxInput {
146161
);
147162
this.combobox._pattern.inputs.inputValue = this.value;
148163

164+
const controls = this.combobox.popup()?.controls();
165+
if (controls instanceof ComboboxDialogPattern) {
166+
return;
167+
}
168+
149169
/** Focuses & selects the first item in the combobox if the user changes the input value. */
150170
afterRenderEffect(() => {
151171
this.value();
152-
this.combobox.popup()?.controls()?.items();
172+
controls?.items();
153173
untracked(() => this.combobox._pattern.onFilter());
154174
});
155175
}
@@ -172,6 +192,58 @@ export class ComboboxPopup<V> {
172192

173193
/** The controls the popup exposes to the combobox. */
174194
readonly controls = signal<
175-
ComboboxListboxControls<any, V> | ComboboxTreeControls<any, V> | undefined
195+
| ComboboxListboxControls<any, V>
196+
| ComboboxTreeControls<any, V>
197+
| ComboboxDialogPattern
198+
| undefined
176199
>(undefined);
177200
}
201+
202+
@Directive({
203+
selector: 'dialog[ngComboboxDialog]',
204+
exportAs: 'ngComboboxDialog',
205+
host: {
206+
'[attr.data-open]': 'combobox._pattern.expanded()',
207+
'(keydown)': '_pattern.onKeydown($event)',
208+
'(click)': '_pattern.onClick($event)',
209+
},
210+
hostDirectives: [ComboboxPopup],
211+
})
212+
export class ComboboxDialog {
213+
/** The dialog element. */
214+
readonly element = inject(ElementRef<HTMLDialogElement>);
215+
216+
/** The combobox that the dialog belongs to. */
217+
readonly combobox = inject(Combobox);
218+
219+
/** A reference to the parent combobox popup, if one exists. */
220+
private readonly _popup = inject<ComboboxPopup<unknown>>(ComboboxPopup, {
221+
optional: true,
222+
});
223+
224+
_pattern: ComboboxDialogPattern;
225+
226+
constructor() {
227+
this._pattern = new ComboboxDialogPattern({
228+
id: () => '',
229+
element: () => this.element.nativeElement,
230+
combobox: this.combobox._pattern,
231+
});
232+
233+
if (this._popup) {
234+
this._popup.controls.set(this._pattern);
235+
}
236+
237+
afterRenderEffect(() => {
238+
if (this.element) {
239+
this.combobox._pattern.expanded()
240+
? this.element.nativeElement.showModal()
241+
: this.element.nativeElement.close();
242+
}
243+
});
244+
}
245+
246+
close() {
247+
this._popup?.combobox?._pattern.close();
248+
}
249+
}

src/aria/combobox/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,10 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
export {Combobox, ComboboxInput, ComboboxPopup, ComboboxPopupContainer} from './combobox';
9+
export {
10+
Combobox,
11+
ComboboxDialog,
12+
ComboboxInput,
13+
ComboboxPopup,
14+
ComboboxPopupContainer,
15+
} from './combobox';

src/aria/listbox/listbox.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ import {ComboboxPopup} from '../combobox';
5656
'(pointerdown)': '_pattern.onPointerdown($event)',
5757
'(focusin)': 'onFocus()',
5858
},
59-
hostDirectives: [{directive: ComboboxPopup}],
59+
hostDirectives: [ComboboxPopup],
6060
})
6161
export class Listbox<V> {
6262
/** A unique identifier for the listbox. */
@@ -187,6 +187,11 @@ export class Listbox<V> {
187187
scrollActiveItemIntoView(options: ScrollIntoViewOptions = {block: 'nearest'}) {
188188
this._pattern.inputs.activeItem()?.element()?.scrollIntoView(options);
189189
}
190+
191+
/** Navigates to the first item in the listbox. */
192+
gotoFirst() {
193+
this._pattern.listBehavior.first();
194+
}
190195
}
191196

192197
/** A selectable option in a Listbox. */

src/aria/private/combobox/combobox.spec.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ function getComboboxPattern(
106106
filterMode: signal(inputs.filterMode ?? 'manual'),
107107
firstMatch,
108108
inputValue,
109+
alwaysExpanded: signal(false),
109110
});
110111

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

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

403+
expect(listbox.inputs.value()).toEqual(['Apple']);
402404
type('Appl', {backspace: true});
403-
combobox.onInput(new InputEvent('input', {inputType: 'deleteContentBackward'}));
404-
405-
expect(listbox.getSelectedItems().length).toBe(0);
405+
expect(listbox.inputs.value()).toEqual(['Apple']);
406+
combobox.onKeydown(escape());
406407
expect(listbox.inputs.value()).toEqual([]);
407408
});
408409

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

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

766-
type('Appl', {backspace: true});
767-
768-
expect(tree.getSelectedItems().length).toBe(0);
767+
expect(tree.inputs.value()).toEqual(['Fruit']);
768+
type('Frui', {backspace: true});
769+
expect(tree.inputs.value()).toEqual(['Fruit']);
770+
combobox.onKeydown(escape());
769771
expect(tree.inputs.value()).toEqual([]);
770772
});
771773

0 commit comments

Comments
 (0)