Skip to content

Commit

Permalink
feat(dialog): initial framework for md-dialog (#761)
Browse files Browse the repository at this point in the history
  • Loading branch information
jelbourn authored and kara committed Jul 14, 2016
1 parent 8354750 commit 9552ed5
Show file tree
Hide file tree
Showing 24 changed files with 556 additions and 21 deletions.
19 changes: 19 additions & 0 deletions src/components/dialog/dialog-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import {ViewContainerRef} from '@angular/core';

/** Valid ARIA roles for a dialog element. */
export type DialogRole = 'dialog' | 'alertdialog'



/**
* Configuration for opening a modal dialog with the MdDialog service.
*/
export class MdDialogConfig {
viewContainerRef: ViewContainerRef;

/** The ARIA role of the dialog element. */
role: DialogRole = 'dialog';

// TODO(jelbourn): add configuration for size, clickOutsideToClose, lifecycle hooks,
// ARIA labelling.
}
1 change: 1 addition & 0 deletions src/components/dialog/dialog-container.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<template portalHost></template>
8 changes: 8 additions & 0 deletions src/components/dialog/dialog-container.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
@import 'elevation';

:host {
// TODO(jelbourn): add real Material Design dialog styles.
display: block;
background: deeppink;
@include md-elevation(2);
}
86 changes: 86 additions & 0 deletions src/components/dialog/dialog-container.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import {Component, ComponentRef, ViewChild, AfterViewInit} from '@angular/core';
import {
BasePortalHost,
ComponentPortal,
TemplatePortal
} from '@angular2-material/core/portal/portal';
import {PortalHostDirective} from '@angular2-material/core/portal/portal-directives';
import {PromiseCompleter} from '@angular2-material/core/async/promise-completer';
import {MdDialogConfig} from './dialog-config';
import {MdDialogContentAlreadyAttachedError} from './dialog-errors';


/**
* Internal component that wraps user-provided dialog content.
*/
@Component({
moduleId: module.id,
selector: 'md-dialog-container',
templateUrl: 'dialog-container.html',
styleUrls: ['dialog-container.css'],
directives: [PortalHostDirective],
host: {
'class': 'md-dialog-container',
'[attr.role]': 'dialogConfig?.role'
}
})
export class MdDialogContainer extends BasePortalHost implements AfterViewInit {
/** The portal host inside of this container into which the dialog content will be loaded. */
@ViewChild(PortalHostDirective) private _portalHost: PortalHostDirective;

/**
* Completer used to resolve the promise for cases when a portal is attempted to be attached,
* but AfterViewInit has not yet occured.
*/
private _deferredAttachCompleter: PromiseCompleter<ComponentRef<any>>;

/** Portal to be attached upon AfterViewInit. */
private _deferredAttachPortal: ComponentPortal<any>;

/** The dialog configuration. */
dialogConfig: MdDialogConfig;

/** TODO: internal */
ngAfterViewInit() {
// If there was an attempted call to `attachComponentPortal` before this lifecycle stage,
// we actually perform the attachment now that the `@ViewChild` is resolved.
if (this._deferredAttachCompleter) {
this.attachComponentPortal(this._deferredAttachPortal).then(componentRef => {
this._deferredAttachCompleter.resolve(componentRef);

this._deferredAttachPortal = null;
this._deferredAttachCompleter = null;
}, () => {
this._deferredAttachCompleter.reject();
this._deferredAttachCompleter = null;
this._deferredAttachPortal = null;
});
}
}

/** Attach a portal as content to this dialog container. */
attachComponentPortal<T>(portal: ComponentPortal<T>): Promise<ComponentRef<T>> {
if (this._portalHost) {
if (this._portalHost.hasAttached()) {
throw new MdDialogContentAlreadyAttachedError();
}

return this._portalHost.attachComponentPortal(portal);
} else {
// The @ViewChild query for the portalHost is not resolved until AfterViewInit, but this
// function may be called before this lifecycle event. As such, we defer the attachment of
// the portal until AfterViewInit.
if (this._deferredAttachCompleter) {
throw new MdDialogContentAlreadyAttachedError();
}

this._deferredAttachPortal = portal;
this._deferredAttachCompleter = new PromiseCompleter();
return this._deferredAttachCompleter.promise;
}
}

attachTemplatePortal(portal: TemplatePortal): Promise<Map<string, any>> {
throw Error('Not yet implemented');
}
}
8 changes: 8 additions & 0 deletions src/components/dialog/dialog-errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import {MdError} from '@angular2-material/core/errors/error';

/** Exception thrown when a ComponentPortal is attached to a DomPortalHost without an origin. */
export class MdDialogContentAlreadyAttachedError extends MdError {
constructor() {
super('Attempting to attach dialog content after content is already attached');
}
}
16 changes: 16 additions & 0 deletions src/components/dialog/dialog-injector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import {Injector} from '@angular/core';
import {MdDialogRef} from './dialog-ref';


/** Custom injector type specifically for instantiating components with a dialog. */
export class DialogInjector implements Injector {
constructor(private _dialogRef: MdDialogRef<any>, private _parentInjector: Injector) { }

get(token: any, notFoundValue?: any): any {
if (token === MdDialogRef) {
return this._dialogRef;
}

return this._parentInjector.get(token, notFoundValue);
}
}
9 changes: 9 additions & 0 deletions src/components/dialog/dialog-ref.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* Reference to a dialog opened via the MdDialog service.
*/
export class MdDialogRef<T> {
/** The instance of component opened into the dialog. */
componentInstance: T;

// TODO(jelbourn): Add methods to resize, close, and get results from the dialog.
}
127 changes: 127 additions & 0 deletions src/components/dialog/dialog.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import {
inject,
fakeAsync,
async,
addProviders,
} from '@angular/core/testing';
import {TestComponentBuilder, ComponentFixture} from '@angular/compiler/testing';
import {
Component,
Directive,
ViewChild,
ViewContainerRef,
ChangeDetectorRef,
} from '@angular/core';
import {MdDialog} from './dialog';
import {OVERLAY_PROVIDERS, OVERLAY_CONTAINER_TOKEN} from '@angular2-material/core/overlay/overlay';
import {MdDialogConfig} from './dialog-config';
import {MdDialogRef} from './dialog-ref';



describe('MdDialog', () => {
let builder: TestComponentBuilder;
let dialog: MdDialog;
let overlayContainerElement: HTMLElement;

let testViewContainerRef: ViewContainerRef;
let viewContainerFixture: ComponentFixture<ComponentWithChildViewContainer>;

beforeEach(() => {
addProviders([
OVERLAY_PROVIDERS,
MdDialog,
{provide: OVERLAY_CONTAINER_TOKEN, useFactory: () => {
overlayContainerElement = document.createElement('div');
return overlayContainerElement;
}}
]);
});

let deps = [TestComponentBuilder, MdDialog];
beforeEach(inject(deps, fakeAsync((tcb: TestComponentBuilder, d: MdDialog) => {
builder = tcb;
dialog = d;
})));

beforeEach(async(() => {
builder.createAsync(ComponentWithChildViewContainer).then(fixture => {
viewContainerFixture = fixture;

viewContainerFixture.detectChanges();
testViewContainerRef = fixture.componentInstance.childViewContainer;
});
}));

it('should open a dialog with a component', async(() => {
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');
});

detectChangesForDialogOpen(viewContainerFixture);
}));

it('should apply the configured role to the dialog element', async(() => {
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');
});

detectChangesForDialogOpen(viewContainerFixture);
}));
});


