Skip to content

Commit e74c69b

Browse files
committed
fix(aria/combobox): closing immediately when opening programmatically with zone.js (angular#33518)
The combobox was using an effect to decide whether to close the overlay, however this was brittle because it relied on the effect scheduler to run once all bindings have been evaluated. This was breaking down with Zone.js which seemed to fire the effect for each signal change individually. These changes resolve the issue by moving the logic into the `focusout` handler which gives us a better indication about where the event is coming from. Fixes angular#33516. (cherry picked from commit def7bc2)
1 parent b2620ab commit e74c69b

4 files changed

Lines changed: 26 additions & 20 deletions

File tree

goldens/aria/private/index.api.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,6 @@ export class ComboboxPattern {
7777
readonly activeDescendant: _angular_core.Signal<string | undefined>;
7878
readonly autocomplete: _angular_core.Signal<"none" | "inline" | "list" | "both">;
7979
click: _angular_core.Signal<ClickEventManager<PointerEvent>>;
80-
closePopupOnBlurEffect(): void;
8180
readonly disabled: () => boolean;
8281
readonly element: () => HTMLElement;
8382
highlightEffect(): void;
@@ -93,7 +92,7 @@ export class ComboboxPattern {
9392
keydown: _angular_core.Signal<KeyboardEventManager<KeyboardEvent>>;
9493
onClick(event: PointerEvent): void;
9594
onFocusin(): void;
96-
onFocusout(event: FocusEvent): void;
95+
onFocusout(): void;
9796
onInput(event: Event): void;
9897
onKeydown(event: KeyboardEvent): void;
9998
readonly popupId: _angular_core.Signal<string | undefined>;

src/aria/combobox/combobox.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ import type {ComboboxPopup} from './combobox-popup';
7373
'[attr.readonly]': 'disabled() && _pattern.isEditable() ? "" : null',
7474
'(keydown)': '_pattern.onKeydown($event)',
7575
'(focusin)': '_pattern.onFocusin()',
76-
'(focusout)': '_pattern.onFocusout($event)',
76+
'(focusout)': '_pattern.onFocusout()',
7777
'(click)': '_pattern.onClick($event)',
7878
'(input)': '_pattern.onInput($event)',
7979
},
@@ -126,7 +126,6 @@ export class Combobox extends DeferredContentAware implements OnInit {
126126
super();
127127

128128
afterRenderEffect({write: () => this._pattern.keyboardEventRelayEffect()});
129-
afterRenderEffect(() => this._pattern.closePopupOnBlurEffect());
130129
afterRenderEffect(() => {
131130
this.contentVisible.set(this._pattern.isExpanded());
132131
});

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

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ describe('ComboboxPattern', () => {
5555
};
5656
}
5757

58+
function wait(milliseconds: number) {
59+
return new Promise(resolve => setTimeout(resolve, milliseconds));
60+
}
61+
5862
describe('Aria-autocomplete calculation', () => {
5963
it('should return "list" when only popup is present', () => {
6064
const {pattern} = setup();
@@ -115,7 +119,7 @@ describe('ComboboxPattern', () => {
115119
pattern.onFocusin();
116120
expect(pattern.isFocused()).toBe(true);
117121

118-
pattern.onFocusout(new FocusEvent('focusout'));
122+
pattern.onFocusout();
119123
expect(pattern.isFocused()).toBe(false);
120124
});
121125
});
@@ -202,23 +206,27 @@ describe('ComboboxPattern', () => {
202206
});
203207

204208
describe('Blur behavior', () => {
205-
it('should close when focus leaves both combobox and popup', () => {
209+
it('should close when focus leaves both combobox and popup', async () => {
206210
const {pattern, expanded} = setup();
207211
expanded.set(true);
208212
pattern.isFocused.set(false);
209213
pattern.inputs.popup()!.isFocused.set(false);
210214

211-
pattern.closePopupOnBlurEffect();
215+
pattern.onFocusout();
216+
await wait(100);
217+
212218
expect(expanded()).toBe(false);
213219
});
214220

215-
it('should remain open if popup is focused', () => {
221+
it('should remain open if popup is focused', async () => {
216222
const {pattern, expanded} = setup();
217223
expanded.set(true);
218224
pattern.isFocused.set(false);
219225
pattern.inputs.popup()!.isFocused.set(true);
220226

221-
pattern.closePopupOnBlurEffect();
227+
pattern.onFocusout();
228+
await wait(100);
229+
222230
expect(expanded()).toBe(true);
223231
});
224232
});

src/aria/private/combobox/combobox.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,17 @@ export class ComboboxPattern {
193193
}
194194

195195
/** Handles focus out events for the combobox. */
196-
onFocusout(event: FocusEvent) {
196+
onFocusout() {
197+
// Give focus some time to move before we check it.
198+
setTimeout(() => {
199+
const comboboxFocused = this.isFocused();
200+
const popupFocused = !!this.inputs.popup()?.isFocused();
201+
202+
if (!this.inputs.alwaysExpanded() && !comboboxFocused && !popupFocused) {
203+
this.inputs.expanded.set(false);
204+
}
205+
});
206+
197207
this.isFocused.set(false);
198208
}
199209

@@ -243,16 +253,6 @@ export class ComboboxPattern {
243253
}
244254
});
245255
}
246-
247-
/** Closes the popup when focus leaves the combobox and popup. */
248-
closePopupOnBlurEffect() {
249-
const expanded = this.isExpanded();
250-
const comboboxFocused = this.isFocused();
251-
const popupFocused = !!this.inputs.popup()?.isFocused();
252-
if (expanded && !this.inputs.alwaysExpanded() && !comboboxFocused && !popupFocused) {
253-
this.inputs.expanded.set(false);
254-
}
255-
}
256256
}
257257

258258
/** Represents the required inputs for a simple combobox popup. */

0 commit comments

Comments
 (0)