Skip to content

Commit def7bc2

Browse files
authored
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.
1 parent ebd64a1 commit def7bc2

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
@@ -79,7 +79,6 @@ export class ComboboxPattern {
7979
readonly ariaReadonly: _angular_core.Signal<"true" | null>;
8080
readonly autocomplete: _angular_core.Signal<"none" | "inline" | "list" | "both">;
8181
click: _angular_core.Signal<ClickEventManager<PointerEvent>>;
82-
closePopupOnBlurEffect(): void;
8382
readonly disabled: () => boolean;
8483
readonly element: () => HTMLElement;
8584
highlightEffect(): void;
@@ -97,7 +96,7 @@ export class ComboboxPattern {
9796
readonly nativeReadonly: _angular_core.Signal<"" | null>;
9897
onClick(event: PointerEvent): void;
9998
onFocusin(): void;
100-
onFocusout(event: FocusEvent): void;
99+
onFocusout(): void;
101100
onInput(event: Event): void;
102101
onKeydown(event: KeyboardEvent): void;
103102
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
@@ -74,7 +74,7 @@ import type {ComboboxPopup} from './combobox-popup';
7474
'[attr.readonly]': '_pattern.nativeReadonly()',
7575
'(keydown)': '_pattern.onKeydown($event)',
7676
'(focusin)': '_pattern.onFocusin()',
77-
'(focusout)': '_pattern.onFocusout($event)',
77+
'(focusout)': '_pattern.onFocusout()',
7878
'(click)': '_pattern.onClick($event)',
7979
'(input)': '_pattern.onInput($event)',
8080
},
@@ -131,7 +131,6 @@ export class Combobox extends DeferredContentAware implements OnInit {
131131
super();
132132

133133
afterRenderEffect({write: () => this._pattern.keyboardEventRelayEffect()});
134-
afterRenderEffect(() => this._pattern.closePopupOnBlurEffect());
135134
afterRenderEffect(() => {
136135
this.contentVisible.set(this._pattern.isExpanded());
137136
});

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

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ describe('ComboboxPattern', () => {
5959
};
6060
}
6161

62+
function wait(milliseconds: number) {
63+
return new Promise(resolve => setTimeout(resolve, milliseconds));
64+
}
65+
6266
describe('Aria-autocomplete calculation', () => {
6367
it('should return "list" when only popup is present', () => {
6468
const {pattern} = setup();
@@ -119,7 +123,7 @@ describe('ComboboxPattern', () => {
119123
pattern.onFocusin();
120124
expect(pattern.isFocused()).toBe(true);
121125

122-
pattern.onFocusout(new FocusEvent('focusout'));
126+
pattern.onFocusout();
123127
expect(pattern.isFocused()).toBe(false);
124128
});
125129
});
@@ -206,23 +210,27 @@ describe('ComboboxPattern', () => {
206210
});
207211

208212
describe('Blur behavior', () => {
209-
it('should close when focus leaves both combobox and popup', () => {
213+
it('should close when focus leaves both combobox and popup', async () => {
210214
const {pattern, expanded} = setup();
211215
expanded.set(true);
212216
pattern.isFocused.set(false);
213217
pattern.inputs.popup()!.isFocused.set(false);
214218

215-
pattern.closePopupOnBlurEffect();
219+
pattern.onFocusout();
220+
await wait(100);
221+
216222
expect(expanded()).toBe(false);
217223
});
218224

219-
it('should remain open if popup is focused', () => {
225+
it('should remain open if popup is focused', async () => {
220226
const {pattern, expanded} = setup();
221227
expanded.set(true);
222228
pattern.isFocused.set(false);
223229
pattern.inputs.popup()!.isFocused.set(true);
224230

225-
pattern.closePopupOnBlurEffect();
231+
pattern.onFocusout();
232+
await wait(100);
233+
226234
expect(expanded()).toBe(true);
227235
});
228236
});

src/aria/private/combobox/combobox.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,17 @@ export class ComboboxPattern {
215215
}
216216

217217
/** Handles focus out events for the combobox. */
218-
onFocusout(event: FocusEvent) {
218+
onFocusout() {
219+
// Give focus some time to move before we check it.
220+
setTimeout(() => {
221+
const comboboxFocused = this.isFocused();
222+
const popupFocused = !!this.inputs.popup()?.isFocused();
223+
224+
if (!this.inputs.alwaysExpanded() && !comboboxFocused && !popupFocused) {
225+
this.inputs.expanded.set(false);
226+
}
227+
});
228+
219229
this.isFocused.set(false);
220230
}
221231

@@ -265,16 +275,6 @@ export class ComboboxPattern {
265275
}
266276
});
267277
}
268-
269-
/** Closes the popup when focus leaves the combobox and popup. */
270-
closePopupOnBlurEffect() {
271-
const expanded = this.isExpanded();
272-
const comboboxFocused = this.isFocused();
273-
const popupFocused = !!this.inputs.popup()?.isFocused();
274-
if (expanded && !this.inputs.alwaysExpanded() && !comboboxFocused && !popupFocused) {
275-
this.inputs.expanded.set(false);
276-
}
277-
}
278278
}
279279

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

0 commit comments

Comments
 (0)