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; 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; + }; } /**