Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions goldens/cdk/overlay/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -501,6 +502,7 @@ export class OverlayRef implements PortalOutlet {
detachBackdrop(): void;
detachments(): Observable<void>;
dispose(): void;
get eventPredicate(): ((event: Event) => boolean) | null;
getConfig(): OverlayConfig;
getDirection(): Direction;
hasAttached(): boolean;
Expand Down
14 changes: 14 additions & 0 deletions src/cdk/overlay/dispatchers/base-overlay-dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<T>(overlayRef: OverlayRef, event: Event, stream: Subject<T>): boolean {
if (stream.observers.length < 1) {
return false;
}

if (overlayRef.eventPredicate) {
return overlayRef.eventPredicate(event);
}

return true;
}
}
20 changes: 20 additions & 0 deletions src/cdk/overlay/dispatchers/overlay-keyboard-dispatcher.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'})
Expand Down
5 changes: 3 additions & 2 deletions src/cdk/overlay/dispatchers/overlay-keyboard-dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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));
Expand Down
6 changes: 6 additions & 0 deletions src/cdk/overlay/overlay-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions src/cdk/overlay/overlay-ref.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(portal: ComponentPortal<T>): ComponentRef<T>;
attach<T>(portal: TemplatePortal<T>): EmbeddedViewRef<T>;
attach(portal: any): any;
Expand Down
22 changes: 17 additions & 5 deletions src/material/tooltip/tooltip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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) {
Expand Down Expand Up @@ -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;
};
}

/**
Expand Down
Loading