feat(snack bar): Add enter and exit animations. (#1320)

* feat(md-snack-bar): Create initial MdSnackBar.

* Add enter and exit animation to MdSnackBar.

* feat(md-snack-bar): Create initial MdSnackBar.

* feat(md-snack-bar): Create initial MdSnackBar.

* Add enter and exit animation to MdSnackBar.
This commit is contained in:
Joey Perrott
2016-10-18 13:31:56 -07:00
committed by Jeremy Elbourn
parent 3c1e8d2d28
commit 6df29dcd5b
7 changed files with 157 additions and 27 deletions
+13
View File
@@ -0,0 +1,13 @@
export class AnimationCurves {
static STANDARD_CURVE = 'cubic-bezier(0.4,0.0,0.2,1)';
static DECELERATION_CURVE = 'cubic-bezier(0.0,0.0,0.2,1)';
static ACCELERATION_CURVE = 'cubic-bezier(0.4,0.0,1,1)';
static SHARP_CURVE = 'cubic-bezier(0.4,0.0,0.6,1)';
};
export class AnimationDurations {
static COMPLEX = '375ms';
static ENTERING = '225ms';
static EXITING = '195ms';
};
+3
View File
@@ -79,6 +79,9 @@ export {ComponentType} from './overlay/generic-component-type';
// Keybindings
export * from './keyboard/keycodes';
// Animation
export * from './animation/animation';
@NgModule({
imports: [MdLineModule, RtlModule, MdRippleModule, PortalModule, OverlayModule, A11yModule],
+3 -1
View File
@@ -10,11 +10,13 @@ $md-snack-bar-max-width: 568px !default;
@include md-elevation(24);
background: #323232;
border-radius: 2px;
box-sizing: content-box;
display: block;
height: $md-snack-bar-height;
max-width: $md-snack-bar-max-width;
min-width: $md-snack-bar-min-width;
overflow: hidden;
padding: $md-snack-bar-padding;
box-sizing: content-box;
// Initial transformation is applied to start snack bar out of view.
transform: translateY(100%);
}
+65 -5
View File
@@ -1,18 +1,32 @@
import {
Component,
ComponentRef,
ViewChild
ViewChild,
trigger,
state,
style,
transition,
animate,
AnimationTransitionEvent,
NgZone
} from '@angular/core';
import {
BasePortalHost,
ComponentPortal,
TemplatePortal,
PortalHostDirective
PortalHostDirective,
AnimationCurves,
AnimationDurations,
} from '../core';
import {MdSnackBarConfig} from './snack-bar-config';
import {MdSnackBarContentAlreadyAttached} from './snack-bar-errors';
import {Observable} from 'rxjs/Observable';
import {Subject} from 'rxjs/Subject';
export type SnackBarState = 'initial' | 'visible' | 'complete' | 'void';
/**
* Internal component that wraps user-provided snack bar content.
*/
@@ -22,17 +36,40 @@ import {MdSnackBarContentAlreadyAttached} from './snack-bar-errors';
templateUrl: 'snack-bar-container.html',
styleUrls: ['snack-bar-container.css'],
host: {
'role': 'alert'
}
'role': 'alert',
'[@state]': 'animationState',
'(@state.done)': 'markAsExited($event)'
},
animations: [
trigger('state', [
state('initial', style({transform: 'translateY(100%)'})),
state('visible', style({transform: 'translateY(0%)'})),
state('complete', style({transform: 'translateY(100%)'})),
transition('visible => complete',
animate(`${AnimationDurations.EXITING} ${AnimationCurves.DECELERATION_CURVE}`)),
transition('initial => visible, void => visible',
animate(`${AnimationDurations.ENTERING} ${AnimationCurves.ACCELERATION_CURVE}`)),
])
],
})
export class MdSnackBarContainer extends BasePortalHost {
/** The portal host inside of this container into which the snack bar content will be loaded. */
@ViewChild(PortalHostDirective) _portalHost: PortalHostDirective;
/** Subject for notifying that the snack bar has exited from view. */
private _onExit: Subject<any> = new Subject();
/** The state of the snack bar animations. */
animationState: SnackBarState = 'initial';
/** The snack bar configuration. */
snackBarConfig: MdSnackBarConfig;
/** Attach a portal as content to this snack bar container. */
constructor(private _ngZone: NgZone) {
super();
}
/** Attach a component portal as content to this snack bar container. */
attachComponentPortal<T>(portal: ComponentPortal<T>): ComponentRef<T> {
if (this._portalHost.hasAttached()) {
throw new MdSnackBarContentAlreadyAttached();
@@ -41,7 +78,30 @@ export class MdSnackBarContainer extends BasePortalHost {
return this._portalHost.attachComponentPortal(portal);
}
/** Attach a template portal as content to this snack bar container. */
attachTemplatePortal(portal: TemplatePortal): Map<string, any> {
throw Error('Not yet implemented');
}
/** Begin animation of the snack bar exiting from view. */
exit(): Observable<void> {
this.animationState = 'complete';
return this._onExit.asObservable();
}
/** Mark snack bar as exited from the view. */
markAsExited(event: AnimationTransitionEvent) {
if (event.fromState === 'visible' &&
(event.toState === 'void' || event.toState === 'complete')) {
this._ngZone.run(() => {
this._onExit.next();
this._onExit.complete();
});
}
}
/** Begin animation of snack bar entrance into view. */
enter(): void {
this.animationState = 'visible';
}
}
+13 -3
View File
@@ -1,6 +1,7 @@
import {OverlayRef} from '../core';
import {Observable} from 'rxjs/Observable';
import {Subject} from 'rxjs/Subject';
import {MdSnackBarContainer} from './snack-bar-container';
// TODO(josephperrott): Implement onAction observable.
@@ -12,19 +13,28 @@ export class MdSnackBarRef<T> {
/** The instance of the component making up the content of the snack bar. */
readonly instance: T;
/** The instance of the component making up the content of the snack bar. */
readonly containerInstance: MdSnackBarContainer;
/** Subject for notifying the user that the snack bar has closed. */
private _afterClosed: Subject<any> = new Subject();
constructor(instance: T, private _overlayRef: OverlayRef) {
constructor(instance: T,
containerInstance: MdSnackBarContainer,
private _overlayRef: OverlayRef) {
// Sets the readonly instance of the snack bar content component.
this.instance = instance;
this.containerInstance = containerInstance;
}
/** Dismisses the snack bar. */
dismiss(): void {
if (!this._afterClosed.closed) {
this._overlayRef.dispose();
this._afterClosed.complete();
this.containerInstance.exit().subscribe(() => {
this._overlayRef.dispose();
this._afterClosed.next();
this._afterClosed.complete();
});
}
}
+45 -6
View File
@@ -16,6 +16,7 @@ import {OverlayContainer} from '../core';
import {MdSnackBarConfig} from './snack-bar-config';
import {SimpleSnackBar} from './simple-snack-bar';
// TODO(josephperrott): Update tests to mock waiting for time to complete for animations.
describe('MdSnackBar', () => {
let snackBar: MdSnackBar;
@@ -56,7 +57,6 @@ describe('MdSnackBar', () => {
snackBar.open(simpleMessage, simpleActionLabel, config);
let containerElement = overlayContainerElement.querySelector('snack-bar-container');
expect(containerElement.getAttribute('role'))
.toBe('alert', 'Expected snack bar container to have role="alert"');
});
@@ -102,7 +102,6 @@ describe('MdSnackBar', () => {
expect(messageElement.tagName).toBe('SPAN', 'Expected snack bar message element to be <span>');
expect(messageElement.textContent)
.toBe(simpleMessage, `Expected the snack bar message to be '${simpleMessage}''`);
expect(overlayContainerElement.querySelector('button.md-simple-snackbar-action'))
.toBeNull('Expected the query selection for action label to be null');
});
@@ -120,10 +119,11 @@ describe('MdSnackBar', () => {
.toBeGreaterThan(0, 'Expected overlay container element to have at least one child');
snackBarRef.dismiss();
expect(dismissed).toBeTruthy('Expected the snack bar to be dismissed');
expect(overlayContainerElement.childElementCount)
.toBe(0, 'Expected the overlay container element to have no child elements');
snackBarRef.afterDismissed().subscribe(null, null, () => {
expect(dismissed).toBeTruthy('Expected the snack bar to be dismissed');
expect(overlayContainerElement.childElementCount)
.toBe(0, 'Expected the overlay container element to have no child elements');
});
});
it('should open a custom component', () => {
@@ -136,7 +136,46 @@ describe('MdSnackBar', () => {
expect(overlayContainerElement.textContent)
.toBe('Burritos are on the way.',
`Expected the overlay text content to be 'Burritos are on the way'`);
});
it('should set the animation state to visible on entry', () => {
let config = new MdSnackBarConfig(testViewContainerRef);
let snackBarRef = snackBar.open(simpleMessage, null, config);
viewContainerFixture.detectChanges();
expect(snackBarRef.containerInstance.animationState)
.toBe('visible', `Expected the animation state would be 'visible'.`);
});
it('should set the animation state to complete on exit', () => {
let config = new MdSnackBarConfig(testViewContainerRef);
let snackBarRef = snackBar.open(simpleMessage, null, config);
snackBarRef.dismiss();
viewContainerFixture.detectChanges();
expect(snackBarRef.containerInstance.animationState)
.toBe('complete', `Expected the animation state would be 'complete'.`);
});
it(`should set the old snack bar animation state to complete and the new snack bar animation
state to visible on entry of new snack bar`, () => {
let config = new MdSnackBarConfig(testViewContainerRef);
let snackBarRef = snackBar.open(simpleMessage, null, config);
viewContainerFixture.detectChanges();
expect(snackBarRef.containerInstance.animationState)
.toBe('visible', `Expected the animation state would be 'visible'.`);
let config2 = new MdSnackBarConfig(testViewContainerRef);
let snackBarRef2 = snackBar.open(simpleMessage, null, config2);
viewContainerFixture.detectChanges();
snackBarRef.afterDismissed().subscribe(null, null, () => {
expect(snackBarRef.containerInstance.animationState)
.toBe('complete', `Expected the animation state would be 'complete'.`);
expect(snackBarRef2.containerInstance.animationState)
.toBe('visible', `Expected the animation state would be 'visible'.`);
});
});
});
+15 -12
View File
@@ -21,10 +21,6 @@ import {MdSnackBarRef} from './snack-bar-ref';
import {MdSnackBarContainer} from './snack-bar-container';
import {SimpleSnackBar} from './simple-snack-bar';
export {MdSnackBarRef} from './snack-bar-ref';
export {MdSnackBarConfig} from './snack-bar-config';
// TODO(josephperrott): Animate entrance and exit of snack bars.
// TODO(josephperrott): Automate dismiss after timeout.
@@ -45,14 +41,24 @@ export class MdSnackBar {
*/
openFromComponent<T>(component: ComponentType<T>,
config: MdSnackBarConfig): MdSnackBarRef<T> {
if (this._snackBarRef) {
this._snackBarRef.dismiss();
}
let overlayRef = this._createOverlay();
let snackBarContainer = this._attachSnackBarContainer(overlayRef, config);
let mdSnackBarRef = this._attachSnackbarContent(component, snackBarContainer, overlayRef);
// If a snack bar is already in view, dismiss it and enter the new snack bar after exit
// animation is complete.
if (this._snackBarRef) {
this._snackBarRef.afterDismissed().subscribe(() => {
mdSnackBarRef.containerInstance.enter();
});
this._snackBarRef.dismiss();
// If no snack bar is in view, enter the new snack bar.
} else {
mdSnackBarRef.containerInstance.enter();
}
this._live.announce(config.announcementMessage, config.politeness);
return mdSnackBarRef;
this._snackBarRef = mdSnackBarRef;
return this._snackBarRef;
}
/**
@@ -88,10 +94,7 @@ export class MdSnackBar {
overlayRef: OverlayRef): MdSnackBarRef<T> {
let portal = new ComponentPortal(component);
let contentRef = container.attachComponentPortal(portal);
let snackBarRef = <MdSnackBarRef<T>> new MdSnackBarRef(contentRef.instance, overlayRef);
this._snackBarRef = snackBarRef;
return snackBarRef;
return new MdSnackBarRef(contentRef.instance, container, overlayRef);
}
/**