Skip to content

Commit e20e7e9

Browse files
committed
fix(aria/combobox): queue relayed keyboard events to prevent dropping back-to-back keys
1 parent 5856d87 commit e20e7e9

4 files changed

Lines changed: 134 additions & 35 deletions

File tree

‎goldens/aria/private/index.api.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ export class ComboboxPattern {
8787
readonly isEditable: _angular_core.Signal<boolean>;
8888
readonly isExpanded: _angular_core.Signal<boolean>;
8989
readonly isFocused: _angular_core.WritableSignal<boolean>;
90-
readonly keyboardEventRelay: _angular_core.WritableSignal<KeyboardEvent | undefined>;
90+
readonly keyboardEventRelay: _angular_core.WritableSignal<KeyboardEvent[]>;
9191
keyboardEventRelayEffect(): void;
9292
keydown: _angular_core.Signal<KeyboardEventManager<KeyboardEvent>>;
9393
readonly nativeDisabled: _angular_core.Signal<"" | null>;

‎src/aria/combobox/combobox.spec.ts‎

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,42 @@ describe('Combobox', () => {
206206
options[options.length - 1].id,
207207
);
208208
});
209+
210+
it('should handle multiple navigation keys pressed back to back in the same render', async () => {
211+
await down(); // Open popup (focuses options[0]: Alabama)
212+
213+
dispatchKeyboardEvent(inputElement, 'keydown', undefined, 'ArrowDown');
214+
dispatchKeyboardEvent(inputElement, 'keydown', undefined, 'ArrowDown');
215+
dispatchKeyboardEvent(inputElement, 'keydown', undefined, 'ArrowDown');
216+
await fixture.whenStable();
217+
218+
const options = getOptions();
219+
expect(inputElement.getAttribute('aria-activedescendant')).toBe(options[3].id);
220+
});
221+
222+
it('should handle navigation keys pressed in the same render that opens the popup', async () => {
223+
await focus();
224+
225+
dispatchKeyboardEvent(inputElement, 'keydown', undefined, 'ArrowDown'); // Open (focuses options[0])
226+
dispatchKeyboardEvent(inputElement, 'keydown', undefined, 'ArrowDown'); // Move to options[1]
227+
await fixture.whenStable();
228+
229+
const options = getOptions();
230+
expect(inputElement.getAttribute('aria-activedescendant')).toBe(options[1].id);
231+
});
232+
233+
it('should relay back-to-back typeahead characters in order on a non-editable combobox', async () => {
234+
await setupCombobox(ComboboxSelectOnlyListboxExample);
235+
await down(); // Open popup (focuses options[0]: Alabama)
236+
237+
for (const char of 'new y') {
238+
dispatchKeyboardEvent(inputElement, 'keydown', undefined, char);
239+
}
240+
await fixture.whenStable();
241+
242+
const newYork = getOption('New York')!;
243+
expect(inputElement.getAttribute('aria-activedescendant')).toBe(newYork.id);
244+
});
209245
});
210246

211247
describe('Expansion', () => {
@@ -346,6 +382,18 @@ describe('Combobox', () => {
346382

347383
expect(fixture.componentInstance.valueChanges).toEqual([]);
348384
});
385+
386+
it('should select the target option when navigation keys and Enter are pressed in the same render', async () => {
387+
await down(); // Open popup (focuses Alabama)
388+
389+
dispatchKeyboardEvent(inputElement, 'keydown', undefined, 'ArrowDown');
390+
dispatchKeyboardEvent(inputElement, 'keydown', undefined, 'ArrowDown');
391+
dispatchKeyboardEvent(inputElement, 'keydown', undefined, 'Enter');
392+
await fixture.whenStable();
393+
394+
expect(fixture.componentInstance.value()).toEqual(['Arizona']);
395+
expect(inputElement.value).toBe('Arizona');
396+
});
349397
});
350398

