From 63b169e1addc99d4a7d8bb267c3fdf6cb82b2b4e Mon Sep 17 00:00:00 2001 From: Lukas Spirig Date: Fri, 8 Oct 2021 17:15:01 +0200 Subject: [PATCH] fix(cdk/overlay): OverlayRef.outsidePointerEvents() should only emit due to pointerdown outside overlay Currently OverlayRef.outsidePointerEvents() emits when a user starts a click inside the overlay, drags the cursor outside the overlay and releases the click (e.g. selecting text and moving the mouse outside the overlay). In order to only emit when the click originates outside the overlay, we track the target of the preceding pointerdown event and check if it originated from outside the overlay. Fixes #23643 --- .../overlay-outside-click-dispatcher.spec.ts | 73 +++++++++++++++++++ .../overlay-outside-click-dispatcher.ts | 26 ++++++- 2 files changed, 97 insertions(+), 2 deletions(-) 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 1d571d68fbbb..34e3e808cf20 100644 --- a/src/cdk/overlay/dispatchers/overlay-outside-click-dispatcher.spec.ts +++ b/src/cdk/overlay/dispatchers/overlay-outside-click-dispatcher.spec.ts @@ -180,6 +180,79 @@ describe('OverlayOutsideClickDispatcher', () => { overlayRef.dispose(); }); + it('should dispatch an event when a click is started outside the overlay and ' + + 'released outside of it', () => { + const portal = new ComponentPortal(TestComponent); + const overlayRef = overlay.create(); + overlayRef.attach(portal); + const context = document.createElement('div'); + document.body.appendChild(context); + + const spy = jasmine.createSpy('overlay mouse click event spy'); + overlayRef.outsidePointerEvents().subscribe(spy); + + dispatchMouseEvent(context, 'pointerdown'); + context.click(); + expect(spy).toHaveBeenCalled(); + + context.remove(); + overlayRef.dispose(); + }); + + it('should not dispatch an event when a click is started inside the overlay and ' + + 'released inside of it', () => { + const portal = new ComponentPortal(TestComponent); + const overlayRef = overlay.create(); + overlayRef.attach(portal); + + const spy = jasmine.createSpy('overlay mouse click event spy'); + overlayRef.outsidePointerEvents().subscribe(spy); + + dispatchMouseEvent(overlayRef.overlayElement, 'pointerdown'); + overlayRef.overlayElement.click(); + expect(spy).not.toHaveBeenCalled(); + + overlayRef.dispose(); + }); + + it('should not dispatch an event when a click is started inside the overlay and ' + + 'released outside of it', () => { + const portal = new ComponentPortal(TestComponent); + const overlayRef = overlay.create(); + overlayRef.attach(portal); + const context = document.createElement('div'); + document.body.appendChild(context); + + const spy = jasmine.createSpy('overlay mouse click event spy'); + overlayRef.outsidePointerEvents().subscribe(spy); + + dispatchMouseEvent(overlayRef.overlayElement, 'pointerdown'); + context.click(); + expect(spy).not.toHaveBeenCalled(); + + context.remove(); + overlayRef.dispose(); + }); + + it('should not dispatch an event when a click is started outside the overlay and ' + + 'released inside of it', () => { + const portal = new ComponentPortal(TestComponent); + const overlayRef = overlay.create(); + overlayRef.attach(portal); + const context = document.createElement('div'); + document.body.appendChild(context); + + const spy = jasmine.createSpy('overlay mouse click event spy'); + overlayRef.outsidePointerEvents().subscribe(spy); + + dispatchMouseEvent(context, 'pointerdown'); + overlayRef.overlayElement.click(); + expect(spy).not.toHaveBeenCalled(); + + context.remove(); + overlayRef.dispose(); + }); + it('should dispatch an event when a context menu is triggered outside the overlay', () => { const portal = new ComponentPortal(TestComponent); const overlayRef = overlay.create(); diff --git a/src/cdk/overlay/dispatchers/overlay-outside-click-dispatcher.ts b/src/cdk/overlay/dispatchers/overlay-outside-click-dispatcher.ts index 38f06362543f..b2a4217a2844 100644 --- a/src/cdk/overlay/dispatchers/overlay-outside-click-dispatcher.ts +++ b/src/cdk/overlay/dispatchers/overlay-outside-click-dispatcher.ts @@ -21,6 +21,7 @@ import {BaseOverlayDispatcher} from './base-overlay-dispatcher'; export class OverlayOutsideClickDispatcher extends BaseOverlayDispatcher { private _cursorOriginalValue: string; private _cursorStyleIsSet = false; + private _pointerDownEventTarget: EventTarget | null; constructor(@Inject(DOCUMENT) document: any, private _platform: Platform) { super(document); @@ -38,6 +39,7 @@ export class OverlayOutsideClickDispatcher extends BaseOverlayDispatcher { // https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariWebContent/HandlingEvents/HandlingEvents.html if (!this._isAttached) { const body = this._document.body; + body.addEventListener('pointerdown', this._pointerDownListener, true); body.addEventListener('click', this._clickListener, true); body.addEventListener('auxclick', this._clickListener, true); body.addEventListener('contextmenu', this._clickListener, true); @@ -58,6 +60,7 @@ export class OverlayOutsideClickDispatcher extends BaseOverlayDispatcher { protected detach() { if (this._isAttached) { const body = this._document.body; + body.removeEventListener('pointerdown', this._pointerDownListener, true); body.removeEventListener('click', this._clickListener, true); body.removeEventListener('auxclick', this._clickListener, true); body.removeEventListener('contextmenu', this._clickListener, true); @@ -69,9 +72,26 @@ export class OverlayOutsideClickDispatcher extends BaseOverlayDispatcher { } } + /** Store pointerdown event target to track origin of click. */ + private _pointerDownListener = (event: PointerEvent) => { + this._pointerDownEventTarget = _getEventTarget(event); + } + /** Click event listener that will be attached to the body propagate phase. */ private _clickListener = (event: MouseEvent) => { const target = _getEventTarget(event); + // In case of a click event, we want to check the origin of the click + // (e.g. in case where a user starts a click inside the overlay and + // releases the click outside of it). + // This is done by using the event target of the preceding pointerdown event. + // Every click event caused by a pointer device has a preceding pointerdown + // event, unless the click was programmatically triggered (e.g. in a unit test). + const origin = event.type === 'click' && this._pointerDownEventTarget + ? this._pointerDownEventTarget : target; + // Reset the stored pointerdown event target, to avoid having it interfere + // in subsequent events. + this._pointerDownEventTarget = null; + // We copy the array because the original may be modified asynchronously if the // outsidePointerEvents listener decides to detach overlays resulting in index errors inside // the for loop. @@ -88,8 +108,10 @@ export class OverlayOutsideClickDispatcher extends BaseOverlayDispatcher { } // If it's a click inside the overlay, just break - we should do nothing - // If it's an outside click dispatch the mouse event, and proceed with the next overlay - if (overlayRef.overlayElement.contains(target as Node)) { + // If it's an outside click (both origin and target of the click) dispatch the mouse event, + // and proceed with the next overlay + if (overlayRef.overlayElement.contains(target as Node) || + overlayRef.overlayElement.contains(origin as Node)) { break; }