From e8574603039510cce70563cd65a72cc6a65d6355 Mon Sep 17 00:00:00 2001 From: crisbeto Date: Tue, 21 Feb 2017 19:46:22 +0100 Subject: [PATCH] feat(dialog): add enter/exit animations * Adds enter/exit animations to the dialog. * Refactors the `MdDialogContainer` and `MdDialogRef` to accommodate the animations. * Fixes some test failures due to the animations. * Allows for the backdrop to be detached before the rest of the overlay, in order to allow for it to be transitioned in parallel. Fixes #2665. --- src/lib/core/overlay/overlay-ref.ts | 6 +- src/lib/dialog/dialog-container.ts | 72 +++++++++--- src/lib/dialog/dialog-ref.ts | 24 +++- src/lib/dialog/dialog.spec.ts | 171 ++++++++++++++++++---------- src/lib/dialog/dialog.ts | 14 +-- 5 files changed, 200 insertions(+), 87 deletions(-) diff --git a/src/lib/core/overlay/overlay-ref.ts b/src/lib/core/overlay/overlay-ref.ts index f614ba7b3bcb..0a8e42d2ffda 100644 --- a/src/lib/core/overlay/overlay-ref.ts +++ b/src/lib/core/overlay/overlay-ref.ts @@ -52,7 +52,7 @@ export class OverlayRef implements PortalHost { * @returns Resolves when the overlay has been detached. */ detach(): Promise { - this._detachBackdrop(); + this.detachBackdrop(); // When the overlay is detached, the pane element should disable pointer events. // This is necessary because otherwise the pane element will cover the page and disable @@ -70,7 +70,7 @@ export class OverlayRef implements PortalHost { this._state.positionStrategy.dispose(); } - this._detachBackdrop(); + this.detachBackdrop(); this._portalHost.dispose(); } @@ -154,7 +154,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 c9948e53b7b4..bb54a4d79ac7 100644 --- a/src/lib/dialog/dialog-container.ts +++ b/src/lib/dialog/dialog-container.ts @@ -5,18 +5,28 @@ import { ViewEncapsulation, NgZone, OnDestroy, - Renderer, + 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({ @@ -24,11 +34,21 @@ 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.mat-dialog-container]': 'true', '[attr.role]': 'dialogConfig?.role', + '[@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. */ @@ -38,15 +58,18 @@ export class MdDialogContainer extends BasePortalHost implements OnDestroy { @ViewChild(FocusTrap) _focusTrap: FocusTrap; /** Element that was focused before the dialog was opened. Save this to restore upon close. */ - private _elementFocusedBeforeDialogWasOpened: Element = null; + private _elementFocusedBeforeDialogWasOpened: HTMLElement = null; /** 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, private _renderer: Renderer) { + constructor(private _ngZone: NgZone) { super(); } @@ -87,20 +110,43 @@ export class MdDialogContainer extends BasePortalHost implements OnDestroy { // ready in instances where change detection has to run first. To deal with this, we simply // wait for the microtask queue to be empty. this._ngZone.onMicrotaskEmpty.first().subscribe(() => { - this._elementFocusedBeforeDialogWasOpened = document.activeElement; + this._elementFocusedBeforeDialogWasOpened = document.activeElement as HTMLElement; this._focusTrap.focusFirstTabbableElement(); }); } + /** + * 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); + } + 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 . Also note that we need the extra check, because // IE can set the `activeElement` to null in some cases. - if (this._elementFocusedBeforeDialogWasOpened) { - this._ngZone.onMicrotaskEmpty.first().subscribe(() => { - this._renderer.invokeElementMethod(this._elementFocusedBeforeDialogWasOpened, 'focus'); - }); - } + this._ngZone.onMicrotaskEmpty.first().subscribe(() => { + let toFocus = this._elementFocusedBeforeDialogWasOpened as HTMLElement; + + // We need to check whether the focus method exists at all, because IE seems to throw an + // exception, even if the element is the document.body. + if (toFocus && 'focus' in toFocus) { + toFocus.focus(); + } + + this._onAnimationStateChange.complete(); + }); } } diff --git a/src/lib/dialog/dialog-ref.ts b/src/lib/dialog/dialog-ref.ts index bbbd31664792..fa70a0b78741 100644 --- a/src/lib/dialog/dialog-ref.ts +++ b/src/lib/dialog/dialog-ref.ts @@ -1,7 +1,7 @@ import {OverlayRef} from '../core'; -import {MdDialogConfig} from './dialog-config'; import {Observable} from 'rxjs/Observable'; import {Subject} from 'rxjs/Subject'; +import {MdDialogContainer, MdDialogContainerAnimationState} from './dialog-container'; // TODO(jelbourn): resizing @@ -18,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, public config: MdDialogConfig) { } + /** Result to be passed to afterClosed. */ + private _result: any; + + constructor(private _overlayRef: OverlayRef, public _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 dbb02ac81aed..48abaa1f438c 100644 --- a/src/lib/dialog/dialog.spec.ts +++ b/src/lib/dialog/dialog.spec.ts @@ -15,12 +15,13 @@ import {NgModule, Injector, Inject, } from '@angular/core'; +import {By} from '@angular/platform-browser'; import {MdDialogModule} from './index'; import {MdDialog} from './dialog'; -import {OverlayContainer} from '../core'; +import {MdDialogContainer} from './dialog-container'; +import {OverlayContainer, ESCAPE} from '../core'; import {MdDialogRef} from './dialog-ref'; import {MD_DIALOG_DATA} from './dialog-injector'; -import {ESCAPE} from '../core/keyboard/keycodes'; describe('MdDialog', () => { @@ -109,38 +110,35 @@ 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 }); - viewContainerFixture.detectChanges(); - dispatchKeydownEvent(document, ESCAPE); + 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 }); @@ -148,10 +146,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 notify the observers if a dialog has been opened', () => { let ref: MdDialogRef; @@ -163,7 +165,7 @@ describe('MdDialog', () => { })).toBe(ref); }); - it('should notify the observers if all open dialogs have finished closing', () => { + it('should notify the observers if all open dialogs have finished closing', async(() => { const ref1 = dialog.open(PizzaMsg, { viewContainerRef: testViewContainerRef }); @@ -177,10 +179,19 @@ describe('MdDialog', () => { }); ref1.close(); - expect(allClosed).toBeFalsy(); - ref2.close(); - expect(allClosed).toBeTruthy(); - }); + viewContainerFixture.detectChanges(); + + viewContainerFixture.whenStable().then(() => { + expect(allClosed).toBeFalsy(); + + ref2.close(); + viewContainerFixture.detectChanges(); + + viewContainerFixture.whenStable().then(() => { + expect(allClosed).toBeTruthy(); + }); + }); + })); it('should should override the width of the overlay pane', () => { dialog.open(PizzaMsg, { @@ -262,7 +273,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); @@ -270,10 +281,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(dialogContainer._state).toBe('enter'); + + dialogRef.close(); - expect(overlayContainerElement.querySelectorAll('md-dialog-container').length).toBe(0); + 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('passing in data', () => { it('should be able to pass in data', () => { let config = { @@ -318,7 +366,6 @@ describe('MdDialog', () => { }); viewContainerFixture.detectChanges(); - dispatchKeydownEvent(document, ESCAPE); expect(overlayContainerElement.querySelector('md-dialog-container')).toBeTruthy(); @@ -385,13 +432,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('.mat-dialog-container').length).toBe(1); (overlayContainerElement.querySelector('button[md-dialog-close]') as HTMLElement).click(); - expect(overlayContainerElement.querySelectorAll('.mat-dialog-container').length).toBe(0); - }); + viewContainerFixture.whenStable().then(() => { + expect(overlayContainerElement.querySelectorAll('.mat-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('.mat-dialog-container').length).toBe(1); @@ -401,14 +450,16 @@ describe('MdDialog', () => { expect(overlayContainerElement.querySelectorAll('.mat-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]'); @@ -452,33 +503,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 90cba743464a..3cc1c57ebe67 100644 --- a/src/lib/dialog/dialog.ts +++ b/src/lib/dialog/dialog.ts @@ -9,10 +9,7 @@ import {MdDialogConfig} from './dialog-config'; import {MdDialogRef} from './dialog-ref'; import {MdDialogContainer} from './dialog-container'; import {TemplatePortal} from '../core/portal/portal'; - - -// TODO(jelbourn): animations - +import 'rxjs/add/operator/first'; /** @@ -135,16 +132,13 @@ 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, config); + let dialogRef = new MdDialogRef(overlayRef, dialogContainer) as MdDialogRef; if (!config.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. @@ -217,7 +211,9 @@ export class MdDialog { private _handleKeydown(event: KeyboardEvent): void { let topDialog = this._openDialogs[this._openDialogs.length - 1]; - if (event.keyCode === ESCAPE && topDialog && !topDialog.config.disableClose) { + if (event.keyCode === ESCAPE && topDialog && + !topDialog._containerInstance.dialogConfig.disableClose) { + topDialog.close(); } }