351399
describe('with auto-select behavior', () => {
@@ -1937,3 +1985,34 @@ class ComboboxListboxGeneratedIdExample {
19371985
popupExpanded = signal(false);
19381986
options = ['Apple', 'Banana'];
19391987
}
1988+
1989+
@Component({
1990+
template: `
1991+
<div>
1992+
<div
1993+
ngCombobox
1994+
#combobox="ngCombobox"
1995+
aria-label="State"
1996+
[(expanded)]="popupExpanded"
1997+
>Pick a state</div>
1998+
1999+
<ng-template ngComboboxPopup [combobox]="combobox">
2000+
<div
2001+
ngComboboxWidget
2002+
#listbox="ngListbox"
2003+
ngListbox
2004+
focusMode="activedescendant"
2005+
[activeDescendant]="listbox.activeDescendant()">
2006+
@for (option of options; track option) {
2007+
<div ngOption [value]="option" [label]="option">{{option}}</div>
2008+
}
2009+
</div>
2010+
</ng-template>
2011+
</div>
2012+
`,
2013+
imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option],
2014+
})
2015+
class ComboboxSelectOnlyListboxExample {
2016+
popupExpanded = signal(false);
2017+
options = states;
2018+
}

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

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -273,12 +273,12 @@ describe('ComboboxPattern', () => {
273273
const shiftUp = createKeyboardEvent('keydown', 38, 'ArrowUp');
274274
Object.defineProperty(shiftUp, 'shiftKey', {value: true});
275275
pattern.onKeydown(shiftUp);
276-
expect(pattern.keyboardEventRelay()).toBe(shiftUp);
276+
expect(pattern.keyboardEventRelay()).toEqual([shiftUp]);
277277

278278
const shiftDown = createKeyboardEvent('keydown', 40, 'ArrowDown');
279279
Object.defineProperty(shiftDown, 'shiftKey', {value: true});
280280
pattern.onKeydown(shiftDown);
281-
expect(pattern.keyboardEventRelay()).toBe(shiftDown);
281+
expect(pattern.keyboardEventRelay()).toEqual([shiftUp, shiftDown]);
282282
});
283283

284284
it('should NOT forward Ctrl+A or Shift+Home/End for editable inputs', () => {
@@ -288,12 +288,12 @@ describe('ComboboxPattern', () => {
288288
const ctrlA = createKeyboardEvent('keydown', 65, 'a');
289289
Object.defineProperty(ctrlA, 'ctrlKey', {value: true});
290290
pattern.onKeydown(ctrlA);
291-
expect(pattern.keyboardEventRelay()).toBeUndefined();
291+
expect(pattern.keyboardEventRelay()).toEqual([]);
292292

293293
const shiftHome = createKeyboardEvent('keydown', 36, 'Home');
294294
Object.defineProperty(shiftHome, 'shiftKey', {value: true});
295295
pattern.onKeydown(shiftHome);
296-
expect(pattern.keyboardEventRelay()).toBeUndefined();
296+
expect(pattern.keyboardEventRelay()).toEqual([]);
297297
});
298298

299299
it('should forward Ctrl+A and Shift+Home/End for select-only (non-editable) comboboxes', () => {
@@ -305,12 +305,23 @@ describe('ComboboxPattern', () => {
305305
const ctrlA = createKeyboardEvent('keydown', 65, 'a');
306306
Object.defineProperty(ctrlA, 'ctrlKey', {value: true});
307307
pattern.onKeydown(ctrlA);
308-
expect(pattern.keyboardEventRelay()).toBe(ctrlA);
308+
expect(pattern.keyboardEventRelay()).toEqual([ctrlA]);
309309

310310
const shiftHome = createKeyboardEvent('keydown', 36, 'Home');
311311
Object.defineProperty(shiftHome, 'shiftKey', {value: true});
312312
pattern.onKeydown(shiftHome);
313-
expect(pattern.keyboardEventRelay()).toBe(shiftHome);
313+
expect(pattern.keyboardEventRelay()).toEqual([ctrlA, shiftHome]);
314+
});
315+
316+
it('should only enqueue Space once on select-only (non-editable) comboboxes', () => {
317+
const selectOnlyElement = document.createElement('div');
318+
const {pattern, expanded} = setup();
319+
pattern.inputs.element = signal(selectOnlyElement);
320+
expanded.set(true);
321+
322+
const space = createKeyboardEvent('keydown', 32, ' ');
323+
pattern.onKeydown(space);
324+
expect(pattern.keyboardEventRelay()).toEqual([space]);
314325
});
315326
});
316327

‎src/aria/private/combobox/combobox.ts‎

Lines changed: 37 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,8 @@ export class ComboboxPattern {
8888
return 'none';
8989
});
9090

91-
/** A relay for keyboard events to the popup. */
92-
readonly keyboardEventRelay = signal<KeyboardEvent | undefined>(undefined);
91+
/** A relay queue for keyboard events to the popup. */
92+
readonly keyboardEventRelay = signal<KeyboardEvent[]>([]);
9393

9494
/** Whether the combobox is focused. */
9595
readonly isFocused = signal(false);
@@ -137,44 +137,43 @@ export class ComboboxPattern {
137137
.on(
138138
'ArrowLeft',
139139
e => {
140-
this.keyboardEventRelay.set(e);
140+
this._relayKeydown(e);
141141
},
142142
{preventDefault: this.popupType() !== 'listbox', ignoreRepeat: false},
143143
)
144144
.on(
145145
'ArrowRight',
146146
e => {
147-
this.keyboardEventRelay.set(e);
147+
this._relayKeydown(e);
148148
},
149149
{preventDefault: this.popupType() !== 'listbox', ignoreRepeat: false},
150150
)
151-
.on('ArrowUp', e => this.keyboardEventRelay.set(e), {ignoreRepeat: false})
152-
.on('ArrowDown', e => this.keyboardEventRelay.set(e), {ignoreRepeat: false})
153-
.on(Modifier.Shift, 'ArrowUp', e => this.keyboardEventRelay.set(e), {ignoreRepeat: false})
154-
.on(Modifier.Shift, 'ArrowDown', e => this.keyboardEventRelay.set(e), {ignoreRepeat: false})
155-
.on('Home', e => this.keyboardEventRelay.set(e))
156-
.on('End', e => this.keyboardEventRelay.set(e))
157-
.on('PageUp', e => this.keyboardEventRelay.set(e))
158-
.on('PageDown', e => this.keyboardEventRelay.set(e))
151+
.on('ArrowUp', e => this._relayKeydown(e), {ignoreRepeat: false})
152+
.on('ArrowDown', e => this._relayKeydown(e), {ignoreRepeat: false})
153+
.on(Modifier.Shift, 'ArrowUp', e => this._relayKeydown(e), {ignoreRepeat: false})
154+
.on(Modifier.Shift, 'ArrowDown', e => this._relayKeydown(e), {ignoreRepeat: false})
155+
.on('Home', e => this._relayKeydown(e))
156+
.on('End', e => this._relayKeydown(e))
157+
.on('PageUp', e => this._relayKeydown(e))
158+
.on('PageDown', e => this._relayKeydown(e))
159159
.on('Escape', () => {
160160
if (!this.inputs.alwaysExpanded()) {
161161
this.inputs.expanded.set(false);
162+
this.keyboardEventRelay.set([]);
162163
}
163164
});
164165

165166
if (!this.readonly()) {
166-
manager.on('Enter', e => this.keyboardEventRelay.set(e));
167+
manager.on('Enter', e => this._relayKeydown(e));
167168
}
168169

169170
if (!this.isEditable() && !this.readonly()) {
170171
manager
171-
.on(' ', e => this.keyboardEventRelay.set(e))
172-
.on([Modifier.Ctrl, Modifier.Meta], 'a', e => this.keyboardEventRelay.set(e))
173-
.on([Modifier.Ctrl, Modifier.Meta], 'A', e => this.keyboardEventRelay.set(e))
174-
.on(Modifier.Shift, 'Home', e => this.keyboardEventRelay.set(e))
175-
.on(Modifier.Shift, 'End', e => this.keyboardEventRelay.set(e))
172+
.on([Modifier.Ctrl, Modifier.Meta], 'A', e => this._relayKeydown(e))
173+
.on(Modifier.Shift, 'Home', e => this._relayKeydown(e))
174+
.on(Modifier.Shift, 'End', e => this._relayKeydown(e))
176175
.on(/^.$/, e => {
177-
this.keyboardEventRelay.set(e);
176+
this._relayKeydown(e);
178177
});
179178
}
180179

@@ -196,6 +195,11 @@ export class ComboboxPattern {
196195
this.value = inputs.value;
197196
}
198197

198+
/** Queues a keyboard event to be relayed to the popup. */
199+
private _relayKeydown(event: KeyboardEvent) {
200+
this.keyboardEventRelay.update(events => [...events, event]);
201+
}
202+
199203
/** Handles keydown events for the combobox. */
200204
onKeydown(event: KeyboardEvent) {
201205
if (!this.inputs.disabled()) {
@@ -266,19 +270,24 @@ export class ComboboxPattern {
266270

267271
/** Relays keyboard events to the popup. */
268272
keyboardEventRelayEffect() {
269-
const event = this.keyboardEventRelay();
270-
if (event === undefined) return;
273+
const events = this.keyboardEventRelay();
274+
if (events.length === 0) return;
271275

272276
// Reset isDeleting when the user navigates, so that the highlight effect can run again.
273277
this.isDeleting.set(false);
274278

275-
untracked(() => {
276-
const popup = this.inputs.popup();
277-
if (this.isExpanded()) {
278-
const relayedEvent = new KeyboardEvent(event.type, event);
279-
popup?.controlTarget()?.dispatchEvent(relayedEvent);
280-
}
281-
});
279+
if (!untracked(() => this.isExpanded())) {
280+
this.keyboardEventRelay.set([]);
281+
return;
282+
}
283+
284+
const target = this.inputs.popup()?.controlTarget();
285+
if (!target) return;
286+
287+
this.keyboardEventRelay.set([]);
288+
for (const event of events) {
289+
target.dispatchEvent(new KeyboardEvent(event.type, event));
290+
}
282291
}
283292
}
284293

0 commit comments

Comments
 (0)