diff --git a/src/components/dialog/dialog-config.ts b/src/components/dialog/dialog-config.ts new file mode 100644 index 000000000000..fa6d74fb6e26 --- /dev/null +++ b/src/components/dialog/dialog-config.ts @@ -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. +} diff --git a/src/components/dialog/dialog-container.html b/src/components/dialog/dialog-container.html new file mode 100644 index 000000000000..4d5e533eef18 --- /dev/null +++ b/src/components/dialog/dialog-container.html @@ -0,0 +1 @@ + diff --git a/src/components/dialog/dialog-container.scss b/src/components/dialog/dialog-container.scss new file mode 100644 index 000000000000..7a91944e7e41 --- /dev/null +++ b/src/components/dialog/dialog-container.scss @@ -0,0 +1,8 @@ +@import 'elevation'; + +:host { + // TODO(jelbourn): add real Material Design dialog styles. + display: block; + background: deeppink; + @include md-elevation(2); +} diff --git a/src/components/dialog/dialog-container.ts b/src/components/dialog/dialog-container.ts new file mode 100644 index 000000000000..62c54764d2d0 --- /dev/null +++ b/src/components/dialog/dialog-container.ts @@ -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>; + + /** Portal to be attached upon AfterViewInit. */ + private _deferredAttachPortal: ComponentPortal; + + /** 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(portal: ComponentPortal): Promise> { + 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> { + throw Error('Not yet implemented'); + } +} diff --git a/src/components/dialog/dialog-errors.ts b/src/components/dialog/dialog-errors.ts new file mode 100644 index 000000000000..20a133c6d49f --- /dev/null +++ b/src/components/dialog/dialog-errors.ts @@ -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'); + } +} diff --git a/src/components/dialog/dialog-injector.ts b/src/components/dialog/dialog-injector.ts new file mode 100644 index 000000000000..76f589e66066 --- /dev/null +++ b/src/components/dialog/dialog-injector.ts @@ -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, private _parentInjector: Injector) { } + + get(token: any, notFoundValue?: any): any { + if (token === MdDialogRef) { + return this._dialogRef; + } + + return this._parentInjector.get(token, notFoundValue); + } +} diff --git a/src/components/dialog/dialog-ref.ts b/src/components/dialog/dialog-ref.ts new file mode 100644 index 000000000000..141745dd4787 --- /dev/null +++ b/src/components/dialog/dialog-ref.ts @@ -0,0 +1,9 @@ +/** + * Reference to a dialog opened via the MdDialog service. + */ +export class MdDialogRef { + /** The instance of component opened into the dialog. */ + componentInstance: T; + + // TODO(jelbourn): Add methods to resize, close, and get results from the dialog. +} diff --git a/src/components/dialog/dialog.spec.ts b/src/components/dialog/dialog.spec.ts new file mode 100644 index 000000000000..c70636049e45 --- /dev/null +++ b/src/components/dialog/dialog.spec.ts @@ -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; + + 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) { + // 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: ``, + 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: '

Pizza

', +}) +class PizzaMsg { + constructor(public dialogRef: MdDialogRef) { } +} diff --git a/src/components/dialog/dialog.ts b/src/components/dialog/dialog.ts new file mode 100644 index 000000000000..73e700496669 --- /dev/null +++ b/src/components/dialog/dialog.ts @@ -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(component: ComponentType, config: MdDialogConfig): Promise> { + 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 { + 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> { + 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( + component: ComponentType, + containerRef: ComponentRef): Promise> { + 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; + } +} diff --git a/src/core/overlay/generic-component-type.ts b/src/core/overlay/generic-component-type.ts new file mode 100644 index 000000000000..e7b31c68c4aa --- /dev/null +++ b/src/core/overlay/generic-component-type.ts @@ -0,0 +1,4 @@ + +export interface ComponentType { + new (...args: any[]): T; +} diff --git a/src/core/overlay/overlay-ref.ts b/src/core/overlay/overlay-ref.ts index 8eb91664e706..ab402e8e54f7 100644 --- a/src/core/overlay/overlay-ref.ts +++ b/src/core/overlay/overlay-ref.ts @@ -12,9 +12,15 @@ export class OverlayRef implements PortalHost { private _state: OverlayState) { } attach(portal: Portal): Promise { - return this._portalHost.attach(portal).then(() => { + let attachPromise = this._portalHost.attach(portal); + + // Don't chain the .then() call in the return because we want the result of portalHost.attach + // to be returned from this method. + attachPromise.then(() => { this._updatePosition(); }); + + return attachPromise; } detach(): Promise { diff --git a/src/core/overlay/overlay.spec.ts b/src/core/overlay/overlay.spec.ts index 6f744e4bb003..c3494570cf2b 100644 --- a/src/core/overlay/overlay.spec.ts +++ b/src/core/overlay/overlay.spec.ts @@ -23,7 +23,7 @@ import {ViewportRuler} from './position/viewport-ruler'; describe('Overlay', () => { let builder: TestComponentBuilder; let overlay: Overlay; - let componentPortal: ComponentPortal; + let componentPortal: ComponentPortal; let templatePortal: TemplatePortal; let overlayContainerElement: HTMLElement; diff --git a/src/core/overlay/overlay.ts b/src/core/overlay/overlay.ts index 3247f4bbf228..0471f5084607 100644 --- a/src/core/overlay/overlay.ts +++ b/src/core/overlay/overlay.ts @@ -82,9 +82,7 @@ export class Overlay { * @returns A portal host for the given DOM element. */ private _createPortalHost(pane: HTMLElement): DomPortalHost { - return new DomPortalHost( - pane, - this._componentResolver); + return new DomPortalHost(pane, this._componentResolver); } /** diff --git a/src/core/portal/dom-portal-host.ts b/src/core/portal/dom-portal-host.ts index 49fce9ce0bdf..2e9a3d8bd478 100644 --- a/src/core/portal/dom-portal-host.ts +++ b/src/core/portal/dom-portal-host.ts @@ -17,14 +17,16 @@ export class DomPortalHost extends BasePortalHost { } /** Attach the given ComponentPortal to DOM element using the ComponentResolver. */ - attachComponentPortal(portal: ComponentPortal): Promise> { + attachComponentPortal(portal: ComponentPortal): Promise> { if (portal.viewContainerRef == null) { throw new MdComponentPortalAttachedToDomWithoutOriginError(); } return this._componentResolver.resolveComponent(portal.component).then(componentFactory => { let ref = portal.viewContainerRef.createComponent( - componentFactory, portal.viewContainerRef.length, portal.viewContainerRef.parentInjector); + componentFactory, + portal.viewContainerRef.length, + portal.injector || portal.viewContainerRef.parentInjector); let hostView = > ref.hostView; this._hostDomElement.appendChild(hostView.rootNodes[0]); diff --git a/src/core/portal/portal-directives.ts b/src/core/portal/portal-directives.ts index 689b36468311..f5094f94f3fc 100644 --- a/src/core/portal/portal-directives.ts +++ b/src/core/portal/portal-directives.ts @@ -59,7 +59,7 @@ export class PortalHostDirective extends BasePortalHost { } /** Attach the given ComponentPortal to this PortlHost using the ComponentResolver. */ - attachComponentPortal(portal: ComponentPortal): Promise> { + attachComponentPortal(portal: ComponentPortal): Promise> { portal.setAttachedHost(this); // If the portal specifies an origin, use that as the logical location of the component @@ -70,7 +70,8 @@ export class PortalHostDirective extends BasePortalHost { return this._componentResolver.resolveComponent(portal.component).then(componentFactory => { let ref = viewContainerRef.createComponent( - componentFactory, viewContainerRef.length, viewContainerRef.parentInjector); + componentFactory, viewContainerRef.length, + portal.injector || viewContainerRef.parentInjector); this.setDisposeFn(() => ref.destroy()); return ref; @@ -93,7 +94,7 @@ export class PortalHostDirective extends BasePortalHost { let maybeDetach = this.hasAttached() ? this.detach() : Promise.resolve(null); maybeDetach.then(() => { - if (p != null) { + if (p) { this.attach(p); this._portal = p; } diff --git a/src/core/portal/portal.spec.ts b/src/core/portal/portal.spec.ts index 839e79e901ae..9a8c0fae012b 100644 --- a/src/core/portal/portal.spec.ts +++ b/src/core/portal/portal.spec.ts @@ -9,7 +9,9 @@ import { ViewChildren, QueryList, ViewContainerRef, - ComponentResolver + ComponentResolver, + Optional, + Injector, } from '@angular/core'; import {TemplatePortalDirective, PortalHostDirective} from './portal-directives'; import {Portal, ComponentPortal} from './portal'; @@ -47,6 +49,34 @@ describe('Portals', () => { expect(hostContainer.textContent).toContain('Pizza'); })); + it('should load a component into the portal with a given injector', fakeAsync(() => { + let appFixture: ComponentFixture; + + builder.createAsync(PortalTestApp).then(fixture => { + appFixture = fixture; + }); + + // Flush the async creation of the PortalTestApp. + flushMicrotasks(); + + // Create a custom injector for the component. + let chocolateInjector = new ChocolateInjector(appFixture.componentInstance.injector); + + // Set the selectedHost to be a ComponentPortal. + let testAppComponent = appFixture.debugElement.componentInstance; + testAppComponent.selectedPortal = new ComponentPortal(PizzaMsg, null, chocolateInjector); + appFixture.detectChanges(); + + // Flush the attachment of the Portal. + flushMicrotasks(); + appFixture.detectChanges(); + + // Expect that the content of the attached portal is present. + let hostContainer = appFixture.nativeElement.querySelector('.portal-container'); + expect(hostContainer.textContent).toContain('Pizza'); + expect(hostContainer.textContent).toContain('Chocolate'); + })); + it('should load a