diff --git a/goldens/aria/private/index.api.md b/goldens/aria/private/index.api.md index 2c0f92483ac2..68bd33a35950 100644 --- a/goldens/aria/private/index.api.md +++ b/goldens/aria/private/index.api.md @@ -87,7 +87,7 @@ export class ComboboxPattern { readonly isEditable: _angular_core.Signal; readonly isExpanded: _angular_core.Signal; readonly isFocused: _angular_core.WritableSignal; - readonly keyboardEventRelay: _angular_core.WritableSignal; + readonly keyboardEventRelay: _angular_core.WritableSignal; keyboardEventRelayEffect(): void; keydown: _angular_core.Signal>; readonly nativeDisabled: _angular_core.Signal<"" | null>; diff --git a/src/aria/combobox/combobox.spec.ts b/src/aria/combobox/combobox.spec.ts index fd10570dfc27..d43fa0b044f6 100644 --- a/src/aria/combobox/combobox.spec.ts +++ b/src/aria/combobox/combobox.spec.ts @@ -206,6 +206,42 @@ describe('Combobox', () => { options[options.length - 1].id, ); }); + + it('should handle multiple navigation keys pressed back to back in the same render', async () => { + await down(); // Open popup (focuses options[0]: Alabama) + + dispatchKeyboardEvent(inputElement, 'keydown', undefined, 'ArrowDown'); + dispatchKeyboardEvent(inputElement, 'keydown', undefined, 'ArrowDown'); + dispatchKeyboardEvent(inputElement, 'keydown', undefined, 'ArrowDown'); + await fixture.whenStable(); + + const options = getOptions(); + expect(inputElement.getAttribute('aria-activedescendant')).toBe(options[3].id); + }); + + it('should handle navigation keys pressed in the same render that opens the popup', async () => { + await focus(); + + dispatchKeyboardEvent(inputElement, 'keydown', undefined, 'ArrowDown'); // Open (focuses options[0]) + dispatchKeyboardEvent(inputElement, 'keydown', undefined, 'ArrowDown'); // Move to options[1] + await fixture.whenStable(); + + const options = getOptions(); + expect(inputElement.getAttribute('aria-activedescendant')).toBe(options[1].id); + }); + + it('should relay back-to-back typeahead characters in order on a non-editable combobox', async () => { + await setupCombobox(ComboboxSelectOnlyListboxExample); + await down(); // Open popup (focuses options[0]: Alabama) + + for (const char of 'new y') { + dispatchKeyboardEvent(inputElement, 'keydown', undefined, char); + } + await fixture.whenStable(); + + const newYork = getOption('New York')!; + expect(inputElement.getAttribute('aria-activedescendant')).toBe(newYork.id); + }); }); describe('Expansion', () => { @@ -346,6 +382,18 @@ describe('Combobox', () => { expect(fixture.componentInstance.valueChanges).toEqual([]); }); + + it('should select the target option when navigation keys and Enter are pressed in the same render', async () => { + await down(); // Open popup (focuses Alabama) + + dispatchKeyboardEvent(inputElement, 'keydown', undefined, 'ArrowDown'); + dispatchKeyboardEvent(inputElement, 'keydown', undefined, 'ArrowDown'); + dispatchKeyboardEvent(inputElement, 'keydown', undefined, 'Enter'); + await fixture.whenStable(); + + expect(fixture.componentInstance.value()).toEqual(['Arizona']); + expect(inputElement.value).toBe('Arizona'); + }); }); describe('with auto-select behavior', () => { @@ -1937,3 +1985,34 @@ class ComboboxListboxGeneratedIdExample { popupExpanded = signal(false); options = ['Apple', 'Banana']; } + +@Component({ + template: ` +
+
Pick a state
+ + +
+ @for (option of options; track option) { +
{{option}}
+ } +
+
+
+ `, + imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option], +}) +class ComboboxSelectOnlyListboxExample { + popupExpanded = signal(false); + options = states; +} diff --git a/src/aria/private/combobox/combobox.spec.ts b/src/aria/private/combobox/combobox.spec.ts index 471a9cc91fdc..c3ca733fafb7 100644 --- a/src/aria/private/combobox/combobox.spec.ts +++ b/src/aria/private/combobox/combobox.spec.ts @@ -273,12 +273,12 @@ describe('ComboboxPattern', () => { const shiftUp = createKeyboardEvent('keydown', 38, 'ArrowUp'); Object.defineProperty(shiftUp, 'shiftKey', {value: true}); pattern.onKeydown(shiftUp); - expect(pattern.keyboardEventRelay()).toBe(shiftUp); + expect(pattern.keyboardEventRelay()).toEqual([shiftUp]); const shiftDown = createKeyboardEvent('keydown', 40, 'ArrowDown'); Object.defineProperty(shiftDown, 'shiftKey', {value: true}); pattern.onKeydown(shiftDown); - expect(pattern.keyboardEventRelay()).toBe(shiftDown); + expect(pattern.keyboardEventRelay()).toEqual([shiftUp, shiftDown]); }); it('should NOT forward Ctrl+A or Shift+Home/End for editable inputs', () => { @@ -288,12 +288,12 @@ describe('ComboboxPattern', () => { const ctrlA = createKeyboardEvent('keydown', 65, 'a'); Object.defineProperty(ctrlA, 'ctrlKey', {value: true}); pattern.onKeydown(ctrlA); - expect(pattern.keyboardEventRelay()).toBeUndefined(); + expect(pattern.keyboardEventRelay()).toEqual([]); const shiftHome = createKeyboardEvent('keydown', 36, 'Home'); Object.defineProperty(shiftHome, 'shiftKey', {value: true}); pattern.onKeydown(shiftHome); - expect(pattern.keyboardEventRelay()).toBeUndefined(); + expect(pattern.keyboardEventRelay()).toEqual([]); }); it('should forward Ctrl+A and Shift+Home/End for select-only (non-editable) comboboxes', () => { @@ -305,12 +305,23 @@ describe('ComboboxPattern', () => { const ctrlA = createKeyboardEvent('keydown', 65, 'a'); Object.defineProperty(ctrlA, 'ctrlKey', {value: true}); pattern.onKeydown(ctrlA); - expect(pattern.keyboardEventRelay()).toBe(ctrlA); + expect(pattern.keyboardEventRelay()).toEqual([ctrlA]); const shiftHome = createKeyboardEvent('keydown', 36, 'Home'); Object.defineProperty(shiftHome, 'shiftKey', {value: true}); pattern.onKeydown(shiftHome); - expect(pattern.keyboardEventRelay()).toBe(shiftHome); + expect(pattern.keyboardEventRelay()).toEqual([ctrlA, shiftHome]); + }); + + it('should only enqueue Space once on select-only (non-editable) comboboxes', () => { + const selectOnlyElement = document.createElement('div'); + const {pattern, expanded} = setup(); + pattern.inputs.element = signal(selectOnlyElement); + expanded.set(true); + + const space = createKeyboardEvent('keydown', 32, ' '); + pattern.onKeydown(space); + expect(pattern.keyboardEventRelay()).toEqual([space]); }); }); diff --git a/src/aria/private/combobox/combobox.ts b/src/aria/private/combobox/combobox.ts index 4e23acbc1c2b..4335cd58fa3d 100644 --- a/src/aria/private/combobox/combobox.ts +++ b/src/aria/private/combobox/combobox.ts @@ -88,8 +88,8 @@ export class ComboboxPattern { return 'none'; }); - /** A relay for keyboard events to the popup. */ - readonly keyboardEventRelay = signal(undefined); + /** A relay queue for keyboard events to the popup. */ + readonly keyboardEventRelay = signal([]); /** Whether the combobox is focused. */ readonly isFocused = signal(false); @@ -137,44 +137,43 @@ export class ComboboxPattern { .on( 'ArrowLeft', e => { - this.keyboardEventRelay.set(e); + this._relayKeydown(e); }, {preventDefault: this.popupType() !== 'listbox', ignoreRepeat: false}, ) .on( 'ArrowRight', e => { - this.keyboardEventRelay.set(e); + this._relayKeydown(e); }, {preventDefault: this.popupType() !== 'listbox', ignoreRepeat: false}, ) - .on('ArrowUp', e => this.keyboardEventRelay.set(e), {ignoreRepeat: false}) - .on('ArrowDown', e => this.keyboardEventRelay.set(e), {ignoreRepeat: false}) - .on(Modifier.Shift, 'ArrowUp', e => this.keyboardEventRelay.set(e), {ignoreRepeat: false}) - .on(Modifier.Shift, 'ArrowDown', e => this.keyboardEventRelay.set(e), {ignoreRepeat: false}) - .on('Home', e => this.keyboardEventRelay.set(e)) - .on('End', e => this.keyboardEventRelay.set(e)) - .on('PageUp', e => this.keyboardEventRelay.set(e)) - .on('PageDown', e => this.keyboardEventRelay.set(e)) + .on('ArrowUp', e => this._relayKeydown(e), {ignoreRepeat: false}) + .on('ArrowDown', e => this._relayKeydown(e), {ignoreRepeat: false}) + .on(Modifier.Shift, 'ArrowUp', e => this._relayKeydown(e), {ignoreRepeat: false}) + .on(Modifier.Shift, 'ArrowDown', e => this._relayKeydown(e), {ignoreRepeat: false}) + .on('Home', e => this._relayKeydown(e)) + .on('End', e => this._relayKeydown(e)) + .on('PageUp', e => this._relayKeydown(e)) + .on('PageDown', e => this._relayKeydown(e)) .on('Escape', () => { if (!this.inputs.alwaysExpanded()) { this.inputs.expanded.set(false); + this.keyboardEventRelay.set([]); } }); if (!this.readonly()) { - manager.on('Enter', e => this.keyboardEventRelay.set(e)); + manager.on('Enter', e => this._relayKeydown(e)); } if (!this.isEditable() && !this.readonly()) { manager - .on(' ', e => this.keyboardEventRelay.set(e)) - .on([Modifier.Ctrl, Modifier.Meta], 'a', e => this.keyboardEventRelay.set(e)) - .on([Modifier.Ctrl, Modifier.Meta], 'A', e => this.keyboardEventRelay.set(e)) - .on(Modifier.Shift, 'Home', e => this.keyboardEventRelay.set(e)) - .on(Modifier.Shift, 'End', e => this.keyboardEventRelay.set(e)) + .on([Modifier.Ctrl, Modifier.Meta], 'A', e => this._relayKeydown(e)) + .on(Modifier.Shift, 'Home', e => this._relayKeydown(e)) + .on(Modifier.Shift, 'End', e => this._relayKeydown(e)) .on(/^.$/, e => { - this.keyboardEventRelay.set(e); + this._relayKeydown(e); }); } @@ -196,6 +195,11 @@ export class ComboboxPattern { this.value = inputs.value; } + /** Queues a keyboard event to be relayed to the popup. */ + private _relayKeydown(event: KeyboardEvent) { + this.keyboardEventRelay.update(events => [...events, event]); + } + /** Handles keydown events for the combobox. */ onKeydown(event: KeyboardEvent) { if (!this.inputs.disabled()) { @@ -266,19 +270,24 @@ export class ComboboxPattern { /** Relays keyboard events to the popup. */ keyboardEventRelayEffect() { - const event = this.keyboardEventRelay(); - if (event === undefined) return; + const events = this.keyboardEventRelay(); + if (events.length === 0) return; // Reset isDeleting when the user navigates, so that the highlight effect can run again. this.isDeleting.set(false); - untracked(() => { - const popup = this.inputs.popup(); - if (this.isExpanded()) { - const relayedEvent = new KeyboardEvent(event.type, event); - popup?.controlTarget()?.dispatchEvent(relayedEvent); - } - }); + if (!untracked(() => this.isExpanded())) { + this.keyboardEventRelay.set([]); + return; + } + + const target = this.inputs.popup()?.controlTarget(); + if (!target) return; + + this.keyboardEventRelay.set([]); + for (const event of events) { + target.dispatchEvent(new KeyboardEvent(event.type, event)); + } } }