diff --git a/src/lib/core/overlay/overlay-ref.ts b/src/lib/core/overlay/overlay-ref.ts index 5feff92c7923..88d15c2119f5 100644 --- a/src/lib/core/overlay/overlay-ref.ts +++ b/src/lib/core/overlay/overlay-ref.ts @@ -47,7 +47,7 @@ export class OverlayRef implements PortalHost { * @returns Resolves when the overlay has been detached. */ detach(): Promise { - this._detachBackdrop(); + this.detachBackdrop(); return this._portalHost.detach(); } @@ -59,7 +59,7 @@ export class OverlayRef implements PortalHost { this._state.positionStrategy.dispose(); } - this._detachBackdrop(); + this.detachBackdrop(); this._portalHost.dispose(); } @@ -138,7 +138,7 @@ export class OverlayRef implements PortalHost { } /** Detaches the backdrop (if any) associated with the overlay. */ - private _detachBackdrop(): void { + detachBackdrop(): void { let backdropToDetach = this._backdropElement; if (backdropToDetach) { diff --git a/src/lib/dialog/dialog-container.ts b/src/lib/dialog/dialog-container.ts index 6004809e8e05..225f7c6ed572 100644 --- a/src/lib/dialog/dialog-container.ts +++ b/src/lib/dialog/dialog-container.ts @@ -5,17 +5,28 @@ import { ViewEncapsulation, NgZone, OnDestroy, + animate, + state, + style, + transition, + trigger, + AnimationTransitionEvent, + EventEmitter, } from '@angular/core'; import {BasePortalHost, ComponentPortal, PortalHostDirective, TemplatePortal} from '../core'; import {MdDialogConfig} from './dialog-config'; -import {MdDialogRef} from './dialog-ref'; import {MdDialogContentAlreadyAttachedError} from './dialog-errors'; import {FocusTrap} from '../core/a11y/focus-trap'; import 'rxjs/add/operator/first'; +/** Possible states for the dialog container animation. */ +export type MdDialogContainerAnimationState = 'void' | 'enter' | 'exit' | 'exit-start'; + + /** * Internal component that wraps user-provided dialog content. + * Animation is based on https://material.io/guidelines/motion/choreography.html. * @docs-private */ @Component({ @@ -23,12 +34,22 @@ import 'rxjs/add/operator/first'; selector: 'md-dialog-container, mat-dialog-container', templateUrl: 'dialog-container.html', styleUrls: ['dialog.css'], + encapsulation: ViewEncapsulation.None, + animations: [ + trigger('slideDialog', [ + state('void', style({ transform: 'translateY(25%) scale(0.9)', opacity: 0 })), + state('enter', style({ transform: 'translateY(0%) scale(1)', opacity: 1 })), + state('exit', style({ transform: 'translateY(25%)', opacity: 0 })), + transition('* => *', animate('400ms cubic-bezier(0.25, 0.8, 0.25, 1)')), + ]) + ], host: { 'class': 'md-dialog-container', '[attr.role]': 'dialogConfig?.role', - '(keydown.escape)': 'handleEscapeKey()', + '(keydown.escape)': '_handleEscapeKey()', + '[@slideDialog]': '_state', + '(@slideDialog.done)': '_onAnimationDone($event)', }, - encapsulation: ViewEncapsulation.None, }) export class MdDialogContainer extends BasePortalHost implements OnDestroy { /** The portal host inside of this container into which the dialog content will be loaded. */ @@ -43,8 +64,11 @@ export class MdDialogContainer extends BasePortalHost implements OnDestroy { /** The dialog configuration. */ dialogConfig: MdDialogConfig; - /** Reference to the open dialog. */ - dialogRef: MdDialogRef; + /** State of the dialog animation. */ + _state: MdDialogContainerAnimationState = 'enter'; + + /** Emits the current animation state whenever it changes. */ + _onAnimationStateChange = new EventEmitter(); constructor(private _ngZone: NgZone) { super(); @@ -77,22 +101,40 @@ export class MdDialogContainer extends BasePortalHost implements OnDestroy { throw Error('Not yet implemented'); } + ngOnDestroy() { + // When the dialog is destroyed, return focus to the element that originally had it before + // the dialog was opened. Wait for the DOM to finish settling before changing the focus so + // that it doesn't end up back on the . + this._ngZone.onMicrotaskEmpty.first().subscribe(() => { + (this._elementFocusedBeforeDialogWasOpened as HTMLElement).focus(); + this._onAnimationStateChange.complete(); + }); + } + /** * Handles the user pressing the Escape key. * @docs-private */ - handleEscapeKey() { + _handleEscapeKey() { if (!this.dialogConfig.disableClose) { - this.dialogRef.close(); + this._exit(); } } - ngOnDestroy() { - // When the dialog is destroyed, return focus to the element that originally had it before - // the dialog was opened. Wait for the DOM to finish settling before changing the focus so - // that it doesn't end up back on the . - this._ngZone.onMicrotaskEmpty.first().subscribe(() => { - (this._elementFocusedBeforeDialogWasOpened as HTMLElement).focus(); - }); + /** + * Kicks off the leave animation. + * @docs-private + */ + _exit(): void { + this._state = 'exit'; + this._onAnimationStateChange.emit('exit-start'); + } + + /** + * Callback, invoked whenever an animation on the host completes. + * @docs-private + */ + _onAnimationDone(event: AnimationTransitionEvent) { + this._onAnimationStateChange.emit(event.toState as MdDialogContainerAnimationState); } } diff --git a/src/lib/dialog/dialog-ref.ts b/src/lib/dialog/dialog-ref.ts index bd9a45df84ac..5fa563468a86 100644 --- a/src/lib/dialog/dialog-ref.ts +++ b/src/lib/dialog/dialog-ref.ts @@ -1,6 +1,7 @@ import {OverlayRef} from '../core'; import {Observable} from 'rxjs/Observable'; import {Subject} from 'rxjs/Subject'; +import {MdDialogContainer, MdDialogContainerAnimationState} from './dialog-container'; // TODO(jelbourn): resizing @@ -17,16 +18,30 @@ export class MdDialogRef { /** Subject for notifying the user that the dialog has finished closing. */ private _afterClosed: Subject = new Subject(); - constructor(private _overlayRef: OverlayRef) { } + /** Result to be passed to afterClosed. */ + private _result: any; + + constructor(private _overlayRef: OverlayRef, private _containerInstance: MdDialogContainer) { + _containerInstance._onAnimationStateChange.subscribe( + (state: MdDialogContainerAnimationState) => { + if (state === 'exit-start') { + // Transition the backdrop in parallel with the dialog. + this._overlayRef.detachBackdrop(); + } else if (state === 'exit') { + this._overlayRef.dispose(); + this._afterClosed.next(this._result); + this._afterClosed.complete(); + } + }); + } /** * Close the dialog. * @param dialogResult Optional result to return to the dialog opener. */ close(dialogResult?: any): void { - this._overlayRef.dispose(); - this._afterClosed.next(dialogResult); - this._afterClosed.complete(); + this._result = dialogResult; + this._containerInstance._exit(); } /** diff --git a/src/lib/dialog/dialog.spec.ts b/src/lib/dialog/dialog.spec.ts index 5cc05dd3d70b..f3e4ec27e08b 100644 --- a/src/lib/dialog/dialog.spec.ts +++ b/src/lib/dialog/dialog.spec.ts @@ -102,26 +102,22 @@ describe('MdDialog', () => { expect(dialogContainerElement.getAttribute('role')).toBe('alertdialog'); }); - it('should close a dialog and get back a result', () => { - let dialogRef = dialog.open(PizzaMsg, { - viewContainerRef: testViewContainerRef - }); + it('should close a dialog and get back a result', async(() => { + let dialogRef = dialog.open(PizzaMsg, { viewContainerRef: testViewContainerRef }); + let afterCloseCallback = jasmine.createSpy('afterClose callback'); + dialogRef.afterClosed().subscribe(afterCloseCallback); + dialogRef.close('Charmander'); viewContainerFixture.detectChanges(); - let afterCloseResult: string; - dialogRef.afterClosed().subscribe(result => { - afterCloseResult = result; + viewContainerFixture.whenStable().then(() => { + expect(afterCloseCallback).toHaveBeenCalledWith('Charmander'); + expect(overlayContainerElement.querySelector('md-dialog-container')).toBeNull(); }); - - dialogRef.close('Charmander'); - - expect(afterCloseResult).toBe('Charmander'); - expect(overlayContainerElement.querySelector('md-dialog-container')).toBeNull(); - }); + })); - it('should close a dialog via the escape key', () => { + it('should close a dialog via the escape key', async(() => { dialog.open(PizzaMsg, { viewContainerRef: testViewContainerRef }); @@ -132,12 +128,15 @@ describe('MdDialog', () => { viewContainerFixture.debugElement.query(By.directive(MdDialogContainer)).componentInstance; // Fake the user pressing the escape key by calling the handler directly. - dialogContainer.handleEscapeKey(); + dialogContainer._handleEscapeKey(); + viewContainerFixture.detectChanges(); - expect(overlayContainerElement.querySelector('md-dialog-container')).toBeNull(); - }); + viewContainerFixture.whenStable().then(() => { + expect(overlayContainerElement.querySelector('md-dialog-container')).toBeNull(); + }); + })); - it('should close when clicking on the overlay backdrop', () => { + it('should close when clicking on the overlay backdrop', async(() => { dialog.open(PizzaMsg, { viewContainerRef: testViewContainerRef }); @@ -145,10 +144,14 @@ describe('MdDialog', () => { viewContainerFixture.detectChanges(); let backdrop = overlayContainerElement.querySelector('.cdk-overlay-backdrop') as HTMLElement; + backdrop.click(); + viewContainerFixture.detectChanges(); - expect(overlayContainerElement.querySelector('md-dialog-container')).toBeFalsy(); - }); + viewContainerFixture.whenStable().then(() => { + expect(overlayContainerElement.querySelector('md-dialog-container')).toBeFalsy(); + }); + })); it('should should override the width of the overlay pane', () => { dialog.open(PizzaMsg, { @@ -230,7 +233,7 @@ describe('MdDialog', () => { expect(overlayPane.style.marginRight).toBe('125px'); }); - it('should close all of the dialogs', () => { + it('should close all of the dialogs', async(() => { dialog.open(PizzaMsg); dialog.open(PizzaMsg); dialog.open(PizzaMsg); @@ -238,10 +241,47 @@ describe('MdDialog', () => { expect(overlayContainerElement.querySelectorAll('md-dialog-container').length).toBe(3); dialog.closeAll(); + viewContainerFixture.detectChanges(); + + viewContainerFixture.whenStable().then(() => { + expect(overlayContainerElement.querySelectorAll('md-dialog-container').length).toBe(0); + }); + })); + + it('should set the proper animation states', () => { + let dialogRef = dialog.open(PizzaMsg, { viewContainerRef: testViewContainerRef }); + let dialogContainer: MdDialogContainer = + viewContainerFixture.debugElement.query(By.directive(MdDialogContainer)).componentInstance; - expect(overlayContainerElement.querySelectorAll('md-dialog-container').length).toBe(0); + expect(dialogContainer._state).toBe('enter'); + + dialogRef.close(); + + expect(dialogContainer._state).toBe('exit'); }); + it('should emit an event with the proper animation state', async(() => { + let dialogRef = dialog.open(PizzaMsg, { viewContainerRef: testViewContainerRef }); + let dialogContainer: MdDialogContainer = + viewContainerFixture.debugElement.query(By.directive(MdDialogContainer)).componentInstance; + let spy = jasmine.createSpy('animation state callback'); + + dialogContainer._onAnimationStateChange.subscribe(spy); + viewContainerFixture.detectChanges(); + + viewContainerFixture.whenStable().then(() => { + expect(spy).toHaveBeenCalledWith('enter'); + + dialogRef.close(); + viewContainerFixture.detectChanges(); + expect(spy).toHaveBeenCalledWith('exit-start'); + + viewContainerFixture.whenStable().then(() => { + expect(spy).toHaveBeenCalledWith('exit'); + }); + }); + })); + describe('disableClose option', () => { it('should prevent closing via clicks on the backdrop', () => { dialog.open(PizzaMsg, { @@ -269,7 +309,7 @@ describe('MdDialog', () => { By.directive(MdDialogContainer)).componentInstance; // Fake the user pressing the escape key by calling the handler directly. - dialogContainer.handleEscapeKey(); + dialogContainer._handleEscapeKey(); expect(overlayContainerElement.querySelector('md-dialog-container')).toBeTruthy(); }); @@ -333,13 +373,15 @@ describe('MdDialog', () => { viewContainerFixture.detectChanges(); }); - it('should close the dialog when clicking on the close button', () => { + it('should close the dialog when clicking on the close button', async(() => { expect(overlayContainerElement.querySelectorAll('.md-dialog-container').length).toBe(1); (overlayContainerElement.querySelector('button[md-dialog-close]') as HTMLElement).click(); - expect(overlayContainerElement.querySelectorAll('.md-dialog-container').length).toBe(0); - }); + viewContainerFixture.whenStable().then(() => { + expect(overlayContainerElement.querySelectorAll('.md-dialog-container').length).toBe(0); + }); + })); it('should not close the dialog if [md-dialog-close] is applied on a non-button node', () => { expect(overlayContainerElement.querySelectorAll('.md-dialog-container').length).toBe(1); @@ -349,14 +391,16 @@ describe('MdDialog', () => { expect(overlayContainerElement.querySelectorAll('.md-dialog-container').length).toBe(1); }); - it('should allow for a user-specified aria-label on the close button', () => { + it('should allow for a user-specified aria-label on the close button', async(() => { let button = overlayContainerElement.querySelector('button[md-dialog-close]'); dialogRef.componentInstance.closeButtonAriaLabel = 'Best close button ever'; viewContainerFixture.detectChanges(); - expect(button.getAttribute('aria-label')).toBe('Best close button ever'); - }); + viewContainerFixture.whenStable().then(() => { + expect(button.getAttribute('aria-label')).toBe('Best close button ever'); + }); + })); it('should override the "type" attribute of the close button', () => { let button = overlayContainerElement.querySelector('button[md-dialog-close]'); @@ -400,33 +444,39 @@ describe('MdDialog with a parent MdDialog', () => { overlayContainerElement.innerHTML = ''; }); - it('should close dialogs opened by a parent when calling closeAll on a child MdDialog', () => { - parentDialog.open(PizzaMsg); - fixture.detectChanges(); + it('should close dialogs opened by a parent when calling closeAll on a child MdDialog', + async(() => { + parentDialog.open(PizzaMsg); + fixture.detectChanges(); - expect(overlayContainerElement.textContent) - .toContain('Pizza', 'Expected a dialog to be opened'); + expect(overlayContainerElement.textContent) + .toContain('Pizza', 'Expected a dialog to be opened'); - childDialog.closeAll(); - fixture.detectChanges(); + childDialog.closeAll(); + fixture.detectChanges(); - expect(overlayContainerElement.textContent.trim()) - .toBe('', 'Expected closeAll on child MdDialog to close dialog opened by parent'); - }); + fixture.whenStable().then(() => { + expect(overlayContainerElement.textContent.trim()) + .toBe('', 'Expected closeAll on child MdDialog to close dialog opened by parent'); + }); + })); - it('should close dialogs opened by a child when calling closeAll on a parent MdDialog', () => { - childDialog.open(PizzaMsg); - fixture.detectChanges(); + it('should close dialogs opened by a child when calling closeAll on a parent MdDialog', + async(() => { + childDialog.open(PizzaMsg); + fixture.detectChanges(); - expect(overlayContainerElement.textContent) - .toContain('Pizza', 'Expected a dialog to be opened'); + expect(overlayContainerElement.textContent) + .toContain('Pizza', 'Expected a dialog to be opened'); - parentDialog.closeAll(); - fixture.detectChanges(); + parentDialog.closeAll(); + fixture.detectChanges(); - expect(overlayContainerElement.textContent.trim()) - .toBe('', 'Expected closeAll on parent MdDialog to close dialog opened by child'); - }); + fixture.whenStable().then(() => { + expect(overlayContainerElement.textContent.trim()) + .toBe('', 'Expected closeAll on parent MdDialog to close dialog opened by child'); + }); + })); }); diff --git a/src/lib/dialog/dialog.ts b/src/lib/dialog/dialog.ts index 17f61bcf7064..be0c71dececa 100644 --- a/src/lib/dialog/dialog.ts +++ b/src/lib/dialog/dialog.ts @@ -7,11 +7,10 @@ import {DialogInjector} from './dialog-injector'; import {MdDialogConfig} from './dialog-config'; import {MdDialogRef} from './dialog-ref'; import {MdDialogContainer} from './dialog-container'; +import 'rxjs/add/operator/first'; // TODO(jelbourn): add support for opening with a TemplateRef -// TODO(jelbourn): animations - /** @@ -106,25 +105,21 @@ export class MdDialog { config?: MdDialogConfig): MdDialogRef { // 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(overlayRef); + let dialogRef = new MdDialogRef(overlayRef, dialogContainer) as MdDialogRef; if (!dialogContainer.dialogConfig.disableClose) { // When the dialog backdrop is clicked, we want to close it. overlayRef.backdropClick().first().subscribe(() => dialogRef.close()); } - // Set the dialogRef to the container so that it can use the ref to close the dialog. - dialogContainer.dialogRef = dialogRef; - // 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 userInjector = config && config.viewContainerRef && config.viewContainerRef.injector; let dialogInjector = new DialogInjector(dialogRef, userInjector || this._injector); - let contentPortal = new ComponentPortal(component, null, dialogInjector); - let contentRef = dialogContainer.attachComponentPortal(contentPortal); + dialogRef.componentInstance = contentRef.instance; return dialogRef;