-
Notifications
You must be signed in to change notification settings - Fork 6.8k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(dialog): initial framework for md-dialog (#761)
- Loading branch information
Showing
24 changed files
with
556 additions
and
21 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
<template portalHost></template> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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>) { } | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
Oops, something went wrong.