Skip to content

Commit cd7d7d1

Browse files
committed
feat(dialog): inital framework for md-dialog
1 parent 3ccb23e commit cd7d7d1

20 files changed

+520
-15
lines changed
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import {ViewContainerRef} from '@angular/core';
2+
3+
/** Valid ARIA roles for a dialog element. */
4+
export type DialogRole = 'dialog' | 'alertdialog'
5+
6+
7+
8+
/**
9+
* Configuration for opening a modal dialog with the MdDialog service.
10+
*/
11+
export class MdDialogConfig {
12+
viewContainerRef: ViewContainerRef;
13+
14+
/** The ARIA role of the dialog element. */
15+
role: DialogRole = 'dialog';
16+
17+
// TODO(jelbourn): add configuration for size, clickOutsideToClose, lifecycle hooks,
18+
// ARIA labelling.
19+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<template portalHost></template>
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
@import 'elevation';
2+
3+
:host {
4+
// TODO(jelbourn): add real Material Design dialog styles.
5+
display: block;
6+
background: deeppink;
7+
@include md-elevation(2);
8+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import {Component, ComponentRef, ViewChild, AfterViewInit} from '@angular/core';
2+
import {BasePortalHost, ComponentPortal, TemplatePortal} from '../../core/portal/portal';
3+
import {PortalHostDirective} from '@angular2-material/core/portal/portal-directives';
4+
import {PromiseCompleter} from '@angular2-material/core/async/promise-completer';
5+
import {MdDialogConfig} from './dialog-config';
6+
7+
8+
9+
/**
10+
* Internal component that wraps user-provided dialog content.
11+
*/
12+
@Component({
13+
moduleId: module.id,
14+
selector: 'md-dialog-container',
15+
templateUrl: 'dialog-container.html',
16+
styleUrls: ['dialog-container.css'],
17+
directives: [PortalHostDirective],
18+
host: {
19+
'class': 'md-dialog-container',
20+
'[attr.role]': 'dialogConfig?.role'
21+
}
22+
})
23+
export class MdDialogContainer extends BasePortalHost implements AfterViewInit {
24+
/** The portal host inside of this container into which the dialog content will be loaded. */
25+
@ViewChild(PortalHostDirective) private _portalHost: PortalHostDirective;
26+
27+
/**
28+
* Completer used to resolve the promise for cases when a portal is attempted to be attached,
29+
* but AfterViewInit has not yet occured.
30+
*/
31+
private _deferredAttachCompleter: PromiseCompleter<ComponentRef<any>>;
32+
33+
/** Portal to be attached upon AfterViewInit. */
34+
private _deferredAttachPortal: ComponentPortal;
35+
36+
/** The dialog configuration. */
37+
dialogConfig: MdDialogConfig;
38+
39+
/** TODO: internal */
40+
ngAfterViewInit() {
41+
// If there was an attempted call to `attachComponentPortal` before this lifecycle stage,
42+
// we actually perform the attachment now that the `@ViewChild` is resolved.
43+
if (this._deferredAttachCompleter) {
44+
this.attachComponentPortal(this._deferredAttachPortal).then(componentRef => {
45+
this._deferredAttachCompleter.resolve(componentRef);
46+
47+
this._deferredAttachPortal = null;
48+
this._deferredAttachCompleter = null;
49+
});
50+
}
51+
}
52+
53+
/** Attach a portal as content to this dialog container. */
54+
attachComponentPortal(portal: ComponentPortal): Promise<ComponentRef<any>> {
55+
if (this._portalHost) {
56+
return this._portalHost.attachComponentPortal(portal);
57+
} else {
58+
// The @ViewChild query for the portalHost is not resolved until AfterViewInit, but this
59+
// function may be called before this lifecycle event. As such, we defer the attachment of
60+
// the portal until AfterViewInit.
61+
this._deferredAttachPortal = portal;
62+
this._deferredAttachCompleter = new PromiseCompleter();
63+
return this._deferredAttachCompleter.promise;
64+
}
65+
}
66+
67+
attachTemplatePortal(portal: TemplatePortal): Promise<Map<string, any>> {
68+
throw Error('Not yet implemented');
69+
}
70+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import {Injector} from '@angular/core';
2+
import {MdDialogRef} from './dialog-ref';
3+
4+
5+
/**
6+
* Custom injector type specifically for instantiating components with a dialog.
7+
* @internal
8+
*/
9+
export class DialogInjector implements Injector {
10+
constructor(private _dialogRef: MdDialogRef<any>, private _parentInjector: Injector) { }
11+
12+
get(token: any, notFoundValue?: any): any {
13+
if (token === MdDialogRef) {
14+
return this._dialogRef;
15+
}
16+
17+
return this._parentInjector.get(token, notFoundValue);
18+
}
19+
}

src/components/dialog/dialog-ref.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/**
2+
* Reference to a dialog opened via the MdDialog service.
3+
*/
4+
export class MdDialogRef<T> {
5+
/** The instance of component opened into the dialog. */
6+
componentInstance: T;
7+
8+
// TODO(jelbourn): Add methods to resize, close, and get results from the dialog.
9+
}

src/components/dialog/dialog.spec.ts

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import {
2+
it,
3+
describe,
4+
expect,
5+
beforeEach,
6+
inject,
7+
fakeAsync,
8+
async,
9+
beforeEachProviders,
10+
} from '@angular/core/testing';
11+
import {TestComponentBuilder, ComponentFixture} from '@angular/compiler/testing';
12+
import {
13+
Component,
14+
Directive,
15+
ViewChild,
16+
provide,
17+
ViewContainerRef,
18+
ChangeDetectorRef,
19+
} from '@angular/core';
20+
import {MdDialog} from './dialog';
21+
import {OVERLAY_PROVIDERS, OVERLAY_CONTAINER_TOKEN} from '../../core/overlay/overlay';
22+
import {MdDialogConfig} from './dialog-config';
23+
import {MdDialogRef} from './dialog-ref';
24+
25+
26+
27+
describe('MdDialog', () => {
28+
let builder: TestComponentBuilder;
29+
let dialog: MdDialog;
30+
let overlayContainerElement: HTMLElement;
31+
32+
let testViewContainerRef: ViewContainerRef;
33+
let viewContainerFixture: ComponentFixture<ComponentWithChildViewContainer>;
34+
35+
beforeEachProviders(() => [
36+
OVERLAY_PROVIDERS,
37+
MdDialog,
38+
provide(OVERLAY_CONTAINER_TOKEN, {
39+
useFactory: () => {
40+
overlayContainerElement = document.createElement('div');
41+
return overlayContainerElement;
42+
}
43+
})
44+
]);
45+
46+
let deps = [TestComponentBuilder, MdDialog];
47+
beforeEach(inject(deps, fakeAsync((tcb: TestComponentBuilder, d: MdDialog) => {
48+
builder = tcb;
49+
dialog = d;
50+
})));
51+
52+
beforeEach(async(() => {
53+
builder.createAsync(ComponentWithChildViewContainer).then(fixture => {
54+
viewContainerFixture = fixture;
55+
56+
viewContainerFixture.detectChanges();
57+
testViewContainerRef = fixture.componentInstance.childViewContainer;
58+
});
59+
}));
60+
61+
it('should open a dialog with a component', async(() => {
62+
let config = new MdDialogConfig();
63+
config.viewContainerRef = testViewContainerRef;
64+
65+
dialog.open(PizzaMsg, config).then(dialogRef => {
66+
expect(overlayContainerElement.textContent).toContain('Pizza');
67+
expect(dialogRef.componentInstance).toEqual(jasmine.any(PizzaMsg));
68+
expect(dialogRef.componentInstance.dialogRef).toBe(dialogRef);
69+
});
70+
71+
detectChangesForDialogOpen(viewContainerFixture);
72+
}));
73+
74+
it('should apply the configured role to the dialog element', async(() => {
75+
let config = new MdDialogConfig();
76+
config.viewContainerRef = testViewContainerRef;
77+
config.role = 'alertdialog';
78+
79+
dialog.open(PizzaMsg, config).then(() => {
80+
let dialogContainerElement = overlayContainerElement.querySelector('md-dialog-container');
81+
expect(dialogContainerElement.getAttribute('role')).toBe('alertdialog');
82+
});
83+
84+
detectChangesForDialogOpen(viewContainerFixture);
85+
}));
86+
});
87+
88+
89+
/** Runs the necessary detectChanges for a dialog to complete its opening. */
90+
function detectChangesForDialogOpen(fixture: ComponentFixture<ComponentWithChildViewContainer>) {
91+
// TODO(jelbourn): figure out why the test zone is "stable" when where are still pending
92+
// tasks, such that we have to use `setTimeout` to run the second round of change detection.
93+
// Two rounds of change detection are necessary: one to *create* the dialog container, and
94+
// another to cause the lifecycle events of the container to run and load the dialog content.
95+
fixture.detectChanges();
96+
setTimeout(() => fixture.detectChanges(), 50);
97+
}
98+
99+
@Directive({selector: 'dir-with-view-container'})
100+
class DirectiveWithViewContainer {
101+
constructor(public viewContainerRef: ViewContainerRef) { }
102+
}
103+
104+
@Component({
105+
selector: 'arbitrary-component',
106+
template: `<dir-with-view-container></dir-with-view-container>`,
107+
directives: [DirectiveWithViewContainer],
108+
})
109+
class ComponentWithChildViewContainer {
110+
@ViewChild(DirectiveWithViewContainer) childWithViewContainer: DirectiveWithViewContainer;
111+
112+
constructor(public changeDetectorRef: ChangeDetectorRef) { }
113+
114+
get childViewContainer() {
115+
return this.childWithViewContainer.viewContainerRef;
116+
}
117+
}
118+
119+
/** Simple component for testing ComponentPortal. */
120+
@Component({
121+
selector: 'pizza-msg',
122+
template: '<p>Pizza</p>',
123+
})
124+
class PizzaMsg {
125+
constructor(public dialogRef: MdDialogRef<PizzaMsg>) { }
126+
}

src/components/dialog/dialog.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import {Injector, ComponentRef, Injectable} from '@angular/core';
2+
import {Overlay} from '../../core/overlay/overlay';
3+
import {OverlayRef} from '../../core/overlay/overlay-ref';
4+
import {OverlayState} from '../../core/overlay/overlay-state';
5+
import {MdDialogConfig} from './dialog-config';
6+
import {MdDialogRef} from './dialog-ref';
7+
import {DialogInjector} from './dialog-injector';
8+
import {ComponentPortal} from '../../core/portal/portal';
9+
import {MdDialogContainer} from './dialog-container';
10+
11+
12+
export {MdDialogConfig} from './dialog-config';
13+
export {MdDialogRef} from './dialog-ref';
14+
15+
16+
// TODO(jelbourn): add shortcuts for `alert` and `confirm`.
17+
// TODO(jelbourn): add support for opening with a TemplateRef
18+
// TODO(jelbourn): add `closeAll` method
19+
// TODO(jelbourn): add backdrop
20+
// TODO(jelbourn): default dialog config
21+
// TODO(jelbourn): focus trapping
22+
// TODO(jelbourn): potentially change API from accepting component constructor to component factory.
23+
24+
25+
26+
/**
27+
* Service to open Material Design modal dialogs.
28+
*/
29+
@Injectable()
30+
export class MdDialog {
31+
constructor(private _overlay: Overlay, private _injector: Injector) { }
32+
33+
/**
34+
* Opens a modal dialog containing the given component.
35+
* @param component Type of the component to load into the load.
36+
* @param config
37+
*/
38+
open<T>(component: {new (...args: any[]): T}, config: MdDialogConfig): Promise<MdDialogRef<T>> {
39+
return this._createOverlay(config)
40+
.then(overlayRef => this._attachDialogContainer(overlayRef, config))
41+
.then(containerRef => this._attachDialogContent(component, containerRef));
42+
}
43+
44+
/**
45+
* Creates the overlay into which the dialog will be loaded.
46+
* @param dialogConfig The dialog configuration.
47+
* @returns A promise resolving to the OverlayRef for the created overlay.
48+
*/
49+
private _createOverlay(dialogConfig: MdDialogConfig): Promise<OverlayRef> {
50+
let overlayState = this._getOverlayState(dialogConfig);
51+
return this._overlay.create(overlayState);
52+
}
53+
54+
/**
55+
* Attaches an MdDialogContainer to a dialog's already-created overlay.
56+
* @param overlayRef Reference to the dialog's underlying overlay.
57+
* @param config The dialog configuration.
58+
* @returns A promise resolving to a ComponentRef for the attached container.
59+
*/
60+
private _attachDialogContainer(overlayRef: OverlayRef, config: MdDialogConfig):
61+
Promise<ComponentRef<MdDialogContainer>> {
62+
let containerPortal = new ComponentPortal(MdDialogContainer, config.viewContainerRef);
63+
return overlayRef.attach(containerPortal).then(containerRef => {
64+
// Pass the config directly to the container so that it can consume any relevant settings.
65+
containerRef.instance.dialogConfig = config;
66+
return containerRef;
67+
});
68+
}
69+
70+
/**
71+
* Attaches the user-provided component to the already-created MdDialogContainer.
72+
* @param component The type of component being loaded into the dialog.
73+
* @param containerRef Reference to the wrapping MdDialogContainer.
74+
* @returns A promise resolving to the MdDialogRef that should be returned to the user.
75+
*/
76+
private _attachDialogContent<T>(
77+
component: {new (): T},
78+
containerRef: ComponentRef<MdDialogContainer>): Promise<MdDialogRef<T>> {
79+
let dialogContainer = containerRef.instance;
80+
81+
// Create a reference to the dialog we're creating in order to give the user a handle
82+
// to modify and close it.
83+
let dialogRef = new MdDialogRef();
84+
85+
// We create an injector specifically for the component we're instantiating so that it can
86+
// inject the MdDialogRef. This allows a component loaded inside of a dialog to close itself
87+
// and, optionally, to return a value.
88+
let dialogInjector = new DialogInjector(dialogRef, this._injector);
89+
90+
let contentPortal = new ComponentPortal(component, null, dialogInjector);
91+
return dialogContainer.attachComponentPortal(contentPortal).then(contentRef => {
92+
dialogRef.componentInstance = contentRef.instance;
93+
return dialogRef;
94+
});
95+
}
96+
97+
/**
98+
* Creates an overlay state from a dialog config.
99+
* @param dialogConfig The dialog configuration.
100+
* @returns The overlay configuration.
101+
*/
102+
private _getOverlayState(dialogConfig: MdDialogConfig): OverlayState {
103+
let state = new OverlayState();
104+
105+
state.positionStrategy = this._overlay.position()
106+
.global()
107+
.centerHorizontally()
108+
.centerVertically();
109+
110+
return state;
111+
}
112+
}

src/core/overlay/overlay-ref.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,12 @@ export class OverlayRef implements PortalHost {
1212
private _state: OverlayState) { }
1313

1414
attach(portal: Portal<any>): Promise<any> {
15-
return this._portalHost.attach(portal).then(() => {
15+
let attachPromise = this._portalHost.attach(portal);
16+
attachPromise.then(() => {
1617
this._updatePosition();
1718
});
19+
20+
return attachPromise;
1821
}
1922

2023
detach(): Promise<any> {

src/core/overlay/overlay.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,7 @@ export class Overlay {
8282
* @returns A portal host for the given DOM element.
8383
*/
8484
private _createPortalHost(pane: HTMLElement): DomPortalHost {
85-
return new DomPortalHost(
86-
pane,
87-
this._componentResolver);
85+
return new DomPortalHost(pane, this._componentResolver);
8886
}
8987

9088
/**

0 commit comments

Comments
 (0)