feat(dialog): add enter/exit animations (#2825)

* 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.
This commit is contained in:
Kristiyan Kostadinov
2017-02-23 00:13:12 +01:00
committed by mmalerba
parent e3b2486c4b
commit 549222534c
5 changed files with 201 additions and 88 deletions
+3 -3
View File
@@ -52,7 +52,7 @@ export class OverlayRef implements PortalHost {
* @returns Resolves when the overlay has been detached.
*/
detach(): Promise<any> {
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) {
+59 -13
View File
@@ -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<any>;
/** State of the dialog animation. */
_state: MdDialogContainerAnimationState = 'enter';
constructor(private _ngZone: NgZone, private _renderer: Renderer) {
/** Emits the current animation state whenever it changes. */
_onAnimationStateChange = new EventEmitter<MdDialogContainerAnimationState>();
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 <body>. 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();
});
}
}
+19 -5
View File
@@ -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<T> {
/** Subject for notifying the user that the dialog has finished closing. */
private _afterClosed: Subject<any> = 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();
}
/**
+115 -58
View File
@@ -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
});
dispatchKeydownEvent(document, ESCAPE);
viewContainerFixture.detectChanges();
dispatchKeydownEvent(document, ESCAPE);
viewContainerFixture.whenStable().then(() => {
expect(overlayContainerElement.querySelector('md-dialog-container')).toBeNull();
});
}));
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();
expect(overlayContainerElement.querySelector('md-dialog-container')).toBeFalsy();
});
backdrop.click();
viewContainerFixture.detectChanges();
viewContainerFixture.whenStable().then(() => {
expect(overlayContainerElement.querySelector('md-dialog-container')).toBeFalsy();
});
}));
it('should notify the observers if a dialog has been opened', () => {
let ref: MdDialogRef<PizzaMsg>;
@@ -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();
expect(overlayContainerElement.querySelectorAll('md-dialog-container').length).toBe(0);
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(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');
});
}));
});
+5 -9
View File
@@ -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<T> {
// Create a reference to the dialog we're creating in order to give the user a handle
// to modify and close it.
let dialogRef = <MdDialogRef<T>> new MdDialogRef(overlayRef, config);
let dialogRef = new MdDialogRef(overlayRef, dialogContainer) as MdDialogRef<T>;
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();
}
}