diff --git a/src/cdk/overlay/dispatchers/overlay-keyboard-dispatcher.spec.ts b/src/cdk/overlay/dispatchers/overlay-keyboard-dispatcher.spec.ts index 0fca4875aca5..b5423cdf7af0 100644 --- a/src/cdk/overlay/dispatchers/overlay-keyboard-dispatcher.spec.ts +++ b/src/cdk/overlay/dispatchers/overlay-keyboard-dispatcher.spec.ts @@ -1,12 +1,13 @@ import {TestBed, inject} from '@angular/core/testing'; import {dispatchKeyboardEvent} from '../../testing/private'; import {ESCAPE} from '@angular/cdk/keycodes'; -import {Component} from '@angular/core'; +import {ApplicationRef, Component} from '@angular/core'; import {OverlayModule, Overlay} from '../index'; import {OverlayKeyboardDispatcher} from './overlay-keyboard-dispatcher'; import {ComponentPortal} from '@angular/cdk/portal'; describe('OverlayKeyboardDispatcher', () => { + let appRef: ApplicationRef; let keyboardDispatcher: OverlayKeyboardDispatcher; let overlay: Overlay; @@ -16,10 +17,14 @@ describe('OverlayKeyboardDispatcher', () => { declarations: [TestComponent], }); - inject([OverlayKeyboardDispatcher, Overlay], (kbd: OverlayKeyboardDispatcher, o: Overlay) => { - keyboardDispatcher = kbd; - overlay = o; - })(); + inject( + [ApplicationRef, OverlayKeyboardDispatcher, Overlay], + (ar: ApplicationRef, kbd: OverlayKeyboardDispatcher, o: Overlay) => { + appRef = ar; + keyboardDispatcher = kbd; + overlay = o; + }, + )(); }); it('should track overlays in order as they are attached and detached', () => { @@ -179,6 +184,21 @@ describe('OverlayKeyboardDispatcher', () => { expect(overlayTwoSpy).not.toHaveBeenCalled(); expect(overlayOneSpy).toHaveBeenCalled(); }); + + it('should not run change detection if there are no `keydownEvents` observers', () => { + spyOn(appRef, 'tick'); + const overlayRef = overlay.create(); + keyboardDispatcher.add(overlayRef); + + expect(appRef.tick).toHaveBeenCalledTimes(0); + dispatchKeyboardEvent(document.body, 'keydown', ESCAPE); + expect(appRef.tick).toHaveBeenCalledTimes(0); + + overlayRef.keydownEvents().subscribe(); + dispatchKeyboardEvent(document.body, 'keydown', ESCAPE); + + expect(appRef.tick).toHaveBeenCalledTimes(1); + }); }); @Component({ diff --git a/src/cdk/overlay/dispatchers/overlay-keyboard-dispatcher.ts b/src/cdk/overlay/dispatchers/overlay-keyboard-dispatcher.ts index 884eac733f81..bc3a528f82cf 100644 --- a/src/cdk/overlay/dispatchers/overlay-keyboard-dispatcher.ts +++ b/src/cdk/overlay/dispatchers/overlay-keyboard-dispatcher.ts @@ -7,7 +7,7 @@ */ import {DOCUMENT} from '@angular/common'; -import {Inject, Injectable} from '@angular/core'; +import {Inject, Injectable, NgZone, Optional} from '@angular/core'; import {OverlayReference} from '../overlay-reference'; import {BaseOverlayDispatcher} from './base-overlay-dispatcher'; @@ -18,7 +18,11 @@ import {BaseOverlayDispatcher} from './base-overlay-dispatcher'; */ @Injectable({providedIn: 'root'}) export class OverlayKeyboardDispatcher extends BaseOverlayDispatcher { - constructor(@Inject(DOCUMENT) document: any) { + constructor( + @Inject(DOCUMENT) document: any, + /** @breaking-change 14.0.0 _ngZone will be required. */ + @Optional() private _ngZone?: NgZone, + ) { super(document); } @@ -28,7 +32,14 @@ export class OverlayKeyboardDispatcher extends BaseOverlayDispatcher { // Lazily start dispatcher once first overlay is added if (!this._isAttached) { - this._document.body.addEventListener('keydown', this._keydownListener); + /** @breaking-change 14.0.0 _ngZone will be required. */ + if (this._ngZone) { + this._ngZone.runOutsideAngular(() => + this._document.body.addEventListener('keydown', this._keydownListener), + ); + } else { + this._document.body.addEventListener('keydown', this._keydownListener); + } this._isAttached = true; } } @@ -53,7 +64,13 @@ export class OverlayKeyboardDispatcher extends BaseOverlayDispatcher { // 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) { - overlays[i]._keydownEvents.next(event); + const keydownEvents = overlays[i]._keydownEvents; + /** @breaking-change 14.0.0 _ngZone will be required. */ + if (this._ngZone) { + this._ngZone.run(() => keydownEvents.next(event)); + } else { + 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 bbb6afe519ba..55a70e4588bc 100644 --- a/src/cdk/overlay/dispatchers/overlay-outside-click-dispatcher.spec.ts +++ b/src/cdk/overlay/dispatchers/overlay-outside-click-dispatcher.spec.ts @@ -1,11 +1,12 @@ import {TestBed, inject, fakeAsync} from '@angular/core/testing'; -import {Component} from '@angular/core'; +import {ApplicationRef, Component} from '@angular/core'; import {dispatchFakeEvent, dispatchMouseEvent} from '../../testing/private'; import {OverlayModule, Overlay} from '../index'; import {OverlayOutsideClickDispatcher} from './overlay-outside-click-dispatcher'; import {ComponentPortal} from '@angular/cdk/portal'; describe('OverlayOutsideClickDispatcher', () => { + let appRef: ApplicationRef; let outsideClickDispatcher: OverlayOutsideClickDispatcher; let overlay: Overlay; @@ -16,8 +17,9 @@ describe('OverlayOutsideClickDispatcher', () => { }); inject( - [OverlayOutsideClickDispatcher, Overlay], - (ocd: OverlayOutsideClickDispatcher, o: Overlay) => { + [ApplicationRef, OverlayOutsideClickDispatcher, Overlay], + (ar: ApplicationRef, ocd: OverlayOutsideClickDispatcher, o: Overlay) => { + appRef = ar; outsideClickDispatcher = ocd; overlay = o; }, @@ -336,6 +338,70 @@ describe('OverlayOutsideClickDispatcher', () => { thirdOverlayRef.dispose(); }), ); + + describe('change detection behavior', () => { + it('should not run change detection if there is no portal attached to the overlay', () => { + spyOn(appRef, 'tick'); + const overlayRef = overlay.create(); + outsideClickDispatcher.add(overlayRef); + + const context = document.createElement('div'); + document.body.appendChild(context); + + overlayRef.outsidePointerEvents().subscribe(); + dispatchMouseEvent(context, 'click'); + + expect(appRef.tick).toHaveBeenCalledTimes(0); + }); + + it('should not run change detection if the click was made outside the overlay but there are no `outsidePointerEvents` observers', () => { + spyOn(appRef, 'tick'); + const portal = new ComponentPortal(TestComponent); + const overlayRef = overlay.create(); + overlayRef.attach(portal); + outsideClickDispatcher.add(overlayRef); + + const context = document.createElement('div'); + document.body.appendChild(context); + + dispatchMouseEvent(context, 'click'); + + expect(appRef.tick).toHaveBeenCalledTimes(0); + }); + + it('should not run change detection if the click was made inside the overlay and there are `outsidePointerEvents` observers', () => { + spyOn(appRef, 'tick'); + const portal = new ComponentPortal(TestComponent); + const overlayRef = overlay.create(); + overlayRef.attach(portal); + outsideClickDispatcher.add(overlayRef); + + overlayRef.outsidePointerEvents().subscribe(); + dispatchMouseEvent(overlayRef.overlayElement, 'click'); + + expect(appRef.tick).toHaveBeenCalledTimes(0); + }); + + it('should run change detection if the click was made outside the overlay and there are `outsidePointerEvents` observers', () => { + spyOn(appRef, 'tick'); + const portal = new ComponentPortal(TestComponent); + const overlayRef = overlay.create(); + overlayRef.attach(portal); + outsideClickDispatcher.add(overlayRef); + + const context = document.createElement('div'); + document.body.appendChild(context); + + expect(appRef.tick).toHaveBeenCalledTimes(0); + dispatchMouseEvent(context, 'click'); + expect(appRef.tick).toHaveBeenCalledTimes(0); + + overlayRef.outsidePointerEvents().subscribe(); + + dispatchMouseEvent(context, 'click'); + expect(appRef.tick).toHaveBeenCalledTimes(1); + }); + }); }); @Component({ diff --git a/src/cdk/overlay/dispatchers/overlay-outside-click-dispatcher.ts b/src/cdk/overlay/dispatchers/overlay-outside-click-dispatcher.ts index ae57b23a5885..a70512502457 100644 --- a/src/cdk/overlay/dispatchers/overlay-outside-click-dispatcher.ts +++ b/src/cdk/overlay/dispatchers/overlay-outside-click-dispatcher.ts @@ -7,7 +7,7 @@ */ import {DOCUMENT} from '@angular/common'; -import {Inject, Injectable} from '@angular/core'; +import {Inject, Injectable, NgZone, Optional} from '@angular/core'; import {OverlayReference} from '../overlay-reference'; import {Platform, _getEventTarget} from '@angular/cdk/platform'; import {BaseOverlayDispatcher} from './base-overlay-dispatcher'; @@ -23,7 +23,12 @@ export class OverlayOutsideClickDispatcher extends BaseOverlayDispatcher { private _cursorStyleIsSet = false; private _pointerDownEventTarget: EventTarget | null; - constructor(@Inject(DOCUMENT) document: any, private _platform: Platform) { + constructor( + @Inject(DOCUMENT) document: any, + private _platform: Platform, + /** @breaking-change 14.0.0 _ngZone will be required. */ + @Optional() private _ngZone?: NgZone, + ) { super(document); } @@ -39,10 +44,13 @@ 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); + + /** @breaking-change 14.0.0 _ngZone will be required. */ + if (this._ngZone) { + this._ngZone.runOutsideAngular(() => this._addEventListeners(body)); + } else { + this._addEventListeners(body); + } // click event is not fired on iOS. To make element "clickable" we are // setting the cursor to pointer @@ -72,6 +80,13 @@ export class OverlayOutsideClickDispatcher extends BaseOverlayDispatcher { } } + private _addEventListeners(body: HTMLElement): void { + body.addEventListener('pointerdown', this._pointerDownListener, true); + body.addEventListener('click', this._clickListener, true); + body.addEventListener('auxclick', this._clickListener, true); + body.addEventListener('contextmenu', this._clickListener, true); + } + /** Store pointerdown event target to track origin of click. */ private _pointerDownListener = (event: PointerEvent) => { this._pointerDownEventTarget = _getEventTarget(event); @@ -119,7 +134,13 @@ export class OverlayOutsideClickDispatcher extends BaseOverlayDispatcher { break; } - overlayRef._outsidePointerEvents.next(event); + const outsidePointerEvents = overlayRef._outsidePointerEvents; + /** @breaking-change 14.0.0 _ngZone will be required. */ + if (this._ngZone) { + this._ngZone.run(() => outsidePointerEvents.next(event)); + } else { + outsidePointerEvents.next(event); + } } }; } diff --git a/tools/public_api_guard/cdk/overlay.md b/tools/public_api_guard/cdk/overlay.md index 79adbbb8d438..08231c916901 100644 --- a/tools/public_api_guard/cdk/overlay.md +++ b/tools/public_api_guard/cdk/overlay.md @@ -317,11 +317,12 @@ export class OverlayContainer implements OnDestroy { // @public export class OverlayKeyboardDispatcher extends BaseOverlayDispatcher { - constructor(document: any); + constructor(document: any, + _ngZone?: NgZone | undefined); add(overlayRef: OverlayReference): void; protected detach(): void; // (undocumented) - static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵfac: i0.ɵɵFactoryDeclaration; // (undocumented) static ɵprov: i0.ɵɵInjectableDeclaration; } @@ -338,11 +339,12 @@ export class OverlayModule { // @public export class OverlayOutsideClickDispatcher extends BaseOverlayDispatcher { - constructor(document: any, _platform: Platform); + constructor(document: any, _platform: Platform, + _ngZone?: NgZone | undefined); add(overlayRef: OverlayReference): void; protected detach(): void; // (undocumented) - static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵfac: i0.ɵɵFactoryDeclaration; // (undocumented) static ɵprov: i0.ɵɵInjectableDeclaration; }