Skip to content

Commit e71b56b

Browse files
committed
feat(dialog): inital framework for md-dialog
1 parent fad4ef5 commit e71b56b

23 files changed

+540
-21
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: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import {Component, ComponentRef, ViewChild, AfterViewInit} from '@angular/core';
2+
import {
3+
BasePortalHost,
4+
ComponentPortal,
5+
TemplatePortal
6+
} from '@angular2-material/core/portal/portal';
7+
import {PortalHostDirective} from '@angular2-material/core/portal/portal-directives';
8+
import {PromiseCompleter} from '@angular2-material/core/async/promise-completer';
9+
import {MdDialogConfig} from './dialog-config';
10+
11+
12+
/**
13+
* Internal component that wraps user-provided dialog content.
14+
*/
15+
@Component({
16+
moduleId: module.id,
17+
selector: 'md-dialog-container',
18+
templateUrl: 'dialog-container.html',
19+
styleUrls: ['dialog-container.css'],
20+
directives: [PortalHostDirective],
21+
host: {
22+
'class': 'md-dialog-container',
23+
'[attr.role]': 'dialogConfig?.role'
24+
}
25+
})
26+
export class MdDialogContainer extends BasePortalHost implements AfterViewInit {
27+
/** The portal host inside of this container into which the dialog content will be loaded. */
28+
@ViewChild(PortalHostDirective) private _portalHost: PortalHostDirective;
29+
30+
/**
31+
* Completer used to resolve the promise for cases when a portal is attempted to be attached,
32+
* but AfterViewInit has not yet occured.
33+
*/
34+
private _deferredAttachCompleter: PromiseCompleter<ComponentRef<any>>;
35+
36+
/** Portal to be attached upon AfterViewInit. */
37+
private _deferredAttachPortal: ComponentPortal<any>;
38+
39+
/** The dialog configuration. */
40+
dialogConfig: MdDialogConfig;
41+
42+
/** TODO: internal */
43+
ngAfterViewInit() {
44+
// If there was an attempted call to `attachComponentPortal` before this lifecycle stage,
45+
// we actually perform the attachment now that the `@ViewChild` is resolved.
46+
if (this._deferredAttachCompleter) {
47+
this.attachComponentPortal(this._deferredAttachPortal).then(componentRef => {
48+
this._deferredAttachCompleter.resolve(componentRef);
49+
50+
this._deferredAttachPortal = null;
51+
this._deferredAttachCompleter = null;
52+
});
53+
}
54+
}
55+
56+
/** Attach a portal as content to this dialog container. */
57+
attachComponentPortal<T>(portal: ComponentPortal<T>): Promise<ComponentRef<T>> {
58+
if (this._portalHost) {
59+
return this._portalHost.attachComponentPortal(portal);
60+
} else {
61+
// The @ViewChild query for the portalHost is not resolved until AfterViewInit, but this
62+
// function may be called before this lifecycle event. As such, we defer the attachment of
63+
// the portal until AfterViewInit.
64+
this._deferredAttachPortal = portal;
65+
this._deferredAttachCompleter = new PromiseCompleter();
66+
return this._deferredAttachCompleter.promise;
67+
}
68+
}
69+
70+
attachTemplatePortal(portal: TemplatePortal): Promise<Map<string, any>> {
71+
throw Error('Not yet implemented');
72+
}
73+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import {Injector} from '@angular/core';
2+
import {MdDialogRef} from './dialog-ref';
3+
4+
5+
/** Custom injector type specifically for instantiating components with a dialog. */
6+
export class DialogInjector implements Injector {
7+
constructor(private _dialogRef: MdDialogRef<any>, private _parentInjector: Injector) { }
8+
9+
get(token: any, notFoundValue?: any): any {
10+
if (token === MdDialogRef) {
11+
return this._dialogRef;
12+
}
13+
14+
return this._parentInjector.get(token, notFoundValue);
15+
}
16+
}
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+
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
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 '@angular2-material/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+
viewContainerFixture.detectChanges();
71+
let dialogContainerElement = overlayContainerElement.querySelector('md-dialog-container');
72+
expect(dialogContainerElement.getAttribute('role')).toBe('dialog');
73+
});
74+
75+
detectChangesForDialogOpen(viewContainerFixture);
76+
}));
77+
78+
it('should apply the configured role to the dialog element', async(() => {
79+
let config = new MdDialogConfig();
80+
config.viewContainerRef = testViewContainerRef;
81+
config.role = 'alertdialog';
82+
83+
dialog.open(PizzaMsg, config).then(dialogRef => {
84+
viewContainerFixture.detectChanges();
85+
86+
let dialogContainerElement = overlayContainerElement.querySelector('md-dialog-container');
87+
expect(dialogContainerElement.getAttribute('role')).toBe('alertdialog');
88+
});
89+
90+
detectChangesForDialogOpen(viewContainerFixture);
91+
}));
92+
});
93+
94+
95+
/** Runs the necessary detectChanges for a dialog to complete its opening. */
96+
function detectChangesForDialogOpen(fixture: ComponentFixture<ComponentWithChildViewContainer>) {
97+
// TODO(jelbourn): figure out why the test zone is "stable" when where are still pending
98+
// tasks, such that we have to use `setTimeout` to run the second round of change detection.
99+
// Two rounds of change detection are necessary: one to *create* the dialog container, and
100+
// another to cause the lifecycle events of the container to run and load the dialog content.
101+
fixture.detectChanges();
102+
setTimeout(() => fixture.detectChanges(), 50);
103+
}
104+
105+
@Directive({selector: 'dir-with-view-container'})
106+
class DirectiveWithViewContainer {
107+
constructor(public viewContainerRef: ViewContainerRef) { }
108+
}
109+
110+
@Component({
111+
selector: 'arbitrary-component',
112+
template: `<dir-with-view-container></dir-with-view-container>`,
113+
directives: [DirectiveWithViewContainer],
114+
})
115+
class ComponentWithChildViewContainer {
116+
@ViewChild(DirectiveWithViewContainer) childWithViewContainer: DirectiveWithViewContainer;
117+
118+
constructor(public changeDetectorRef: ChangeDetectorRef) { }
119+
120+
get childViewContainer() {
121+
return this.childWithViewContainer.viewContainerRef;
122+
}
123+
}
124+
125+
/** Simple component for testing ComponentPortal. */
126+
@Component({
127+
selector: 'pizza-msg',
128+
template: '<p>Pizza</p>',
129+
})
130+
class PizzaMsg {
131+
constructor(public dialogRef: MdDialogRef<PizzaMsg>) { }
132+
}

src/components/dialog/dialog.ts

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

0 commit comments

Comments
 (0)