Skip to content
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): add enter/exit animations #2825

Merged
merged 1 commit into from
Feb 22, 2017
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/lib/core/overlay/overlay-ref.ts
Original file line number Diff line number Diff line change
@@ -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) {
72 changes: 59 additions & 13 deletions src/lib/dialog/dialog-container.ts
Original file line number Diff line number Diff line change
@@ -5,30 +5,50 @@ 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({
moduleId: module.id,
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';

/** Emits the current animation state whenever it changes. */
_onAnimationStateChange = new EventEmitter<MdDialogContainerAnimationState>();

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 <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();
});
}
}
24 changes: 19 additions & 5 deletions src/lib/dialog/dialog-ref.ts
Original file line number Diff line number Diff line change
@@ -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();
}

/**
171 changes: 114 additions & 57 deletions src/lib/dialog/dialog.spec.ts
Original file line number Diff line number Diff line change
@@ -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,49 +110,50 @@ 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
});

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<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,18 +273,55 @@ 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);

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');
});
}));
});


14 changes: 5 additions & 9 deletions src/lib/dialog/dialog.ts
Original file line number Diff line number Diff line change
@@ -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();
}
}