├── .babelrc ├── .babelrc.js ├── .eslintignore ├── .eslintrc.js ├── .flowconfig ├── .gitignore ├── .storybook ├── .babelrc ├── config.js ├── head.html └── webpack.config.js ├── .travis.yml ├── .vscode └── settings.json ├── LICENSE.md ├── README.md ├── SUMMARY.md ├── documentation ├── api_reference.md └── enter_leave_animations.md ├── flow-typed └── react-flip-move_v2.9.x-v2.10.x.js ├── karma.conf.js ├── package.json ├── rollup.config.js ├── src ├── CODE_TOUR.md ├── FlipMove.js ├── dom-manipulation.js ├── enter-leave-presets.js ├── error-messages.js ├── helpers.js ├── index.js ├── prop-converter.js └── typings.js ├── stories ├── appear-animations.stories.js ├── enter-leave-animations.stories.js ├── github-issues.stories.js ├── helpers │ ├── Controls.js │ ├── FlipMoveListItem.js │ ├── FlipMoveListItemLegacy.js │ └── FlipMoveWrapper.js ├── hooks.stories.js ├── invalid.stories.js ├── legacy.stories.js ├── misc.stories.js ├── primary.stories.js ├── sequencing.stories.js └── special-props.stories.js ├── test ├── helpers │ └── index.js └── index.spec.js ├── tslint.json ├── typings ├── react-flip-move.d.ts └── test.tsx ├── webpack.config.base.js ├── webpack.config.development.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["./.babelrc.js"] 3 | } 4 | -------------------------------------------------------------------------------- /.babelrc.js: -------------------------------------------------------------------------------- 1 | const { NODE_ENV, BABEL_ENV } = process.env; 2 | const test = NODE_ENV === 'test'; 3 | const modules = BABEL_ENV === 'cjs' || test ? 'commonjs' : false; 4 | const loose = true; 5 | 6 | module.exports = { 7 | "presets": [ 8 | ["env", { modules, loose, targets: { uglify: true } }], 9 | "stage-0", 10 | "react", 11 | "flow", 12 | ], 13 | "plugins": [ 14 | "transform-decorators-legacy", 15 | modules === 'commonjs' && "add-module-exports", 16 | test && ["istanbul", { "exclude": ["test/**/*.js"]}], 17 | ].filter(Boolean), 18 | }; 19 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | flow-typed 2 | coverage 3 | lib 4 | dist 5 | flow-coverage 6 | !.storybook 7 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | 'airbnb', 4 | 'plugin:flowtype/recommended', 5 | 'prettier', 6 | 'prettier/flowtype', 7 | 'prettier/react', 8 | ], 9 | plugins: ['prettier', 'flowtype', 'sort-class-members', 'bdd'], 10 | parser: 'babel-eslint', 11 | rules: { 12 | 'import/no-unresolved': 0, 13 | 'import/no-extraneous-dependencies': 0, 14 | 'react/jsx-filename-extension': 0, 15 | 'react/no-unused-prop-types': 0, 16 | 'react/sort-comp': 0, 17 | 'sort-class-members/sort-class-members': [ 18 | 2, 19 | { 20 | order: [ 21 | '[static-properties]', 22 | '[static-methods]', 23 | '[properties]', 24 | 'constructor', 25 | 'componentWillMount', 26 | 'componentDidMount', 27 | 'componentWillReceiveProps', 28 | 'shouldComponentUpdate', 29 | 'componentWillUpdate', 30 | 'componentDidUpdate', 31 | 'componentWillUnmount', 32 | '[arrow-function-properties]', 33 | '[methods]', 34 | 'render', 35 | ], 36 | }, 37 | ], 38 | 'no-console': [1, { allow: ['warn', 'error'] }], 39 | 'no-plusplus': [1, { allowForLoopAfterthoughts: true }], 40 | 41 | 'flowtype/delimiter-dangle': [2, 'always-multiline'], 42 | 'flowtype/no-dupe-keys': 2, 43 | 'flowtype/no-primitive-constructor-types': 2, 44 | 'flowtype/no-weak-types': 1, 45 | 'flowtype/object-type-delimiter': [2, 'comma'], 46 | 'flowtype/semi': 2, 47 | 48 | 'bdd/focus': 2, 49 | 'bdd/exclude': 2, 50 | 51 | 'prettier/prettier': [ 52 | 'error', 53 | { 54 | trailingComma: 'all', 55 | singleQuote: true, 56 | }, 57 | ], 58 | }, 59 | settings: { 60 | flowtype: { 61 | onlyFilesWithFlowAnnotation: true, 62 | }, 63 | }, 64 | env: { 65 | browser: true, 66 | node: true, 67 | }, 68 | }; 69 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | /lib/.* 3 | .*/node_modules/editions/.* 4 | .*/node_modules/flow-coverage-report/.* 5 | .*/node_modules/preact/.* 6 | .*/node_modules/@storybook/.* 7 | .*/node_modules/radium/.* 8 | .*/node_modules/styled-components/test-utils/.* 9 | 10 | [include] 11 | 12 | [libs] 13 | 14 | [options] 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | **/.DS_Store 3 | coverage 4 | *.log 5 | lib 6 | dist 7 | examples 8 | flow-coverage 9 | .idea 10 | -------------------------------------------------------------------------------- /.storybook/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../.babelrc" 3 | } 4 | -------------------------------------------------------------------------------- /.storybook/config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import React from 'react'; 3 | import { configure } from '@storybook/react'; 4 | 5 | const req = require.context('../stories', true, /\.stories\.js$/); 6 | 7 | function loadStories() { 8 | req.keys().forEach((filename) => req(filename)); 9 | } 10 | 11 | configure(loadStories, module); -------------------------------------------------------------------------------- /.storybook/head.html: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /.storybook/webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | resolve: { 3 | alias: 4 | process.env.REACT_IMPL === 'preact' 5 | ? { 6 | react: 'preact-compat', 7 | 'react-dom': 'preact-compat', 8 | 'create-react-class': 'preact-compat/lib/create-react-class', 9 | } 10 | : {}, 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | services: 3 | - xvfb 4 | addons: 5 | chrome: stable 6 | cache: 7 | directories: 8 | - node_modules 9 | node_js: 10 | - "6" 11 | - "8" 12 | before_script: 13 | - npm install -g babel-cli 14 | script: 15 | - npm run build 16 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "javascript.validate.enable": false, 4 | "eslint.enable": true 5 | } 6 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016-present Joshua Comeau 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | React Flip Move 2 | ========= 3 | 4 | [![build status](https://travis-ci.org/joshwcomeau/react-flip-move.svg?branch=master)](https://travis-ci.org/joshwcomeau/react-flip-move) 5 | [![npm version](https://img.shields.io/npm/v/react-flip-move.svg)](https://www.npmjs.com/package/react-flip-move) 6 | [![npm monthly downloads](https://img.shields.io/npm/dm/react-flip-move.svg)](https://www.npmjs.com/package/react-flip-move) 7 | 8 | 9 | 10 | This module was built to tackle the common but arduous problem of animating a list of items when the list's order changes. 11 | 12 | CSS transitions only work for CSS properties. If your list is shuffled, the items have rearranged themselves, but without the use of CSS. The DOM nodes don't know that their on-screen location has changed; from their perspective, they've been removed and inserted elsewhere in the document. 13 | 14 | Flip Move uses the [_FLIP technique_](https://aerotwist.com/blog/flip-your-animations/#the-general-approach) to work out what such a transition would look like, and fakes it using 60+ FPS hardware-accelerated CSS transforms. 15 | 16 | [**Read more about how it works**](https://medium.com/developers-writing/animating-the-unanimatable-1346a5aab3cd) 17 | 18 | [![demo](https://s3.amazonaws.com/githubdocs/fm-main-demo.gif)](http://joshwcomeau.github.io/react-flip-move/examples/#/shuffle) 19 | 20 | 21 | ## Current Status 22 | 23 | React Flip Move is [looking for maintainers](https://github.com/joshwcomeau/react-flip-move/issues/233)! 24 | 25 | In the meantime, we'll do our best to make sure React Flip Move continues to work with new versions of React, but otherwise it isn't being actively worked on. 26 | 27 | Because it isn't under active development, you may be interested in checking out projects like [react-flip-toolkit](https://github.com/aholachek/react-flip-toolkit). 28 | 29 | 30 | ## Demos 31 | 32 | * __List/Grid Shuffle__ 33 | * __Fuscia Square__ 34 | * __Scrabble__ 35 | * __Laboratory__ 36 | 37 | 38 | 39 | ## Installation 40 | 41 | Flip Move can be installed with [NPM](https://www.npmjs.com/) or [Yarn](https://yarnpkg.com/en/). 42 | 43 | ```bash 44 | yarn add react-flip-move 45 | 46 | # Or, if not using yarn: 47 | npm i -S react-flip-move 48 | ``` 49 | 50 | A UMD build is made available for those not using JS package managers: 51 | * [react-flip-move.js](https://unpkg.com/react-flip-move/dist/react-flip-move.js) 52 | * [react-flip-move.min.js](https://unpkg.com/react-flip-move/dist/react-flip-move.min.js) 53 | 54 | To use a UMD build, you can use ` 60 | 63 | 64 | 65 | ``` 66 | 67 | 68 | ## Features 69 | 70 | Flip Move was inspired by Ryan Florence's awesome _Magic Move_, and offers: 71 | 72 | * Exclusive use of hardware-accelerated CSS properties (`transform: translate`) instead of positioning properties (`top`, `left`). _Read why this matters_. 73 | 74 | * Full support for enter/exit animations, including some spiffy presets, that all leverage hardware-accelerated CSS properties. 75 | 76 | * Ability to 'humanize' transitions by staggering the delay and/or duration of subsequent elements. 77 | 78 | * Ability to provide `onStart` / `onFinish` callbacks. 79 | 80 | * Compatible with [Preact](https://preactjs.com/) (should work with other React-like libraries as well). 81 | 82 | * Tiny! Gzipped size is <5kb! ⚡ 83 | 84 | 85 | ## Quickstart 86 | 87 | Flip Move aims to be a "plug and play" solution, without needing a lot of tinkering. In the ideal case, you can wrap the children you already have with ``, and get animation for free: 88 | 89 | ```jsx 90 | /** 91 | * BEFORE: 92 | */ 93 | const TopArticles = ({ articles }) => ( 94 | {articles.map(article => ( 95 |
96 | ))} 97 | ); 98 | 99 | /** 100 | * AFTER: 101 | */ 102 | import FlipMove from 'react-flip-move'; 103 | 104 | const TopArticles = ({ articles }) => ( 105 | 106 | {articles.map(article => ( 107 |
108 | ))} 109 | 110 | ); 111 | ``` 112 | 113 | There are a number of [options](https://github.com/joshwcomeau/react-flip-move/blob/master/documentation/api_reference.md) you can provide to customize Flip Move. There are also some [gotchas](https://github.com/joshwcomeau/react-flip-move#gotchas) to be aware of. 114 | 115 | 116 | ## Usage with Functional Components 117 | 118 | Functional components do not have a `ref`, which is needed by Flip Move to work. To make it work you need to wrap your functional component into [React.forwardRef](https://reactjs.org/docs/forwarding-refs.html) and pass it down to the first element which accepts refs, such as DOM elements or class components: 119 | 120 | ```jsx 121 | import React, { forwardRef } from 'react'; 122 | import FlipMove from 'react-flip-move'; 123 | 124 | const FunctionalArticle = forwardRef((props, ref) => ( 125 |
126 | {props.articleName} 127 |
128 | )); 129 | 130 | // you do not have to modify the parent component 131 | // this will stay as described in the quickstart 132 | const TopArticles = ({ articles }) => ( 133 | 134 | {articles.map(article => ( 135 | 136 | ))} 137 | 138 | ); 139 | ``` 140 | 141 | 142 | ## API Reference 143 | 144 | View the [full API reference documentation](https://github.com/joshwcomeau/react-flip-move/blob/master/documentation/api_reference.md) 145 | 146 | 147 | ## Enter/Leave Animations 148 | 149 | View the [enter/leave docs](https://github.com/joshwcomeau/react-flip-move/blob/master/documentation/enter_leave_animations.md) 150 | 151 | 152 | ## Compatibility 153 | 154 | | | Chrome | Firefox | Safari | IE | Edge | iOS Safari/Chrome | Android Chrome | 155 | |-----------|:------:|:-------:|:------:|:-----:|:----:|:-----------------:|:--------------:| 156 | | Supported | ✔ 10+ | ✔ 4+ | ✔ 6.1+ | ✔ 10+ | ✔ | ✔ 6.1+ | ✔ | 157 | 158 | 159 | ## How It Works 160 | 161 | Curious how this works, under the hood? [__Read the Medium post__](https://medium.com/@joshuawcomeau/animating-the-unanimatable-1346a5aab3cd). 162 | 163 | 164 | --- 165 | 166 | ### Wrapping Element 167 | 168 | By default, Flip Move wraps the children you pass it in a `
`: 169 | 170 | ```jsx 171 | // JSX 172 | 173 |
Hello
174 |
World
175 |
176 | 177 | // HTML 178 |
179 |
Hello
180 |
World
181 |
182 | ``` 183 | 184 | Any unrecognized props to `` will be delegated to this wrapper element: 185 | 186 | ```jsx 187 | // JSX 188 | 189 |
Hello
190 |
World
191 |
192 | 193 | // HTML 194 |
195 |
Hello
196 |
World
197 |
198 | ``` 199 | 200 | You can supply a different element type with the `typeName` prop: 201 | 202 | ```jsx 203 | // JSX 204 | 205 |
  • Hello
  • 206 |
  • World
  • 207 |
    208 | 209 | // HTML 210 |
      211 |
    • Hello
    • 212 |
    • World
    • 213 |
    214 | ``` 215 | 216 | Finally, if you're using React 16 or higher, and Flip Move 2.10 or higher, you can use the new "wrapperless" mode. This takes advantage of a React Fiber feature, which allows us to omit this wrapping element: 217 | 218 | ```jsx 219 | // JSX 220 |
    221 | 222 |
    Hello
    223 |
    World
    224 |
    225 |
    226 | 227 | // HTML 228 |
    229 |
    Hello
    230 |
    World
    231 |
    232 | ``` 233 | 234 | Wrapperless mode is nice, because it makes Flip Move more "invisible", and makes it easier to integrate with parent-child CSS properties like flexbox. However, there are some things to note: 235 | 236 | - This is a new feature in FlipMove, and isn't as battle-tested as the traditional method. Please test thoroughly before using in production, and report any bugs! 237 | - Flip Move does some positioning magic for enter/exit animations - specifically, it temporarily applies `position: absolute` to its children. For this to work correctly, you'll need to make sure that `` is within a container that has a non-static position (eg. `position: relative`), and no padding: 238 | 239 | ```jsx 240 | // BAD - this will cause children to jump to a new position before exiting: 241 |
    242 | 243 |
    Hello world
    244 |
    245 |
    246 | 247 | // GOOD - a non-static position and a tight-fitting wrapper means children will 248 | // stay in place while exiting: 249 |
    250 | 251 |
    Hello world
    252 |
    253 |
    254 | ``` 255 | 256 | --- 257 | 258 | 259 | ## Gotchas 260 | 261 | * Does not work with stateless functional components without a [React.forwardRef](https://reactjs.org/docs/forwarding-refs.html), read more about [here](#usage-with-functional-components). This is because Flip Move uses refs to identify and apply styles to children, and stateless functional components cannot be given refs. Make sure the children you pass to `` are either native DOM elements (like `
    `), or class components. 262 | 263 | * All children **need a unique `key` property**. Even if Flip Move is only given a single child, it needs to have a unique `key` prop for Flip Move to track it. 264 | 265 | * Flip Move clones the direct children passed to it and overwrites the `ref` prop. As a result, you won't be able to set a `ref` on the top-most elements passed to FlipMove. To work around this limitation, you can wrap each child you pass to `` in a `
    `. 266 | 267 | * Elements whose positions have not changed between states will not be animated. This means that no `onStart` or `onFinish` callbacks will be executed for those elements. 268 | 269 | * Sometimes you'll want to update or change an item _without_ triggering a Flip Move animation. For example, with optimistic updating, you may render a temporary version before replacing it with the server-validated one. In this case, use the same `key` for both versions, and Flip Move will treat them as the same item. 270 | 271 | * If you have a vertical list with numerous elements, exceeding viewport, and you are experiencing automatic scrolling issues when reordering an item (i.e. the browser scrolls to the moved item's position), you can add `style={{ overflowAnchor: 'none' }}` to the container element (e.g. `
      `) to prevent this issue. 272 | 273 | ## Known Issues 274 | 275 | * **Interrupted enter/leave animations can be funky**. This has gotten better recently thanks to our great contributors, but extremely fast adding/removing of items can cause weird visual glitches, or cause state to become inconsistent. Experiment with your usecase! 276 | 277 | * **Existing transition/transform properties will be overridden.** I am hoping to change this in a future version, but at present, Flip Move does not take into account existing `transition` or `transform` CSS properties on its direct children. 278 | 279 | 280 | ## Note on `will-change` 281 | 282 | To fully benefit from hardware acceleration, each item being translated should have its own compositing layer. This can be accomplished with the [CSS will-change property](https://dev.opera.com/articles/css-will-change-property/). 283 | 284 | Applying `will-change` too willy-nilly, though, can have an adverse effect on mobile browsers, so I have opted to not use it at all. 285 | 286 | In my personal experimentations on modern versions of Chrome, Safari, Firefox and IE, this property offers little to no gain (in Chrome's timeline I saw a savings of ~0.5ms on a 24-item shuffle). 287 | 288 | YMMV: Feel free to experiment with the property in your CSS. Flip Move will respect the wishes of your stylesheet :) 289 | 290 | Further reading: [CSS will-change Property](https://dev.opera.com/articles/css-will-change-property/) 291 | 292 | 293 | 294 | ## Contributions 295 | 296 | Contributors welcome! Please discuss new features with me ahead of time, and submit PRs for bug fixes with tests (Testing stack is Mocha/Chai/Sinon, tested in-browser by Karma). 297 | 298 | There is a shared prepush hook which launches eslint, flow checks, and tests. It sets itself up automatically during `npm install`. 299 | 300 | 301 | ## Development 302 | 303 | This project uses [React Storybook](https://github.com/kadirahq/react-storybook) in development. The developer experience is absolutely lovely, and it makes testing new features like enter/leave presets super straightforward. 304 | 305 | After installing dependencies, launch the Storybook dev server with `npm run storybook`. 306 | 307 | This project adheres to the formatting established by [airbnb's style guide](https://github.com/airbnb/javascript/tree/master/react). When contributing, you can make use of the autoformatter [prettier](https://github.com/prettier/prettier) to apply these rules by running the eslint script `npm run lint:fix`. If there are conflicts, the linter triggered by the prepush hook will inform you of those as well. To check your code by hand, run `npm run lint`. 308 | 309 | ## Flow support 310 | 311 | Flip Move's sources are type-checked with [Flow](https://flow.org/). If your project uses it too, you may want to install typings for our public API from [flow-typed](https://github.com/flowtype/flow-typed) repo. 312 | 313 | ```bash 314 | npm install --global flow-typed # if not already 315 | flow-typed install react-flip-move@ 316 | ``` 317 | 318 | If you're getting some flow errors coming from `node_modules/react-flip-move/src` path, you should add this to your `.flowconfig` file: 319 | 320 | ``` 321 | [ignore] 322 | .*/node_modules/react-flip-move/.* 323 | ``` 324 | 325 | 326 | ## License 327 | 328 | [MIT](https://github.com/joshwcomeau/flip-move/blob/master/LICENSE.md) 329 | -------------------------------------------------------------------------------- /SUMMARY.md: -------------------------------------------------------------------------------- 1 | # Summary 2 | 3 | * [API Reference](documentation/api_reference.md) 4 | * [Enter/Leave Animations](documentation/enter_leave_animations.md) 5 | 6 | -------------------------------------------------------------------------------- /documentation/api_reference.md: -------------------------------------------------------------------------------- 1 | # API Reference 2 | 3 | FlipMove is a React component, and is configured via the following props: 4 | 5 | 6 | 7 | ### `children` 8 | 9 | | **Accepted Types:** | **Default Value** | 10 | |---------------------|-------------------| 11 | | `Array`, `Object` | `undefined` | 12 | 13 | 14 | The children passed to FlipMove are the component(s) or DOM element(s) that will be moved about. Accepts either a single child (as long as it has a unique `key` property) or an array of children. 15 | 16 | --- 17 | 18 | ### `easing` 19 | 20 | | **Accepted Types:** | **Default Value** | 21 | |---------------------|-------------------| 22 | | `String` | "ease-in-out" | 23 | 24 | 25 | Any valid CSS3 timing function (eg. "linear", "ease-in", "cubic-bezier(1, 0, 0, 1)"). 26 | 27 | --- 28 | 29 | ### `duration` 30 | 31 | | **Accepted Types:** | **Default Value** | 32 | |---------------------|-------------------| 33 | | `Number` | `350` | 34 | 35 | 36 | The length, in milliseconds, that the transition ought to take. 37 | 38 | 39 | --- 40 | 41 | ### `delay` 42 | 43 | | **Accepted Types:** | **Default Value** | 44 | |---------------------|-------------------| 45 | | `Number` | `0` | 46 | 47 | 48 | The length, in milliseconds, to wait before the animation begins. 49 | 50 | --- 51 | 52 | ### `staggerDurationBy` 53 | 54 | | **Accepted Types:** | **Default Value** | 55 | |---------------------|-------------------| 56 | | `Number` | `0` | 57 | 58 | 59 | The length, in milliseconds, to be added to the duration of each subsequent element. 60 | 61 | For example, if you are animating 4 elements with a `duration` of 200 and a `staggerDurationBy` of 20: 62 | 63 | * The first element will take 200ms to transition. 64 | * The second element will take 220ms to transition. 65 | * The third element will take 240ms to transition. 66 | * The fourth element will take 260ms to transition. 67 | 68 | This effect is great for "humanizing" transitions and making them feel less robotic. 69 | 70 | --- 71 | 72 | ### `staggerDelayBy` 73 | 74 | | **Accepted Types:** | **Default Value** | 75 | |---------------------|-------------------| 76 | | `Number` | `0` | 77 | 78 | 79 | The length, in milliseconds, to be added to the delay of each subsequent element. 80 | 81 | For example, if you are animating 4 elements with a `delay` of 0 and a `staggerDelayBy` of 20: 82 | 83 | * The first element will start transitioning immediately. 84 | * The second element will start transitioning after 20ms. 85 | * The third element will start transitioning after 40ms. 86 | * The fourth element will start transitioning after 60ms. 87 | 88 | Similarly to staggerDurationBy, This effect is great for "humanizing" transitions and making them feel less robotic. 89 | 90 | **Protip:** You can make elements animate one-at-a-time by using an identical `duration` and `staggerDelayBy`. 91 | 92 | --- 93 | 94 | ### `appearAnimation` 95 | 96 | | **Accepted Types:** | **Default Value** | 97 | |--------------------------------|-------------------| 98 | | `String`, `Boolean`, `Object` | undefined | 99 | 100 | Control the appear animation that runs when the component mounts. Works identically to [`enterAnimation`](#enteranimation) below, but only fires on the initial children. 101 | 102 | --- 103 | 104 | ### `enterAnimation` 105 | 106 | | **Accepted Types:** | **Default Value** | 107 | |--------------------------------|-------------------| 108 | | `String`, `Boolean`, `Object` | 'elevator' | 109 | 110 | Control the onEnter animation that runs when new items are added to the DOM. For examples of this property, see the enter/leave docs. 111 | 112 | Accepts several types: 113 | 114 | **String:** You can enter one of the following presets to select that as your enter animation: 115 | * `elevator` (default) 116 | * `fade` 117 | * `accordionVertical` 118 | * `accordionHorizontal` 119 | * `none` 120 | 121 | View the CSS implementation of these presets. 122 | 123 | **Boolean:** You can enter `false` to disable the enter animation, or `true` to select the default enter animation (elevator). 124 | 125 | **Object:** For fully granular control, you can pass in an object that contains the styles you'd like to animate. 126 | 127 | It requires two keys: `from` and `to`. Each key holds an object of CSS properties. You can supply any valid camelCase CSS properties, and flip-move will transition between the two, over the course of the specified `duration`. 128 | 129 | Example: 130 | 131 | ```jsx 132 | const customEnterAnimation = { 133 | from: { transform: 'scale(0.5, 1)' }, 134 | to: { transform: 'scale(1, 1)' } 135 | }; 136 | 137 | 138 | {renderChildren()} 139 | 140 | ``` 141 | 142 | It is recommended that you stick to hardware-accelerated CSS properties for optimal performance: transform and opacity. 143 | 144 | --- 145 | 146 | ### `leaveAnimation` 147 | 148 | | **Accepted Types:** | **Default Value** | 149 | |--------------------------------|-------------------| 150 | | `String`, `Boolean`, `Object` | 'elevator' | 151 | 152 | Control the onLeave animation that runs when new items are removed from the DOM. For examples of this property, see the enter/leave docs. 153 | 154 | This property functions identically to `enterAnimation`. 155 | 156 | Accepts several types: 157 | 158 | **String:** You can enter one of the following presets to select that as your enter animation: 159 | * `elevator` (default) 160 | * `fade` 161 | * `accordionVertical` 162 | * `accordionHorizontal` 163 | * `none` 164 | 165 | View the CSS implementation of these presets. 166 | 167 | **Boolean:** You can enter `false` to disable the leave animation, or `true` to select the default leave animation (elevator). 168 | 169 | **Object:** For fully granular control, you can pass in an object that contains the styles you'd like to animate. 170 | 171 | It requires two keys: `from` and `to`. Each key holds an object of CSS properties. You can supply any valid camelCase CSS properties, and flip-move will transition between the two, over the course of the specified `duration`. 172 | 173 | Example: 174 | 175 | ```jsx 176 | const customLeaveAnimation = { 177 | from: { transform: 'scale(1, 1)' }, 178 | to: { transform: 'scale(0.5, 1) translateY(-20px)' } 179 | }; 180 | 181 | 182 | {renderChildren()} 183 | 184 | ``` 185 | 186 | It is recommended that you stick to hardware-accelerated CSS properties for optimal performance: transform and opacity. 187 | 188 | --- 189 | 190 | ### `maintainContainerHeight` 191 | 192 | | **Accepted Types:** | **Default Value** | 193 | |---------------------|-------------------| 194 | | `Boolean` | `false` | 195 | 196 | Do not collapse container height until after leaving animations complete. 197 | 198 | When `false`, children are immediately removed from the DOM flow as they animate away. Setting this value to `true` will maintain the height of the container until after their leaving animation completes. 199 | 200 | --- 201 | 202 | ### `verticalAlignment` 203 | 204 | | **Accepted Types:** | **Default Value** | **Accepted Values** | 205 | |---------------------|-------------------|---------------------| 206 | | `String` | `'top'` | `'top'`, `'bottom'` | 207 | 208 | If the container is bottom-aligned and an element is removed, the container's top edge moves lower. You can tell `react-flip-move` to account for this by passing `'bottom'` to the `verticalAlignment` prop. 209 | 210 | --- 211 | 212 | ### `onStart` 213 | 214 | | **Accepted Types:** | **Default Value** | 215 | |---------------------|-------------------| 216 | | `Function` | `undefined` | 217 | 218 | 219 | A callback to be invoked **once per child element** at the start of the animation. 220 | 221 | The callback is invoked with two arguments: 222 | 223 | * `childElement`: A reference to the React Element being animated. 224 | * `domNode`: A reference to the unadulterated DOM node being animated. 225 | 226 | In general, it is advisable to ignore the `domNode` argument and work with the `childElement`. The `domNode` is just an escape hatch for doing complex things not otherwise possible. 227 | 228 | --- 229 | 230 | ### `onFinish` 231 | 232 | | **Accepted Types:** | **Default Value** | 233 | |---------------------|-------------------| 234 | | `Function` | `undefined` | 235 | 236 | 237 | A callback to be invoked **once per child element** at the end of the animation. 238 | 239 | The callback is invoked with two arguments: 240 | 241 | * `childElement`: A reference to the React Element being animated. 242 | * `domNode`: A reference to the unadulterated DOM node being animated. 243 | 244 | In general, it is advisable to ignore the `domNode` argument and work with the `childElement`. The `domNode` is just an escape hatch for doing complex things not otherwise possible. 245 | 246 | --- 247 | 248 | ### `onStartAll` 249 | 250 | | **Accepted Types:** | **Default Value** | 251 | |---------------------|-------------------| 252 | | `Function` | `undefined` | 253 | 254 | 255 | A callback to be invoked **once per group** at the start of the animation. 256 | 257 | The callback is invoked with two arguments: 258 | 259 | * `childElements`: An array of the references to the React Element(s) being animated. 260 | * `domNodes`: An array of the references to the unadulterated DOM node(s) being animated. 261 | 262 | These arguments are similar to the ones provided for `onStart`, except we provide an *array* of the elements and nodes. The order of both arguments is guaranteed; this means you can use a zipping function like lodash's .zip to get pairs of element/node, if needed. 263 | 264 | In general, it is advisable to ignore the `domNodes` argument and work with the `childElements`. The `domNodes` are just an escape hatch for doing complex things not otherwise possible. 265 | 266 | --- 267 | 268 | ### `onFinishAll` 269 | 270 | | **Accepted Types:** | **Default Value** | 271 | |---------------------|-------------------| 272 | | `Function` | `undefined` | 273 | 274 | 275 | A callback to be invoked **once per group** at the end of the animation. 276 | 277 | The callback is invoked with two arguments: 278 | 279 | * `childElements`: An array of the references to the React Element(s) being animated. 280 | * `domNodes`: An array of the references to the unadulterated DOM node(s) being animated. 281 | 282 | These arguments are similar to the ones provided for `onFinish`, except we provide an *array* of the elements and nodes. The order of both arguments is guaranteed; this means you can use a zipping function like lodash's .zip to get pairs of element/node, if needed. 283 | 284 | In general, it is advisable to ignore the `domNodes` argument and work with the `childElements`. The `domNodes` are just an escape hatch for doing complex things not otherwise possible. 285 | 286 | --- 287 | 288 | ### `typeName` 289 | 290 | | **Accepted Types:** | **Default Value** | 291 | |----------------------|-------------------| 292 | | `String`, `null` | 'div' | 293 | 294 | 295 | Flip Move wraps your children in a container element. By default, this element is a `div`, but you may wish to provide a custom HTML element (for example, if your children are list items, you may wish to set this to `ul`). 296 | 297 | Any valid HTML element type is accepted, but peculiar things may happen if you use an unconventional element. 298 | 299 | With React 16, Flip Move can opt not to use a container element: set `typeName` to `null` to use this new "wrapperless" behaviour. [Read more](https://github.com/joshwcomeau/react-flip-move/blob/master/README.md#wrapping-elements). 300 | 301 | --- 302 | 303 | ### `disableAllAnimations` 304 | 305 | | **Accepted Types:** | **Default Value** | 306 | |---------------------|-------------------| 307 | | `Boolean` | `false` | 308 | 309 | 310 | Sometimes, you may wish to temporarily disable the animations and have the normal behaviour resumed. Setting this flag to `true` skips all animations. 311 | 312 | --- 313 | 314 | ### `getPosition` 315 | 316 | | **Accepted Types:** | **Default Value** | 317 | |---------------------|-------------------------| 318 | | `Function` | `getBoundingClientRect` | 319 | 320 | 321 | This function is called with a DOM node as the only argument. It should return an object as specified by the [getBoundingClientRect() spec](https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect). 322 | 323 | For normal usage of FlipMove you won't need this. An example of usage is when FlipMove is used in a container that is scaled using CSS. You can correct the values from `getBoundingClientRect` by using this prop. 324 | 325 | --- 326 | -------------------------------------------------------------------------------- /documentation/enter_leave_animations.md: -------------------------------------------------------------------------------- 1 | # Enter/Leave Animations 2 | 3 | FlipMove supports CSS-based enter/leave animations. For convenience, several presets are provided: 4 | 5 | 6 | #### Elevator (default) 7 | 8 | ![Elevator](https://s3.amazonaws.com/githubdocs/fm-elevator.gif) 9 | 10 | ```jsx 11 | 12 | ``` 13 | 14 | #### Fade 15 | 16 | ![Fade](https://s3.amazonaws.com/githubdocs/fm-fade.gif) 17 | 18 | ```jsx 19 | 20 | ``` 21 | 22 | #### Accordion (Vertical) 23 | 24 | ![Accordion (Vertical)](https://s3.amazonaws.com/githubdocs/fm-accordian-vertical.gif) 25 | 26 | ```jsx 27 | 28 | ``` 29 | 30 | #### Accordion (Horizontal) 31 | 32 | ![Accordion (Horizontal)](https://s3.amazonaws.com/githubdocs/fm-accordian-horizontal.gif) 33 | 34 | ```jsx 35 | 36 | ``` 37 | 38 | #### Custom 39 | 40 | You can supply your own CSS-based transitions to customize the behaviour. Both `enterAnimation` and `leaveAnimation` take an object with `from` and `to` properties. You can then provide any valid CSS properties to this object, although for performance reasons it is recommended that you stick to `transform` and `opacity`. 41 | 42 | ![Custom](https://s3.amazonaws.com/githubdocs/fm-custom-rotate-x.gif) 43 | 44 | ```jsx 45 | 66 | {this.renderRows()} 67 | 68 | ``` 69 | 70 | ## Appear animations 71 | 72 | `enterAnimation` will not fire for the _initial_ render. If you want your items to animate on mount, you'll have to use `appearAnimation`. 73 | 74 | It functions identically to `enterAnimation`, and has the same presets. 75 | 76 | For example: 77 | 78 | ```jsx 79 | 85 | {this.renderRows()} 86 | 87 | ``` 88 | -------------------------------------------------------------------------------- /flow-typed/react-flip-move_v2.9.x-v2.10.x.js: -------------------------------------------------------------------------------- 1 | declare module 'react-flip-move' { 2 | declare export type Styles = { 3 | [key: string]: string, 4 | }; 5 | 6 | declare type ReactStyles = { 7 | [key: string]: string | number, 8 | }; 9 | 10 | declare export type Animation = { 11 | from: Styles, 12 | to: Styles, 13 | }; 14 | 15 | declare export type Presets = { 16 | elevator: Animation, 17 | fade: Animation, 18 | accordionVertical: Animation, 19 | accordionHorizontal: Animation, 20 | none: null, 21 | }; 22 | 23 | declare export type AnimationProp = $Keys | boolean | Animation; 24 | 25 | declare export type ClientRect = { 26 | top: number, 27 | right: number, 28 | bottom: number, 29 | left: number, 30 | height: number, 31 | width: number, 32 | }; 33 | 34 | // can't use $Shape> here, because we use it in intersection 35 | declare export type ElementShape = { 36 | +type: $PropertyType, 'type'>, 37 | +props: $PropertyType, 'props'>, 38 | +key: $PropertyType, 'key'>, 39 | +ref: $PropertyType, 'ref'>, 40 | }; 41 | 42 | declare type ChildHook = (element: ElementShape, node: ?HTMLElement) => mixed; 43 | 44 | declare export type ChildrenHook = ( 45 | elements: Array, 46 | nodes: Array, 47 | ) => mixed; 48 | 49 | declare export type GetPosition = (node: HTMLElement) => ClientRect; 50 | 51 | declare export type VerticalAlignment = 'top' | 'bottom'; 52 | 53 | declare export type Child = void | null | boolean | React$Element<*>; 54 | 55 | // can't import from React, see https://github.com/facebook/flow/issues/4787 56 | declare type ChildrenArray = $ReadOnlyArray> | T; 57 | 58 | declare type BaseProps = { 59 | easing: string, 60 | typeName: string, 61 | disableAllAnimations: boolean, 62 | getPosition: GetPosition, 63 | maintainContainerHeight: boolean, 64 | verticalAlignment: VerticalAlignment, 65 | }; 66 | 67 | declare type PolymorphicProps = { 68 | duration: string | number, 69 | delay: string | number, 70 | staggerDurationBy: string | number, 71 | staggerDelayBy: string | number, 72 | enterAnimation: AnimationProp, 73 | leaveAnimation: AnimationProp, 74 | }; 75 | 76 | declare type Hooks = { 77 | onStart?: ChildHook, 78 | onFinish?: ChildHook, 79 | onStartAll?: ChildrenHook, 80 | onFinishAll?: ChildrenHook, 81 | }; 82 | 83 | declare export type DelegatedProps = { 84 | style?: ReactStyles, 85 | }; 86 | 87 | declare export type FlipMoveDefaultProps = BaseProps & PolymorphicProps; 88 | 89 | declare export type CommonProps = BaseProps & 90 | Hooks & { 91 | children?: ChildrenArray, 92 | }; 93 | 94 | declare export type FlipMoveProps = FlipMoveDefaultProps & 95 | CommonProps & 96 | DelegatedProps & { 97 | appearAnimation?: AnimationProp, 98 | disableAnimations?: boolean, // deprecated, use disableAllAnimations instead 99 | }; 100 | 101 | declare class FlipMove extends React$Component { 102 | static defaultProps: FlipMoveDefaultProps; 103 | } 104 | 105 | declare export default typeof FlipMove 106 | } 107 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration 2 | const karmaWebpack = require('karma-webpack'); 3 | 4 | module.exports = function createConfig(config) { 5 | const configuration = { 6 | // base path that will be used to resolve all patterns (eg. files, exclude) 7 | basePath: '', 8 | 9 | // frameworks to use 10 | // available frameworks: https://npmjs.org/browse/keyword/karma-adapter 11 | frameworks: ['mocha', 'sinon-chai'], 12 | 13 | client: { 14 | mocha: { 15 | ui: 'bdd', 16 | }, 17 | }, 18 | 19 | // list of files / patterns to load in the browser 20 | files: ['test/**/*.spec.js'], 21 | 22 | webpack: { 23 | devtool: 'inline-source-map', 24 | 25 | module: { 26 | loaders: [ 27 | { 28 | test: /\.jsx?$/, 29 | loader: 'babel-loader', 30 | exclude: /node_modules/, 31 | }, 32 | ], 33 | }, 34 | 35 | externals: { 36 | cheerio: 'window', 37 | 'react/addons': true, 38 | 'react/lib/ExecutionEnvironment': true, 39 | 'react/lib/ReactContext': true, 40 | 'react-addons-test-utils': true, 41 | }, 42 | }, 43 | 44 | // list of files to exclude 45 | exclude: [], 46 | 47 | plugins: [ 48 | karmaWebpack, 49 | 'karma-mocha', 50 | 'karma-sinon-chai', 51 | 'karma-sourcemap-loader', 52 | 'karma-chrome-launcher', 53 | 'karma-coverage', 54 | ], 55 | 56 | // preprocess matching files before serving them to the browser 57 | // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor 58 | preprocessors: { 59 | 'src/**/*.js': ['webpack', 'sourcemap'], 60 | 'test/**/*.js': ['webpack', 'sourcemap'], 61 | }, 62 | 63 | // test results reporter to use 64 | // possible values: 'dots', 'progress' 65 | // available reporters: https://npmjs.org/browse/keyword/karma-reporter 66 | reporters: ['progress', 'coverage'], 67 | 68 | // web server port 69 | port: 9876, 70 | 71 | // enable / disable colors in the output (reporters and logs) 72 | colors: true, 73 | 74 | // level of logging 75 | // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG 76 | logLevel: config.LOG_INFO, 77 | 78 | // enable / disable watching file and executing tests whenever any file changes 79 | autoWatch: true, 80 | 81 | // start these browsers 82 | // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher 83 | browsers: ['Chrome', 'ChromeHeadless', 'ChromeHeadlessNoSandbox'], 84 | 85 | customLaunchers: { 86 | ChromeHeadlessNoSandbox: { 87 | base: 'ChromeHeadless', 88 | flags: ['--no-sandbox'], 89 | }, 90 | }, 91 | 92 | // Continuous Integration mode 93 | // if true, Karma captures browsers, runs the tests and exits 94 | singleRun: false, 95 | 96 | // Concurrency level 97 | // how many browser should be started simultaneous 98 | concurrency: Infinity, 99 | }; 100 | 101 | config.set(configuration); 102 | }; 103 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-flip-move", 3 | "version": "3.0.5", 4 | "description": "Effortless animation between DOM changes (eg. list reordering) using the FLIP technique.", 5 | "main": "dist/react-flip-move.cjs.js", 6 | "module": "dist/react-flip-move.es.js", 7 | "typings": "typings/react-flip-move.d.ts", 8 | "sideEffects": false, 9 | "files": [ 10 | "dist", 11 | "src", 12 | "typings" 13 | ], 14 | "scripts": { 15 | "lint": "eslint .", 16 | "lint:fix": "npm run lint -- --fix", 17 | "build": "rollup -c", 18 | "flow": "flow", 19 | "flow-coverage": "flow-coverage-report -i 'src/**/*.js' -t html -t json -t text", 20 | "prebuild": "npm run lint && flow check && npm run typescript && npm run test", 21 | "prepublishOnly": "npm run build", 22 | "prepush": "npm run prebuild", 23 | "test": "cross-env NODE_ENV=test ./node_modules/.bin/karma start --single-run", 24 | "test:dev": "karma start karma.conf.js", 25 | "tslint": "tslint typings/*.tsx typings/*.ts", 26 | "typescript": "npm run tslint && tsc typings/test.tsx --noEmit --jsx react --target es6 --module es2015 --moduleResolution node", 27 | "storybook": "start-storybook -p 9001", 28 | "storybook:preact": "cross-env REACT_IMPL=preact npm run storybook" 29 | }, 30 | "author": "Joshua Comeau ", 31 | "license": "MIT", 32 | "repository": { 33 | "type": "git", 34 | "url": "https://github.com/joshwcomeau/react-flip-move.git" 35 | }, 36 | "keywords": [ 37 | "react", 38 | "react-dom", 39 | "animation", 40 | "magic-move", 41 | "component", 42 | "react-component", 43 | "flip", 44 | "web-animations" 45 | ], 46 | "peerDependencies": { 47 | "react": ">=16.3.x", 48 | "react-dom": ">=16.3.x" 49 | }, 50 | "devDependencies": { 51 | "@storybook/react": "3.2.12", 52 | "@types/react": "16.9.1", 53 | "babel": "6.23.0", 54 | "babel-core": "6.26.3", 55 | "babel-eslint": "7.2.3", 56 | "babel-loader": "7.1.2", 57 | "babel-plugin-add-module-exports": "0.1.1", 58 | "babel-plugin-external-helpers": "^6.22.0", 59 | "babel-plugin-istanbul": "4.1.4", 60 | "babel-plugin-transform-decorators-legacy": "1.3.5", 61 | "babel-plugin-transform-object-assign": "6.22.0", 62 | "babel-preset-env": "^1.7.0", 63 | "babel-preset-flow": "7.0.0-beta.2", 64 | "babel-preset-react": "6.24.1", 65 | "babel-preset-stage-0": "^6.24.1", 66 | "chai": "4.2.0", 67 | "create-react-class": "15.6.3", 68 | "cross-env": "^5.2.0", 69 | "enzyme": "3.10.0", 70 | "enzyme-adapter-react-16": "1.14.0", 71 | "eslint": "3.10.0", 72 | "eslint-config-airbnb": "12.0.0", 73 | "eslint-config-prettier": "^2.3.0", 74 | "eslint-plugin-bdd": "2.1.1", 75 | "eslint-plugin-flowtype": "2.32.1", 76 | "eslint-plugin-import": "1.16.0", 77 | "eslint-plugin-jsx-a11y": "2.2.3", 78 | "eslint-plugin-prettier": "^2.2.0", 79 | "eslint-plugin-react": "6.10.3", 80 | "eslint-plugin-sort-class-members": "1.5.0", 81 | "flow-bin": "0.54.0", 82 | "flow-coverage-report": "0.3.0", 83 | "husky": "0.14.1", 84 | "karma": "1.7.1", 85 | "karma-chai": "0.1.0", 86 | "karma-chrome-launcher": "2.2.0", 87 | "karma-coverage": "1.1.1", 88 | "karma-mocha": "1.3.0", 89 | "karma-sinon": "1.0.5", 90 | "karma-sinon-chai": "1.3.2", 91 | "karma-sourcemap-loader": "0.3.7", 92 | "karma-webpack": "2.0.4", 93 | "lodash": "4.17.15", 94 | "mocha": "3.5.3", 95 | "preact": "8.5.1", 96 | "preact-compat": "3.19.0", 97 | "prettier": "1.18.2", 98 | "prop-types": "15.7.2", 99 | "react": "16.9.0", 100 | "react-dom": "16.9.0", 101 | "react-test-renderer": "16.9.0", 102 | "rollup": "^0.53.4", 103 | "rollup-plugin-babel": "^3.0.3", 104 | "rollup-plugin-replace": "^2.0.0", 105 | "rollup-plugin-uglify": "^2.0.1", 106 | "sinon": "1.17.3", 107 | "sinon-chai": "2.8.0", 108 | "styled-components": "^4.3.2", 109 | "tslint": "5.18.0", 110 | "typescript": "3.5.3", 111 | "webpack": "3.6.0" 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from 'rollup-plugin-babel'; 2 | import replace from 'rollup-plugin-replace'; 3 | import uglify from 'rollup-plugin-uglify'; 4 | import pkg from './package.json'; 5 | 6 | const mergeAll = objs => Object.assign({}, ...objs); 7 | 8 | const commonPlugins = [ 9 | babel({ 10 | exclude: 'node_modules/**', 11 | plugins: ['external-helpers'], 12 | }), 13 | ]; 14 | 15 | const configBase = { 16 | input: 'src/index.js', 17 | external: [ 18 | ...Object.keys(pkg.dependencies || {}), 19 | ...Object.keys(pkg.peerDependencies || {}), 20 | ], 21 | plugins: commonPlugins, 22 | }; 23 | 24 | const umdConfig = mergeAll([ 25 | configBase, 26 | { 27 | output: { 28 | file: `dist/${pkg.name}.js`, 29 | format: 'umd', 30 | name: 'FlipMove', 31 | globals: { 32 | react: 'React', 33 | 'react-dom': 'ReactDOM', 34 | }, 35 | }, 36 | external: Object.keys(pkg.peerDependencies || {}), 37 | }, 38 | ]); 39 | 40 | const devUmdConfig = mergeAll([ 41 | umdConfig, 42 | { 43 | plugins: umdConfig.plugins.concat( 44 | replace({ 45 | 'process.env.NODE_ENV': JSON.stringify('development'), 46 | }), 47 | ), 48 | }, 49 | ]); 50 | 51 | const prodUmdConfig = mergeAll([ 52 | umdConfig, 53 | { 54 | output: mergeAll([ 55 | umdConfig.output, 56 | { file: umdConfig.output.file.replace(/\.js$/, '.min.js') }, 57 | ]), 58 | }, 59 | { 60 | plugins: umdConfig.plugins.concat( 61 | replace({ 62 | 'process.env.NODE_ENV': JSON.stringify('production'), 63 | }), 64 | uglify({ 65 | compress: { 66 | pure_getters: true, 67 | unsafe: true, 68 | unsafe_comps: true, 69 | warnings: false, 70 | }, 71 | }), 72 | ), 73 | }, 74 | ]); 75 | 76 | const webConfig = mergeAll([ 77 | configBase, 78 | { 79 | output: [ 80 | { file: pkg.module, format: 'es' }, 81 | { file: pkg.main, format: 'cjs' }, 82 | ], 83 | }, 84 | ]); 85 | 86 | export default [devUmdConfig, prodUmdConfig, webConfig]; 87 | -------------------------------------------------------------------------------- /src/CODE_TOUR.md: -------------------------------------------------------------------------------- 1 | # Code Tour 2 | 3 | This project started off pretty simply: Use the FLIP technique to handle list re-orderings. 4 | 5 | Over the past year, though, the code has become increasingly complex as new features were added, and edge-cases corrected. 6 | 7 | This guide serves as a high-level overview of how the code works, to make it easier for others (and future me) to continue developing this project. 8 | 9 | 10 | ## Prop Conversion via HOC 11 | 12 | FlipMove takes a lot of props, and these props need a fair bit of validation/cleanup. 13 | 14 | Rather than litter the main component with a bunch of guards and boilerplate, I created a higher-order component which handles all of that business. 15 | 16 | the file `prop-converter.js` holds all prop-validation logic. It should mostly be pretty self-explanatory: It takes in props, validates them, makes a few tweaks, and passes them down to FlipMove. 17 | 18 | 19 | ### DOM Manipulation 20 | 21 | There's no getting around it: when you practice the FLIP technique, you have to get down and dirty with the DOM. 22 | 23 | I've tried to isolate all DOM activity to a set of impure functions, located in `dom-manipulation.js`. 24 | 25 | 26 | ## FlipMove 27 | 28 | All of the core logic lives within the primary FlipMove component. It's become a bit of a behemoth, so let's walk through how it works at a high level. 29 | 30 | 31 | ### Instantiation 32 | 33 | A bunch of stuff happens when a FlipMove component is instantiated. 34 | 35 | ##### `childrenData` 36 | 37 | `childrenData` holds metadata about the rendered children. A snapshot might look like this: 38 | 39 | ```js 40 | { 41 | abc123: { 42 | domNode:
      ...
      , 43 | boundingBox: { 44 | top: 10, 45 | left: 0, 46 | right: 500, 47 | bottom: 530, 48 | width: 100, 49 | height: 300, 50 | }, 51 | }, 52 | } 53 | ``` 54 | 55 | The object is keyed by the `key` prop that you must supply when passing children to FlipMove, and it holds a reference to the backing instance, and that backing instance's bounding box✱. 56 | 57 | > ✱ Sidenote: Typically, a boundingBox refers to where an element is relative to the _viewport_. In our case, though, it refers to the element's position relative to its parent. We do this so that scrolling doesn't break everything. 58 | 59 | ##### `parentData` 60 | 61 | Similarly, `parentData` holds the same information about the wrapping container element, the one created by FlipMove itself. 62 | 63 | 64 | ##### `heightPlaceholderData` 65 | 66 | The default behaviour, when items are removed from the DOM, is for the container height to instantly snap down to its new height. 67 | 68 | This makes sense, when you think about it. Items that are animating out NEED to be removed from the DOM flow, so that its siblings can begin to move and take its space. However, it means that the parent container suddenly has "holes", and will collapse to only fit the non-removing containers. 69 | 70 | To combat this issue, an optional prop can be provided, to maintain the container's height until all animations have completed. 71 | 72 | In order to accomplish this goal, we have a placeholder. When items are removed, it grows to fill the "holes" created by them, so that the parent container doesn't need to shrink at all. 73 | 74 | `heightPlaceholderData` just holds a reference to that DOM node. 75 | 76 | 77 | ##### `state.children` 78 | 79 | Also within our constructor, we transfer the contents of `this.props.children` into the component's state. 80 | 81 | You may have read that this is an anti-pattern, and produces multiple sources of truth. In our case, though, we actually need two separate sources of truth, to deal with leave animations. 82 | 83 | Let's say our component receives new props, and two of the children are missing in them. We can deduce that these children need to be removed from the DOM. 84 | 85 | If we simply use this.props.children, though, the missing children will instantly disappear. We can't smoothly transition them away, because they're immediately removed from the DOM. 86 | 87 | By copying props to state, and rendering from `this.state.children`, we can hang onto them for a brief moment. 88 | 89 | 90 | ##### `remainingAnimations` and `childrenToAnimate` 91 | 92 | We need to track which element(s) are currently undergoing an animation, and how many are left. 93 | 94 | We have hooks for `onStartAll` and `onFinishAll`, and these hooks are provided the element(s) and DOM node(s) of all animating children. `childrenToAnimate` is an array which holds all the `key`s of children that are animating. 95 | 96 | `remainingAnimations` is a simple counter, and it's how we can tell that _all_ animations have completed. Because we can stagger animations, they don't all finish at the same time. 97 | 98 | 99 | ##### `originalDomStyles` 100 | 101 | Finally, in our constructor, we have an object that holds CSS. 102 | 103 | Don't worry too much about this; it's an edge-case bug fix for when items are removed and then re-introduced in rapid succession, to ensure that their original styles can be re-applied. 104 | 105 | 106 | 107 | ## FLIP flow 108 | 109 | Let's quickly go over how the FLIP process works: 110 | 111 | - The component receives props. 112 | 113 | - We update our cached values for children and parent bounding boxes. This will become the "First" position, our origins. 114 | 115 | - We compute our new set of children - this may be a simple matter of using this.props.children, but if items are leaving, we need to do some rejigging. 116 | 117 | - we set our state to these new children, causing a re-render. The re-render causes the children to render in their new, final position, also known as the 'Last' position, in FLIP terminology. 118 | 119 | - After the component has been re-rendered, but _before_ the changes have been painted to screen, we need to run our animation. Before we can do that, though, we need to do our animation prep. Prep consists of: 120 | - for children that are about to leave, remove them from the document flow, so that its siblings can shift into its position. 121 | - update the placeholder height, if needed, to keep the container open despite the removal from document flow. 122 | 123 | - Finally, the animation! We filter out all children that don't need to be animated, invoke the `onStartAll` callback with the dynamic children, and hand each one to our `animateChild` method. 124 | 125 | - `animateChild` does the actual flipping. For items that are being shuffled, it starts by calculating where it should be, by comparing the cached boundingBox with a freshly-calculated one. For items that are entering/leaving, we just merge in the `from` animation style. This is the 'Invert' stage. 126 | 127 | - At this point, the DOM has been redrawn with the items in their new positions, but then we've offset them (using `transform: translate`) so that everything is exactly where it was before the change. We allow a frame to pass, so that these invisible changes can be painted to the screen. 128 | 129 | - Then, to "Play" the animation, we simply remove our `transform` prop, and apply a `transition` so that it happens gradually. The item's transform will undo itself, and the item will shift back into its natural, new position. 130 | 131 | - We bind a `transitionEnd` event listener so that we know exactly when each animation ends. At that point, we do a few things: 132 | 133 | - remove the `transition` property we applied. 134 | - trigger the `onFinish` hook for this element. 135 | - If this is the last item that needed to animate, trigger the `onFinishAll` hook, and clean up our various variables so that the next run starts from a clean slate. 136 | 137 | 138 | ### Method Map 139 | 140 | The summary above is well and good, but sometimes I just need to refresh my memory on how the method calls are laid out. Here's what an update cycle looks like: 141 | 142 | 143 | ``` 144 | - componentWillReceiveProps 145 | - this.updateBoundingBoxCaches 146 | - getRelativeBoundingBox 147 | - this.calculateNextSetOfChildren 148 | - this.setState 149 | 150 | - componentDidUpdate 151 | - this.prepForAnimation 152 | - removeNodeFromDOMFlow 153 | - updateHeightPlaceholder 154 | - this.runAnimation 155 | - this.doesChildNeedToBeAnimated 156 | - getPositionDelta 157 | - this.animateChild 158 | - this.computeInitialStyles 159 | - applyStylesToDOMNode 160 | - createTransitionString 161 | - applyStylesToDOMNode 162 | - this.bindTransitionEndHandler 163 | - this.triggerFinishHooks 164 | - this.formatChildrenForHooks 165 | - this.formatChildrenForHooks 166 | ``` 167 | -------------------------------------------------------------------------------- /src/FlipMove.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | /** 3 | * React Flip Move 4 | * (c) 2016-present Joshua Comeau 5 | * 6 | * For information on how this code is laid out, check out CODE_TOUR.md 7 | */ 8 | 9 | /* eslint-disable react/prop-types */ 10 | 11 | import { Children, cloneElement, createElement, Component } from 'react'; 12 | import ReactDOM from 'react-dom'; 13 | // eslint-disable-next-line no-duplicate-imports 14 | import type { Element, ElementRef, Key, ChildrenArray } from 'react'; 15 | 16 | import { parentNodePositionStatic, childIsDisabled } from './error-messages'; 17 | import propConverter from './prop-converter'; 18 | import { 19 | applyStylesToDOMNode, 20 | createTransitionString, 21 | getNativeNode, 22 | getPositionDelta, 23 | getRelativeBoundingBox, 24 | removeNodeFromDOMFlow, 25 | updateHeightPlaceholder, 26 | whichTransitionEvent, 27 | } from './dom-manipulation'; 28 | import { arraysEqual, find } from './helpers'; 29 | import type { 30 | Child, 31 | ConvertedProps, 32 | FlipMoveState, 33 | ElementShape, 34 | ChildrenHook, 35 | ChildData, 36 | NodeData, 37 | DelegatedProps, 38 | Styles, 39 | } from './typings'; 40 | 41 | const transitionEnd = whichTransitionEvent(); 42 | const noBrowserSupport = !transitionEnd; 43 | 44 | function getKey(childData: ChildData): Key { 45 | return childData.key || ''; 46 | } 47 | 48 | function getElementChildren(children: ChildrenArray): Array> { 49 | // Fix incomplete typing of Children.toArray 50 | // eslint-disable-next-line flowtype/no-weak-types 51 | return (Children.toArray(children): any); 52 | } 53 | 54 | class FlipMove extends Component { 55 | // Copy props.children into state. 56 | // To understand why this is important (and not an anti-pattern), consider 57 | // how "leave" animations work. An item has "left" when the component 58 | // receives a new set of props that do NOT contain the item. 59 | // If we just render the props as-is, the item would instantly disappear. 60 | // We want to keep the item rendered for a little while, until its animation 61 | // can complete. Because we cannot mutate props, we make `state` the source 62 | // of truth. 63 | state = { 64 | children: getElementChildren( 65 | // `this.props` ought to always be defined at this point, but a report 66 | // was made about it not being defined in IE10. 67 | // TODO: Test in IE10, to see if there's an underlying cause that can 68 | // be addressed. 69 | this.props ? this.props.children : [], 70 | ).map((element: Element<*>) => ({ 71 | ...element, 72 | element, 73 | appearing: true, 74 | })), 75 | }; 76 | 77 | // FlipMove needs to know quite a bit about its children in order to do 78 | // its job. We store these as a property on the instance. We're not using 79 | // state, because we don't want changes to trigger re-renders, we just 80 | // need a place to keep the data for reference, when changes happen. 81 | // This field should not be accessed directly. Instead, use getChildData, 82 | // putChildData, etc... 83 | childrenData: { 84 | /* Populated via callback refs on render. eg 85 | userSpecifiedKey1: { 86 | domNode: , 87 | boundingBox: { top, left, right, bottom, width, height }, 88 | }, 89 | userSpecifiedKey2: { ... }, 90 | ... 91 | */ 92 | [userSpecifiedKey: Key]: NodeData, 93 | } = {}; 94 | 95 | // Similarly, track the dom node and box of our parent element. 96 | parentData: NodeData = { 97 | domNode: null, 98 | boundingBox: null, 99 | }; 100 | 101 | // If `maintainContainerHeight` prop is set to true, we'll create a 102 | // placeholder element which occupies space so that the parent height 103 | // doesn't change when items are removed from the document flow (which 104 | // happens during leave animations) 105 | heightPlaceholderData: NodeData = { 106 | domNode: null, 107 | }; 108 | 109 | // Keep track of remaining animations so we know when to fire the 110 | // all-finished callback, and clean up after ourselves. 111 | // NOTE: we can't simply use childrenToAnimate.length to track remaining 112 | // animations, because we need to maintain the list of animating children, 113 | // to pass to the `onFinishAll` handler. 114 | remainingAnimations = 0; 115 | childrenToAnimate: Array = []; 116 | 117 | componentDidMount() { 118 | // Because React 16 no longer requires wrapping elements, Flip Move can opt 119 | // to not wrap the children in an element. In that case, find the parent 120 | // element using `findDOMNode`. 121 | if (this.props.typeName === null) { 122 | this.findDOMContainer(); 123 | } 124 | 125 | // Run our `appearAnimation` if it was requested, right after the 126 | // component mounts. 127 | const shouldTriggerFLIP = 128 | this.props.appearAnimation && !this.isAnimationDisabled(this.props); 129 | 130 | if (shouldTriggerFLIP) { 131 | this.prepForAnimation(); 132 | this.runAnimation(); 133 | } 134 | } 135 | 136 | componentDidUpdate(previousProps: ConvertedProps) { 137 | if (this.props.typeName === null) { 138 | this.findDOMContainer(); 139 | } 140 | // If the children have been re-arranged, moved, or added/removed, 141 | // trigger the main FLIP animation. 142 | // 143 | // IMPORTANT: We need to make sure that the children have actually changed. 144 | // At the end of the transition, we clean up nodes that need to be removed. 145 | // We DON'T want this cleanup to trigger another update. 146 | 147 | const oldChildrenKeys: Array = getElementChildren( 148 | this.props.children, 149 | ).map((d: Element<*>) => d.key); 150 | const nextChildrenKeys: Array = getElementChildren( 151 | previousProps.children, 152 | ).map((d: Element<*>) => d.key); 153 | 154 | const shouldTriggerFLIP = 155 | !arraysEqual(oldChildrenKeys, nextChildrenKeys) && 156 | !this.isAnimationDisabled(this.props); 157 | 158 | if (shouldTriggerFLIP) { 159 | this.prepForAnimation(); 160 | this.runAnimation(); 161 | } 162 | } 163 | 164 | findDOMContainer = () => { 165 | // eslint-disable-next-line react/no-find-dom-node 166 | const domNode = ReactDOM.findDOMNode(this); 167 | const parentNode = domNode && domNode.parentNode; 168 | 169 | // This ought to be impossible, but handling it for Flow's sake. 170 | if (!parentNode || !(parentNode instanceof HTMLElement)) { 171 | return; 172 | } 173 | 174 | // If the parent node has static positioning, leave animations might look 175 | // really funky. Let's automatically apply `position: relative` in this 176 | // case, to prevent any quirkiness. 177 | if (window.getComputedStyle(parentNode).position === 'static') { 178 | parentNode.style.position = 'relative'; 179 | parentNodePositionStatic(); 180 | } 181 | 182 | this.parentData.domNode = parentNode; 183 | }; 184 | 185 | runAnimation = () => { 186 | const dynamicChildren = this.state.children.filter( 187 | this.doesChildNeedToBeAnimated, 188 | ); 189 | 190 | // Splitting DOM reads and writes to be peformed in batches 191 | const childrenInitialStyles = dynamicChildren.map(child => 192 | this.computeInitialStyles(child), 193 | ); 194 | dynamicChildren.forEach((child, index) => { 195 | this.remainingAnimations += 1; 196 | this.childrenToAnimate.push(getKey(child)); 197 | this.animateChild(child, index, childrenInitialStyles[index]); 198 | }); 199 | 200 | if (typeof this.props.onStartAll === 'function') { 201 | this.callChildrenHook(this.props.onStartAll); 202 | } 203 | }; 204 | 205 | doesChildNeedToBeAnimated = (child: ChildData) => { 206 | // If the child doesn't have a key, it's an immovable child (one that we 207 | // do not want to do FLIP stuff to.) 208 | if (!getKey(child)) { 209 | return false; 210 | } 211 | 212 | const childData: NodeData = this.getChildData(getKey(child)); 213 | const childDomNode = childData.domNode; 214 | const childBoundingBox = childData.boundingBox; 215 | const parentBoundingBox = this.parentData.boundingBox; 216 | 217 | if (!childDomNode) { 218 | return false; 219 | } 220 | 221 | const { 222 | appearAnimation, 223 | enterAnimation, 224 | leaveAnimation, 225 | getPosition, 226 | } = this.props; 227 | 228 | const isAppearingWithAnimation = child.appearing && appearAnimation; 229 | const isEnteringWithAnimation = child.entering && enterAnimation; 230 | const isLeavingWithAnimation = child.leaving && leaveAnimation; 231 | 232 | if ( 233 | isAppearingWithAnimation || 234 | isEnteringWithAnimation || 235 | isLeavingWithAnimation 236 | ) { 237 | return true; 238 | } 239 | 240 | // If it isn't entering/leaving, we want to animate it if it's 241 | // on-screen position has changed. 242 | const [dX, dY] = getPositionDelta({ 243 | childDomNode, 244 | childBoundingBox, 245 | parentBoundingBox, 246 | getPosition, 247 | }); 248 | 249 | return dX !== 0 || dY !== 0; 250 | }; 251 | 252 | calculateNextSetOfChildren( 253 | nextChildren: Array>, 254 | ): Array { 255 | // We want to: 256 | // - Mark all new children as `entering` 257 | // - Pull in previous children that aren't in nextChildren, and mark them 258 | // as `leaving` 259 | // - Preserve the nextChildren list order, with leaving children in their 260 | // appropriate places. 261 | // 262 | 263 | // Start by marking new children as 'entering' 264 | const updatedChildren: Array = nextChildren.map(nextChild => { 265 | const child = this.findChildByKey(nextChild.key); 266 | 267 | // If the current child did exist, but it was in the midst of leaving, 268 | // we want to treat it as though it's entering 269 | const isEntering = !child || child.leaving; 270 | 271 | return { ...nextChild, element: nextChild, entering: isEntering }; 272 | }); 273 | 274 | // This is tricky. We want to keep the nextChildren's ordering, but with 275 | // any just-removed items maintaining their original position. 276 | // eg. 277 | // this.state.children = [ 1, 2, 3, 4 ] 278 | // nextChildren = [ 3, 1 ] 279 | // 280 | // In this example, we've removed the '2' & '4' 281 | // We want to end up with: [ 2, 3, 1, 4 ] 282 | // 283 | // To accomplish that, we'll iterate through this.state.children. whenever 284 | // we find a match, we'll append our `leaving` flag to it, and insert it 285 | // into the nextChildren in its ORIGINAL position. Note that, as we keep 286 | // inserting old items into the new list, the "original" position will 287 | // keep incrementing. 288 | let numOfChildrenLeaving = 0; 289 | this.state.children.forEach((child: ChildData, index) => { 290 | const isLeaving = !find(({ key }) => key === getKey(child), nextChildren); 291 | 292 | // If the child isn't leaving (or, if there is no leave animation), 293 | // we don't need to add it into the state children. 294 | if (!isLeaving || !this.props.leaveAnimation) return; 295 | 296 | const nextChild: ChildData = { ...child, leaving: true }; 297 | const nextChildIndex = index + numOfChildrenLeaving; 298 | 299 | updatedChildren.splice(nextChildIndex, 0, nextChild); 300 | numOfChildrenLeaving += 1; 301 | }); 302 | 303 | return updatedChildren; 304 | } 305 | 306 | prepForAnimation() { 307 | // Our animation prep consists of: 308 | // - remove children that are leaving from the DOM flow, so that the new 309 | // layout can be accurately calculated, 310 | // - update the placeholder container height, if needed, to ensure that 311 | // the parent's height doesn't collapse. 312 | 313 | const { leaveAnimation, maintainContainerHeight, getPosition } = this.props; 314 | 315 | // we need to make all leaving nodes "invisible" to the layout calculations 316 | // that will take place in the next step (this.runAnimation). 317 | if (leaveAnimation) { 318 | const leavingChildren = this.state.children.filter( 319 | child => child.leaving, 320 | ); 321 | 322 | leavingChildren.forEach(leavingChild => { 323 | const childData = this.getChildData(getKey(leavingChild)); 324 | 325 | // Warn if child is disabled 326 | if ( 327 | !this.isAnimationDisabled(this.props) && 328 | childData.domNode && 329 | childData.domNode.disabled 330 | ) { 331 | childIsDisabled(); 332 | } 333 | 334 | // We need to take the items out of the "flow" of the document, so that 335 | // its siblings can move to take its place. 336 | if (childData.boundingBox) { 337 | removeNodeFromDOMFlow(childData, this.props.verticalAlignment); 338 | } 339 | }); 340 | 341 | if (maintainContainerHeight && this.heightPlaceholderData.domNode) { 342 | updateHeightPlaceholder({ 343 | domNode: this.heightPlaceholderData.domNode, 344 | parentData: this.parentData, 345 | getPosition, 346 | }); 347 | } 348 | } 349 | 350 | // For all children not in the middle of entering or leaving, 351 | // we need to reset the transition, so that the NEW shuffle starts from 352 | // the right place. 353 | this.state.children.forEach(child => { 354 | const { domNode } = this.getChildData(getKey(child)); 355 | 356 | // Ignore children that don't render DOM nodes (eg. by returning null) 357 | if (!domNode) { 358 | return; 359 | } 360 | 361 | if (!child.entering && !child.leaving) { 362 | applyStylesToDOMNode({ 363 | domNode, 364 | styles: { 365 | transition: '', 366 | }, 367 | }); 368 | } 369 | }); 370 | } 371 | 372 | // eslint-disable-next-line camelcase 373 | UNSAFE_componentWillReceiveProps(nextProps: ConvertedProps) { 374 | // When the component is handed new props, we need to figure out the 375 | // "resting" position of all currently-rendered DOM nodes. 376 | // We store that data in this.parent and this.children, 377 | // so it can be used later to work out the animation. 378 | this.updateBoundingBoxCaches(); 379 | 380 | // Convert opaque children object to array. 381 | const nextChildren: Array> = getElementChildren( 382 | nextProps.children, 383 | ); 384 | 385 | // Next, we need to update our state, so that it contains our new set of 386 | // children. If animation is disabled or unsupported, this is easy; 387 | // we just copy our props into state. 388 | // Assuming that we can animate, though, we have to do some work. 389 | // Essentially, we want to keep just-deleted nodes in the DOM for a bit 390 | // longer, so that we can animate them away. 391 | this.setState({ 392 | children: this.isAnimationDisabled(nextProps) 393 | ? nextChildren.map(element => ({ ...element, element })) 394 | : this.calculateNextSetOfChildren(nextChildren), 395 | }); 396 | } 397 | 398 | animateChild(child: ChildData, index: number, childInitialStyles: Styles) { 399 | const { domNode } = this.getChildData(getKey(child)); 400 | if (!domNode) { 401 | return; 402 | } 403 | 404 | // Apply the relevant style for this DOM node 405 | // This is the offset from its actual DOM position. 406 | // eg. if an item has been re-rendered 20px lower, we want to apply a 407 | // style of 'transform: translate(-20px)', so that it appears to be where 408 | // it started. 409 | // In FLIP terminology, this is the 'Invert' stage. 410 | applyStylesToDOMNode({ 411 | domNode, 412 | styles: childInitialStyles, 413 | }); 414 | 415 | // Start by invoking the onStart callback for this child. 416 | if (this.props.onStart) this.props.onStart(child, domNode); 417 | 418 | // Next, animate the item from it's artificially-offset position to its 419 | // new, natural position. 420 | requestAnimationFrame(() => { 421 | requestAnimationFrame(() => { 422 | // NOTE, RE: the double-requestAnimationFrame: 423 | // Sadly, this is the most browser-compatible way to do this I've found. 424 | // Essentially we need to set the initial styles outside of any request 425 | // callbacks to avoid batching them. Then, a frame needs to pass with 426 | // the styles above rendered. Then, on the second frame, we can apply 427 | // our final styles to perform the animation. 428 | 429 | // Our first order of business is to "undo" the styles applied in the 430 | // previous frames, while also adding a `transition` property. 431 | // This way, the item will smoothly transition from its old position 432 | // to its new position. 433 | 434 | // eslint-disable-next-line flowtype/require-variable-type 435 | let styles = { 436 | transition: createTransitionString(index, this.props), 437 | transform: '', 438 | opacity: '', 439 | }; 440 | 441 | if (child.appearing && this.props.appearAnimation) { 442 | styles = { 443 | ...styles, 444 | ...this.props.appearAnimation.to, 445 | }; 446 | } else if (child.entering && this.props.enterAnimation) { 447 | styles = { 448 | ...styles, 449 | ...this.props.enterAnimation.to, 450 | }; 451 | } else if (child.leaving && this.props.leaveAnimation) { 452 | styles = { 453 | ...styles, 454 | ...this.props.leaveAnimation.to, 455 | }; 456 | } 457 | 458 | // In FLIP terminology, this is the 'Play' stage. 459 | applyStylesToDOMNode({ domNode, styles }); 460 | }); 461 | }); 462 | 463 | this.bindTransitionEndHandler(child); 464 | } 465 | 466 | bindTransitionEndHandler(child: ChildData) { 467 | const { domNode } = this.getChildData(getKey(child)); 468 | if (!domNode) { 469 | return; 470 | } 471 | 472 | // The onFinish callback needs to be bound to the transitionEnd event. 473 | // We also need to unbind it when the transition completes, so this ugly 474 | // inline function is required (we need it here so it closes over 475 | // dependent variables `child` and `domNode`) 476 | const transitionEndHandler = (ev: Event) => { 477 | // It's possible that this handler is fired not on our primary transition, 478 | // but on a nested transition (eg. a hover effect). Ignore these cases. 479 | if (ev.target !== domNode) return; 480 | 481 | // Remove the 'transition' inline style we added. This is cleanup. 482 | domNode.style.transition = ''; 483 | 484 | // Trigger any applicable onFinish/onFinishAll hooks 485 | this.triggerFinishHooks(child, domNode); 486 | 487 | domNode.removeEventListener(transitionEnd, transitionEndHandler); 488 | 489 | if (child.leaving) { 490 | this.removeChildData(getKey(child)); 491 | } 492 | }; 493 | 494 | domNode.addEventListener(transitionEnd, transitionEndHandler); 495 | } 496 | 497 | triggerFinishHooks(child: ChildData, domNode: HTMLElement) { 498 | if (this.props.onFinish) this.props.onFinish(child, domNode); 499 | 500 | // Reduce the number of children we need to animate by 1, 501 | // so that we can tell when all children have finished. 502 | this.remainingAnimations -= 1; 503 | 504 | if (this.remainingAnimations === 0) { 505 | // Remove any items from the DOM that have left, and reset `entering`. 506 | const nextChildren: Array = this.state.children 507 | .filter(({ leaving }) => !leaving) 508 | .map((item: ChildData) => ({ 509 | ...item, 510 | // fix for Flow 511 | element: item.element, 512 | appearing: false, 513 | entering: false, 514 | })); 515 | 516 | this.setState({ children: nextChildren }, () => { 517 | if (typeof this.props.onFinishAll === 'function') { 518 | this.callChildrenHook(this.props.onFinishAll); 519 | } 520 | 521 | // Reset our variables for the next iteration 522 | this.childrenToAnimate = []; 523 | }); 524 | 525 | // If the placeholder was holding the container open while elements were 526 | // leaving, we we can now set its height to zero. 527 | if (this.heightPlaceholderData.domNode) { 528 | this.heightPlaceholderData.domNode.style.height = '0'; 529 | } 530 | } 531 | } 532 | 533 | callChildrenHook(hook: ChildrenHook) { 534 | const elements: Array = []; 535 | const domNodes: Array = []; 536 | 537 | this.childrenToAnimate.forEach(childKey => { 538 | // If this was an exit animation, the child may no longer exist. 539 | // If so, skip it. 540 | const child = this.findChildByKey(childKey); 541 | 542 | if (!child) { 543 | return; 544 | } 545 | 546 | elements.push(child); 547 | 548 | if (this.hasChildData(childKey)) { 549 | domNodes.push(this.getChildData(childKey).domNode); 550 | } 551 | }); 552 | 553 | hook(elements, domNodes); 554 | } 555 | 556 | updateBoundingBoxCaches() { 557 | // This is the ONLY place that parentData and childrenData's 558 | // bounding boxes are updated. They will be calculated at other times 559 | // to be compared to this value, but it's important that the cache is 560 | // updated once per update. 561 | const parentDomNode = this.parentData.domNode; 562 | 563 | if (!parentDomNode) { 564 | return; 565 | } 566 | 567 | this.parentData.boundingBox = this.props.getPosition(parentDomNode); 568 | 569 | // Splitting DOM reads and writes to be peformed in batches 570 | const childrenBoundingBoxes = []; 571 | 572 | this.state.children.forEach(child => { 573 | const childKey = getKey(child); 574 | 575 | // It is possible that a child does not have a `key` property; 576 | // Ignore these children, they don't need to be moved. 577 | if (!childKey) { 578 | childrenBoundingBoxes.push(null); 579 | return; 580 | } 581 | 582 | // In very rare circumstances, for reasons unknown, the ref is never 583 | // populated for certain children. In this case, avoid doing this update. 584 | // see: https://github.com/joshwcomeau/react-flip-move/pull/91 585 | if (!this.hasChildData(childKey)) { 586 | childrenBoundingBoxes.push(null); 587 | return; 588 | } 589 | 590 | const childData = this.getChildData(childKey); 591 | 592 | // If the child element returns null, we need to avoid trying to 593 | // account for it 594 | if (!childData.domNode || !child) { 595 | childrenBoundingBoxes.push(null); 596 | return; 597 | } 598 | 599 | childrenBoundingBoxes.push( 600 | getRelativeBoundingBox({ 601 | childDomNode: childData.domNode, 602 | parentDomNode, 603 | getPosition: this.props.getPosition, 604 | }), 605 | ); 606 | }); 607 | 608 | this.state.children.forEach((child, index) => { 609 | const childKey = getKey(child); 610 | 611 | const childBoundingBox = childrenBoundingBoxes[index]; 612 | 613 | if (!childKey) { 614 | return; 615 | } 616 | 617 | this.setChildData(childKey, { 618 | boundingBox: childBoundingBox, 619 | }); 620 | }); 621 | } 622 | 623 | computeInitialStyles(child: ChildData): Styles { 624 | if (child.appearing) { 625 | return this.props.appearAnimation ? this.props.appearAnimation.from : {}; 626 | } else if (child.entering) { 627 | if (!this.props.enterAnimation) { 628 | return {}; 629 | } 630 | // If this child was in the middle of leaving, it still has its 631 | // absolute positioning styles applied. We need to undo those. 632 | return { 633 | position: '', 634 | top: '', 635 | left: '', 636 | right: '', 637 | bottom: '', 638 | ...this.props.enterAnimation.from, 639 | }; 640 | } else if (child.leaving) { 641 | return this.props.leaveAnimation ? this.props.leaveAnimation.from : {}; 642 | } 643 | 644 | const childData = this.getChildData(getKey(child)); 645 | const childDomNode = childData.domNode; 646 | const childBoundingBox = childData.boundingBox; 647 | const parentBoundingBox = this.parentData.boundingBox; 648 | 649 | if (!childDomNode) { 650 | return {}; 651 | } 652 | 653 | const [dX, dY] = getPositionDelta({ 654 | childDomNode, 655 | childBoundingBox, 656 | parentBoundingBox, 657 | getPosition: this.props.getPosition, 658 | }); 659 | 660 | return { 661 | transform: `translate(${dX}px, ${dY}px)`, 662 | }; 663 | } 664 | 665 | // eslint-disable-next-line class-methods-use-this 666 | isAnimationDisabled(props: ConvertedProps): boolean { 667 | // If the component is explicitly passed a `disableAllAnimations` flag, 668 | // we can skip this whole process. Similarly, if all of the numbers have 669 | // been set to 0, there is no point in trying to animate; doing so would 670 | // only cause a flicker (and the intent is probably to disable animations) 671 | // We can also skip this rigamarole if there's no browser support for it. 672 | return ( 673 | noBrowserSupport || 674 | props.disableAllAnimations || 675 | (props.duration === 0 && 676 | props.delay === 0 && 677 | props.staggerDurationBy === 0 && 678 | props.staggerDelayBy === 0) 679 | ); 680 | } 681 | 682 | findChildByKey(key: ?Key): ?ChildData { 683 | return find(child => getKey(child) === key, this.state.children); 684 | } 685 | 686 | hasChildData(key: Key): boolean { 687 | // Object has some built-in properties on its prototype, such as toString. hasOwnProperty makes 688 | // sure that key is present on childrenData itself, not on its prototype. 689 | return Object.prototype.hasOwnProperty.call(this.childrenData, key); 690 | } 691 | 692 | getChildData(key: Key): NodeData { 693 | return this.hasChildData(key) ? this.childrenData[key] : {}; 694 | } 695 | 696 | setChildData(key: Key, data: NodeData): void { 697 | this.childrenData[key] = { ...this.getChildData(key), ...data }; 698 | } 699 | 700 | removeChildData(key: Key): void { 701 | delete this.childrenData[key]; 702 | this.setState(prevState => ({ 703 | ...prevState, 704 | children: prevState.children.filter(child => child.element.key !== key), 705 | })); 706 | } 707 | 708 | createHeightPlaceholder(): Element<*> { 709 | const { typeName } = this.props; 710 | 711 | // If requested, create an invisible element at the end of the list. 712 | // Its height will be modified to prevent the container from collapsing 713 | // prematurely. 714 | const isContainerAList = typeName === 'ul' || typeName === 'ol'; 715 | const placeholderType = isContainerAList ? 'li' : 'div'; 716 | 717 | return createElement(placeholderType, { 718 | key: 'height-placeholder', 719 | ref: (domNode: ?HTMLElement) => { 720 | this.heightPlaceholderData.domNode = domNode; 721 | }, 722 | style: { visibility: 'hidden', height: 0 }, 723 | }); 724 | } 725 | 726 | childrenWithRefs(): Array> { 727 | // We need to clone the provided children, capturing a reference to the 728 | // underlying DOM node. Flip Move needs to use the React escape hatches to 729 | // be able to do its calculations. 730 | return this.state.children.map(child => 731 | cloneElement(child.element, { 732 | ref: (element: ?ElementRef<*>) => { 733 | // Functional Components without a forwarded ref are not supported by FlipMove, 734 | // because they don't have instances. 735 | if (!element) { 736 | return; 737 | } 738 | 739 | const domNode: ?HTMLElement = getNativeNode(element); 740 | this.setChildData(getKey(child), { domNode }); 741 | }, 742 | }), 743 | ); 744 | } 745 | 746 | render() { 747 | const { 748 | typeName, 749 | delegated, 750 | leaveAnimation, 751 | maintainContainerHeight, 752 | } = this.props; 753 | 754 | const children = this.childrenWithRefs(); 755 | if (leaveAnimation && maintainContainerHeight) { 756 | children.push(this.createHeightPlaceholder()); 757 | } 758 | 759 | if (!typeName) return children; 760 | 761 | const props: DelegatedProps = { 762 | ...delegated, 763 | children, 764 | ref: (node: ?HTMLElement) => { 765 | this.parentData.domNode = node; 766 | }, 767 | }; 768 | 769 | return createElement(typeName, props); 770 | } 771 | } 772 | 773 | const enhancedFlipMove = /* #__PURE__ */ propConverter(FlipMove); 774 | 775 | export default enhancedFlipMove; 776 | -------------------------------------------------------------------------------- /src/dom-manipulation.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | /** 3 | * React Flip Move 4 | * (c) 2016-present Joshua Comeau 5 | * 6 | * These methods read from and write to the DOM. 7 | * They almost always have side effects, and will hopefully become the 8 | * only spot in the codebase with impure functions. 9 | */ 10 | import { findDOMNode } from 'react-dom'; 11 | import type { ElementRef } from 'react'; 12 | 13 | import { find, hyphenate } from './helpers'; 14 | import type { 15 | Styles, 16 | ClientRect, 17 | GetPosition, 18 | NodeData, 19 | VerticalAlignment, 20 | ConvertedProps, 21 | } from './typings'; 22 | 23 | export function applyStylesToDOMNode({ 24 | domNode, 25 | styles, 26 | }: { 27 | domNode: HTMLElement, 28 | styles: Styles, 29 | }) { 30 | // Can't just do an object merge because domNode.styles is no regular object. 31 | // Need to do it this way for the engine to fire its `set` listeners. 32 | Object.keys(styles).forEach(key => { 33 | domNode.style.setProperty(hyphenate(key), styles[key]); 34 | }); 35 | } 36 | 37 | // Modified from Modernizr 38 | export function whichTransitionEvent(): string { 39 | const transitions = { 40 | transition: 'transitionend', 41 | '-o-transition': 'oTransitionEnd', 42 | '-moz-transition': 'transitionend', 43 | '-webkit-transition': 'webkitTransitionEnd', 44 | }; 45 | 46 | // If we're running in a browserless environment (eg. SSR), it doesn't apply. 47 | // Return a placeholder string, for consistent type return. 48 | if (typeof document === 'undefined') return ''; 49 | 50 | const el = document.createElement('fakeelement'); 51 | 52 | const match = find( 53 | t => el.style.getPropertyValue(t) !== undefined, 54 | Object.keys(transitions), 55 | ); 56 | 57 | // If no `transition` is found, we must be running in a browser so ancient, 58 | // React itself won't run. Return an empty string, for consistent type return 59 | return match ? transitions[match] : ''; 60 | } 61 | 62 | export const getRelativeBoundingBox = ({ 63 | childDomNode, 64 | parentDomNode, 65 | getPosition, 66 | }: { 67 | childDomNode: HTMLElement, 68 | parentDomNode: HTMLElement, 69 | getPosition: GetPosition, 70 | }): ClientRect => { 71 | const parentBox = getPosition(parentDomNode); 72 | const { top, left, right, bottom, width, height } = getPosition(childDomNode); 73 | 74 | return { 75 | top: top - parentBox.top, 76 | left: left - parentBox.left, 77 | right: parentBox.right - right, 78 | bottom: parentBox.bottom - bottom, 79 | width, 80 | height, 81 | }; 82 | }; 83 | 84 | /** getPositionDelta 85 | * This method returns the delta between two bounding boxes, to figure out 86 | * how many pixels on each axis the element has moved. 87 | * 88 | */ 89 | export const getPositionDelta = ({ 90 | childDomNode, 91 | childBoundingBox, 92 | parentBoundingBox, 93 | getPosition, 94 | }: { 95 | childDomNode: HTMLElement, 96 | childBoundingBox: ?ClientRect, 97 | parentBoundingBox: ?ClientRect, 98 | getPosition: GetPosition, 99 | }): [number, number] => { 100 | // TEMP: A mystery bug is sometimes causing unnecessary boundingBoxes to 101 | // remain. Until this bug can be solved, this band-aid fix does the job: 102 | const defaultBox: ClientRect = { 103 | top: 0, 104 | left: 0, 105 | right: 0, 106 | bottom: 0, 107 | height: 0, 108 | width: 0, 109 | }; 110 | 111 | // Our old box is its last calculated position, derived on mount or at the 112 | // start of the previous animation. 113 | const oldRelativeBox = childBoundingBox || defaultBox; 114 | const parentBox = parentBoundingBox || defaultBox; 115 | 116 | // Our new box is the new final resting place: Where we expect it to wind up 117 | // after the animation. First we get the box in absolute terms (AKA relative 118 | // to the viewport), and then we calculate its relative box (relative to the 119 | // parent container) 120 | const newAbsoluteBox = getPosition(childDomNode); 121 | const newRelativeBox = { 122 | top: newAbsoluteBox.top - parentBox.top, 123 | left: newAbsoluteBox.left - parentBox.left, 124 | }; 125 | 126 | return [ 127 | oldRelativeBox.left - newRelativeBox.left, 128 | oldRelativeBox.top - newRelativeBox.top, 129 | ]; 130 | }; 131 | 132 | /** removeNodeFromDOMFlow 133 | * This method does something very sneaky: it removes a DOM node from the 134 | * document flow, but without actually changing its on-screen position. 135 | * 136 | * It works by calculating where the node is, and then applying styles 137 | * so that it winds up being positioned absolutely, but in exactly the 138 | * same place. 139 | * 140 | * This is a vital part of the FLIP technique. 141 | */ 142 | export const removeNodeFromDOMFlow = ( 143 | childData: NodeData, 144 | verticalAlignment: VerticalAlignment, 145 | ) => { 146 | const { domNode, boundingBox } = childData; 147 | 148 | if (!domNode || !boundingBox) { 149 | return; 150 | } 151 | 152 | // For this to work, we have to offset any given `margin`. 153 | const computed: CSSStyleDeclaration = window.getComputedStyle(domNode); 154 | 155 | // We need to clean up margins, by converting and removing suffix: 156 | // eg. '21px' -> 21 157 | const marginAttrs = ['margin-top', 'margin-left', 'margin-right']; 158 | const margins: { 159 | [string]: number, 160 | } = marginAttrs.reduce((acc, margin) => { 161 | const propertyVal = computed.getPropertyValue(margin); 162 | 163 | return { 164 | ...acc, 165 | [margin]: Number(propertyVal.replace('px', '')), 166 | }; 167 | }, {}); 168 | 169 | // If we're bottom-aligned, we need to add the height of the child to its 170 | // top offset. This is because, when the container is bottom-aligned, its 171 | // height shrinks from the top, not the bottom. We're removing this node 172 | // from the flow, so the top is going to drop by its height. 173 | const topOffset = 174 | verticalAlignment === 'bottom' 175 | ? boundingBox.top - boundingBox.height 176 | : boundingBox.top; 177 | 178 | const styles: Styles = { 179 | position: 'absolute', 180 | top: `${topOffset - margins['margin-top']}px`, 181 | left: `${boundingBox.left - margins['margin-left']}px`, 182 | right: `${boundingBox.right - margins['margin-right']}px`, 183 | }; 184 | 185 | applyStylesToDOMNode({ domNode, styles }); 186 | }; 187 | 188 | /** updateHeightPlaceholder 189 | * An optional property to FlipMove is a `maintainContainerHeight` boolean. 190 | * This property creates a node that fills space, so that the parent 191 | * container doesn't collapse when its children are removed from the 192 | * document flow. 193 | */ 194 | export const updateHeightPlaceholder = ({ 195 | domNode, 196 | parentData, 197 | getPosition, 198 | }: { 199 | domNode: HTMLElement, 200 | parentData: NodeData, 201 | getPosition: GetPosition, 202 | }) => { 203 | const parentDomNode = parentData.domNode; 204 | const parentBoundingBox = parentData.boundingBox; 205 | 206 | if (!parentDomNode || !parentBoundingBox) { 207 | return; 208 | } 209 | 210 | // We need to find the height of the container *without* the placeholder. 211 | // Since it's possible that the placeholder might already be present, 212 | // we first set its height to 0. 213 | // This allows the container to collapse down to the size of just its 214 | // content (plus container padding or borders if any). 215 | applyStylesToDOMNode({ domNode, styles: { height: '0' } }); 216 | 217 | // Find the distance by which the container would be collapsed by elements 218 | // leaving. We compare the freshly-available parent height with the original, 219 | // cached container height. 220 | const originalParentHeight = parentBoundingBox.height; 221 | const collapsedParentHeight = getPosition(parentDomNode).height; 222 | const reductionInHeight = originalParentHeight - collapsedParentHeight; 223 | 224 | // If the container has become shorter, update the padding element's 225 | // height to take up the difference. Otherwise set its height to zero, 226 | // so that it has no effect. 227 | const styles: Styles = { 228 | height: reductionInHeight > 0 ? `${reductionInHeight}px` : '0', 229 | }; 230 | 231 | applyStylesToDOMNode({ domNode, styles }); 232 | }; 233 | 234 | export const getNativeNode = (element: ElementRef<*>): ?HTMLElement => { 235 | // When running in a windowless environment, abort! 236 | if (typeof HTMLElement === 'undefined') { 237 | return null; 238 | } 239 | 240 | // `element` may already be a native node. 241 | if (element instanceof HTMLElement) { 242 | return element; 243 | } 244 | 245 | // While ReactDOM's `findDOMNode` is discouraged, it's the only 246 | // publicly-exposed way to find the underlying DOM node for 247 | // composite components. 248 | const foundNode: ?(Element | Text) = findDOMNode(element); 249 | 250 | if (foundNode && foundNode.nodeType === Node.TEXT_NODE) { 251 | // Text nodes are not supported 252 | return null; 253 | } 254 | // eslint-disable-next-line flowtype/no-weak-types 255 | return ((foundNode: any): ?HTMLElement); 256 | }; 257 | 258 | export const createTransitionString = ( 259 | index: number, 260 | props: ConvertedProps, 261 | ): string => { 262 | let { delay, duration } = props; 263 | const { staggerDurationBy, staggerDelayBy, easing } = props; 264 | 265 | delay += index * staggerDelayBy; 266 | duration += index * staggerDurationBy; 267 | 268 | const cssProperties = ['transform', 'opacity']; 269 | 270 | return cssProperties 271 | .map(prop => `${prop} ${duration}ms ${easing} ${delay}ms`) 272 | .join(', '); 273 | }; 274 | -------------------------------------------------------------------------------- /src/enter-leave-presets.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | /** 3 | * React Flip Move | enterLeavePresets 4 | * (c) 2016-present Joshua Comeau 5 | * 6 | * This contains the master list of presets available for enter/leave animations, 7 | * along with the mapping between preset and styles. 8 | */ 9 | import type { Presets } from './typings'; 10 | 11 | export const enterPresets: Presets = { 12 | elevator: { 13 | from: { transform: 'scale(0)', opacity: '0' }, 14 | to: { transform: '', opacity: '' }, 15 | }, 16 | fade: { 17 | from: { opacity: '0' }, 18 | to: { opacity: '' }, 19 | }, 20 | accordionVertical: { 21 | from: { transform: 'scaleY(0)', transformOrigin: 'center top' }, 22 | to: { transform: '', transformOrigin: 'center top' }, 23 | }, 24 | accordionHorizontal: { 25 | from: { transform: 'scaleX(0)', transformOrigin: 'left center' }, 26 | to: { transform: '', transformOrigin: 'left center' }, 27 | }, 28 | none: null, 29 | }; 30 | 31 | export const leavePresets: Presets = { 32 | elevator: { 33 | from: { transform: 'scale(1)', opacity: '1' }, 34 | to: { transform: 'scale(0)', opacity: '0' }, 35 | }, 36 | fade: { 37 | from: { opacity: '1' }, 38 | to: { opacity: '0' }, 39 | }, 40 | accordionVertical: { 41 | from: { transform: 'scaleY(1)', transformOrigin: 'center top' }, 42 | to: { transform: 'scaleY(0)', transformOrigin: 'center top' }, 43 | }, 44 | accordionHorizontal: { 45 | from: { transform: 'scaleX(1)', transformOrigin: 'left center' }, 46 | to: { transform: 'scaleX(0)', transformOrigin: 'left center' }, 47 | }, 48 | none: null, 49 | }; 50 | 51 | // For now, appearPresets will be identical to enterPresets. 52 | // Assigning a custom export in case we ever want to add appear-specific ones. 53 | export const appearPresets = enterPresets; 54 | 55 | export const defaultPreset = 'elevator'; 56 | export const disablePreset = 'none'; 57 | -------------------------------------------------------------------------------- /src/error-messages.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import type { Presets } from './typings'; 3 | 4 | function warnOnce(msg: string) { 5 | let hasWarned = false; 6 | return () => { 7 | if (!hasWarned) { 8 | console.warn(msg); 9 | hasWarned = true; 10 | } 11 | }; 12 | } 13 | 14 | export const statelessFunctionalComponentSupplied = warnOnce(` 15 | >> Error, via react-flip-move << 16 | 17 | You provided a stateless functional component as a child to . Unfortunately, SFCs aren't supported, because Flip Move needs access to the backing instances via refs, and SFCs don't have a public instance that holds that info. 18 | 19 | Please wrap your components in a native element (eg.
      ), or a non-functional component. 20 | `); 21 | 22 | export const primitiveNodeSupplied = warnOnce(` 23 | >> Error, via react-flip-move << 24 | 25 | You provided a primitive (text or number) node as a child to . Flip Move needs containers with unique keys to move children around. 26 | 27 | Please wrap your value in a native element (eg. ), or a component. 28 | `); 29 | 30 | export const invalidTypeForTimingProp = (args: { 31 | prop: string, 32 | value: string | number, 33 | defaultValue: number, 34 | }) => 35 | // prettier-ignore 36 | console.error(` 37 | >> Error, via react-flip-move << 38 | 39 | The prop you provided for '${args.prop}' is invalid. It needs to be a positive integer, or a string that can be resolved to a number. The value you provided is '${args.value}'. 40 | 41 | As a result, the default value for this parameter will be used, which is '${args.defaultValue}'. 42 | `); 43 | 44 | export const invalidEnterLeavePreset = (args: { 45 | value: string, 46 | acceptableValues: string, 47 | defaultValue: $Keys, 48 | }) => 49 | // prettier-ignore 50 | console.error(` 51 | >> Error, via react-flip-move << 52 | 53 | The enter/leave preset you provided is invalid. We don't currently have a '${args.value} preset.' 54 | 55 | Acceptable values are ${args.acceptableValues}. The default value of '${args.defaultValue}' will be used. 56 | `); 57 | 58 | export const parentNodePositionStatic = warnOnce(` 59 | >> Warning, via react-flip-move << 60 | 61 | When using "wrapperless" mode (by supplying 'typeName' of 'null'), strange things happen when the direct parent has the default "static" position. 62 | 63 | FlipMove has added 'position: relative' to this node, to ensure Flip Move animates correctly. 64 | 65 | To avoid seeing this warning, simply apply a non-static position to that parent node. 66 | `); 67 | 68 | export const childIsDisabled = warnOnce(` 69 | >> Warning, via react-flip-move << 70 | 71 | One or more of Flip Move's child elements have the html attribute 'disabled' set to true. 72 | 73 | Please note that this will cause animations to break in Internet Explorer 11 and below. Either remove the disabled attribute or set 'animation' to false. 74 | `); 75 | -------------------------------------------------------------------------------- /src/helpers.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import type { Element } from 'react'; 3 | 4 | export const find = ( 5 | predicate: (T, number, T[]) => boolean, 6 | arr: T[], 7 | ): ?T => { 8 | for (let i = 0; i < arr.length; i++) { 9 | if (predicate(arr[i], i, arr)) { 10 | return arr[i]; 11 | } 12 | } 13 | 14 | return undefined; 15 | }; 16 | 17 | export const every = ( 18 | predicate: (T, number, T[]) => boolean, 19 | arr: T[], 20 | ): boolean => { 21 | for (let i = 0; i < arr.length; i++) { 22 | if (!predicate(arr[i], i, arr)) { 23 | return false; 24 | } 25 | } 26 | return true; 27 | }; 28 | 29 | // eslint-disable-next-line import/no-mutable-exports 30 | export let isArray = (arr: mixed): boolean => { 31 | isArray = 32 | Array.isArray || 33 | (arg => Object.prototype.toString.call(arg) === '[object Array]'); 34 | return isArray(arr); 35 | }; 36 | 37 | export const isElementAnSFC = (element: Element<*>): boolean => { 38 | const isNativeDOMElement = typeof element.type === 'string'; 39 | 40 | if (isNativeDOMElement) { 41 | return false; 42 | } 43 | 44 | return ( 45 | typeof element.type === 'function' && 46 | !element.type.prototype.isReactComponent 47 | ); 48 | }; 49 | 50 | export function omit(obj: T, attrs: Array<$Keys> = []): R { 51 | const result: $Shape = {}; 52 | Object.keys(obj).forEach((key: $Keys) => { 53 | if (attrs.indexOf(key) === -1) { 54 | result[key] = obj[key]; 55 | } 56 | }); 57 | return result; 58 | } 59 | 60 | export function arraysEqual(a: Array, b: Array) { 61 | const sameObject = a === b; 62 | if (sameObject) { 63 | return true; 64 | } 65 | 66 | const notBothArrays = !isArray(a) || !isArray(b); 67 | const differentLengths = a.length !== b.length; 68 | 69 | if (notBothArrays || differentLengths) { 70 | return false; 71 | } 72 | 73 | return every((element, index) => element === b[index], a); 74 | } 75 | 76 | function memoizeString(fn: string => T): string => T { 77 | const cache: { [string]: T } = {}; 78 | 79 | return str => { 80 | if (!cache[str]) { 81 | cache[str] = fn(str); 82 | } 83 | return cache[str]; 84 | }; 85 | } 86 | 87 | export const hyphenate = memoizeString(str => 88 | str.replace(/([A-Z])/g, '-$1').toLowerCase(), 89 | ); 90 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | /** 3 | * React Flip Move 4 | * (c) 2016-present Joshua Comeau 5 | */ 6 | import FlipMove from './FlipMove'; 7 | 8 | export default FlipMove; 9 | -------------------------------------------------------------------------------- /src/prop-converter.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | /** 3 | * React Flip Move | propConverter 4 | * (c) 2016-present Joshua Comeau 5 | * 6 | * Abstracted away a bunch of the messy business with props. 7 | * - props flow types and defaultProps 8 | * - Type conversion (We accept 'string' and 'number' values for duration, 9 | * delay, and other fields, but we actually need them to be ints.) 10 | * - Children conversion (we need the children to be an array. May not always 11 | * be, if a single child is passed in.) 12 | * - Resolving animation presets into their base CSS styles 13 | */ 14 | /* eslint-disable block-scoped-var */ 15 | 16 | import React, { Component, Children } from 'react'; 17 | // eslint-disable-next-line no-duplicate-imports 18 | import type { ComponentType, Element } from 'react'; 19 | 20 | import { 21 | statelessFunctionalComponentSupplied, 22 | primitiveNodeSupplied, 23 | invalidTypeForTimingProp, 24 | invalidEnterLeavePreset, 25 | } from './error-messages'; 26 | import { 27 | appearPresets, 28 | enterPresets, 29 | leavePresets, 30 | defaultPreset, 31 | disablePreset, 32 | } from './enter-leave-presets'; 33 | import { isElementAnSFC, omit } from './helpers'; 34 | import type { 35 | Animation, 36 | AnimationProp, 37 | Presets, 38 | FlipMoveProps, 39 | ConvertedProps, 40 | DelegatedProps, 41 | } from './typings'; 42 | 43 | declare var process: { 44 | env: { 45 | NODE_ENV: 'production' | 'development', 46 | }, 47 | }; 48 | 49 | function propConverter( 50 | ComposedComponent: ComponentType, 51 | ): ComponentType { 52 | return class FlipMovePropConverter extends Component { 53 | static defaultProps = { 54 | easing: 'ease-in-out', 55 | duration: 350, 56 | delay: 0, 57 | staggerDurationBy: 0, 58 | staggerDelayBy: 0, 59 | typeName: 'div', 60 | enterAnimation: defaultPreset, 61 | leaveAnimation: defaultPreset, 62 | disableAllAnimations: false, 63 | getPosition: (node: HTMLElement) => node.getBoundingClientRect(), 64 | maintainContainerHeight: false, 65 | verticalAlignment: 'top', 66 | }; 67 | 68 | // eslint-disable-next-line class-methods-use-this 69 | checkChildren(children) { 70 | // Skip all console warnings in production. 71 | // Bail early, to avoid unnecessary work. 72 | if (process.env.NODE_ENV === 'production') { 73 | return; 74 | } 75 | 76 | // same as React.Node, but without fragments, see https://github.com/facebook/flow/issues/4781 77 | type Child = void | null | boolean | number | string | Element<*>; 78 | 79 | // FlipMove does not support stateless functional components. 80 | // Check to see if any supplied components won't work. 81 | // If the child doesn't have a key, it means we aren't animating it. 82 | // It's allowed to be an SFC, since we ignore it. 83 | Children.forEach(children, (child: Child) => { 84 | // null, undefined, and booleans will be filtered out by Children.toArray 85 | if (child == null || typeof child === 'boolean') { 86 | return; 87 | } 88 | 89 | if (typeof child !== 'object') { 90 | primitiveNodeSupplied(); 91 | return; 92 | } 93 | 94 | if (isElementAnSFC(child) && child.key != null) { 95 | statelessFunctionalComponentSupplied(); 96 | } 97 | }); 98 | } 99 | 100 | convertProps(props: FlipMoveProps): ConvertedProps { 101 | const workingProps: ConvertedProps = { 102 | // explicitly bypass the props that don't need conversion 103 | children: props.children, 104 | easing: props.easing, 105 | onStart: props.onStart, 106 | onFinish: props.onFinish, 107 | onStartAll: props.onStartAll, 108 | onFinishAll: props.onFinishAll, 109 | typeName: props.typeName, 110 | disableAllAnimations: props.disableAllAnimations, 111 | getPosition: props.getPosition, 112 | maintainContainerHeight: props.maintainContainerHeight, 113 | verticalAlignment: props.verticalAlignment, 114 | 115 | // Do string-to-int conversion for all timing-related props 116 | duration: this.convertTimingProp('duration'), 117 | delay: this.convertTimingProp('delay'), 118 | staggerDurationBy: this.convertTimingProp('staggerDurationBy'), 119 | staggerDelayBy: this.convertTimingProp('staggerDelayBy'), 120 | 121 | // Our enter/leave animations can be specified as boolean (default or 122 | // disabled), string (preset name), or object (actual animation values). 123 | // Let's standardize this so that they're always objects 124 | appearAnimation: this.convertAnimationProp( 125 | props.appearAnimation, 126 | appearPresets, 127 | ), 128 | enterAnimation: this.convertAnimationProp( 129 | props.enterAnimation, 130 | enterPresets, 131 | ), 132 | leaveAnimation: this.convertAnimationProp( 133 | props.leaveAnimation, 134 | leavePresets, 135 | ), 136 | 137 | delegated: {}, 138 | }; 139 | 140 | this.checkChildren(workingProps.children); 141 | 142 | // Gather any additional props; 143 | // they will be delegated to the ReactElement created. 144 | const primaryPropKeys = Object.keys(workingProps); 145 | const delegatedProps: DelegatedProps = omit(this.props, primaryPropKeys); 146 | 147 | // The FlipMove container element needs to have a non-static position. 148 | // We use `relative` by default, but it can be overridden by the user. 149 | // Now that we're delegating props, we need to merge this in. 150 | delegatedProps.style = { 151 | position: 'relative', 152 | ...delegatedProps.style, 153 | }; 154 | 155 | workingProps.delegated = delegatedProps; 156 | 157 | return workingProps; 158 | } 159 | 160 | convertTimingProp(prop: string): number { 161 | const rawValue: string | number = this.props[prop]; 162 | 163 | const value = 164 | typeof rawValue === 'number' ? rawValue : parseInt(rawValue, 10); 165 | 166 | if (isNaN(value)) { 167 | const defaultValue: number = FlipMovePropConverter.defaultProps[prop]; 168 | 169 | if (process.env.NODE_ENV !== 'production') { 170 | invalidTypeForTimingProp({ 171 | prop, 172 | value: rawValue, 173 | defaultValue, 174 | }); 175 | } 176 | 177 | return defaultValue; 178 | } 179 | 180 | return value; 181 | } 182 | 183 | // eslint-disable-next-line class-methods-use-this 184 | convertAnimationProp( 185 | animation: ?AnimationProp, 186 | presets: Presets, 187 | ): ?Animation { 188 | switch (typeof animation) { 189 | case 'boolean': { 190 | // If it's true, we want to use the default preset. 191 | // If it's false, we want to use the 'none' preset. 192 | return presets[animation ? defaultPreset : disablePreset]; 193 | } 194 | 195 | case 'string': { 196 | const presetKeys = Object.keys(presets); 197 | 198 | if (presetKeys.indexOf(animation) === -1) { 199 | if (process.env.NODE_ENV !== 'production') { 200 | invalidEnterLeavePreset({ 201 | value: animation, 202 | acceptableValues: presetKeys.join(', '), 203 | defaultValue: defaultPreset, 204 | }); 205 | } 206 | 207 | return presets[defaultPreset]; 208 | } 209 | 210 | return presets[animation]; 211 | } 212 | 213 | default: { 214 | return animation; 215 | } 216 | } 217 | } 218 | 219 | render() { 220 | return ; 221 | } 222 | }; 223 | } 224 | 225 | export default propConverter; 226 | -------------------------------------------------------------------------------- /src/typings.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import type { Element } from 'react'; 3 | import type { 4 | Child, 5 | Styles, 6 | Animation, 7 | Presets, 8 | AnimationProp, 9 | ClientRect, 10 | ElementShape, 11 | ChildrenHook, 12 | GetPosition, 13 | VerticalAlignment, 14 | CommonProps, 15 | DelegatedProps, 16 | FlipMoveProps, 17 | } from 'react-flip-move'; // eslint-disable-line import/extensions 18 | 19 | export type { 20 | Child, 21 | Styles, 22 | Animation, 23 | Presets, 24 | AnimationProp, 25 | ClientRect, 26 | ElementShape, 27 | ChildrenHook, 28 | GetPosition, 29 | VerticalAlignment, 30 | CommonProps, 31 | DelegatedProps, 32 | FlipMoveProps, 33 | }; 34 | 35 | export type ConvertedProps = CommonProps & { 36 | duration: number, 37 | delay: number, 38 | staggerDurationBy: number, 39 | staggerDelayBy: number, 40 | appearAnimation: ?Animation, 41 | enterAnimation: ?Animation, 42 | leaveAnimation: ?Animation, 43 | delegated: DelegatedProps, 44 | }; 45 | 46 | export type ChildData = ElementShape & { 47 | element: Element<*>, 48 | appearing?: boolean, 49 | entering?: boolean, 50 | leaving?: boolean, 51 | }; 52 | 53 | export type FlipMoveState = { 54 | children: Array, 55 | }; 56 | 57 | export type NodeData = { 58 | domNode?: ?HTMLElement, 59 | boundingBox?: ?ClientRect, 60 | }; 61 | -------------------------------------------------------------------------------- /stories/appear-animations.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { storiesOf } from '@storybook/react'; 3 | 4 | import FlipMoveWrapper from './helpers/FlipMoveWrapper'; 5 | import FlipMoveListItem from './helpers/FlipMoveListItem'; 6 | 7 | ['div', FlipMoveListItem].forEach(type => { 8 | const typeLabel = type === 'div' ? 'native' : 'composite'; 9 | 10 | storiesOf(`Appear Animations - ${typeLabel}`, module) 11 | .add('Default (disabled)', () => ) 12 | .add('preset - elevator', () => ( 13 | 17 | )) 18 | .add('preset - fade', () => ( 19 | 23 | )) 24 | .add('preset - accordionVertical', () => ( 25 | 29 | )) 30 | .add('preset - accordionHorizontal', () => ( 31 | 46 | )) 47 | .add('boolean - `true` (default preset)', () => ( 48 | 54 | )) 55 | .add('boolean - `false` (disabled)', () => ( 56 | 62 | )) 63 | .add('fade with stagger', () => ( 64 | 72 | )) 73 | .add('accordionVertical with stagger', () => ( 74 | 82 | )) 83 | .add('invalid preset (default preset)', () => ( 84 | 90 | )); 91 | }); 92 | -------------------------------------------------------------------------------- /stories/enter-leave-animations.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { storiesOf } from '@storybook/react'; 3 | 4 | import FlipMoveWrapper from './helpers/FlipMoveWrapper'; 5 | import FlipMoveListItem from './helpers/FlipMoveListItem'; 6 | 7 | ['div', FlipMoveListItem].forEach(type => { 8 | const typeLabel = type === 'div' ? 'native' : 'composite'; 9 | 10 | storiesOf(`Enter/Leave Animations - ${typeLabel}`, module) 11 | .add('default (elevator preset)', () => ) 12 | .add('default (elevator preset) with constantly change item', () => ( 13 | 14 | )) 15 | .add('preset - fade', () => ( 16 | 23 | )) 24 | .add('preset - accordionVertical', () => ( 25 | 32 | )) 33 | .add('preset - accordionHorizontal', () => ( 34 | 52 | )) 53 | .add('preset - mixed', () => ( 54 | 61 | )) 62 | .add('custom Enter animation', () => ( 63 | 76 | )) 77 | .add('custom Enter and Leave animation, 2D rotate', () => ( 78 | 91 | )) 92 | .add('custom Enter and Leave animation, 3D rotate', () => ( 93 | 108 | )) 109 | .add('boolean - `false` enter (disabled enter)', () => ( 110 | 116 | )) 117 | .add('boolean - `false` enter/leave (disabled enter/leave)', () => ( 118 | 125 | )) 126 | .add('boolean - `true` (default preset)', () => ( 127 | 134 | )) 135 | .add('invalid preset (default preset)', () => ( 136 | 143 | )); 144 | }); 145 | -------------------------------------------------------------------------------- /stories/github-issues.stories.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-plusplus */ 2 | import React, { Component } from 'react'; 3 | import { storiesOf } from '@storybook/react'; 4 | import range from 'lodash/range'; 5 | 6 | import FlipMoveWrapper from './helpers/FlipMoveWrapper'; 7 | import FlipMove from '../src/FlipMove'; 8 | 9 | const sampleItems = [ 10 | { name: 'Potent Potables' }, 11 | { name: 'The Pen is Mightier' }, 12 | { name: 'Famous Horsemen' }, 13 | { name: 'A Petit Déjeuner' }, 14 | ]; 15 | 16 | storiesOf('Github Issues', module) 17 | .add('#31', () => ) 18 | .add('#120', () => { 19 | class Example extends React.Component { 20 | counter = 0; 21 | 22 | constructor(props) { 23 | super(props); 24 | this.state = { 25 | items: [], 26 | }; 27 | } 28 | 29 | onRemoveItem = () => { 30 | const { items } = this.state; 31 | this.setState({ 32 | items: sampleItems.slice(0, items.length - 1), 33 | }); 34 | }; 35 | 36 | onAddItem = () => { 37 | this.setState({ 38 | items: this.state.items.concat([`item${++this.counter}`]), 39 | }); 40 | }; 41 | 42 | handleAddItems = calls => { 43 | const items = []; 44 | for (let i = 0; i < calls; i++) { 45 | items.push(`item${++this.counter}`); 46 | } 47 | this.setState(() => ({ 48 | items, 49 | })); 50 | }; 51 | 52 | onAddItems = () => { 53 | setTimeout(this.handleAddItems(50), 0); 54 | setTimeout(this.handleAddItems(50), 20); 55 | }; 56 | 57 | render() { 58 | const { items } = this.state; 59 | 60 | return ( 61 |
      62 |
      63 | 64 | 65 | 66 |
      67 | 91 | {items.map(item => ( 92 |
    • 93 | {item} 94 |
    • 95 | ))} 96 |
      97 |
      98 | ); 99 | } 100 | } 101 | 102 | return ( 103 |
      104 | 105 | Spam "add many items" button, then inspect first element. it 106 | will be overlayed by a zombie element that wasnt correctle removed 107 | from the DOM 108 | 109 | 110 |
      111 | ); 112 | }) 113 | .add('#141', () => ( 114 | ({ id: `${i}`, text: `Header ${i}` }))} 116 | flipMoveContainerStyles={{ 117 | position: 'relative', 118 | height: '500px', 119 | overflow: 'scroll', 120 | }} 121 | listItemStyles={{ 122 | position: 'sticky', 123 | top: 0, 124 | height: 20, 125 | backgroundColor: 'black', 126 | color: 'white', 127 | }} 128 | /> 129 | )); 130 | 131 | // eslint-disable-next-line react/no-multi-comp 132 | class Controls extends Component { 133 | constructor() { 134 | super(); 135 | this.state = { items: [...sampleItems] }; 136 | } 137 | 138 | buttonClickHandler = () => { 139 | const newItems = this.state.items.slice(); 140 | newItems.splice(1, 1); 141 | 142 | this.setState({ items: newItems }); 143 | }; 144 | 145 | listItemClickHandler = clickedItem => { 146 | this.setState({ 147 | items: this.state.items.filter(item => item !== clickedItem), 148 | }); 149 | }; 150 | 151 | restore = () => { 152 | this.setState({ items: sampleItems }); 153 | }; 154 | 155 | renderItems() { 156 | const answerWrapperStyle = { 157 | backgroundColor: '#FFF', 158 | borderRadius: '20px', 159 | padding: '1em 2em', 160 | marginBottom: '1em', 161 | minWidth: 400, 162 | }; 163 | 164 | const answerStyle = { 165 | fontSize: '16px', 166 | }; 167 | return this.state.items.map(item => ( 168 | // eslint-disable-next-line jsx-a11y/no-static-element-interactions 169 |
      this.listItemClickHandler(item)} 173 | > 174 |
      {item.name}
      175 |
      176 | )); 177 | } 178 | 179 | render() { 180 | return ( 181 |
      189 |
      190 | 191 | 192 |
      193 | 194 | {this.renderItems()} 195 | 196 |
      197 | ); 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /stories/helpers/Controls.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | const styles = { 5 | button: { 6 | padding: '5px 20px', 7 | marginRight: '10px', 8 | fontSize: '14px', 9 | }, 10 | }; 11 | 12 | const Controls = ({ 13 | onRemove, 14 | onRemoveAll, 15 | onRestore, 16 | onRotate, 17 | onShuffle, 18 | onRestartSequence, 19 | numOfCurrentItems, 20 | numOfTotalItems, 21 | numOfStepsInSequence, 22 | }) => ( 23 |
      24 | 31 | 32 | 39 | 40 | 47 | 48 | 55 | 56 | 63 | 64 | 71 |
      72 | ); 73 | 74 | Controls.propTypes = { 75 | onRemove: PropTypes.func.isRequired, 76 | onRemoveAll: PropTypes.func.isRequired, 77 | onRestore: PropTypes.func.isRequired, 78 | onRotate: PropTypes.func.isRequired, 79 | onShuffle: PropTypes.func.isRequired, 80 | onRestartSequence: PropTypes.func.isRequired, 81 | numOfCurrentItems: PropTypes.number.isRequired, 82 | numOfTotalItems: PropTypes.number.isRequired, 83 | numOfStepsInSequence: PropTypes.number.isRequired, 84 | }; 85 | 86 | Controls.defaultProps = { 87 | numOfStepsInSequence: 0, 88 | }; 89 | 90 | export default Controls; 91 | -------------------------------------------------------------------------------- /stories/helpers/FlipMoveListItem.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | // eslint-disable-next-line react/prefer-stateless-function 5 | class FlipMoveListItem extends Component { 6 | render() { 7 | const { style, id, children } = this.props; 8 | 9 | return ( 10 |
      11 | {children} 12 |
      13 | ); 14 | } 15 | } 16 | 17 | FlipMoveListItem.propTypes = { 18 | children: PropTypes.string, 19 | id: PropTypes.string, 20 | // eslint-disable-next-line react/forbid-prop-types 21 | style: PropTypes.object, 22 | }; 23 | 24 | export default FlipMoveListItem; 25 | -------------------------------------------------------------------------------- /stories/helpers/FlipMoveListItemLegacy.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import createClass from 'create-react-class'; 4 | 5 | // eslint-disable-next-line 6 | const FlipMoveListItemLegacy = createClass({ 7 | render() { 8 | // eslint-disable-next-line react/prop-types 9 | const { style, children } = this.props; 10 | 11 | return
      {children}
      ; 12 | }, 13 | }); 14 | 15 | FlipMoveListItemLegacy.propTypes = { 16 | children: PropTypes.string, 17 | // eslint-disable-next-line react/forbid-prop-types 18 | style: PropTypes.object, 19 | }; 20 | 21 | export default FlipMoveListItemLegacy; 22 | -------------------------------------------------------------------------------- /stories/helpers/FlipMoveWrapper.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { sample, shuffle, clone } from 'lodash'; 4 | 5 | import FlipMove from '../../src'; 6 | import Controls from './Controls'; 7 | 8 | const baseStyles = { 9 | bodyContainerStyles: { 10 | background: '#F3F3F3', 11 | padding: '100px', 12 | minHeight: '100%', 13 | }, 14 | flipMoveContainerStyles: { paddingTop: '20px' }, 15 | listItemStyles: { 16 | position: 'relative', 17 | fontFamily: '"Helvetica Neue", "San Francisco", sans-serif', 18 | padding: '10px', 19 | background: '#FFFFFF', 20 | borderBottom: '1px solid #DDD', 21 | }, 22 | }; 23 | class FlipMoveWrapper extends Component { 24 | constructor(props) { 25 | super(props); 26 | 27 | this.state = { 28 | items: this.props.items, 29 | }; 30 | 31 | this.removeItem = this.removeItem.bind(this); 32 | this.removeAllItems = this.removeAllItems.bind(this); 33 | this.restoreItems = this.restoreItems.bind(this); 34 | this.rotateItems = this.rotateItems.bind(this); 35 | this.shuffleItems = this.shuffleItems.bind(this); 36 | this.runSequence = this.runSequence.bind(this); 37 | this.restartSequence = this.restartSequence.bind(this); 38 | } 39 | 40 | componentDidMount() { 41 | if (this.props.sequence) { 42 | this.runSequence(); 43 | } 44 | if (this.props.applyContinuousItemUpdates) { 45 | this.updateCountOnInterval(); 46 | } 47 | } 48 | 49 | componentWillUnmount() { 50 | clearInterval(this.runningInterval); 51 | } 52 | 53 | updateCountOnInterval() { 54 | this.runningInterval = setInterval(() => { 55 | const newItems = clone(this.state.items); 56 | 57 | if (newItems.length === 0) { 58 | return; 59 | } 60 | 61 | newItems[0] = { ...newItems[0], count: newItems[0].count + 1 }; 62 | 63 | this.setState({ items: newItems }); 64 | }, 250); 65 | } 66 | 67 | restartSequence() { 68 | this.restoreItems(); 69 | 70 | window.setTimeout(() => { 71 | this.runSequence(0); 72 | }, this.props.flipMoveProps.duration || 500); 73 | } 74 | 75 | runSequence(index = 0) { 76 | const { eventName, delay, args = [] } = this.props.sequence[index]; 77 | 78 | window.setTimeout(() => { 79 | this[eventName](...args); 80 | 81 | // If it's not the last item in the sequence, queue the next step. 82 | const nextIndex = index + 1; 83 | const nextItem = this.props.sequence[nextIndex]; 84 | 85 | if (nextItem) { 86 | this.runSequence(nextIndex); 87 | } 88 | }, delay); 89 | } 90 | 91 | removeItem(itemId) { 92 | // Randomly remove one, if no specific itemId is provided. 93 | if (typeof itemId === 'undefined') { 94 | // eslint-disable-next-line no-param-reassign 95 | itemId = sample(this.state.items).id; 96 | } 97 | 98 | const itemIndex = this.state.items.findIndex(item => item.id === itemId); 99 | 100 | const newItems = this.state.items.slice(); 101 | newItems.splice(itemIndex, 1); 102 | 103 | this.setState({ items: newItems }); 104 | } 105 | 106 | removeAllItems() { 107 | this.setState({ items: [] }); 108 | } 109 | 110 | restoreItems() { 111 | this.setState({ items: this.props.items }); 112 | } 113 | 114 | rotateItems() { 115 | const newItems = this.state.items.slice(); 116 | newItems.unshift(newItems.pop()); 117 | 118 | this.setState({ items: newItems }); 119 | } 120 | 121 | shuffleItems() { 122 | const newItems = shuffle(this.state.items.slice()); 123 | 124 | this.setState({ items: newItems }); 125 | } 126 | 127 | renderItems() { 128 | const { items } = this.state; 129 | 130 | // Support falsy children by passing them straight to FlipMove 131 | if (!items) { 132 | return items; 133 | } 134 | 135 | return items.map(item => { 136 | // Support falsy children by passing them straight to FlipMove 137 | if (!item) { 138 | return item; 139 | } 140 | 141 | let text = item.text; 142 | 143 | if (item.count) { 144 | text += ` - Count: ${item.count}`; 145 | } 146 | 147 | return React.createElement( 148 | this.props.itemType, 149 | { 150 | key: item.id, 151 | id: item.id, 152 | style: { 153 | ...baseStyles.listItemStyles, 154 | ...this.props.listItemStyles, 155 | // zIndex: item.id.charCodeAt(0), 156 | }, 157 | }, 158 | text, 159 | ); 160 | }); 161 | } 162 | 163 | render() { 164 | return ( 165 |
      171 | 184 | 192 | {this.renderItems()} 193 | 194 |
      195 | ); 196 | } 197 | } 198 | 199 | /* eslint-disable react/forbid-prop-types */ 200 | FlipMoveWrapper.propTypes = { 201 | items: PropTypes.arrayOf( 202 | PropTypes.shape({ 203 | id: PropTypes.string.isRequired, 204 | text: PropTypes.string, 205 | }), 206 | ), 207 | flipMoveProps: PropTypes.object, 208 | itemType: PropTypes.oneOfType([ 209 | PropTypes.string, // for DOM types like 'div' 210 | PropTypes.func, // for composite components 211 | ]), 212 | bodyContainerStyles: PropTypes.object, 213 | flipMoveContainerStyles: PropTypes.object, 214 | listItemStyles: PropTypes.object, 215 | applyContinuousItemUpdates: PropTypes.bool, 216 | sequence: PropTypes.arrayOf( 217 | PropTypes.shape({ 218 | eventName: PropTypes.string, 219 | delay: PropTypes.number, 220 | args: PropTypes.array, 221 | }), 222 | ), 223 | }; 224 | 225 | FlipMoveWrapper.defaultProps = { 226 | items: [ 227 | { 228 | id: 'a', 229 | text: "7 Insecticides You Don't Know You're Consuming", 230 | count: 0, 231 | }, 232 | { id: 'b', text: '11 Ways To Style Your Hair', count: 0 }, 233 | { 234 | id: 'c', 235 | text: 'The 200 Countries You Have To Visit Before The Apocalypse', 236 | count: 0, 237 | }, 238 | { 239 | id: 'd', 240 | text: 'Turtles: The Unexpected Miracle Anti-Aging Product', 241 | count: 0, 242 | }, 243 | { 244 | id: 'e', 245 | text: 'Divine Intervention: Fashion Tips For The Vatican', 246 | count: 0, 247 | }, 248 | ], 249 | itemType: 'div', 250 | }; 251 | 252 | export default FlipMoveWrapper; 253 | -------------------------------------------------------------------------------- /stories/hooks.stories.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import React from 'react'; 3 | import { storiesOf } from '@storybook/react'; 4 | 5 | import FlipMoveWrapper from './helpers/FlipMoveWrapper'; 6 | 7 | let time = Date.now(); 8 | 9 | storiesOf('Hooks', module).add('Storybook actions (see console)', () => ( 10 | 29 | )); 30 | -------------------------------------------------------------------------------- /stories/invalid.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { storiesOf } from '@storybook/react'; 3 | 4 | import FlipMoveWrapper from './helpers/FlipMoveWrapper'; 5 | 6 | storiesOf('Invalid', module).add('Stateless Functional Components', () => { 7 | const MyComponent = () =>
      Hello there!
      ; 8 | 9 | return ; 10 | }); 11 | -------------------------------------------------------------------------------- /stories/legacy.stories.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/forbid-prop-types */ 2 | import React, { Component } from 'react'; 3 | import { storiesOf } from '@storybook/react'; 4 | import shuffle from 'lodash/shuffle'; 5 | import range from 'lodash/range'; 6 | import PropTypes from 'prop-types'; 7 | 8 | import FlipMove from '../src'; 9 | 10 | const getPosition = node => 11 | Object.values(node.getBoundingClientRect()).reduce( 12 | (newRect, prop) => ({ ...newRect, [prop]: prop / 0.5 }), 13 | {}, 14 | ); 15 | 16 | storiesOf('Legacy Stories', module) 17 | .add('simple transition', () => ) 18 | .add('when animation is disabled', () => ) 19 | .add('when removing items - elevator (default)', () => ( 20 | 21 | )) 22 | .add('when removing items - fade', () => ( 23 | 24 | )) 25 | .add('when removing items - accordionVertical', () => ( 26 | 31 | )) 32 | .add('when removing items - accordionHorizontal', () => ( 33 | 38 | )) 39 | .add('when removing items - none', () => ( 40 | 41 | )) 42 | .add( 43 | 'when adding/removing items - custom object with default "leave"', 44 | () => ( 45 | 56 | ), 57 | ) 58 | .add('when adding/removing items - custom object with rotate', () => ( 59 | 70 | )) 71 | .add('when adding/removing items - custom object with 3D rotate', () => ( 72 | 83 | )) 84 | .add('with centered flex content', () => ( 85 | 86 | )) 87 | .add('with transition on child', () => ( 88 | 95 | )) 96 | .add('with onStartAll callback', () => ( 97 | 99 | // eslint-disable-next-line no-console 100 | console.log('Started with', elements, nodes) 101 | } 102 | /> 103 | )) 104 | .add('when prop keys do not change, but items rearrange', () => ( 105 | 106 | )) 107 | .add('delegated prop - width', () => ( 108 | 109 | )) 110 | .add('inside a scaled container', () => ( 111 | 112 | )) 113 | .add('empty', () => { 114 | class HandleEmpty extends Component { 115 | constructor(props) { 116 | super(props); 117 | 118 | this.state = { 119 | empty: true, 120 | }; 121 | } 122 | 123 | render() { 124 | return ( 125 |
      126 | 129 | 130 | 131 | {this.state.empty ? null :
      Not empty!
      } 132 |
      133 |
      134 | ); 135 | } 136 | } 137 | 138 | return ; 139 | }) 140 | .add('maintain container height', () => ( 141 | 146 | )) 147 | .add('maintain container height with
        ', () => ( 148 | 153 | )); 154 | 155 | // Controlling component 156 | const items = [ 157 | { name: 'Potent Potables' }, 158 | { name: 'The Pen is Mightier' }, 159 | { name: 'Famous Horsemen' }, 160 | { name: 'A Petit Déjeuner' }, 161 | ]; 162 | // eslint-disable-next-line react/no-multi-comp 163 | class Controls extends Component { 164 | static defaultProps = { 165 | firstChildOuterStyles: {}, 166 | firstChildInnerStyles: {}, 167 | childInnerStyles: {}, 168 | childOuterStyles: {}, 169 | }; 170 | 171 | constructor() { 172 | super(); 173 | this.state = { items: items.slice() }; 174 | } 175 | 176 | buttonClickHandler = () => { 177 | let newItems; 178 | let newTopItem; 179 | 180 | switch (this.props.mode) { 181 | case 'remove': 182 | newItems = this.state.items.slice(); 183 | newItems.splice(1, 1); 184 | break; 185 | 186 | case 'rotate': 187 | newItems = this.state.items.slice(); 188 | newTopItem = newItems.pop(); 189 | newTopItem.name += Math.random(); 190 | newItems.unshift(newTopItem); 191 | break; 192 | 193 | default: 194 | newItems = shuffle(this.state.items); 195 | break; 196 | } 197 | 198 | this.setState({ items: newItems }); 199 | }; 200 | 201 | listItemClickHandler = clickedItem => { 202 | this.setState({ 203 | items: this.state.items.filter(item => item !== clickedItem), 204 | }); 205 | }; 206 | 207 | restore = () => { 208 | this.setState({ 209 | items, 210 | }); 211 | }; 212 | 213 | renderItems() { 214 | const stylesOuter = { 215 | position: 'relative', 216 | display: 'block', 217 | padding: '6px', 218 | listStyleType: 'none', 219 | }; 220 | 221 | const stylesInner = { 222 | padding: '8px', 223 | background: '#FFFFFF', 224 | color: '#F34D93', 225 | fontFamily: 'sans-serif', 226 | borderRadius: '4px', 227 | }; 228 | 229 | return this.state.items.map(item => { 230 | // Make a working copy of styles 231 | let stylesOuterCopy = { ...stylesOuter, ...this.props.childOuterStyles }; 232 | let stylesInnerCopy = { ...stylesInner, ...this.props.childInnerStyles }; 233 | 234 | if (this.props.styleFirstChild && item.name === 'Potent Potables') { 235 | stylesOuterCopy = { 236 | ...stylesOuterCopy, 237 | ...this.props.firstChildOuterStyles, 238 | }; 239 | 240 | stylesInnerCopy = { 241 | ...stylesInnerCopy, 242 | ...this.props.firstChildInnerStyles, 243 | }; 244 | } 245 | 246 | return ( 247 | // eslint-disable-next-line jsx-a11y/no-static-element-interactions 248 |
      • this.listItemClickHandler(item)} 252 | > 253 |
        254 | 257 |
        258 |
      • 259 | ); 260 | }); 261 | } 262 | 263 | render() { 264 | /* eslint-disable no-unused-vars */ 265 | const { 266 | childInnerStyles, 267 | childOuterStyles, 268 | styleFirstChild, 269 | firstChildInnerStyles, 270 | firstChildOuterStyles, 271 | ...filteredProps 272 | } = this.props; 273 | /* eslint-enable no-unused-vars */ 274 | return ( 275 |
        282 |
        283 | 284 | 285 |
        286 | {this.renderItems()} 287 |
        288 | ); 289 | } 290 | } 291 | Controls.propTypes = { 292 | mode: PropTypes.string, 293 | childOuterStyles: PropTypes.object, 294 | childInnerStyles: PropTypes.object, 295 | styleFirstChild: PropTypes.bool, 296 | firstChildOuterStyles: PropTypes.object, 297 | firstChildInnerStyles: PropTypes.object, 298 | }; 299 | 300 | // eslint-disable-next-line react/no-multi-comp 301 | class StaticItems extends Component { 302 | static renderItems() { 303 | return range(4).map(i => { 304 | const left = `${Math.floor(Math.random() * 100)}px`; 305 | const top = `${Math.floor(Math.random() * 100)}px`; 306 | 307 | return ( 308 |
        318 | Item! 319 |
        320 | ); 321 | }); 322 | } 323 | 324 | render() { 325 | return ( 326 |
        327 | 330 | {StaticItems.renderItems()} 331 |
        332 | ); 333 | } 334 | } 335 | -------------------------------------------------------------------------------- /stories/misc.stories.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { storiesOf } from '@storybook/react'; 3 | 4 | import FlipMove from '../src'; 5 | import FlipMoveWrapper from './helpers/FlipMoveWrapper'; 6 | import FlipMoveListItem from './helpers/FlipMoveListItem'; 7 | 8 | ['div', FlipMoveListItem].forEach(type => { 9 | const typeLabel = type === 'div' ? 'native' : 'composite'; 10 | 11 | storiesOf(`Misc - ${typeLabel}`, module) 12 | .add('Flex - horizontally centered', () => ( 13 | 21 | )) 22 | .add('Flex - vertically centered (BUGGY)', () => ( 23 | 32 | )) 33 | .add('Bottom aligned', () => ( 34 | 44 | )) 45 | .add('Including children without keys', () => ( 46 | 62 | )) 63 | .add('falsy children', () => ( 64 | 80 | )) 81 | .add('Valid children that resolve to null', () => { 82 | /* eslint-disable react/prop-types */ 83 | class CustomComponent extends Component { 84 | render() { 85 | if (!this.props.isVisible) { 86 | return null; 87 | } 88 | 89 | return
        {this.props.children}
        ; 90 | } 91 | } 92 | /* eslint-enable */ 93 | 94 | return ( 95 | 96 | 97 | Hello! 98 | 99 | 100 | Hi! 101 | 102 | 103 | ); 104 | }); 105 | }); 106 | 107 | storiesOf('Misc - wrapperless', module).add('within a static
        ', () => { 108 | // eslint-disable-next-line react/no-multi-comp 109 | class CustomComponent extends Component { 110 | state = { 111 | items: ['hello', 'world', 'how', 'are', 'you'], 112 | }; 113 | 114 | componentDidMount() { 115 | this.intervalId = window.setInterval(() => { 116 | const { items } = this.state; 117 | 118 | if (items.length === 0) { 119 | return; 120 | } 121 | 122 | this.setState({ 123 | items: items.slice(0, items.length - 1), 124 | }); 125 | }, 1000); 126 | } 127 | 128 | componentWillUnmount() { 129 | window.clearInterval(this.intervalId); 130 | } 131 | 132 | render() { 133 | return ( 134 |
        142 | 143 | {this.state.items.map(item => ( 144 |
        {item}
        145 | ))} 146 |
        147 |
        148 | ); 149 | } 150 | } 151 | 152 | return ; 153 | }); 154 | -------------------------------------------------------------------------------- /stories/primary.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { storiesOf } from '@storybook/react'; 3 | 4 | import FlipMoveWrapper from './helpers/FlipMoveWrapper'; 5 | import FlipMoveListItem from './helpers/FlipMoveListItem'; 6 | import FlipMoveListItemLegacy from './helpers/FlipMoveListItemLegacy'; 7 | 8 | storiesOf('Basic Behaviour', module) 9 | .add('native (
        ) children', () => ) 10 | .add('composite () children', () => ( 11 | 12 | )) 13 | .add( 14 | 'Original composite () children (createClass)', 15 | () => , 16 | ) 17 | .add('with long duration', () => ( 18 | 22 | )) 23 | .add('with long delay', () => ( 24 | 28 | )); 29 | 30 | const easings = ['linear', 'ease-in', 'ease-out', 'cubic-bezier(1,0,0,1)']; 31 | easings.forEach(easing => { 32 | storiesOf('Easings', module).add(easing, () => ( 33 | 40 | )); 41 | }); 42 | 43 | storiesOf('Staggers', module) 44 | .add('short duration stagger', () => ( 45 | 49 | )) 50 | .add('medium duration stagger', () => ( 51 | 55 | )) 56 | .add('long duration stagger', () => ( 57 | 61 | )) 62 | .add('short delay stagger', () => ( 63 | 67 | )) 68 | .add('medium delay stagger', () => ( 69 | 73 | )) 74 | .add('long delay stagger', () => ( 75 | 79 | )) 80 | .add('mixed delay and duration stagger', () => ( 81 | 88 | )); 89 | 90 | storiesOf('Disabled animations', module) 91 | .add('with disableAllAnimations prop', () => ( 92 | 98 | )) 99 | .add('with all timing props set to 0', () => ( 100 | 106 | )); 107 | 108 | storiesOf('Type names', module) 109 | .add('ul/li', () => ( 110 | 111 | )) 112 | .add('ol/li', () => ( 113 | 114 | )) 115 | .add('null', () => ( 116 | 117 | )); 118 | -------------------------------------------------------------------------------- /stories/sequencing.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { storiesOf } from '@storybook/react'; 3 | 4 | import FlipMoveWrapper from './helpers/FlipMoveWrapper'; 5 | import FlipMoveListItem from './helpers/FlipMoveListItem'; 6 | 7 | ['div', FlipMoveListItem].forEach(type => { 8 | const typeLabel = type === 'div' ? 'native' : 'composite'; 9 | 10 | storiesOf(`Sequencing - ${typeLabel}`, module) 11 | .add('uninterrupted shuffles', () => ( 12 | 24 | )) 25 | .add('interrupted shuffles', () => ( 26 | 43 | )) 44 | .add('interrupted appear', () => ( 45 | 54 | )) 55 | .add('leave during shuffle', () => ( 56 | 65 | )) 66 | .add('remove items with interrupt, in order', () => ( 67 | 77 | )) 78 | .add('remove items with interrupt, random order', () => ( 79 | 89 | )); 90 | }); 91 | -------------------------------------------------------------------------------- /stories/special-props.stories.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import React from 'react'; 3 | import { storiesOf } from '@storybook/react'; 4 | 5 | import FlipMoveWrapper from './helpers/FlipMoveWrapper'; 6 | 7 | storiesOf('Special Props', module) 8 | .add('maintainContainerHeight (
        )', () => ( 9 | 20 | )) 21 | .add('maintainContainerHeight (
          )', () => ( 22 | 35 | )) 36 | .add('maintainContainerHeight (
            )', () => ( 37 | 50 | )) 51 | .add('custom scaling with custom getPosition function', () => { 52 | function getPosition(node) { 53 | const rect = node.getBoundingClientRect(); 54 | const newRect = {}; 55 | 56 | // eslint-disable-next-line guard-for-in, no-restricted-syntax 57 | for (const prop in rect) { 58 | newRect[prop] = rect[prop] / 0.5; 59 | } 60 | 61 | return newRect; 62 | } 63 | 64 | return ( 65 | 79 | ); 80 | }); 81 | -------------------------------------------------------------------------------- /test/helpers/index.js: -------------------------------------------------------------------------------- 1 | export function getContainerBox(renderedComponent) { 2 | const container = renderedComponent.find('ul').getDOMNode(); 3 | return container.getBoundingClientRect(); 4 | } 5 | 6 | export function getTagPositions(renderedComponent) { 7 | // returns { a: ClientRect, b: ClientRect, c: ClientRect } 8 | return ['a', 'b', 'c'].reduce( 9 | (acc, key) => ({ 10 | ...acc, 11 | [key]: renderedComponent 12 | .find(`li#${key}`) 13 | .getDOMNode() 14 | .getBoundingClientRect(), 15 | }), 16 | {}, 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /test/index.spec.js: -------------------------------------------------------------------------------- 1 | /* global chai, expect, sinon */ 2 | /* eslint-env mocha */ 3 | /* eslint-disable react/prop-types, react/no-multi-comp, no-unused-expressions */ 4 | import React, { Component } from 'react'; 5 | import Enzyme, { shallow, mount } from 'enzyme'; 6 | import Adapter from 'enzyme-adapter-react-16'; 7 | import styled from 'styled-components'; 8 | 9 | import { getContainerBox, getTagPositions } from './helpers'; 10 | import FlipMove from '../src/FlipMove'; 11 | import { 12 | defaultPreset, 13 | disablePreset, 14 | appearPresets, 15 | } from '../src/enter-leave-presets'; 16 | 17 | Enzyme.configure({ adapter: new Adapter() }); 18 | 19 | describe('FlipMove', () => { 20 | let finishAllStub; 21 | 22 | before(() => { 23 | sinon.stub(window, 'requestAnimationFrame', cb => setTimeout(cb, 0)); 24 | finishAllStub = sinon.stub(); 25 | }); 26 | afterEach(() => { 27 | finishAllStub.reset(); 28 | }); 29 | after(() => { 30 | window.requestAnimationFrame.restore(); 31 | }); 32 | 33 | // To test this, here is our setup: 34 | // We're making a simple list of news articles, with the ability to 35 | // change them from sorting ascending vs. descending. 36 | // Doing so will cause the items to be re-rendered in a different 37 | // order, and we want the transition to be animated. 38 | const articles = [ 39 | { id: 'a', name: 'The Dawn of Time', timestamp: 123456 }, 40 | { id: 'b', name: 'A While Back', timestamp: 333333 }, 41 | { id: 'c', name: 'This Just Happened', timestamp: 654321 }, 42 | ]; 43 | 44 | const ListItemString = ({ name }) => name; 45 | 46 | // We need a list item, the thing we'll be moving about. 47 | // eslint-disable-next-line react/prefer-stateless-function 48 | const ListItem = class ListItem extends Component { 49 | render() { 50 | return ( 51 |
          1. 52 | {this.props.useString ? ( 53 | 54 | ) : ( 55 | this.props.name 56 | )} 57 |
          2. 58 | ); 59 | } 60 | }; 61 | 62 | class ListItemsFragment extends Component { 63 | render() { 64 | return articles.map(article => ( 65 | 71 | )); 72 | } 73 | } 74 | 75 | // We need our list parent, which contains our FlipMove as well as 76 | // all the list items. 77 | const ListParent = class ListParent extends Component { 78 | state = { 79 | duration: this.props.duration || 500, 80 | staggerDelayBy: this.props.staggerDelayBy || 0, 81 | staggerDurationBy: this.props.staggerDurationBy || 0, 82 | disableAllAnimations: this.props.disableAllAnimations || false, 83 | maintainContainerHeight: this.props.maintainContainerHeight || false, 84 | articles: this.props.articles || articles, 85 | useFragment: this.props.useFragment || false, 86 | }; 87 | 88 | count = 0; 89 | 90 | onFinishHandler = () => { 91 | this.count += 1; 92 | }; 93 | 94 | onStartHandler = () => { 95 | this.count -= 1; 96 | }; 97 | 98 | renderArticles() { 99 | return this.state.useFragment ? ( 100 | 101 | ) : ( 102 | this.state.articles.map(article => ( 103 | 108 | )) 109 | ); 110 | } 111 | 112 | render() { 113 | return ( 114 |
              115 | 126 | {this.renderArticles()} 127 | 128 |
            129 | ); 130 | } 131 | }; 132 | 133 | let attachedWrapper; 134 | const container = document.createElement('div'); 135 | document.body.appendChild(container); 136 | function mountAttached(props) { 137 | attachedWrapper = mount(, { attachTo: container }); 138 | attachedWrapper.setState({ ...props }); 139 | } 140 | 141 | afterEach(() => { 142 | if (attachedWrapper) { 143 | attachedWrapper.detach(); 144 | attachedWrapper = null; 145 | } 146 | }); 147 | 148 | it('renders the children components', () => { 149 | const wrapper = mount(); 150 | 151 | expect(wrapper.find(ListItem).length).to.equal(3); 152 | expect(wrapper.find('li').length).to.equal(3); 153 | 154 | const outputComponents = wrapper.find(ListItem); 155 | 156 | // Check that they're rendered in order 157 | expect(outputComponents.at(0).prop('id')).to.equal('a'); 158 | expect(outputComponents.at(1).prop('id')).to.equal('b'); 159 | expect(outputComponents.at(2).prop('id')).to.equal('c'); 160 | }); 161 | 162 | it('renders the children components as fragments', () => { 163 | const wrapper = mount(); 164 | 165 | expect(wrapper.find(ListItem).length).to.equal(3); 166 | expect(wrapper.find('li').length).to.equal(3); 167 | 168 | const outputComponents = wrapper.find(ListItem); 169 | 170 | // Check that they're rendered in order 171 | expect(outputComponents.at(0).prop('id')).to.equal('a'); 172 | expect(outputComponents.at(1).prop('id')).to.equal('b'); 173 | expect(outputComponents.at(2).prop('id')).to.equal('c'); 174 | }); 175 | 176 | it('renders the children without wrapper if typeName prop is falsy', () => { 177 | const wrapper = mount(); 178 | 179 | expect(wrapper.find(ListItem).length).to.equal(3); 180 | expect(wrapper.find('li').length).to.equal(3); 181 | 182 | const outputComponents = wrapper.find('FlipMove').children(); 183 | 184 | // Check that they're rendered in order 185 | expect(outputComponents.at(0).prop('id')).to.equal('a'); 186 | expect(outputComponents.at(1).prop('id')).to.equal('b'); 187 | expect(outputComponents.at(2).prop('id')).to.equal('c'); 188 | }); 189 | 190 | describe('updating state', () => { 191 | let originalPositions; 192 | 193 | beforeEach(() => { 194 | mountAttached(); 195 | originalPositions = getTagPositions(attachedWrapper); 196 | attachedWrapper.setState({ 197 | articles: articles.reverse(), 198 | }); 199 | attachedWrapper.update(); 200 | }); 201 | 202 | it('rearranges the components and DOM nodes', () => { 203 | const outputComponents = attachedWrapper.find(ListItem); 204 | const outputTags = attachedWrapper.find('li'); 205 | 206 | expect(outputComponents.at(0).prop('id')).to.equal('c'); 207 | expect(outputComponents.at(1).prop('id')).to.equal('b'); 208 | expect(outputComponents.at(2).prop('id')).to.equal('a'); 209 | 210 | expect(outputTags.at(0).prop('id')).to.equal('c'); 211 | expect(outputTags.at(1).prop('id')).to.equal('b'); 212 | expect(outputTags.at(2).prop('id')).to.equal('a'); 213 | }); 214 | 215 | it("doesn't actually move the elements on-screen synchronously", () => { 216 | // The animation has not started yet. 217 | // While the DOM nodes might have changed places, their on-screen 218 | // positions should be consistent with where they started. 219 | const newPositions = getTagPositions(attachedWrapper); 220 | 221 | // Even though, in terms of the DOM, tag C is at the top, 222 | // its bounding box should still be the lowest 223 | expect(newPositions).to.deep.equal(originalPositions); 224 | }); 225 | 226 | it('stacks all the elements on top of each other after 250ms', done => { 227 | // We know the total duration of the animation is 500ms. 228 | // Three items are being re-arranged; top and bottom changing places. 229 | // Therefore, if we wait 250ms, all 3 items should be stacked. 230 | setTimeout(() => { 231 | const newPositions = getTagPositions(attachedWrapper); 232 | // B should not move at all 233 | expect(newPositions.b).to.deep.equal(originalPositions.b); 234 | 235 | // In an ideal world, these three elements would be near-identical 236 | // in their placement. 237 | // This works very well on localhost, but travis doesn't run so quick. 238 | // I'm just going to assume that as long as it's somewhere between 239 | // initial and final, things are good. 240 | expect(newPositions.a.top).to.be.greaterThan(originalPositions.a.top); 241 | expect(newPositions.c.top).to.be.lessThan(originalPositions.c.top); 242 | 243 | done(); 244 | }, 250); 245 | }); 246 | 247 | it('finishes the animation after 750ms', done => { 248 | // Waiting 750ms. Giving a buffer because 249 | // Travis is slowwww 250 | setTimeout(() => { 251 | const newPositions = getTagPositions(attachedWrapper); 252 | 253 | // B should still be in the same place. 254 | expect(newPositions.b).to.deep.equal(originalPositions.b); 255 | 256 | // A and C should have swapped places. 257 | expect(newPositions.a).to.deep.equal(originalPositions.c); 258 | expect(newPositions.c).to.deep.equal(originalPositions.a); 259 | 260 | done(); 261 | }, 750); 262 | }); 263 | }); 264 | 265 | describe('callbacks', () => { 266 | beforeEach(() => { 267 | mountAttached(); 268 | attachedWrapper.setState({ 269 | articles: articles.reverse(), 270 | }); 271 | }); 272 | 273 | it('should fire the onStart handler immediately', () => { 274 | expect(attachedWrapper.instance().count).to.equal(-2); 275 | }); 276 | 277 | it('should fire onFinish after the animation', done => { 278 | setTimeout(() => { 279 | expect(attachedWrapper.instance().count).to.equal(0); 280 | done(); 281 | }, 750); 282 | }); 283 | 284 | it('should fire the onFinishAll stub only once', done => { 285 | setTimeout(() => { 286 | expect(finishAllStub).to.have.been.calledOnce; 287 | done(); 288 | }, 750); 289 | }); 290 | }); 291 | 292 | describe('prop runtime checking and conversion', () => { 293 | let errorStub; 294 | let warnStub; 295 | let env; 296 | const SFC = () => null; 297 | 298 | before(() => { 299 | errorStub = sinon.stub(console, 'error'); 300 | warnStub = sinon.stub(console, 'warn'); 301 | env = process.env; 302 | }); 303 | afterEach(() => { 304 | errorStub.reset(); 305 | warnStub.reset(); 306 | process.env = env; 307 | }); 308 | after(() => { 309 | errorStub.restore(); 310 | warnStub.restore(); 311 | }); 312 | 313 | it("doesn't run checks in production environment", () => { 314 | process.env = { NODE_ENV: 'production' }; 315 | 316 | shallow( 317 | 318 | 319 | , 320 | ); 321 | shallow(hi); 322 | shallow(); 323 | shallow(); 324 | 325 | expect(errorStub).to.not.have.been.called; 326 | expect(warnStub).to.not.have.been.called; 327 | }); 328 | 329 | describe('timing props', () => { 330 | it('applies a bogus string', () => { 331 | shallow(); 332 | expect(errorStub).to.have.been.calledWith(` 333 | >> Error, via react-flip-move << 334 | 335 | The prop you provided for 'duration' is invalid. It needs to be a positive integer, or a string that can be resolved to a number. The value you provided is 'hi'. 336 | 337 | As a result, the default value for this parameter will be used, which is '350'. 338 | `); 339 | }); 340 | 341 | it('applies an array prop and throws', () => { 342 | shallow(); 343 | expect(errorStub).to.have.been.calledWith(` 344 | >> Error, via react-flip-move << 345 | 346 | The prop you provided for 'duration' is invalid. It needs to be a positive integer, or a string that can be resolved to a number. The value you provided is 'hi'. 347 | 348 | As a result, the default value for this parameter will be used, which is '350'. 349 | `); 350 | }); 351 | 352 | it('applies a string that can be converted to an int', () => { 353 | shallow(); 354 | expect(errorStub).to.not.have.been.called; 355 | }); 356 | }); 357 | 358 | describe('unsupported children', () => { 359 | it("doesn't warn about SFC without key", () => { 360 | shallow( 361 | 362 | 363 | , 364 | ); 365 | expect(warnStub).to.not.have.been.called; 366 | }); 367 | 368 | it('warns once about SFC with key', () => { 369 | shallow( 370 | 371 | 372 | 373 | , 374 | ); 375 | expect(warnStub).to.have.been.calledOnce; 376 | expect(warnStub).to.have.been.calledWith(` 377 | >> Error, via react-flip-move << 378 | 379 | You provided a stateless functional component as a child to . Unfortunately, SFCs aren't supported, because Flip Move needs access to the backing instances via refs, and SFCs don't have a public instance that holds that info. 380 | 381 | Please wrap your components in a native element (eg.
            ), or a non-functional component. 382 | `); 383 | }); 384 | 385 | it('warns once about plain text children', () => { 386 | shallow( 387 | 388 | hi 389 |
            390 | hi 391 | , 392 | ); 393 | expect(warnStub).to.have.been.calledOnce; 394 | expect(warnStub).to.have.been.calledWith(` 395 | >> Error, via react-flip-move << 396 | 397 | You provided a primitive (text or number) node as a child to . Flip Move needs containers with unique keys to move children around. 398 | 399 | Please wrap your value in a native element (eg. ), or a component. 400 | `); 401 | }); 402 | 403 | it("doesn't warn when key is present", () => { 404 | shallow( 405 | 406 |
            407 | , 408 | ); 409 | expect(warnStub).to.not.have.been.called; 410 | }); 411 | 412 | it('warns when child has disabled attribute', () => { 413 | const items = [ 414 |