From 60ae18fc5722d6fce829a20f6b28f71865f32cd0 Mon Sep 17 00:00:00 2001 From: Lazizbek Ergashev Date: Tue, 18 Aug 2026 23:32:16 +0500 Subject: [PATCH] fix(aria/combobox): popup not closing when focus leaves it --- goldens/aria/private/index.api.md | 3 +++ src/aria/combobox/combobox-popup.ts | 3 ++- src/aria/private/combobox/combobox.spec.ts | 29 ++++++++++++++++++++++ src/aria/private/combobox/combobox.ts | 15 +++++++++-- 4 files changed, 47 insertions(+), 3 deletions(-) diff --git a/goldens/aria/private/index.api.md b/goldens/aria/private/index.api.md index 9fb23800cbd8..6b79a1da58e4 100644 --- a/goldens/aria/private/index.api.md +++ b/goldens/aria/private/index.api.md @@ -79,6 +79,7 @@ export class ComboboxPattern { readonly ariaReadonly: _angular_core.Signal<"true" | null>; readonly autocomplete: _angular_core.Signal<"none" | "inline" | "list" | "both">; click: _angular_core.Signal>; + closePopupOnFocusout(): void; readonly disabled: () => boolean; readonly element: () => HTMLElement; highlightEffect(): void; @@ -109,6 +110,7 @@ export class ComboboxPattern { // @public export interface ComboboxPopupInputs { activeDescendant: SignalLike; + combobox: SignalLike; controlTarget: SignalLike; popupId: SignalLike; popupType: SignalLike<'listbox' | 'tree' | 'grid' | 'dialog'>; @@ -118,6 +120,7 @@ export interface ComboboxPopupInputs { export class ComboboxPopupPattern { constructor(inputs: ComboboxPopupInputs); readonly activeDescendant: () => string | undefined; + readonly combobox: () => ComboboxPattern | undefined; readonly controlTarget: () => HTMLElement | undefined; // (undocumented) readonly inputs: ComboboxPopupInputs; diff --git a/src/aria/combobox/combobox-popup.ts b/src/aria/combobox/combobox-popup.ts index b220a5d548fb..5a29117367ae 100644 --- a/src/aria/combobox/combobox-popup.ts +++ b/src/aria/combobox/combobox-popup.ts @@ -54,8 +54,9 @@ export class ComboboxPopup implements OnInit, OnDestroy { readonly popupType = input<'listbox' | 'tree' | 'grid' | 'dialog'>('listbox'); /** The popup pattern. */ - readonly _pattern = new ComboboxPopupPattern({ + readonly _pattern: ComboboxPopupPattern = new ComboboxPopupPattern({ ...this, + combobox: computed(() => this.combobox()._pattern), }); ngOnInit() { diff --git a/src/aria/private/combobox/combobox.spec.ts b/src/aria/private/combobox/combobox.spec.ts index be922258905b..471a9cc91fdc 100644 --- a/src/aria/private/combobox/combobox.spec.ts +++ b/src/aria/private/combobox/combobox.spec.ts @@ -26,11 +26,13 @@ describe('ComboboxPattern', () => { const controlTarget = document.createElement('div'); const popupType = signal<'listbox' | 'tree' | 'grid' | 'dialog'>(inputs.popupType ?? 'listbox'); + const combobox = signal(undefined); const popup = new ComboboxPopupPattern({ popupType, controlTarget: signal(controlTarget), activeDescendant, popupId, + combobox, }); const pattern = new ComboboxPattern({ @@ -45,6 +47,8 @@ describe('ComboboxPattern', () => { expandable: signal(true), }); + combobox.set(pattern); + return { pattern, element, @@ -234,6 +238,31 @@ describe('ComboboxPattern', () => { expect(expanded()).toBe(true); }); + + it('should close when focus leaves the popup', async () => { + const {pattern, expanded, popup} = setup(); + expanded.set(true); + pattern.isFocused.set(false); + popup.isFocused.set(true); + + popup.onFocusout(new FocusEvent('focusout')); + await wait(100); + + expect(expanded()).toBe(false); + }); + + it('should remain open if focus moves back to the combobox', async () => { + const {pattern, expanded, popup} = setup(); + expanded.set(true); + pattern.isFocused.set(false); + popup.isFocused.set(true); + + popup.onFocusout(new FocusEvent('focusout')); + pattern.onFocusin(); + await wait(100); + + expect(expanded()).toBe(true); + }); }); describe('Advanced Combo Keys Relay', () => { diff --git a/src/aria/private/combobox/combobox.ts b/src/aria/private/combobox/combobox.ts index f5ff59d1ca8b..656e139f1a29 100644 --- a/src/aria/private/combobox/combobox.ts +++ b/src/aria/private/combobox/combobox.ts @@ -217,6 +217,12 @@ export class ComboboxPattern { /** Handles focus out events for the combobox. */ onFocusout() { + this.closePopupOnFocusout(); + this.isFocused.set(false); + } + + /** Closes the popup once focus has left both the combobox and the popup. */ + closePopupOnFocusout() { // Give focus some time to move before we check it. setTimeout(() => { const comboboxFocused = this.isFocused(); @@ -226,8 +232,6 @@ export class ComboboxPattern { this.inputs.expanded.set(false); } }); - - this.isFocused.set(false); } /** Handles input events for the combobox. */ @@ -291,6 +295,9 @@ export interface ComboboxPopupInputs { /** The ID of the popup. */ popupId: SignalLike; + + /** A reference to the parent combobox. */ + combobox: SignalLike; } /** Controls the state of a simple combobox popup. */ @@ -307,6 +314,9 @@ export class ComboboxPopupPattern { /** The ID of the popup. */ readonly popupId = () => this.inputs.popupId(); + /** A reference to the parent combobox. */ + readonly combobox = () => this.inputs.combobox(); + /** Whether the popup is focused. */ readonly isFocused = signal(false); @@ -323,5 +333,6 @@ export class ComboboxPopupPattern { if (this.controlTarget()?.contains(focusTarget)) return; this.isFocused.set(false); + this.combobox()?.closePopupOnFocusout(); } }