Skip to content

Commit

Permalink
feat(dialog): add backdrop
Browse files Browse the repository at this point in the history
  • Loading branch information
jelbourn committed Aug 14, 2016
1 parent 2411c24 commit f652ee6
Show file tree
Hide file tree
Showing 7 changed files with 215 additions and 55 deletions.
109 changes: 68 additions & 41 deletions src/components/dialog/dialog.spec.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -27,7 +34,7 @@ describe('MdDialog', () => {
TestBed.compileComponents();
}));

beforeEach(inject([MdDialog], fakeAsync((d: MdDialog) => {
beforeEach(fakeAsync(inject([MdDialog], (d: MdDialog) => {
dialog = d;
})));

Expand All @@ -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<PizzaMsg>;
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<PizzaMsg>;
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<PizzaMsg>;
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<PizzaMsg>;
dialog.open(PizzaMsg, config).then(ref => {
dialogRef = ref;
});

flushDialogOpen(viewContainerFixture);

let backdrop = <HTMLElement> 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<ComponentWithChildViewContainer>) {
// 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<any>) {
// 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'})
Expand All @@ -123,10 +153,7 @@ class ComponentWithChildViewContainer {
}

/** Simple component for testing ComponentPortal. */
@Component({
selector: 'pizza-msg',
template: '<p>Pizza</p>',
})
@Component({template: '<p>Pizza</p>'})
class PizzaMsg {
constructor(public dialogRef: MdDialogRef<PizzaMsg>) { }
}
Expand Down
4 changes: 4 additions & 0 deletions src/components/dialog/dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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()
Expand Down
72 changes: 64 additions & 8 deletions src/core/overlay/overlay-ref.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,56 @@
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<any> = new Subject();

constructor(
private _portalHost: PortalHost,
private _pane: HTMLElement,
private _state: OverlayState) { }

attach(portal: Portal<any>): Promise<any> {
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<any> {
return this._portalHost.detach();
return Promise.all([
this._portalHost.detach(),
this._detatchBackdrop()
]).then(values => values[0]);
}

dispose(): void {
this._detatchBackdrop();
this._portalHost.dispose();
}

hasAttached(): boolean {
return this._portalHost.hasAttached();
}

backdropClick(): Observable<void> {
return this._backdropClick.asObservable();
}

/** Gets the current state config of the overlay. */
getState() {
return this._state;
Expand All @@ -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<void> {
let backdropToDetach = this._backdropElement;

if (backdropToDetach) {
let completer = new PromiseCompleter<void>();

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();
}
}
}
3 changes: 3 additions & 0 deletions src/core/overlay/overlay-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 29 additions & 1 deletion src/core/overlay/overlay.scss
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -14,12 +19,35 @@
left: 0;
height: 100%;
width: 100%;

z-index: 1;
}

/** A single overlay pane. */
.md-overlay-pane {
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;
}
Loading

0 comments on commit f652ee6

Please sign in to comment.