Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(dialog): inital framework for md-dialog #761

Merged
merged 5 commits into from
Jul 14, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 liner should probably be inlined.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There will be more; this is just the first PR

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 => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

catch and reject?

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;
Copy link
Contributor

@hansl hansl Jul 7, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add an assertion here that there's no portal already, so we can find a bug if the assertion fails.

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 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a blocker, but having an Injector with a set(token, notFoundValue) method would be a nice addition to angular/core.

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 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is in components/... but is not a component. As a general discussion should we have a naming convention for services that aren't in core? Is there going to be an MdDialog that's actually a component?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As discussed, treating this as "UI components" more than @Componentss

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