Skip to content

Commit ff2a3cc

Browse files
authored
fix(material/autocomplete): blocking events to other overlays when there are no results (#27432)
The autocomplete trigger attaches an overlay even if there are no options in the list. It also subscribes to keydown events and clicks while the panel is open which block events from reaching other overlays. This means that if the user focuses an autocomplete input with no options, the events from it won't reach other overlays. These changes resolve the issue by subcribing and unsubscribing from the event streams depending on the visibility state of the panel. Fixes #26479.
1 parent b6ce8cc commit ff2a3cc

File tree

2 files changed

+72
-38
lines changed

2 files changed

+72
-38
lines changed

src/material/autocomplete/autocomplete-trigger.ts

Lines changed: 58 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,8 @@ export abstract class _MatAutocompleteTriggerBase
106106
private _componentDestroyed = false;
107107
private _autocompleteDisabled = false;
108108
private _scrollStrategy: () => ScrollStrategy;
109+
private _keydownSubscription: Subscription | null;
110+
private _outsideClickSubscription: Subscription | null;
109111

110112
/** Old value of the native input. Used to work around issues with the `input` event on IE. */
111113
private _previousValue: string | number | null;
@@ -286,6 +288,8 @@ export abstract class _MatAutocompleteTriggerBase
286288
this._closingActionsSubscription.unsubscribe();
287289
}
288290

