├── .babelrc ├── .editorconfig ├── .eslintrc ├── .gitignore ├── LICENSE ├── README.md ├── gulpfile.babel.js ├── package-lock.json ├── package.json ├── src ├── ReactTransitionGroupPlus.js └── demo │ ├── animates │ ├── animates.jsx │ ├── animates.styl │ └── animation-states.js │ ├── index.jade │ ├── main.js │ ├── main.jsx │ └── style.styl ├── test └── test-helper.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "react", "babel-preset-stage-1"], 3 | "plugins": ["babel-plugin-transform-decorators-legacy"], 4 | "env": { 5 | "development": { 6 | "plugins": [ 7 | ["react-transform", { 8 | "transforms": [{ 9 | "transform": "react-transform-hmr", 10 | "imports": ["react"], 11 | "locals": ["module"] 12 | }] 13 | }] 14 | ] 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | 14 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb", 3 | "ecmaFeatures": { 4 | "jsx": true, 5 | "modules": true 6 | }, 7 | "env": { 8 | "browser": true, 9 | "node": true 10 | }, 11 | "parser": "babel-eslint", 12 | "rules": { 13 | "quotes": [2, "single"], 14 | "react/jsx-uses-react": 2, 15 | "react/jsx-uses-vars": 2, 16 | "react/react-in-jsx-scope": 2, 17 | "comma-dangle": [2, "never"], 18 | "space-after-keywords": [2, "never"], 19 | "react/jsx-quotes": [2, "double"], 20 | "react/prop-types": 0, 21 | "no-use-before-define": 0, 22 | "padded-blocks": 0, 23 | "id-length": [2, { 24 | "min": 3, 25 | "max": 30, 26 | "properties": "never", 27 | "exceptions": ["x", "y", "vx", "vy", "id", "i", "e", "fn"] 28 | }] 29 | }, 30 | "plugins": [ 31 | "react" 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | npm-debug.log 2 | node_modules 3 | public 4 | .DS_Store 5 | rev-manifest.json 6 | config.gypi 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Since this code was forked from React's ReactTransitionGroup.js, significant 2 | lines of codes till fall under React's original BSD license. New code is 3 | licensed under MIT. 4 | 5 | 6 | BSD License 7 | 8 | For React software 9 | 10 | Copyright (c) 2013-present, Facebook, Inc. 11 | All rights reserved. 12 | 13 | Redistribution and use in source and binary forms, with or without modification, 14 | are permitted provided that the following conditions are met: 15 | 16 | * Redistributions of source code must retain the above copyright notice, this 17 | list of conditions and the following disclaimer. 18 | 19 | * Redistributions in binary form must reproduce the above copyright notice, 20 | this list of conditions and the following disclaimer in the documentation 21 | and/or other materials provided with the distribution. 22 | 23 | * Neither the name Facebook nor the names of its contributors may be used to 24 | endorse or promote products derived from this software without specific 25 | prior written permission. 26 | 27 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 28 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 29 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 30 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR 31 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 32 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 33 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 34 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 35 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 36 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 37 | 38 | 39 | The MIT License (MIT) 40 | Copyright (c) 2015 Chang Wang 41 | 42 | Permission is hereby granted, free of charge, to any person obtaining a copy 43 | of this software and associated documentation files (the "Software"), to deal 44 | in the Software without restriction, including without limitation the rights 45 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 46 | copies of the Software, and to permit persons to whom the Software is 47 | furnished to do so, subject to the following conditions: 48 | 49 | The above copyright notice and this permission notice shall be included in all 50 | copies or substantial portions of the Software. 51 | 52 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 53 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 54 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 55 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 56 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 57 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 58 | OR OTHER DEALINGS IN THE SOFTWARE. 59 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React TransitionGroupPlus 2 | 3 | [![npm](https://img.shields.io/npm/v/react-transition-group-plus.svg)](https://www.npmjs.com/package/react-transition-group-plus) [![npm](https://img.shields.io/npm/dt/react-transition-group-plus.svg)](https://npmcharts.com/compare/react-transition-group-plus?minimal=true) 4 | 5 | A drop-in replacement for the original react-addons-transition-group that allows interruptible transitions and specifying transition order. 6 | 7 | **Note that this is not API-compatible with react-transition-group v2** 8 | 9 | ### Installation 10 | 11 | ``` 12 | npm install --save react-transition-group-plus 13 | ``` 14 | 15 | ### Demo 16 | See a **[comparative demo](http://cheapsteak.github.com/react-transition-group-plus/)** between ReactTransitionGroup and TransitionGroupPlus 17 | 18 | Aside from being able to specify transition order, notice how a component's enter transition is aborted and the leave transition runs as soon as a component should no longer be active. 19 | 20 | ### Why? 21 | 22 | ReactTransitionGroup has a few shortcomings 23 | 24 | - **Animation order can't be specified.** 25 | Different components' `componentWillEnter` and `componentWillLeave` always occur simultaneously. 26 | It's difficult to wait for the outgoing component's `componentWillLeave` to finish before running the incoming component's `componentWillEnter`. 27 | 28 | - **The same component's transitions can't be interrupted**. 29 | Once a component's `componentWillEnter` is called, calls to the same component's `componentWillLeave` will be delayed until the enter animation finishes 30 | This problem becomes apparent for page transitions and carousels, when something that's entering might need to immediately exit. 31 | 32 | TransitionGroupPlus builds upon ReactTransitionGroup's existing code to solve these problems. 33 | 34 | 35 | ### Usage 36 | 37 | Usage of TransitionGroupPlus is nearly identical to ReactTransitionGroup. (See the [guide on react's website](https://facebook.github.io/react/docs/animation.html#low-level-api-reacttransitiongroup) on how to use ReactTransitionGroup) 38 | 39 | Additional props: 40 | 41 | - **`transitionMode`** (optional) 42 | can have the following values: 43 | - `simultaneous` _(default)_ 44 | `componentWillEnter` and `componentWillLeave` will be run at the same time. 45 | The `transitionMode` prop can be omitted if simultaneous transitions are desired as this is the default value. 46 | - `out-in` 47 | Wait for the outgoing component's `componentWillLeave` to finish before calling the incoming component's `componentWillEnter`. 48 | Note: 49 | If an incoming component needs to leave while it's still waiting for its `componentWillEnter` to be called, its `componentWillEnter` will be skipped and only its `componentWillLeave` will be called. 50 | - `in-out` 51 | Wait for the incoming component's `componentWillEnter` to finish before calling the outgoing component's `componentWillLeave`. 52 | - **`deferLeavingComponentRemoval`** (optional, boolean, defaults to `false`) 53 | When `true`, children that leave will not be removed immediately after their `componentWillLeave` is called, but will wait for the next component's `componentWillEnter` to finish. 54 | Only affects the transition modes "simultaneous" and "out-in". Has no effect on "in-out". 55 | 56 | ##### sample: 57 | ```js 58 | 59 | ... 60 | 61 | ``` 62 | 63 | ### Browser Support 64 | 65 | This component relies on Promises, which exists natively in most browsers, but a polyfill would be required for IE11 and below. 66 | 67 | Other than that, this should run on all browsers where React runs. 68 | 69 | ### License 70 | 71 | Since this code was forked from React's ReactTransitionGroup, significant lines of codes still fall under React's original BSD license. 72 | 73 | New code is licensed under MIT 74 | 75 | 76 | Inspired by [Vue's transitions](http://vuejs.org/guide/transitions.html#JavaScript_Transitions) 77 | -------------------------------------------------------------------------------- /gulpfile.babel.js: -------------------------------------------------------------------------------- 1 | import browserify from 'browserify'; 2 | import browserSync from 'browser-sync'; 3 | import duration from 'gulp-duration'; 4 | import gulp from 'gulp'; 5 | import hmr from 'browserify-hmr'; 6 | import gutil from 'gulp-util'; 7 | import jade from 'gulp-jade'; 8 | import notifier from 'node-notifier'; 9 | import path from 'path'; 10 | import prefix from 'gulp-autoprefixer'; 11 | import rev from 'gulp-rev'; 12 | import source from 'vinyl-source-stream'; 13 | import exorcist from 'exorcist'; 14 | import transform from 'vinyl-transform'; 15 | import concat from 'gulp-concat'; 16 | import sourcemaps from 'gulp-sourcemaps'; 17 | import streamify from 'gulp-streamify'; 18 | import stylus from 'gulp-stylus'; 19 | import uglify from 'gulp-uglify'; 20 | import watchify from 'watchify'; 21 | import watch from 'gulp-watch'; 22 | import inject from 'gulp-inject'; 23 | 24 | import ghPages from 'gulp-gh-pages'; 25 | 26 | // eslint "no-process-env":0 27 | const production = process.env.NODE_ENV === 'production'; 28 | 29 | const config = require('./package.json').build; 30 | 31 | const browserifyConfig = { 32 | entries: [config.scripts.source], 33 | extensions: config.scripts.extensions, 34 | debug: !production, 35 | cache: {}, 36 | packageCache: {} 37 | }; 38 | 39 | function handleError(err) { 40 | gutil.log(err.message); 41 | gutil.beep(); 42 | notifier.notify({ 43 | title: 'Compile Error', 44 | message: err.message 45 | }); 46 | return this.emit('end'); 47 | } 48 | 49 | gulp.task('scripts', () => { 50 | let pipeline = browserify(browserifyConfig) 51 | .bundle() 52 | .on('error', handleError) 53 | .pipe(source(config.scripts.filename)); 54 | 55 | if(production) { 56 | pipeline = pipeline 57 | .pipe(streamify(uglify())) 58 | .pipe(streamify(rev())); 59 | } else { 60 | pipeline = pipeline.pipe(transform(() => { 61 | return exorcist(path.join(config.scripts.destination, config.scripts.filename) + '.map'); 62 | })); 63 | } 64 | 65 | return pipeline.pipe(gulp.dest(config.scripts.destination)); 66 | }); 67 | 68 | gulp.task('templates', ['styles', 'scripts'], () => { 69 | const resources = gulp.src(config.inject.resources, {read: false}); 70 | 71 | const pipeline = gulp.src(config.templates.source) 72 | .pipe(jade({ 73 | pretty: !production 74 | })) 75 | .on('error', handleError) 76 | .pipe(inject(resources, {ignorePath: ['public', '../../public'], removeTags: true, relative: true})) 77 | .pipe(gulp.dest(config.templates.destination)); 78 | 79 | if(production) { 80 | return pipeline; 81 | } 82 | 83 | return pipeline.pipe(browserSync.reload({ 84 | stream: true 85 | })); 86 | }); 87 | 88 | 89 | /* 90 | * Stylus -> CSS 91 | * Takes all .styl files from src, compiles them separately and then merges them into one file 92 | */ 93 | 94 | gulp.task('styles', () => { 95 | let pipeline = gulp.src(config.styles.source); 96 | 97 | if(!production) { 98 | pipeline = pipeline.pipe(sourcemaps.init()); 99 | } 100 | 101 | pipeline = pipeline.pipe(stylus({ 102 | 'include css': true, 103 | paths: ['node_modules', path.join(__dirname, config.source)], 104 | compress: production 105 | })) 106 | .on('error', handleError) 107 | .pipe(prefix(config.styles.browserVersions)) 108 | .pipe(concat(config.styles.filename)); 109 | 110 | if(production) { 111 | pipeline = pipeline.pipe(rev()); 112 | } else { 113 | pipeline = pipeline.pipe(sourcemaps.write('.')); 114 | } 115 | 116 | pipeline = pipeline.pipe(gulp.dest(config.styles.destination)); 117 | 118 | if(production) { 119 | return pipeline; 120 | } 121 | 122 | return pipeline.pipe(browserSync.stream({ 123 | match: '**/*.css' 124 | })); 125 | }); 126 | 127 | gulp.task('assets', () => { 128 | return gulp.src(config.assets.source) 129 | .pipe(gulp.dest(config.assets.destination)); 130 | }); 131 | 132 | gulp.task('server', () => { 133 | return browserSync({ 134 | open: false, 135 | port: 9001, 136 | notify: false, 137 | ghostMode: false, 138 | server: { 139 | baseDir: config.destination 140 | } 141 | }); 142 | }); 143 | 144 | gulp.task('watch', () => { 145 | 146 | ['templates', 'styles', 'assets'].forEach((watched) => { 147 | watch(config[watched].watch, () => { 148 | gulp.start(watched); 149 | }); 150 | }); 151 | 152 | const bundle = watchify(browserify(browserifyConfig).plugin(hmr)); 153 | 154 | bundle.on('update', () => { 155 | const build = bundle.bundle() 156 | .on('error', handleError) 157 | .pipe(source(config.scripts.filename)); 158 | 159 | build 160 | .pipe(transform(() => { 161 | return exorcist(config.scripts.destination + config.scripts.filename + '.map'); 162 | })) 163 | .pipe(gulp.dest(config.scripts.destination)) 164 | .pipe(duration('Rebundling browserify bundle')); 165 | }).emit('update'); 166 | }); 167 | 168 | gulp.task('deploy:pages', function() { 169 | return gulp.src('./public/**/*') 170 | .pipe(ghPages()); 171 | }); 172 | 173 | gulp.task('build', ['styles', 'assets', 'scripts', 'templates']); 174 | gulp.task('default', ['styles', 'assets', 'templates', 'watch', 'server']); 175 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-transition-group-plus", 3 | "version": "0.5.2", 4 | "description": "More full featured transition group for react", 5 | "files": [ 6 | "*.md", 7 | "src/ReactTransitionGroupPlus.js" 8 | ], 9 | "author": "Chang Wang ", 10 | "license": "BSD", 11 | "main": "src/ReactTransitionGroupPlus.js", 12 | "repository": { 13 | "type": "git", 14 | "url": "git://github.com/cheapsteak/react-transition-group-plus.git" 15 | }, 16 | "homepage": "https://github.com/cheapsteak/react-transition-group-plus", 17 | "scripts": { 18 | "start": "rm -rf public && gulp", 19 | "build": "rm -rf public && gulp build", 20 | "build:production": "rm -rf public && NODE_ENV=production gulp build", 21 | "deploy:pages": "npm run build:production && gulp deploy:pages", 22 | "lint": "eslint src", 23 | "test": "mocha src/**/__tests__/*.js --compilers js:babel-core/register --require test/test-helper" 24 | }, 25 | "keywords": [ 26 | "react", 27 | "transition-group", 28 | "animations" 29 | ], 30 | "dependencies": { 31 | "create-react-class": "^15.5.3", 32 | "lodash.assign": "^4.2.0", 33 | "lodash.difference": "^4.0.2", 34 | "lodash.keyby": "^4.6.0", 35 | "object-assign": "^4.0.1", 36 | "prop-types": "^15.5.10" 37 | }, 38 | "peerDependencies": { 39 | "react": "^15.0.1" 40 | }, 41 | "build": { 42 | "source": "./src", 43 | "destination": "./public", 44 | "scripts": { 45 | "source": "./src/demo/main.js", 46 | "destination": "./public/js/", 47 | "extensions": [], 48 | "filename": "bundle.js" 49 | }, 50 | "templates": { 51 | "source": "./src/demo/*.jade", 52 | "watch": "./src/demo/*.jade", 53 | "destination": "./public/", 54 | "revision": "./public/**/*.html" 55 | }, 56 | "styles": { 57 | "source": "./src/**/*.styl", 58 | "watch": "./src/**/*.styl", 59 | "destination": "./public/css/", 60 | "filename": "style.css", 61 | "browserVersions": [ 62 | "last 2 versions", 63 | "Chrome 34", 64 | "Firefox 28", 65 | "iOS 7" 66 | ] 67 | }, 68 | "assets": { 69 | "source": "./src/assets/**/*.*", 70 | "watch": "./src/assets/**/*.*", 71 | "destination": "./public/" 72 | }, 73 | "inject": { 74 | "resources": [ 75 | "./public/**/*.css", 76 | "./public/**/*.js" 77 | ] 78 | } 79 | }, 80 | "devDependencies": { 81 | "babel": "^6.23.0", 82 | "babel-core": "^6.25.0", 83 | "babel-eslint": "^4.1.3", 84 | "babel-plugin-react-transform": "^2.0.0", 85 | "babel-plugin-transform-decorators-legacy": "^1.3.4", 86 | "babel-preset-es2015": "^6.24.1", 87 | "babel-preset-latest": "^6.24.1", 88 | "babel-preset-react": "^6.24.1", 89 | "babel-preset-stage-0": "^6.24.1", 90 | "babel-runtime": "^5.8.35", 91 | "babelify": "^7.3.0", 92 | "browser-sync": "^2.9.4", 93 | "browserify": "^10.2.1", 94 | "browserify-hmr": "^0.3.1", 95 | "chai": "^3.0.0", 96 | "envify": "^3.4.0", 97 | "eslint": "^1.5.1", 98 | "eslint-config-airbnb": "0.0.9", 99 | "eslint-plugin-react": "^3.4.2", 100 | "exorcist": "^0.4.0", 101 | "gsap-promise": "^1.4.1", 102 | "gulp": "3.9.0", 103 | "gulp-autoprefixer": "1.0.1", 104 | "gulp-concat": "^2.6.0", 105 | "gulp-duration": "0.0.0", 106 | "gulp-gh-pages": "^0.5.4", 107 | "gulp-inject": "^3.0.0", 108 | "gulp-jade": "~0.9.0", 109 | "gulp-replace": "^0.5.3", 110 | "gulp-rev": "^4.0.0", 111 | "gulp-sourcemaps": "^1.3.0", 112 | "gulp-streamify": "0.0.5", 113 | "gulp-stylus": "^2.6.0", 114 | "gulp-uglify": "~1.0.1", 115 | "gulp-util": "~3.0.1", 116 | "gulp-watch": "^4.3.4", 117 | "jsdom": "^5.6.0", 118 | "mocha": "^2.2.5", 119 | "node-notifier": "^4.2.1", 120 | "react": "^15.6.1", 121 | "react-addons-transition-group": "^0.14.6", 122 | "react-dom": "^15.6.1", 123 | "react-radio-group": "^2.2.0", 124 | "react-transform-hmr": "^1.0.1", 125 | "rimraf": "^2.3.4", 126 | "vinyl-source-stream": "~1.0.0", 127 | "vinyl-transform": "^1.0.0", 128 | "watchify": "^3.2.1" 129 | }, 130 | "browserify": { 131 | "transform": [ 132 | [ 133 | "babelify" 134 | ], 135 | [ 136 | "envify" 137 | ] 138 | ] 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/ReactTransitionGroupPlus.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2013-present, Facebook, Inc. 3 | * All rights reserved. 4 | * 5 | * This source code is licensed under the BSD-style license found in the 6 | * LICENSE file in the root directory of this source tree. An additional grant 7 | * of patent rights can be found in the PATENTS file in the same directory. 8 | * 9 | * @providesModule ReactTransitionGroup 10 | */ 11 | 12 | 'use strict'; 13 | 14 | var React = require('react'); 15 | var PropTypes = require('prop-types'); 16 | var createReactClass = require('create-react-class'); 17 | var difference = require('lodash.difference'); 18 | var keyBy = require('lodash.keyby'); 19 | 20 | var assign = require('object-assign'); 21 | 22 | var getChildMapping = function (children) { 23 | return keyBy( 24 | React.Children.toArray( 25 | children 26 | ), 27 | function(child) { 28 | return child.key; 29 | } 30 | ); 31 | }; 32 | 33 | var ReactTransitionGroupPlus = createReactClass({ 34 | displayName: 'ReactTransitionGroupPlus', 35 | 36 | propTypes: { 37 | component: PropTypes.any, 38 | childFactory: PropTypes.func, 39 | transitionMode: PropTypes.oneOf(['in-out', 'out-in', 'simultaneous']), 40 | deferLeavingComponentRemoval: PropTypes.bool, 41 | }, 42 | 43 | getDefaultProps: function() { 44 | return { 45 | component: 'span', 46 | childFactory: function (arg) { 47 | return arg; 48 | }, 49 | transitionMode: 'simultaneous', 50 | deferLeavingComponentRemoval: false, 51 | }; 52 | }, 53 | 54 | getInitialState: function() { 55 | return { 56 | children: getChildMapping(this.props.children), 57 | }; 58 | }, 59 | 60 | componentWillMount: function() { 61 | this.currentlyEnteringOrEnteredKeys = {}; 62 | this.currentlyEnteringKeys = {}; 63 | this.currentlyEnteringPromises = {}; 64 | this.currentlyLeavingKeys = {}; 65 | this.currentlyLeavingPromises = {}; 66 | this.pendingEnterCallbacks = {}; 67 | this.pendingLeaveCallbacks = {}; 68 | this.deferredLeaveRemovalCallbacks = []; 69 | this.keysToEnter = []; 70 | this.keysToLeave = []; 71 | this.cancel = null; 72 | }, 73 | 74 | componentDidMount: function() { 75 | var initialChildMapping = this.state.children; 76 | for (var key in initialChildMapping) { 77 | if (initialChildMapping[key]) { 78 | this.performAppear(key); 79 | } 80 | } 81 | }, 82 | 83 | componentWillReceiveProps: function(nextProps) { 84 | var nextChildMapping = getChildMapping( 85 | nextProps.children 86 | ); 87 | var prevChildMapping = this.state.children; 88 | 89 | var mergedChildMapping = assign({}, 90 | prevChildMapping, 91 | nextChildMapping 92 | ); 93 | this.setState({ 94 | children: mergedChildMapping 95 | }); 96 | 97 | var key; 98 | 99 | for (key in nextChildMapping) { 100 | var hasPrev = prevChildMapping && prevChildMapping.hasOwnProperty(key); 101 | if (nextChildMapping[key] && ( !hasPrev || this.currentlyLeavingKeys[key])) { 102 | this.keysToEnter.push(key); 103 | } 104 | } 105 | 106 | for (key in prevChildMapping) { 107 | var hasNext = nextChildMapping && nextChildMapping.hasOwnProperty(key); 108 | if (prevChildMapping[key] && !hasNext ) { 109 | this.keysToLeave.push(key); 110 | } 111 | } 112 | 113 | if (this.props.transitionMode === 'out-in') { 114 | this.keysToEnter = difference(this.keysToEnter, this.keysToLeave); 115 | } 116 | 117 | // If we want to someday check for reordering, we could do it here. 118 | }, 119 | 120 | componentDidUpdate: function() { 121 | var keysToEnter = this.keysToEnter; 122 | var keysToLeave = this.keysToLeave; 123 | 124 | switch (this.props.transitionMode) { 125 | case 'out-in': 126 | this.keysToLeave = []; 127 | if (keysToLeave.length) { 128 | keysToLeave.forEach(this.performLeave) 129 | } else { 130 | this.keysToEnter = []; 131 | keysToEnter.forEach(this.performEnter) 132 | } 133 | break; 134 | case 'in-out': 135 | this.keysToEnter = []; 136 | this.keysToLeave = []; 137 | 138 | if (keysToEnter.length) { 139 | Promise.all(keysToEnter.map(this.performEnter)) 140 | .then(function () { 141 | keysToLeave.forEach(this.performLeave) 142 | }.bind(this)) 143 | } else { 144 | keysToLeave.forEach(this.performLeave) 145 | } 146 | break; 147 | default: 148 | this.keysToEnter = []; 149 | this.keysToLeave = []; 150 | keysToEnter.forEach(this.performEnter); 151 | keysToLeave.forEach(this.performLeave); 152 | break; 153 | } 154 | }, 155 | 156 | performAppear: function(key) { 157 | this.currentlyEnteringOrEnteredKeys[key] = true; 158 | 159 | var component = this.refs[key]; 160 | 161 | if (component.componentWillAppear) { 162 | component.componentWillAppear( 163 | this._handleDoneAppearing.bind(this, key) 164 | ); 165 | } else { 166 | this._handleDoneAppearing(key); 167 | } 168 | }, 169 | 170 | _handleDoneAppearing: function(key) { 171 | var component = this.refs[key]; 172 | if (component && component.componentDidAppear) { 173 | component.componentDidAppear(); 174 | } 175 | 176 | var currentChildMapping = getChildMapping( 177 | this.props.children 178 | ); 179 | 180 | if (!currentChildMapping || !currentChildMapping.hasOwnProperty(key)) { 181 | // This was removed before it had fully appeared. Remove it. 182 | this.performLeave(key); 183 | } 184 | }, 185 | 186 | performEnter: function(key) { 187 | 188 | if (this.currentlyEnteringKeys[key]) { 189 | return this.currentlyEnteringPromises[key]; 190 | } 191 | 192 | this.cancelPendingLeave(key); 193 | 194 | 195 | var component = this.refs[key]; 196 | 197 | if (!component) { 198 | return Promise.resolve(); 199 | } 200 | 201 | this.currentlyEnteringOrEnteredKeys[key] = true; 202 | this.currentlyEnteringKeys[key] = true; 203 | 204 | var callback = this._handleDoneEntering.bind(this, key); 205 | this.pendingEnterCallbacks[key] = callback; 206 | 207 | var enterPromise = new Promise(function (resolve) { 208 | if (component.componentWillEnter) { 209 | component.componentWillEnter(resolve); 210 | } else { 211 | resolve(); 212 | } 213 | }).then(callback); 214 | 215 | this.currentlyEnteringPromises[key] = enterPromise; 216 | 217 | return enterPromise; 218 | }, 219 | 220 | _handleDoneEntering: function(key) { 221 | delete this.pendingEnterCallbacks[key]; 222 | delete this.currentlyEnteringPromises[key]; 223 | delete this.currentlyEnteringKeys[key]; 224 | 225 | this.deferredLeaveRemovalCallbacks.forEach(function(fn) { fn(); }); 226 | this.deferredLeaveRemovalCallbacks = []; 227 | 228 | var component = this.refs[key]; 229 | if (component && component.componentDidEnter) { 230 | component.componentDidEnter(); 231 | } 232 | 233 | var currentChildMapping = getChildMapping( 234 | this.props.children 235 | ); 236 | 237 | if (!currentChildMapping || !currentChildMapping.hasOwnProperty(key) && this.currentlyEnteringOrEnteredKeys[key]) { 238 | // This was removed before it had fully entered. Remove it. 239 | 240 | if (this.props.transitionMode !== 'in-out') { 241 | this.performLeave(key); 242 | } 243 | } 244 | }, 245 | 246 | performLeave: function(key) { 247 | if (this.currentlyLeavingKeys[key]) { 248 | //already leaving, let it finish 249 | return this.currentlyLeavingPromises[key]; 250 | } 251 | 252 | this.cancelPendingEnter(key); 253 | 254 | var component = this.refs[key]; 255 | 256 | if (!component) { 257 | return Promise.resolve(); 258 | } 259 | 260 | this.currentlyLeavingKeys[key] = true; 261 | 262 | var callback = this._handleDoneLeaving.bind(this, key); 263 | this.pendingLeaveCallbacks[key] = callback; 264 | 265 | var leavePromise = new Promise(function (resolve) { 266 | if (component.componentWillLeave) { 267 | component.componentWillLeave(resolve); 268 | } else { 269 | resolve(); 270 | } 271 | }) 272 | // Note that this is somewhat dangerous b/c it calls setState() 273 | // again, effectively mutating the component before all the work 274 | // is done. 275 | .then(callback); 276 | 277 | this.currentlyLeavingPromises[key] = leavePromise; 278 | return leavePromise; 279 | }, 280 | 281 | _handleDoneLeaving: function(key) { 282 | delete this.pendingLeaveCallbacks[key]; 283 | delete this.currentlyLeavingKeys[key]; 284 | delete this.currentlyLeavingPromises[key]; 285 | 286 | var component = this.refs[key]; 287 | 288 | if (component && component.componentDidLeave) { 289 | component.componentDidLeave(); 290 | } 291 | 292 | 293 | var currentChildMapping = getChildMapping( 294 | this.props.children 295 | ); 296 | 297 | var updateChildren = function updateChildren () { 298 | this.setState(function(state) { 299 | var newChildren = assign({}, state.children); 300 | delete newChildren[key]; 301 | return {children: newChildren}; 302 | }); 303 | }.bind(this); 304 | 305 | if (currentChildMapping && currentChildMapping.hasOwnProperty(key)) { 306 | // This entered again before it fully left. Add it again. 307 | // but only perform enter if currently animating out, not already animated out 308 | if (this.props.transitionMode !== 'in-out') { 309 | this.performEnter(key); 310 | } 311 | } else { 312 | delete this.currentlyEnteringOrEnteredKeys[key]; 313 | 314 | if (this.props.deferLeavingComponentRemoval && this.props.transitionMode !== 'in-out') { 315 | this.deferredLeaveRemovalCallbacks.push(updateChildren); 316 | this.forceUpdate(); 317 | } else { 318 | updateChildren(); 319 | } 320 | } 321 | }, 322 | 323 | cancelPendingLeave: function (key) { 324 | if (this.pendingLeaveCallbacks[key]) { 325 | this.pendingLeaveCallbacks[key](); 326 | delete this.pendingLeaveCallbacks[key]; 327 | } 328 | }, 329 | 330 | cancelPendingEnter: function (key) { 331 | if (this.pendingEnterCallbacks[key]) { 332 | this.pendingEnterCallbacks[key](); 333 | delete this.pendingEnterCallbacks[key]; 334 | } 335 | }, 336 | 337 | cleanProps: function(props) { 338 | delete props.component; 339 | delete props.transitionMode; 340 | delete props.childFactory; 341 | delete props.deferLeavingComponentRemoval; 342 | return props; 343 | }, 344 | 345 | render: function() { 346 | // TODO: we could get rid of the need for the wrapper node 347 | // by cloning a single child 348 | var childrenToRender = []; 349 | for (var key in this.state.children) { 350 | var child = this.state.children[key]; 351 | if (child) { 352 | // You may need to apply reactive updates to a child as it is leaving. 353 | // The normal React way to do it won't work since the child will have 354 | // already been removed. In case you need this behavior you can provide 355 | // a childFactory function to wrap every child, even the ones that are 356 | // leaving. 357 | childrenToRender.push(React.cloneElement( 358 | this.props.childFactory(child), 359 | {ref: key, key: key} 360 | )); 361 | } 362 | } 363 | return React.createElement( 364 | this.props.component, 365 | this.cleanProps(assign({},this.props)), 366 | childrenToRender 367 | ); 368 | }, 369 | }); 370 | 371 | module.exports = ReactTransitionGroupPlus; 372 | -------------------------------------------------------------------------------- /src/demo/animates/animates.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { findDOMNode } from 'react-dom'; 4 | import animationStates from './animation-states.js'; 5 | 6 | export default class Animates extends React.Component { 7 | 8 | static animationStates = animationStates; 9 | 10 | componentDidMount() { 11 | const el = findDOMNode(this); 12 | 13 | this.timeline = new TimelineMax() 14 | .pause() 15 | .add(TweenMax.to(el, 1, Object.assign({}, Animates.animationStates.beforeEnter, {ease: Linear.easeNone}))) 16 | .add('beforeEnter') 17 | .add(TweenMax.to(el, 1, Object.assign({}, Animates.animationStates.idle, {ease: Linear.easeNone}))) 18 | .add('idle') 19 | .add(TweenMax.to(el, 1, Object.assign({}, Animates.animationStates.afterLeave, {ease: Linear.easeNone}))) 20 | .add('afterLeave') 21 | 22 | this.timeline.seek('beforeEnter'); 23 | } 24 | 25 | componentWillAppear(callback) { 26 | this.timeline.seek('idle'); 27 | callback(); 28 | } 29 | 30 | componentWillEnter(callback) { 31 | const el = findDOMNode(this); 32 | 33 | this.timeline.seek('beforeEnter'); 34 | TweenMax.killTweensOf(this.timeline); 35 | TweenMax.to(this.timeline, this.props.enterDuration, { time: this.timeline.getLabelTime('idle'), onComplete: callback, ease: Sine.easeOut }); 36 | } 37 | 38 | componentWillLeave(callback) { 39 | const className = this.props.className; 40 | this.timeline.pause(); 41 | TweenMax.killTweensOf(this.timeline); 42 | TweenMax.to(this.timeline, this.props.leaveDuration, { time: this.timeline.getLabelTime('afterLeave'), onComplete: callback, ease: Sine.easeIn }); 43 | } 44 | 45 | render() { 46 | return
; 47 | } 48 | 49 | } -------------------------------------------------------------------------------- /src/demo/animates/animates.styl: -------------------------------------------------------------------------------- 1 | .animates 2 | position: absolute 3 | top: 0 4 | right: 0 5 | bottom: 0 6 | left: 0 7 | margin: auto 8 | 9 | width: 50px 10 | height: 50px -------------------------------------------------------------------------------- /src/demo/animates/animation-states.js: -------------------------------------------------------------------------------- 1 | export default { 2 | beforeEnter: { y: 100, scale: 1.6, opacity: 0 }, 3 | idle: { y: 0, scale: 1, opacity: 1 }, 4 | afterLeave: { y: -100, scale: 0.7, opacity: 0 }, 5 | }; -------------------------------------------------------------------------------- /src/demo/index.jade: -------------------------------------------------------------------------------- 1 | doctype html 2 | html 3 | head 4 | title React Transition Group Plus 5 | // inject:css 6 | // endinject 7 | script. 8 | (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){ 9 | (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o), 10 | m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m) 11 | })(window,document,'script','https://www.google-analytics.com/analytics.js','ga'); 12 | 13 | ga('create', 'UA-62233935-4', 'auto'); 14 | ga('send', 'pageview'); 15 | script(src="https://cdn.polyfill.io/v2/polyfill.min.js") 16 | body 17 | #container 18 | // inject:js 19 | // endinject 20 | -------------------------------------------------------------------------------- /src/demo/main.js: -------------------------------------------------------------------------------- 1 | require('./main.jsx'); -------------------------------------------------------------------------------- /src/demo/main.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { render, findDOMNode } from 'react-dom'; 4 | import ReactTransitionGroup from 'react-addons-transition-group'; 5 | import ReactTransitionGroupPlus from '../ReactTransitionGroupPlus.js'; 6 | import animate from 'gsap-promise'; 7 | import RadioGroup from 'react-radio-group'; 8 | 9 | import _ from 'lodash'; 10 | 11 | import Animates from './animates/animates.jsx'; 12 | 13 | const colors = [ 14 | 'blue', 15 | 'red', 16 | 'green', 17 | 'orange', 18 | 'purple', 19 | ]; 20 | 21 | class App extends React.Component { 22 | state = { 23 | counter: 0, 24 | transitionMode: 'simultaneous', 25 | transitionGroupComponent: ReactTransitionGroupPlus, 26 | enterDuration: 0.8, 27 | leaveDuration: 0.3, 28 | }; 29 | 30 | handleClick = () => { 31 | this.setState({counter: this.state.counter + 1}); 32 | }; 33 | 34 | handleTransitionModeChange = (e) => { 35 | this.setState({transitionMode: e.target.value}); 36 | }; 37 | 38 | handleTransitionGroupComponentChange = (componentName) => { 39 | this.setState({ 40 | transitionGroupComponent: componentName === 'ReactTransitionGroupPlus' 41 | ? ReactTransitionGroupPlus 42 | : ReactTransitionGroup 43 | }); 44 | }; 45 | 46 | render() { 47 | const color = colors[this.state.counter % 5]; 48 | 49 | const TransitionGroup = this.state.transitionGroupComponent; 50 | const selectedTransitionGroupComponentName = this.state.transitionGroupComponent === ReactTransitionGroupPlus 51 | ? 'ReactTransitionGroupPlus' 52 | : 'ReactTransitionGroup'; 53 | 54 | const transitionModeRadioGroup = TransitionGroup === ReactTransitionGroupPlus && this.setState({transitionMode}) } 58 | > 59 | {Radio => ( 60 |
61 | Transition Mode 62 | 63 | 64 | 65 |
66 | )} 67 |
; 68 | 69 | return
70 |
71 |

72 | 73 | Transition
Group
Plus
74 |
75 | demo 76 |

77 | 82 | {Radio => ( 83 |
84 | 85 | 86 |
87 | )} 88 |
89 | {transitionModeRadioGroup} 90 | 94 | 98 | 101 | 102 |
103 | 104 | npm 105 | 106 | 112 |
113 |
114 | 115 | 121 | 127 | 128 | 129 |
; 130 | } 131 | } 132 | 133 | render(, document.getElementById('container')); -------------------------------------------------------------------------------- /src/demo/style.styl: -------------------------------------------------------------------------------- 1 | body, html 2 | margin: 0 3 | font: 14px/1.4 'Helvetica Neue', Helvetica, Arial 4 | background-color: #31323D 5 | color: #e6e1dc 6 | height: 100% 7 | 8 | #container 9 | height: 100% 10 | 11 | @import "demo/animates/animates.styl"; 12 | 13 | .blue 14 | background-color: blue 15 | .red 16 | background-color: red 17 | .green 18 | background-color: green 19 | .orange 20 | background-color: orange 21 | .purple 22 | background-color: purple 23 | 24 | .blue 25 | background-color: #3498DB 26 | .red 27 | background-color: #B9121B 28 | .green 29 | background-color: #BEDB39 30 | .orange 31 | background-color: #ffa500 32 | .purple 33 | background-color: #9768D1 34 | 35 | 36 | .demo 37 | display: flex 38 | height: 100% 39 | 40 | .control-panel 41 | padding: 1em 2.1em 42 | display: flex 43 | flex-direction: column 44 | & > *:not(:first-child) 45 | margin-top: 1em 46 | h1 47 | font-size: 26px 48 | line-height: 0.94 49 | a 50 | color: inherit 51 | text-decoration: none 52 | &:hover 53 | color: #DCE0F0 54 | small 55 | font-size: 17px 56 | color: #ADCEF0 57 | font-style: italic 58 | legend 59 | font-weight: bold 60 | label 61 | display: block 62 | input[type="range"] 63 | display: block 64 | .radiogroup 65 | label 66 | display: flex 67 | margin-left: -1em 68 | margin-right: -1em 69 | padding: 0.3em 2em 0.3em 1em 70 | align-items: center 71 | cursor: pointer 72 | &:hover 73 | background-color: #414363 74 | span 75 | margin-left: 0.3em 76 | .github-btn 77 | border: 0 78 | 79 | .output-panel 80 | background-color: #f5f5fd 81 | flex-grow: 1 82 | position: relative 83 | overflow: hidden 84 | cursor: pointer 85 | 86 | .cta-button 87 | padding: 1em 88 | cursor: pointer 89 | 90 | .badges 91 | position: absolute 92 | bottom: 2em 93 | & > * 94 | display: block 95 | &:not(:first-child) 96 | margin-top: 1em -------------------------------------------------------------------------------- /test/test-helper.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import {jsdom} from 'jsdom'; 4 | 5 | const document = global.document = jsdom(''); 6 | const window = global.window = document.defaultView; 7 | global.navigator = window.navigator = {}; 8 | --------------------------------------------------------------------------------