/** 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.
// 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);
}

@Directive({selector: 'dir-with-view-container'})
class DirectiveWithViewContainer {
constructor(public viewContainerRef: ViewContainerRef) { }
}

@Component({
selector: 'arbitrary-component',
template: `<dir-with-view-container></dir-with-view-container>`,
directives: [DirectiveWithViewContainer],
})
class ComponentWithChildViewContainer {
@ViewChild(DirectiveWithViewContainer) childWithViewContainer: DirectiveWithViewContainer;

constructor(public changeDetectorRef: ChangeDetectorRef) { }

get childViewContainer() {
return this.childWithViewContainer.viewContainerRef;
}
}

/** Simple component for testing ComponentPortal. */
@Component({
selector: 'pizza-msg',
template: '<p>Pizza</p>',
})
class PizzaMsg {
constructor(public dialogRef: MdDialogRef<PizzaMsg>) { }
}
113 changes: 113 additions & 0 deletions src/components/dialog/dialog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import {Injector, ComponentRef, Injectable} from '@angular/core';
import {Overlay} from '@angular2-material/core/overlay/overlay';
import {OverlayRef} from '@angular2-material/core/overlay/overlay-ref';
import {OverlayState} from '@angular2-material/core/overlay/overlay-state';
import {ComponentPortal} from '@angular2-material/core/portal/portal';
import {ComponentType} from '@angular2-material/core/overlay/generic-component-type';
import {MdDialogConfig} from './dialog-config';
import {MdDialogRef} from './dialog-ref';
import {DialogInjector} from './dialog-injector';
import {MdDialogContainer} from './dialog-container';


