Skip to content

Commit f652ee6

Browse files
committed
feat(dialog): add backdrop
1 parent 2411c24 commit f652ee6

File tree

7 files changed

+215
-55
lines changed

7 files changed

+215
-55
lines changed

src/components/dialog/dialog.spec.ts

Lines changed: 68 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import {inject, fakeAsync, async, ComponentFixture, TestBed} from '@angular/core/testing';
1+
import {
2+
inject,
3+
fakeAsync,
4+
async,
5+
ComponentFixture,
6+
TestBed,
7+
flushMicrotasks,
8+
} from '@angular/core/testing';
29
import {NgModule, Component, Directive, ViewChild, ViewContainerRef} from '@angular/core';
310
import {MdDialog, MdDialogModule} from './dialog';
411
import {OverlayContainer} from '@angular2-material/core/overlay/overlay-container';
@@ -27,7 +34,7 @@ describe('MdDialog', () => {
2734
TestBed.compileComponents();
2835
}));
2936

30-
beforeEach(inject([MdDialog], fakeAsync((d: MdDialog) => {
37+
beforeEach(fakeAsync(inject([MdDialog], (d: MdDialog) => {
3138
dialog = d;
3239
})));
3340

@@ -38,71 +45,94 @@ describe('MdDialog', () => {
3845
testViewContainerRef = viewContainerFixture.componentInstance.childViewContainer;
3946
});
4047

41-
it('should open a dialog with a component', async(() => {
48+
it('should open a dialog with a component', fakeAsync(() => {
4249
let config = new MdDialogConfig();
4350
config.viewContainerRef = testViewContainerRef;
4451

45-
dialog.open(PizzaMsg, config).then(dialogRef => {
46-
expect(overlayContainerElement.textContent).toContain('Pizza');
47-
expect(dialogRef.componentInstance).toEqual(jasmine.any(PizzaMsg));
48-
expect(dialogRef.componentInstance.dialogRef).toBe(dialogRef);
49-
50-
viewContainerFixture.detectChanges();
51-
let dialogContainerElement = overlayContainerElement.querySelector('md-dialog-container');
52-
expect(dialogContainerElement.getAttribute('role')).toBe('dialog');
52+
let dialogRef: MdDialogRef<PizzaMsg>;
53+
dialog.open(PizzaMsg, config).then(ref => {
54+
dialogRef = ref;
5355
});
5456

55-
detectChangesForDialogOpen(viewContainerFixture);
57+
flushDialogOpen(viewContainerFixture);
58+
59+
expect(overlayContainerElement.textContent).toContain('Pizza');
60+
expect(dialogRef.componentInstance).toEqual(jasmine.any(PizzaMsg));
61+
expect(dialogRef.componentInstance.dialogRef).toBe(dialogRef);
62+
63+
viewContainerFixture.detectChanges();
64+
let dialogContainerElement = overlayContainerElement.querySelector('md-dialog-container');
65+
expect(dialogContainerElement.getAttribute('role')).toBe('dialog');
5666
}));
5767

58-
it('should apply the configured role to the dialog element', async(() => {
68+
it('should apply the configured role to the dialog element', fakeAsync(() => {
5969
let config = new MdDialogConfig();
6070
config.viewContainerRef = testViewContainerRef;
6171
config.role = 'alertdialog';
6272

63-
dialog.open(PizzaMsg, config).then(dialogRef => {
64-
viewContainerFixture.detectChanges();
65-
66-
let dialogContainerElement = overlayContainerElement.querySelector('md-dialog-container');
67-
expect(dialogContainerElement.getAttribute('role')).toBe('alertdialog');
73+
let dialogRef: MdDialogRef<PizzaMsg>;
74+
dialog.open(PizzaMsg, config).then(ref => {
75+
dialogRef = ref;
6876
});
6977

70-
detectChangesForDialogOpen(viewContainerFixture);
78+
flushDialogOpen(viewContainerFixture);
79+
80+
let dialogContainerElement = overlayContainerElement.querySelector('md-dialog-container');
81+
expect(dialogContainerElement.getAttribute('role')).toBe('alertdialog');
7182
}));
7283

73-
it('should close a dialog and get back a result', async(() => {
84+
it('should close a dialog and get back a result', fakeAsync(() => {
7485
let config = new MdDialogConfig();
7586
config.viewContainerRef = testViewContainerRef;
7687

77-
dialog.open(PizzaMsg, config).then(dialogRef => {
78-
viewContainerFixture.detectChanges();
88+
let dialogRef: MdDialogRef<PizzaMsg>;
89+
dialog.open(PizzaMsg, config).then(ref => {
90+
dialogRef = ref;
91+
});
7992

80-
let afterCloseResult: string;
81-
dialogRef.afterClosed().subscribe(result => {
82-
afterCloseResult = result;
83-
});
93+
flushDialogOpen(viewContainerFixture);
8494

85-
dialogRef.close('Charmander');
95+
viewContainerFixture.detectChanges();
8696

87-
viewContainerFixture.whenStable().then(() => {
88-
expect(afterCloseResult).toBe('Charmander');
89-
expect(overlayContainerElement.childNodes.length).toBe(0);
90-
});
97+
let afterCloseResult: string;
98+
dialogRef.afterClosed().subscribe(result => {
99+
afterCloseResult = result;
91100
});
92101

93-
detectChangesForDialogOpen(viewContainerFixture);
102+
dialogRef.close('Charmander');
103+
flushMicrotasks();
104+
105+
expect(afterCloseResult).toBe('Charmander');
106+
expect(overlayContainerElement.querySelector('md-dialog-container')).toBeNull();
107+
}));
108+
109+
it('should close when clicking on the overlay backdrop', fakeAsync(() => {
110+
let config = new MdDialogConfig();
111+
config.viewContainerRef = testViewContainerRef;
112+
113+
let dialogRef: MdDialogRef<PizzaMsg>;
114+
dialog.open(PizzaMsg, config).then(ref => {
115+
dialogRef = ref;
116+
});
117+
118+
flushDialogOpen(viewContainerFixture);
119+
120+
let backdrop = <HTMLElement> overlayContainerElement.querySelector('.md-overlay-backdrop');
121+
backdrop.click();
122+
123+
flushMicrotasks();
124+
expect(overlayContainerElement.querySelector('md-dialog-container')).toBeFalsy();
94125
}));
95126
});
96127

97128

98-
/** Runs the necessary detectChanges for a dialog to complete its opening. */
99-
function detectChangesForDialogOpen(fixture: ComponentFixture<ComponentWithChildViewContainer>) {
100-
// TODO(jelbourn): figure out why the test zone is "stable" when there are still pending
101-
// tasks, such that we have to use `setTimeout` to run the second round of change detection.
129+
/** Flush the creation of a dialog. */
130+
function flushDialogOpen(fixture: ComponentFixture<any>) {
102131
// Two rounds of change detection are necessary: one to *create* the dialog container, and
103132
// another to cause the lifecycle events of the container to run and load the dialog content.
104133
fixture.detectChanges();
105-
setTimeout(() => fixture.detectChanges(), 50);
134+
flushMicrotasks();
135+
fixture.detectChanges();
106136
}
107137

108138
@Directive({selector: 'dir-with-view-container'})
@@ -123,10 +153,7 @@ class ComponentWithChildViewContainer {
123153
}
124154

125155
/** Simple component for testing ComponentPortal. */
126-
@Component({
127-
selector: 'pizza-msg',
128-
template: '<p>Pizza</p>',
129-
})
156+
@Component({template: '<p>Pizza</p>'})
130157
class PizzaMsg {
131158
constructor(public dialogRef: MdDialogRef<PizzaMsg>) { }
132159
}

src/components/dialog/dialog.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,9 @@ export class MdDialog {
9191
// to modify and close it.
9292
let dialogRef = new MdDialogRef(overlayRef);
9393

94+
// When the dialog backdrop is clicked, we want to close it.
95+
overlayRef.backdropClick().subscribe(() => dialogRef.close());
96+
9497
// We create an injector specifically for the component we're instantiating so that it can
9598
// inject the MdDialogRef. This allows a component loaded inside of a dialog to close itself
9699
// and, optionally, to return a value.
@@ -111,6 +114,7 @@ export class MdDialog {
111114
private _getOverlayState(dialogConfig: MdDialogConfig): OverlayState {
112115
let state = new OverlayState();
113116

117+
state.hasBackdrop = true;
114118
state.positionStrategy = this._overlay.position()
115119
.global()
116120
.centerHorizontally()

src/core/overlay/overlay-ref.ts

Lines changed: 64 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,56 @@
11
import {PortalHost, Portal} from '../portal/portal';
22
import {OverlayState} from './overlay-state';
3+
import {Observable} from 'rxjs/Observable';
4+
import {Subject} from 'rxjs/Subject';
5+
import {PromiseCompleter} from '../async/promise-completer';
6+
37

48
/**
59
* Reference to an overlay that has been created with the Overlay service.
610
* Used to manipulate or dispose of said overlay.
711
*/
812
export class OverlayRef implements PortalHost {
13+
private _backdropElement: HTMLElement = null;
14+
private _backdropClick: Subject<any> = new Subject();
15+
916
constructor(
1017
private _portalHost: PortalHost,
1118
private _pane: HTMLElement,
1219
private _state: OverlayState) { }
1320

1421
attach(portal: Portal<any>): Promise<any> {
15-
let attachPromise = this._portalHost.attach(portal);
22+
// We specially do not wait for the backdrop to finish animating to complete the returned
23+
// promise because we do not want to delay the loading of additional content into the overlay.
24+
if (this._state.hasBackdrop) {
25+
this._attachBackdrop();
26+
}
1627

17-
// Don't chain the .then() call in the return because we want the result of portalHost.attach
18-
// to be returned from this method.
19-
attachPromise.then(() => {
28+
return this._portalHost.attach(portal).then(attachResult => {
2029
this.updatePosition();
30+
return attachResult;
2131
});
22-
23-
return attachPromise;
2432
}
2533

2634
detach(): Promise<any> {
27-
return this._portalHost.detach();
35+
return Promise.all([
36+
this._portalHost.detach(),
37+
this._detatchBackdrop()
38+
]).then(values => values[0]);
2839
}
2940

3041
dispose(): void {
42+
this._detatchBackdrop();
3143
this._portalHost.dispose();
3244
}
3345

3446
hasAttached(): boolean {
3547
return this._portalHost.hasAttached();
3648
}
3749

50+
backdropClick(): Observable<void> {
51+
return this._backdropClick.asObservable();
52+
}
53+
3854
/** Gets the current state config of the overlay. */
3955
getState() {
4056
return this._state;
@@ -47,5 +63,45 @@ export class OverlayRef implements PortalHost {
4763
}
4864
}
4965

50-
// TODO(jelbourn): add additional methods for manipulating the overlay.
66+
/** Attaches a backdrop for this overlay. */
67+
private _attachBackdrop() {
68+
this._backdropElement = document.createElement('div');
69+
this._backdropElement.classList.add('md-overlay-backdrop');
70+
this._pane.parentElement.appendChild(this._backdropElement);
71+
72+
// Forward backdrop clicks that that the consumer of the overlay can perform whatever
73+
// action desired when such a click occurs (usually closing the overlay).
74+
this._backdropElement.addEventListener('click', () => {
75+
this._backdropClick.next(null);
76+
});
77+
78+
// Add class to fade-in the backdrop after one frame.
79+
requestAnimationFrame(() => {
80+
this._backdropElement.classList.add('md-overlay-backdrop-showing');
81+
});
82+
}
83+
84+
/** Detaches the backdrop (if any) associated with the overlay. */
85+
private _detatchBackdrop(): Promise<void> {
86+
let backdropToDetach = this._backdropElement;
87+
88+
if (backdropToDetach) {
89+
let completer = new PromiseCompleter<void>();
90+
91+
backdropToDetach.classList.remove('md-overlay-backdrop-showing');
92+
backdropToDetach.addEventListener('transitionend', () => {
93+
backdropToDetach.parentNode.removeChild(backdropToDetach);
94+
95+
// It is possible that a new portal has been attached to this overlay since we started
96+
// removing the backdrop. If that is the case, only clear our the backdrop reference if it
97+
// is still the same instance that we started to remove.
98+
if (this._backdropElement == backdropToDetach) {
99+
this._backdropElement = null;
100+
}
101+
completer.resolve();
102+
});
103+
} else {
104+
return Promise.resolve();
105+
}
106+
}
51107
}

src/core/overlay/overlay-state.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ export class OverlayState {
99
/** Strategy with which to position the overlay. */
1010
positionStrategy: PositionStrategy;
1111

12+
/** Whether the overlay has a backdrop. */
13+
hasBackdrop: boolean = false;
14+
1215
// TODO(jelbourn): configuration still to add
1316
// - overlay size
1417
// - focus trap

src/core/overlay/overlay.scss

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
@import 'variables';
2+
@import 'palette';
3+
4+
$md-backdrop-color: md-color($md-grey, 900);
5+
16
// TODO(jelbourn): change from the `md` prefix to something else for everything in the toolkit.
27

38
@import 'variables';
@@ -14,12 +19,35 @@
1419
left: 0;
1520
height: 100%;
1621
width: 100%;
22+
23+
z-index: 1;
1724
}
1825

1926
/** A single overlay pane. */
2027
.md-overlay-pane {
2128
position: absolute;
2229
pointer-events: auto;
2330
box-sizing: border-box;
24-
z-index: $z-index-overlay;
31+
z-index: $md-z-index-overlay;
32+
}
33+
34+
.md-overlay-backdrop {
35+
position: absolute;
36+
top: 0;
37+
bottom: 0;
38+
left: 0;
39+
right: 0;
40+
41+
z-index: $md-z-index-overlay-backdrop;
42+
pointer-events: auto;
43+
44+
// TODO(jelbourn): figure out if there are actually spec'ed colors for both light and dark
45+
// themes here. Currently using the values from Angular Material 1.
46+
transition: opacity $swift-ease-out-duration $swift-ease-out-timing-function;
47+
background: $md-backdrop-color;
48+
opacity: 0;
49+
}
50+
51+
.md-overlay-backdrop.md-overlay-backdrop-showing {
52+
opacity: .48;
2553
}

0 commit comments

Comments
 (0)