From 0de2b7a3f579480373852a9632694ce398f3d813 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Tue, 10 Feb 2026 09:03:59 +0100 Subject: [PATCH 1/2] refactor(cdk/overlay): add way to only handle specific events in overlay We dispatch keyboard events to the different overlays depending on their attachment order and if they're listening for keyboard events. This works fine for the most part, but can lead to unexpected behavior where an overlay only cares about one type of event which ends up blocking the event from reaching other overlays. These changes add an `eventPredicate` option that overlay can use to allow some events to pass through. --- goldens/cdk/overlay/index.api.md | 2 ++ .../dispatchers/base-overlay-dispatcher.ts | 14 ++++++++++ .../overlay-keyboard-dispatcher.spec.ts | 20 +++++++++++++ .../overlay-keyboard-dispatcher.ts | 5 ++-- .../overlay-outside-click-dispatcher.spec.ts | 28 +++++++++++++++++++ .../overlay-outside-click-dispatcher.ts | 9 ++++-- src/cdk/overlay/overlay-config.ts | 6 ++++ src/cdk/overlay/overlay-ref.ts | 8 ++++++ 8 files changed, 88 insertions(+), 4 deletions(-) diff --git a/goldens/cdk/overlay/index.api.md b/goldens/cdk/overlay/index.api.md index e5c474f19cbf..d0acaf6c780b 100644 --- a/goldens/cdk/overlay/index.api.md +++ b/goldens/cdk/overlay/index.api.md @@ -394,6 +394,7 @@ export class OverlayConfig { direction?: Direction | Directionality; disableAnimations?: boolean; disposeOnNavigation?: boolean; + eventPredicate?: (event: Event) => boolean; hasBackdrop?: boolean; height?: number | string; maxHeight?: number | string; @@ -501,6 +502,7 @@ export class OverlayRef implements PortalOutlet { detachBackdrop(): void; detachments(): Observable; dispose(): void; + get eventPredicate(): ((event: Event) => boolean) | null; getConfig(): OverlayConfig; getDirection(): Direction; hasAttached(): boolean; diff --git a/src/cdk/overlay/dispatchers/base-overlay-dispatcher.ts b/src/cdk/overlay/dispatchers/base-overlay-dispatcher.ts index 9c4c28490271..4d56a65ef244 100644 --- a/src/cdk/overlay/dispatchers/base-overlay-dispatcher.ts +++ b/src/cdk/overlay/dispatchers/base-overlay-dispatcher.ts @@ -8,6 +8,7 @@ import {Injectable, OnDestroy, inject, DOCUMENT} from '@angular/core'; import type {OverlayRef} from '../overlay-ref'; +import {Subject} from 'rxjs'; /** * Service for dispatching events that land on the body to appropriate overlay ref, @@ -53,4 +54,17 @@ export abstract class BaseOverlayDispatcher implements OnDestroy { /** Detaches the global event listener. */ protected abstract detach(): void; + + /** Determines whether an overlay is allowed to receive an event. */ + protected canReceiveEvent(overlayRef: OverlayRef, event: Event, stream: Subject): boolean { + if (stream.observers.length < 1) { + return false; + } + + if (overlayRef.eventPredicate) { + return overlayRef.eventPredicate(event); + } + + return true; + } } diff --git a/src/cdk/overlay/dispatchers/overlay-keyboard-dispatcher.spec.ts b/src/cdk/overlay/dispatchers/overlay-keyboard-dispatcher.spec.ts index 8d4d378a4f02..a5b89112a693 100644 --- a/src/cdk/overlay/dispatchers/overlay-keyboard-dispatcher.spec.ts +++ b/src/cdk/overlay/dispatchers/overlay-keyboard-dispatcher.spec.ts @@ -187,6 +187,26 @@ describe('OverlayKeyboardDispatcher', () => { dispatchKeyboardEvent(document.body, 'keydown', ESCAPE); expect(appRef.tick).toHaveBeenCalledTimes(0); }); + + it('should not dispatch to overlay whose eventPredicate does not allow the event', () => { + const overlayOne = createOverlayRef(injector); + const overlayTwo = createOverlayRef(injector, {eventPredicate: () => false}); + const overlayOneSpy = jasmine.createSpy('overlayOne keyboard event spy'); + const overlayTwoSpy = jasmine.createSpy('overlayTwo keyboard event spy'); + + overlayOne.keydownEvents().subscribe(overlayOneSpy); + overlayTwo.keydownEvents().subscribe(overlayTwoSpy); + + // Attach overlays + keyboardDispatcher.add(overlayOne); + keyboardDispatcher.add(overlayTwo); + + dispatchKeyboardEvent(document.body, 'keydown', ESCAPE); + + // Most recent overlay should receive event + expect(overlayOneSpy).toHaveBeenCalled(); + expect(overlayTwoSpy).not.toHaveBeenCalled(); + }); }); @Component({template: 'Hello'}) diff --git a/src/cdk/overlay/dispatchers/overlay-keyboard-dispatcher.ts b/src/cdk/overlay/dispatchers/overlay-keyboard-dispatcher.ts index 82d8a9bb5604..b3b0e6aff9d6 100644 --- a/src/cdk/overlay/dispatchers/overlay-keyboard-dispatcher.ts +++ b/src/cdk/overlay/dispatchers/overlay-keyboard-dispatcher.ts @@ -54,8 +54,9 @@ export class OverlayKeyboardDispatcher extends BaseOverlayDispatcher { // (e.g. for select and autocomplete). We skip overlays without keydown event subscriptions, // because we don't want overlays that don't handle keyboard events to block the ones below // them that do. - if (overlays[i]._keydownEvents.observers.length > 0) { - this._ngZone.run(() => overlays[i]._keydownEvents.next(event)); + const overlayRef = overlays[i]; + if (this.canReceiveEvent(overlayRef, event, overlayRef._keydownEvents)) { + this._ngZone.run(() => overlayRef._keydownEvents.next(event)); break; } } diff --git a/src/cdk/overlay/dispatchers/overlay-outside-click-dispatcher.spec.ts b/src/cdk/overlay/dispatchers/overlay-outside-click-dispatcher.spec.ts index b9ff3647b097..0ce18f25c739 100644 --- a/src/cdk/overlay/dispatchers/overlay-outside-click-dispatcher.spec.ts +++ b/src/cdk/overlay/dispatchers/overlay-outside-click-dispatcher.spec.ts @@ -334,6 +334,34 @@ describe('OverlayOutsideClickDispatcher', () => { thirdOverlayRef.dispose(); }); + it('should not dispatch to overlays whose eventPredicate does not allow the event', () => { + const eventPredicate = () => false; + const overlayOne = createOverlayRef(injector, {eventPredicate}); + overlayOne.attach(new ComponentPortal(TestComponent)); + const overlayTwo = createOverlayRef(injector, {eventPredicate}); + overlayTwo.attach(new ComponentPortal(TestComponent)); + + const overlayOneSpy = jasmine.createSpy('overlayOne mouse click event spy'); + const overlayTwoSpy = jasmine.createSpy('overlayTwo mouse click event spy'); + + overlayOne.outsidePointerEvents().subscribe(overlayOneSpy); + overlayTwo.outsidePointerEvents().subscribe(overlayTwoSpy); + + outsideClickDispatcher.add(overlayOne); + outsideClickDispatcher.add(overlayTwo); + + const button = document.createElement('button'); + document.body.appendChild(button); + button.click(); + + expect(overlayOneSpy).not.toHaveBeenCalled(); + expect(overlayTwoSpy).not.toHaveBeenCalled(); + + button.remove(); + overlayOne.dispose(); + overlayTwo.dispose(); + }); + describe('change detection behavior', () => { it('should not run change detection if there is no portal attached to the overlay', () => { spyOn(appRef, 'tick'); diff --git a/src/cdk/overlay/dispatchers/overlay-outside-click-dispatcher.ts b/src/cdk/overlay/dispatchers/overlay-outside-click-dispatcher.ts index 5077f69cfe0d..710a33438212 100644 --- a/src/cdk/overlay/dispatchers/overlay-outside-click-dispatcher.ts +++ b/src/cdk/overlay/dispatchers/overlay-outside-click-dispatcher.ts @@ -107,7 +107,13 @@ export class OverlayOutsideClickDispatcher extends BaseOverlayDispatcher { // the loop. for (let i = overlays.length - 1; i > -1; i--) { const overlayRef = overlays[i]; - if (overlayRef._outsidePointerEvents.observers.length < 1 || !overlayRef.hasAttached()) { + const outsidePointerEvents = overlayRef._outsidePointerEvents; + + if ( + // TODO(crisbeto): this should move into `canReceiveEvent` but may be breaking. + !overlayRef.hasAttached() || + !this.canReceiveEvent(overlayRef, event, outsidePointerEvents) + ) { continue; } @@ -121,7 +127,6 @@ export class OverlayOutsideClickDispatcher extends BaseOverlayDispatcher { break; } - const outsidePointerEvents = overlayRef._outsidePointerEvents; /** @breaking-change 14.0.0 _ngZone will be required. */ if (this._ngZone) { this._ngZone.run(() => outsidePointerEvents.next(event)); diff --git a/src/cdk/overlay/overlay-config.ts b/src/cdk/overlay/overlay-config.ts index c49ed9c1eb2b..e0ec3af1fec2 100644 --- a/src/cdk/overlay/overlay-config.ts +++ b/src/cdk/overlay/overlay-config.ts @@ -67,6 +67,12 @@ export class OverlayConfig { */ usePopover?: boolean; + /** + * Function that determines if the overlay should receive a specific + * event or if the event should go to the next overlay in the stack. + */ + eventPredicate?: (event: Event) => boolean; + constructor(config?: OverlayConfig) { if (config) { // Use `Iterable` instead of `Array` because TypeScript, as of 3.6.3, diff --git a/src/cdk/overlay/overlay-ref.ts b/src/cdk/overlay/overlay-ref.ts index f60a8a72ad3c..040efd324f95 100644 --- a/src/cdk/overlay/overlay-ref.ts +++ b/src/cdk/overlay/overlay-ref.ts @@ -109,6 +109,14 @@ export class OverlayRef implements PortalOutlet { return this._host; } + /** + * Function that determines if this overlay should receive a specific event. + */ + get eventPredicate(): ((event: Event) => boolean) | null { + // Note: the safe read here is redundant, but some internal tests mock out the overlay ref. + return this._config?.eventPredicate || null; + } + attach(portal: ComponentPortal): ComponentRef; attach(portal: TemplatePortal): EmbeddedViewRef; attach(portal: any): any; From 003a4c25d5ba242f621c64818a5dd4091ac51d20 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Tue, 10 Feb 2026 14:13:59 +0100 Subject: [PATCH 2/2] fix(material/tooltip): do not block events to other overlays Fixes that the tooltip was blocking keyboard events to other overlays, even if it doesn't care about them. Fixes #32760. --- src/material/tooltip/tooltip.ts | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/material/tooltip/tooltip.ts b/src/material/tooltip/tooltip.ts index 885ae2d08cb8..221b4199c4e8 100644 --- a/src/material/tooltip/tooltip.ts +++ b/src/material/tooltip/tooltip.ts @@ -543,6 +543,7 @@ export class MatTooltip implements OnDestroy, AfterViewInit { panelClass: this._overlayPanelClass ? [...this._overlayPanelClass, panelClass] : panelClass, scrollStrategy: this._injector.get(MAT_TOOLTIP_SCROLL_STRATEGY)(), disableAnimations: this._animationsDisabled, + eventPredicate: this._overlayEventPredicate, }); this._updatePosition(this._overlayRef); @@ -561,11 +562,10 @@ export class MatTooltip implements OnDestroy, AfterViewInit { .keydownEvents() .pipe(takeUntil(this._destroyed)) .subscribe(event => { - if (this._isTooltipVisible() && event.keyCode === ESCAPE && !hasModifierKey(event)) { - event.preventDefault(); - event.stopPropagation(); - this._ngZone.run(() => this.hide(0)); - } + // Note: we don't check the `keyCode` since it's covered by the `eventPredicate` above. + event.preventDefault(); + event.stopPropagation(); + this._ngZone.run(() => this.hide(0)); }); if (this._defaultOptions?.disableTooltipInteractivity) { @@ -935,6 +935,18 @@ export class MatTooltip implements OnDestroy, AfterViewInit { ); } } + + /** Determines which events should be routed to the tooltip overlay. */ + private _overlayEventPredicate = (event: Event) => { + if (event.type === 'keydown') { + return ( + this._isTooltipVisible() && + (event as KeyboardEvent).keyCode === ESCAPE && + !hasModifierKey(event as KeyboardEvent) + ); + } + return true; + }; } /**