├── .babelrc ├── .gitignore ├── .travis.yml ├── README.md ├── demo.gif ├── example ├── bundle.js ├── index.d.ts ├── index.html ├── index.tsx ├── style.css └── webpack.config.js ├── index.d.ts ├── index.js ├── index.tsx ├── package-lock.json ├── package.json ├── test ├── index.html ├── server.js └── test.js └── tsconfig.json /.babelrc: -------------------------------------------------------------------------------- 1 | { "presets": ["react", "es2015"] } 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | cache: 3 | directories: 4 | - node_modules 5 | - test/node_modules 6 | node_js: 7 | - '10' 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-animate-on-change 2 | 3 | [![Build Status](https://travis-ci.org/arve0/react-animate-on-change.svg?branch=master)](https://travis-ci.org/arve0/react-animate-on-change) 4 | 5 | Animate your react components on props or state changes, in contrast to [entries added/removed from arrays](https://facebook.github.io/react/docs/animation.html). 6 | 7 | 8 | 9 | ## Install 10 | ```sh 11 | npm install react-animate-on-change react 12 | ``` 13 | 14 | ## Usage 15 | ```js 16 | import AnimateOnChange from 'react-animate-on-change' 17 | // CommonJS: 18 | // const AnimateOnChange = require('react-animate-on-change').default 19 | 20 | // functional component 21 | const Score = (props) => 22 | 26 | Score: {props.score} 27 | 28 | ``` 29 | 30 | The example above will (roughly) render to: 31 | 32 | **On enter or changes in `props.diff` or `props.score`:** 33 | ```html 34 | 35 | Score: 100 36 | 37 | ``` 38 | 39 | **On animation end:** 40 | ```html 41 | 42 | Score: 100 43 | 44 | ``` 45 | 46 | Also, [see the example folder](example). 47 | 48 | ## Props 49 | `baseClassName {string}` : Base class name that be added to the component. 50 | 51 | `animationClassName {string}` : Animation class name. Added when `animate == true`. Removed when the event [`animationend`](http://www.w3.org/TR/css3-animations/#animationend) is triggered. 52 | 53 | `animate {bool}` : Whether component should animate. 54 | 55 | `customTag {string}` : HTML tag of the component. 56 | 57 | `onAnimationEnd {() => void)}` : Callback which is called when animation ends. 58 | 59 | ## Develop 60 | Setup: 61 | ```sh 62 | npm install 63 | cd test 64 | npm install 65 | cd .. 66 | ``` 67 | 68 | Add tests in [test/client-tests.js](client-tests.js), start tests with: 69 | ``` 70 | npm test 71 | ``` 72 | 73 | Build and view example: 74 | ``` 75 | npm run build-example 76 | open example/index.html 77 | ``` 78 | 79 | ## Known issues 80 | - The browser must support CSS3 animations. 81 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arve0/react-animate-on-change/5e1cd6cc8d3fe1c9cb45608a643710de86ede7f2/demo.gif -------------------------------------------------------------------------------- /example/index.d.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | react-animate-on-change exmaple 8 | 45 | 46 | 47 | 48 |
49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /example/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import { createStore, Store } from 'redux' 4 | import { connect, Provider } from 'react-redux' 5 | import AnimateOnChange from '../index.js' 6 | 7 | const initialState = { 8 | diff: 0, 9 | score: 0 10 | } 11 | 12 | interface Action { 13 | type: string, 14 | diff: number 15 | } 16 | 17 | const reducer = (state = initialState, action: Action) => { 18 | switch (action.type) { 19 | case 'INCREMENT_SCORE': 20 | return { ...state, 21 | diff: action.diff, 22 | score: state.score + action.diff } 23 | default: 24 | return state 25 | } 26 | } 27 | 28 | const store: Store = createStore(reducer) 29 | setInterval(() => { 30 | store.dispatch({ 31 | type: 'INCREMENT_SCORE', 32 | diff: 10 33 | }) 34 | }, 2000) 35 | 36 | interface Props { 37 | diff: number, 38 | score: number, 39 | } 40 | 41 | function handleClick(): void { 42 | console.log('click!'); 43 | } 44 | 45 | const AppComponent = ({ diff, score }: Props) => 46 |
47 | 54 | Score: {score} 55 | 56 |
57 | 58 | const App = connect(s => s)(AppComponent) 59 | 60 | // @ts-ignore 61 | ReactDOM.render(, 62 | document.getElementById('root')) 63 | -------------------------------------------------------------------------------- /example/style.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arve0/react-animate-on-change/5e1cd6cc8d3fe1c9cb45608a643710de86ede7f2/example/style.css -------------------------------------------------------------------------------- /example/webpack.config.js: -------------------------------------------------------------------------------- 1 | var path = require('path') 2 | 3 | module.exports = { 4 | entry: path.resolve(__dirname, 'index.tsx'), 5 | output: { 6 | path: path.resolve(__dirname), 7 | filename: 'bundle.js' 8 | }, 9 | module: { 10 | rules: [ 11 | { 12 | test: /\.tsx?$/, 13 | use: 'ts-loader', 14 | exclude: /node_modules/ 15 | }, 16 | ] 17 | }, 18 | resolve: { 19 | extensions: [ '.tsx', '.ts', '.js' ] 20 | }, 21 | mode: 'development' 22 | } 23 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | import { Component } from 'react'; 2 | export interface Props { 3 | children: any; 4 | animate: boolean; 5 | baseClassName: string; 6 | animationClassName: string; 7 | customTag?: string; 8 | onAnimationEnd?: () => void; 9 | } 10 | export interface State { 11 | animating: boolean; 12 | clearAnimationClass: boolean; 13 | } 14 | interface AnimateOnChange { 15 | elm: HTMLElement; 16 | setElementRef: (ref: HTMLElement) => void; 17 | } 18 | /** 19 | * # AnimateOnChange component. 20 | * Adds `animationClassName` when `animate` is true, then removes 21 | * `animationClassName` when animation is done (event `animationend` is 22 | * triggered). 23 | * 24 | * @prop {string} baseClassName - Base class name. 25 | * @prop {string} animationClassName - Class added when `animate == true`. 26 | * @prop {bool} animate - Wheter to animate component. 27 | */ 28 | declare class AnimateOnChange extends Component implements AnimateOnChange { 29 | constructor(props: Props); 30 | componentDidMount(): void; 31 | componentWillUnmount(): void; 32 | addEventListener(type: string, elm: HTMLElement, eventHandler: (e: Event) => void): void; 33 | removeEventListeners(type: string, elm: HTMLElement, eventHandler: (e: Event) => void): void; 34 | updateEvents(type: string, newEvent: string): void; 35 | animationStart(e: Event): void; 36 | animationEnd(e: Event): void; 37 | shouldComponentUpdate(nextProps: Props, nextState: State): boolean; 38 | render(): JSX.Element; 39 | } 40 | export default AnimateOnChange; 41 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __extends = (this && this.__extends) || (function () { 3 | var extendStatics = Object.setPrototypeOf || 4 | ({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) || 5 | function (d, b) { for (var p in b) if (b.hasOwnProperty(p)) d[p] = b[p]; }; 6 | return function (d, b) { 7 | extendStatics(d, b); 8 | function __() { this.constructor = d; } 9 | d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __()); 10 | }; 11 | })(); 12 | var __assign = (this && this.__assign) || Object.assign || function(t) { 13 | for (var s, i = 1, n = arguments.length; i < n; i++) { 14 | s = arguments[i]; 15 | for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) 16 | t[p] = s[p]; 17 | } 18 | return t; 19 | }; 20 | var __rest = (this && this.__rest) || function (s, e) { 21 | var t = {}; 22 | for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0) 23 | t[p] = s[p]; 24 | if (s != null && typeof Object.getOwnPropertySymbols === "function") 25 | for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) if (e.indexOf(p[i]) < 0) 26 | t[p[i]] = s[p[i]]; 27 | return t; 28 | }; 29 | var __importStar = (this && this.__importStar) || function (mod) { 30 | if (mod && mod.__esModule) return mod; 31 | var result = {}; 32 | if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) result[k] = mod[k]; 33 | result["default"] = mod; 34 | return result; 35 | }; 36 | Object.defineProperty(exports, "__esModule", { value: true }); 37 | var react_1 = __importStar(require("react")); 38 | var events = { 39 | start: ['animationstart', 'webkitAnimationStart', 'mozAnimationStart', 'oanimationstart', 'MSAnimationStart'], 40 | end: ['animationend', 'webkitAnimationEnd', 'mozAnimationEnd', 'oanimationend', 'MSAnimationEnd'], 41 | startRemoved: [], 42 | endRemoved: [] 43 | }; 44 | /** 45 | * # AnimateOnChange component. 46 | * Adds `animationClassName` when `animate` is true, then removes 47 | * `animationClassName` when animation is done (event `animationend` is 48 | * triggered). 49 | * 50 | * @prop {string} baseClassName - Base class name. 51 | * @prop {string} animationClassName - Class added when `animate == true`. 52 | * @prop {bool} animate - Wheter to animate component. 53 | */ 54 | var AnimateOnChange = /** @class */ (function (_super) { 55 | __extends(AnimateOnChange, _super); 56 | function AnimateOnChange(props) { 57 | var _this = _super.call(this, props) || this; 58 | _this.state = { animating: false, clearAnimationClass: false }; 59 | _this.animationStart = _this.animationStart.bind(_this); 60 | _this.animationEnd = _this.animationEnd.bind(_this); 61 | _this.setElementRef = function (ref) { 62 | _this.elm = ref; 63 | }; 64 | return _this; 65 | } 66 | AnimateOnChange.prototype.componentDidMount = function () { 67 | this.addEventListener('start', this.elm, this.animationStart); 68 | this.addEventListener('end', this.elm, this.animationEnd); 69 | }; 70 | AnimateOnChange.prototype.componentWillUnmount = function () { 71 | this.removeEventListeners('start', this.elm, this.animationStart); 72 | this.removeEventListeners('end', this.elm, this.animationEnd); 73 | }; 74 | AnimateOnChange.prototype.addEventListener = function (type, elm, eventHandler) { 75 | // until an event has been triggered bind them all 76 | events[type].map(function (event) { 77 | // console.log(`adding ${event}`) 78 | // @ts-ignore 79 | elm.addEventListener(event, eventHandler); 80 | }); 81 | }; 82 | AnimateOnChange.prototype.removeEventListeners = function (type, elm, eventHandler) { 83 | events[type].map(function (event) { 84 | // console.log(`removing ${event}`) 85 | // @ts-ignore 86 | elm.removeEventListener(event, eventHandler); 87 | }); 88 | }; 89 | AnimateOnChange.prototype.updateEvents = function (type, newEvent) { 90 | // console.log(`updating ${type} event to ${newEvent}`) 91 | events[type + 'Removed'] = events[type].filter(function (e) { return e !== newEvent; }); 92 | events[type] = [newEvent]; 93 | }; 94 | AnimateOnChange.prototype.animationStart = function (e) { 95 | if (events['start'].length > 1) { 96 | this.updateEvents('start', e.type); 97 | this.removeEventListeners('startRemoved', this.elm, this.animationStart); 98 | } 99 | this.setState({ animating: true, clearAnimationClass: false }); 100 | }; 101 | AnimateOnChange.prototype.animationEnd = function (e) { 102 | if (events['end'].length > 1) { 103 | this.updateEvents('end', e.type); 104 | this.removeEventListeners('endRemoved', this.elm, this.animationStart); 105 | } 106 | // send separate, animation state change will not render 107 | this.setState({ clearAnimationClass: true }); // renders 108 | this.setState({ animating: false, clearAnimationClass: false }); 109 | if (typeof this.props.onAnimationEnd === 'function') { 110 | this.props.onAnimationEnd(); 111 | } 112 | }; 113 | AnimateOnChange.prototype.shouldComponentUpdate = function (nextProps, nextState) { 114 | if (this.state.animating !== nextState.animating) { 115 | // do not render on animation change 116 | return false; 117 | } 118 | return true; 119 | }; 120 | AnimateOnChange.prototype.render = function () { 121 | var clearAnimationClass = this.state.clearAnimationClass; 122 | var _a = this.props, baseClassName = _a.baseClassName, animate = _a.animate, animationClassName = _a.animationClassName, customTag = _a.customTag, children = _a.children, onAnimationEnd = _a.onAnimationEnd, // unpack, such that otherProps does not contain it 123 | otherProps = __rest(_a, ["baseClassName", "animate", "animationClassName", "customTag", "children", "onAnimationEnd"]); 124 | var className = baseClassName; 125 | if (animate && !clearAnimationClass) { 126 | className += " " + animationClassName; 127 | } 128 | var Tag = customTag || 'span'; 129 | return react_1.default.createElement(Tag, __assign({ ref: this.setElementRef, className: className }, otherProps), children); 130 | }; 131 | return AnimateOnChange; 132 | }(react_1.Component)); 133 | exports.default = AnimateOnChange; 134 | -------------------------------------------------------------------------------- /index.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component, ReactInstance } from 'react' 2 | 3 | interface Events { 4 | [index:string]: string[] 5 | } 6 | 7 | const events: Events = { 8 | start: ['animationstart', 'webkitAnimationStart', 'mozAnimationStart', 'oanimationstart', 'MSAnimationStart'], 9 | end: ['animationend', 'webkitAnimationEnd', 'mozAnimationEnd', 'oanimationend', 'MSAnimationEnd'], 10 | startRemoved: [], 11 | endRemoved: [] 12 | } 13 | 14 | export interface Props { 15 | children: any, 16 | animate: boolean, 17 | baseClassName: string, 18 | animationClassName: string, 19 | customTag?: string, 20 | onAnimationEnd?: () => void, 21 | } 22 | 23 | export interface State { 24 | animating: boolean, 25 | clearAnimationClass: boolean 26 | } 27 | 28 | interface AnimateOnChange { 29 | elm: HTMLElement, 30 | setElementRef: (ref: HTMLElement) => void 31 | } 32 | 33 | /** 34 | * # AnimateOnChange component. 35 | * Adds `animationClassName` when `animate` is true, then removes 36 | * `animationClassName` when animation is done (event `animationend` is 37 | * triggered). 38 | * 39 | * @prop {string} baseClassName - Base class name. 40 | * @prop {string} animationClassName - Class added when `animate == true`. 41 | * @prop {bool} animate - Wheter to animate component. 42 | */ 43 | class AnimateOnChange extends Component implements AnimateOnChange { 44 | constructor (props: Props) { 45 | super(props) 46 | this.state = { animating: false, clearAnimationClass: false } 47 | this.animationStart = this.animationStart.bind(this) 48 | this.animationEnd = this.animationEnd.bind(this) 49 | 50 | this.setElementRef = (ref) => { 51 | this.elm = ref 52 | } 53 | } 54 | 55 | componentDidMount () { 56 | this.addEventListener('start', this.elm, this.animationStart) 57 | this.addEventListener('end', this.elm, this.animationEnd) 58 | } 59 | 60 | componentWillUnmount () { 61 | this.removeEventListeners('start', this.elm, this.animationStart) 62 | this.removeEventListeners('end', this.elm, this.animationEnd) 63 | } 64 | 65 | addEventListener (type: string, elm: HTMLElement, eventHandler: (e: Event) => void) { 66 | // until an event has been triggered bind them all 67 | events[type].map(event => { 68 | // console.log(`adding ${event}`) 69 | // @ts-ignore 70 | elm.addEventListener(event, eventHandler) 71 | }) 72 | } 73 | 74 | removeEventListeners (type: string, elm: HTMLElement, eventHandler: (e: Event) => void) { 75 | events[type].map(event => { 76 | // console.log(`removing ${event}`) 77 | // @ts-ignore 78 | elm.removeEventListener(event, eventHandler) 79 | }) 80 | } 81 | 82 | updateEvents (type: string, newEvent: string) { 83 | // console.log(`updating ${type} event to ${newEvent}`) 84 | events[type + 'Removed'] = events[type].filter(e => e !== newEvent) 85 | events[type] = [newEvent] 86 | } 87 | 88 | animationStart (e: Event) { 89 | if (events['start'].length > 1) { 90 | this.updateEvents('start', e.type) 91 | this.removeEventListeners('startRemoved', this.elm, this.animationStart) 92 | } 93 | this.setState({ animating: true, clearAnimationClass: false }) 94 | } 95 | 96 | animationEnd (e: Event) { 97 | if (events['end'].length > 1) { 98 | this.updateEvents('end', e.type) 99 | this.removeEventListeners('endRemoved', this.elm, this.animationStart) 100 | } 101 | // send separate, animation state change will not render 102 | this.setState({ clearAnimationClass: true }) // renders 103 | this.setState({ animating: false, clearAnimationClass: false }) 104 | 105 | if (typeof this.props.onAnimationEnd === 'function') { 106 | this.props.onAnimationEnd() 107 | } 108 | } 109 | 110 | shouldComponentUpdate (nextProps: Props, nextState: State) { 111 | if (this.state.animating !== nextState.animating) { 112 | // do not render on animation change 113 | return false 114 | } 115 | return true 116 | } 117 | 118 | render () { 119 | const { clearAnimationClass } = this.state; 120 | const { 121 | baseClassName, 122 | animate, 123 | animationClassName, 124 | customTag, 125 | children, 126 | onAnimationEnd, // unpack, such that otherProps does not contain it 127 | ...otherProps 128 | } = this.props; 129 | 130 | let className = baseClassName 131 | 132 | if (animate && !clearAnimationClass) { 133 | className += ` ${animationClassName}` 134 | } 135 | 136 | let Tag = customTag || 'span'; 137 | 138 | return 139 | {children} 140 | 141 | } 142 | } 143 | 144 | export default AnimateOnChange 145 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-animate-on-change", 3 | "version": "2.2.0", 4 | "description": "Animate your components on state change", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "tsc", 8 | "build-example": "cd example && webpack", 9 | "postversion": "npm run build && npm run build-example && git add . && git commit --amend --no-edit", 10 | "postpublish": "git push && git push --tags", 11 | "test": "npm run build && mocha" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/arve0/react-animate-on-change.git" 16 | }, 17 | "keywords": [ 18 | "react-component", 19 | "react", 20 | "animate", 21 | "animation", 22 | "css" 23 | ], 24 | "author": "Arve Seljebu", 25 | "license": "MIT", 26 | "bugs": { 27 | "url": "https://github.com/arve0/react-animate-on-change/issues" 28 | }, 29 | "homepage": "https://github.com/arve0/react-animate-on-change#readme", 30 | "devDependencies": { 31 | "@types/node": "^10.3.1", 32 | "@types/react": "^16.3.16", 33 | "@types/react-dom": "^16.0.6", 34 | "@types/react-redux": "^6.0.2", 35 | "debug": "^3.1.0", 36 | "mocha": "^6.2.3", 37 | "puppeteer-firefox": "^0.5.0", 38 | "react": ">15.0.0", 39 | "react-dom": ">15.0.0", 40 | "react-redux": "^5.0.7", 41 | "redux": "^4.0.0", 42 | "ts-loader": "^4.4.1", 43 | "typescript": "^2.9.1", 44 | "webpack": "^4.42.1", 45 | "webpack-cli": "^3.0.3" 46 | }, 47 | "peerDependencies": { 48 | "react": ">15.0.0" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /test/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Browser testing with puppeteer 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /test/server.js: -------------------------------------------------------------------------------- 1 | const http = require("http"); 2 | const fs = require("fs"); 3 | const { normalize, join } = require("path"); 4 | 5 | let rootPath; 6 | const mimeTypes = { 7 | 'html': 'text/html', 8 | 'jpeg': 'image/jpeg', 9 | 'jpg': 'image/jpeg', 10 | 'png': 'image/png', 11 | 'js': 'text/javascript', 12 | 'css': 'text/css', 13 | 'default': 'text/plain', 14 | }; 15 | 16 | const webserver = http.createServer(sendFile); 17 | 18 | function sendFile(request, response) { 19 | let path = request.url || ''; 20 | let safePath = normalize(path).replace('^(\.\.[\/\\])+', ''); 21 | if (safePath === '' || safePath === '/' || safePath === '\\') { 22 | safePath = 'index.html'; 23 | } 24 | let filename = join(rootPath, safePath); 25 | if (!fs.existsSync(filename)) { 26 | console.log('Server: 404 - ' + request.method + ': ' + request.url); 27 | response.statusCode = 404; 28 | response.end('File not found.'); 29 | } 30 | else { 31 | // console.log('Server: 200 - ' + request.method + ': ' + request.url); 32 | let headers = { 33 | // @ts-ignore 34 | 'Content-Type': mimeTypes[filename.split('.').pop()] || mimeTypes['default'] 35 | }; 36 | response.writeHead(200, headers); 37 | response.end(fs.readFileSync(filename)); 38 | } 39 | } 40 | 41 | function start(path = __dirname, port = 8888) { 42 | rootPath = path 43 | return new Promise((resolve, reject) => { 44 | webserver.listen(port, (err) => { 45 | if (err) { 46 | return reject(err); 47 | } 48 | console.log(`Server: Listening on port ${port}`); 49 | resolve(); 50 | }); 51 | }); 52 | } 53 | exports.start = start; 54 | function shutdown() { 55 | webserver.close(); 56 | } 57 | exports.shutdown = shutdown; 58 | //# sourceMappingURL=server.js.map 59 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | const server = require('./server') 2 | const configuration = require('../package.json') 3 | const puppeteer = configuration.devDependencies.puppeteer 4 | ? require('puppeteer') 5 | : require('puppeteer-firefox'); 6 | const path = require('path') 7 | const assert = require('assert') 8 | 9 | // configuration 10 | const rootPath = path.join(__dirname, '..') // which folder to serve over http 11 | const port = 8888 // which port to use for http server 12 | const mainPage = `http://localhost:${port}/test/index.html` 13 | const headless = true // false: show browser, true: hide browser 14 | const slowMo = false // true: each browser action will take 100 milliseconds 15 | ? 100 16 | : 0 17 | 18 | const ANIMATION_TIME = slowMo ? 1000 : 100 19 | const ANIMATION_SETTLE = slowMo ? 500 : 50 20 | const STYLE = ` 21 | #root { 22 | margin-top: 50vh; 23 | margin-left: 50vw; 24 | } 25 | .base { 26 | background-color: black; 27 | color: white; 28 | border-radius: 3px; 29 | padding: 5px; 30 | width: 100px; 31 | } 32 | .fade { 33 | animation-name: fade-in; 34 | animation-duration: ${ANIMATION_TIME}ms; 35 | } 36 | @keyframes fade-in { 37 | 0% { opacity: 0; } 38 | 100% { opacity: 1; } 39 | }` 40 | 41 | // globals 42 | let browser = null 43 | let page = null 44 | 45 | before(async function () { 46 | this.timeout(30 * 1000) // starting browser may take more than 2 seconds 47 | 48 | await server.start(rootPath, port) 49 | browser = await puppeteer.launch({ headless, slowMo }) 50 | page = (await browser.pages())[0] 51 | 52 | page.on('console', async function (msg) { 53 | if (msg.type() === 'error' && msg.args().length) { 54 | let args = await Promise.all(msg.args().map(arg => arg.jsonValue())) 55 | console.error("Browser console.error:", ...args) 56 | } else { 57 | console.log(msg.text()) 58 | } 59 | }) 60 | }) 61 | 62 | beforeEach(async function () { 63 | await page.goto(mainPage) 64 | await page.addStyleTag({ content: STYLE }) 65 | await page.evaluate(setup) 66 | }) 67 | 68 | after(function () { 69 | browser.close() 70 | server.shutdown() 71 | }) 72 | 73 | describe('tests', function () { 74 | this.timeout(slowMo === 0 ? 2000 : 0) 75 | 76 | it('should render to dom', async () => { 77 | await page.evaluate(renderAnimated) 78 | await page.waitFor(10) 79 | const children = await page.evaluate(() => root.children.length) 80 | assert.equal(children, 1, 'no children in root') 81 | const tag = await page.evaluate(() => root.children[0].tagName) 82 | assert.equal(tag, 'SPAN', `custom tag 'span' not rendered, got ${tag}`) 83 | }) 84 | 85 | it('animation class name is added on enter', async () => { 86 | await page.evaluate(renderAnimated) 87 | await page.waitFor(10) 88 | const fadeElement = await page.$('.fade') 89 | assert.notDeepStrictEqual(fadeElement, null, 'animation class not added') 90 | }) 91 | 92 | it('removes animation class', async () => { 93 | await page.evaluate(renderAnimated) 94 | await page.waitFor(ANIMATION_TIME + ANIMATION_SETTLE) 95 | const fadeElement = await page.$('.fade') 96 | assert.deepStrictEqual(fadeElement, null, 'animation class not removed') 97 | }) 98 | 99 | it('adds animation class on props change', async () => { 100 | await page.evaluate(renderUpdateProps, ANIMATION_TIME, ANIMATION_SETTLE) 101 | 102 | await page.waitFor(ANIMATION_TIME + ANIMATION_SETTLE) 103 | let fadeElement = await page.$('.fade') 104 | assert.deepStrictEqual(fadeElement, null, 'animation class not removed') 105 | 106 | await page.waitFor(ANIMATION_SETTLE + 0.5 * ANIMATION_TIME) 107 | const textContent = await page.$eval('.fade', element => element.textContent) 108 | assert.equal(textContent, 'updated text') 109 | }) 110 | 111 | it('define custom tag', async () => { 112 | await page.evaluate(renderAnimated, { tag: 'div' }) 113 | const tag = await page.$eval('#root', root => root.children[0].tagName) 114 | assert.equal(tag, 'DIV', `custom tag 'div' not rendered, got ${tag}`) 115 | }) 116 | 117 | it('calls back when animation complete', async () => { 118 | await page.evaluate(renderAnimated, { cb: true }) 119 | await page.waitFor(ANIMATION_TIME + ANIMATION_SETTLE) 120 | const callCount = await page.$eval('#root', root => root.callCount) 121 | assert.equal(callCount, 1) 122 | }) 123 | 124 | it('should support other props', async () => { 125 | await page.evaluate(renderAnimated, { someProp: 'asdf' }) 126 | const someProp = await page.$eval('.base', base => base.getAttribute('someProp')) 127 | assert.equal(someProp, 'asdf', 'attribute someProp not rendered to dom') 128 | }) 129 | }) 130 | 131 | function setup() { 132 | window.root = document.getElementById('root') 133 | 134 | window.Animated = ({ children = 'text', tag = 'span', cb, ...rest }) => 135 | React.createElement(AnimateOnChange, Object.assign({ 136 | baseClassName: "base", 137 | animationClassName: "fade", 138 | animate: true, 139 | customTag: tag, 140 | onAnimationEnd: cb 141 | }, rest), children) 142 | 143 | class UpdatePropsAfterAnimationTimePlus2xAnimationSettle extends React.Component { 144 | constructor(props) { 145 | super(props) 146 | this.state = { 147 | text: 'initial text' 148 | } 149 | setTimeout(() => { 150 | this.setState({ text: 'updated text' }) 151 | }, props.ANIMATION_TIME + 2 * props.ANIMATION_SETTLE) 152 | } 153 | render() { 154 | return React.createElement(AnimateOnChange, { 155 | baseClassName: "base", 156 | animationClassName: "fade", 157 | animate: true, 158 | }, this.state.text) 159 | } 160 | } 161 | window.UpdatePropsAfterAnimationTimePlus2xAnimationSettle = UpdatePropsAfterAnimationTimePlus2xAnimationSettle 162 | } 163 | 164 | function renderAnimated(props) { 165 | if (props && props.cb) { 166 | props.cb = () => { 167 | if (!root.callCount) { root.callCount = 0 } 168 | root.callCount += 1 169 | } 170 | } 171 | ReactDOM.render(React.createElement(Animated, props), root) 172 | } 173 | 174 | // AnimateOnChange with prop change after ANIMATION_TIME + 2 * ANIMATION_SETTLE 175 | function renderUpdateProps(ANIMATION_TIME, ANIMATION_SETTLE) { 176 | ReactDOM.render(React.createElement(UpdatePropsAfterAnimationTimePlus2xAnimationSettle, { 177 | ANIMATION_TIME, ANIMATION_SETTLE 178 | }), root) 179 | } 180 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "*.tsx" 4 | ], 5 | "compilerOptions": { 6 | "target": "es5", 7 | "module": "commonjs", 8 | "moduleResolution": "node", 9 | "jsx": "react", 10 | "declaration": true, 11 | "strict": true, 12 | "esModuleInterop": true, 13 | "allowSyntheticDefaultImports": true, 14 | } 15 | } 16 | --------------------------------------------------------------------------------