From f652ee6614d394bb61e4e4d026364535c9b27f71 Mon Sep 17 00:00:00 2001 From: Jeremy Elbourn Date: Fri, 15 Jul 2016 13:06:12 -0700 Subject: [PATCH] feat(dialog): add backdrop --- src/components/dialog/dialog.spec.ts | 109 +++++++++++++++++---------- src/components/dialog/dialog.ts | 4 + src/core/overlay/overlay-ref.ts | 72 ++++++++++++++++-- src/core/overlay/overlay-state.ts | 3 + src/core/overlay/overlay.scss | 30 +++++++- src/core/overlay/overlay.spec.ts | 45 ++++++++++- src/core/style/_variables.scss | 7 +- 7 files changed, 215 insertions(+), 55 deletions(-) diff --git a/src/components/dialog/dialog.spec.ts b/src/components/dialog/dialog.spec.ts index 9e2bb2131907..a18379f5c46c 100644 --- a/src/components/dialog/dialog.spec.ts +++ b/src/components/dialog/dialog.spec.ts @@ -1,4 +1,11 @@ -import {inject, fakeAsync, async, ComponentFixture, TestBed} from '@angular/core/testing'; +import { + inject, + fakeAsync, + async, + ComponentFixture, + TestBed, + flushMicrotasks, +} from '@angular/core/testing'; import {NgModule, Component, Directive, ViewChild, ViewContainerRef} from '@angular/core'; import {MdDialog, MdDialogModule} from './dialog'; import {OverlayContainer} from '@angular2-material/core/overlay/overlay-container'; @@ -27,7 +34,7 @@ describe('MdDialog', () => { TestBed.compileComponents(); })); - beforeEach(inject([MdDialog], fakeAsync((d: MdDialog) => { + beforeEach(fakeAsync(inject([MdDialog], (d: MdDialog) => { dialog = d; }))); @@ -38,71 +45,94 @@ describe('MdDialog', () => { testViewContainerRef = viewContainerFixture.componentInstance.childViewContainer; }); - it('should open a dialog with a component', async(() => { + it('should open a dialog with a component', fakeAsync(() => { let config = new MdDialogConfig(); config.viewContainerRef = testViewContainerRef; - dialog.open(PizzaMsg, config).then(dialogRef => { - expect(overlayContainerElement.textContent).toContain('Pizza'); - expect(dialogRef.componentInstance).toEqual(jasmine.any(PizzaMsg)); - expect(dialogRef.componentInstance.dialogRef).toBe(dialogRef); - - viewContainerFixture.detectChanges(); - let dialogContainerElement = overlayContainerElement.querySelector('md-dialog-container'); - expect(dialogContainerElement.getAttribute('role')).toBe('dialog'); + let dialogRef: MdDialogRef; + dialog.open(PizzaMsg, config).then(ref => { + dialogRef = ref; }); - detectChangesForDialogOpen(viewContainerFixture); + flushDialogOpen(viewContainerFixture); + + expect(overlayContainerElement.textContent).toContain('Pizza'); + expect(dialogRef.componentInstance).toEqual(jasmine.any(PizzaMsg)); + expect(dialogRef.componentInstance.dialogRef).toBe(dialogRef); + + viewContainerFixture.detectChanges(); + let dialogContainerElement = overlayContainerElement.querySelector('md-dialog-container'); + expect(dialogContainerElement.getAttribute('role')).toBe('dialog'); })); - it('should apply the configured role to the dialog element', async(() => { + it('should apply the configured role to the dialog element', fakeAsync(() => { let config = new MdDialogConfig(); config.viewContainerRef = testViewContainerRef; config.role = 'alertdialog'; - dialog.open(PizzaMsg, config).then(dialogRef => { - viewContainerFixture.detectChanges(); - - let dialogContainerElement = overlayContainerElement.querySelector('md-dialog-container'); - expect(dialogContainerElement.getAttribute('role')).toBe('alertdialog'); + let dialogRef: MdDialogRef; + dialog.open(PizzaMsg, config).then(ref => { + dialogRef = ref; }); - detectChangesForDialogOpen(viewContainerFixture); + flushDialogOpen(viewContainerFixture); + + let dialogContainerElement = overlayContainerElement.querySelector('md-dialog-container'); + expect(dialogContainerElement.getAttribute('role')).toBe('alertdialog'); })); - it('should close a dialog and get back a result', async(() => { + it('should close a dialog and get back a result', fakeAsync(() => { let config = new MdDialogConfig(); config.viewContainerRef = testViewContainerRef; - dialog.open(PizzaMsg, config).then(dialogRef => { - viewContainerFixture.detectChanges(); + let dialogRef: MdDialogRef; + dialog.open(PizzaMsg, config).then(ref => { + dialogRef = ref; + }); - let afterCloseResult: string; - dialogRef.afterClosed().subscribe(result => { - afterCloseResult = result; - }); + flushDialogOpen(viewContainerFixture); - dialogRef.close('Charmander'); + viewContainerFixture.detectChanges(); - viewContainerFixture.whenStable().then(() => { - expect(afterCloseResult).toBe('Charmander'); - expect(overlayContainerElement.childNodes.length).toBe(0); - }); + let afterCloseResult: string; + dialogRef.afterClosed().subscribe(result => { + afterCloseResult = result; }); - detectChangesForDialogOpen(viewContainerFixture); + dialogRef.close('Charmander'); + flushMicrotasks(); + + expect(afterCloseResult).toBe('Charmander'); + expect(overlayContainerElement.querySelector('md-dialog-container')).toBeNull(); + })); + + it('should close when clicking on the overlay backdrop', fakeAsync(() => { + let config = new MdDialogConfig(); + config.viewContainerRef = testViewContainerRef; + + let dialogRef: MdDialogRef; + dialog.open(PizzaMsg, config).then(ref => { + dialogRef = ref; + }); + + flushDialogOpen(viewContainerFixture); + + let backdrop = overlayContainerElement.querySelector('.md-overlay-backdrop'); + backdrop.click(); + + flushMicrotasks(); + expect(overlayContainerElement.querySelector('md-dialog-container')).toBeFalsy(); })); }); -/** Runs the necessary detectChanges for a dialog to complete its opening. */ -function detectChangesForDialogOpen(fixture: ComponentFixture) { - // TODO(jelbourn): figure out why the test zone is "stable" when there are still pending - // tasks, such that we have to use `setTimeout` to run the second round of change detection. +/** Flush the creation of a dialog. */ +function flushDialogOpen(fixture: ComponentFixture) { // Two rounds of change detection are necessary: one to *create* the dialog container, and // another to cause the lifecycle events of the container to run and load the dialog content. fixture.detectChanges(); - setTimeout(() => fixture.detectChanges(), 50); + flushMicrotasks(); + fixture.detectChanges(); } @Directive({selector: 'dir-with-view-container'}) @@ -123,10 +153,7 @@ class ComponentWithChildViewContainer { } /** Simple component for testing ComponentPortal. */ -@Component({ - selector: 'pizza-msg', - template: '

Pizza

', -}) +@Component({template: '

Pizza

'}) class PizzaMsg { constructor(public dialogRef: MdDialogRef) { } } diff --git a/src/components/dialog/dialog.ts b/src/components/dialog/dialog.ts index e27b6edeca86..85f1e7902c9d 100644 --- a/src/components/dialog/dialog.ts +++ b/src/components/dialog/dialog.ts @@ -91,6 +91,9 @@ export class MdDialog { // to modify and close it. let dialogRef = new MdDialogRef(overlayRef); + // When the dialog backdrop is clicked, we want to close it. + overlayRef.backdropClick().subscribe(() => dialogRef.close()); + // We create an injector specifically for the component we're instantiating so that it can // inject the MdDialogRef. This allows a component loaded inside of a dialog to close itself // and, optionally, to return a value. @@ -111,6 +114,7 @@ export class MdDialog { private _getOverlayState(dialogConfig: MdDialogConfig): OverlayState { let state = new OverlayState(); + state.hasBackdrop = true; state.positionStrategy = this._overlay.position() .global() .centerHorizontally() diff --git a/src/core/overlay/overlay-ref.ts b/src/core/overlay/overlay-ref.ts index 5db00dbe0a77..88885d22add1 100644 --- a/src/core/overlay/overlay-ref.ts +++ b/src/core/overlay/overlay-ref.ts @@ -1,33 +1,45 @@ import {PortalHost, Portal} from '../portal/portal'; import {OverlayState} from './overlay-state'; +import {Observable} from 'rxjs/Observable'; +import {Subject} from 'rxjs/Subject'; +import {PromiseCompleter} from '../async/promise-completer'; + /** * Reference to an overlay that has been created with the Overlay service. * Used to manipulate or dispose of said overlay. */ export class OverlayRef implements PortalHost { + private _backdropElement: HTMLElement = null; + private _backdropClick: Subject = new Subject(); + constructor( private _portalHost: PortalHost, private _pane: HTMLElement, private _state: OverlayState) { } attach(portal: Portal): Promise { - let attachPromise = this._portalHost.attach(portal); + // We specially do not wait for the backdrop to finish animating to complete the returned + // promise because we do not want to delay the loading of additional content into the overlay. + if (this._state.hasBackdrop) { + this._attachBackdrop(); + } - // Don't chain the .then() call in the return because we want the result of portalHost.attach - // to be returned from this method. - attachPromise.then(() => { + return this._portalHost.attach(portal).then(attachResult => { this.updatePosition(); + return attachResult; }); - - return attachPromise; } detach(): Promise { - return this._portalHost.detach(); + return Promise.all([ + this._portalHost.detach(), + this._detatchBackdrop() + ]).then(values => values[0]); } dispose(): void { + this._detatchBackdrop(); this._portalHost.dispose(); } @@ -35,6 +47,10 @@ export class OverlayRef implements PortalHost { return this._portalHost.hasAttached(); } + backdropClick(): Observable { + return this._backdropClick.asObservable(); + } + /** Gets the current state config of the overlay. */ getState() { return this._state; @@ -47,5 +63,45 @@ export class OverlayRef implements PortalHost { } } - // TODO(jelbourn): add additional methods for manipulating the overlay. + /** Attaches a backdrop for this overlay. */ + private _attachBackdrop() { + this._backdropElement = document.createElement('div'); + this._backdropElement.classList.add('md-overlay-backdrop'); + this._pane.parentElement.appendChild(this._backdropElement); + + // Forward backdrop clicks that that the consumer of the overlay can perform whatever + // action desired when such a click occurs (usually closing the overlay). + this._backdropElement.addEventListener('click', () => { + this._backdropClick.next(null); + }); + + // Add class to fade-in the backdrop after one frame. + requestAnimationFrame(() => { + this._backdropElement.classList.add('md-overlay-backdrop-showing'); + }); + } + + /** Detaches the backdrop (if any) associated with the overlay. */ + private _detatchBackdrop(): Promise { + let backdropToDetach = this._backdropElement; + + if (backdropToDetach) { + let completer = new PromiseCompleter(); + + backdropToDetach.classList.remove('md-overlay-backdrop-showing'); + backdropToDetach.addEventListener('transitionend', () => { + 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 our the backdrop reference if it + // is still the same instance that we started to remove. + if (this._backdropElement == backdropToDetach) { + this._backdropElement = null; + } + completer.resolve(); + }); + } else { + return Promise.resolve(); + } + } } diff --git a/src/core/overlay/overlay-state.ts b/src/core/overlay/overlay-state.ts index 573dd034638a..8f9d39de72d0 100644 --- a/src/core/overlay/overlay-state.ts +++ b/src/core/overlay/overlay-state.ts @@ -9,6 +9,9 @@ export class OverlayState { /** Strategy with which to position the overlay. */ positionStrategy: PositionStrategy; + /** Whether the overlay has a backdrop. */ + hasBackdrop: boolean = false; + // TODO(jelbourn): configuration still to add // - overlay size // - focus trap diff --git a/src/core/overlay/overlay.scss b/src/core/overlay/overlay.scss index d04ea0f48f72..d7cf2407e246 100644 --- a/src/core/overlay/overlay.scss +++ b/src/core/overlay/overlay.scss @@ -1,3 +1,8 @@ +@import 'variables'; +@import 'palette'; + +$md-backdrop-color: md-color($md-grey, 900); + // TODO(jelbourn): change from the `md` prefix to something else for everything in the toolkit. @import 'variables'; @@ -14,6 +19,8 @@ left: 0; height: 100%; width: 100%; + + z-index: 1; } /** A single overlay pane. */ @@ -21,5 +28,26 @@ position: absolute; pointer-events: auto; box-sizing: border-box; - z-index: $z-index-overlay; + z-index: $md-z-index-overlay; +} + +.md-overlay-backdrop { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + + z-index: $md-z-index-overlay-backdrop; + pointer-events: auto; + + // TODO(jelbourn): figure out if there are actually spec'ed colors for both light and dark + // themes here. Currently using the values from Angular Material 1. + transition: opacity $swift-ease-out-duration $swift-ease-out-timing-function; + background: $md-backdrop-color; + opacity: 0; +} + +.md-overlay-backdrop.md-overlay-backdrop-showing { + opacity: .48; } diff --git a/src/core/overlay/overlay.spec.ts b/src/core/overlay/overlay.spec.ts index 961289f342fd..900b635497cb 100644 --- a/src/core/overlay/overlay.spec.ts +++ b/src/core/overlay/overlay.spec.ts @@ -1,4 +1,11 @@ -import {inject, fakeAsync, flushMicrotasks, TestBed, async} from '@angular/core/testing'; +import { + inject, + fakeAsync, + flushMicrotasks, + TestBed, + async, + ComponentFixture +} from '@angular/core/testing'; import {NgModule, Component, ViewChild, ViewContainerRef} from '@angular/core'; import {TemplatePortalDirective, PortalModule} from '../portal/portal-directives'; import {TemplatePortal, ComponentPortal} from '../portal/portal'; @@ -15,6 +22,7 @@ describe('Overlay', () => { let componentPortal: ComponentPortal; let templatePortal: TemplatePortal; let overlayContainerElement: HTMLElement; + let viewContainerFixture: ComponentFixture; beforeEach(async(() => { TestBed.configureTestingModule({ @@ -37,6 +45,7 @@ describe('Overlay', () => { fixture.detectChanges(); templatePortal = fixture.componentInstance.templatePortal; componentPortal = new ComponentPortal(PizzaMsg, fixture.componentInstance.viewContainerRef); + viewContainerFixture = fixture; flushMicrotasks(); }))); @@ -106,7 +115,7 @@ describe('Overlay', () => { expect(overlayContainerElement.textContent).toBe(''); })); - describe('applyState', () => { + describe('positioning', () => { let state: OverlayState; beforeEach(() => { @@ -125,6 +134,36 @@ describe('Overlay', () => { expect(overlayContainerElement.querySelectorAll('.fake-positioned').length).toBe(1); })); }); + + describe('backdrop', () => { + it('should create and destroy an overlay backdrop', fakeAsync(() => { + let overlayRef: OverlayRef; + let config = new OverlayState(); + config.hasBackdrop = true; + + overlay.create(config).then(ref => { + overlayRef = ref; + overlayRef.attach(componentPortal); + }); + + flushMicrotasks(); + + viewContainerFixture.whenStable().then(() => { + viewContainerFixture.detectChanges(); + let backdrop = overlayContainerElement.querySelector('.md-overlay-backdrop'); + expect(backdrop).toBeTruthy(); + expect(backdrop.classList).not.toContain('.md-overlay-backdrop-showing'); + + let backdropClickHandler = jasmine.createSpy('backdropClickHander'); + overlayRef.backdropClick().subscribe(backdropClickHandler); + + backdrop.click(); + expect(backdropClickHandler).toHaveBeenCalled(); + }); + + flushMicrotasks(); + })); + }); }); @@ -157,6 +196,4 @@ class FakePositionStrategy implements PositionStrategy { element.classList.add('fake-positioned'); return Promise.resolve(); } - } - diff --git a/src/core/style/_variables.scss b/src/core/style/_variables.scss index d69d5ad651cc..b8d75a2baa4f 100644 --- a/src/core/style/_variables.scss +++ b/src/core/style/_variables.scss @@ -10,9 +10,14 @@ $md-xsmall: 'max-width: 600px'; // TODO: Revisit all z-indices before beta // z-index master list + $z-index-fab: 20 !default; $z-index-drawer: 100 !default; -$z-index-overlay: 1000 !default; + +// Overlay z indices. +$md-z-index-overlay: 1000; +$md-z-index-overlay-backdrop: 1; + // Global constants $pi: 3.14159265;