Skip to content

Commit 9552ed5

Browse files
jelbournkara
authored andcommitted
feat(dialog): initial framework for md-dialog (#761)
1 parent 8354750 commit 9552ed5

24 files changed

+556
-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: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
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+
import {MdDialogContentAlreadyAttachedError} from './dialog-errors';
11+
12+
13+
/**
14+
* Internal component that wraps user-provided dialog content.
15+
*/
16+
@Component({
17+
moduleId: module.id,
18+
selector: 'md-dialog-container',
19+
templateUrl: 'dialog-container.html',
20+
styleUrls: ['dialog-container.css'],
21+
directives: [PortalHostDirective],
22+
host: {
23+
'class': 'md-dialog-container',
24+
'[attr.role]': 'dialogConfig?.role'
25+
}
26+
})
27+
export class MdDialogContainer extends BasePortalHost implements AfterViewInit {
28+
/** The portal host inside of this container into which the dialog content will be loaded. */
29+
@ViewChild(PortalHostDirective) private _portalHost: PortalHostDirective;
30+
31+
/**
32+
* Completer used to resolve the promise for cases when a portal is attempted to be attached,
33+
* but AfterViewInit has not yet occured.
34+
*/
35+
private _deferredAttachCompleter: PromiseCompleter<ComponentRef<any>>;
36+
37+
/** Portal to be attached upon AfterViewInit. */
38+
private _deferredAttachPortal: ComponentPortal<any>;
39+
40+
/** The dialog configuration. */
41+
dialogConfig: MdDialogConfig;
42+
43+
/** TODO: internal */
44+
ngAfterViewInit() {
45+
// If there was an attempted call to `attachComponentPortal` before this lifecycle stage,
46+
// we actually perform the attachment now that the `@ViewChild` is resolved.
47+
if (this._deferredAttachCompleter) {
48+
this.attachComponentPortal(this._deferredAttachPortal).then(componentRef => {
49+
this._deferredAttachCompleter.resolve(componentRef);
50+
51+
this._deferredAttachPortal = null;
52+
this._deferredAttachCompleter = null;
53+
}, () => {
54+
this._deferredAttachCompleter.reject();
55+
this._deferredAttachCompleter = null;
56+
this._deferredAttachPortal = null;
57+
});
58+
}
59+
}
60+
61+
/** Attach a portal as content to this dialog container. */
62+
attachComponentPortal<T>(portal: ComponentPortal<T>): Promise<ComponentRef<T>> {
63+
if (this._portalHost) {
64+
if (this._portalHost.hasAttached()) {
65+
throw new MdDialogContentAlreadyAttachedError();
66+
}
67+
68+
return this._portalHost.attachComponentPortal(portal);
69+
} else {
70+
// The @ViewChild query for the portalHost is not resolved until AfterViewInit, but this
71+
// function may be called before this lifecycle event. As such, we defer the attachment of
72+
// the portal until AfterViewInit.
73+
if (this._deferredAttachCompleter) {
74+
throw new MdDialogContentAlreadyAttachedError();
75+
}
76+
77+
this._deferredAttachPortal = portal;
78+
this._deferredAttachCompleter = new PromiseCompleter();
79+
return this._deferredAttachCompleter.promise;
80+
}
81+
}
82+
83+
attachTemplatePortal(portal: TemplatePortal): Promise<Map<string, any>> {
84+
throw Error('Not yet implemented');
85+
}
86+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import {MdError} from '@angular2-material/core/errors/error';
2+
3+
/** Exception thrown when a ComponentPortal is attached to a DomPortalHost without an origin. */
4+
export class MdDialogContentAlreadyAttachedError extends MdError {
5+
constructor() {
6+
super('Attempting to attach dialog content after content is already attached');
7+
}
8+
}
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+
}

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

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+
}

0 commit comments

Comments
 (0)