export {MdDialogConfig} from './dialog-config';
export {MdDialogRef} from './dialog-ref';


// TODO(jelbourn): add shortcuts for `alert` and `confirm`.
// TODO(jelbourn): add support for opening with a TemplateRef
// TODO(jelbourn): add `closeAll` method
// TODO(jelbourn): add backdrop
// TODO(jelbourn): default dialog config
// TODO(jelbourn): focus trapping
// TODO(jelbourn): potentially change API from accepting component constructor to component factory.



/**
* Service to open Material Design modal dialogs.
*/
@Injectable()
export class MdDialog {
constructor(private _overlay: Overlay, private _injector: Injector) { }

/**
* Opens a modal dialog containing the given component.
* @param component Type of the component to load into the load.
* @param config
*/
open<T>(component: ComponentType<T>, config: MdDialogConfig): Promise<MdDialogRef<T>> {
return this._createOverlay(config)
.then(overlayRef => this._attachDialogContainer(overlayRef, config))
.then(containerRef => this._attachDialogContent(component, containerRef));
}

/**
* Creates the overlay into which the dialog will be loaded.
* @param dialogConfig The dialog configuration.
* @returns A promise resolving to the OverlayRef for the created overlay.
*/
private _createOverlay(dialogConfig: MdDialogConfig): Promise<OverlayRef> {
let overlayState = this._getOverlayState(dialogConfig);
return this._overlay.create(overlayState);
}

/**
* Attaches an MdDialogContainer to a dialog's already-created overlay.
* @param overlayRef Reference to the dialog's underlying overlay.
* @param config The dialog configuration.
* @returns A promise resolving to a ComponentRef for the attached container.
*/
private _attachDialogContainer(overlayRef: OverlayRef, config: MdDialogConfig):
Promise<ComponentRef<MdDialogContainer>> {
let containerPortal = new ComponentPortal(MdDialogContainer, config.viewContainerRef);
return overlayRef.attach(containerPortal).then(containerRef => {
// Pass the config directly to the container so that it can consume any relevant settings.
containerRef.instance.dialogConfig = config;
return containerRef;
});
}

/**
* Attaches the user-provided component to the already-created MdDialogContainer.
* @param component The type of component being loaded into the dialog.
* @param containerRef Reference to the wrapping MdDialogContainer.
* @returns A promise resolving to the MdDialogRef that should be returned to the user.
*/
private _attachDialogContent<T>(
component: ComponentType<T>,
containerRef: ComponentRef<MdDialogContainer>): Promise<MdDialogRef<T>> {
let dialogContainer = containerRef.instance;

// Create a reference to the dialog we're creating in order to give the user a handle
// to modify and close it.
let dialogRef = new MdDialogRef();

// 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.
let dialogInjector = new DialogInjector(dialogRef, this._injector);

let contentPortal = new ComponentPortal(component, null, dialogInjector);
return dialogContainer.attachComponentPortal(contentPortal).then(contentRef => {
dialogRef.componentInstance = contentRef.instance;
return dialogRef;
});
}

/**
* Creates an overlay state from a dialog config.
* @param dialogConfig The dialog configuration.
* @returns The overlay configuration.
*/
private _getOverlayState(dialogConfig: MdDialogConfig): OverlayState {
let state = new OverlayState();

state.positionStrategy = this._overlay.position()
.global()
.centerHorizontally()
.centerVertically();

return state;
}
}
Loading

0 comments on commit 9552ed5

Please sign in to comment.