diff --git a/build.sh b/build.sh index b79938fe7b..5d2d9943af 100755 --- a/build.sh +++ b/build.sh @@ -13,6 +13,7 @@ PACKAGES=(core platform-server platform-webworker platform-webworker-dynamic + animation http upgrade router diff --git a/modules/@angular/animation/index.ts b/modules/@angular/animation/index.ts new file mode 100644 index 0000000000..fc890095d3 --- /dev/null +++ b/modules/@angular/animation/index.ts @@ -0,0 +1,14 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +/** + * @module + * @description + * Entry point for all public APIs of the animation package. + */ +export * from './src/animation'; diff --git a/modules/@angular/animation/package.json b/modules/@angular/animation/package.json new file mode 100644 index 0000000000..96fe66eb8f --- /dev/null +++ b/modules/@angular/animation/package.json @@ -0,0 +1,17 @@ +{ + "name": "@angular/animation", + "version": "0.0.0-PLACEHOLDER", + "description": "Angular - animation integration with web-animations", + "main": "bundles/animation.umd.js", + "module": "index.js", + "typings": "index.d.ts", + "author": "angular", + "license": "MIT", + "peerDependencies": { + "@angular/core": "0.0.0-PLACEHOLDER" + }, + "repository": { + "type": "git", + "url": "https://github.com/angular/angular.git" + } +} diff --git a/modules/@angular/animation/rollup-testing.config.js b/modules/@angular/animation/rollup-testing.config.js new file mode 100644 index 0000000000..3c37bc8f76 --- /dev/null +++ b/modules/@angular/animation/rollup-testing.config.js @@ -0,0 +1,20 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +export default { + entry: '../../../dist/packages-dist/animation/testing/index.js', + dest: '../../../dist/packages-dist/animation/bundles/animation-testing.umd.js', + format: 'umd', + moduleName: 'ng.animation.testing', + globals: { + '@angular/core': 'ng.core', + '@angular/animation': 'ng.animation', + 'rxjs/Observable': 'Rx', + 'rxjs/Subject': 'Rx' + } +}; diff --git a/modules/@angular/animation/rollup.config.js b/modules/@angular/animation/rollup.config.js new file mode 100644 index 0000000000..9ecbc1047a --- /dev/null +++ b/modules/@angular/animation/rollup.config.js @@ -0,0 +1,19 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +export default { + entry: '../../../dist/packages-dist/animation/index.js', + dest: '../../../dist/packages-dist/animation/bundles/animation.umd.js', + format: 'umd', + moduleName: 'ng.animation', + globals: { + '@angular/core': 'ng.core', + 'rxjs/Observable': 'Rx', + 'rxjs/Subject': 'Rx', + } +}; diff --git a/modules/@angular/animation/src/animation.ts b/modules/@angular/animation/src/animation.ts new file mode 100644 index 0000000000..3de54172d2 --- /dev/null +++ b/modules/@angular/animation/src/animation.ts @@ -0,0 +1,11 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +export {AnimationModule} from './animation_module'; +export {Animation} from './dsl/animation'; +export {AUTO_STYLE, AnimationGroupMetadata, AnimationKeyframesSequenceMetadata, AnimationSequenceMetadata, AnimationStateMetadata, AnimationStyleMetadata, AnimationTransitionMetadata, animate, group, keyframes, sequence, state, style, transition} from './dsl/animation_metadata'; +export {AnimationTrigger, trigger} from './dsl/animation_trigger'; diff --git a/modules/@angular/animation/src/animation_module.ts b/modules/@angular/animation/src/animation_module.ts new file mode 100644 index 0000000000..42387486c1 --- /dev/null +++ b/modules/@angular/animation/src/animation_module.ts @@ -0,0 +1,36 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {NgModule} from '@angular/core'; +import {AnimationStyleNormalizer} from './dsl/style_normalization/animation_style_normalizer'; +import {WebAnimationsStyleNormalizer} from './dsl/style_normalization/web_animations_style_normalizer'; +import {AnimationDriver, NoOpAnimationDriver} from './engine/animation_driver'; +import {DomAnimationTransitionEngine} from './engine/dom_animation_transition_engine'; +import {WebAnimationsDriver, supportsWebAnimations} from './engine/web_animations/web_animations_driver'; +import {TransitionEngine} from './private_import_core'; + +export function resolveDefaultAnimationDriver(): AnimationDriver { + if (supportsWebAnimations()) { + return new WebAnimationsDriver(); + } + return new NoOpAnimationDriver(); +} + +/** + * The module that includes all animation code such as `style()`, `animate()`, `trigger()`, etc... + * + * @experimental + */ +@NgModule({ + providers: [ + {provide: AnimationDriver, useFactory: resolveDefaultAnimationDriver}, + {provide: AnimationStyleNormalizer, useClass: WebAnimationsStyleNormalizer}, + {provide: TransitionEngine, useClass: DomAnimationTransitionEngine} + ] +}) +export class AnimationModule { +} diff --git a/modules/@angular/animation/src/common/style_data.ts b/modules/@angular/animation/src/common/style_data.ts new file mode 100644 index 0000000000..2b67654b82 --- /dev/null +++ b/modules/@angular/animation/src/common/style_data.ts @@ -0,0 +1,8 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +export interface StyleData { [key: string]: string|number; } diff --git a/modules/@angular/animation/src/common/util.ts b/modules/@angular/animation/src/common/util.ts new file mode 100644 index 0000000000..e6d2c75bad --- /dev/null +++ b/modules/@angular/animation/src/common/util.ts @@ -0,0 +1,73 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {AnimationStyles} from '@angular/core'; +import {AnimateTimings} from './../dsl/animation_metadata'; +import {StyleData} from './style_data'; + +export const ONE_SECOND = 1000; + +export function parseTimeExpression(exp: string | number, errors: string[]): AnimateTimings { + const regex = /^([\.\d]+)(m?s)(?:\s+([\.\d]+)(m?s))?(?:\s+([-a-z]+(?:\(.+?\))?))?$/i; + let duration: number; + let delay: number = 0; + let easing: string = null; + if (typeof exp === 'string') { + const matches = exp.match(regex); + if (matches === null) { + errors.push(`The provided timing value "${exp}" is invalid.`); + return {duration: 0, delay: 0, easing: null}; + } + + let durationMatch = parseFloat(matches[1]); + const durationUnit = matches[2]; + if (durationUnit == 's') { + durationMatch *= ONE_SECOND; + } + duration = Math.floor(durationMatch); + + const delayMatch = matches[3]; + const delayUnit = matches[4]; + if (delayMatch != null) { + let delayVal: number = parseFloat(delayMatch); + if (delayUnit != null && delayUnit == 's') { + delayVal *= ONE_SECOND; + } + delay = Math.floor(delayVal); + } + + const easingVal = matches[5]; + if (easingVal) { + easing = easingVal; + } + } else { + duration = exp; + } + + return {duration, delay, easing}; +} + +export function normalizeStyles(styles: AnimationStyles): StyleData { + const normalizedStyles: StyleData = {}; + styles.styles.forEach((styleMap: any) => copyStyles(styleMap, false, normalizedStyles)); + return normalizedStyles; +} + +export function copyStyles( + styles: StyleData, readPrototype: boolean, destination: StyleData = {}): StyleData { + if (readPrototype) { + // we make use of a for-in loop so that the + // prototypically inherited properties are + // revealed from the backFill map + for (let prop in styles) { + destination[prop] = styles[prop]; + } + } else { + Object.keys(styles).forEach(prop => destination[prop] = styles[prop]); + } + return destination; +} diff --git a/modules/@angular/animation/src/dsl/animation.ts b/modules/@angular/animation/src/dsl/animation.ts new file mode 100644 index 0000000000..88cc8fb009 --- /dev/null +++ b/modules/@angular/animation/src/dsl/animation.ts @@ -0,0 +1,59 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {AnimationPlayer, AnimationStyles, Injector} from '@angular/core'; +import {StyleData} from '../common/style_data'; +import {normalizeStyles} from '../common/util'; +import {AnimationDriver} from '../engine/animation_driver'; +import {DomAnimationTransitionEngine} from '../engine/dom_animation_transition_engine'; +import {AnimationMetadata, sequence} from './animation_metadata'; +import {AnimationTimelineInstruction} from './animation_timeline_instruction'; +import {buildAnimationKeyframes} from './animation_timeline_visitor'; +import {validateAnimationSequence} from './animation_validator_visitor'; +import {AnimationStyleNormalizer} from './style_normalization/animation_style_normalizer'; + +/** + * @experimental Animation support is experimental. + */ +export class Animation { + private _animationAst: AnimationMetadata; + constructor(input: AnimationMetadata|AnimationMetadata[]) { + const ast = + Array.isArray(input) ? sequence(input) : input; + const errors = validateAnimationSequence(ast); + if (errors.length) { + const errorMessage = `animation validation failed:\n${errors.join("\n")}`; + throw new Error(errorMessage); + } + this._animationAst = ast; + } + + buildTimelines(startingStyles: StyleData|StyleData[], destinationStyles: StyleData|StyleData[]): + AnimationTimelineInstruction[] { + const start = Array.isArray(startingStyles) ? + normalizeStyles(new AnimationStyles(startingStyles)) : + startingStyles; + const dest = Array.isArray(destinationStyles) ? + normalizeStyles(new AnimationStyles(destinationStyles)) : + destinationStyles; + return buildAnimationKeyframes(this._animationAst, start, dest); + } + + // this is only used for development demo purposes for now + private create( + injector: Injector, element: any, startingStyles: StyleData = {}, + destinationStyles: StyleData = {}): AnimationPlayer { + const instructions = this.buildTimelines(startingStyles, destinationStyles); + + // note the code below is only here to make the tests happy (once the new renderer is + // within core then the code below will interact with Renderer.transition(...)) + const driver: AnimationDriver = injector.get(AnimationDriver); + const normalizer: AnimationStyleNormalizer = injector.get(AnimationStyleNormalizer); + const engine = new DomAnimationTransitionEngine(driver, normalizer); + return engine.process(element, instructions); + } +} diff --git a/modules/@angular/animation/src/dsl/animation_dsl_visitor.ts b/modules/@angular/animation/src/dsl/animation_dsl_visitor.ts new file mode 100644 index 0000000000..d6c573c8bf --- /dev/null +++ b/modules/@angular/animation/src/dsl/animation_dsl_visitor.ts @@ -0,0 +1,40 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import * as meta from './animation_metadata'; + +export interface AnimationDslVisitor { + visitState(ast: meta.AnimationStateMetadata, context: any): any; + visitTransition(ast: meta.AnimationTransitionMetadata, context: any): any; + visitSequence(ast: meta.AnimationSequenceMetadata, context: any): any; + visitGroup(ast: meta.AnimationGroupMetadata, context: any): any; + visitAnimate(ast: meta.AnimationAnimateMetadata, context: any): any; + visitStyle(ast: meta.AnimationStyleMetadata, context: any): any; + visitKeyframeSequence(ast: meta.AnimationKeyframesSequenceMetadata, context: any): any; +} + +export function visitAnimationNode( + visitor: AnimationDslVisitor, node: meta.AnimationMetadata, context: any) { + switch (node.type) { + case meta.AnimationMetadataType.State: + return visitor.visitState(node, context); + case meta.AnimationMetadataType.Transition: + return visitor.visitTransition(node, context); + case meta.AnimationMetadataType.Sequence: + return visitor.visitSequence(node, context); + case meta.AnimationMetadataType.Group: + return visitor.visitGroup(node, context); + case meta.AnimationMetadataType.Animate: + return visitor.visitAnimate(node, context); + case meta.AnimationMetadataType.KeyframeSequence: + return visitor.visitKeyframeSequence(node, context); + case meta.AnimationMetadataType.Style: + return visitor.visitStyle(node, context); + default: + throw new Error(`Unable to resolve animation metadata node #${node.type}`); + } +} diff --git a/modules/@angular/animation/src/dsl/animation_metadata.ts b/modules/@angular/animation/src/dsl/animation_metadata.ts new file mode 100644 index 0000000000..38a6da05af --- /dev/null +++ b/modules/@angular/animation/src/dsl/animation_metadata.ts @@ -0,0 +1,512 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {StyleData} from '../common/style_data'; + +export declare type AnimateTimings = { + duration: number, + delay: number, + easing: string +}; + +export const enum AnimationMetadataType { + State, + Transition, + Sequence, + Group, + Animate, + KeyframeSequence, + Style +} + +/** + * @experimental Animation support is experimental. + */ +export const AUTO_STYLE = '*'; + +/** + * @experimental Animation support is experimental. + */ +export interface AnimationMetadata { type: AnimationMetadataType; } + +/** + * Metadata representing the entry of animations. Instances of this class are provided via the + * animation DSL when the {@link state state animation function} is called. + * + * @experimental Animation support is experimental. + */ +export interface AnimationStateMetadata extends AnimationMetadata { + name: string; + styles: AnimationStyleMetadata; +} + +/** + * Metadata representing the entry of animations. Instances of this class are provided via the + * animation DSL when the {@link transition transition animation function} is called. + * + * @experimental Animation support is experimental. + */ +export interface AnimationTransitionMetadata extends AnimationMetadata { + expr: string|((fromState: string, toState: string) => boolean); + animation: AnimationMetadata; +} + +/** + * Metadata representing the entry of animations. Instances of this class are provided via the + * animation DSL when the {@link keyframes keyframes animation function} is called. + * + * @experimental Animation support is experimental. + */ +export interface AnimationKeyframesSequenceMetadata extends AnimationMetadata { + steps: AnimationStyleMetadata[]; +} + +/** + * Metadata representing the entry of animations. Instances of this class are provided via the + * animation DSL when the {@link style style animation function} is called. + * + * @experimental Animation support is experimental. + */ +export interface AnimationStyleMetadata extends AnimationMetadata { + styles: StyleData[]; + offset: number; +} + +/** + * Metadata representing the entry of animations. Instances of this class are provided via the + * animation DSL when the {@link animate animate animation function} is called. + * + * @experimental Animation support is experimental. + */ +export interface AnimationAnimateMetadata extends AnimationMetadata { + timings: string|number|AnimateTimings; + styles: AnimationStyleMetadata|AnimationKeyframesSequenceMetadata; +} + +/** + * Metadata representing the entry of animations. Instances of this class are provided via the + * animation DSL when the {@link sequence sequence animation function} is called. + * + * @experimental Animation support is experimental. + */ +export interface AnimationSequenceMetadata extends AnimationMetadata { steps: AnimationMetadata[]; } + +/** + * Metadata representing the entry of animations. Instances of this class are provided via the + * animation DSL when the {@link group group animation function} is called. + * + * @experimental Animation support is experimental. + */ +export interface AnimationGroupMetadata extends AnimationMetadata { steps: AnimationMetadata[]; } + +/** + * `animate` is an animation-specific function that is designed to be used inside of Angular2's + * animation DSL language. If this information is new, please navigate to the {@link + * Component#animations-anchor component animations metadata page} to gain a better understanding of + * how animations in Angular2 are used. + * + * `animate` specifies an animation step that will apply the provided `styles` data for a given + * amount of time based on the provided `timing` expression value. Calls to `animate` are expected + * to be used within {@link sequence an animation sequence}, {@link group group}, or {@link + * transition transition}. + * + * ### Usage + * + * The `animate` function accepts two input parameters: `timing` and `styles`: + * + * - `timing` is a string based value that can be a combination of a duration with optional delay + * and easing values. The format for the expression breaks down to `duration delay easing` + * (therefore a value such as `1s 100ms ease-out` will be parse itself into `duration=1000, + * delay=100, easing=ease-out`. If a numeric value is provided then that will be used as the + * `duration` value in millisecond form. + * - `styles` is the style input data which can either be a call to {@link style style} or {@link + * keyframes keyframes}. If left empty then the styles from the destination state will be collected + * and used (this is useful when describing an animation step that will complete an animation by + * {@link transition#the-final-animate-call animating to the final state}). + * + * ```typescript + * // various functions for specifying timing data + * animate(500, style(...)) + * animate("1s", style(...)) + * animate("100ms 0.5s", style(...)) + * animate("5s ease", style(...)) + * animate("5s 10ms cubic-bezier(.17,.67,.88,.1)", style(...)) + * + * // either style() of keyframes() can be used + * animate(500, style({ background: "red" })) + * animate(500, keyframes([ + * style({ background: "blue" })), + * style({ background: "red" })) + * ]) + * ``` + * + * {@example core/animation/ts/dsl/animation_example.ts region='Component'} + * + * @experimental Animation support is experimental. + */ +export function animate( + timings: string | number, styles: AnimationStyleMetadata | AnimationKeyframesSequenceMetadata = + null): AnimationAnimateMetadata { + return {type: AnimationMetadataType.Animate, styles: styles, timings: timings}; +} + +/** + * `group` is an animation-specific function that is designed to be used inside of Angular2's + * animation DSL language. If this information is new, please navigate to the {@link + * Component#animations-anchor component animations metadata page} to gain a better understanding of + * how animations in Angular2 are used. + * + * `group` specifies a list of animation steps that are all run in parallel. Grouped animations are + * useful when a series of styles must be animated/closed off at different statrting/ending times. + * + * The `group` function can either be used within a {@link sequence sequence} or a {@link transition + * transition} and it will only continue to the next instruction once all of the inner animation + * steps have completed. + * + * ### Usage + * + * The `steps` data that is passed into the `group` animation function can either consist of {@link + * style style} or {@link animate animate} function calls. Each call to `style()` or `animate()` + * within a group will be executed instantly (use {@link keyframes keyframes} or a {@link + * animate#usage animate() with a delay value} to offset styles to be applied at a later time). + * + * ```typescript + * group([ + * animate("1s", { background: "black" })) + * animate("2s", { color: "white" })) + * ]) + * ``` + * + * {@example core/animation/ts/dsl/animation_example.ts region='Component'} + * + * @experimental Animation support is experimental. + */ +export function group(steps: AnimationMetadata[]): AnimationGroupMetadata { + return {type: AnimationMetadataType.Group, steps: steps}; +} + +/** + * `sequence` is an animation-specific function that is designed to be used inside of Angular2's + * animation DSL language. If this information is new, please navigate to the {@link + * Component#animations-anchor component animations metadata page} to gain a better understanding of + * how animations in Angular2 are used. + * + * `sequence` Specifies a list of animation steps that are run one by one. (`sequence` is used by + * default when an array is passed as animation data into {@link transition transition}.) + * + * The `sequence` function can either be used within a {@link group group} or a {@link transition + * transition} and it will only continue to the next instruction once each of the inner animation + * steps have completed. + * + * To perform animation styling in parallel with other animation steps then have a look at the + * {@link group group} animation function. + * + * ### Usage + * + * The `steps` data that is passed into the `sequence` animation function can either consist of + * {@link style style} or {@link animate animate} function calls. A call to `style()` will apply the + * provided styling data immediately while a call to `animate()` will apply its styling data over a + * given time depending on its timing data. + * + * ```typescript + * sequence([ + * style({ opacity: 0 })), + * animate("1s", { opacity: 1 })) + * ]) + * ``` + * + * {@example core/animation/ts/dsl/animation_example.ts region='Component'} + * + * @experimental Animation support is experimental. + */ +export function sequence(steps: AnimationMetadata[]): AnimationSequenceMetadata { + return {type: AnimationMetadataType.Sequence, steps: steps}; +} + +/** + * `style` is an animation-specific function that is designed to be used inside of Angular2's + * animation DSL language. If this information is new, please navigate to the {@link + * Component#animations-anchor component animations metadata page} to gain a better understanding of + * how animations in Angular2 are used. + * + * `style` declares a key/value object containing CSS properties/styles that can then be used for + * {@link state animation states}, within an {@link sequence animation sequence}, or as styling data + * for both {@link animate animate} and {@link keyframes keyframes}. + * + * ### Usage + * + * `style` takes in a key/value string map as data and expects one or more CSS property/value pairs + * to be defined. + * + * ```typescript + * // string values are used for css properties + * style({ background: "red", color: "blue" }) + * + * // numerical (pixel) values are also supported + * style({ width: 100, height: 0 }) + * ``` + * + * #### Auto-styles (using `*`) + * + * When an asterix (`*`) character is used as a value then it will be detected from the element + * being animated and applied as animation data when the animation starts. + * + * This feature proves useful for a state depending on layout and/or environment factors; in such + * cases the styles are calculated just before the animation starts. + * + * ```typescript + * // the steps below will animate from 0 to the + * // actual height of the element + * style({ height: 0 }), + * animate("1s", style({ height: "*" })) + * ``` + * + * {@example core/animation/ts/dsl/animation_example.ts region='Component'} + * + * @experimental Animation support is experimental. + */ +export function style( + tokens: {[key: string]: string | number} | + Array<{[key: string]: string | number}>): AnimationStyleMetadata { + let input: StyleData[]; + let offset: number = null; + if (Array.isArray(tokens)) { + input = tokens; + } else { + input = [tokens]; + } + input.forEach(entry => { + const entryOffset = (entry as StyleData)['offset']; + if (entryOffset != null) { + offset = offset == null ? parseFloat(entryOffset) : offset; + } + }); + return _style(offset, input); +} + +function _style(offset: number, styles: StyleData[]): AnimationStyleMetadata { + return {type: AnimationMetadataType.Style, styles: styles, offset: offset}; +} + + +/** + * `state` is an animation-specific function that is designed to be used inside of Angular2's + * animation DSL language. If this information is new, please navigate to the {@link + * Component#animations-anchor component animations metadata page} to gain a better understanding of + * how animations in Angular2 are used. + * + * `state` declares an animation state within the given trigger. When a state is active within a + * component then its associated styles will persist on the element that the trigger is attached to + * (even when the animation ends). + * + * To animate between states, have a look at the animation {@link transition transition} DSL + * function. To register states to an animation trigger please have a look at the {@link trigger + * trigger} function. + * + * #### The `void` state + * + * The `void` state value is a reserved word that angular uses to determine when the element is not + * apart of the application anymore (e.g. when an `ngIf` evaluates to false then the state of the + * associated element is void). + * + * #### The `*` (default) state + * + * The `*` state (when styled) is a fallback state that will be used if the state that is being + * animated is not declared within the trigger. + * + * ### Usage + * + * `state` will declare an animation state with its associated styles + * within the given trigger. + * + * - `stateNameExpr` can be one or more state names separated by commas. + * - `styles` refers to the {@link style styling data} that will be persisted on the element once + * the state has been reached. + * + * ```typescript + * // "void" is a reserved name for a state and is used to represent + * // the state in which an element is detached from from the application. + * state("void", style({ height: 0 })) + * + * // user-defined states + * state("closed", style({ height: 0 })) + * state("open, visible", style({ height: "*" })) + * ``` + * + * {@example core/animation/ts/dsl/animation_example.ts region='Component'} + * + * @experimental Animation support is experimental. + */ +export function state(name: string, styles: AnimationStyleMetadata): AnimationStateMetadata { + return {type: AnimationMetadataType.State, name: name, styles: styles}; +} + +/** + * `keyframes` is an animation-specific function that is designed to be used inside of Angular2's + * animation DSL language. If this information is new, please navigate to the {@link + * Component#animations-anchor component animations metadata page} to gain a better understanding of + * how animations in Angular2 are used. + * + * `keyframes` specifies a collection of {@link style style} entries each optionally characterized + * by an `offset` value. + * + * ### Usage + * + * The `keyframes` animation function is designed to be used alongside the {@link animate animate} + * animation function. Instead of applying animations from where they are currently to their + * destination, keyframes can describe how each style entry is applied and at what point within the + * animation arc (much like CSS Keyframe Animations do). + * + * For each `style()` entry an `offset` value can be set. Doing so allows to specifiy at what + * percentage of the animate time the styles will be applied. + * + * ```typescript + * // the provided offset values describe when each backgroundColor value is applied. + * animate("5s", keyframes([ + * style({ backgroundColor: "red", offset: 0 }), + * style({ backgroundColor: "blue", offset: 0.2 }), + * style({ backgroundColor: "orange", offset: 0.3 }), + * style({ backgroundColor: "black", offset: 1 }) + * ])) + * ``` + * + * Alternatively, if there are no `offset` values used within the style entries then the offsets + * will be calculated automatically. + * + * ```typescript + * animate("5s", keyframes([ + * style({ backgroundColor: "red" }) // offset = 0 + * style({ backgroundColor: "blue" }) // offset = 0.33 + * style({ backgroundColor: "orange" }) // offset = 0.66 + * style({ backgroundColor: "black" }) // offset = 1 + * ])) + * ``` + * + * {@example core/animation/ts/dsl/animation_example.ts region='Component'} + * + * @experimental Animation support is experimental. + */ +export function keyframes(steps: AnimationStyleMetadata[]): AnimationKeyframesSequenceMetadata { + return {type: AnimationMetadataType.KeyframeSequence, steps: steps}; +} + +/** + * `transition` is an animation-specific function that is designed to be used inside of Angular2's + * animation DSL language. If this information is new, please navigate to the {@link + * Component#animations-anchor component animations metadata page} to gain a better understanding of + * how animations in Angular2 are used. + * + * `transition` declares the {@link sequence sequence of animation steps} that will be run when the + * provided `stateChangeExpr` value is satisfied. The `stateChangeExpr` consists of a `state1 => + * state2` which consists of two known states (use an asterix (`*`) to refer to a dynamic starting + * and/or ending state). + * + * A function can also be provided as the `stateChangeExpr` argument for a transition and this + * function will be executed each time a state change occurs. If the value returned within the + * function is true then the associated animation will be run. + * + * Animation transitions are placed within an {@link trigger animation trigger}. For an transition + * to animate to a state value and persist its styles then one or more {@link state animation + * states} is expected to be defined. + * + * ### Usage + * + * An animation transition is kicked off the `stateChangeExpr` predicate evaluates to true based on + * what the previous state is and what the current state has become. In other words, if a transition + * is defined that matches the old/current state criteria then the associated animation will be + * triggered. + * + * ```typescript + * // all transition/state changes are defined within an animation trigger + * trigger("myAnimationTrigger", [ + * // if a state is defined then its styles will be persisted when the + * // animation has fully completed itself + * state("on", style({ background: "green" })), + * state("off", style({ background: "grey" })), + * + * // a transition animation that will be kicked off when the state value + * // bound to "myAnimationTrigger" changes from "on" to "off" + * transition("on => off", animate(500)), + * + * // it is also possible to do run the same animation for both directions + * transition("on <=> off", animate(500)), + * + * // or to define multiple states pairs separated by commas + * transition("on => off, off => void", animate(500)), + * + * // this is a catch-all state change for when an element is inserted into + * // the page and the destination state is unknown + * transition("void => *", [ + * style({ opacity: 0 }), + * animate(500) + * ]), + * + * // this will capture a state change between any states + * transition("* => *", animate("1s 0s")), + * + * // you can also go full out and include a function + * transition((fromState, toState) => { + * // when `true` then it will allow the animation below to be invoked + * return fromState == "off" && toState == "on"; + * }, animate("1s 0s")) + * ]) + * ``` + * + * The template associated with this component will make use of the `myAnimationTrigger` animation + * trigger by binding to an element within its template code. + * + * ```html + * + *
...
+ * ``` + * + * #### The final `animate` call + * + * If the final step within the transition steps is a call to `animate()` that **only** uses a + * timing value with **no style data** then it will be automatically used as the final animation arc + * for the element to animate itself to the final state. This involves an automatic mix of + * adding/removing CSS styles so that the element will be in the exact state it should be for the + * applied state to be presented correctly. + * + * ``` + * // start off by hiding the element, but make sure that it animates properly to whatever state + * // is currently active for "myAnimationTrigger" + * transition("void => *", [ + * style({ opacity: 0 }), + * animate(500) + * ]) + * ``` + * + * ### Transition Aliases (`:enter` and `:leave`) + * + * Given that enter (insertion) and leave (removal) animations are so common, the `transition` + * function accepts both `:enter` and `:leave` values which are aliases for the `void => *` and `* + * => void` state changes. + * + * ``` + * transition(":enter", [ + * style({ opacity: 0 }), + * animate(500, style({ opacity: 1 })) + * ]) + * transition(":leave", [ + * animate(500, style({ opacity: 0 })) + * ]) + * ``` + * + * {@example core/animation/ts/dsl/animation_example.ts region='Component'} + * + * @experimental Animation support is experimental. + */ +export function transition( + stateChangeExpr: string | ((fromState: string, toState: string) => boolean), + steps: AnimationMetadata | AnimationMetadata[]): AnimationTransitionMetadata { + return { + type: AnimationMetadataType.Transition, + expr: stateChangeExpr, + animation: Array.isArray(steps) ? sequence(steps) : steps + }; +} diff --git a/modules/@angular/animation/src/dsl/animation_timeline_instruction.ts b/modules/@angular/animation/src/dsl/animation_timeline_instruction.ts new file mode 100644 index 0000000000..c9f66f30ce --- /dev/null +++ b/modules/@angular/animation/src/dsl/animation_timeline_instruction.ts @@ -0,0 +1,28 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {StyleData} from '../common/style_data'; +import {AnimationEngineInstruction, AnimationTransitionInstructionType} from '../engine/animation_engine_instruction'; + +export interface AnimationTimelineInstruction extends AnimationEngineInstruction { + keyframes: StyleData[]; + duration: number; + delay: number; + easing: string; +} + +export function createTimelineInstruction( + keyframes: StyleData[], duration: number, delay: number, + easing: string): AnimationTimelineInstruction { + return { + type: AnimationTransitionInstructionType.TimelineAnimation, + keyframes, + duration, + delay, + easing + }; +} diff --git a/modules/@angular/animation/src/dsl/animation_timeline_visitor.ts b/modules/@angular/animation/src/dsl/animation_timeline_visitor.ts new file mode 100644 index 0000000000..32f4f3103b --- /dev/null +++ b/modules/@angular/animation/src/dsl/animation_timeline_visitor.ts @@ -0,0 +1,468 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {AnimationPlayer, AnimationStyles} from '@angular/core'; + +import {StyleData} from '../common/style_data'; +import {copyStyles, normalizeStyles, parseTimeExpression} from '../common/util'; + +import {AnimationDslVisitor, visitAnimationNode} from './animation_dsl_visitor'; +import * as meta from './animation_metadata'; +import {AnimationTimelineInstruction, createTimelineInstruction} from './animation_timeline_instruction'; + + +/* + * The code within this file aims to generate web-animations-compatible keyframes from Angular's + * animation DSL code. + * + * The code below will be converted from: + * + * ``` + * sequence([ + * style({ opacity: 0 }), + * animate(1000, style({ opacity: 0 })) + * ]) + * ``` + * + * To: + * ``` + * keyframes = [{ opacity: 0, offset: 0 }, { opacity: 1, offset: 1 }] + * duration = 1000 + * delay = 0 + * easing = '' + * ``` + * + * For this operation to cover the combination of animation verbs (style, animate, group, etc...) a + * combination of prototypical inheritance, AST traversal and merge-sort-like algorithms are used. + * + * [AST Traversal] + * Each of the animation verbs, when executed, will return an string-map object representing what + * type of action it is (style, animate, group, etc...) and the data associated with it. This means + * that when functional composition mix of these functions is evaluated (like in the example above) + * then it will end up producing a tree of objects representing the animation itself. + * + * When this animation object tree is processed by the visitor code below it will visit each of the + * verb statements within the visitor. And during each visit it will build the context of the + * animation keyframes by interacting with the `TimelineBuilder`. + * + * [TimelineBuilder] + * This class is responsible for tracking the styles and building a series of keyframe objects for a + * timeline between a start and end time. There is always one top-level timeline and sub timelines + * are forked in two specific cases: + * + * 1. When keyframes() is used it will create a sub timeline. Upon creation, ALL OF THE COLLECTED + * STYLES from the parent timeline up until this point will be inherited into the keyframes + * timeline. + * + * 2. When group() is used it will create a sub timeline. Upon creation, NONE OF THE COLLECTED + * STYLES from the parent timeline will be inherited. Although, if the sub timeline does reference a + * style that was previously used within the parent then it will be copied over into the sub + * timeline. + * + * As the AST is traversed, the timing state on each of the timelines will be incremented. If a sub + * timeline was created (based on one of the cases above) then the parent timeline will attempt to + * merge the styles used within the sub timelines into itself (only with group() this will happen). + * This happens with a merge operation (much like how the merge works in mergesort) and it will only + * copy the most recently used styles from the sub timelines into the parent timeline. This ensures + * that if the styles are used later on in another phase of the animation then they will be the most + * up-to-date values. + * + * [How Missing Styles Are Updated] + * Each timeline has a `backFill` property which is responsible for filling in new styles into + * already processed keyframes if a new style shows up later within the animation sequence. + * + * ``` + * sequence([ + * style({ width: 0 }), + * animate(1000, style({ width: 100 })), + * animate(1000, style({ width: 200 })), + * animate(1000, style({ width: 300 })) + * animate(1000, style({ width: 400, height: 400 })) // notice how `height` doesn't exist anywhere + * else + * ]) + * ``` + * + * What is happening here is that the `height` value is added later in the sequence, but is missing + * from all previous animation steps. Therefore when a keyframe is created it would also be missing + * from all previous keyframes up until where it is first used. For the timeline keyframe generation + * to properly fill in the style it will place the previous value (the value from the parent + * timeline) or a default value of `*` into the backFill object. Given that each of the keyframe + * styles are objects that prototypically inhert from the backFill object, this means that if a + * value is added into the backFill then it will automatically propagate any missing values to all + * keyframes. Therefore the missing `height` value will be properly filled into the already + * processed keyframes. + * + * (For prototypically-inherited contents to be detected a `for(i in obj)` loop must be used.) + * + * Based on whether the styles are inherited into a sub timeline (depending on the two cases + * mentioned above), the functionality of the backFill will behave differently: + * + * 1. If the styles are inherited from the parent then the backFill property will also be inherited + * and therefore any newly added styles to the backFill will be propagated to the parent timeline + * and its already processed keyframes. + * + * 2. If the styles are not inherited from the parent then the sub timeline will have its own + * backFill. Then if the sub timeline comes across a property that was not defined already then it + * will read that from the parent's styles and pass that into its own backFill (which will then + * propagate the missing styles across the sub timeline only). + * + * [Validation] + * The code in this file is not responsible for validation. That functionaliy happens with within + * the `AnimationValidatorVisitor` code. + */ +export function buildAnimationKeyframes( + ast: meta.AnimationMetadata | meta.AnimationMetadata[], startingStyles: StyleData = {}, + finalStyles: StyleData = {}): AnimationTimelineInstruction[] { + const normalizedAst = Array.isArray(ast) ? meta.sequence(ast) : + ast; + return new AnimationTimelineVisitor().buildKeyframes(normalizedAst, startingStyles, finalStyles); +} + +export declare type StyleAtTime = { + time: number; value: string | number; +}; + +export class AnimationTimelineContext { + currentTimeline: TimelineBuilder; + currentAnimateTimings: meta.AnimateTimings; + previousNode: meta.AnimationMetadata = {}; + subContextCount = 0; + + constructor( + public errors: any[], public timelines: TimelineBuilder[], + initialTimeline: TimelineBuilder = null) { + this.currentTimeline = initialTimeline || new TimelineBuilder(0); + timelines.push(this.currentTimeline); + } + + createSubContext(inherit: boolean = false): AnimationTimelineContext { + const context = new AnimationTimelineContext( + this.errors, this.timelines, this.currentTimeline.fork(inherit)); + context.previousNode = this.previousNode; + context.currentAnimateTimings = this.currentAnimateTimings; + this.subContextCount++; + return context; + } + + transformIntoNewTimeline(newTime = 0) { + const oldTimeline = this.currentTimeline; + const oldTime = oldTimeline.time; + if (newTime > 0) { + oldTimeline.time = newTime; + } + this.currentTimeline = oldTimeline.fork(true); + oldTimeline.time = oldTime; + this.timelines.push(this.currentTimeline); + return this.currentTimeline; + } + + incrementTime(time: number) { + this.currentTimeline.forwardTime(this.currentTimeline.time + time); + } +} + +export class AnimationTimelineVisitor implements AnimationDslVisitor { + buildKeyframes(ast: meta.AnimationMetadata, startingStyles: StyleData, finalStyles: StyleData): + AnimationTimelineInstruction[] { + const context = new AnimationTimelineContext([], []); + context.currentTimeline.setStyles(startingStyles); + + visitAnimationNode(this, ast, context); + const normalizedFinalStyles = copyStyles(finalStyles, true); + + // this is a special case for when animate(TIME) is used (without any styles) + // thus indicating to create an animation arc between the final keyframe and + // the destination styles. When this occurs we need to ensure that the styles + // that are missing on the finalStyles map are set to AUTO + if (Object.keys(context.currentTimeline.getFinalKeyframe()).length == 0) { + context.currentTimeline.properties.forEach(prop => { + const val = normalizedFinalStyles[prop]; + if (val == null) { + normalizedFinalStyles[prop] = meta.AUTO_STYLE; + } + }); + } + + context.currentTimeline.setStyles(normalizedFinalStyles); + const timelineInstructions: AnimationTimelineInstruction[] = []; + context.timelines.forEach(timeline => { + // this checks to see if an actual animation happened + if (timeline.hasStyling()) { + timelineInstructions.push(timeline.buildKeyframes()); + } + }); + + if (timelineInstructions.length == 0) { + timelineInstructions.push(createTimelineInstruction([], 0, 0, '')); + } + return timelineInstructions; + } + + visitState(ast: meta.AnimationStateMetadata, context: any): any { + // these values are not visited in this AST + } + + visitTransition(ast: meta.AnimationTransitionMetadata, context: any): any { + // these values are not visited in this AST + } + + visitSequence(ast: meta.AnimationSequenceMetadata, context: AnimationTimelineContext) { + const subContextCount = context.subContextCount; + if (context.previousNode.type == meta.AnimationMetadataType.Style) { + context.currentTimeline.forwardFrame(); + context.currentTimeline.snapshotCurrentStyles(); + } + ast.steps.map(s => visitAnimationNode(this, s, context)); + context.previousNode = ast; + if (context.subContextCount > subContextCount) { + context.transformIntoNewTimeline(); + context.currentTimeline.snapshotCurrentStyles(); + } + } + + visitGroup(ast: meta.AnimationGroupMetadata, context: AnimationTimelineContext) { + const innerTimelines: TimelineBuilder[] = []; + let furthestTime = context.currentTimeline.currentTime; + ast.steps.map(s => { + const innerContext = context.createSubContext(false); + innerContext.currentTimeline.snapshotCurrentStyles(); + visitAnimationNode(this, s, innerContext); + furthestTime = Math.max(furthestTime, innerContext.currentTimeline.currentTime); + innerTimelines.push(innerContext.currentTimeline); + }); + + context.transformIntoNewTimeline(furthestTime); + + // this operation is run after the AST loop because otherwise + // if the parent timeline's collected styles were updated then + // it would pass in invalid data into the new-to-be forked items + innerTimelines.forEach( + timeline => context.currentTimeline.mergeTimelineCollectedStyles(timeline)); + + // we do this because the window between this timeline and the sub timeline + // should ensure that the styles within are exactly the same as they were before + context.currentTimeline.snapshotCurrentStyles(); + context.previousNode = ast; + } + + visitAnimate(ast: meta.AnimationAnimateMetadata, context: AnimationTimelineContext) { + const timings = ast.timings.hasOwnProperty('duration') ? + ast.timings : + parseTimeExpression(ast.timings, context.errors); + context.currentAnimateTimings = timings; + + if (timings.delay) { + context.incrementTime(timings.delay); + context.currentTimeline.snapshotCurrentStyles(); + } + + const astType = ast.styles ? ast.styles.type : -1; + if (astType == meta.AnimationMetadataType.KeyframeSequence) { + this.visitKeyframeSequence(ast.styles, context); + } else { + context.incrementTime(timings.duration); + if (astType == meta.AnimationMetadataType.Style) { + this.visitStyle(ast.styles, context); + } + } + + context.currentAnimateTimings = null; + context.previousNode = ast; + } + + visitStyle(ast: meta.AnimationStyleMetadata, context: AnimationTimelineContext) { + // this is a special case when a style() call is issued directly after + // a call to animate(). If the clock is not forwarded by one frame then + // the style() calls will be merged into the previous animate() call + // which is incorrect. + if (!context.currentAnimateTimings && + context.previousNode.type == meta.AnimationMetadataType.Animate) { + context.currentTimeline.forwardFrame(); + } + + const normalizedStyles = normalizeStyles(new AnimationStyles(ast.styles)); + const easing = context.currentAnimateTimings && context.currentAnimateTimings.easing; + if (easing) { + normalizedStyles['easing'] = easing; + } + + context.currentTimeline.setStyles(normalizedStyles); + context.previousNode = ast; + } + + visitKeyframeSequence( + ast: meta.AnimationKeyframesSequenceMetadata, context: AnimationTimelineContext) { + const MAX_KEYFRAME_OFFSET = 1; + const limit = ast.steps.length - 1; + const firstKeyframe = ast.steps[0]; + + let offsetGap = 0; + const containsOffsets = firstKeyframe.styles.find(styles => styles['offset'] >= 0); + if (!containsOffsets) { + offsetGap = MAX_KEYFRAME_OFFSET / limit; + } + + const keyframeDuration = context.currentAnimateTimings.duration; + const innerContext = context.createSubContext(true); + const innerTimeline = innerContext.currentTimeline; + innerTimeline.easing = context.currentAnimateTimings.easing; + + // this will ensure that all collected styles so far + // are populated into the first keyframe of the keyframes() + // timeline (even if there exists a starting keyframe then + // it will override the contents of the first frame later) + innerTimeline.snapshotCurrentStyles(); + + ast.steps.map((step: meta.AnimationStyleMetadata, i: number) => { + const normalizedStyles = normalizeStyles(new AnimationStyles(step.styles)); + const offset = containsOffsets ? normalizedStyles['offset'] : + (i == limit ? MAX_KEYFRAME_OFFSET : i * offsetGap); + innerTimeline.forwardTime(offset * keyframeDuration); + innerTimeline.setStyles(normalizedStyles); + }); + + // this will ensure that the parent timeline gets all the styles from + // the child even if the new timeline below is not used + context.currentTimeline.mergeTimelineCollectedStyles(innerTimeline); + + // we do this because the window between this timeline and the sub timeline + // should ensure that the styles within are exactly the same as they were before + context.transformIntoNewTimeline(context.currentTimeline.time + keyframeDuration); + context.currentTimeline.snapshotCurrentStyles(); + context.previousNode = ast; + } +} + +export class TimelineBuilder { + public time: number = 0; + public easing: string = ''; + private _currentKeyframe: StyleData; + private _keyframes = new Map(); + private _styleSummary: {[prop: string]: StyleAtTime} = {}; + private _localTimelineStyles: StyleData; + private _backFill: StyleData = {}; + + constructor( + public startTime: number, private _globalTimelineStyles: StyleData = null, + inheritedBackFill: StyleData = null, inheritedStyles: StyleData = null) { + if (inheritedBackFill) { + this._backFill = inheritedBackFill; + } + + this._localTimelineStyles = Object.create(this._backFill, {}); + if (inheritedStyles) { + this._localTimelineStyles = copyStyles(inheritedStyles, false, this._localTimelineStyles); + } + + if (!this._globalTimelineStyles) { + this._globalTimelineStyles = this._localTimelineStyles; + } + this._loadKeyframe(); + } + + hasStyling(): boolean { return this._keyframes.size > 1; } + + get currentTime() { return this.startTime + this.time; } + + fork(inherit: boolean = false): TimelineBuilder { + let inheritedBackFill = inherit ? this._backFill : null; + let inheritedStyles = inherit ? this._localTimelineStyles : null; + return new TimelineBuilder( + this.currentTime, this._globalTimelineStyles, inheritedBackFill, inheritedStyles); + } + + private _loadKeyframe() { + this._currentKeyframe = this._keyframes.get(this.time); + if (!this._currentKeyframe) { + this._currentKeyframe = Object.create(this._backFill, {}); + this._keyframes.set(this.time, this._currentKeyframe); + } + } + + forwardFrame() { + this.time++; + this._loadKeyframe(); + } + + forwardTime(time: number) { + this.time = time; + this._loadKeyframe(); + } + + private _updateStyle(prop: string, value: string|number) { + if (prop != 'easing') { + if (!this._localTimelineStyles[prop]) { + this._backFill[prop] = this._globalTimelineStyles[prop] || meta.AUTO_STYLE; + } + this._localTimelineStyles[prop] = value; + this._globalTimelineStyles[prop] = value; + this._styleSummary[prop] = {time: this.currentTime, value}; + } + } + + setStyles(styles: StyleData) { + Object.keys(styles).forEach(prop => { + if (prop !== 'offset') { + const val = styles[prop]; + this._currentKeyframe[prop] = val; + this._updateStyle(prop, val); + } + }); + Object.keys(this._localTimelineStyles).forEach(prop => { + if (!this._currentKeyframe.hasOwnProperty(prop)) { + this._currentKeyframe[prop] = this._localTimelineStyles[prop]; + } + }); + } + + snapshotCurrentStyles() { copyStyles(this._localTimelineStyles, false, this._currentKeyframe); } + + getFinalKeyframe() { return this._keyframes.get(this.time); } + + get properties() { + const properties: string[] = []; + for (let prop in this._currentKeyframe) { + properties.push(prop); + } + return properties; + } + + mergeTimelineCollectedStyles(timeline: TimelineBuilder) { + Object.keys(timeline._styleSummary).forEach(prop => { + const details0 = this._styleSummary[prop]; + const details1 = timeline._styleSummary[prop]; + if (!details0 || details1.time > details0.time) { + this._updateStyle(prop, details1.value); + } + }); + } + + buildKeyframes(): AnimationTimelineInstruction { + const finalKeyframes: StyleData[] = []; + // special case for when there are only start/destination + // styles but no actual animation animate steps... + if (this.time == 0) { + const targetKeyframe = this.getFinalKeyframe(); + + const firstKeyframe = copyStyles(targetKeyframe, true); + firstKeyframe['offset'] = 0; + finalKeyframes.push(firstKeyframe); + + const lastKeyframe = copyStyles(targetKeyframe, true); + lastKeyframe['offset'] = 1; + finalKeyframes.push(lastKeyframe); + } else { + this._keyframes.forEach((keyframe, time) => { + const finalKeyframe = copyStyles(keyframe, true); + finalKeyframe['offset'] = time / this.time; + finalKeyframes.push(finalKeyframe); + }); + } + + return createTimelineInstruction(finalKeyframes, this.time, this.startTime, this.easing); + } +} diff --git a/modules/@angular/animation/src/dsl/animation_transition_expr.ts b/modules/@angular/animation/src/dsl/animation_transition_expr.ts new file mode 100644 index 0000000000..de2fb7da11 --- /dev/null +++ b/modules/@angular/animation/src/dsl/animation_transition_expr.ts @@ -0,0 +1,64 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +export const ANY_STATE = '*'; +export declare type TransitionMatcherFn = (fromState: any, toState: any) => boolean; + +export function parseTransitionExpr( + transitionValue: string | TransitionMatcherFn, errors: string[]): TransitionMatcherFn[] { + const expressions: TransitionMatcherFn[] = []; + if (typeof transitionValue == 'string') { + (transitionValue) + .split(/\s*,\s*/) + .forEach(str => parseInnerTransitionStr(str, expressions, errors)); + } else { + expressions.push(transitionValue); + } + return expressions; +} + +function parseInnerTransitionStr( + eventStr: string, expressions: TransitionMatcherFn[], errors: string[]) { + if (eventStr[0] == ':') { + eventStr = parseAnimationAlias(eventStr, errors); + } + const match = eventStr.match(/^(\*|[-\w]+)\s*()\s*(\*|[-\w]+)$/); + if (match == null || match.length < 4) { + errors.push(`The provided transition expression "${eventStr}" is not supported`); + return expressions; + } + + const fromState = match[1]; + const separator = match[2]; + const toState = match[3]; + expressions.push(makeLambdaFromStates(fromState, toState)); + + const isFullAnyStateExpr = fromState == ANY_STATE && toState == ANY_STATE; + if (separator[0] == '<' && !isFullAnyStateExpr) { + expressions.push(makeLambdaFromStates(toState, fromState)); + } +} + +function parseAnimationAlias(alias: string, errors: string[]): string { + switch (alias) { + case ':enter': + return 'void => *'; + case ':leave': + return '* => void'; + default: + errors.push(`The transition alias value "${alias}" is not supported`); + return '* => *'; + } +} + +function makeLambdaFromStates(lhs: string, rhs: string): TransitionMatcherFn { + return (fromState: any, toState: any): boolean => { + const lhsMatch = lhs == ANY_STATE || lhs == fromState; + const rhsMatch = rhs == ANY_STATE || rhs == toState; + return lhsMatch && rhsMatch; + }; +} diff --git a/modules/@angular/animation/src/dsl/animation_transition_factory.ts b/modules/@angular/animation/src/dsl/animation_transition_factory.ts new file mode 100644 index 0000000000..5f0333950d --- /dev/null +++ b/modules/@angular/animation/src/dsl/animation_transition_factory.ts @@ -0,0 +1,43 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {TransitionFactory} from '@angular/core'; +import {StyleData} from '../common/style_data'; +import {AnimationMetadata, AnimationTransitionMetadata} from './animation_metadata'; +import {buildAnimationKeyframes} from './animation_timeline_visitor'; +import {TransitionMatcherFn} from './animation_transition_expr'; +import {AnimationTransitionInstruction, createTransitionInstruction} from './animation_transition_instruction'; + +export class AnimationTransitionFactory implements TransitionFactory { + private _animationAst: AnimationMetadata; + + constructor( + private _triggerName: string, ast: AnimationTransitionMetadata, + private matchFns: TransitionMatcherFn[], + private _stateStyles: {[stateName: string]: StyleData}) { + this._animationAst = ast.animation; + } + + match(currentState: any, nextState: any): AnimationTransitionInstruction { + if (!oneOrMoreTransitionsMatch(this.matchFns, currentState, nextState)) return; + + const backupStateStyles = this._stateStyles['*'] || {}; + const currentStateStyles = this._stateStyles[currentState] || backupStateStyles; + const nextStateStyles = this._stateStyles[nextState] || backupStateStyles; + + const timelines = + buildAnimationKeyframes(this._animationAst, currentStateStyles, nextStateStyles); + + return createTransitionInstruction( + this._triggerName, nextState === 'void', currentStateStyles, nextStateStyles, timelines); + } +} + +function oneOrMoreTransitionsMatch( + matchFns: TransitionMatcherFn[], currentState: any, nextState: any): boolean { + return matchFns.some(fn => fn(currentState, nextState)); +} diff --git a/modules/@angular/animation/src/dsl/animation_transition_instruction.ts b/modules/@angular/animation/src/dsl/animation_transition_instruction.ts new file mode 100644 index 0000000000..eaf2bd43ea --- /dev/null +++ b/modules/@angular/animation/src/dsl/animation_transition_instruction.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {StyleData} from '../common/style_data'; +import {AnimationEngineInstruction, AnimationTransitionInstructionType} from '../engine/animation_engine_instruction'; +import {AnimationTimelineInstruction} from './animation_timeline_instruction'; + +export interface AnimationTransitionInstruction extends AnimationEngineInstruction { + triggerName: string; + isRemovalTransition: boolean; + fromStyles: StyleData; + toStyles: StyleData; + timelines: AnimationTimelineInstruction[]; +} + +export function createTransitionInstruction( + triggerName: string, isRemovalTransition: boolean, fromStyles: StyleData, toStyles: StyleData, + timelines: AnimationTimelineInstruction[]): AnimationTransitionInstruction { + return { + type: AnimationTransitionInstructionType.TransitionAnimation, + triggerName, + isRemovalTransition, + fromStyles, + toStyles, + timelines + }; +} diff --git a/modules/@angular/animation/src/dsl/animation_trigger.ts b/modules/@angular/animation/src/dsl/animation_trigger.ts new file mode 100644 index 0000000000..68b15aa387 --- /dev/null +++ b/modules/@angular/animation/src/dsl/animation_trigger.ts @@ -0,0 +1,141 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {AnimationStyles, Trigger} from '@angular/core'; +import {StyleData} from '../common/style_data'; +import {copyStyles, normalizeStyles} from '../common/util'; +import {AnimationDslVisitor, visitAnimationNode} from './animation_dsl_visitor'; +import {AnimationAnimateMetadata, AnimationGroupMetadata, AnimationKeyframesSequenceMetadata, AnimationMetadata, AnimationSequenceMetadata, AnimationStateMetadata, AnimationStyleMetadata, AnimationTransitionMetadata} from './animation_metadata'; +import {parseTransitionExpr} from './animation_transition_expr'; +import {AnimationTransitionFactory} from './animation_transition_factory'; +import {AnimationTransitionInstruction} from './animation_transition_instruction'; +import {validateAnimationSequence} from './animation_validator_visitor'; + + +/** + * `trigger` is an animation-specific function that is designed to be used inside of Angular2's + animation DSL language. If this information is new, please navigate to the {@link + Component#animations-anchor component animations metadata page} to gain a better understanding of + how animations in Angular2 are used. + * + * `trigger` Creates an animation trigger which will a list of {@link state state} and {@link + transition transition} entries that will be evaluated when the expression bound to the trigger + changes. + * + * Triggers are registered within the component annotation data under the {@link + Component#animations-anchor animations section}. An animation trigger can be placed on an element + within a template by referencing the name of the trigger followed by the expression value that the + trigger is bound to (in the form of `[@triggerName]="expression"`. + * + * ### Usage + * + * `trigger` will create an animation trigger reference based on the provided `name` value. The + provided `animation` value is expected to be an array consisting of {@link state state} and {@link + transition transition} declarations. + * + * ```typescript + * @Component({ + * selector: 'my-component', + * templateUrl: 'my-component-tpl.html', + * animations: [ + * trigger("myAnimationTrigger", [ + * state(...), + * state(...), + * transition(...), + * transition(...) + * ]) + * ] + * }) + * class MyComponent { + * myStatusExp = "something"; + * } + * ``` + * + * The template associated with this component will make use of the `myAnimationTrigger` animation + trigger by binding to an element within its template code. + * + * ```html + * + *
...
+ tools/gulp-tasks/validate-commit-message.js ``` + * + * {@example core/animation/ts/dsl/animation_example.ts region='Component'} + * + * @experimental Animation support is experimental. + */ +export function trigger(name: string, definitions: AnimationMetadata[]): AnimationTrigger { + return new AnimationTriggerVisitor().buildTrigger(name, definitions); +} + +/** +* @experimental Animation support is experimental. +*/ +export class AnimationTrigger implements Trigger { + public transitionFactories: AnimationTransitionFactory[] = []; + public states: {[stateName: string]: StyleData} = {}; + + constructor( + public name: string, states: {[stateName: string]: StyleData}, + private _transitionAsts: AnimationTransitionMetadata[]) { + Object.keys(states).forEach( + stateName => { this.states[stateName] = copyStyles(states[stateName], false); }); + + const errors: string[] = []; + _transitionAsts.forEach(ast => { + const exprs = parseTransitionExpr(ast.expr, errors); + const sequenceErrors = validateAnimationSequence(ast); + if (sequenceErrors.length) { + errors.push(...sequenceErrors); + } else { + this.transitionFactories.push( + new AnimationTransitionFactory(this.name, ast, exprs, states)); + } + }); + + if (errors.length) { + const LINE_START = '\n - '; + throw new Error( + `Animation parsing for the ${name} trigger have failed:${LINE_START}${errors.join(LINE_START)}`); + } + } + + matchTransition(currentState: any, nextState: any): AnimationTransitionInstruction { + for (let i = 0; i < this.transitionFactories.length; i++) { + let result = this.transitionFactories[i].match(currentState, nextState); + if (result) return result; + } + return null; + } +} + +class AnimationTriggerContext { + public errors: string[] = []; + public states: {[stateName: string]: StyleData} = {}; + public transitions: AnimationTransitionMetadata[] = []; +} + +class AnimationTriggerVisitor implements AnimationDslVisitor { + buildTrigger(name: string, definitions: AnimationMetadata[]): AnimationTrigger { + const context = new AnimationTriggerContext(); + definitions.forEach(def => visitAnimationNode(this, def, context)); + return new AnimationTrigger(name, context.states, context.transitions); + } + + visitState(ast: AnimationStateMetadata, context: any): any { + context.states[ast.name] = normalizeStyles(new AnimationStyles(ast.styles.styles)); + } + + visitTransition(ast: AnimationTransitionMetadata, context: any): any { + context.transitions.push(ast); + } + + visitSequence(ast: AnimationSequenceMetadata, context: any) {} + visitGroup(ast: AnimationGroupMetadata, context: any) {} + visitAnimate(ast: AnimationAnimateMetadata, context: any) {} + visitStyle(ast: AnimationStyleMetadata, context: any) {} + visitKeyframeSequence(ast: AnimationKeyframesSequenceMetadata, context: any) {} +} diff --git a/modules/@angular/animation/src/dsl/animation_validator_visitor.ts b/modules/@angular/animation/src/dsl/animation_validator_visitor.ts new file mode 100644 index 0000000000..27e3dc34de --- /dev/null +++ b/modules/@angular/animation/src/dsl/animation_validator_visitor.ts @@ -0,0 +1,188 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {AnimationStyles} from '@angular/core'; +import {normalizeStyles, parseTimeExpression} from '../common/util'; +import {AnimationDslVisitor, visitAnimationNode} from './animation_dsl_visitor'; +import * as meta from './animation_metadata'; + +export type StyleTimeTuple = { + startTime: number; endTime: number; +}; + +/* + * [Validation] + * The visitor code below will traverse the animation AST generated by the animation verb functions + * (the output is a tree of objects) and attempt to perform a series of validations on the data. The + * following corner-cases will be validated: + * + * 1. Overlap of animations + * Given that a CSS property cannot be animated in more than one place at the same time, it's + * important that this behaviour is detected and validated. The way in which this occurs is that + * each time a style property is examined, a string-map containing the property will be updated with + * the start and end times for when the property is used within an animation step. + * + * If there are two or more parallel animations that are currently running (these are invoked by the + * group()) on the same element then the validator will throw an error. Since the start/end timing + * values are collected for each property then if the current animation step is animating the same + * property and its timing values fall anywhere into the window of time that the property is + * currently being animated within then this is what causes an error. + * + * 2. Timing values + * The validator will validate to see if a timing value of `duration delay easing` or + * `durationNumber` is valid or not. + * + * (note that upon validation the code below will replace the timing data with an object containing + * {duration,delay,easing}. + * + * 3. Offset Validation + * Each of the style() calls are allowed to have an offset value when placed inside of keyframes(). + * Offsets within keyframes() are considered valid when: + * + * - No offsets are used at all + * - Each style() entry contains an offset value + * - Each offset is between 0 and 1 + * - Each offset is greater to or equal than the previous one + * + * Otherwise an error will be thrown. + */ +export function validateAnimationSequence(ast: meta.AnimationMetadata) { + return new AnimationValidatorVisitor().validate(ast); +} + +export class AnimationValidatorVisitor implements AnimationDslVisitor { + validate(ast: meta.AnimationMetadata): string[] { + const context = new AnimationValidatorContext(); + visitAnimationNode(this, ast, context); + return context.errors; + } + + visitState(ast: meta.AnimationStateMetadata, context: any): any {} + + visitTransition(ast: meta.AnimationTransitionMetadata, context: any): any {} + + visitSequence(ast: meta.AnimationSequenceMetadata, context: AnimationValidatorContext): any { + ast.steps.forEach(step => visitAnimationNode(this, step, context)); + } + + visitGroup(ast: meta.AnimationGroupMetadata, context: AnimationValidatorContext): any { + const currentTime = context.currentTime; + let furthestTime = 0; + ast.steps.forEach(step => { + context.currentTime = currentTime; + visitAnimationNode(this, step, context); + furthestTime = Math.max(furthestTime, context.currentTime); + }); + context.currentTime = furthestTime; + } + + visitAnimate(ast: meta.AnimationAnimateMetadata, context: AnimationValidatorContext): any { + // we reassign the timings here so that they are not reparsed each + // time an animation occurs + context.currentAnimateTimings = ast.timings = + parseTimeExpression(ast.timings, context.errors); + + const astType = ast.styles && ast.styles.type; + if (astType == meta.AnimationMetadataType.KeyframeSequence) { + this.visitKeyframeSequence(ast.styles, context); + } else { + context.currentTime += + context.currentAnimateTimings.duration + context.currentAnimateTimings.delay; + if (astType == meta.AnimationMetadataType.Style) { + this.visitStyle(ast.styles, context); + } + } + + context.currentAnimateTimings = null; + } + + visitStyle(ast: meta.AnimationStyleMetadata, context: AnimationValidatorContext): any { + const styleData = normalizeStyles(new AnimationStyles(ast.styles)); + const timings = context.currentAnimateTimings; + let endTime = context.currentTime; + let startTime = context.currentTime; + if (timings && startTime > 0) { + startTime -= timings.duration + timings.delay; + } + Object.keys(styleData).forEach(prop => { + const collectedEntry = context.collectedStyles[prop]; + let updateCollectedStyle = true; + if (collectedEntry) { + if (startTime != endTime && startTime >= collectedEntry.startTime && + endTime <= collectedEntry.endTime) { + context.errors.push( + `The CSS property "${prop}" that exists between the times of "${collectedEntry.startTime}ms" and "${collectedEntry.endTime}ms" is also being animated in a parallel animation between the times of "${startTime}ms" and "${endTime}ms"`); + updateCollectedStyle = false; + } + + // we always choose the smaller start time value since we + // want to have a record of the entire animation window where + // the style property is being animated in between + startTime = collectedEntry.startTime; + } + if (updateCollectedStyle) { + context.collectedStyles[prop] = {startTime, endTime}; + } + }); + } + + visitKeyframeSequence( + ast: meta.AnimationKeyframesSequenceMetadata, context: AnimationValidatorContext): any { + let totalKeyframesWithOffsets = 0; + const offsets: number[] = []; + let offsetsOutOfOrder = false; + let keyframesOutOfRange = false; + let previousOffset: number = 0; + ast.steps.forEach(step => { + const styleData = normalizeStyles(new AnimationStyles(step.styles)); + let offset = 0; + if (styleData.hasOwnProperty('offset')) { + totalKeyframesWithOffsets++; + offset = styleData['offset']; + } + keyframesOutOfRange = keyframesOutOfRange || offset < 0 || offset > 1; + offsetsOutOfOrder = offsetsOutOfOrder || offset < previousOffset; + previousOffset = offset; + offsets.push(offset); + }); + + if (keyframesOutOfRange) { + context.errors.push(`Please ensure that all keyframe offsets are between 0 and 1`); + } + + if (offsetsOutOfOrder) { + context.errors.push(`Please ensure that all keyframe offsets are in order`); + } + + const length = ast.steps.length; + let generatedOffset = 0; + if (totalKeyframesWithOffsets > 0 && totalKeyframesWithOffsets < length) { + context.errors.push(`Not all style() steps within the declared keyframes() contain offsets`); + } else if (totalKeyframesWithOffsets == 0) { + generatedOffset = 1 / length; + } + + const limit = length - 1; + const currentTime = context.currentTime; + const animateDuration = context.currentAnimateTimings.duration; + ast.steps.forEach((step, i) => { + const offset = generatedOffset > 0 ? (i == limit ? 1 : (generatedOffset * i)) : offsets[i]; + const durationUpToThisFrame = offset * animateDuration; + context.currentTime = + currentTime + context.currentAnimateTimings.delay + durationUpToThisFrame; + context.currentAnimateTimings.duration = durationUpToThisFrame; + this.visitStyle(step, context); + }); + } +} + +export class AnimationValidatorContext { + public errors: string[] = []; + public currentTime: number = 0; + public currentAnimateTimings: meta.AnimateTimings; + public collectedStyles: {[propName: string]: StyleTimeTuple} = {}; +} diff --git a/modules/@angular/animation/src/dsl/style_normalization/animation_style_normalizer.ts b/modules/@angular/animation/src/dsl/style_normalization/animation_style_normalizer.ts new file mode 100644 index 0000000000..1859e4c344 --- /dev/null +++ b/modules/@angular/animation/src/dsl/style_normalization/animation_style_normalizer.ts @@ -0,0 +1,23 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +export abstract class AnimationStyleNormalizer { + abstract normalizePropertyName(propertyName: string, errors: string[]): string; + abstract normalizeStyleValue( + userProvidedProperty: string, normalizedProperty: string, value: string|number, + errors: string[]): string; +} + +export class NoOpAnimationStyleNormalizer { + normalizePropertyName(propertyName: string, errors: string[]): string { return propertyName; } + + normalizeStyleValue( + userProvidedProperty: string, normalizedProperty: string, value: string|number, + errors: string[]): string { + return value; + } +} diff --git a/modules/@angular/animation/src/dsl/style_normalization/web_animations_style_normalizer.ts b/modules/@angular/animation/src/dsl/style_normalization/web_animations_style_normalizer.ts new file mode 100644 index 0000000000..89e59f7db2 --- /dev/null +++ b/modules/@angular/animation/src/dsl/style_normalization/web_animations_style_normalizer.ts @@ -0,0 +1,48 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {AnimationStyleNormalizer} from './animation_style_normalizer'; + +export class WebAnimationsStyleNormalizer extends AnimationStyleNormalizer { + normalizePropertyName(propertyName: string, errors: string[]): string { + return dashCaseToCamelCase(propertyName); + } + + normalizeStyleValue( + userProvidedProperty: string, normalizedProperty: string, value: string|number, + errors: string[]): string { + let unit: string = ''; + const strVal = value.toString().trim(); + + if (DIMENSIONAL_PROP_MAP[normalizedProperty] && value !== 0 && value !== '0') { + if (typeof value === 'number') { + unit = 'px'; + } else { + const valAndSuffixMatch = value.match(/^[+-]?[\d\.]+([a-z]*)$/); + if (valAndSuffixMatch && valAndSuffixMatch[1].length == 0) { + errors.push(`Please provide a CSS unit value for ${userProvidedProperty}:${value}`); + } + } + } + return strVal + unit; + } +} + +const DIMENSIONAL_PROP_MAP = makeBooleanMap( + 'width,height,minWidth,minHeight,maxWidth,maxHeight,left,top,bottom,right,fontSize,outlineWidth,outlineOffset,paddingTop,paddingLeft,paddingBottom,paddingRight,marginTop,marginLeft,marginBottom,marginRight,borderRadius,borderWidth,borderTopWidth,borderLeftWidth,borderRightWidth,borderBottomWidth,textIndent' + .split(',')); + +function makeBooleanMap(keys: string[]): {[key: string]: boolean} { + const map: {[key: string]: boolean} = {}; + keys.forEach(key => map[key] = true); + return map; +} + +const DASH_CASE_REGEXP = /-+([a-z0-9])/g; +export function dashCaseToCamelCase(input: string): string { + return input.replace(DASH_CASE_REGEXP, (...m: any[]) => m[1].toUpperCase()); +} diff --git a/modules/@angular/animation/src/engine/animation_driver.ts b/modules/@angular/animation/src/engine/animation_driver.ts new file mode 100644 index 0000000000..b851ee4bf6 --- /dev/null +++ b/modules/@angular/animation/src/engine/animation_driver.ts @@ -0,0 +1,32 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {AnimationPlayer} from '@angular/core'; +import {StyleData} from '../common/style_data'; +import {NoOpAnimationPlayer} from '../private_import_core'; + +/** + * @experimental + */ +export class NoOpAnimationDriver implements AnimationDriver { + animate( + element: any, keyframes: StyleData[], duration: number, delay: number, easing: string, + previousPlayers: AnimationPlayer[] = []): AnimationPlayer { + return new NoOpAnimationPlayer(); + } +} + +/** + * @experimental + */ +export abstract class AnimationDriver { + static NOOP: AnimationDriver = new NoOpAnimationDriver(); + abstract animate( + element: any, keyframes: StyleData[], duration: number, delay: number, easing: string, + previousPlayers?: AnimationPlayer[]): AnimationPlayer; +} diff --git a/modules/@angular/animation/src/engine/animation_engine_instruction.ts b/modules/@angular/animation/src/engine/animation_engine_instruction.ts new file mode 100644 index 0000000000..eb28ee3245 --- /dev/null +++ b/modules/@angular/animation/src/engine/animation_engine_instruction.ts @@ -0,0 +1,14 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {TransitionInstruction} from '@angular/core'; + +export const enum AnimationTransitionInstructionType {TransitionAnimation, TimelineAnimation} + +export interface AnimationEngineInstruction extends TransitionInstruction { + type: AnimationTransitionInstructionType; +} diff --git a/modules/@angular/animation/src/engine/dom_animation_transition_engine.ts b/modules/@angular/animation/src/engine/dom_animation_transition_engine.ts new file mode 100644 index 0000000000..cce6a46a50 --- /dev/null +++ b/modules/@angular/animation/src/engine/dom_animation_transition_engine.ts @@ -0,0 +1,236 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {AnimationPlayer, Injectable} from '@angular/core'; +import {StyleData} from '../common/style_data'; +import {AnimationTimelineInstruction} from '../dsl/animation_timeline_instruction'; +import {AnimationTransitionInstruction} from '../dsl/animation_transition_instruction'; +import {AnimationStyleNormalizer} from '../dsl/style_normalization/animation_style_normalizer'; +import {AnimationGroupPlayer, NoOpAnimationPlayer, TransitionEngine} from '../private_import_core'; + +import {AnimationDriver} from './animation_driver'; +import {AnimationEngineInstruction, AnimationTransitionInstructionType} from './animation_engine_instruction'; + +export declare type AnimationPlayerTuple = { + element: any; player: AnimationPlayer; +}; + +@Injectable() +export class DomAnimationTransitionEngine extends TransitionEngine { + private _flaggedInserts = new Set(); + private _queuedRemovals: any[] = []; + private _queuedAnimations: AnimationPlayerTuple[] = []; + private _activeElementAnimations = new Map(); + private _activeTransitionAnimations = new Map(); + + constructor(private _driver: AnimationDriver, private _normalizer: AnimationStyleNormalizer) { + super(); + } + + insertNode(container: any, element: any) { + container.appendChild(element); + this._flaggedInserts.add(element); + } + + removeNode(element: any) { this._queuedRemovals.push(element); } + + process(element: any, instructions: AnimationEngineInstruction[]): AnimationPlayer { + const players = instructions.map(instruction => { + if (instruction.type == AnimationTransitionInstructionType.TransitionAnimation) { + return this._handleTransitionAnimation( + element, instruction); + } + if (instruction.type == AnimationTransitionInstructionType.TimelineAnimation) { + return this._handleTimelineAnimation( + element, instruction, []); + } + return new NoOpAnimationPlayer(); + }); + return optimizeGroupPlayer(players); + } + + private _handleTransitionAnimation(element: any, instruction: AnimationTransitionInstruction): + AnimationPlayer { + const triggerName = instruction.triggerName; + const elmTransitionMap = getOrSetAsInMap(this._activeTransitionAnimations, element, {}); + + let previousPlayers: AnimationPlayer[]; + if (instruction.isRemovalTransition) { + // we make a copy of the array because the actual source array is modified + // each time a player is finished/destroyed (the forEach loop would fail otherwise) + previousPlayers = copyArray(this._activeElementAnimations.get(element)); + } else { + previousPlayers = []; + const existingPlayer = elmTransitionMap[triggerName]; + if (existingPlayer) { + previousPlayers.push(existingPlayer); + } + } + + // it's important to do this step before destroying the players + // so that the onDone callback below won't fire before this + eraseStyles(element, instruction.fromStyles); + + // we first run this so that the previous animation player + // data can be passed into the successive animation players + const players = instruction.timelines.map( + timelineInstruction => this._buildPlayer(element, timelineInstruction, previousPlayers)); + + previousPlayers.forEach(previousPlayer => previousPlayer.destroy()); + + const player = optimizeGroupPlayer(players); + player.onDone(() => { + player.destroy(); + const elmTransitionMap = this._activeTransitionAnimations.get(element); + if (elmTransitionMap) { + delete elmTransitionMap[triggerName]; + if (Object.keys(elmTransitionMap).length == 0) { + this._activeTransitionAnimations.delete(element); + } + } + deleteFromArrayMap(this._activeElementAnimations, element, player); + setStyles(element, instruction.toStyles); + }); + + this._queuePlayer(element, player); + elmTransitionMap[triggerName] = player; + + return player; + } + + private _handleTimelineAnimation( + element: any, instruction: AnimationTimelineInstruction, + previousPlayers: AnimationPlayer[]): AnimationPlayer { + const player = this._buildPlayer(element, instruction, previousPlayers); + player.onDestroy(() => { deleteFromArrayMap(this._activeElementAnimations, element, player); }); + this._queuePlayer(element, player); + return player; + } + + private _buildPlayer( + element: any, instruction: AnimationTimelineInstruction, + previousPlayers: AnimationPlayer[]): AnimationPlayer { + return this._driver.animate( + element, this._normalizeKeyframes(instruction.keyframes), instruction.duration, + instruction.delay, instruction.easing, previousPlayers); + } + + private _normalizeKeyframes(keyframes: StyleData[]): StyleData[] { + const errors: string[] = []; + const normalizedKeyframes: StyleData[] = []; + keyframes.forEach(kf => { + const normalizedKeyframe: StyleData = {}; + Object.keys(kf).forEach(prop => { + let normalizedProp = prop; + let normalizedValue = kf[prop]; + if (prop != 'offset') { + normalizedProp = this._normalizer.normalizePropertyName(prop, errors); + normalizedValue = + this._normalizer.normalizeStyleValue(prop, normalizedProp, kf[prop], errors); + } + normalizedKeyframe[normalizedProp] = normalizedValue; + }); + normalizedKeyframes.push(normalizedKeyframe); + }); + if (errors.length) { + const LINE_START = '\n - '; + throw new Error( + `Unable to animate due to the following errors:${LINE_START}${errors.join(LINE_START)}`); + } + return normalizedKeyframes; + } + + private _queuePlayer(element: any, player: AnimationPlayer) { + const tuple = {element, player}; + this._queuedAnimations.push(tuple); + player.init(); + + const elementAnimations = getOrSetAsInMap(this._activeElementAnimations, element, []); + elementAnimations.push(player); + } + + triggerAnimations() { + while (this._queuedAnimations.length) { + const {player, element} = this._queuedAnimations.shift(); + // in the event that an animation throws an error then we do + // not want to re-run animations on any previous animations + // if they have already been kicked off beforehand + if (!player.hasStarted()) { + player.play(); + } + } + + this._queuedRemovals.forEach(element => { + if (this._flaggedInserts.has(element)) return; + + let parent = element; + let players: AnimationPlayer[]; + while (parent = parent.parentNode) { + const match = this._activeElementAnimations.get(parent); + if (match) { + players = match; + break; + } + } + if (players) { + optimizeGroupPlayer(players).onDone(() => remove(element)); + } else { + if (element.parentNode) { + remove(element); + } + } + }); + + this._queuedRemovals = []; + this._flaggedInserts.clear(); + } +} + +function getOrSetAsInMap(map: Map, key: any, defaultValue: any) { + let value = map.get(key); + if (!value) { + map.set(key, value = defaultValue); + } + return value; +} + +function deleteFromArrayMap(map: Map, key: any, value: any) { + let arr = map.get(key); + if (arr) { + const index = arr.indexOf(value); + if (index >= 0) { + arr.splice(index, 1); + if (arr.length == 0) { + map.delete(key); + } + } + } +} + +function setStyles(element: any, styles: StyleData) { + Object.keys(styles).forEach(prop => { element.style[prop] = styles[prop]; }); +} + +function eraseStyles(element: any, styles: StyleData) { + Object.keys(styles).forEach(prop => { + // IE requires '' instead of null + // see https://github.com/angular/angular/issues/7916 + element.style[prop] = ''; + }); +} + +function optimizeGroupPlayer(players: AnimationPlayer[]): AnimationPlayer { + return players.length == 1 ? players[0] : new AnimationGroupPlayer(players); +} + +function copyArray(source: any[]): any[] { + return source ? source.splice(0) : []; +} + +function remove(element: any) { + element.parentNode.removeChild(element); +} diff --git a/modules/@angular/animation/src/engine/web_animations/dom_animation.ts b/modules/@angular/animation/src/engine/web_animations/dom_animation.ts new file mode 100644 index 0000000000..3004695a9e --- /dev/null +++ b/modules/@angular/animation/src/engine/web_animations/dom_animation.ts @@ -0,0 +1,19 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +export interface DOMAnimation { + cancel(): void; + play(): void; + pause(): void; + finish(): void; + onfinish: Function; + position: number; + currentTime: number; + addEventListener(eventName: string, handler: (event: any) => any): any; + dispatchEvent(eventName: string): any; +} diff --git a/modules/@angular/animation/src/engine/web_animations/web_animations_driver.ts b/modules/@angular/animation/src/engine/web_animations/web_animations_driver.ts new file mode 100644 index 0000000000..5f06ab49d9 --- /dev/null +++ b/modules/@angular/animation/src/engine/web_animations/web_animations_driver.ts @@ -0,0 +1,36 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {AnimationPlayer} from '@angular/core'; + +import {StyleData} from '../../common/style_data'; +import {AnimationDriver} from '../animation_driver'; + +import {WebAnimationsPlayer} from './web_animations_player'; + +export class WebAnimationsDriver implements AnimationDriver { + animate( + element: any, keyframes: StyleData[], duration: number, delay: number, easing: string, + previousPlayers: AnimationPlayer[] = []): WebAnimationsPlayer { + const playerOptions: {[key: string]: string | + number} = {'duration': duration, 'delay': delay, 'fill': 'forwards'}; + + // we check for this to avoid having a null|undefined value be present + // for the easing (which results in an error for certain browsers #9752) + if (easing) { + playerOptions['easing'] = easing; + } + + const previousWebAnimationPlayers = previousPlayers.filter( + player => { return player instanceof WebAnimationsPlayer; }); + return new WebAnimationsPlayer(element, keyframes, playerOptions, previousWebAnimationPlayers); + } +} + +export function supportsWebAnimations() { + return typeof Element !== 'undefined' && typeof(Element).prototype['animate'] === 'function'; +} diff --git a/modules/@angular/animation/src/engine/web_animations/web_animations_player.ts b/modules/@angular/animation/src/engine/web_animations/web_animations_player.ts new file mode 100644 index 0000000000..9c6fb63f13 --- /dev/null +++ b/modules/@angular/animation/src/engine/web_animations/web_animations_player.ts @@ -0,0 +1,194 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {AUTO_STYLE, AnimationPlayer} from '@angular/core'; + +import {DOMAnimation} from './dom_animation'; + +export class WebAnimationsPlayer implements AnimationPlayer { + private _onDoneFns: Function[] = []; + private _onStartFns: Function[] = []; + private _onDestroyFns: Function[] = []; + private _player: DOMAnimation; + private _duration: number; + private _initialized = false; + private _finished = false; + private _started = false; + private _destroyed = false; + private _finalKeyframe: {[key: string]: string | number}; + + public parentPlayer: AnimationPlayer = null; + public previousStyles: {[styleName: string]: string | number}; + + constructor( + public element: any, public keyframes: {[key: string]: string | number}[], + public options: {[key: string]: string | number}, + previousPlayers: WebAnimationsPlayer[] = []) { + this._duration = options['duration']; + + this.previousStyles = {}; + previousPlayers.forEach(player => { + let styles = player._captureStyles(); + Object.keys(styles).forEach(prop => this.previousStyles[prop] = styles[prop]); + }); + } + + private _onFinish() { + if (!this._finished) { + this._finished = true; + this._onDoneFns.forEach(fn => fn()); + this._onDoneFns = []; + } + } + + init(): void { + if (this._initialized) return; + this._initialized = true; + + const keyframes = this.keyframes.map(styles => { + const formattedKeyframe: {[key: string]: string | number} = {}; + Object.keys(styles).forEach((prop, index) => { + let value = styles[prop]; + if (value == AUTO_STYLE) { + value = _computeStyle(this.element, prop); + } + if (value != undefined) { + formattedKeyframe[prop] = value; + } + }); + return formattedKeyframe; + }); + + const previousStyleProps = Object.keys(this.previousStyles); + if (previousStyleProps.length) { + let startingKeyframe = keyframes[0]; + let missingStyleProps: string[] = []; + previousStyleProps.forEach(prop => { + if (startingKeyframe[prop] != null) { + missingStyleProps.push(prop); + } + startingKeyframe[prop] = this.previousStyles[prop]; + }); + + if (missingStyleProps.length) { + for (let i = 1; i < keyframes.length; i++) { + let kf = keyframes[i]; + missingStyleProps.forEach(prop => { kf[prop] = _computeStyle(this.element, prop); }); + } + } + } + + this._player = this._triggerWebAnimation(this.element, keyframes, this.options); + this._finalKeyframe = _copyKeyframeStyles(keyframes[keyframes.length - 1]); + + // this is required so that the player doesn't start to animate right away + this._resetDomPlayerState(); + this._player.addEventListener('finish', () => this._onFinish()); + } + + /** @internal */ + _triggerWebAnimation(element: any, keyframes: any[], options: any): DOMAnimation { + // jscompiler doesn't seem to know animate is a native property because it's not fully + // supported yet across common browsers (we polyfill it for Edge/Safari) [CL #143630929] + return element['animate'](keyframes, options); + } + + get domPlayer() { return this._player; } + + onStart(fn: () => void): void { this._onStartFns.push(fn); } + + onDone(fn: () => void): void { this._onDoneFns.push(fn); } + + onDestroy(fn: () => void): void { this._onDestroyFns.push(fn); } + + play(): void { + this.init(); + if (!this.hasStarted()) { + this._onStartFns.forEach(fn => fn()); + this._onStartFns = []; + this._started = true; + } + this._player.play(); + } + + pause(): void { + this.init(); + this._player.pause(); + } + + finish(): void { + this.init(); + this._onFinish(); + this._player.finish(); + } + + reset(): void { + this._resetDomPlayerState(); + this._destroyed = false; + this._finished = false; + this._started = false; + } + + private _resetDomPlayerState() { + if (this._player) { + this._player.cancel(); + } + } + + restart(): void { + this.reset(); + this.play(); + } + + hasStarted(): boolean { return this._started; } + + destroy(): void { + if (!this._destroyed) { + this._resetDomPlayerState(); + this._onFinish(); + this._destroyed = true; + this._onDestroyFns.forEach(fn => fn()); + this._onDestroyFns = []; + } + } + + get totalTime(): number { return this._duration; } + + setPosition(p: number): void { this._player.currentTime = p * this.totalTime; } + + getPosition(): number { return this._player.currentTime / this.totalTime; } + + private _captureStyles(): {[prop: string]: string | number} { + const styles: {[key: string]: string | number} = {}; + if (this.hasStarted()) { + Object.keys(this._finalKeyframe).forEach(prop => { + if (prop != 'offset') { + styles[prop] = + this._finished ? this._finalKeyframe[prop] : _computeStyle(this.element, prop); + } + }); + } + + return styles; + } +} + +function _computeStyle(element: any, prop: string): string { + return (window.getComputedStyle(element))[prop]; +} + +function _copyKeyframeStyles(styles: {[style: string]: string | number}): + {[style: string]: string | number} { + const newStyles: {[style: string]: string | number} = {}; + Object.keys(styles).forEach(prop => { + if (prop != 'offset') { + newStyles[prop] = styles[prop]; + } + }); + return newStyles; +} diff --git a/modules/@angular/animation/src/private_import_core.ts b/modules/@angular/animation/src/private_import_core.ts new file mode 100644 index 0000000000..df77b22d85 --- /dev/null +++ b/modules/@angular/animation/src/private_import_core.ts @@ -0,0 +1,13 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {__core_private__ as r} from '@angular/core'; + +export const AnimationGroupPlayer: typeof r.AnimationGroupPlayer = r.AnimationGroupPlayer; +export const NoOpAnimationPlayer: typeof r.NoOpAnimationPlayer = r.NoOpAnimationPlayer; +export const TransitionEngine: typeof r.TransitionEngine = r.TransitionEngine; diff --git a/modules/@angular/animation/src/version.ts b/modules/@angular/animation/src/version.ts new file mode 100644 index 0000000000..1964425208 --- /dev/null +++ b/modules/@angular/animation/src/version.ts @@ -0,0 +1,19 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +/** + * @module + * @description + * Entry point for all public APIs of the animation package. + */ + +import {Version} from '@angular/core'; +/** + * @stable + */ +export const VERSION = new Version('0.0.0-PLACEHOLDER'); diff --git a/modules/@angular/animation/test/animation_engine/dom_animation_transition_engine_spec.ts b/modules/@angular/animation/test/animation_engine/dom_animation_transition_engine_spec.ts new file mode 100644 index 0000000000..35f2d6a12f --- /dev/null +++ b/modules/@angular/animation/test/animation_engine/dom_animation_transition_engine_spec.ts @@ -0,0 +1,469 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {el} from '@angular/platform-browser/testing/browser_util'; +import {animate, keyframes, state, style, transition} from '../../src/dsl/animation_metadata'; +import {buildAnimationKeyframes} from '../../src/dsl/animation_timeline_visitor'; +import {trigger} from '../../src/dsl/animation_trigger'; +import {AnimationStyleNormalizer, NoOpAnimationStyleNormalizer} from '../../src/dsl/style_normalization/animation_style_normalizer'; +import {AnimationEngineInstruction} from '../../src/engine/animation_engine_instruction'; +import {DomAnimationTransitionEngine} from '../../src/engine/dom_animation_transition_engine'; +import {NoOpAnimationPlayer} from '../../src/private_import_core'; +import {MockAnimationDriver, MockAnimationPlayer} from '../../testing/mock_animation_driver'; + +export function main() { + const driver = new MockAnimationDriver(); + + // these tests are only mean't to be run within the DOM + if (typeof Element == 'undefined') return; + + describe('AnimationEngine', () => { + let element: any; + + beforeEach(() => { + MockAnimationDriver.log = []; + element = el('
'); + }); + + function makeEngine(normalizer: AnimationStyleNormalizer = null) { + return new DomAnimationTransitionEngine( + driver, normalizer || new NoOpAnimationStyleNormalizer()); + } + + describe('instructions', () => { + it('should animate a transition instruction', () => { + const engine = makeEngine(); + + const trig = trigger('something', [ + state('on', style({height: 100})), state('off', style({height: 0})), + transition('on => off', animate(9876)) + ]); + + const instruction = trig.matchTransition('on', 'off'); + + expect(MockAnimationDriver.log.length).toEqual(0); + engine.process(element, [instruction]); + expect(MockAnimationDriver.log.length).toEqual(1); + }); + + it('should animate a timeline instruction', () => { + const engine = makeEngine(); + + const timelines = + buildAnimationKeyframes([style({height: 100}), animate(1000, style({height: 0}))]); + + const instruction = timelines[0]; + expect(MockAnimationDriver.log.length).toEqual(0); + engine.process(element, [instruction]); + expect(MockAnimationDriver.log.length).toEqual(1); + }); + + it('should animate an array of animation instructions', () => { + const engine = makeEngine(); + + const instructions = buildAnimationKeyframes([ + style({height: 100}), animate(1000, style({height: 0})), + animate(1000, keyframes([style({width: 0}), style({width: 1000})])) + ]); + + expect(MockAnimationDriver.log.length).toEqual(0); + engine.process(element, instructions); + expect(MockAnimationDriver.log.length).toBeGreaterThan(0); + }); + + it('should return a noOp player when an unsupported instruction is provided', () => { + const engine = makeEngine(); + const instruction = {type: -1}; + expect(MockAnimationDriver.log.length).toEqual(0); + const player = engine.process(element, [instruction]); + expect(MockAnimationDriver.log.length).toEqual(0); + expect(player instanceof NoOpAnimationPlayer).toBeTruthy(); + }); + }); + + describe('transition operations', () => { + it('should persist the styles on the element as actual styles once the animation is complete', + () => { + const engine = makeEngine(); + const trig = trigger('something', [ + state('on', style({height: '100px'})), state('off', style({height: '0px'})), + transition('on => off', animate(9876)) + ]); + + const instruction = trig.matchTransition('on', 'off'); + const player = engine.process(element, [instruction]); + + expect(element.style.height).not.toEqual('0px'); + player.finish(); + expect(element.style.height).toEqual('0px'); + }); + + it('should remove all existing state styling from an element when a follow-up transition occurs on the same trigger', + () => { + const engine = makeEngine(); + const trig = trigger('something', [ + state('a', style({height: '100px'})), state('b', style({height: '500px'})), + state('c', style({width: '200px'})), transition('* => *', animate(9876)) + ]); + + const instruction1 = trig.matchTransition('a', 'b'); + const player1 = engine.process(element, [instruction1]); + + player1.finish(); + expect(element.style.height).toEqual('500px'); + + const instruction2 = trig.matchTransition('b', 'c'); + const player2 = engine.process(element, [instruction2]); + + expect(element.style.height).not.toEqual('500px'); + player2.finish(); + expect(element.style.width).toEqual('200px'); + expect(element.style.height).not.toEqual('500px'); + }); + + it('should allow two animation transitions with different triggers to animate in parallel', + () => { + const engine = makeEngine(); + const trig1 = trigger('something1', [ + state('a', style({width: '100px'})), state('b', style({width: '200px'})), + transition('* => *', animate(1000)) + ]); + + const trig2 = trigger('something2', [ + state('x', style({height: '500px'})), state('y', style({height: '1000px'})), + transition('* => *', animate(2000)) + ]); + + let doneCount = 0; + function doneCallback() { doneCount++; } + + const instruction1 = trig1.matchTransition('a', 'b'); + const instruction2 = trig2.matchTransition('x', 'y'); + const player1 = engine.process(element, [instruction1]); + player1.onDone(doneCallback); + expect(doneCount).toEqual(0); + + const player2 = engine.process(element, [instruction2]); + player2.onDone(doneCallback); + expect(doneCount).toEqual(0); + + player1.finish(); + expect(doneCount).toEqual(1); + + player2.finish(); + expect(doneCount).toEqual(2); + + expect(element.style.width).toEqual('200px'); + expect(element.style.height).toEqual('1000px'); + }); + + it('should cancel a previously running animation when a follow-up transition kicks off on the same trigger', + () => { + const engine = makeEngine(); + const trig = trigger('something', [ + state('x', style({opacity: 0})), state('y', style({opacity: .5})), + state('z', style({opacity: 1})), transition('* => *', animate(1000)) + ]); + + const instruction1 = trig.matchTransition('x', 'y'); + const instruction2 = trig.matchTransition('y', 'z'); + + expect(parseFloat(element.style.opacity)).not.toEqual(.5); + + const player1 = engine.process(element, [instruction1]); + const player2 = engine.process(element, [instruction2]); + + expect(parseFloat(element.style.opacity)).toEqual(.5); + + player2.finish(); + expect(parseFloat(element.style.opacity)).toEqual(1); + + player1.finish(); + expect(parseFloat(element.style.opacity)).toEqual(1); + }); + + it('should pass in the previously running players into the follow-up transition player when cancelled', + () => { + const engine = makeEngine(); + const trig = trigger('something', [ + state('x', style({opacity: 0})), state('y', style({opacity: .5})), + state('z', style({opacity: 1})), transition('* => *', animate(1000)) + ]); + + const instruction1 = trig.matchTransition('x', 'y'); + const instruction2 = trig.matchTransition('y', 'z'); + const instruction3 = trig.matchTransition('z', 'x'); + + const player1 = engine.process(element, [instruction1]); + engine.triggerAnimations(); + player1.setPosition(0.5); + + const player2 = engine.process(element, [instruction2]); + expect(player2.previousPlayers).toEqual([player1]); + player2.finish(); + + const player3 = engine.process(element, [instruction3]); + expect(player3.previousPlayers).toEqual([]); + }); + + it('should cancel all existing players if a removal animation is set to occur', () => { + const engine = makeEngine(); + const trig = trigger('something', [ + state('m', style({opacity: 0})), state('n', style({opacity: 1})), + transition('* => *', animate(1000)) + ]); + + let doneCount = 0; + function doneCallback() { doneCount++; } + + const instruction1 = trig.matchTransition('m', 'n'); + const instructions2 = + buildAnimationKeyframes([style({height: 0}), animate(1000, style({height: 100}))]); + const instruction3 = trig.matchTransition('n', 'void'); + + const player1 = engine.process(element, [instruction1]); + player1.onDone(doneCallback); + + const player2 = engine.process(element, instructions2); + player2.onDone(doneCallback); + + expect(doneCount).toEqual(0); + + const player3 = engine.process(element, [instruction3]); + expect(doneCount).toEqual(2); + }); + + it('should only persist styles that exist in the final state styles and not the last keyframe', + () => { + const engine = makeEngine(); + const trig = trigger('something', [ + state('0', style({width: '0px'})), state('1', style({width: '100px'})), + transition('* => *', [animate(1000, style({height: '200px'}))]) + ]); + + const instruction = trig.matchTransition('0', '1'); + const player = engine.process(element, [instruction]); + expect(element.style.width).not.toEqual('100px'); + + player.finish(); + expect(element.style.height).not.toEqual('200px'); + expect(element.style.width).toEqual('100px'); + }); + + it('should default to using styling from the `*` state if a matching state is not found', + () => { + const engine = makeEngine(); + const trig = trigger('something', [ + state('a', style({opacity: 0})), state('*', style({opacity: .5})), + transition('* => *', animate(1000)) + ]); + + const instruction = trig.matchTransition('a', 'z'); + engine.process(element, [instruction]).finish(); + + expect(parseFloat(element.style.opacity)).toEqual(.5); + }); + + it('should treat `void` as `void`', () => { + const engine = makeEngine(); + const trig = trigger('something', [ + state('a', style({opacity: 0})), state('void', style({opacity: .8})), + transition('* => *', animate(1000)) + ]); + + const instruction = trig.matchTransition('a', 'void'); + engine.process(element, [instruction]).finish(); + + expect(parseFloat(element.style.opacity)).toEqual(.8); + }); + }); + + describe('timeline operations', () => { + it('should not destroy timeline-based animations after they have finished', () => { + const engine = makeEngine(); + + const log: string[] = []; + function capture(value: string) { + return () => { log.push(value); }; + } + + const instructions = + buildAnimationKeyframes([style({height: 0}), animate(1000, style({height: 500}))]); + + const player = engine.process(element, instructions); + player.onDone(capture('done')); + player.onDestroy(capture('destroy')); + expect(log).toEqual([]); + + player.finish(); + expect(log).toEqual(['done']); + + player.destroy(); + expect(log).toEqual(['done', 'destroy']); + }); + }); + + describe('style normalizer', () => { + it('should normalize the style values that are processed within an a transition animation', + () => { + const engine = makeEngine(new SuffixNormalizer('-normalized')); + + const trig = trigger('something', [ + state('on', style({height: 100})), state('off', style({height: 0})), + transition('on => off', animate(9876)) + ]); + + const instruction = trig.matchTransition('on', 'off'); + const player = engine.process(element, [instruction]); + + expect(player.keyframes).toEqual([ + {'height-normalized': '100-normalized', offset: 0}, + {'height-normalized': '0-normalized', offset: 1} + ]); + }); + + it('should normalize the style values that are processed within an a timeline animation', + () => { + const engine = makeEngine(new SuffixNormalizer('-normalized')); + + const instructions = buildAnimationKeyframes([ + style({width: '333px'}), + animate(1000, style({width: '999px'})), + ]); + + const player = engine.process(element, instructions); + expect(player.keyframes).toEqual([ + {'width-normalized': '333px-normalized', offset: 0}, + {'width-normalized': '999px-normalized', offset: 1} + ]); + }); + + it('should throw an error when normalization fails within a transition animation', () => { + const engine = makeEngine(new ExactCssValueNormalizer({left: '100px'})); + + const trig = trigger('something', [ + state('a', style({left: '0px', width: '200px'})), + state('b', style({left: '100px', width: '100px'})), transition('a => b', animate(9876)) + ]); + + const instruction = trig.matchTransition('a', 'b'); + + let errorMessage = ''; + try { + engine.process(element, [instruction]); + } catch (e) { + errorMessage = e.toString(); + } + + expect(errorMessage).toMatch(/Unable to animate due to the following errors:/); + expect(errorMessage).toMatch(/- The CSS property `left` is not allowed to be `0px`/); + expect(errorMessage).toMatch(/- The CSS property `width` is not allowed/); + }); + }); + + describe('view operations', () => { + it('should perform insert operations immediately ', () => { + const engine = makeEngine(); + + let container = el('
'); + let child1 = el('
'); + let child2 = el('
'); + + engine.insertNode(container, child1); + engine.insertNode(container, child2); + + expect(container.contains(child1)).toBe(true); + expect(container.contains(child2)).toBe(true); + }); + + it('should queue up all `remove` DOM operations until all animations are complete', () => { + let container = el('
'); + let targetContainer = el('
'); + let otherContainer = el('
'); + let child1 = el('
'); + let child2 = el('
'); + container.appendChild(targetContainer); + container.appendChild(otherContainer); + targetContainer.appendChild(child1); + targetContainer.appendChild(child2); + + /*----------------* + container + / \ + target other + / \ + c1 c2 + *----------------*/ + + expect(container.contains(otherContainer)).toBe(true); + + const engine = makeEngine(); + engine.removeNode(child1); + engine.removeNode(child2); + engine.removeNode(otherContainer); + + expect(container.contains(child1)).toBe(true); + expect(container.contains(child2)).toBe(true); + expect(container.contains(otherContainer)).toBe(true); + + const instructions = + buildAnimationKeyframes([style({height: 0}), animate(1000, style({height: 100}))]); + + const player = engine.process(targetContainer, instructions); + + expect(container.contains(child1)).toBe(true); + expect(container.contains(child2)).toBe(true); + expect(container.contains(otherContainer)).toBe(true); + + engine.triggerAnimations(); + expect(container.contains(child1)).toBe(true); + expect(container.contains(child2)).toBe(true); + expect(container.contains(otherContainer)).toBe(false); + + player.finish(); + expect(container.contains(child1)).toBe(false); + expect(container.contains(child2)).toBe(false); + expect(container.contains(otherContainer)).toBe(false); + }); + }); + }); +} + +class SuffixNormalizer extends AnimationStyleNormalizer { + constructor(private _suffix: string) { super(); } + + normalizePropertyName(propertyName: string, errors: string[]): string { + return propertyName + this._suffix; + } + + normalizeStyleValue( + userProvidedProperty: string, normalizedProperty: string, value: string|number, + errors: string[]): string { + return value + this._suffix; + } +} + +class ExactCssValueNormalizer extends AnimationStyleNormalizer { + constructor(private _allowedValues: {[propName: string]: any}) { super(); } + + normalizePropertyName(propertyName: string, errors: string[]): string { + if (!this._allowedValues[propertyName]) { + errors.push(`The CSS property \`${propertyName}\` is not allowed`); + } + return propertyName; + } + + normalizeStyleValue( + userProvidedProperty: string, normalizedProperty: string, value: string|number, + errors: string[]): string { + const expectedValue = this._allowedValues[userProvidedProperty]; + if (expectedValue != value) { + errors.push(`The CSS property \`${userProvidedProperty}\` is not allowed to be \`${value}\``); + } + return expectedValue; + } +} diff --git a/modules/@angular/animation/test/dsl/animation_spec.ts b/modules/@angular/animation/test/dsl/animation_spec.ts new file mode 100644 index 0000000000..33bd62e281 --- /dev/null +++ b/modules/@angular/animation/test/dsl/animation_spec.ts @@ -0,0 +1,541 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {StyleData} from '../../src/common/style_data'; +import {Animation} from '../../src/dsl/animation'; +import {AUTO_STYLE, AnimationMetadata, animate, group, keyframes, sequence, style} from '../../src/dsl/animation_metadata'; +import {AnimationTimelineInstruction} from '../../src/dsl/animation_timeline_instruction'; +import {validateAnimationSequence} from '../../src/dsl/animation_validator_visitor'; + +export function main() { + describe('Animation', () => { + describe('validation', () => { + it('should throw an error if one or more but not all keyframes() styles contain offsets', + () => { + const steps = animate(1000, keyframes([ + style({opacity: 0}), + style({opacity: 1, offset: 1}), + ])); + + expect(() => { validateAndThrowAnimationSequence(steps); }) + .toThrowError( + /Not all style\(\) steps within the declared keyframes\(\) contain offsets/); + }); + + it('should throw an error if not all offsets are between 0 and 1', () => { + let steps = animate(1000, keyframes([ + style({opacity: 0, offset: -1}), + style({opacity: 1, offset: 1}), + ])); + + expect(() => { + validateAndThrowAnimationSequence(steps); + }).toThrowError(/Please ensure that all keyframe offsets are between 0 and 1/); + + steps = animate(1000, keyframes([ + style({opacity: 0, offset: 0}), + style({opacity: 1, offset: 1.1}), + ])); + + expect(() => { + validateAndThrowAnimationSequence(steps); + }).toThrowError(/Please ensure that all keyframe offsets are between 0 and 1/); + }); + + it('should throw an error if a smaller offset shows up after a bigger one', () => { + let steps = animate(1000, keyframes([ + style({opacity: 0, offset: 1}), + style({opacity: 1, offset: 0}), + ])); + + expect(() => { + validateAndThrowAnimationSequence(steps); + }).toThrowError(/Please ensure that all keyframe offsets are in order/); + }); + + it('should throw an error if any styles overlap during parallel animations', () => { + const steps = group([ + sequence([ + // 0 -> 2000ms + style({opacity: 0}), animate('500ms', style({opacity: .25})), + animate('500ms', style({opacity: .5})), animate('500ms', style({opacity: .75})), + animate('500ms', style({opacity: 1})) + ]), + animate('1s 500ms', keyframes([ + // 0 -> 1500ms + style({width: 0}), + style({opacity: 1, width: 1000}), + ])) + ]); + + expect(() => { validateAndThrowAnimationSequence(steps); }) + .toThrowError( + /The CSS property "opacity" that exists between the times of "0ms" and "2000ms" is also being animated in a parallel animation between the times of "0ms" and "1500ms"/); + }); + + it('should throw an error if an animation time is invalid', () => { + const steps = [animate('500xs', style({opacity: 1}))]; + + expect(() => { + validateAndThrowAnimationSequence(steps); + }).toThrowError(/The provided timing value "500xs" is invalid/); + + const steps2 = [animate('500ms 500ms 500ms ease-out', style({opacity: 1}))]; + + expect(() => { + validateAndThrowAnimationSequence(steps2); + }).toThrowError(/The provided timing value "500ms 500ms 500ms ease-out" is invalid/); + }); + }); + + describe('keyframe building', () => { + describe('style() / animate()', () => { + it('should produce a balanced series of keyframes given a sequence of animate steps', + () => { + const steps = [ + style({width: 0}), animate(1000, style({height: 50})), + animate(1000, style({width: 100})), animate(1000, style({height: 150})), + animate(1000, style({width: 200})) + ]; + + const players = invokeAnimationSequence(steps); + expect(players[0].keyframes).toEqual([ + {height: AUTO_STYLE, width: 0, offset: 0}, + {height: 50, width: 0, offset: .25}, + {height: 50, width: 100, offset: .5}, + {height: 150, width: 100, offset: .75}, + {height: 150, width: 200, offset: 1}, + ]); + }); + + it('should fill in missing starting steps when a starting `style()` value is not used', + () => { + const steps = [animate(1000, style({width: 999}))]; + + const players = invokeAnimationSequence(steps); + expect(players[0].keyframes).toEqual([ + {width: AUTO_STYLE, offset: 0}, {width: 999, offset: 1} + ]); + }); + + it('should merge successive style() calls together before an animate() call', () => { + const steps = [ + style({width: 0}), style({height: 0}), style({width: 200}), style({opacity: 0}), + animate(1000, style({width: 100, height: 400, opacity: 1})) + ]; + + const players = invokeAnimationSequence(steps); + expect(players[0].keyframes).toEqual([ + {width: 200, height: 0, opacity: 0, offset: 0}, + {width: 100, height: 400, opacity: 1, offset: 1} + ]); + }); + + it('should not merge in successive style() calls to the previous animate() keyframe', + () => { + const steps = [ + style({opacity: 0}), animate(1000, style({opacity: .5})), style({opacity: .6}), + animate(1000, style({opacity: 1})) + ]; + + const players = invokeAnimationSequence(steps); + const keyframes = humanizeOffsets(players[0].keyframes, 4); + + expect(keyframes).toEqual([ + {opacity: 0, offset: 0}, + {opacity: .5, offset: .4998}, + {opacity: .6, offset: .5002}, + {opacity: 1, offset: 1}, + ]); + }); + + it('should support an easing value that uses cubic-bezier(...)', () => { + const steps = [ + style({opacity: 0}), + animate('1s cubic-bezier(.29, .55 ,.53 ,1.53)', style({opacity: 1})) + ]; + + const player = invokeAnimationSequence(steps)[0]; + const lastKeyframe = player.keyframes[1]; + const lastKeyframeEasing = lastKeyframe['easing']; + expect(lastKeyframeEasing.replace(/\s+/g, '')).toEqual('cubic-bezier(.29,.55,.53,1.53)'); + }); + }); + + describe('sequence()', () => { + it('should not produce extra timelines when multiple sequences are used within each other', + () => { + const steps = [ + style({width: 0}), animate(1000, style({width: 100})), sequence([ + animate(1000, style({width: 200})), + sequence([animate(1000, style({width: 300}))]) + ]), + animate(1000, style({width: 400})), sequence([animate(1000, style({width: 500}))]) + ]; + + const players = invokeAnimationSequence(steps); + expect(players[0].keyframes).toEqual([ + {width: 0, offset: 0}, {width: 100, offset: .2}, {width: 200, offset: .4}, + {width: 300, offset: .6}, {width: 400, offset: .8}, {width: 500, offset: 1} + ]); + }); + + it('should produce a 1ms animation step if a style call exists before sequence within a call to animate()', + () => { + const steps = [ + style({width: 100}), sequence([ + animate(1000, style({width: 200})), + ]) + ]; + + const players = invokeAnimationSequence(steps); + expect(humanizeOffsets(players[0].keyframes, 4)).toEqual([ + {width: 100, offset: 0}, {width: 100, offset: .001}, {width: 200, offset: 1} + ]); + }); + + it('should create a new timeline after a sequence if group() or keyframe() commands are used within', + () => { + const steps = [ + style({width: 100, height: 100}), animate(1000, style({width: 150, height: 150})), + sequence([ + group([ + animate(1000, style({height: 200})), + ]), + animate(1000, keyframes([style({width: 180}), style({width: 200})])) + ]), + animate(1000, style({width: 500, height: 500})) + ]; + + const players = invokeAnimationSequence(steps); + expect(players.length).toEqual(4); + + const finalPlayer = players[players.length - 1]; + expect(finalPlayer.keyframes).toEqual([ + {width: 200, height: 200, offset: 0}, {width: 500, height: 500, offset: 1} + ]); + }); + }); + + describe('keyframes()', () => { + it('should produce a sub timeline when `keyframes()` is used within a sequence', () => { + const steps = [ + animate(1000, style({opacity: .5})), animate(1000, style({opacity: 1})), + animate( + 1000, keyframes([style({height: 0}), style({height: 100}), style({height: 0})])), + animate(1000, style({opacity: 0})) + ]; + + const players = invokeAnimationSequence(steps); + expect(players.length).toEqual(3); + + const player0 = players[0]; + expect(player0.delay).toEqual(0); + expect(player0.keyframes).toEqual([ + {opacity: AUTO_STYLE, height: AUTO_STYLE, offset: 0}, + {opacity: .5, height: AUTO_STYLE, offset: .5}, + {opacity: 1, height: AUTO_STYLE, offset: 1}, + ]); + + const subPlayer = players[1]; + expect(subPlayer.delay).toEqual(2000); + expect(subPlayer.keyframes).toEqual([ + {opacity: 1, height: 0, offset: 0}, + {opacity: 1, height: 100, offset: .5}, + {opacity: 1, height: 0, offset: 1}, + ]); + + const player1 = players[2]; + expect(player1.delay).toEqual(3000); + expect(player1.keyframes).toEqual([ + {opacity: 1, height: 0, offset: 0}, {opacity: 0, height: 0, offset: 1} + ]); + }); + + it('should propagate inner keyframe style data to the parent timeline if used afterwards', + () => { + const steps = [ + style({opacity: 0}), animate(1000, style({opacity: .5})), + animate(1000, style({opacity: 1})), animate(1000, keyframes([ + style({color: 'red'}), + style({color: 'blue'}), + ])), + animate(1000, style({color: 'green', opacity: 0})) + ]; + + const players = invokeAnimationSequence(steps); + const finalPlayer = players[players.length - 1]; + expect(finalPlayer.keyframes).toEqual([ + {opacity: 1, color: 'blue', offset: 0}, {opacity: 0, color: 'green', offset: 1} + ]); + }); + + it('should feed in starting data into inner keyframes if used in an style step beforehand', + () => { + const steps = [ + animate(1000, style({opacity: .5})), animate(1000, keyframes([ + style({opacity: .8, offset: .5}), + style({opacity: 1, offset: 1}), + ])) + ]; + + const players = invokeAnimationSequence(steps); + expect(players.length).toEqual(2); + + const topPlayer = players[0]; + expect(topPlayer.keyframes).toEqual([ + {opacity: AUTO_STYLE, offset: 0}, {opacity: .5, offset: 1} + ]); + + const subPlayer = players[1]; + expect(subPlayer.keyframes).toEqual([ + {opacity: .5, offset: 0}, {opacity: .8, offset: 0.5}, {opacity: 1, offset: 1} + ]); + }); + + it('should set the easing value as an easing value for the entire timeline', () => { + const steps = [ + style({opacity: 0}), animate(1000, style({opacity: .5})), + animate( + '1s ease-out', + keyframes([style({opacity: .8, offset: .5}), style({opacity: 1, offset: 1})])) + ]; + + const player = invokeAnimationSequence(steps)[1]; + expect(player.easing).toEqual('ease-out'); + }); + + it('should combine the starting time + the given delay as the delay value for the animated keyframes', + () => { + const steps = [ + style({opacity: 0}), animate(500, style({opacity: .5})), + animate( + '1s 2s ease-out', + keyframes([style({opacity: .8, offset: .5}), style({opacity: 1, offset: 1})])) + ]; + + const player = invokeAnimationSequence(steps)[1]; + expect(player.delay).toEqual(2500); + }); + }); + + describe('group()', () => { + it('should properly tally style data within a group() for use in a follow-up animate() step', + () => { + const steps = [ + style({width: 0, height: 0}), animate(1000, style({width: 20, height: 50})), + group([animate('1s 1s', style({width: 200})), animate('1s', style({height: 500}))]), + animate(1000, style({width: 1000, height: 1000})) + ]; + + const players = invokeAnimationSequence(steps); + expect(players.length).toEqual(4); + + const player0 = players[0]; + expect(player0.duration).toEqual(1000); + expect(player0.keyframes).toEqual([ + {width: 0, height: 0, offset: 0}, {width: 20, height: 50, offset: 1} + ]); + + const gPlayer1 = players[1]; + expect(gPlayer1.duration).toEqual(2000); + expect(gPlayer1.delay).toEqual(1000); + expect(gPlayer1.keyframes).toEqual([ + {width: 20, offset: 0}, {width: 20, offset: .5}, {width: 200, offset: 1} + ]); + + const gPlayer2 = players[2]; + expect(gPlayer2.duration).toEqual(1000); + expect(gPlayer2.delay).toEqual(1000); + expect(gPlayer2.keyframes).toEqual([ + {height: 50, offset: 0}, {height: 500, offset: 1} + ]); + + const player1 = players[3]; + expect(player1.duration).toEqual(1000); + expect(player1.delay).toEqual(3000); + expect(player1.keyframes).toEqual([ + {width: 200, height: 500, offset: 0}, {width: 1000, height: 1000, offset: 1} + ]); + }); + + it('should support groups with nested sequences', () => { + const steps = [group([ + sequence([ + style({opacity: 0}), + animate(1000, style({opacity: 1})), + ]), + sequence([ + style({width: 0}), + animate(1000, style({width: 200})), + ]) + ])]; + + const players = invokeAnimationSequence(steps); + expect(players.length).toEqual(2); + + const gPlayer1 = players[0]; + expect(gPlayer1.delay).toEqual(0); + expect(gPlayer1.keyframes).toEqual([ + {opacity: 0, offset: 0}, + {opacity: 1, offset: 1}, + ]); + + const gPlayer2 = players[1]; + expect(gPlayer1.delay).toEqual(0); + expect(gPlayer2.keyframes).toEqual([{width: 0, offset: 0}, {width: 200, offset: 1}]); + }); + + it('should respect delays after group entries', () => { + const steps = [ + style({width: 0, height: 0}), animate(1000, style({width: 50, height: 50})), group([ + animate(1000, style({width: 100})), + animate(1000, style({height: 100})), + ]), + animate('1s 1s', style({height: 200, width: 200})) + ]; + + const players = invokeAnimationSequence(steps); + expect(players.length).toEqual(4); + + const finalPlayer = players[players.length - 1]; + expect(finalPlayer.delay).toEqual(2000); + expect(finalPlayer.duration).toEqual(2000); + expect(finalPlayer.keyframes).toEqual([ + {width: 100, height: 100, offset: 0}, + {width: 100, height: 100, offset: .5}, + {width: 200, height: 200, offset: 1}, + ]); + }); + }); + + describe('timing values', () => { + it('should properly combine an easing value with a delay into a set of three keyframes', + () => { + const steps: AnimationMetadata[] = + [style({opacity: 0}), animate('3s 1s ease-out', style({opacity: 1}))]; + + const player = invokeAnimationSequence(steps)[0]; + expect(player.keyframes).toEqual([ + {opacity: 0, offset: 0}, {opacity: 0, offset: .25}, + {opacity: 1, offset: 1, easing: 'ease-out'} + ]); + }); + + it('should allow easing values to exist for each animate() step', () => { + const steps: AnimationMetadata[] = [ + style({width: 0}), animate('1s linear', style({width: 10})), + animate('2s ease-out', style({width: 20})), animate('1s ease-in', style({width: 30})) + ]; + + const players = invokeAnimationSequence(steps); + expect(players.length).toEqual(1); + + const player = players[0]; + expect(player.keyframes).toEqual([ + {width: 0, offset: 0}, {width: 10, offset: .25, easing: 'linear'}, + {width: 20, offset: .75, easing: 'ease-out'}, + {width: 30, offset: 1, easing: 'ease-in'} + ]); + }); + + it('should produce a top-level timeline only for the duration that is set as before a group kicks in', + () => { + const steps: AnimationMetadata[] = [ + style({width: 0, height: 0, opacity: 0}), + animate('1s', style({width: 100, height: 100, opacity: .2})), group([ + animate('500ms 1s', style({width: 500})), animate('1s', style({height: 500})), + sequence([ + animate(500, style({opacity: .5})), + animate(500, style({opacity: .6})), + animate(500, style({opacity: .7})), + animate(500, style({opacity: 1})), + ]) + ]) + ]; + + const player = invokeAnimationSequence(steps)[0]; + expect(player.duration).toEqual(1000); + expect(player.delay).toEqual(0); + }); + + it('should offset group() and keyframe() timelines with a delay which is the current time of the previous player when called', + () => { + const steps: AnimationMetadata[] = [ + style({width: 0, height: 0}), + animate('1500ms linear', style({width: 10, height: 10})), group([ + animate(1000, style({width: 500, height: 500})), + animate(2000, style({width: 500, height: 500})) + ]), + animate(1000, keyframes([ + style({width: 200}), + style({width: 500}), + ])) + ]; + + const players = invokeAnimationSequence(steps); + expect(players[0].delay).toEqual(0); // top-level animation + expect(players[1].delay).toEqual(1500); // first entry in group() + expect(players[2].delay).toEqual(1500); // second entry in group() + expect(players[3].delay).toEqual(3500); // animate(...keyframes()) + }); + }); + + describe('state based data', () => { + it('should create an empty animation if there are zero animation steps', () => { + const steps: AnimationMetadata[] = []; + + const fromStyles: StyleData[] = [{background: 'blue', height: 100}]; + + const toStyles: StyleData[] = [{background: 'red'}]; + + const player = invokeAnimationSequence(steps, fromStyles, toStyles)[0]; + expect(player.duration).toEqual(0); + expect(player.keyframes).toEqual([]); + }); + + it('should produce an animation from start to end between the to and from styles if there are animate steps in between', + () => { + const steps: AnimationMetadata[] = [animate(1000)]; + + const fromStyles: StyleData[] = [{background: 'blue', height: 100}]; + + const toStyles: StyleData[] = [{background: 'red'}]; + + const players = invokeAnimationSequence(steps, fromStyles, toStyles); + expect(players[0].keyframes).toEqual([ + {background: 'blue', height: 100, offset: 0}, + {background: 'red', height: AUTO_STYLE, offset: 1} + ]); + }); + }); + }); + }); +} + +function humanizeOffsets(keyframes: StyleData[], digits: number = 3): StyleData[] { + return keyframes.map(keyframe => { + keyframe['offset'] = Number(parseFloat(keyframe['offset']).toFixed(digits)); + return keyframe; + }); +} + +function invokeAnimationSequence( + steps: AnimationMetadata | AnimationMetadata[], startingStyles: StyleData[] = [], + destinationStyles: StyleData[] = []): AnimationTimelineInstruction[] { + return new Animation(steps).buildTimelines(startingStyles, destinationStyles); +} + +function validateAndThrowAnimationSequence(steps: AnimationMetadata | AnimationMetadata[]) { + const ast = + Array.isArray(steps) ? sequence(steps) : steps; + const errors = validateAnimationSequence(ast); + if (errors.length) { + throw new Error(errors.join('\n')); + } +} diff --git a/modules/@angular/animation/test/dsl/animation_trigger_spec.ts b/modules/@angular/animation/test/dsl/animation_trigger_spec.ts new file mode 100644 index 0000000000..bfc123f0dc --- /dev/null +++ b/modules/@angular/animation/test/dsl/animation_trigger_spec.ts @@ -0,0 +1,158 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {animate, state, style, transition} from '../../src/dsl/animation_metadata'; +import {trigger} from '../../src/dsl/animation_trigger'; + +export function main() { + describe('AnimationTrigger', () => { + describe('trigger validation', () => { + it('should group errors together for an animation trigger', () => { + expect(() => { + trigger('myTrigger', [transition('12345', animate(3333))]); + }).toThrowError(/Animation parsing for the myTrigger trigger have failed/); + }); + + it('should throw an error when a transition within a trigger contains an invalid expression', + () => { + expect(() => { trigger('name', [transition('somethingThatIsWrong', animate(3333))]); }) + .toThrowError( + /- The provided transition expression "somethingThatIsWrong" is not supported/); + }); + + it('should throw an error if an animation alias is used that is not yet supported', () => { + expect(() => { + trigger('name', [transition(':angular', animate(3333))]); + }).toThrowError(/- The transition alias value ":angular" is not supported/); + }); + }); + + describe('trigger usage', () => { + it('should construct a trigger based on the states and transition data', () => { + const result = trigger('name', [ + state('on', style({width: 0})), state('off', style({width: 100})), + transition('on => off', animate(1000)), transition('off => on', animate(1000)) + ]); + + expect(result.states).toEqual({'on': {width: 0}, 'off': {width: 100}}); + + expect(result.transitionFactories.length).toEqual(2); + }); + + it('should find the first transition that matches', () => { + const result = trigger( + 'name', [transition('a => b', animate(1234)), transition('b => c', animate(5678))]); + + const trans = result.matchTransition('b', 'c'); + expect(trans.timelines.length).toEqual(1); + const timeline = trans.timelines[0]; + expect(timeline.duration).toEqual(5678); + }); + + it('should find a transition with a `*` value', () => { + const result = trigger('name', [ + transition('* => b', animate(1234)), transition('b => *', animate(5678)), + transition('* => *', animate(9999)) + ]); + + let trans = result.matchTransition('b', 'c'); + expect(trans.timelines[0].duration).toEqual(5678); + + trans = result.matchTransition('a', 'b'); + expect(trans.timelines[0].duration).toEqual(1234); + + trans = result.matchTransition('c', 'c'); + expect(trans.timelines[0].duration).toEqual(9999); + }); + + it('should null when no results are found', () => { + const result = trigger('name', [transition('a => b', animate(1111))]); + + const trans = result.matchTransition('b', 'a'); + expect(trans).toBeFalsy(); + }); + + it('should allow a function to be used as a predicate for the transition', () => { + let returnValue = false; + + const result = trigger('name', [transition((from, to) => returnValue, animate(1111))]); + + expect(result.matchTransition('a', 'b')).toBeFalsy(); + expect(result.matchTransition('1', 2)).toBeFalsy(); + expect(result.matchTransition(false, true)).toBeFalsy(); + + returnValue = true; + + expect(result.matchTransition('a', 'b')).toBeTruthy(); + }); + + it('should call each transition predicate function until the first one that returns true', + () => { + let count = 0; + + function countAndReturn(value: boolean) { + return (fromState: any, toState: any) => { + count++; + return value; + }; + } + + const result = trigger('name', [ + transition(countAndReturn(false), animate(1111)), + transition(countAndReturn(false), animate(2222)), + transition(countAndReturn(true), animate(3333)), + transition(countAndReturn(true), animate(3333)) + ]); + + const trans = result.matchTransition('a', 'b'); + expect(trans.timelines[0].duration).toEqual(3333); + + expect(count).toEqual(3); + }); + + it('should support bi-directional transition expressions', () => { + const result = trigger('name', [transition('a <=> b', animate(2222))]); + + const t1 = result.matchTransition('a', 'b'); + expect(t1.timelines[0].duration).toEqual(2222); + + const t2 = result.matchTransition('b', 'a'); + expect(t2.timelines[0].duration).toEqual(2222); + }); + + it('should support multiple transition statements in one string', () => { + const result = trigger('name', [transition('a => b, b => a, c => *', animate(1234))]); + + const t1 = result.matchTransition('a', 'b'); + expect(t1.timelines[0].duration).toEqual(1234); + + const t2 = result.matchTransition('b', 'a'); + expect(t2.timelines[0].duration).toEqual(1234); + + const t3 = result.matchTransition('c', 'a'); + expect(t3.timelines[0].duration).toEqual(1234); + }); + + describe('aliases', () => { + it('should alias the :enter transition as void => *', () => { + const result = trigger('name', [transition(':enter', animate(3333))]); + + const trans = result.matchTransition('void', 'something'); + expect(trans.timelines[0].duration).toEqual(3333); + }); + + it('should alias the :leave transition as * => void', () => { + const result = trigger('name', [transition(':leave', animate(3333))]); + + const trans = result.matchTransition('something', 'void'); + expect(trans.timelines[0].duration).toEqual(3333); + }); + }); + }); + }); +} diff --git a/modules/@angular/animation/test/dsl/style_normalizer/web_animations_style_normalizer_spec.ts b/modules/@angular/animation/test/dsl/style_normalizer/web_animations_style_normalizer_spec.ts new file mode 100644 index 0000000000..98e4ff63c5 --- /dev/null +++ b/modules/@angular/animation/test/dsl/style_normalizer/web_animations_style_normalizer_spec.ts @@ -0,0 +1,64 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {WebAnimationsStyleNormalizer} from '../../../src/dsl/style_normalization/web_animations_style_normalizer'; + +export function main() { + describe('WebAnimationsStyleNormalizer', () => { + const normalizer = new WebAnimationsStyleNormalizer(); + + describe('normalizePropertyName', () => { + it('should normalize CSS property values to camel-case', () => { + expect(normalizer.normalizePropertyName('width', [])).toEqual('width'); + expect(normalizer.normalizePropertyName('border-width', [])).toEqual('borderWidth'); + expect(normalizer.normalizePropertyName('borderHeight', [])).toEqual('borderHeight'); + expect(normalizer.normalizePropertyName('-webkit-animation', [ + ])).toEqual('WebkitAnimation'); + }); + }); + + describe('normalizeStyleValue', () => { + function normalize(prop: string, val: string | number): string { + const errors: string[] = []; + const result = normalizer.normalizeStyleValue(prop, prop, val, errors); + if (errors.length) { + throw new Error(errors.join('\n')); + } + return result; + } + + it('should normalize number-based dimensional properties to use a `px` suffix if missing', + () => { + expect(normalize('width', 10)).toEqual('10px'); + expect(normalize('height', 20)).toEqual('20px'); + }); + + it('should report an error when a string-based dimensional value does not contain a suffix at all', + () => { + expect(() => { + normalize('width', '50'); + }).toThrowError(/Please provide a CSS unit value for width:50/); + }); + + it('should not normalize non-dimensional properties with `px` values, but only convert them to string', + () => { + expect(normalize('opacity', 0)).toEqual('0'); + expect(normalize('opacity', '1')).toEqual('1'); + expect(normalize('color', 'red')).toEqual('red'); + expect(normalize('fontWeight', '100')).toEqual('100'); + }); + + it('should not normalize dimensional-based values that already contain a dimensional suffix or a non dimensional value', + () => { + expect(normalize('width', '50em')).toEqual('50em'); + expect(normalize('height', '500pt')).toEqual('500pt'); + expect(normalize('borderWidth', 'inherit')).toEqual('inherit'); + expect(normalize('paddingTop', 'calc(500px + 200px)')).toEqual('calc(500px + 200px)'); + }); + }); + }); +} diff --git a/modules/@angular/animation/testing/index.ts b/modules/@angular/animation/testing/index.ts new file mode 100644 index 0000000000..511a350c01 --- /dev/null +++ b/modules/@angular/animation/testing/index.ts @@ -0,0 +1,14 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +/** + * @module + * @description + * Entry point for all public APIs of the animation/testing package. + */ +export {MockAnimationDriver, MockAnimationPlayer} from './mock_animation_driver'; diff --git a/modules/@angular/animation/testing/mock_animation_driver.ts b/modules/@angular/animation/testing/mock_animation_driver.ts new file mode 100644 index 0000000000..e36e4e122d --- /dev/null +++ b/modules/@angular/animation/testing/mock_animation_driver.ts @@ -0,0 +1,33 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {AnimationPlayer} from '@angular/core'; + +import {StyleData} from '../src/common/style_data'; +import {AnimationDriver} from '../src/engine/animation_driver'; +import {NoOpAnimationPlayer} from '../src/private_import_core'; + +export class MockAnimationDriver implements AnimationDriver { + static log: AnimationPlayer[] = []; + + animate( + element: any, keyframes: StyleData[], duration: number, delay: number, easing: string, + previousPlayers: AnimationPlayer[] = []): AnimationPlayer { + const player = + new MockAnimationPlayer(element, keyframes, duration, delay, easing, previousPlayers); + MockAnimationDriver.log.push(player); + return player; + } +} + +export class MockAnimationPlayer extends NoOpAnimationPlayer { + constructor( + public element: any, public keyframes: StyleData[], public duration: number, + public delay: number, public easing: string, public previousPlayers: AnimationPlayer[]) { + super(); + } +} diff --git a/modules/@angular/animation/tsconfig-build.json b/modules/@angular/animation/tsconfig-build.json new file mode 100644 index 0000000000..9ebb4ef4bf --- /dev/null +++ b/modules/@angular/animation/tsconfig-build.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "declaration": true, + "stripInternal": true, + "experimentalDecorators": true, + "module": "es2015", + "moduleResolution": "node", + "outDir": "../../../dist/packages-dist/animation", + "paths": { + "@angular/core": ["../../../dist/packages-dist/core"] + }, + "rootDir": ".", + "sourceMap": true, + "inlineSources": true, + "target": "es5", + "lib": ["es2015", "dom"], + "skipLibCheck": true, + // don't auto-discover @types/node, it results in a /// void): void { this._onDoneFns.push(fn); } + onDestroy(fn: () => void): void { this._onDestroyFns.push(fn); } + hasStarted() { return this._started; } play() { @@ -78,6 +81,8 @@ export class AnimationGroupPlayer implements AnimationPlayer { this._onFinish(); this._players.forEach(player => player.destroy()); this._destroyed = true; + this._onDestroyFns.forEach(fn => fn()); + this._onDestroyFns = []; } } diff --git a/modules/@angular/core/src/animation/animation_player.ts b/modules/@angular/core/src/animation/animation_player.ts index c086bf09fb..76bf7b76cf 100644 --- a/modules/@angular/core/src/animation/animation_player.ts +++ b/modules/@angular/core/src/animation/animation_player.ts @@ -15,6 +15,7 @@ import {scheduleMicroTask} from '../facade/lang'; export abstract class AnimationPlayer { abstract onDone(fn: () => void): void; abstract onStart(fn: () => void): void; + abstract onDestroy(fn: () => void): void; abstract init(): void; abstract hasStarted(): boolean; abstract play(): void; @@ -32,16 +33,22 @@ export abstract class AnimationPlayer { export class NoOpAnimationPlayer implements AnimationPlayer { private _onDoneFns: Function[] = []; private _onStartFns: Function[] = []; + private _onDestroyFns: Function[] = []; private _started = false; + private _destroyed = false; + private _finished = false; public parentPlayer: AnimationPlayer = null; constructor() { scheduleMicroTask(() => this._onFinish()); } - /** @internal */ - _onFinish() { - this._onDoneFns.forEach(fn => fn()); - this._onDoneFns = []; + private _onFinish() { + if (!this._finished) { + this._finished = true; + this._onDoneFns.forEach(fn => fn()); + this._onDoneFns = []; + } } onStart(fn: () => void): void { this._onStartFns.push(fn); } onDone(fn: () => void): void { this._onDoneFns.push(fn); } + onDestroy(fn: () => void): void { this._onDestroyFns.push(fn); } hasStarted(): boolean { return this._started; } init(): void {} play(): void { @@ -54,7 +61,14 @@ export class NoOpAnimationPlayer implements AnimationPlayer { pause(): void {} restart(): void {} finish(): void { this._onFinish(); } - destroy(): void {} + destroy(): void { + if (!this._destroyed) { + this._destroyed = true; + this.finish(); + this._onDestroyFns.forEach(fn => fn()); + this._onDestroyFns = []; + } + } reset(): void {} setPosition(p: number): void {} getPosition(): number { return 0; } diff --git a/modules/@angular/core/src/animation/animation_sequence_player.ts b/modules/@angular/core/src/animation/animation_sequence_player.ts index c12e31e77b..0da4651824 100644 --- a/modules/@angular/core/src/animation/animation_sequence_player.ts +++ b/modules/@angular/core/src/animation/animation_sequence_player.ts @@ -15,6 +15,7 @@ export class AnimationSequencePlayer implements AnimationPlayer { private _activePlayer: AnimationPlayer; private _onDoneFns: Function[] = []; private _onStartFns: Function[] = []; + private _onDestroyFns: Function[] = []; private _finished = false; private _started = false; private _destroyed = false; @@ -60,6 +61,8 @@ export class AnimationSequencePlayer implements AnimationPlayer { onDone(fn: () => void): void { this._onDoneFns.push(fn); } + onDestroy(fn: () => void): void { this._onDestroyFns.push(fn); } + hasStarted() { return this._started; } play(): void { @@ -101,6 +104,8 @@ export class AnimationSequencePlayer implements AnimationPlayer { this._players.forEach(player => player.destroy()); this._destroyed = true; this._activePlayer = new NoOpAnimationPlayer(); + this._onDestroyFns.forEach(fn => fn()); + this._onDestroyFns = []; } } diff --git a/modules/@angular/core/src/core.ts b/modules/@angular/core/src/core.ts index 0d0105cf58..a8380a8935 100644 --- a/modules/@angular/core/src/core.ts +++ b/modules/@angular/core/src/core.ts @@ -38,3 +38,4 @@ export {AnimationPlayer} from './animation/animation_player'; export {AnimationStyles} from './animation/animation_styles'; export {AnimationKeyframe} from './animation/animation_keyframe'; export {Sanitizer, SecurityContext} from './security'; +export {TransitionFactory, TransitionInstruction, Trigger} from './triggers'; diff --git a/modules/@angular/core/src/core_private_export.ts b/modules/@angular/core/src/core_private_export.ts index 4c1da19a4a..ef43f462f7 100644 --- a/modules/@angular/core/src/core_private_export.ts +++ b/modules/@angular/core/src/core_private_export.ts @@ -40,6 +40,7 @@ import * as reflection_capabilities from './reflection/reflection_capabilities'; import * as reflector_reader from './reflection/reflector_reader'; import * as reflection_types from './reflection/types'; import * as api from './render/api'; +import {TransitionEngine} from './transition/transition_engine'; import * as decorators from './util/decorators'; import {isObservable, isPromise} from './util/lang'; import * as viewEngine from './view/index'; @@ -110,7 +111,11 @@ export const __core_private__: { AnimationTransition: typeof AnimationTransition view_utils: typeof view_utils, ERROR_COMPONENT_TYPE: typeof ERROR_COMPONENT_TYPE, +<<<<<<< HEAD viewEngine: typeof viewEngine, +======= + TransitionEngine: typeof TransitionEngine +>>>>>>> 4577b7c2a... refactor(animations): introduce @angular/animation module } = { isDefaultChangeDetectionStrategy: constants.isDefaultChangeDetectionStrategy, ChangeDetectorStatus: constants.ChangeDetectorStatus, @@ -161,5 +166,6 @@ export const __core_private__: { isPromise: isPromise, isObservable: isObservable, AnimationTransition: AnimationTransition, - ERROR_COMPONENT_TYPE: ERROR_COMPONENT_TYPE + ERROR_COMPONENT_TYPE: ERROR_COMPONENT_TYPE, + TransitionEngine: TransitionEngine }; diff --git a/modules/@angular/core/src/platform_core_providers.ts b/modules/@angular/core/src/platform_core_providers.ts index e5a71a0154..191f3fbadd 100644 --- a/modules/@angular/core/src/platform_core_providers.ts +++ b/modules/@angular/core/src/platform_core_providers.ts @@ -12,6 +12,7 @@ import {Provider} from './di'; import {Reflector, reflector} from './reflection/reflection'; import {ReflectorReader} from './reflection/reflector_reader'; import {TestabilityRegistry} from './testability/testability'; +import {NoOpTransitionEngine, TransitionEngine} from './transition/transition_engine'; function _reflector(): Reflector { return reflector; @@ -22,6 +23,7 @@ const _CORE_PLATFORM_PROVIDERS: Provider[] = [ {provide: PlatformRef, useExisting: PlatformRef_}, {provide: Reflector, useFactory: _reflector, deps: []}, {provide: ReflectorReader, useExisting: Reflector}, + {provide: TransitionEngine, useClass: NoOpTransitionEngine}, TestabilityRegistry, Console, ]; diff --git a/modules/@angular/core/src/transition/transition_engine.ts b/modules/@angular/core/src/transition/transition_engine.ts new file mode 100644 index 0000000000..b32dd8f6d3 --- /dev/null +++ b/modules/@angular/core/src/transition/transition_engine.ts @@ -0,0 +1,41 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {AnimationPlayer, NoOpAnimationPlayer} from '../animation/animation_player'; +import {TransitionInstruction} from '../triggers'; + + +/** + * @experimental Transition support is experimental. + */ +export abstract class TransitionEngine { + abstract insertNode(container: any, element: any): void; + abstract removeNode(element: any): void; + abstract process(element: any, instructions: TransitionInstruction[]): AnimationPlayer; + abstract triggerAnimations(): void; +} + +/** + * @experimental Transition support is experimental. + */ +export class NoOpTransitionEngine extends TransitionEngine { + constructor() { super(); } + + insertNode(container: any, element: any): void { container.appendChild(element); } + + removeNode(element: any): void { remove(element); } + + process(element: any, instructions: TransitionInstruction[]): AnimationPlayer { + return new NoOpAnimationPlayer(); + } + + triggerAnimations(): void {} +} + +function remove(element: any) { + element.parentNode.removeChild(element); +} diff --git a/modules/@angular/core/src/triggers.ts b/modules/@angular/core/src/triggers.ts new file mode 100644 index 0000000000..343e01524e --- /dev/null +++ b/modules/@angular/core/src/triggers.ts @@ -0,0 +1,27 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +/** + * @experimental View triggers are experimental + */ +export interface Trigger { + name: string; + transitionFactories: TransitionFactory[]; +} + +/** + * @experimental View triggers are experimental + */ +export interface TransitionFactory { + match(currentState: any, nextState: any): TransitionInstruction; +} + +/** + * @experimental View triggers are experimental + */ +export interface TransitionInstruction {} diff --git a/modules/@angular/core/testing/mock_animation_player.ts b/modules/@angular/core/testing/mock_animation_player.ts index c16a00e661..41f48e23ff 100644 --- a/modules/@angular/core/testing/mock_animation_player.ts +++ b/modules/@angular/core/testing/mock_animation_player.ts @@ -10,6 +10,7 @@ import {AUTO_STYLE, AnimationPlayer} from '@angular/core'; export class MockAnimationPlayer implements AnimationPlayer { private _onDoneFns: Function[] = []; private _onStartFns: Function[] = []; + private _onDestroyFns: Function[] = []; private _finished = false; private _destroyed = false; private _started = false; @@ -47,6 +48,8 @@ export class MockAnimationPlayer implements AnimationPlayer { onStart(fn: () => void): void { this._onStartFns.push(fn); } + onDestroy(fn: () => void): void { this._onDestroyFns.push(fn); } + hasStarted() { return this._started; } play(): void { @@ -76,6 +79,8 @@ export class MockAnimationPlayer implements AnimationPlayer { this._destroyed = true; this.finish(); this.log.push('destroy'); + this._onDestroyFns.forEach(fn => fn()); + this._onDestroyFns = []; } } diff --git a/modules/@angular/language-service/test/test_utils.ts b/modules/@angular/language-service/test/test_utils.ts index f1af3c9db6..579e2906da 100644 --- a/modules/@angular/language-service/test/test_utils.ts +++ b/modules/@angular/language-service/test/test_utils.ts @@ -52,6 +52,7 @@ export function validateCache(): {exists: string[], unused: string[], reported: } missingCache.set('/node_modules/@angular/core.d.ts', true); +missingCache.set('/node_modules/@angular/animation.d.ts', true); missingCache.set('/node_modules/@angular/common.d.ts', true); missingCache.set('/node_modules/@angular/forms.d.ts', true); missingCache.set('/node_modules/@angular/core/src/di/provider.metadata.json', true); @@ -325,4 +326,4 @@ export function includeDiagnostic(diagnostics: Diagnostics, message: string, p1? } } } -} \ No newline at end of file +} diff --git a/modules/@angular/language-service/tsconfig-build.json b/modules/@angular/language-service/tsconfig-build.json index fcdbb0db4d..85882c8938 100644 --- a/modules/@angular/language-service/tsconfig-build.json +++ b/modules/@angular/language-service/tsconfig-build.json @@ -10,6 +10,7 @@ "outDir": "../../../dist/packages-dist/language-service", "paths": { "@angular/core": ["../../../dist/packages-dist/core"], + "@angular/animation": ["../../../dist/packages-dist/animation"], "@angular/core/testing": ["../../../dist/packages-dist/core/testing"], "@angular/core/testing/*": ["../../../dist/packages-dist/core/testing/*"], "@angular/common": ["../../../dist/packages-dist/common"], diff --git a/modules/@angular/platform-browser/src/dom/web_animations_player.ts b/modules/@angular/platform-browser/src/dom/web_animations_player.ts index 78c1b5d6c6..ae3cb4c5e8 100644 --- a/modules/@angular/platform-browser/src/dom/web_animations_player.ts +++ b/modules/@angular/platform-browser/src/dom/web_animations_player.ts @@ -17,6 +17,7 @@ import {DomAnimatePlayer} from './dom_animate_player'; export class WebAnimationsPlayer implements AnimationPlayer { private _onDoneFns: Function[] = []; private _onStartFns: Function[] = []; + private _onDestroyFns: Function[] = []; private _player: DomAnimatePlayer; private _duration: number; private _initialized = false; @@ -107,6 +108,8 @@ export class WebAnimationsPlayer implements AnimationPlayer { onDone(fn: () => void): void { this._onDoneFns.push(fn); } + onDestroy(fn: () => void): void { this._onDestroyFns.push(fn); } + play(): void { this.init(); if (!this.hasStarted()) { @@ -153,6 +156,8 @@ export class WebAnimationsPlayer implements AnimationPlayer { this._resetDomPlayerState(); this._onFinish(); this._destroyed = true; + this._onDestroyFns.forEach(fn => fn()); + this._onDestroyFns = []; } } diff --git a/modules/@angular/platform-webworker/src/web_workers/worker/renderer.ts b/modules/@angular/platform-webworker/src/web_workers/worker/renderer.ts index 314f196f2f..e1271f5b4c 100644 --- a/modules/@angular/platform-webworker/src/web_workers/worker/renderer.ts +++ b/modules/@angular/platform-webworker/src/web_workers/worker/renderer.ts @@ -356,6 +356,11 @@ class _AnimationWorkerRendererPlayer implements RenderStoreObject { this._runOnService('onDone', []); } + onDestroy(fn: () => void): void { + this._renderElement.animationPlayerEvents.listen(this, 'onDestroy', fn); + this._runOnService('onDestroy', []); + } + hasStarted(): boolean { return this._started; } init(): void { this._runOnService('init', []); } diff --git a/modules/benchmarks/src/bootstrap_ng2.ts b/modules/benchmarks/src/bootstrap_ng2.ts index a44ecb01d4..de71af4df4 100644 --- a/modules/benchmarks/src/bootstrap_ng2.ts +++ b/modules/benchmarks/src/bootstrap_ng2.ts @@ -24,6 +24,7 @@ defaultJSExtensions: true, map: { '@angular/core': '/packages-dist/core/bundles/core.umd.js', + '@angular/animation': '/packages-dist/common/bundles/animation.umd.js', '@angular/common': '/packages-dist/common/bundles/common.umd.js', '@angular/forms': '/packages-dist/forms/bundles/forms.umd.js', '@angular/compiler': '/packages-dist/compiler/bundles/compiler.umd.js', @@ -51,6 +52,7 @@ map: {'@angular': '/all/@angular', 'rxjs': '/all/benchmarks/vendor/rxjs'}, packages: { '@angular/core': {main: 'index.js', defaultExtension: 'js'}, + '@angular/animation': {main: 'index.js', defaultExtension: 'js'}, '@angular/compiler': {main: 'index.js', defaultExtension: 'js'}, '@angular/router': {main: 'index.js', defaultExtension: 'js'}, '@angular/common': {main: 'index.js', defaultExtension: 'js'}, diff --git a/modules/playground/src/bootstrap.ts b/modules/playground/src/bootstrap.ts index 5f91a4b7b9..15e6b32edc 100644 --- a/modules/playground/src/bootstrap.ts +++ b/modules/playground/src/bootstrap.ts @@ -25,6 +25,7 @@ map: { 'index': 'index.js', '@angular/common': '/packages-dist/common/bundles/common.umd.js', + '@angular/animation': '/packages-dist/common/bundles/animation.umd.js', '@angular/compiler': '/packages-dist/compiler/bundles/compiler.umd.js', '@angular/core': '/packages-dist/core/bundles/core.umd.js', '@angular/forms': '/packages-dist/forms/bundles/forms.umd.js', @@ -60,6 +61,7 @@ packages: { 'app': {defaultExtension: 'js'}, '@angular/common': {main: 'index.js', defaultExtension: 'js'}, + '@angular/animation': {main: 'index.js', defaultExtension: 'js'}, '@angular/compiler': {main: 'index.js', defaultExtension: 'js'}, '@angular/core': {main: 'index.js', defaultExtension: 'js'}, '@angular/forms': {main: 'index.js', defaultExtension: 'js'}, diff --git a/tools/gulp-tasks/public-api.js b/tools/gulp-tasks/public-api.js index ed8b9a7010..f9fd5f55aa 100644 --- a/tools/gulp-tasks/public-api.js +++ b/tools/gulp-tasks/public-api.js @@ -1,14 +1,11 @@ const entrypoints = [ - 'dist/packages-dist/core/index.d.ts', - 'dist/packages-dist/core/testing/index.d.ts', - 'dist/packages-dist/common/index.d.ts', - 'dist/packages-dist/common/testing/index.d.ts', + 'dist/packages-dist/core/index.d.ts', 'dist/packages-dist/core/testing/index.d.ts', + 'dist/packages-dist/common/index.d.ts', 'dist/packages-dist/common/testing/index.d.ts', // The API surface of the compiler is currently unstable - all of the important APIs are exposed // via @angular/core, @angular/platform-browser or @angular/platform-browser-dynamic instead. //'dist/packages-dist/compiler/index.d.ts', //'dist/packages-dist/compiler/testing.d.ts', - 'dist/packages-dist/upgrade/index.d.ts', - 'dist/packages-dist/upgrade/static.d.ts', + 'dist/packages-dist/upgrade/index.d.ts', 'dist/packages-dist/upgrade/static.d.ts', 'dist/packages-dist/platform-browser/index.d.ts', 'dist/packages-dist/platform-browser/testing/index.d.ts', 'dist/packages-dist/platform-browser-dynamic/index.d.ts', @@ -16,11 +13,9 @@ const entrypoints = [ 'dist/packages-dist/platform-webworker/index.d.ts', 'dist/packages-dist/platform-webworker-dynamic/index.d.ts', 'dist/packages-dist/platform-server/index.d.ts', - 'dist/packages-dist/platform-server/testing/index.d.ts', - 'dist/packages-dist/http/index.d.ts', - 'dist/packages-dist/http/testing/index.d.ts', - 'dist/packages-dist/forms/index.d.ts', - 'dist/packages-dist/router/index.d.ts', + 'dist/packages-dist/platform-server/testing/index.d.ts', 'dist/packages-dist/http/index.d.ts', + 'dist/packages-dist/http/testing/index.d.ts', 'dist/packages-dist/forms/index.d.ts', + 'dist/packages-dist/router/index.d.ts', 'dist/packages-dist/animation/index.d.ts' ]; const publicApiDir = 'tools/public_api_guard'; diff --git a/tools/public_api_guard/animation/index.d.ts b/tools/public_api_guard/animation/index.d.ts new file mode 100644 index 0000000000..e28fa46570 --- /dev/null +++ b/tools/public_api_guard/animation/index.d.ts @@ -0,0 +1,86 @@ +/** @experimental */ +export declare function animate(timings: string | number, styles?: AnimationStyleMetadata | AnimationKeyframesSequenceMetadata): AnimationAnimateMetadata; + +/** @experimental */ +export declare class Animation { + constructor(input: AnimationMetadata | AnimationMetadata[]); + buildTimelines(startingStyles: StyleData | StyleData[], destinationStyles: StyleData | StyleData[]): AnimationTimelineInstruction[]; +} + +/** @experimental */ +export interface AnimationGroupMetadata extends AnimationMetadata { + steps: AnimationMetadata[]; +} + +/** @experimental */ +export interface AnimationKeyframesSequenceMetadata extends AnimationMetadata { + steps: AnimationStyleMetadata[]; +} + +/** @experimental */ +export declare class AnimationModule { +} + +/** @experimental */ +export interface AnimationSequenceMetadata extends AnimationMetadata { + steps: AnimationMetadata[]; +} + +/** @experimental */ +export interface AnimationStateMetadata extends AnimationMetadata { + name: string; + styles: AnimationStyleMetadata; +} + +/** @experimental */ +export interface AnimationStyleMetadata extends AnimationMetadata { + offset: number; + styles: StyleData[]; +} + +/** @experimental */ +export interface AnimationTransitionMetadata extends AnimationMetadata { + animation: AnimationMetadata; + expr: string | ((fromState: string, toState: string) => boolean); +} + +/** @experimental */ +export declare class AnimationTrigger implements Trigger { + name: string; + states: { + [stateName: string]: StyleData; + }; + transitionFactories: AnimationTransitionFactory[]; + constructor(name: string, states: { + [stateName: string]: StyleData; + }, _transitionAsts: AnimationTransitionMetadata[]); + matchTransition(currentState: any, nextState: any): AnimationTransitionInstruction; +} + +/** @experimental */ +export declare const AUTO_STYLE = "*"; + +/** @experimental */ +export declare function group(steps: AnimationMetadata[]): AnimationGroupMetadata; + +/** @experimental */ +export declare function keyframes(steps: AnimationStyleMetadata[]): AnimationKeyframesSequenceMetadata; + +/** @experimental */ +export declare function sequence(steps: AnimationMetadata[]): AnimationSequenceMetadata; + +/** @experimental */ +export declare function state(name: string, styles: AnimationStyleMetadata): AnimationStateMetadata; + +/** @experimental */ +export declare function style(tokens: { + [key: string]: string | number; +} | Array<{ + [key: string]: string | number; +}>): AnimationStyleMetadata; + +/** @experimental */ +export declare function transition(stateChangeExpr: string | ((fromState: string, toState: string) => boolean), steps: AnimationMetadata | AnimationMetadata[]): AnimationTransitionMetadata; + +/** @experimental */ +export declare function trigger(name: string, definitions: AnimationMetadata[]): AnimationTrigger; diff --git a/tools/public_api_guard/core/index.d.ts b/tools/public_api_guard/core/index.d.ts index c13290b996..92395c1e03 100644 --- a/tools/public_api_guard/core/index.d.ts +++ b/tools/public_api_guard/core/index.d.ts @@ -69,6 +69,7 @@ export declare abstract class AnimationPlayer { abstract getPosition(): number; abstract hasStarted(): boolean; abstract init(): void; + abstract onDestroy(fn: () => void): void; abstract onDone(fn: () => void): void; abstract onStart(fn: () => void): void; abstract pause(): void; @@ -989,6 +990,15 @@ export interface TrackByFunction { /** @experimental */ export declare function transition(stateChangeExpr: string | ((fromState: string, toState: string) => boolean), steps: AnimationMetadata | AnimationMetadata[]): AnimationStateTransitionMetadata; +/** @experimental */ +export interface TransitionFactory { + match(currentState: any, nextState: any): TransitionInstruction; +} + +/** @experimental */ +export interface TransitionInstruction { +} + /** @experimental */ export declare const TRANSLATIONS: InjectionToken; @@ -998,6 +1008,12 @@ export declare const TRANSLATIONS_FORMAT: InjectionToken; /** @experimental */ export declare function trigger(name: string, animation: AnimationMetadata[]): AnimationEntryMetadata; +/** @experimental */ +export interface Trigger { + name: string; + transitionFactories: TransitionFactory[]; +} + /** @stable */ export declare const Type: FunctionConstructor; diff --git a/tools/validate-commit-message/commit-message.json b/tools/validate-commit-message/commit-message.json index 96f5280bf6..7387de4efc 100644 --- a/tools/validate-commit-message/commit-message.json +++ b/tools/validate-commit-message/commit-message.json @@ -13,6 +13,7 @@ ], "scopes": [ "aio", + "animations", "benchpress", "common", "compiler",