Skip to content

Commit 00806ea

Browse files
committed
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. (cherry picked from commit b8eb7ec)
1 parent 58870b5 commit 00806ea

8 files changed

Lines changed: 88 additions & 4 deletions

goldens/cdk/overlay/index.api.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,7 @@ export class OverlayConfig {
394394
direction?: Direction | Directionality;
395395
disableAnimations?: boolean;
396396
disposeOnNavigation?: boolean;
397+
eventPredicate?: (event: Event) => boolean;
397398
hasBackdrop?: boolean;
398399
height?: number | string;
399400
maxHeight?: number | string;
@@ -501,6 +502,7 @@ export class OverlayRef implements PortalOutlet {
501502
detachBackdrop(): void;
502503
detachments(): Observable<void>;
503504
dispose(): void;
505+
get eventPredicate(): ((event: Event) => boolean) | null;
504506
getConfig(): OverlayConfig;
505507
getDirection(): Direction;
506508
hasAttached(): boolean;

src/cdk/overlay/dispatchers/base-overlay-dispatcher.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import {Injectable, OnDestroy, inject, DOCUMENT} from '@angular/core';
1010
import type {OverlayRef} from '../overlay-ref';
11+
import {Subject} from 'rxjs';
1112

1213
/**
1314
* Service for dispatching events that land on the body to appropriate overlay ref,
@@ -53,4 +54,17 @@ export abstract class BaseOverlayDispatcher implements OnDestroy {
5354

5455
/** Detaches the global event listener. */
5556
protected abstract detach(): void;
57+
58+
/** Determines whether an overlay is allowed to receive an event. */
59+
protected canReceiveEvent<T>(overlayRef: OverlayRef, event: Event, stream: Subject<T>): boolean {
60+
if (stream.observers.length < 1) {
61+
return false;
62+
}
63+
64+
if (overlayRef.eventPredicate) {
65+
return overlayRef.eventPredicate(event);
66+
}
67+
68+
return true;
69+
}
5670
}

src/cdk/overlay/dispatchers/overlay-keyboard-dispatcher.spec.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,26 @@ describe('OverlayKeyboardDispatcher', () => {
187187
dispatchKeyboardEvent(document.body, 'keydown', ESCAPE);
188188
expect(appRef.tick).toHaveBeenCalledTimes(0);
189189
});
190+
191+
it('should not dispatch to overlay whose eventPredicate does not allow the event', () => {
192+
const overlayOne = createOverlayRef(injector);
193+
const overlayTwo = createOverlayRef(injector, {eventPredicate: () => false});
194+
const overlayOneSpy = jasmine.createSpy('overlayOne keyboard event spy');
195+
const overlayTwoSpy = jasmine.createSpy('overlayTwo keyboard event spy');
196+
197+
overlayOne.keydownEvents().subscribe(overlayOneSpy);
198+
overlayTwo.keydownEvents().subscribe(overlayTwoSpy);
199+
200+
// Attach overlays
201+
keyboardDispatcher.add(overlayOne);
202+
keyboardDispatcher.add(overlayTwo);
203+
204+
dispatchKeyboardEvent(document.body, 'keydown', ESCAPE);
205+
206+
// Most recent overlay should receive event
207+
expect(overlayOneSpy).toHaveBeenCalled();
208+
expect(overlayTwoSpy).not.toHaveBeenCalled();
209+
});
190210
});
191211

192212
@Component({template: 'Hello'})

