From fe28d4baede1dbf629851177b8a9e47fa3d6ca2e Mon Sep 17 00:00:00 2001 From: crisbeto Date: Sat, 31 Mar 2018 11:06:33 +0200 Subject: [PATCH] refactor(overlay): use component to render backdrop Uses an Angular component to render the backdrop, instead of managing a DOM element manually. This has the advantage of being able to leverage the animations API to transition in/out, as well as not having to worry about the cases where the backdrop animation is disabled. These changes also enable the backdrop transition for the dialog (previously it would be removed immediately on close). --- src/cdk/a11y/tsconfig-build.json | 3 +- src/cdk/overlay/_overlay.scss | 17 +-- src/cdk/overlay/backdrop.ts | 56 ++++++++++ src/cdk/overlay/overlay-directives.spec.ts | 3 +- src/cdk/overlay/overlay-module.ts | 6 +- src/cdk/overlay/overlay-ref.ts | 116 +++++++-------------- src/cdk/overlay/overlay.spec.ts | 29 ++---- src/cdk/overlay/overlay.ts | 30 +++--- src/cdk/overlay/public-api.ts | 2 + src/cdk/overlay/tsconfig-build.json | 3 +- src/lib/dialog/dialog-ref.ts | 5 +- 11 files changed, 139 insertions(+), 131 deletions(-) create mode 100644 src/cdk/overlay/backdrop.ts diff --git a/src/cdk/a11y/tsconfig-build.json b/src/cdk/a11y/tsconfig-build.json index c9480c9ecef8..8bde0bb3f5db 100644 --- a/src/cdk/a11y/tsconfig-build.json +++ b/src/cdk/a11y/tsconfig-build.json @@ -1,7 +1,8 @@ { "extends": "../tsconfig-build", "files": [ - "public-api.ts" + "public-api.ts", + "../typings.d.ts" ], "angularCompilerOptions": { "annotateForClosureCompiler": true, diff --git a/src/cdk/overlay/_overlay.scss b/src/cdk/overlay/_overlay.scss index 7090525badc9..3b06b238e02c 100644 --- a/src/cdk/overlay/_overlay.scss +++ b/src/cdk/overlay/_overlay.scss @@ -79,14 +79,15 @@ $backdrop-animation-timing-function: cubic-bezier(0.25, 0.8, 0.25, 1) !default; transition: opacity $backdrop-animation-duration $backdrop-animation-timing-function; opacity: 0; - &.cdk-overlay-backdrop-showing { - opacity: 1; - - // In high contrast mode the rgba background will become solid - // so we need to fall back to making it opaque using `opacity`. - @include cdk-high-contrast { - opacity: 0.6; - } + // In high contrast mode the rgba background will become solid + // so we need to fall back to making it opaque using `opacity`. + @include cdk-high-contrast { + opacity: 0.6; + } + + // Prevent the user from interacting while the backdrop is animating. + &.ng-animating { + pointer-events: none; } } diff --git a/src/cdk/overlay/backdrop.ts b/src/cdk/overlay/backdrop.ts new file mode 100644 index 000000000000..cab32cbb3c78 --- /dev/null +++ b/src/cdk/overlay/backdrop.ts @@ -0,0 +1,56 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { + Component, + ViewEncapsulation, + ChangeDetectionStrategy, + OnDestroy, + ElementRef, +} from '@angular/core'; +import {animate, AnimationEvent, state, style, transition, trigger} from '@angular/animations'; +import {Subject} from 'rxjs'; + +/** + * Semi-transparent backdrop that will be rendered behind an overlay. + * @docs-private + */ +@Component({ + moduleId: module.id, + template: '', + host: { + 'class': 'cdk-overlay-backdrop', + '[@state]': '_animationState', + '(@state.done)': '_animationStream.next($event)', + '(click)': '_clickStream.next($event)', + }, + animations: [ + trigger('state', [ + state('void', style({opacity: '0'})), + state('visible', style({opacity: '1'})), + transition('* => *', animate('400ms cubic-bezier(0.25, 0.8, 0.25, 1)')), + ]) + ], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, +}) +export class CdkOverlayBackdrop implements OnDestroy { + _animationState = 'visible'; + _clickStream = new Subject(); + _animationStream = new Subject(); + + constructor(public _element: ElementRef) {} + + _setClass(cssClass: string) { + this._element.nativeElement.classList.add(cssClass); + } + + ngOnDestroy() { + this._clickStream.complete(); + } +} diff --git a/src/cdk/overlay/overlay-directives.spec.ts b/src/cdk/overlay/overlay-directives.spec.ts index 135e98e4965e..16837d7a74ef 100644 --- a/src/cdk/overlay/overlay-directives.spec.ts +++ b/src/cdk/overlay/overlay-directives.spec.ts @@ -1,6 +1,7 @@ import {Component, ViewChild} from '@angular/core'; import {By} from '@angular/platform-browser'; import {ComponentFixture, TestBed, async, inject} from '@angular/core/testing'; +import {NoopAnimationsModule} from '@angular/platform-browser/animations'; import {Directionality} from '@angular/cdk/bidi'; import {dispatchKeyboardEvent} from '@angular/cdk/testing'; import {ESCAPE} from '@angular/cdk/keycodes'; @@ -21,7 +22,7 @@ describe('Overlay directives', () => { beforeEach(() => { TestBed.configureTestingModule({ - imports: [OverlayModule], + imports: [OverlayModule, NoopAnimationsModule], declarations: [ConnectedOverlayDirectiveTest, ConnectedOverlayPropertyInitOrder], providers: [{provide: Directionality, useFactory: () => dir = {value: 'ltr'}}], }); diff --git a/src/cdk/overlay/overlay-module.ts b/src/cdk/overlay/overlay-module.ts index 513692578603..862c73fceb8c 100644 --- a/src/cdk/overlay/overlay-module.ts +++ b/src/cdk/overlay/overlay-module.ts @@ -19,13 +19,15 @@ import { CdkOverlayOrigin, } from './overlay-directives'; import {OverlayPositionBuilder} from './position/overlay-position-builder'; +import {CdkOverlayBackdrop} from './backdrop'; @NgModule({ imports: [BidiModule, PortalModule, ScrollDispatchModule], - exports: [CdkConnectedOverlay, CdkOverlayOrigin, ScrollDispatchModule], - declarations: [CdkConnectedOverlay, CdkOverlayOrigin], + exports: [CdkConnectedOverlay, CdkOverlayOrigin, CdkOverlayBackdrop, ScrollDispatchModule], + declarations: [CdkConnectedOverlay, CdkOverlayOrigin, CdkOverlayBackdrop], providers: [Overlay], + entryComponents: [CdkOverlayBackdrop], }) export class OverlayModule {} diff --git a/src/cdk/overlay/overlay-ref.ts b/src/cdk/overlay/overlay-ref.ts index 83eb45f9c665..6a47440670a3 100644 --- a/src/cdk/overlay/overlay-ref.ts +++ b/src/cdk/overlay/overlay-ref.ts @@ -9,11 +9,12 @@ import {Direction} from '@angular/cdk/bidi'; import {ComponentPortal, Portal, PortalOutlet, TemplatePortal} from '@angular/cdk/portal'; import {ComponentRef, EmbeddedViewRef, NgZone} from '@angular/core'; -import {Observable, Subject} from 'rxjs'; +import {Observable, Subject, empty} from 'rxjs'; import {take} from 'rxjs/operators'; import {OverlayKeyboardDispatcher} from './keyboard/overlay-keyboard-dispatcher'; import {OverlayConfig} from './overlay-config'; import {coerceCssPixelValue} from '@angular/cdk/coercion'; +import {CdkOverlayBackdrop} from './backdrop'; /** An object where all of its properties cannot be written. */ @@ -26,10 +27,10 @@ export type ImmutableObject = { * Used to manipulate or dispose of said overlay. */ export class OverlayRef implements PortalOutlet { - private _backdropElement: HTMLElement | null = null; - private _backdropClick: Subject = new Subject(); + private _backdropClick = new Subject(); private _attachments = new Subject(); private _detachments = new Subject(); + private _backdropInstance: CdkOverlayBackdrop | null; /** Stream of keydown events dispatched to this overlay. */ _keydownEvents = new Subject(); @@ -38,10 +39,10 @@ export class OverlayRef implements PortalOutlet { private _portalOutlet: PortalOutlet, private _host: HTMLElement, private _pane: HTMLElement, + private _backdropHost: PortalOutlet | null, private _config: ImmutableObject, private _ngZone: NgZone, - private _keyboardDispatcher: OverlayKeyboardDispatcher, - private _document: Document) { + private _keyboardDispatcher: OverlayKeyboardDispatcher) { if (_config.scrollStrategy) { _config.scrollStrategy.attach(this); @@ -55,7 +56,7 @@ export class OverlayRef implements PortalOutlet { /** The overlay's backdrop HTML element. */ get backdropElement(): HTMLElement | null { - return this._backdropElement; + return this._backdropInstance ? this._backdropInstance._element.nativeElement : null; } /** @@ -79,7 +80,7 @@ export class OverlayRef implements PortalOutlet { * @returns The portal attachment result. */ attach(portal: Portal): any { - let attachResult = this._portalOutlet.attach(portal); + const attachResult = this._portalOutlet.attach(portal); if (this._config.positionStrategy) { this._config.positionStrategy.attach(this); @@ -110,8 +111,10 @@ export class OverlayRef implements PortalOutlet { // Enable pointer events for the overlay pane element. this._togglePointerEvents(true); - if (this._config.hasBackdrop) { - this._attachBackdrop(); + if (this._backdropHost) { + this._backdropInstance = + this._backdropHost.attach(new ComponentPortal(CdkOverlayBackdrop)).instance; + this._backdropInstance!._setClass(this._config.backdropClass!); } if (this._config.panelClass) { @@ -141,7 +144,9 @@ export class OverlayRef implements PortalOutlet { return; } - this.detachBackdrop(); + if (this._backdropHost && this._backdropHost.hasAttached()) { + this._backdropHost.detach(); + } // When the overlay is detached, the pane element should disable pointer events. // This is necessary because otherwise the pane element will cover the page and disable @@ -179,7 +184,7 @@ export class OverlayRef implements PortalOutlet { this._config.scrollStrategy.disable(); } - this.detachBackdrop(); + this.disposeBackdrop(); this._keyboardDispatcher.remove(this); this._portalOutlet.dispose(); this._attachments.complete(); @@ -205,7 +210,7 @@ export class OverlayRef implements PortalOutlet { /** Gets an observable that emits when the backdrop has been clicked. */ backdropClick(): Observable { - return this._backdropClick.asObservable(); + return this._backdropInstance ? this._backdropInstance._clickStream : empty(); } /** Gets an observable that emits when the overlay has been attached. */ @@ -284,40 +289,6 @@ export class OverlayRef implements PortalOutlet { this._pane.style.pointerEvents = enablePointer ? 'auto' : 'none'; } - /** Attaches a backdrop for this overlay. */ - private _attachBackdrop() { - const showingClass = 'cdk-overlay-backdrop-showing'; - - this._backdropElement = this._document.createElement('div'); - this._backdropElement.classList.add('cdk-overlay-backdrop'); - - if (this._config.backdropClass) { - this._backdropElement.classList.add(this._config.backdropClass); - } - - // Insert the backdrop before the pane in the DOM order, - // in order to handle stacked overlays properly. - this._host.parentElement!.insertBefore(this._backdropElement, this._host); - - // Forward backdrop clicks such that the consumer of the overlay can perform whatever - // action desired when such a click occurs (usually closing the overlay). - this._backdropElement.addEventListener('click', - (event: MouseEvent) => this._backdropClick.next(event)); - - // Add class to fade-in the backdrop after one frame. - if (typeof requestAnimationFrame !== 'undefined') { - this._ngZone.runOutsideAngular(() => { - requestAnimationFrame(() => { - if (this._backdropElement) { - this._backdropElement.classList.add(showingClass); - } - }); - }); - } else { - this._backdropElement.classList.add(showingClass); - } - } - /** * Updates the stacking order of the element, moving it to the top if necessary. * This is required in cases where one overlay was detached, while another one, @@ -331,43 +302,30 @@ export class OverlayRef implements PortalOutlet { } } - /** Detaches the backdrop (if any) associated with the overlay. */ - detachBackdrop(): void { - let backdropToDetach = this._backdropElement; - - if (backdropToDetach) { - let finishDetach = () => { - // It may not be attached to anything in certain cases (e.g. unit tests). - if (backdropToDetach && backdropToDetach.parentNode) { - backdropToDetach.parentNode.removeChild(backdropToDetach); - } - - // It is possible that a new portal has been attached to this overlay since we started - // removing the backdrop. If that is the case, only clear the backdrop reference if it - // is still the same instance that we started to remove. - if (this._backdropElement == backdropToDetach) { - this._backdropElement = null; - } - }; - - backdropToDetach.classList.remove('cdk-overlay-backdrop-showing'); + /** Animates out and disposes of the backdrop. */ + disposeBackdrop(): void { + if (this._backdropHost) { + if (this._backdropHost.hasAttached()) { + this._backdropHost.detach(); - if (this._config.backdropClass) { - backdropToDetach.classList.remove(this._config.backdropClass); + this._backdropInstance!._animationStream.pipe(take(1)).subscribe(() => { + this._backdropHost!.dispose(); + this._backdropHost = this._backdropInstance = null; + }); + } else { + this._backdropHost.dispose(); } - - backdropToDetach.addEventListener('transitionend', finishDetach); - - // If the backdrop doesn't have a transition, the `transitionend` event won't fire. - // In this case we make it unclickable and we try to remove it after a delay. - backdropToDetach.style.pointerEvents = 'none'; - - // Run this outside the Angular zone because there's nothing that Angular cares about. - // If it were to run inside the Angular zone, every test that used Overlay would have to be - // either async or fakeAsync. - this._ngZone.runOutsideAngular(() => setTimeout(finishDetach, 500)); } } + + /** + * Detaches the backdrop (if any) associated with the overlay. + * @deprecated Use `disposeBackdrop` instead. + * @deletion-target 7.0.0 + */ + detachBackdrop(): void { + this.disposeBackdrop(); + } } diff --git a/src/cdk/overlay/overlay.spec.ts b/src/cdk/overlay/overlay.spec.ts index a2121b758a21..ce3e5f2fb248 100644 --- a/src/cdk/overlay/overlay.spec.ts +++ b/src/cdk/overlay/overlay.spec.ts @@ -1,6 +1,7 @@ import {async, fakeAsync, tick, ComponentFixture, inject, TestBed} from '@angular/core/testing'; import {Component, NgModule, ViewChild, ViewContainerRef} from '@angular/core'; import {Direction, Directionality} from '@angular/cdk/bidi'; +import {NoopAnimationsModule} from '@angular/platform-browser/animations'; import { ComponentPortal, PortalModule, @@ -30,7 +31,7 @@ describe('Overlay', () => { beforeEach(async(() => { dir = 'ltr'; TestBed.configureTestingModule({ - imports: [OverlayModule, PortalModule, OverlayTestModule], + imports: [OverlayModule, PortalModule, OverlayTestModule, NoopAnimationsModule], providers: [{ provide: Directionality, useFactory: () => { @@ -92,6 +93,7 @@ describe('Overlay', () => { .toBe('auto', 'Expected the overlay pane to enable pointerEvents when attached.'); overlayRef.detach(); + viewContainerFixture.detectChanges(); expect(paneElement.childNodes.length).toBe(0); expect(paneElement.style.pointerEvents) @@ -220,6 +222,8 @@ describe('Overlay', () => { let overlayRef = overlay.create(); overlayRef.detachments().subscribe(() => { + viewContainerFixture.detectChanges(); + expect(overlayContainerElement.querySelector('pizza')) .toBeFalsy('Expected the overlay to have been detached.'); }); @@ -410,7 +414,6 @@ describe('Overlay', () => { viewContainerFixture.detectChanges(); let backdrop = overlayContainerElement.querySelector('.cdk-overlay-backdrop') as HTMLElement; expect(backdrop).toBeTruthy(); - expect(backdrop.classList).not.toContain('cdk-overlay-backdrop-showing'); let backdropClickHandler = jasmine.createSpy('backdropClickHander'); overlayRef.backdropClick().subscribe(backdropClickHandler); @@ -453,29 +456,15 @@ describe('Overlay', () => { expect(backdrop.classList).toContain('cdk-overlay-transparent-backdrop'); }); - it('should disable the pointer events of a backdrop that is being removed', () => { + it('should insert the backdrop before the overlay pane in the DOM order', () => { let overlayRef = overlay.create(config); - overlayRef.attach(componentPortal); - - viewContainerFixture.detectChanges(); - let backdrop = overlayContainerElement.querySelector('.cdk-overlay-backdrop') as HTMLElement; - - expect(backdrop.style.pointerEvents).toBeFalsy(); - - overlayRef.detach(); - - expect(backdrop.style.pointerEvents).toBe('none'); - }); - - it('should insert the backdrop before the overlay host in the DOM order', () => { - const overlayRef = overlay.create(config); overlayRef.attach(componentPortal); viewContainerFixture.detectChanges(); - const backdrop = overlayContainerElement.querySelector('.cdk-overlay-backdrop'); - const host = overlayContainerElement.querySelector('.cdk-overlay-pane')!.parentElement!; - const children = Array.prototype.slice.call(overlayContainerElement.children); + let backdrop = overlayContainerElement.querySelector('.cdk-overlay-backdrop')!.parentNode; + let children = Array.prototype.slice.call(overlayContainerElement.children); + let host = overlayRef.hostElement; expect(children.indexOf(backdrop)).toBeGreaterThan(-1); expect(children.indexOf(host)).toBeGreaterThan(-1); diff --git a/src/cdk/overlay/overlay.ts b/src/cdk/overlay/overlay.ts index 7c6511aa2f4d..ed35be51947b 100644 --- a/src/cdk/overlay/overlay.ts +++ b/src/cdk/overlay/overlay.ts @@ -60,15 +60,17 @@ export class Overlay { * @returns Reference to the created overlay. */ create(config?: OverlayConfig): OverlayRef { - const host = this._createHostElement(); + const overlayConfig = new OverlayConfig(config); + const backdrop = overlayConfig.hasBackdrop ? this._createOverlayElement() : null; + const backdropHost = backdrop ? this._createPortalOutlet(backdrop) : null; + const host = this._createOverlayElement(); const pane = this._createPaneElement(host); const portalOutlet = this._createPortalOutlet(pane); - const overlayConfig = new OverlayConfig(config); overlayConfig.direction = overlayConfig.direction || this._directionality.value; - return new OverlayRef(portalOutlet, host, pane, overlayConfig, this._ngZone, - this._keyboardDispatcher, this._document); + return new OverlayRef(portalOutlet, host, pane, backdropHost, overlayConfig, this._ngZone, + this._keyboardDispatcher); } /** @@ -94,21 +96,17 @@ export class Overlay { return pane; } - /** - * Creates the host element that wraps around an overlay - * and can be used for advanced positioning. - * @returns Newly-create host element. - */ - private _createHostElement(): HTMLElement { - const host = this._document.createElement('div'); - this._overlayContainer.getContainerElement().appendChild(host); - return host; + /** Creates an element and appends it to the overlay container. */ + private _createOverlayElement(): HTMLElement { + const element = this._document.createElement('div'); + this._overlayContainer.getContainerElement().appendChild(element); + return element; } /** - * Create a DomPortalOutlet into which the overlay content can be loaded. - * @param pane The DOM element to turn into a portal outlet. - * @returns A portal outlet for the given DOM element. + * Create a DomPortalHost into which the overlay content can be loaded. + * @param pane The DOM element to turn into a portal host. + * @returns A portal host for the given DOM element. */ private _createPortalOutlet(pane: HTMLElement): DomPortalOutlet { return new DomPortalOutlet(pane, this._componentFactoryResolver, this._appRef, this._injector); diff --git a/src/cdk/overlay/public-api.ts b/src/cdk/overlay/public-api.ts index 9b95f7ef073b..ead93e595e58 100644 --- a/src/cdk/overlay/public-api.ts +++ b/src/cdk/overlay/public-api.ts @@ -10,6 +10,8 @@ export * from './overlay-config'; export * from './position/connected-position'; export * from './scroll/index'; export * from './overlay-module'; +export * from './backdrop'; + export {Overlay} from './overlay'; export {OverlayContainer} from './overlay-container'; export {CdkOverlayOrigin, CdkConnectedOverlay} from './overlay-directives'; diff --git a/src/cdk/overlay/tsconfig-build.json b/src/cdk/overlay/tsconfig-build.json index 0a06cd3fa8c5..758030030236 100644 --- a/src/cdk/overlay/tsconfig-build.json +++ b/src/cdk/overlay/tsconfig-build.json @@ -1,7 +1,8 @@ { "extends": "../tsconfig-build", "files": [ - "public-api.ts" + "public-api.ts", + "../typings.d.ts" ], "angularCompilerOptions": { "annotateForClosureCompiler": true, diff --git a/src/lib/dialog/dialog-ref.ts b/src/lib/dialog/dialog-ref.ts index 2857ec8f731b..a8ef4581b5a6 100644 --- a/src/lib/dialog/dialog-ref.ts +++ b/src/lib/dialog/dialog-ref.ts @@ -104,11 +104,10 @@ export class MatDialogRef { this._containerInstance._animationStateChanged.pipe( filter(event => event.phaseName === 'start'), take(1) - ) - .subscribe(() => { + ).subscribe(() => { this._beforeClose.next(dialogResult); this._beforeClose.complete(); - this._overlayRef.detachBackdrop(); + this._overlayRef.disposeBackdrop(); }); this._containerInstance._startExitAnimation();