From 3b721d9f2bbf475733b7aa3988882252c287b01e Mon Sep 17 00:00:00 2001 From: drakeo338 Date: Tue, 20 Oct 2026 02:58:40 +0000 Subject: [PATCH] fix(aria/combobox): relay every key pressed back to back to the popup The combobox relayed keys to the popup through a single signal that an effect read later. When several keys arrived before the effect ran, only the last one reached the popup. Queue the events and relay all of them, in order, when the effect runs. Fixes #33973 --- src/aria/combobox/combobox.spec.ts | 14 +++++++ src/aria/private/combobox/combobox.ts | 55 ++++++++++++++++++--------- 2 files changed, 50 insertions(+), 19 deletions(-) diff --git a/src/aria/combobox/combobox.spec.ts b/src/aria/combobox/combobox.spec.ts index fb21bba8e71b..db82f8b8f5f5 100644 --- a/src/aria/combobox/combobox.spec.ts +++ b/src/aria/combobox/combobox.spec.ts @@ -178,6 +178,20 @@ describe('Combobox', () => { expect(inputElement.getAttribute('aria-activedescendant')).toBe(options[1].id); }); + it('should handle each ArrowDown pressed back to back', async () => { + await down(); // Open popup + const press = () => + inputElement.dispatchEvent( + new KeyboardEvent('keydown', {key: 'ArrowDown', bubbles: true}), + ); + press(); + press(); + press(); + await fixture.whenStable(); + const options = getOptions(); + expect(inputElement.getAttribute('aria-activedescendant')).toBe(options[3].id); + }); + it('should navigate to the previous item on ArrowUp when open', async () => { await down(); // Open await down(); // Move to next item diff --git a/src/aria/private/combobox/combobox.ts b/src/aria/private/combobox/combobox.ts index 4e23acbc1c2b..fae0d471269d 100644 --- a/src/aria/private/combobox/combobox.ts +++ b/src/aria/private/combobox/combobox.ts @@ -91,6 +91,12 @@ export class ComboboxPattern { /** A relay for keyboard events to the popup. */ readonly keyboardEventRelay = signal(undefined); + /** + * Events waiting to be relayed to the popup. The relay signal only holds the latest event, so + * events that arrive before the relay effect runs are queued here to avoid dropping them. + */ + private _pendingRelayEvents: KeyboardEvent[] = []; + /** Whether the combobox is focused. */ readonly isFocused = signal(false); @@ -137,25 +143,25 @@ export class ComboboxPattern { .on( 'ArrowLeft', e => { - this.keyboardEventRelay.set(e); + this._relayKeyboardEvent(e); }, {preventDefault: this.popupType() !== 'listbox', ignoreRepeat: false}, ) .on( 'ArrowRight', e => { - this.keyboardEventRelay.set(e); + this._relayKeyboardEvent(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._relayKeyboardEvent(e), {ignoreRepeat: false}) + .on('ArrowDown', e => this._relayKeyboardEvent(e), {ignoreRepeat: false}) + .on(Modifier.Shift, 'ArrowUp', e => this._relayKeyboardEvent(e), {ignoreRepeat: false}) + .on(Modifier.Shift, 'ArrowDown', e => this._relayKeyboardEvent(e), {ignoreRepeat: false}) + .on('Home', e => this._relayKeyboardEvent(e)) + .on('End', e => this._relayKeyboardEvent(e)) + .on('PageUp', e => this._relayKeyboardEvent(e)) + .on('PageDown', e => this._relayKeyboardEvent(e)) .on('Escape', () => { if (!this.inputs.alwaysExpanded()) { this.inputs.expanded.set(false); @@ -163,18 +169,18 @@ export class ComboboxPattern { }); if (!this.readonly()) { - manager.on('Enter', e => this.keyboardEventRelay.set(e)); + manager.on('Enter', e => this._relayKeyboardEvent(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(' ', e => this._relayKeyboardEvent(e)) + .on([Modifier.Ctrl, Modifier.Meta], 'a', e => this._relayKeyboardEvent(e)) + .on([Modifier.Ctrl, Modifier.Meta], 'A', e => this._relayKeyboardEvent(e)) + .on(Modifier.Shift, 'Home', e => this._relayKeyboardEvent(e)) + .on(Modifier.Shift, 'End', e => this._relayKeyboardEvent(e)) .on(/^.$/, e => { - this.keyboardEventRelay.set(e); + this._relayKeyboardEvent(e); }); } @@ -196,6 +202,12 @@ export class ComboboxPattern { this.value = inputs.value; } + /** Queues a keyboard event to be relayed to the popup. */ + private _relayKeyboardEvent(event: KeyboardEvent) { + this._pendingRelayEvents.push(event); + this.keyboardEventRelay.set(event); + } + /** Handles keydown events for the combobox. */ onKeydown(event: KeyboardEvent) { if (!this.inputs.disabled()) { @@ -269,14 +281,19 @@ export class ComboboxPattern { const event = this.keyboardEventRelay(); if (event === undefined) return; + const events = this._pendingRelayEvents; + this._pendingRelayEvents = []; + // 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); + const target = popup?.controlTarget(); + for (const pendingEvent of events) { + target?.dispatchEvent(new KeyboardEvent(pendingEvent.type, pendingEvent)); + } } }); }