Skip to content

Commit 9a63c20

Browse files
lazergok7sai
authored andcommitted
fix(aria/combobox): popup not closing when focus leaves it (#33697)
* fix(aria/combobox): popup not closing when focus leaves it * fix(aria/combobox): set isFocused before closing popup on focusout (cherry picked from commit de579cc)
1 parent 16c5e69 commit 9a63c20

5 files changed

Lines changed: 89 additions & 3 deletions

File tree

goldens/aria/private/index.api.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ export class ComboboxPattern {
7979
readonly ariaReadonly: _angular_core.Signal<"true" | null>;
8080
readonly autocomplete: _angular_core.Signal<"both" | "inline" | "list" | "none">;
8181
click: _angular_core.Signal<ClickEventManager<PointerEvent>>;
82+
closePopupOnFocusout(): void;
8283
readonly disabled: () => boolean;
8384
readonly element: () => HTMLElement;
8485
highlightEffect(): void;
@@ -109,6 +110,7 @@ export class ComboboxPattern {
109110
// @public
110111
export interface ComboboxPopupInputs {
111112
activeDescendant: SignalLike<string | undefined>;
113+
combobox: SignalLike<ComboboxPattern | undefined>;
112114
controlTarget: SignalLike<HTMLElement | undefined>;
113115
popupId: SignalLike<string | undefined>;
114116
popupType: SignalLike<'listbox' | 'tree' | 'grid' | 'dialog'>;
@@ -118,6 +120,7 @@ export interface ComboboxPopupInputs {
118120
export class ComboboxPopupPattern {
119121
constructor(inputs: ComboboxPopupInputs);
120122
readonly activeDescendant: () => string | undefined;
123+
readonly combobox: () => ComboboxPattern | undefined;
121124
readonly controlTarget: () => HTMLElement | undefined;
122125
// (undocumented)
123126
readonly inputs: ComboboxPopupInputs;

src/aria/combobox/combobox-popup.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,9 @@ export class ComboboxPopup implements OnInit, OnDestroy {
5454
readonly popupType = input<'listbox' | 'tree' | 'grid' | 'dialog'>('listbox');
5555

5656
/** The popup pattern. */
57-
readonly _pattern = new ComboboxPopupPattern({
57+
readonly _pattern: ComboboxPopupPattern = new ComboboxPopupPattern({
5858
...this,
59+
combobox: computed(() => this.combobox()._pattern),
5960
});
6061

6162
ngOnInit() {

src/aria/combobox/combobox.spec.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1257,6 +1257,9 @@ describe('Combobox', () => {
12571257
.nativeElement as HTMLElement;
12581258
};
12591259

1260+
const wait = (milliseconds: number) =>
1261+
new Promise(resolve => setTimeout(resolve, milliseconds));
1262+
12601263
afterEach(async () => await runAccessibilityChecks(fixture.nativeElement));
12611264

12621265
it('should auto-generate an ID on the widget when none is provided', async () => {
@@ -1276,6 +1279,45 @@ describe('Combobox', () => {
12761279
expect(widgetElement.id).toMatch(/^ng-listbox-/);
12771280
expect(comboboxElement.getAttribute('aria-controls')).toBe(widgetElement.id);
12781281
});
1282+
1283+
it('should close the popup once focus leaves it entirely, even when the trigger never regains focus', async () => {
1284+
await expand(ComboboxDialogExample);
1285+
const filterInput = widgetElement.querySelector('input') as HTMLInputElement;
1286+
1287+
comboboxElement.dispatchEvent(
1288+
new FocusEvent('focusout', {bubbles: true, relatedTarget: filterInput}),
1289+
);
1290+
filterInput.dispatchEvent(new FocusEvent('focusin', {bubbles: true})); // Focus enters popup
1291+
await wait(100);
1292+
expect(comboboxElement.getAttribute('aria-expanded')).toBe('true');
1293+
1294+
// Focus leaves the popup, never passing back through the trigger
1295+
filterInput.dispatchEvent(
1296+
new FocusEvent('focusout', {bubbles: true, relatedTarget: document.body}),
1297+
);
1298+
await wait(100);
1299+
1300+
expect(comboboxElement.getAttribute('aria-expanded')).toBe('false');
1301+
});
1302+
1303+
it('should keep the popup open when focus moves from the widget back to the combobox', async () => {
1304+
await expand(ComboboxDialogExample);
1305+
const filterInput = widgetElement.querySelector('input') as HTMLInputElement;
1306+
1307+
comboboxElement.dispatchEvent(
1308+
new FocusEvent('focusout', {bubbles: true, relatedTarget: filterInput}),
1309+
);
1310+
filterInput.dispatchEvent(new FocusEvent('focusin', {bubbles: true}));
1311+
await wait(100);
1312+
1313+
filterInput.dispatchEvent(
1314+
new FocusEvent('focusout', {bubbles: true, relatedTarget: comboboxElement}),
1315+
);
1316+
comboboxElement.dispatchEvent(new FocusEvent('focusin', {bubbles: true}));
1317+
await wait(100);
1318+
1319+
expect(comboboxElement.getAttribute('aria-expanded')).toBe('true');
1320+
});
12791321
});
12801322
});
12811323

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,13 @@ describe('ComboboxPattern', () => {
2626
const controlTarget = document.createElement('div');
2727
const popupType = signal<'listbox' | 'tree' | 'grid' | 'dialog'>(inputs.popupType ?? 'listbox');
2828

29+
const combobox = signal<ComboboxPattern | undefined>(undefined);
2930
const popup = new ComboboxPopupPattern({
3031
popupType,
3132
controlTarget: signal(controlTarget),
3233
activeDescendant,
3334
popupId,
35+
combobox,
3436
});
3537

3638
const pattern = new ComboboxPattern({
@@ -45,6 +47,8 @@ describe('ComboboxPattern', () => {
4547
expandable: signal(true),
4648
});
4749

50+
combobox.set(pattern);
51+
4852
return {
4953
pattern,
5054
element,
@@ -234,6 +238,31 @@ describe('ComboboxPattern', () => {
234238

235239
expect(expanded()).toBe(true);
236240
});
241+
242+
it('should close when focus leaves the popup', async () => {
243+
const {pattern, expanded, popup} = setup();
244+
expanded.set(true);
245+
pattern.isFocused.set(false);
246+
popup.isFocused.set(true);
247+
248+
popup.onFocusout(new FocusEvent('focusout'));
249+
await wait(100);
250+
251+
expect(expanded()).toBe(false);
252+
});
253+
254+
it('should remain open if focus moves back to the combobox', async () => {
255+
const {pattern, expanded, popup} = setup();
256+
expanded.set(true);
257+
pattern.isFocused.set(false);
258+
popup.isFocused.set(true);
259+
260+
popup.onFocusout(new FocusEvent('focusout'));
261+
pattern.onFocusin();
262+
await wait(100);
263+
264+
expect(expanded()).toBe(true);
265+
});
237266
});
238267

239268
describe('Advanced Combo Keys Relay', () => {

src/aria/private/combobox/combobox.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,12 @@ export class ComboboxPattern {
217217

218218
/** Handles focus out events for the combobox. */
219219
onFocusout() {
220+
this.isFocused.set(false);
221+
this.closePopupOnFocusout();
222+
}
223+
224+
/** Closes the popup once focus has left both the combobox and the popup. */
225+
closePopupOnFocusout() {
220226
// Give focus some time to move before we check it.
221227
setTimeout(() => {
222228
const comboboxFocused = this.isFocused();
@@ -226,8 +232,6 @@ export class ComboboxPattern {
226232
this.inputs.expanded.set(false);
227233
}
228234
});
229-
230-
this.isFocused.set(false);
231235
}
232236

233237
/** Handles input events for the combobox. */
@@ -291,6 +295,9 @@ export interface ComboboxPopupInputs {
291295

292296
/** The ID of the popup. */
293297
popupId: SignalLike<string | undefined>;
298+
299+
/** A reference to the parent combobox. */
300+
combobox: SignalLike<ComboboxPattern | undefined>;
294301
}
295302

296303
/** Controls the state of a simple combobox popup. */
@@ -307,6 +314,9 @@ export class ComboboxPopupPattern {
307314
/** The ID of the popup. */
308315
readonly popupId = () => this.inputs.popupId();
309316

317+
/** A reference to the parent combobox. */
318+
readonly combobox = () => this.inputs.combobox();
319+
310320
/** Whether the popup is focused. */
311321
readonly isFocused = signal(false);
312322

@@ -323,5 +333,6 @@ export class ComboboxPopupPattern {
323333
if (this.controlTarget()?.contains(focusTarget)) return;
324334

325335
this.isFocused.set(false);
336+
this.combobox()?.closePopupOnFocusout();
326337
}
327338
}

0 commit comments

Comments
 (0)