-
Notifications
You must be signed in to change notification settings - Fork 6.7k
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
Changes from all commits
c5229b8
848eee6
aadccc9
e92813f
9efbcbc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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. | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
<template portalHost></template> | ||
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); | ||
} |
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 => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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'); | ||
} | ||
} |
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'); | ||
} | ||
} |
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 { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not a blocker, but having an |
||
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); | ||
} | ||
} |
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. | ||
} |
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>) { } | ||
} |
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 { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is in There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As discussed, treating this as "UI components" more than |
||
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; | ||
} | ||
} |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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