├── .editorconfig ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .npmignore ├── .travis.yml ├── HISTORY.md ├── README.md ├── mangle.json ├── package-lock.json ├── package.json ├── src ├── TransitionChildMapping.js ├── TransitionGroup.js ├── index.js └── util.js └── tests ├── TransitionGroup.test.js └── utils.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [{package.json,.*rc,*.yml}] 11 | indent_style = space 12 | indent_size = 2 13 | 14 | [*.md] 15 | trim_trailing_whitespace = false 16 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v1 12 | - uses: actions/setup-node@v1 13 | with: 14 | node-version: 12 15 | - name: npm install, build, and test 16 | run: | 17 | npm install 18 | npm run prepare --if-present 19 | npm test 20 | env: 21 | CI: true -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | npm-debug.log 4 | coverage 5 | .idea 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | !/src 2 | !/dist 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - stable 4 | -------------------------------------------------------------------------------- /HISTORY.md: -------------------------------------------------------------------------------- 1 | # History 2 | 3 | ## Forked 4 | 5 | Forked from [preact-transition-group](https://github.com/developit/preact-transition-group) 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # preact-transition-group 2 | 3 | A preact-aliased version of [react-addons-transition-group](https://npm.im/react-addons-transition-group), originally extracted from the React codebase, refactored to ES2015 and converted to use Preact. 4 | 5 | See [React's Animation Documentation](https://facebook.github.io/react/docs/animation.html) for usage. 6 | -------------------------------------------------------------------------------- /mangle.json: -------------------------------------------------------------------------------- 1 | { 2 | "vars": { 3 | "props": {} 4 | }, 5 | "props": { 6 | "props": { 7 | "$_ptgLinkedRefs": "i", 8 | "$_finishAbort": "t", 9 | "$_handleDoneAppearing": "n", 10 | "$_handleDoneEntering": "r", 11 | "$_handleDoneLeaving": "s" 12 | } 13 | } 14 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "preact-transition-group", 3 | "amdName": "PreactTransitionGroup", 4 | "version": "2.0.0", 5 | "description": "transition-group ui component for preact", 6 | "source": "src/index.js", 7 | "module": "dist/preact-transition-group.mjs", 8 | "main": "dist/preact-transition-group.js", 9 | "umd:main": "dist/preact-transition-group.umd.js", 10 | "scripts": { 11 | "build": "microbundle", 12 | "test": "run-s lint build test:karma", 13 | "lint": "eslint src tests", 14 | "test:karma": "karmatic", 15 | "test:karma:watch": "karmatic watch --no-headless", 16 | "prepublishOnly": "run-s test", 17 | "release": "npm run -s build && git commit -am $npm_package_version && git tag $npm_package_version && git push && git push --tags && npm publish" 18 | }, 19 | "mangle": { 20 | "regex": "^_" 21 | }, 22 | "eslintConfig": { 23 | "extends": "developit", 24 | "settings": { 25 | "react": { 26 | "pragma": "h" 27 | } 28 | }, 29 | "globals": { 30 | "spyOn": "readonly" 31 | }, 32 | "rules": { 33 | "prefer-rest-params": 0, 34 | "no-prototype-builtins": 0, 35 | "prefer-spread": 0, 36 | "no-cond-assign": 0, 37 | "react/jsx-no-bind": 0, 38 | "react/prefer-stateless-function": 0, 39 | "react/sort-comp": 0, 40 | "jest/valid-expect": 0, 41 | "jest/no-disabled-tests": 0, 42 | "jest/no-jasmine-globals": 0 43 | } 44 | }, 45 | "files": [ 46 | "src", 47 | "dist" 48 | ], 49 | "keywords": [ 50 | "preact", 51 | "preact-component", 52 | "preact-transition-group" 53 | ], 54 | "homepage": "http://github.com/developit/preact-transition-group", 55 | "authors": [ 56 | "Jason Miller ", 57 | "React Authors (https://facebook.github.io/react)" 58 | ], 59 | "repository": "developit/preact-transition-group", 60 | "bugs": { 61 | "url": "http://github.com/developit/preact-transition-group/issues" 62 | }, 63 | "license": "MIT", 64 | "devDependencies": { 65 | "@babel/preset-env": "^7.9.6", 66 | "@types/jasmine": "^3.5.10", 67 | "core-js": "^2.6.11", 68 | "eslint": "^7.0.0", 69 | "eslint-config-developit": "^1.1.1", 70 | "karmatic": "^1.2.0", 71 | "microbundle": "^0.12.0", 72 | "npm-merge-driver-install": "^1.1.1", 73 | "npm-run-all": "^4.1.3", 74 | "preact": "^10.4.1", 75 | "webpack": "^4.19.1" 76 | }, 77 | "peerDependencies": { 78 | "preact": "*" 79 | }, 80 | "dependencies": {} 81 | } -------------------------------------------------------------------------------- /src/TransitionChildMapping.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2013-2014, 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 | * Additional credit to the Author of rc-css-transition-group: https://github.com/yiminghe 10 | * File originally extracted from the React source, converted to ES6 by https://github.com/developit 11 | */ 12 | 13 | import { getKey } from './util'; 14 | 15 | 16 | export function getChildMapping(children) { 17 | let out = {}; 18 | for (let i=0; i next.hasOwnProperty(key) ? next[key] : prev[key]; 33 | 34 | // For each key of `next`, the list of keys to insert before that key in 35 | // the combined list 36 | let nextKeysPending = {}; 37 | 38 | let pendingKeys = []; 39 | for (let prevKey in prev) { 40 | if (next.hasOwnProperty(prevKey)) { 41 | if (pendingKeys.length) { 42 | nextKeysPending[prevKey] = pendingKeys; 43 | pendingKeys = []; 44 | } 45 | } 46 | else { 47 | pendingKeys.push(prevKey); 48 | } 49 | } 50 | 51 | let childMapping = {}; 52 | for (let nextKey in next) { 53 | if (nextKeysPending.hasOwnProperty(nextKey)) { 54 | for (let i=0; i i; 7 | 8 | export class TransitionGroup extends Component { 9 | constructor(props, context) { 10 | super(props, context); 11 | 12 | this.refs = {}; 13 | 14 | this.state = { 15 | children: getChildMapping(toChildArray(toChildArray(this.props.children)) || []) 16 | }; 17 | 18 | this.performAppear = this.performAppear.bind(this); 19 | this.performEnter = this.performEnter.bind(this); 20 | this.performLeave = this.performLeave.bind(this); 21 | } 22 | 23 | componentWillMount() { 24 | this.currentlyTransitioningKeys = {}; 25 | this.keysToAbortLeave = []; 26 | this.keysToEnter = []; 27 | this.keysToLeave = []; 28 | } 29 | 30 | componentDidMount() { 31 | let initialChildMapping = this.state.children; 32 | for (let key in initialChildMapping) { 33 | if (initialChildMapping[key]) { 34 | // this.performAppear(getKey(initialChildMapping[key], key)); 35 | this.performAppear(key); 36 | } 37 | } 38 | } 39 | 40 | componentWillReceiveProps(nextProps) { 41 | let nextChildMapping = getChildMapping(toChildArray(nextProps.children) || []); 42 | let prevChildMapping = this.state.children; 43 | 44 | this.setState(prevState => ({ 45 | children: mergeChildMappings(prevState.children, nextChildMapping) 46 | })); 47 | 48 | let key; 49 | 50 | for (key in nextChildMapping) if (nextChildMapping.hasOwnProperty(key)) { 51 | let hasPrev = prevChildMapping && prevChildMapping.hasOwnProperty(key); 52 | // We should re-enter the component and abort its leave function 53 | if (nextChildMapping[key] && hasPrev && this.currentlyTransitioningKeys[key]) { 54 | this.keysToEnter.push(key); 55 | this.keysToAbortLeave.push(key); 56 | } 57 | else if (nextChildMapping[key] && !hasPrev && !this.currentlyTransitioningKeys[key]) { 58 | this.keysToEnter.push(key); 59 | } 60 | } 61 | 62 | for (key in prevChildMapping) if (prevChildMapping.hasOwnProperty(key)) { 63 | let hasNext = nextChildMapping && nextChildMapping.hasOwnProperty(key); 64 | if (prevChildMapping[key] && !hasNext && !this.currentlyTransitioningKeys[key]) { 65 | this.keysToLeave.push(key); 66 | } 67 | } 68 | } 69 | 70 | componentDidUpdate() { 71 | let keysToEnter = this.keysToEnter; 72 | this.keysToEnter = []; 73 | keysToEnter.forEach(this.performEnter); 74 | 75 | let keysToLeave = this.keysToLeave; 76 | this.keysToLeave = []; 77 | keysToLeave.forEach(this.performLeave); 78 | } 79 | 80 | _finishAbort(key) { 81 | const idx = this.keysToAbortLeave.indexOf(key); 82 | if (idx !== -1) { 83 | this.keysToAbortLeave.splice(idx, 1); 84 | } 85 | } 86 | 87 | performAppear(key) { 88 | this.currentlyTransitioningKeys[key] = true; 89 | 90 | let component = this.refs[key]; 91 | 92 | if (component.componentWillAppear) { 93 | component.componentWillAppear(this._handleDoneAppearing.bind(this, key)); 94 | } 95 | else { 96 | this._handleDoneAppearing(key); 97 | } 98 | } 99 | 100 | _handleDoneAppearing(key) { 101 | let component = this.refs[key]; 102 | if (component.componentDidAppear) { 103 | component.componentDidAppear(); 104 | } 105 | 106 | delete this.currentlyTransitioningKeys[key]; 107 | this._finishAbort(key); 108 | 109 | let currentChildMapping = getChildMapping(toChildArray(this.props.children) || []); 110 | 111 | if (!currentChildMapping || !currentChildMapping.hasOwnProperty(key)) { 112 | // This was removed before it had fully appeared. Remove it. 113 | this.performLeave(key); 114 | } 115 | } 116 | 117 | performEnter(key) { 118 | this.currentlyTransitioningKeys[key] = true; 119 | 120 | let component = this.refs[key]; 121 | 122 | if (component.componentWillEnter) { 123 | component.componentWillEnter(this._handleDoneEntering.bind(this, key)); 124 | } 125 | else { 126 | this._handleDoneEntering(key); 127 | } 128 | } 129 | 130 | _handleDoneEntering(key) { 131 | let component = this.refs[key]; 132 | if (component.componentDidEnter) { 133 | component.componentDidEnter(); 134 | } 135 | 136 | delete this.currentlyTransitioningKeys[key]; 137 | this._finishAbort(key); 138 | 139 | let currentChildMapping = getChildMapping(toChildArray(this.props.children) || []); 140 | 141 | if (!currentChildMapping || !currentChildMapping.hasOwnProperty(key)) { 142 | // This was removed before it had fully entered. Remove it. 143 | this.performLeave(key); 144 | } 145 | } 146 | 147 | performLeave(key) { 148 | // If we should immediately abort this leave function, 149 | // don't run the leave transition at all. 150 | const idx = this.keysToAbortLeave.indexOf(key); 151 | if (idx !== -1) { 152 | return; 153 | } 154 | 155 | this.currentlyTransitioningKeys[key] = true; 156 | 157 | let component = this.refs[key]; 158 | if (component.componentWillLeave) { 159 | component.componentWillLeave(this._handleDoneLeaving.bind(this, key)); 160 | } 161 | else { 162 | // Note that this is somewhat dangerous b/c it calls setState() 163 | // again, effectively mutating the component before all the work 164 | // is done. 165 | this._handleDoneLeaving(key); 166 | } 167 | } 168 | 169 | _handleDoneLeaving(key) { 170 | // If we should immediately abort the leave, 171 | // then skip this altogether 172 | const idx = this.keysToAbortLeave.indexOf(key); 173 | if (idx !== -1) { 174 | return; 175 | } 176 | 177 | let component = this.refs[key]; 178 | 179 | if (component.componentDidLeave) { 180 | component.componentDidLeave(); 181 | } 182 | 183 | delete this.currentlyTransitioningKeys[key]; 184 | 185 | let currentChildMapping = getChildMapping(toChildArray(this.props.children) || []); 186 | 187 | if (currentChildMapping && currentChildMapping.hasOwnProperty(key)) { 188 | // This entered again before it fully left. Add it again. 189 | this.performEnter(key); 190 | } 191 | else { 192 | let children = assign({}, this.state.children); 193 | delete children[key]; 194 | this.setState({ children }); 195 | } 196 | } 197 | 198 | render({ childFactory, transitionLeave, transitionName, transitionAppear, transitionEnter, transitionLeaveTimeout, transitionEnterTimeout, transitionAppearTimeout, component, ...props }, { children }) { 199 | // TODO: we could get rid of the need for the wrapper node 200 | // by cloning a single child 201 | let childrenToRender = []; 202 | for (let key in children) if (children.hasOwnProperty(key)) { 203 | let child = children[key]; 204 | if (child) { 205 | let ref = linkRef(this, key), 206 | el = cloneElement(childFactory(child), { ref, key }); 207 | childrenToRender.push(el); 208 | } 209 | } 210 | 211 | return h(component, props, childrenToRender); 212 | } 213 | } 214 | 215 | TransitionGroup.defaultProps = { 216 | component: 'span', 217 | childFactory: identity 218 | }; 219 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { TransitionGroup } from './TransitionGroup'; 2 | 3 | export default TransitionGroup; 4 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | 3 | export function assign(obj, props) { 4 | for (let i in props) if (props.hasOwnProperty(i)) obj[i] = props[i]; 5 | return obj; 6 | } 7 | 8 | export function getKey(vnode, fallback) { 9 | let key = vnode && vnode.key; 10 | return key===null || key===undefined ? fallback : key; 11 | } 12 | 13 | export function linkRef(component, name) { 14 | let cache = component._ptgLinkedRefs || (component._ptgLinkedRefs = {}); 15 | return cache[name] || (cache[name] = c => { 16 | component.refs[name] = c; 17 | }); 18 | } 19 | -------------------------------------------------------------------------------- /tests/TransitionGroup.test.js: -------------------------------------------------------------------------------- 1 | import { h, Component, render } from 'preact'; 2 | import { act } from 'preact/test-utils'; 3 | import TransitionGroup from '../src'; 4 | import { setupCustomMatchers, setupScratch, teardown } from './utils'; 5 | 6 | /** 7 | * @typedef ArrayLikeMatchers 8 | * @property {(expectedLength: number) => boolean} toHaveLength 9 | */ 10 | 11 | class Todo extends Component { 12 | componentWillEnter(done) { 13 | setTimeout(done, 20); 14 | } 15 | componentDidEnter() { } 16 | 17 | componentWillLeave(done) { 18 | setTimeout(done, 20); 19 | } 20 | componentDidLeave() { } 21 | 22 | render({ onClick, children }) { 23 | return
{children}
; 24 | } 25 | } 26 | 27 | class TodoList extends Component { 28 | constructor() { 29 | super(); 30 | 31 | this.state = { 32 | items: ['hello', 'world', 'click', 'me'] 33 | }; 34 | } 35 | 36 | handleAdd(item) { 37 | let { items } = this.state; 38 | items = items.concat(item); 39 | this.setState({ items }); 40 | } 41 | 42 | handleRemove(i) { 43 | let { items } = this.state; 44 | items.splice(i, 1); 45 | this.setState({ items }); 46 | } 47 | 48 | render(_, { items }) { 49 | return ( 50 |
51 | 52 | {items.map((item, i) => ( 53 | 54 | {item} 55 | 56 | ))} 57 | 58 |
59 | ); 60 | } 61 | } 62 | 63 | 64 | describe('TransitionGroup', () => { 65 | 66 | /** @type {HTMLDivElement} */ 67 | let scratch; 68 | 69 | /** @type {TodoList} */ 70 | let list; 71 | 72 | /** @type {(selector: string) => Element[]} */ 73 | let $ = s => [].slice.call(scratch.querySelectorAll(s)); 74 | 75 | beforeAll(() => { 76 | setupCustomMatchers(); 77 | }); 78 | 79 | beforeEach(() => { 80 | jasmine.clock().install(); 81 | scratch = setupScratch(); 82 | render( list = c} />, scratch); 83 | }); 84 | 85 | afterEach(() => { 86 | list = null; 87 | teardown(scratch); 88 | jasmine.clock().uninstall(); 89 | }); 90 | 91 | it('create works', () => { 92 | expect($('.item')).toHaveLength(4); 93 | }); 94 | 95 | it('enter works', async () => { 96 | spyOn(Todo.prototype, 'componentWillEnter').and.callThrough(); 97 | spyOn(Todo.prototype, 'componentDidEnter').and.callThrough(); 98 | 99 | await act(() => { 100 | list.handleAdd('foo'); 101 | }); 102 | 103 | expect($('.item')).toHaveLength(5); 104 | 105 | jasmine.clock().tick(40); 106 | await act(() => { }); 107 | 108 | expect($('.item')).toHaveLength(5); 109 | expect(Todo.prototype.componentDidEnter).toHaveBeenCalledTimes(1); 110 | }); 111 | 112 | it('leave works', async () => { 113 | spyOn(Todo.prototype, 'componentWillLeave').and.callThrough(); 114 | spyOn(Todo.prototype, 'componentDidLeave').and.callThrough(); 115 | 116 | await act(() => { 117 | list.handleRemove(0); 118 | }); 119 | 120 | expect($('.item')).toHaveLength(4); 121 | 122 | jasmine.clock().tick(40); 123 | await act(() => { }); 124 | 125 | expect($('.item')).toHaveLength(3); 126 | expect(Todo.prototype.componentDidLeave).toHaveBeenCalledTimes(1); 127 | }); 128 | 129 | // it('transitionLeave works', done => { 130 | // // this.timeout(5999); 131 | // list.handleAdd(Date.now()); 132 | // 133 | // setTimeout( () => { 134 | // expect($('.item')).to.have.length(5); 135 | // 136 | // expect($('.item')[0].className).to.contain('example-enter'); 137 | // expect($('.item')[0].className).to.contain('example-enter-active'); 138 | // }, 100); 139 | // 140 | // setTimeout( () => { 141 | // expect($('.item')).to.have.length(5); 142 | // 143 | // expect($('.item')[0].className).not.to.contain('example-enter'); 144 | // expect($('.item')[0].className).not.to.contain('example-enter-active'); 145 | // 146 | // done(); 147 | // }, 1400); 148 | // }); 149 | }); 150 | -------------------------------------------------------------------------------- /tests/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Setup the test environment 3 | * @returns {HTMLDivElement} 4 | */ 5 | export function setupScratch() { 6 | const scratch = document.createElement('div'); 7 | (document.body || document.documentElement).appendChild(scratch); 8 | return scratch; 9 | } 10 | 11 | /** 12 | * Teardown test environment and reset preact's internal state 13 | * @param {HTMLDivElement} scratch 14 | */ 15 | export function teardown(scratch) { 16 | scratch.parentNode.removeChild(scratch); 17 | } 18 | 19 | export function setupCustomMatchers() { 20 | jasmine.addMatchers({ 21 | toHaveLength: () => ({ 22 | compare(actualArray, expectedLength) { 23 | if (!actualArray || typeof actualArray.length !== 'number') { 24 | throw new Error( 25 | '[.not].toHaveLength expected actual value to have a ' + 26 | '"length" property that is a number. Recieved: ' + 27 | actualArray 28 | ); 29 | } 30 | 31 | const actualLength = actualArray.length; 32 | const pass = actualArray.length === expectedLength; 33 | 34 | const message = !pass 35 | ? // Error message for `.toHaveLength` case 36 | `Expected actual value to have length ${expectedLength} but got ${actualLength}` 37 | : // Error message for `.not.toHaveLength` case 38 | `Expected actual value to not have length ${expectedLength} but got ${actualLength}`; 39 | 40 | return { pass, message }; 41 | } 42 | }) 43 | }); 44 | } 45 | --------------------------------------------------------------------------------