src/cdk/overlay/dispatchers/overlay-keyboard-dispatcher.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,9 @@ export class OverlayKeyboardDispatcher extends BaseOverlayDispatcher {
5454
// (e.g. for select and autocomplete). We skip overlays without keydown event subscriptions,
5555
// because we don't want overlays that don't handle keyboard events to block the ones below
5656
// them that do.
57-
if (overlays[i]._keydownEvents.observers.length > 0) {
58-
this._ngZone.run(() => overlays[i]._keydownEvents.next(event));
57+
const overlayRef = overlays[i];
58+
if (this.canReceiveEvent(overlayRef, event, overlayRef._keydownEvents)) {
59+
this._ngZone.run(() => overlayRef._keydownEvents.next(event));
5960
break;
6061
}
6162
}

src/cdk/overlay/dispatchers/overlay-outside-click-dispatcher.spec.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,34 @@ describe('OverlayOutsideClickDispatcher', () => {
334334
thirdOverlayRef.dispose();
335335
});
336336

337+
it('should not dispatch to overlays whose eventPredicate does not allow the event', () => {
338+
const eventPredicate = () => false;
339+
const overlayOne = createOverlayRef(injector, {eventPredicate});
340+
overlayOne.attach(new ComponentPortal(TestComponent));
341+
const overlayTwo = createOverlayRef(injector, {eventPredicate});
342+
overlayTwo.attach(new ComponentPortal(TestComponent));
343+
344+
const overlayOneSpy = jasmine.createSpy('overlayOne mouse click event spy');
345+
const overlayTwoSpy = jasmine.createSpy('overlayTwo mouse click event spy');
346+
347+
overlayOne.outsidePointerEvents().subscribe(overlayOneSpy);
348+
overlayTwo.outsidePointerEvents().subscribe(overlayTwoSpy);
349+
350+
outsideClickDispatcher.add(overlayOne);
351+
outsideClickDispatcher.add(overlayTwo);
352+
353+
const button = document.createElement('button');
354+
document.body.appendChild(button);
355+
button.click();
356+
357+
expect(overlayOneSpy).not.toHaveBeenCalled();
358+
expect(overlayTwoSpy).not.toHaveBeenCalled();
359+
360+
button.remove();
361+
overlayOne.dispose();
362+
overlayTwo.dispose();
363+
});
364+
337365
describe('change detection behavior', () => {
338366
it('should not run change detection if there is no portal attached to the overlay', () => {
339367
spyOn(appRef, 'tick');

src/cdk/overlay/dispatchers/overlay-outside-click-dispatcher.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,13 @@ export class OverlayOutsideClickDispatcher extends BaseOverlayDispatcher {
107107
// the loop.
108108
for (let i = overlays.length - 1; i > -1; i--) {
109109
const overlayRef = overlays[i];
110-
if (overlayRef._outsidePointerEvents.observers.length < 1 || !overlayRef.hasAttached()) {
110+
const outsidePointerEvents = overlayRef._outsidePointerEvents;
111+
112+
if (
113+
// TODO(crisbeto): this should move into `canReceiveEvent` but may be breaking.
114+
!overlayRef.hasAttached() ||
115+
!this.canReceiveEvent(overlayRef, event, outsidePointerEvents)
116+
) {
111117
continue;
112118
}
113119

@@ -121,7 +127,6 @@ export class OverlayOutsideClickDispatcher extends BaseOverlayDispatcher {
121127
break;
122128
}
123129

124-
const outsidePointerEvents = overlayRef._outsidePointerEvents;
125130
/** @breaking-change 14.0.0 _ngZone will be required. */
126131
if (this._ngZone) {
127132
this._ngZone.run(() => outsidePointerEvents.next(event));

src/cdk/overlay/overlay-config.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,12 @@ export class OverlayConfig {
6767
*/
6868
usePopover?: boolean;
6969

70+
/**
71+
* Function that determines if the overlay should receive a specific
72+
* event or if the event should go to the next overlay in the stack.
73+
*/
74+
eventPredicate?: (event: Event) => boolean;
75+
7076
constructor(config?: OverlayConfig) {
7177
if (config) {
7278
// Use `Iterable` instead of `Array` because TypeScript, as of 3.6.3,

src/cdk/overlay/overlay-ref.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,14 @@ export class OverlayRef implements PortalOutlet {
109109
return this._host;
110110
}
111111

112+
/**
113+
* Function that determines if this overlay should receive a specific event.
114+
*/
115+
get eventPredicate(): ((event: Event) => boolean) | null {
116+
// Note: the safe read here is redundant, but some internal tests mock out the overlay ref.
117+
return this._config?.eventPredicate || null;
118+
}
119+
112120
attach<T>(portal: ComponentPortal<T>): ComponentRef<T>;
113121
attach<T>(portal: TemplatePortal<T>): EmbeddedViewRef<T>;
114122
attach(portal: any): any;

0 commit comments

Comments
 (0)