291+
this._updatePanelState();
292+
289293
// Note that in some cases this can end up being called after the component is destroyed.
290294
// Add a check to ensure that we don't try to run change detection on a destroyed view.
291295
if (!this._componentDestroyed) {
@@ -545,7 +549,7 @@ export abstract class _MatAutocompleteTriggerBase
545549
this._zone.run(() => {
546550
const wasOpen = this.panelOpen;
547551
this._resetActiveItem();
548-
this.autocomplete._setVisibility();
552+
this._updatePanelState();
549553
this._changeDetectorRef.detectChanges();
550554

551555
if (this.panelOpen) {
@@ -655,7 +659,6 @@ export abstract class _MatAutocompleteTriggerBase
655659
});
656660
overlayRef = this._overlay.create(this._getOverlayConfig());
657661
this._overlayRef = overlayRef;
658-
this._handleOverlayEvents(overlayRef);
659662
this._viewportSubscription = this._viewportRuler.change().subscribe(() => {
660663
if (this.panelOpen && overlayRef) {
661664
overlayRef.updateSize({width: this._getPanelWidth()});
@@ -674,9 +677,9 @@ export abstract class _MatAutocompleteTriggerBase
674677

675678
const wasOpen = this.panelOpen;
676679

677-
this.autocomplete._setVisibility();
678680
this.autocomplete._isOpen = this._overlayAttached = true;
679681
this.autocomplete._setColor(this._formField?.color);
682+
this._updatePanelState();
680683

681684
this._applyModalPanelOwnership();
682685

@@ -687,6 +690,58 @@ export abstract class _MatAutocompleteTriggerBase
687690
}
688691
}
689692

693+
/** Handles keyboard events coming from the overlay panel. */
694+
private _handlePanelKeydown = (event: KeyboardEvent) => {
695+
// Close when pressing ESCAPE or ALT + UP_ARROW, based on the a11y guidelines.
696+
// See: https://www.w3.org/TR/wai-aria-practices-1.1/#textbox-keyboard-interaction
697+
if (
698+
(event.keyCode === ESCAPE && !hasModifierKey(event)) ||
699+
(event.keyCode === UP_ARROW && hasModifierKey(event, 'altKey'))
700+
) {
701+
// If the user had typed something in before we autoselected an option, and they decided
702+
// to cancel the selection, restore the input value to the one they had typed in.
703+
if (this._pendingAutoselectedOption) {
704+
this._updateNativeInputValue(this._valueBeforeAutoSelection ?? '');
705+
this._pendingAutoselectedOption = null;
706+
}
707+
this._closeKeyEventStream.next();
708+
this._resetActiveItem();
709+
// We need to stop propagation, otherwise the event will eventually
710+
// reach the input itself and cause the overlay to be reopened.
711+
event.stopPropagation();
712+
event.preventDefault();
713+
}
714+
};
715+
716+
/** Updates the panel's visibility state and any trigger state tied to id. */
717+
private _updatePanelState() {
718+
this.autocomplete._setVisibility();
719+
720+
// Note that here we subscribe and unsubscribe based on the panel's visiblity state,
721+
// because the act of subscribing will prevent events from reaching other overlays and
722+
// we don't want to block the events if there are no options.
723+
if (this.panelOpen) {
724+
const overlayRef = this._overlayRef!;
725+
726+
if (!this._keydownSubscription) {
727+
// Use the `keydownEvents` in order to take advantage of
728+
// the overlay event targeting provided by the CDK overlay.
729+
this._keydownSubscription = overlayRef.keydownEvents().subscribe(this._handlePanelKeydown);
730+
}
731+
732+
if (!this._outsideClickSubscription) {
733+
// Subscribe to the pointer events stream so that it doesn't get picked up by other overlays.
734+
// TODO(crisbeto): we should switch `_getOutsideClickStream` eventually to use this stream,
735+
// but the behvior isn't exactly the same and it ends up breaking some internal tests.
736+
this._outsideClickSubscription = overlayRef.outsidePointerEvents().subscribe();
737+
}
738+
} else {
739+
this._keydownSubscription?.unsubscribe();
740+
this._outsideClickSubscription?.unsubscribe();
741+
this._keydownSubscription = this._outsideClickSubscription = null;
742+
}
743+
}
744+
690745
private _getOverlayConfig(): OverlayConfig {
691746
return new OverlayConfig({
692747
positionStrategy: this._getOverlayPosition(),
@@ -835,40 +890,6 @@ export abstract class _MatAutocompleteTriggerBase
835890
}
836891
}
837892

838-
/** Handles keyboard events coming from the overlay panel. */
839-
private _handleOverlayEvents(overlayRef: OverlayRef) {
840-
// Use the `keydownEvents` in order to take advantage of
841-
// the overlay event targeting provided by the CDK overlay.
842-
overlayRef.keydownEvents().subscribe(event => {
843-
// Close when pressing ESCAPE or ALT + UP_ARROW, based on the a11y guidelines.
844-
// See: https://www.w3.org/TR/wai-aria-practices-1.1/#textbox-keyboard-interaction
845-
if (
846-
(event.keyCode === ESCAPE && !hasModifierKey(event)) ||
847-
(event.keyCode === UP_ARROW && hasModifierKey(event, 'altKey'))
848-
) {
849-
// If the user had typed something in before we autoselected an option, and they decided
850-
// to cancel the selection, restore the input value to the one they had typed in.
851-
if (this._pendingAutoselectedOption) {
852-
this._updateNativeInputValue(this._valueBeforeAutoSelection ?? '');
853-
this._pendingAutoselectedOption = null;
854-
}
855-
856-
this._closeKeyEventStream.next();
857-
this._resetActiveItem();
858-
859-
// We need to stop propagation, otherwise the event will eventually
860-
// reach the input itself and cause the overlay to be reopened.
861-
event.stopPropagation();
862-
event.preventDefault();
863-
}
864-
});
865-
866-
// Subscribe to the pointer events stream so that it doesn't get picked up by other overlays.
867-
// TODO(crisbeto): we should switch `_getOutsideClickStream` eventually to use this stream,
868-
// but the behvior isn't exactly the same and it ends up breaking some internal tests.
869-
overlayRef.outsidePointerEvents().subscribe();
870-
}
871-
872893
/**
873894
* Track which modal we have modified the `aria-owns` attribute of. When the combobox trigger is
874895
* inside an aria-modal, we apply aria-owns to the parent modal with the `id` of the options

src/material/autocomplete/autocomplete.spec.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -570,7 +570,7 @@ describe('MDC-based MatAutocomplete', () => {
570570
expect(input.hasAttribute('aria-haspopup')).toBe(false);
571571
});
572572

573-
it('should close the panel when pressing escape', fakeAsync(() => {
573+
it('should reopen the panel when clicking on the input', fakeAsync(() => {
574574
const trigger = fixture.componentInstance.trigger;
575575

576576
input.focus();
@@ -2577,6 +2577,19 @@ describe('MDC-based MatAutocomplete', () => {
25772577
dispatchKeyboardEvent(document.body, 'keydown', ESCAPE);
25782578
expect(closingActionSpy).toHaveBeenCalledWith(null);
25792579
});
2580+
2581+
it('should not prevent escape key propagation when there are no options', () => {
2582+
fixture.componentInstance.filteredStates = fixture.componentInstance.states = [];
2583+
fixture.detectChanges();
2584+
zone.simulateZoneExit();
2585+
2586+
const event = createKeyboardEvent('keydown', ESCAPE);
2587+
spyOn(event, 'stopPropagation').and.callThrough();
2588+
dispatchEvent(document.body, event);
2589+
fixture.detectChanges();
2590+
2591+
expect(event.stopPropagation).not.toHaveBeenCalled();
2592+
});
25802593
});
25812594

25822595
describe('without matInput', () => {

0 commit comments

Comments
 (0)