├── .commitlintrc.json ├── .editorconfig ├── .eslintrc.json ├── .github └── workflows │ ├── deploy-demo.yml │ └── node-ci.yml ├── .gitignore ├── .huskyrc ├── .lintstagedrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── babel.config.js ├── demo ├── .eslintrc.json ├── .gitignore ├── README.md ├── components │ ├── animation-picker │ │ ├── AnimationPicker.js │ │ ├── AnimationPicker.module.css │ │ └── index.js │ ├── index.js │ ├── page-transition │ │ ├── PageTransition.js │ │ ├── PageTransition.module.css │ │ └── index.js │ └── theme-provider │ │ ├── ThemeProvider.js │ │ ├── index.js │ │ └── theme.js ├── next.config.js ├── package-lock.json ├── package.json ├── pages │ ├── _app.js │ ├── _app.module.css │ ├── _document.js │ ├── _global.css │ ├── index.js │ ├── index.module.css │ ├── page2.js │ ├── page2.module.css │ ├── page3.js │ └── page3.module.css ├── postcss.config.js └── public │ └── .nojekyll ├── jest.config.js ├── package-lock.json ├── package.json └── src ├── PageSwapper.js ├── PageSwapper.test.js ├── SwapTransition.js ├── SwapTransition.test.js ├── index.js ├── index.test.js ├── layout.js ├── layout.test.js ├── node-key.js ├── node-key.test.js ├── pop-state.js └── pop-state.test.js /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@commitlint/config-conventional" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 4 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [package.json] 13 | indent_size = 2 14 | 15 | [{*.md,*.snap}] 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "browser": true, 5 | "node": true 6 | }, 7 | "extends": [ 8 | "@moxy/eslint-config-base/esm", 9 | "@moxy/eslint-config-babel", 10 | "@moxy/eslint-config-react", 11 | "@moxy/eslint-config-jest" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /.github/workflows/deploy-demo.yml: -------------------------------------------------------------------------------- 1 | name: Deploy demo 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v1 15 | 16 | - name: Install & build 17 | run: | 18 | npm ci 19 | npm run build 20 | 21 | - name: Install & build demo 22 | run: | 23 | cd demo 24 | npm ci 25 | npx connect-deps link .. -c 26 | npm run build 27 | npm run export 28 | 29 | - name: Deploy 30 | uses: peaceiris/actions-gh-pages@v3 31 | with: 32 | github_token: ${{ secrets.GITHUB_TOKEN }} 33 | publish_dir: ./demo/out 34 | -------------------------------------------------------------------------------- /.github/workflows/node-ci.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: 4 | push: 5 | paths-ignore: 6 | - demo/**/* 7 | 8 | jobs: 9 | 10 | check: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | node-version: ['12', '14'] 15 | name: "[v${{ matrix.node-version }}] check" 16 | 17 | steps: 18 | - name: Checkout code 19 | uses: actions/checkout@v1 20 | 21 | - name: Use Node.js ${{ matrix.node-version }} 22 | uses: actions/setup-node@v1 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | 26 | - name: Install dependencies 27 | run: | 28 | npm ci 29 | 30 | - name: Run lint & tests 31 | env: 32 | CI: 1 33 | run: | 34 | npm run lint 35 | npm t 36 | 37 | - name: Submit coverage 38 | uses: codecov/codecov-action@v1 39 | with: 40 | token: ${{ secrets.CODECOV_TOKEN }} 41 | fail_ci_if_error: true 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log* 3 | coverage 4 | lib/ 5 | es/ 6 | demo/.next 7 | demo/out 8 | -------------------------------------------------------------------------------- /.huskyrc: -------------------------------------------------------------------------------- 1 | { 2 | "hooks": { 3 | "pre-commit": "lint-staged", 4 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.lintstagedrc: -------------------------------------------------------------------------------- 1 | { 2 | "*.js": "eslint" 3 | } 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ## [1.3.0](https://github.com/moxystudio/react-page-swapper/compare/v1.2.0...v1.3.0) (2021-08-03) 6 | 7 | 8 | ### Features 9 | 10 | * add mode ([#22](https://github.com/moxystudio/react-page-swapper/issues/22)) ([0572dcd](https://github.com/moxystudio/react-page-swapper/commit/0572dcd72f63cbf407aab99dff1dcbcbcb5d8c69)) 11 | 12 | ## [1.2.0](https://github.com/moxystudio/react-page-swapper/compare/v1.1.2...v1.2.0) (2021-07-17) 13 | 14 | 15 | ### Features 16 | 17 | * add prevNodeKey to children ([899d8d0](https://github.com/moxystudio/react-page-swapper/commit/899d8d0f19bbe0d3bf15bfbdbc0e894cef09ff52)) 18 | 19 | ### [1.1.2](https://github.com/moxystudio/react-page-swapper/compare/v1.1.1...v1.1.2) (2021-03-23) 20 | 21 | ### [1.1.1](https://github.com/moxystudio/react-page-swapper/compare/v1.1.0...v1.1.1) (2020-08-24) 22 | 23 | 24 | ### Bug Fixes 25 | 26 | * copy over new node if node key is the same ([ae8d0c5](https://github.com/moxystudio/react-page-swapper/commit/ae8d0c5910315b2f994fd0c8b02e2f48774807fd)) 27 | 28 | ## [1.1.0](https://github.com/moxystudio/react-page-swapper/compare/v1.0.0...v1.1.0) (2020-07-29) 29 | 30 | 31 | ### Features 32 | 33 | * update demo to use scroll restoration ([#3](https://github.com/moxystudio/react-page-swapper/issues/3)) ([8c25e43](https://github.com/moxystudio/react-page-swapper/commit/8c25e43f166aa601892f38ba8aba36348568cf5b)) 34 | 35 | 36 | ### Bug Fixes 37 | 38 | * fix components created with forwardRef and memo ([#10](https://github.com/moxystudio/react-page-swapper/issues/10)) ([b8694f8](https://github.com/moxystudio/react-page-swapper/commit/b8694f89dcbb65e1a32ea6d5a8e9018bedecc5c3)) 39 | 40 | ## 1.0.0 (2020-04-17) 41 | 42 | 43 | ### Features 44 | 45 | * initial implementation ([#1](https://github.com/moxystudio/react-page-swapper/issues/1)) ([c787125](https://github.com/moxystudio/react-page-swapper/commit/c787125f332e94d8b2208b79555d73b77efbad20)) 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Made With MOXY Lda 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-page-swapper 2 | 3 | [![NPM version][npm-image]][npm-url] [![Downloads][downloads-image]][npm-url] [![Build Status][build-status-image]][build-status-url] [![Coverage Status][codecov-image]][codecov-url] [![Dependency status][david-dm-image]][david-dm-url] [![Dev Dependency status][david-dm-dev-image]][david-dm-dev-url] 4 | 5 | [npm-url]:https://npmjs.org/package/@moxy/react-page-swapper 6 | [downloads-image]:https://img.shields.io/npm/dm/@moxy/react-page-swapper.svg 7 | [npm-image]:https://img.shields.io/npm/v/@moxy/react-page-swapper.svg 8 | [build-status-url]:https://github.com/moxystudio/react-page-swapper/actions 9 | [build-status-image]:https://img.shields.io/github/workflow/status/moxystudio/react-page-swapper/Node%20CI/master 10 | [codecov-url]:https://codecov.io/gh/moxystudio/react-page-swapper 11 | [codecov-image]:https://img.shields.io/codecov/c/github/moxystudio/react-page-swapper/master.svg 12 | [david-dm-url]:https://david-dm.org/moxystudio/react-page-swapper 13 | [david-dm-image]:https://img.shields.io/david/moxystudio/react-page-swapper.svg 14 | [david-dm-dev-url]:https://david-dm.org/moxystudio/react-page-swapper?type=dev 15 | [david-dm-dev-image]:https://img.shields.io/david/dev/moxystudio/react-page-swapper.svg 16 | 17 | An orchestrator that eases out the implementation of page transitions. 18 | 19 | ## Installation 20 | 21 | ```sh 22 | $ npm install @moxy/react-page-swapper 23 | ``` 24 | 25 | This library is written in modern JavaScript and is published in both CommonJS and ES module transpiled variants. If you target older browsers please make sure to transpile accordingly. 26 | 27 | ## Motivation 28 | 29 | Adding page transitions to your website might look easy at first glance. There are a plethora of articles on the web that suggest using libraries such as `` from [React Transition Group](https://reactcommunity.org/react-transition-group/) or `` from [Framer's Motion](https://www.framer.com/api/motion/), to add page transitions to your website. 30 | 31 | However, they are generic solutions and, as a result, they miss important steps for page transitions. Amongst others, one of the most important steps they miss out is the scroll position. You want your page transitions to work nicely regardless of the scroll being at the top or at the bottom. 32 | 33 | So, what makes a good page transition library? Here's the fundamental steps to take while swapping pages: 34 | 35 | 1. Remove the current page from the normal flow of the document, while keeping it exactly in same position and with the same dimensions. This usually involves making it `position: fixed` and set `top`, `left`, `width` and `height` CSS properties correctly. 36 | 2. Lock the container dimensions by setting `min-width` and `min-height` accordingly. This is needed to maintain the container dimensions since the current page is out of the flow, meaning it will no longer grow its parent. 37 | 3. Render the new page, making it part of the normal flow of the document. 38 | 4. Update the scroll position and unlock the container dimensions that were previously set in step `2.`. Updating the scroll position usually means doing `window.scrollTo(0, 0)` on a new navigation (coming from `history.pushState`) or restoring the scroll position on a `popstate`. 39 | 4. Play the animations, orchestrating the exit and enter transitions of the current and new page respectively. 40 | 5. Unmount the current page from the DOM once both animations finish. The new page has now become the current page. 41 | 42 | `@moxy/react-page-swapper` offers a `` component that performs all the steps mentioned above, effectively orchestrating the swapping of pages. Note, however, that it doesn't actually animate your pages and instead lets you use your favorite animation library, given you respect the established API. 43 | 44 | ## Demo 45 | 46 | You may see a simple demo of `@moxy/react-page-swapper` at [https://moxystudio.github.io/react-page-swapper](https://moxystudio.github.io/react-page-swapper/). 47 | 48 | ## Usage 49 | 50 | Here's a quick example of how you would use it in a [Next.js](https://nextjs.org/) app along with `` from [React Transition Group](https://reactcommunity.org/react-transition-group/): 51 | 52 | ```js 53 | // pages/_app.js 54 | import React from 'react'; 55 | import PageSwapper from '@moxy/react-page-swapper'; 56 | import { CSSTransition } from 'react-transition-group'; 57 | import styles from './_app.module.css'; 58 | 59 | if (typeof history !== 'undefined') { 60 | history.scrollRestoration = 'manual'; 61 | } 62 | 63 | const App = ({ Component, pageProps }) => ( 64 | } 66 | animation="fade"> 67 | { ({ animation, style, in: inProp, onEntered, onExited, node }) => ( 68 | 74 |
{ node }
75 |
76 | ) } 77 |
78 | ); 79 | 80 | export default App; 81 | ``` 82 | 83 | ```css 84 | /* pages/_app.module.css */ 85 | .fade { 86 | transition: opacity 0.6s; 87 | 88 | &.enter { 89 | opacity: 0; 90 | } 91 | 92 | &.enterActive, 93 | &.enterDone { 94 | opacity: 1; 95 | } 96 | } 97 | ``` 98 | 99 | ## Caveats 100 | 101 |
102 | Prevent overflow in the container element 103 | 104 | If you have horizontal / vertical animations, make sure to prevent elements from overflowing the container. Here's an example to disable horizontal overflow: 105 | 106 | ```js 107 | 110 | { () => (/* */) } 111 | 112 | ``` 113 | 114 | Alternatively, you may pass a `className` that has the same CSS declarations. 115 |
116 | 117 |
118 | Focus handling 119 | 120 | The current focused element will be automatically blurred to to prevent animations from glitching. However, it's a good accessibility practice to focus the primary element within the new page. 121 | 122 | To focus elements after a swap is completed, you have two options: 123 | 124 | 1. Use the `onSwapEnd` prop: 125 | 126 | ```js 127 | const handleSwapEnd = useMemo(() => { 128 | document.querySelector('[data-focusable-page-element]')?.focus(); 129 | }, []); 130 | 131 | 134 | { () => (/* */) } 135 | 136 | ``` 137 | 138 | ...and then add the `[data-focusable-page-element]` and `tabIndex="-1"` (if needed) attributes to the element, of each page, that should be immediately focused. 139 | 140 | 2. Use the `transitioning` property of the children render prop: 141 | 142 | ```js 143 | 145 | { ({ style, in: inProp, transitioning, onEntered, onExited }) => ( 146 | 151 |
152 | /* This is the secret sauce */ 153 | { cloneElement(node, { focus: inProp && !transitioning }) } 154 |
155 |
156 | ) } 157 |
158 | ``` 159 | 160 | ...and then handle the `focus` property within your pages' components: 161 | 162 | ```js 163 | const MyPage = ({ focus }) => { 164 | const focusableRef = useRef(); 165 | 166 | useEffect(() => { 167 | if (focus) { 168 | focusableRef.current?.focus(); 169 | } 170 | }, [focus]); 171 | 172 | return ( 173 |
174 |

Page title

175 |

Lorem ipsum dolor sit amet, consectetur adipiscing elit

176 |
177 | ); 178 | }; 179 | ``` 180 |
181 | 182 |
183 | Glitchy animations 184 | 185 | As a rule of thumb, use CSS properties that only require composite, such as `opacity` and `transform`. Properties such as `top` and `left` require layout which are often less performant, thus you should avoid them. You may use [CSS Triggers](https://csstriggers.com/) to check which CSS properties cause layout, paint and composite. 186 | 187 | If you are still experiencing glitchy animations, read the list below for possible solutions: 188 | 189 | 1. Stuttering animations in Firefox 190 | 191 | Try adding `backface-visibility: hidden` to the element. If that doesn't work, try adding `transform-style: preserve-3d` or `transform: translateZ(0)` instead. 192 | 193 | 2. Flicker in iOS Safari 194 | 195 | Sometimes, the current page flickers right before the out animation. This is a known iOS Safari issue when `transform` is used in combination with `position: fixed`. 196 | 197 | First, try promoting the layer to the GPU with the usual `transform: translateZ(0)` "hacks". If these don't work, then changing `transform` to `left` and `top` (or similar) will most likely fix the problem. Since the flicker is caused by the browser delaying the composite calculations, using CSS properties that cause layout will force them to be applied earlier. 198 | 199 | To apply this trade-off only for iOS Safari, you may perform device detection with JavaScript or use the `@supports` like so: 200 | 201 | ```css 202 | @supports not (-webkit-touch-callout: none) { 203 | /* Target all browsers except iOS Safari */ 204 | } 205 | 206 | @supports (-webkit-touch-callout: none) { 207 | /* Target only iOS Safari */ 208 | } 209 | ``` 210 | 211 | > ⚠️ If you are indeed using `top` and `left`, they will conflict with the `style` property from the render prop function. One way to circumvent this is to create a wrapper and apply the `style` property to that element instead. 212 | 213 | > ⚠️ The `@supports` CSS rule is not supported in Internet Explorer. 214 |
215 | 216 | ## API 217 | 218 | ### <PageSwapper> 219 | 220 | `` is the default export and is a component that orchestrates the swapping of pages. 221 | 222 | ℹ️ Besides the props described below, any other props will be spread into the container element, allowing you to specify DOM props such as `className`. 223 | 224 | #### node 225 | 226 | Type: `ReactElement` 227 | 228 | In simple scenarios, this is the page's react element. 229 | 230 | In advanced scenarios, such as nested routes, `node` is a node from a react tree. Usually, the leaf node is the page element and non-leaf nodes are layout elements. 231 | 232 | #### nodeKey 233 | 234 | Type: `string` (*required*) 235 | Default: *random but deterministic* 236 | 237 | A unique key that identifies the `node`. If omitted, a random key node will be generated based on the node's component type. In advanced scenarios, you may specify a key such as one based on the route path or `location.pathname`. You may take a look at [`getNodeKeyFromPathname()`](#getnodekeyfrompathnamelevel-pathname) to see if it's useful for your use-case. 238 | 239 | #### mode 240 | 241 | Type: `string` or `Function` 242 | Default: `simultaneous` 243 | 244 | The mode in which the swap will occur, which can be set to `simultaneous` or `out-in`. 245 | 246 | When mode is `simultaneous`, the current `node` will transition out at the same time as the new `node` will transition in. In contrast, when mode is `out-in`, the current `node` will transition out first and only then the new `node` will be mounted and transition in. It may be a fixed string or a function to determine it, with the following signature: 247 | 248 | ```js 249 | ({ nodeKey, prevNodeKey }) => mode; 250 | ``` 251 | 252 | The function form allows you to select the mode based on the current and previous node keys, making it possible to choose different modes depending on the context. 253 | 254 | #### animation 255 | 256 | Type: `string` or `Function` 257 | 258 | The animation to use when transitioning the current node out and the new one in. It may be a fixed string or a function to determine it, with the following signature: 259 | 260 | ```js 261 | ({ nodeKey, prevNodeKey }) => animation; 262 | ``` 263 | 264 | The function form allows you to select the animation based on the current and previous node keys, making it possible to choose different animations depending on the context. 265 | 266 | #### children 267 | 268 | Type: `Function` (*required*) 269 | 270 | A render prop that is called for exiting and entering nodes, with the correct context. It has the following signature: 271 | 272 | ```js 273 | ({ node, nodeKey, animation, style, transitioning, in, onEntered, onExiting }) => ReactElement; 274 | ``` 275 | 276 | | Property | Type | Description | 277 | | --- | ---- | ----------- | 278 | | `node` | `ReactElement` | The node to render. | 279 | | `nodeKey` | `string` | The key associated to the node. | 280 | | `prevNodeKey` | `string` | The key associated to the previous node, if any. | 281 | | `mode` | `string` | The swap mode, either `simultaneous` or `out-in`. | 282 | | `animation` | `string` | The animation to apply for the transition. | 283 | | `style` | `Object` | An object with CSS styles to be applied to the element being transitioned. | 284 | | `transitioning` | `boolean` | True if the node is transitioning, false otherwise. See note below. | 285 | | `in` | `boolean` | True to show the node, false otherwise. | 286 | | `onEntered` | `Function` | Function to be called when the node finishes transitioning in. | 287 | | `onExited` | `Function` | Function to be called when the node finishes transitioning out. | 288 | 289 | If you are familiar with `` and `` components from [React Transition Group](https://reactcommunity.org/react-transition-group/), the `in`, `onEntered` and `onExited` should be familiar to you. 290 | 291 | The `style` property contains inline styles, namely `position: fixed` with `top`, `left`, `width` and `height` for pages that are exiting. Be sure to apply these to the element being transitioned. 292 | 293 | The `transitioning` property makes it possible to know if the `node` has finished transitioning or not, which is useful to disable behavior while the animation is playing, like ignoring scroll events or [`IntersectionObserver`](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) callbacks. 294 | 295 | #### updateScroll 296 | 297 | Type: `Function` 298 | Default: `({ nodeKey }) => window.scrollTo(0, 0)` 299 | 300 | A function called to update the scroll position during a swap. Usually, you do `window.scrollTo(0, 0)` on a new navigation (coming from `history.pushState`) or restore the scroll position on a `popstate`. 301 | 302 | We recommend using [`scroll-behavior`](https://github.com/taion/scroll-behavior/) to integrate with the Router you are using, and pass `() => scrollBehavior.updateScroll()` as the `updateScroll` property. 303 | 304 | If you are building your application on top of `Next.js` then you may want to integrate this property with [`next-scroll-behavior`](https://github.com/moxystudio/next-scroll-behavior). 305 | 306 | #### onSwapBegin 307 | 308 | Type: `Function` 309 | 310 | A callback called whenever a swap begins, with the following parameters: 311 | 312 | ```js 313 | ({ nodeKey, nextNodeKey }) => {} 314 | ``` 315 | 316 | #### onSwapEnd 317 | 318 | Type: `Function` 319 | 320 | A callback called whenever a swap ends, with the following parameters: 321 | 322 | ```js 323 | ({ nodeKey, prevNodeKey }) => {} 324 | ``` 325 | 326 | ### getNodeKeyFromPathname(level, [pathname]) 327 | 328 | A utility that returns a slice of `location.pathname`. Useful if you want to have fine grained control over `nodeKey`. 329 | 330 | ```js 331 | import { getNodeKeyFromPathname } from '@moxy/react-page-swapper'; 332 | 333 | // Given `location.pathname` equal to `/foo/bar/baz`: 334 | 335 | getNodeKeyFromPathname(0) // /foo 336 | getNodeKeyFromPathname(1) // /foo/bar 337 | getNodeKeyFromPathname(2) // /foo/bar/baz 338 | ``` 339 | 340 | You may specify a custom `pathname`, like a route path: 341 | 342 | ```js 343 | import { getNodeKeyFromPathname } from '@moxy/react-page-swapper'; 344 | 345 | getNodeKeyFromPathname(0, '/blog/[id]') // /blog 346 | getNodeKeyFromPathname(1, '/blog/[id]') // /blog/[id] 347 | ``` 348 | 349 | > ⚠️ Specifying the `pathname` is a must when using certain frameworks. One example is Next.js, where you must use `router.asPath`, otherwise `` will begin swapping too soon, causing a swap to the same `node`. 350 | 351 | ### isHistoryEntryFromPopState() 352 | 353 | A utility to know if the current history entry originated from a `popstate` event or not. Useful to disable animations if the user is using the browser's back and forward functionality. 354 | 355 | ```js 356 | // pages/_app.js 357 | import { isHistoryEntryFromPopState } from '@moxy/react-page-swapper'; 358 | 359 | const animation = isHistoryEntryFromPopState() ? 'none' : 'fade'; 360 | 361 | // and then code the 'none' animation to be a dummy one that finishes instantly 362 | ``` 363 | 364 | ## Tests 365 | 366 | ```sh 367 | $ npm test 368 | $ npm test -- --watch # during development 369 | ``` 370 | 371 | ## License 372 | 373 | Released under the [MIT License](https://www.opensource.org/licenses/mit-license.php). 374 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = (api) => { 4 | api.cache(true); 5 | 6 | return { 7 | ignore: process.env.BABEL_ENV ? ['**/*.test.js', '**/__snapshots__', '**/__mocks__', '**/__fixtures__'] : [], 8 | presets: [ 9 | ['@moxy/babel-preset/lib', { react: true }], 10 | ], 11 | }; 12 | }; 13 | -------------------------------------------------------------------------------- /demo/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "browser": true 5 | }, 6 | "extends": [ 7 | "@moxy/eslint-config-base/esm", 8 | "@moxy/eslint-config-babel", 9 | "@moxy/eslint-config-react", 10 | "@moxy/eslint-config-jest" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /demo/.gitignore: -------------------------------------------------------------------------------- 1 | .connect-deps* 2 | -------------------------------------------------------------------------------- /demo/README.md: -------------------------------------------------------------------------------- 1 | # Demo 2 | 3 | This project uses [Next.js](https://nextjs.org/) and may be accessed at https://moxystudio.github.io/react-page-swapper/. 4 | 5 | Execute the following commands to run the demo locally: 6 | 7 | ```sh 8 | npm i 9 | npm run dev 10 | ``` 11 | 12 | ## Testing changes locally 13 | 14 | You might be running the demo to check if local changes to `@moxy/react-page-swapper` are working correctly. Using `npm link` or `file:..` doesn't work well as it will result in different React packages being used simultaneously. 15 | 16 | The best way to test changes locally is to use [`connect-deps`](https://www.npmjs.com/package/connect-deps), like so: 17 | 18 | ```sh 19 | npx connect-deps link .. -c 20 | npm run dev 21 | ``` 22 | 23 | ℹ️ Be sure to restart the development server so that Next.js picks up the updated `node_modules`. 24 | 25 | Moreover, you might want to watch changes on the `src/` folder to automatically compile with Babel. For this, you may use [`on-change`](https://github.com/sindresorhus/on-change). 26 | 27 | ```sh 28 | npx onchange '../src/**/*' -- npm run build 29 | ``` 30 | -------------------------------------------------------------------------------- /demo/components/animation-picker/AnimationPicker.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef, useEffect } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Link from 'next/link'; 4 | import classNames from 'classnames'; 5 | import { Paper, Button } from '@material-ui/core'; 6 | import styles from './AnimationPicker.module.css'; 7 | 8 | const AnimationPicker = ({ nextHref, followScroll }) => { 9 | const [animate, setAnimate] = useState(false); 10 | const [scrollTop, setScrollTop] = useState(0); 11 | const scrollTimeoutIdRef = useRef(); 12 | 13 | useEffect(() => { 14 | if (!followScroll) { 15 | return; 16 | } 17 | 18 | const handleScroll = () => { 19 | clearTimeout(scrollTimeoutIdRef.current); 20 | scrollTimeoutIdRef.current = setTimeout(() => { 21 | setScrollTop(document.scrollingElement.scrollTop); 22 | }, 200); 23 | }; 24 | 25 | setScrollTop(document.scrollingElement.scrollTop); 26 | window.addEventListener('scroll', handleScroll, { passive: true }); 27 | 28 | return () => { 29 | clearTimeout(scrollTimeoutIdRef.current); 30 | window.removeEventListener('scroll', handleScroll, { passive: true }); 31 | }; 32 | }, [followScroll]); 33 | 34 | useEffect(() => { 35 | const setAnimateTimeoutId = setTimeout(() => setAnimate(true), 50); 36 | 37 | return () => { 38 | clearTimeout(setAnimateTimeoutId); 39 | }; 40 | }, []); 41 | 42 | const style = { transform: `translate(-50%, calc(-50% + ${scrollTop}px))` }; 43 | 44 | return ( 45 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | ); 66 | }; 67 | 68 | AnimationPicker.propTypes = { 69 | nextHref: PropTypes.string.isRequired, 70 | followScroll: PropTypes.bool, 71 | }; 72 | 73 | export default AnimationPicker; 74 | -------------------------------------------------------------------------------- /demo/components/animation-picker/AnimationPicker.module.css: -------------------------------------------------------------------------------- 1 | .animationPicker { 2 | position: absolute; 3 | top: 50vh; 4 | left: 50vw; 5 | transform: translate(-50%, -50%); 6 | display: flex; 7 | flex-direction: column; 8 | padding: 16px; 9 | background-color: color(#000 alpha(0.3)); 10 | 11 | &.animate { 12 | transition: transform 0.4s cubic-bezier(0.25, 1, 0.5, 1); 13 | } 14 | 15 | & .button { 16 | width: 200px; 17 | margin: 10px; 18 | background-color: #fff; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /demo/components/animation-picker/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './AnimationPicker'; 2 | -------------------------------------------------------------------------------- /demo/components/index.js: -------------------------------------------------------------------------------- 1 | export { default as ThemeProvider, theme } from './theme-provider'; 2 | export { default as PageTransition } from './page-transition'; 3 | export { default as AnimationPicker } from './animation-picker'; 4 | -------------------------------------------------------------------------------- /demo/components/page-transition/PageTransition.js: -------------------------------------------------------------------------------- 1 | import React, { cloneElement } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { CSSTransition } from 'react-transition-group'; 4 | import styles from './PageTransition.module.css'; 5 | 6 | const getTimeout = (animation) => { 7 | switch (animation) { 8 | case 'none': 9 | return 1; 10 | default: 11 | return 650; 12 | } 13 | }; 14 | 15 | const getZIndex = (animation, inProp) => { 16 | switch (animation) { 17 | case 'glideLeft': 18 | case 'glideRight': 19 | case 'slideLeft': 20 | case 'slideRight': 21 | case 'fade': 22 | return !inProp ? -1 : undefined; 23 | default: 24 | return undefined; 25 | } 26 | }; 27 | 28 | const PageTransition = ({ node, animation, transitioning, style, in: inProp, onEntered, onExited }) => ( 29 | 42 |
43 | { cloneElement(node, { transitioning }) } 44 |
45 |
46 | ); 47 | 48 | PageTransition.propTypes = { 49 | node: PropTypes.element.isRequired, 50 | animation: PropTypes.oneOf(['none', 'glideLeft', 'glideRight', 'slideLeft', 'slideRight', 'fade']), 51 | transitioning: PropTypes.bool, 52 | style: PropTypes.object, 53 | in: PropTypes.bool, 54 | onEntered: PropTypes.func, 55 | onExited: PropTypes.func, 56 | }; 57 | 58 | PageTransition.defaultProps = { 59 | in: false, 60 | transitioning: false, 61 | animation: 'none', 62 | }; 63 | 64 | export default PageTransition; 65 | -------------------------------------------------------------------------------- /demo/components/page-transition/PageTransition.module.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Below you will find `@supports not (-webkit-touch-callout: none)` and `@supports (-webkit-touch-callout: none)` usages. 3 | * The first rule targets browsers other than iOS browsers, and the second is the opposite. 4 | * We are using it because using `transform` in combination with fixed elements causes flickering in iOS browsers. 5 | * To circumvent that, we use non `transform` styles, such as `left` ONLY for iOS browsers. 6 | */ 7 | 8 | /* ========================================================================== 9 | Fade 10 | ========================================================================== */ 11 | 12 | .fade { 13 | transition: opacity 0.6s; 14 | 15 | &.enter { 16 | opacity: 0; 17 | } 18 | 19 | &.enterActive, 20 | &.enterDone { 21 | opacity: 1; 22 | } 23 | } 24 | 25 | /* ========================================================================== 26 | Slide left 27 | ========================================================================== */ 28 | 29 | @supports not (-webkit-touch-callout: none) { 30 | .slideLeft { 31 | transition: transform 0.6s; 32 | backface-visibility: hidden; 33 | 34 | &.enter { 35 | transform: translateX(100vw); 36 | } 37 | 38 | &.enterActive, 39 | &.enterDone { 40 | transform: translateX(0); 41 | } 42 | } 43 | } 44 | 45 | @supports (-webkit-touch-callout: none) { 46 | .slideLeft { 47 | position: relative; 48 | transition: left 0.6s; 49 | 50 | &.enter { 51 | left: 100vw; 52 | } 53 | 54 | &.enterActive, 55 | &.enterDone { 56 | left: 0; 57 | } 58 | } 59 | } 60 | 61 | /* ========================================================================== 62 | Slide right 63 | ========================================================================== */ 64 | 65 | @supports not (-webkit-touch-callout: none) { 66 | .slideRight { 67 | transition: transform 0.6s; 68 | 69 | &.enter { 70 | transform: translateX(-100vw); 71 | } 72 | 73 | &.enterActive, 74 | &.enterDone { 75 | transform: translateX(0); 76 | } 77 | } 78 | } 79 | 80 | @supports (-webkit-touch-callout: none) { 81 | .slideRight { 82 | position: relative; 83 | transition: left 0.6s; 84 | 85 | &.enter { 86 | left: -100vw; 87 | } 88 | 89 | &.enterActive, 90 | &.enterDone { 91 | left: 0; 92 | } 93 | } 94 | } 95 | 96 | /* ========================================================================== 97 | Glide left 98 | ========================================================================== */ 99 | 100 | @supports not (-webkit-touch-callout: none) { 101 | .glideLeft { 102 | transition: transform 0.6s; 103 | backface-visibility: hidden; /* Necessary because of performance on Firefox */ 104 | 105 | &.enter { 106 | transform: translateX(100vw); 107 | } 108 | 109 | &.enterActive, 110 | &.enterDone, 111 | &.exit { 112 | transform: translateX(0); 113 | } 114 | 115 | &.exitActive, 116 | &.exitDone { 117 | transform: translateX(-25vw); 118 | } 119 | } 120 | } 121 | 122 | @supports (-webkit-touch-callout: none) { 123 | .glideLeft { 124 | position: relative; 125 | transition: left 0.6s; 126 | 127 | &.enter { 128 | left: 100vw; 129 | } 130 | 131 | &.enterActive, 132 | &.enterDone, 133 | &.exit { 134 | left: 0; 135 | } 136 | 137 | &.exitActive, 138 | &.exitDone { 139 | left: -25vw; 140 | } 141 | } 142 | } 143 | 144 | /* ========================================================================== 145 | Glide right 146 | ========================================================================== */ 147 | 148 | @supports not (-webkit-touch-callout: none) { 149 | .glideRight { 150 | transition: transform 0.6s; 151 | backface-visibility: hidden; /* Necessary because of performance on Firefox */ 152 | 153 | &.enter { 154 | transform: translateX(-100vw); 155 | } 156 | 157 | &.enterActive, 158 | &.enterDone, 159 | &.exit { 160 | transform: translateX(0); 161 | } 162 | 163 | &.exitActive, 164 | &.exitDone { 165 | transform: translateX(25vw); 166 | } 167 | } 168 | } 169 | 170 | @supports (-webkit-touch-callout: none) { 171 | .glideRight { 172 | position: relative; 173 | transition: left 0.6s; 174 | 175 | &.enter { 176 | left: -100vw; 177 | } 178 | 179 | &.enterActive, 180 | &.enterDone, 181 | &.exit { 182 | left: 0; 183 | } 184 | 185 | &.exitActive, 186 | &.exitDone { 187 | left: 25vw; 188 | } 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /demo/components/page-transition/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './PageTransition'; 2 | -------------------------------------------------------------------------------- /demo/components/theme-provider/ThemeProvider.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { ThemeProvider as MuiThemeProvider, StylesProvider } from '@material-ui/core/styles'; 4 | import CssBaseline from '@material-ui/core/CssBaseline'; 5 | import theme from './theme'; 6 | 7 | const ThemeProvider = ({ children }) => ( 8 | 9 | 10 | 11 | { children } 12 | 13 | 14 | ); 15 | 16 | ThemeProvider.propTypes = { 17 | children: PropTypes.node, 18 | }; 19 | 20 | export default ThemeProvider; 21 | -------------------------------------------------------------------------------- /demo/components/theme-provider/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './ThemeProvider'; 2 | export { default as theme } from './theme'; 3 | -------------------------------------------------------------------------------- /demo/components/theme-provider/theme.js: -------------------------------------------------------------------------------- 1 | import { createMuiTheme } from '@material-ui/core/styles'; 2 | import { red } from '@material-ui/core/colors'; 3 | 4 | const theme = createMuiTheme({ 5 | palette: { 6 | primary: { 7 | main: '#556cd6', 8 | }, 9 | secondary: { 10 | main: '#19857b', 11 | }, 12 | error: { 13 | main: red.A400, 14 | }, 15 | background: { 16 | default: '#fff', 17 | }, 18 | }, 19 | }); 20 | 21 | export default theme; 22 | -------------------------------------------------------------------------------- /demo/next.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const basePath = process.env.GITHUB_ACTIONS ? '/react-page-swapper' : ''; 4 | 5 | module.exports = { 6 | exportPathMap() { 7 | return { 8 | '/': { page: '/' }, 9 | '/page2': { page: '/page2' }, 10 | '/page3': { page: '/page3' }, 11 | }; 12 | }, 13 | assetPrefix: `${basePath}/`, 14 | experimental: { 15 | basePath, 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demo", 3 | "version": "0.0.0", 4 | "description": "Demo website", 5 | "main": "index.js", 6 | "author": "André Cruz ", 7 | "license": "MIT", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/moxystudio/react-page-swapper.git" 11 | }, 12 | "scripts": { 13 | "dev": "next", 14 | "build": "next build", 15 | "start": "next start", 16 | "export": "next export" 17 | }, 18 | "dependencies": { 19 | "@material-ui/core": "^4.9.8", 20 | "@moxy/next-router-scroll": "^2.0.0", 21 | "@moxy/postcss-preset": "^4.5.1", 22 | "@moxy/react-page-swapper": "latest", 23 | "classnames": "^2.2.6", 24 | "next": "^9.5.2", 25 | "react": "^16.13.1", 26 | "react-dom": "^16.13.1", 27 | "react-transition-group": "^4.3.0" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /demo/pages/_app.js: -------------------------------------------------------------------------------- 1 | import './_global.css'; 2 | import React, { useEffect } from 'react'; 3 | import PropTypes from 'prop-types'; 4 | import { useRouter } from 'next/router'; 5 | import Head from 'next/head'; 6 | import PageSwapper from '@moxy/react-page-swapper'; 7 | import { RouterScrollProvider, useRouterScroll } from '@moxy/next-router-scroll'; 8 | import ThemeProvider from '../components/theme-provider'; 9 | import { PageTransition } from '../components'; 10 | import styles from './_app.module.css'; 11 | 12 | const AppInner = ({ Component, pageProps }) => { 13 | useEffect(() => { 14 | const jssStyles = document.querySelector('#jss-server-side'); 15 | 16 | if (jssStyles) { 17 | jssStyles.parentElement.removeChild(jssStyles); 18 | } 19 | }, []); 20 | 21 | const router = useRouter(); 22 | const { updateScroll } = useRouterScroll(); 23 | 24 | return ( 25 | <> 26 | 27 | @moxy/react-page-swapper demo 28 | 29 | 30 | } 34 | animation={ router.query.animation ?? 'none' }> 35 | { (props) => } 36 | 37 | 38 | ); 39 | }; 40 | 41 | AppInner.propTypes = { 42 | Component: PropTypes.elementType.isRequired, 43 | pageProps: PropTypes.object.isRequired, 44 | }; 45 | 46 | const App = (props) => ( 47 | 48 | 49 | 50 | 51 | 52 | ); 53 | 54 | App.propTypes = { 55 | Component: PropTypes.elementType.isRequired, 56 | pageProps: PropTypes.object.isRequired, 57 | }; 58 | 59 | export default App; 60 | -------------------------------------------------------------------------------- /demo/pages/_app.module.css: -------------------------------------------------------------------------------- 1 | .pageSwapper { 2 | width: 100%; 3 | overflow-x: hidden; 4 | } 5 | -------------------------------------------------------------------------------- /demo/pages/_document.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Document, { Head, Main, NextScript } from 'next/document'; 3 | import { ServerStyleSheets } from '@material-ui/core/styles'; 4 | import { theme } from '../components'; 5 | 6 | export default class MyDocument extends Document { 7 | render() { 8 | return ( 9 | 10 | 11 | { /* PWA primary color */ } 12 | 13 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | ); 23 | } 24 | } 25 | 26 | MyDocument.getInitialProps = async (ctx) => { 27 | // Render app and page and get the context of the page with collected side effects. 28 | const sheets = new ServerStyleSheets(); 29 | const originalRenderPage = ctx.renderPage; 30 | 31 | ctx.renderPage = () => 32 | originalRenderPage({ 33 | enhanceApp: (App) => (props) => sheets.collect(), 34 | }); 35 | 36 | const initialProps = await Document.getInitialProps(ctx); 37 | 38 | return { 39 | ...initialProps, 40 | // Styles fragment is rendered after the app and page rendering finish. 41 | styles: [...React.Children.toArray(initialProps.styles), sheets.getStyleElement()], 42 | }; 43 | }; 44 | -------------------------------------------------------------------------------- /demo/pages/_global.css: -------------------------------------------------------------------------------- 1 | html { 2 | min-width: 320px; 3 | } 4 | 5 | body { 6 | overflow-y: scroll; 7 | margin: 0; 8 | background-color: #244444; 9 | } 10 | -------------------------------------------------------------------------------- /demo/pages/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import classNames from 'classnames'; 4 | import { Typography } from '@material-ui/core'; 5 | import { AnimationPicker } from '../components'; 6 | import styles from './index.module.css'; 7 | 8 | const Home = ({ transitioning }) => ( 9 |
10 | 11 | 12 |
13 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc suscipit tincidunt risus, eget tempor libero. Vestibulum tincidunt tortor ac nulla finibus, nec mollis nisl tempor. Vivamus lacus diam, euismod ut tincidunt commodo, scelerisque vel lorem. Nam sit amet sodales dui. Proin fringilla tellus eu posuere auctor. Cras interdum tempus dolor, ut faucibus neque tristique nec. Sed est odio, iaculis ac elementum vitae, sodales at est. Aliquam sed consectetur ipsum. Nam consequat est non porta finibus. Aliquam arcu nunc, euismod ut consequat vitae, tristique ac mi. Ut accumsan felis id elit commodo fermentum. 14 |
15 |
16 | Morbi a maximus quam. In nec porta tortor, sit amet tincidunt ex. Vivamus vel justo vel massa sollicitudin maximus. Phasellus tempor congue pretium. Sed facilisis, urna sed tristique suscipit, justo justo rutrum tortor, mattis rhoncus dui sem sit amet dui. Aliquam eu luctus nunc. Donec aliquet consectetur orci, sed viverra lorem tincidunt quis. Nullam lobortis imperdiet velit nec dapibus. Nam eu elit et est eleifend venenatis vitae a purus. Cras quis mi interdum, pretium ipsum nec, convallis nunc. Fusce viverra, ligula at sollicitudin imperdiet, nibh purus laoreet sapien, id suscipit mi purus eu mauris. Pellentesque pellentesque urna sapien, id tincidunt odio rutrum ac. Pellentesque facilisis placerat purus, non eleifend sapien ultricies ut. 17 |
18 |
19 | Quisque quis ex justo. Quisque egestas lectus quis enim aliquet eleifend. Curabitur aliquet ornare commodo. Sed tincidunt odio neque. Cras porta tempus nisl ut vehicula. Maecenas vel blandit felis. Pellentesque quis dui accumsan, scelerisque quam vel, scelerisque elit. Sed sodales leo nunc, ac elementum nulla consectetur at. In id dui at lorem sagittis aliquet. Nullam eu lectus ac diam ullamcorper pulvinar. Mauris lacinia ut augue vitae efficitur. Mauris magna mauris, varius at nulla at, pretium molestie sem. Etiam semper dolor ipsum, ac suscipit leo vulputate et. Aliquam enim dolor, pulvinar nec fermentum eget, pulvinar eget erat. 20 |
21 |
22 | Aenean enim lacus, ullamcorper eu commodo in, consectetur in sem. Proin semper urna diam, ut egestas ipsum convallis dictum. Maecenas quis massa vel tellus posuere porttitor luctus vitae risus. Proin turpis mauris, facilisis eu commodo ac, molestie eu odio. Nam at consectetur nibh. Etiam vitae facilisis ante, a fringilla orci. Duis leo tortor, dictum vel tincidunt vel, pharetra sit amet odio. Quisque tempus congue tincidunt. Donec in turpis consectetur, pellentesque odio ut, viverra ante. Pellentesque id metus elementum, maximus nibh a, egestas nisl. Pellentesque ligula mi, viverra id felis eget, dictum feugiat nulla. 23 |
24 |
25 | Donec mollis quam id justo cursus, eget ultrices mauris eleifend. Vivamus euismod eros magna, eu rutrum diam placerat quis. Fusce feugiat, orci quis sodales fringilla, nisl nibh volutpat metus, sed volutpat leo nisl sed est. Fusce eleifend ante in commodo sodales. Nullam suscipit consectetur leo, sit amet hendrerit nulla lobortis a. Phasellus pretium ante eget sem porta, consectetur bibendum turpis congue. Vestibulum enim nisi, convallis ut magna at, feugiat dignissim sem. Duis congue ipsum non risus maximus sagittis. Maecenas neque est, vestibulum ac tincidunt et, dignissim nec mauris. Aenean fringilla massa sit amet sapien viverra, ac facilisis dui elementum. Suspendisse vitae scelerisque mauris. Mauris sagittis eros ut tincidunt feugiat. Ut a diam dolor. 26 |
27 |
28 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc suscipit tincidunt risus, eget tempor libero. Vestibulum tincidunt tortor ac nulla finibus, nec mollis nisl tempor. Vivamus lacus diam, euismod ut tincidunt commodo, scelerisque vel lorem. Nam sit amet sodales dui. Proin fringilla tellus eu posuere auctor. Cras interdum tempus dolor, ut faucibus neque tristique nec. Sed est odio, iaculis ac elementum vitae, sodales at est. Aliquam sed consectetur ipsum. Nam consequat est non porta finibus. Aliquam arcu nunc, euismod ut consequat vitae, tristique ac mi. Ut accumsan felis id elit commodo fermentum. 29 |
30 |
31 | Morbi a maximus quam. In nec porta tortor, sit amet tincidunt ex. Vivamus vel justo vel massa sollicitudin maximus. Phasellus tempor congue pretium. Sed facilisis, urna sed tristique suscipit, justo justo rutrum tortor, mattis rhoncus dui sem sit amet dui. Aliquam eu luctus nunc. Donec aliquet consectetur orci, sed viverra lorem tincidunt quis. Nullam lobortis imperdiet velit nec dapibus. Nam eu elit et est eleifend venenatis vitae a purus. Cras quis mi interdum, pretium ipsum nec, convallis nunc. Fusce viverra, ligula at sollicitudin imperdiet, nibh purus laoreet sapien, id suscipit mi purus eu mauris. Pellentesque pellentesque urna sapien, id tincidunt odio rutrum ac. Pellentesque facilisis placerat purus, non eleifend sapien ultricies ut. 32 |
33 |
34 | Quisque quis ex justo. Quisque egestas lectus quis enim aliquet eleifend. Curabitur aliquet ornare commodo. Sed tincidunt odio neque. Cras porta tempus nisl ut vehicula. Maecenas vel blandit felis. Pellentesque quis dui accumsan, scelerisque quam vel, scelerisque elit. Sed sodales leo nunc, ac elementum nulla consectetur at. In id dui at lorem sagittis aliquet. Nullam eu lectus ac diam ullamcorper pulvinar. Mauris lacinia ut augue vitae efficitur. Mauris magna mauris, varius at nulla at, pretium molestie sem. Etiam semper dolor ipsum, ac suscipit leo vulputate et. Aliquam enim dolor, pulvinar nec fermentum eget, pulvinar eget erat. 35 |
36 |
37 | Aenean enim lacus, ullamcorper eu commodo in, consectetur in sem. Proin semper urna diam, ut egestas ipsum convallis dictum. Maecenas quis massa vel tellus posuere porttitor luctus vitae risus. Proin turpis mauris, facilisis eu commodo ac, molestie eu odio. Nam at consectetur nibh. Etiam vitae facilisis ante, a fringilla orci. Duis leo tortor, dictum vel tincidunt vel, pharetra sit amet odio. Quisque tempus congue tincidunt. Donec in turpis consectetur, pellentesque odio ut, viverra ante. Pellentesque id metus elementum, maximus nibh a, egestas nisl. Pellentesque ligula mi, viverra id felis eget, dictum feugiat nulla. 38 |
39 |
40 | Donec mollis quam id justo cursus, eget ultrices mauris eleifend. Vivamus euismod eros magna, eu rutrum diam placerat quis. Fusce feugiat, orci quis sodales fringilla, nisl nibh volutpat metus, sed volutpat leo nisl sed est. Fusce eleifend ante in commodo sodales. Nullam suscipit consectetur leo, sit amet hendrerit nulla lobortis a. Phasellus pretium ante eget sem porta, consectetur bibendum turpis congue. Vestibulum enim nisi, convallis ut magna at, feugiat dignissim sem. Duis congue ipsum non risus maximus sagittis. Maecenas neque est, vestibulum ac tincidunt et, dignissim nec mauris. Aenean fringilla massa sit amet sapien viverra, ac facilisis dui elementum. Suspendisse vitae scelerisque mauris. Mauris sagittis eros ut tincidunt feugiat. Ut a diam dolor. 41 |
42 |
43 | ); 44 | 45 | Home.propTypes = { 46 | transitioning: PropTypes.bool, 47 | }; 48 | 49 | export default Home; 50 | -------------------------------------------------------------------------------- /demo/pages/index.module.css: -------------------------------------------------------------------------------- 1 | .home { 2 | min-height: 100vh; 3 | 4 | & .section { 5 | padding: 20px; 6 | min-height: 400px; 7 | overflow: hidden; 8 | display: flex; 9 | align-items: center; 10 | justify-content: center; 11 | 12 | &.section1 { 13 | background-color: #f7dba7; 14 | } 15 | 16 | &.section2 { 17 | background-color: #f1ab86; 18 | } 19 | 20 | &.section3 { 21 | background-color: #c57b57; 22 | } 23 | 24 | &.section4 { 25 | background-color: #a63446; 26 | } 27 | 28 | &.section5 { 29 | background-color: #1e2d2f; 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /demo/pages/page2.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import classNames from 'classnames'; 4 | import { Typography } from '@material-ui/core'; 5 | import { AnimationPicker } from '../components'; 6 | import styles from './page2.module.css'; 7 | 8 | const Page2 = ({ transitioning }) => ( 9 |
10 | 11 | 12 |
13 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc suscipit tincidunt risus, eget tempor libero. Vestibulum tincidunt tortor ac nulla finibus, nec mollis nisl tempor. Vivamus lacus diam, euismod ut tincidunt commodo, scelerisque vel lorem. Nam sit amet sodales dui. Proin fringilla tellus eu posuere auctor. Cras interdum tempus dolor, ut faucibus neque tristique nec. Sed est odio, iaculis ac elementum vitae, sodales at est. Aliquam sed consectetur ipsum. Nam consequat est non porta finibus. Aliquam arcu nunc, euismod ut consequat vitae, tristique ac mi. Ut accumsan felis id elit commodo fermentum. 14 |
15 |
16 | Morbi a maximus quam. In nec porta tortor, sit amet tincidunt ex. Vivamus vel justo vel massa sollicitudin maximus. Phasellus tempor congue pretium. Sed facilisis, urna sed tristique suscipit, justo justo rutrum tortor, mattis rhoncus dui sem sit amet dui. Aliquam eu luctus nunc. Donec aliquet consectetur orci, sed viverra lorem tincidunt quis. Nullam lobortis imperdiet velit nec dapibus. Nam eu elit et est eleifend venenatis vitae a purus. Cras quis mi interdum, pretium ipsum nec, convallis nunc. Fusce viverra, ligula at sollicitudin imperdiet, nibh purus laoreet sapien, id suscipit mi purus eu mauris. Pellentesque pellentesque urna sapien, id tincidunt odio rutrum ac. Pellentesque facilisis placerat purus, non eleifend sapien ultricies ut. 17 |
18 |
19 | Quisque quis ex justo. Quisque egestas lectus quis enim aliquet eleifend. Curabitur aliquet ornare commodo. Sed tincidunt odio neque. Cras porta tempus nisl ut vehicula. Maecenas vel blandit felis. Pellentesque quis dui accumsan, scelerisque quam vel, scelerisque elit. Sed sodales leo nunc, ac elementum nulla consectetur at. In id dui at lorem sagittis aliquet. Nullam eu lectus ac diam ullamcorper pulvinar. Mauris lacinia ut augue vitae efficitur. Mauris magna mauris, varius at nulla at, pretium molestie sem. Etiam semper dolor ipsum, ac suscipit leo vulputate et. Aliquam enim dolor, pulvinar nec fermentum eget, pulvinar eget erat. 20 |
21 |
22 | Aenean enim lacus, ullamcorper eu commodo in, consectetur in sem. Proin semper urna diam, ut egestas ipsum convallis dictum. Maecenas quis massa vel tellus posuere porttitor luctus vitae risus. Proin turpis mauris, facilisis eu commodo ac, molestie eu odio. Nam at consectetur nibh. Etiam vitae facilisis ante, a fringilla orci. Duis leo tortor, dictum vel tincidunt vel, pharetra sit amet odio. Quisque tempus congue tincidunt. Donec in turpis consectetur, pellentesque odio ut, viverra ante. Pellentesque id metus elementum, maximus nibh a, egestas nisl. Pellentesque ligula mi, viverra id felis eget, dictum feugiat nulla. 23 |
24 |
25 | Donec mollis quam id justo cursus, eget ultrices mauris eleifend. Vivamus euismod eros magna, eu rutrum diam placerat quis. Fusce feugiat, orci quis sodales fringilla, nisl nibh volutpat metus, sed volutpat leo nisl sed est. Fusce eleifend ante in commodo sodales. Nullam suscipit consectetur leo, sit amet hendrerit nulla lobortis a. Phasellus pretium ante eget sem porta, consectetur bibendum turpis congue. Vestibulum enim nisi, convallis ut magna at, feugiat dignissim sem. Duis congue ipsum non risus maximus sagittis. Maecenas neque est, vestibulum ac tincidunt et, dignissim nec mauris. Aenean fringilla massa sit amet sapien viverra, ac facilisis dui elementum. Suspendisse vitae scelerisque mauris. Mauris sagittis eros ut tincidunt feugiat. Ut a diam dolor. 26 |
27 |
28 | ); 29 | 30 | Page2.propTypes = { 31 | transitioning: PropTypes.bool, 32 | }; 33 | 34 | export default Page2; 35 | -------------------------------------------------------------------------------- /demo/pages/page2.module.css: -------------------------------------------------------------------------------- 1 | .page2 { 2 | min-height: 100vh; 3 | 4 | & .section { 5 | padding: 20px; 6 | min-height: 400px; 7 | overflow: hidden; 8 | display: flex; 9 | align-items: center; 10 | justify-content: center; 11 | 12 | &.section1 { 13 | background-color: #a5cd39; 14 | } 15 | 16 | &.section2 { 17 | background-color: #72814a; 18 | } 19 | 20 | &.section3 { 21 | background-color: #1ab99b; 22 | } 23 | 24 | &.section4 { 25 | background-color: #677777; 26 | } 27 | 28 | &.section5 { 29 | background-color: #5bb2b9; 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /demo/pages/page3.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Typography } from '@material-ui/core'; 4 | import { AnimationPicker } from '../components'; 5 | import styles from './page3.module.css'; 6 | 7 | const Page3 = ({ transitioning }) => ( 8 |
9 | 10 | 11 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc suscipit tincidunt risus, eget tempor libero. Vestibulum tincidunt tortor ac nulla finibus, nec mollis nisl tempor. Vivamus lacus diam, euismod ut tincidunt commodo, scelerisque vel lorem. Nam sit amet sodales dui. Proin fringilla tellus eu posuere auctor. Cras interdum tempus dolor, ut faucibus neque tristique nec. Sed est odio, iaculis ac elementum vitae, sodales at est. Aliquam sed consectetur ipsum. Nam consequat est non porta finibus. Aliquam arcu nunc, euismod ut consequat vitae, tristique ac mi. Ut accumsan felis id elit commodo fermentum. 12 |
13 | ); 14 | 15 | Page3.propTypes = { 16 | transitioning: PropTypes.bool, 17 | }; 18 | 19 | export default Page3; 20 | -------------------------------------------------------------------------------- /demo/pages/page3.module.css: -------------------------------------------------------------------------------- 1 | .page3 { 2 | min-height: 100vh; 3 | padding: 20px; 4 | background-color: #1ab99b; 5 | display: flex; 6 | flex-direction: column; 7 | align-items: center; 8 | justify-content: center; 9 | } 10 | -------------------------------------------------------------------------------- /demo/postcss.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = require('@moxy/postcss-preset')(); 4 | -------------------------------------------------------------------------------- /demo/public/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moxystudio/react-page-swapper/b92d9fe8ad8986ccaae394eb657a53da54d6d4a2/demo/public/.nojekyll -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { compose, baseConfig } = require('@moxy/jest-config-base'); 4 | const withWeb = require('@moxy/jest-config-web'); 5 | const { withRTL } = require('@moxy/jest-config-testing-library'); 6 | 7 | module.exports = compose( 8 | baseConfig(), 9 | withWeb(), 10 | withRTL(), 11 | ); 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@moxy/react-page-swapper", 3 | "version": "1.3.0", 4 | "description": "An orchestrator that eases out the implementation of page transitions", 5 | "main": "lib/index.js", 6 | "module": "es/index.js", 7 | "files": [ 8 | "lib", 9 | "es", 10 | "!**/*.test.js", 11 | "!**/__snapshots__", 12 | "!**/__mocks__" 13 | ], 14 | "homepage": "https://github.com/moxystudio/react-page-swapper#readme", 15 | "author": "André Cruz ", 16 | "license": "MIT", 17 | "keywords": [ 18 | "react", 19 | "page", 20 | "animation", 21 | "transition", 22 | "orchestrator" 23 | ], 24 | "repository": { 25 | "type": "git", 26 | "url": "git+https://github.com/moxystudio/react-page-swapper.git" 27 | }, 28 | "bugs": { 29 | "url": "https://github.com/moxystudio/react-page-swapper/issues" 30 | }, 31 | "scripts": { 32 | "build:commonjs": "cross-env BABEL_ENV=commonjs babel src -d lib --delete-dir-on-start", 33 | "build:es": "cross-env BABEL_ENV=es babel src -d es --delete-dir-on-start", 34 | "build": "npm run build:commonjs && npm run build:es", 35 | "test": "jest", 36 | "lint": "eslint --ignore-path .gitignore .", 37 | "prerelease": "npm t && npm run lint && npm run build", 38 | "release": "standard-version", 39 | "postrelease": "git push --follow-tags origin HEAD && npm publish" 40 | }, 41 | "peerDependencies": { 42 | "react": ">=16.8.0 <18", 43 | "react-dom": ">=16.8.0 <18" 44 | }, 45 | "dependencies": { 46 | "lodash": "^4.17.21", 47 | "memoize-one": "^5.1.1", 48 | "once": "^1.4.0", 49 | "prop-types": "^15.7.2", 50 | "react-transition-group": "^4.3.0", 51 | "shallowequal": "^1.1.0" 52 | }, 53 | "devDependencies": { 54 | "@babel/cli": "^7.13.10", 55 | "@babel/core": "^7.13.10", 56 | "@commitlint/config-conventional": "^12.0.1", 57 | "@moxy/babel-preset": "^3.3.1", 58 | "@moxy/eslint-config-babel": "^13.0.3", 59 | "@moxy/eslint-config-base": "^13.0.3", 60 | "@moxy/eslint-config-jest": "^13.0.3", 61 | "@moxy/eslint-config-react": "^13.0.3", 62 | "@moxy/jest-config-base": "^5.2.0", 63 | "@moxy/jest-config-testing-library": "^5.2.0", 64 | "@moxy/jest-config-web": "^5.2.0", 65 | "@testing-library/react": "^11.2.5", 66 | "commitlint": "^12.0.1", 67 | "cross-env": "^7.0.3", 68 | "eslint": "^7.22.0", 69 | "husky": "^4.3.8", 70 | "jest": "^26.6.3", 71 | "lint-staged": "^10.5.4", 72 | "react": "^17.0.2", 73 | "react-dom": "^17.0.2", 74 | "standard-version": "^9.1.1" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/PageSwapper.js: -------------------------------------------------------------------------------- 1 | import React, { Component, createRef } from 'react'; 2 | import { findDOMNode } from 'react-dom'; 3 | import { TransitionGroup } from 'react-transition-group'; 4 | import PropTypes from 'prop-types'; 5 | import { omit } from 'lodash'; 6 | import memoizeOne from 'memoize-one'; 7 | import SwapTransition from './SwapTransition'; 8 | import { getRandomNodeKey } from './node-key'; 9 | import { lockContainerSize, buildEnterStyle, buildExitStyle } from './layout'; 10 | 11 | export default class PageSwapper extends Component { 12 | static propTypes = { 13 | node: PropTypes.element.isRequired, 14 | nodeKey: PropTypes.string, 15 | mode: PropTypes.oneOfType([ 16 | PropTypes.oneOf(['simultaneous', 'out-in']), 17 | PropTypes.func, 18 | ]), 19 | animation: PropTypes.oneOfType([ 20 | PropTypes.string, 21 | PropTypes.func, 22 | ]), 23 | children: PropTypes.func.isRequired, 24 | updateScroll: PropTypes.func, 25 | onSwapBegin: PropTypes.func, 26 | onSwapEnd: PropTypes.func, 27 | }; 28 | 29 | static defaultProps = { 30 | mode: 'simultaneous', 31 | updateScroll: () => window.scrollTo(0, 0), 32 | }; 33 | 34 | state = {}; 35 | containerRef = createRef(); 36 | ref = createRef(); 37 | remainingSwapAcks = 0; 38 | raf; 39 | halfSwap; 40 | 41 | constructor(props) { 42 | super(props); 43 | 44 | this.state = this.buildState(); 45 | } 46 | 47 | componentDidUpdate() { 48 | if (!this.isSwapping()) { 49 | if (this.isOutOfSync()) { 50 | this.beginSwap(); 51 | } else { 52 | this.maybeUpdateNode(); 53 | } 54 | } 55 | } 56 | 57 | componentWillUnmount() { 58 | cancelAnimationFrame(this.raf); 59 | } 60 | 61 | render() { 62 | const { children } = this.props; 63 | const { node, nodeKey, prevNodeKey, in: inProp, mode, animation, style } = this.state; 64 | 65 | return ( 66 | 69 | { nodeKey && ( 70 | 82 | { children } 83 | 84 | ) } 85 | 86 | ); 87 | } 88 | 89 | isSwapping() { 90 | return this.remainingSwapAcks > 0; 91 | } 92 | 93 | isOutOfSync() { 94 | const currentNodeKey = this.state.nodeKey; 95 | const nodeKey = this.props.nodeKey || getRandomNodeKey(this.props.node); 96 | 97 | return nodeKey !== currentNodeKey; 98 | } 99 | 100 | beginSwap() { 101 | const state = this.buildState(); 102 | 103 | const { nodeKey, prevNodeKey } = state; 104 | const element = findDOMNode(this.ref.current); 105 | const containerElement = findDOMNode(this.containerRef.current); 106 | 107 | this.props.onSwapBegin?.({ nodeKey: prevNodeKey, nextNodeKey: nodeKey }); 108 | 109 | this.remainingSwapAcks = 2; 110 | cancelAnimationFrame(this.raf); 111 | 112 | // Prepare exiting: 113 | // - Lock the container size 114 | // - Blur activeElement if any 115 | // - Apply the animation and out-of-flow styles 116 | const unlockSize = lockContainerSize(containerElement); 117 | 118 | document.activeElement?.blur(); 119 | 120 | this.setState({ 121 | animation: state.animation, 122 | style: buildExitStyle(element), 123 | }, () => { 124 | // Need to wait an animation frame so that the styles are applied 125 | // This is especially necessary for Safari, to avoid "flickering" 126 | this.raf = requestAnimationFrame(() => { 127 | if (state.mode === 'out-in') { 128 | // Finally start the swap by making the current node exit. 129 | this.setState({ node: null, nodeKey: null }); 130 | 131 | // Declare what to do after it exists, which will be used on the handleExited function 132 | this.halfSwap = () => { 133 | // Make the new node enter. 134 | this.setState(state, () => { 135 | // Now that we have the current node, its dimensions are being counted 136 | // towards the document flow, meaning we can now update the scroll 137 | // and unlock size 138 | this.props.updateScroll({ nodeKey }); 139 | unlockSize(); 140 | }); 141 | }; 142 | } else { 143 | this.halfSwap = undefined; 144 | 145 | // Finally start the swap! 146 | this.setState(state, () => { 147 | // Now that we have the current node, its dimensions are being counted 148 | // towards the document flow, meaning we can now update the scroll 149 | // and unlock size 150 | this.props.updateScroll({ nodeKey }); 151 | unlockSize(); 152 | }); 153 | } 154 | }); 155 | }); 156 | } 157 | 158 | finishSwap() { 159 | const { nodeKey, prevNodeKey } = this.state; 160 | 161 | this.props.onSwapEnd?.({ nodeKey, prevNodeKey }); 162 | 163 | if (this.isOutOfSync()) { 164 | this.beginSwap(); 165 | } else { 166 | this.maybeUpdateNode(); 167 | } 168 | } 169 | 170 | maybeUpdateNode() { 171 | if (this.props.node !== this.state.node) { 172 | this.setState({ node: this.props.node }); 173 | } 174 | } 175 | 176 | buildState() { 177 | const { node, mode, animation } = this.props; 178 | const { nodeKey: currentNodeKey } = this.state; 179 | 180 | const nodeKey = this.props.nodeKey ?? getRandomNodeKey(node); 181 | const modeStr = typeof mode === 'function' ? 182 | mode({ prevNodeKey: currentNodeKey, nodeKey }) : 183 | mode; 184 | const animationStr = typeof animation === 'function' ? 185 | animation({ prevNodeKey: currentNodeKey, nodeKey }) : 186 | animation; 187 | 188 | return { 189 | node, 190 | nodeKey, 191 | prevNodeKey: currentNodeKey, 192 | mode: modeStr, 193 | animation: animationStr, 194 | style: buildEnterStyle(), 195 | }; 196 | } 197 | 198 | // eslint-disable-next-line react/sort-comp 199 | buildContainerProps = memoizeOne((props) => omit(props, [ 200 | 'node', 201 | 'nodeKey', 202 | 'mode', 203 | 'animation', 204 | 'children', 205 | 'updateScroll', 206 | 'onSwapBegin', 207 | 'onSwapEnd', 208 | ])); 209 | 210 | handleRef = (ref) => { 211 | if (ref) { 212 | this.ref.current = ref; 213 | } 214 | }; 215 | 216 | handleEntered = () => { 217 | this.remainingSwapAcks -= 1; 218 | 219 | if (!this.isSwapping()) { 220 | this.finishSwap(); 221 | } 222 | }; 223 | 224 | handleExited = () => { 225 | this.remainingSwapAcks -= 1; 226 | 227 | if (this.halfSwap) { 228 | this.halfSwap(); 229 | } else { 230 | this.finishSwap(); 231 | } 232 | }; 233 | } 234 | -------------------------------------------------------------------------------- /src/PageSwapper.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import PageSwapper from './PageSwapper'; 4 | 5 | const Page1 = () =>

Page 1

; 6 | const Page2 = () =>

Page 2

; 7 | const Page3 = ({ children }) =>

Page 3

{ children }
; 8 | 9 | beforeAll(() => { 10 | let counter = 0; 11 | 12 | // eslint-disable-next-line no-plusplus 13 | jest.spyOn(Math, 'random').mockImplementation(() => (++counter) * 0.00001); 14 | }); 15 | 16 | beforeAll(() => { 17 | const style = document.createElement('style'); 18 | 19 | style.type = 'text/css'; 20 | style.innerHTML = ` 21 | main { 22 | width: 200px; 23 | height: 300px; 24 | } 25 | 26 | .pageSwapper { 27 | width: 200px; 28 | height: 300px; 29 | } 30 | `; 31 | 32 | document.head.appendChild(style); 33 | 34 | jest.spyOn(window, 'scrollTo').mockImplementation(() => {}); 35 | }); 36 | 37 | afterEach(() => { 38 | jest.clearAllMocks(); 39 | }); 40 | 41 | it('should work correctly on mount', () => { 42 | const children = jest.fn(({ node }) => node); 43 | 44 | const { getByText } = render( 45 | } 47 | animation="fade"> 48 | { children } 49 | , 50 | ); 51 | 52 | expect(getByText('Page 1')).toBeInTheDocument(); 53 | expect(children).toHaveBeenCalledTimes(1); 54 | expect(children).toHaveBeenCalledWith({ 55 | node: , 56 | nodeKey: 'cre66i9s', 57 | mode: 'simultaneous', 58 | animation: 'fade', 59 | style: { position: 'relative' }, 60 | in: true, 61 | transitioning: false, 62 | onEntered: expect.any(Function), 63 | onExited: expect.any(Function), 64 | }); 65 | }); 66 | 67 | describe('simultaneous mode', () => { 68 | it('should swap nodes at the same time', async () => { 69 | const callbacks = {}; 70 | 71 | const children = jest.fn(({ in: inProp, node, onEntered, onExited }) => { 72 | if (inProp) { 73 | callbacks.onEntered = onEntered; 74 | } else { 75 | callbacks.onExited = onExited; 76 | } 77 | 78 | return node; 79 | }); 80 | 81 | const { rerender } = render( 82 | } 84 | animation="fade"> 85 | { children } 86 | , 87 | ); 88 | 89 | children.mockClear(); 90 | 91 | rerender( 92 | } 94 | animation="slide"> 95 | { children } 96 | , 97 | ); 98 | 99 | await new Promise((resolve) => setTimeout(resolve, 50)); 100 | 101 | expect(children).toHaveBeenCalledTimes(3); 102 | 103 | callbacks.onExited(); 104 | callbacks.onEntered(); 105 | await new Promise((resolve) => setTimeout(resolve, 50)); 106 | 107 | expect(children).toHaveBeenCalledTimes(4); 108 | 109 | expect(children).toHaveBeenNthCalledWith(1, { 110 | node: , 111 | nodeKey: 'cre66i9s', 112 | mode: 'simultaneous', 113 | animation: 'slide', 114 | style: { 115 | position: 'fixed', 116 | top: 0, 117 | left: 0, 118 | width: 200, 119 | height: 300, 120 | pointerEvents: 'none', 121 | }, 122 | in: true, 123 | transitioning: false, 124 | onEntered: expect.any(Function), 125 | onExited: expect.any(Function), 126 | }); 127 | 128 | expect(children).toHaveBeenNthCalledWith(2, { 129 | node: , 130 | nodeKey: 'piscd0jk', 131 | prevNodeKey: 'cre66i9s', 132 | mode: 'simultaneous', 133 | animation: 'slide', 134 | style: { 135 | position: 'relative', 136 | }, 137 | in: true, 138 | transitioning: true, 139 | onEntered: expect.any(Function), 140 | onExited: expect.any(Function), 141 | }); 142 | 143 | expect(children).toHaveBeenNthCalledWith(3, { 144 | node: , 145 | nodeKey: 'cre66i9s', 146 | mode: 'simultaneous', 147 | animation: 'slide', 148 | style: { 149 | position: 'fixed', 150 | top: 0, 151 | left: 0, 152 | width: 200, 153 | height: 300, 154 | pointerEvents: 'none', 155 | }, 156 | in: false, 157 | transitioning: true, 158 | onEntered: expect.any(Function), 159 | onExited: expect.any(Function), 160 | }); 161 | 162 | expect(children).toHaveBeenNthCalledWith(4, { 163 | node: , 164 | nodeKey: 'piscd0jk', 165 | prevNodeKey: 'cre66i9s', 166 | mode: 'simultaneous', 167 | animation: 'slide', 168 | style: { 169 | position: 'relative', 170 | }, 171 | in: true, 172 | transitioning: false, 173 | onEntered: expect.any(Function), 174 | onExited: expect.any(Function), 175 | }); 176 | }); 177 | 178 | it('should lock / unlock container size when swapping', async () => { 179 | const children = ({ node }) => node; 180 | 181 | const { rerender, container } = render( 182 | }> 185 | { children } 186 | , 187 | ); 188 | 189 | const element = container.childNodes[0]; 190 | 191 | expect(element.style.minWidth).toBe(''); 192 | expect(element.style.minHeight).toBe(''); 193 | 194 | rerender( 195 | }> 198 | { children } 199 | , 200 | ); 201 | 202 | expect(element.style.minWidth).toBe('200px'); 203 | expect(element.style.minHeight).toBe('300px'); 204 | 205 | await new Promise((resolve) => setTimeout(resolve, 50)); 206 | 207 | expect(element.style.minWidth).toBe(''); 208 | expect(element.style.minHeight).toBe(''); 209 | }); 210 | 211 | it('should update scroll position', async () => { 212 | const updateScroll = jest.fn(); 213 | const children = ({ node }) => node; 214 | 215 | const { rerender } = render( 216 | } 218 | updateScroll={ updateScroll }> 219 | { children } 220 | , 221 | ); 222 | 223 | expect(updateScroll).toHaveBeenCalledTimes(0); 224 | 225 | rerender( 226 | } 228 | updateScroll={ updateScroll }> 229 | { children } 230 | , 231 | ); 232 | 233 | await new Promise((resolve) => setTimeout(resolve, 50)); 234 | 235 | expect(updateScroll).toHaveBeenCalledTimes(1); 236 | expect(updateScroll).toHaveBeenCalledWith({ nodeKey: 'piscd0jk' }); 237 | }); 238 | }); 239 | 240 | describe('out-in mode', () => { 241 | it('should first swap out and then in', async () => { 242 | const callbacks = {}; 243 | 244 | const children = jest.fn(({ in: inProp, node, onEntered, onExited }) => { 245 | if (inProp) { 246 | callbacks.onEntered = onEntered; 247 | } else { 248 | callbacks.onExited = onExited; 249 | } 250 | 251 | return node; 252 | }); 253 | 254 | const { rerender } = render( 255 | } 257 | mode="out-in" 258 | animation="fade"> 259 | { children } 260 | , 261 | ); 262 | 263 | children.mockClear(); 264 | 265 | rerender( 266 | } 268 | mode="out-in" 269 | animation="slide"> 270 | { children } 271 | , 272 | ); 273 | 274 | await new Promise((resolve) => setTimeout(resolve, 50)); 275 | 276 | expect(children).toHaveBeenCalledTimes(2); 277 | 278 | callbacks.onExited(); 279 | await new Promise((resolve) => setTimeout(resolve, 50)); 280 | callbacks.onEntered(); 281 | await new Promise((resolve) => setTimeout(resolve, 50)); 282 | 283 | expect(children).toHaveBeenCalledTimes(4); 284 | 285 | expect(children).toHaveBeenNthCalledWith(1, { 286 | node: , 287 | nodeKey: 'cre66i9s', 288 | mode: 'out-in', 289 | animation: 'slide', 290 | style: { 291 | position: 'fixed', 292 | top: 0, 293 | left: 0, 294 | width: 200, 295 | height: 300, 296 | pointerEvents: 'none', 297 | }, 298 | in: true, 299 | transitioning: false, 300 | onEntered: expect.any(Function), 301 | onExited: expect.any(Function), 302 | }); 303 | 304 | expect(children).toHaveBeenNthCalledWith(2, { 305 | node: , 306 | nodeKey: 'cre66i9s', 307 | mode: 'out-in', 308 | animation: 'slide', 309 | style: { 310 | position: 'fixed', 311 | top: 0, 312 | left: 0, 313 | width: 200, 314 | height: 300, 315 | pointerEvents: 'none', 316 | }, 317 | in: false, 318 | transitioning: true, 319 | onEntered: expect.any(Function), 320 | onExited: expect.any(Function), 321 | }); 322 | 323 | expect(children).toHaveBeenNthCalledWith(3, { 324 | node: , 325 | nodeKey: 'piscd0jk', 326 | prevNodeKey: 'cre66i9s', 327 | mode: 'out-in', 328 | animation: 'slide', 329 | style: { 330 | position: 'relative', 331 | }, 332 | in: true, 333 | transitioning: true, 334 | onEntered: expect.any(Function), 335 | onExited: expect.any(Function), 336 | }); 337 | 338 | expect(children).toHaveBeenNthCalledWith(4, { 339 | node: , 340 | nodeKey: 'piscd0jk', 341 | prevNodeKey: 'cre66i9s', 342 | mode: 'out-in', 343 | animation: 'slide', 344 | style: { 345 | position: 'relative', 346 | }, 347 | in: true, 348 | transitioning: false, 349 | onEntered: expect.any(Function), 350 | onExited: expect.any(Function), 351 | }); 352 | }); 353 | 354 | it('should lock container size initially and unlock before swapping in', async () => { 355 | const callbacks = {}; 356 | 357 | const children = jest.fn(({ in: inProp, node, onEntered, onExited }) => { 358 | if (inProp) { 359 | callbacks.onEntered = onEntered; 360 | } else { 361 | callbacks.onExited = onExited; 362 | } 363 | 364 | return node; 365 | }); 366 | 367 | const { rerender, container } = render( 368 | }> 372 | { children } 373 | , 374 | ); 375 | 376 | const element = container.childNodes[0]; 377 | 378 | expect(element.style.minWidth).toBe(''); 379 | expect(element.style.minHeight).toBe(''); 380 | 381 | rerender( 382 | }> 386 | { children } 387 | , 388 | ); 389 | 390 | await new Promise((resolve) => setTimeout(resolve, 50)); 391 | 392 | expect(element.style.minWidth).toBe('200px'); 393 | expect(element.style.minHeight).toBe('300px'); 394 | 395 | await new Promise((resolve) => setTimeout(resolve, 50)); 396 | 397 | expect(element.style.minWidth).toBe('200px'); 398 | expect(element.style.minHeight).toBe('300px'); 399 | 400 | callbacks.onExited(); 401 | await new Promise((resolve) => setTimeout(resolve, 50)); 402 | 403 | expect(element.style.minWidth).toBe(''); 404 | expect(element.style.minHeight).toBe(''); 405 | }); 406 | 407 | it('should update scroll position before swapping in', async () => { 408 | const callbacks = {}; 409 | const updateScroll = jest.fn(); 410 | 411 | const children = jest.fn(({ in: inProp, node, onEntered, onExited }) => { 412 | if (inProp) { 413 | callbacks.onEntered = onEntered; 414 | } else { 415 | callbacks.onExited = onExited; 416 | } 417 | 418 | return node; 419 | }); 420 | 421 | const { rerender } = render( 422 | } 425 | updateScroll={ updateScroll }> 426 | { children } 427 | , 428 | ); 429 | 430 | expect(updateScroll).toHaveBeenCalledTimes(0); 431 | 432 | rerender( 433 | } 436 | updateScroll={ updateScroll }> 437 | { children } 438 | , 439 | ); 440 | 441 | await new Promise((resolve) => setTimeout(resolve, 50)); 442 | 443 | expect(updateScroll).toHaveBeenCalledTimes(0); 444 | 445 | callbacks.onExited(); 446 | await new Promise((resolve) => setTimeout(resolve, 50)); 447 | 448 | expect(updateScroll).toHaveBeenCalledTimes(1); 449 | expect(updateScroll).toHaveBeenCalledWith({ nodeKey: 'piscd0jk' }); 450 | }); 451 | }); 452 | 453 | it('should wait for inflight swap before starting a new one', async () => { 454 | const children = jest.fn(({ in: inProp, node, onEntered, onExited }) => { 455 | if (inProp) { 456 | onEntered(); 457 | } else { 458 | onExited(); 459 | } 460 | 461 | return node; 462 | }); 463 | 464 | const { rerender } = render( 465 | } 467 | animation="fade"> 468 | { children } 469 | , 470 | ); 471 | 472 | children.mockClear(); 473 | 474 | rerender( 475 | } 477 | animation="fade"> 478 | { children } 479 | , 480 | ); 481 | 482 | rerender( 483 | } 485 | animation="slide"> 486 | { children } 487 | , 488 | ); 489 | 490 | await new Promise((resolve) => setTimeout(resolve, 100)); 491 | 492 | expect(children).toHaveBeenCalledTimes(8); 493 | 494 | expect(children).toHaveBeenNthCalledWith(1, { 495 | node: , 496 | nodeKey: 'cre66i9s', 497 | mode: 'simultaneous', 498 | animation: 'fade', 499 | style: { 500 | position: 'fixed', 501 | top: 0, 502 | left: 0, 503 | width: 200, 504 | height: 300, 505 | pointerEvents: 'none', 506 | }, 507 | in: true, 508 | transitioning: false, 509 | onEntered: expect.any(Function), 510 | onExited: expect.any(Function), 511 | }); 512 | 513 | expect(children).toHaveBeenNthCalledWith(2, { 514 | node: , 515 | nodeKey: 'piscd0jk', 516 | prevNodeKey: 'cre66i9s', 517 | mode: 'simultaneous', 518 | animation: 'fade', 519 | style: { 520 | position: 'relative', 521 | }, 522 | in: true, 523 | transitioning: true, 524 | onEntered: expect.any(Function), 525 | onExited: expect.any(Function), 526 | }); 527 | 528 | expect(children).toHaveBeenNthCalledWith(3, { 529 | node: , 530 | nodeKey: 'cre66i9s', 531 | mode: 'simultaneous', 532 | animation: 'fade', 533 | style: { 534 | position: 'fixed', 535 | top: 0, 536 | left: 0, 537 | width: 200, 538 | height: 300, 539 | pointerEvents: 'none', 540 | }, 541 | in: false, 542 | transitioning: true, 543 | onEntered: expect.any(Function), 544 | onExited: expect.any(Function), 545 | }); 546 | 547 | expect(children).toHaveBeenNthCalledWith(4, { 548 | node: , 549 | nodeKey: 'piscd0jk', 550 | prevNodeKey: 'cre66i9s', 551 | mode: 'simultaneous', 552 | animation: 'fade', 553 | style: { 554 | position: 'relative', 555 | }, 556 | in: true, 557 | transitioning: false, 558 | onEntered: expect.any(Function), 559 | onExited: expect.any(Function), 560 | }); 561 | 562 | expect(children).toHaveBeenNthCalledWith(5, { 563 | node: , 564 | nodeKey: 'piscd0jk', 565 | prevNodeKey: 'cre66i9s', 566 | mode: 'simultaneous', 567 | animation: 'slide', 568 | style: { 569 | position: 'fixed', 570 | top: 0, 571 | left: 0, 572 | width: 200, 573 | height: 300, 574 | pointerEvents: 'none', 575 | }, 576 | in: true, 577 | transitioning: false, 578 | onEntered: expect.any(Function), 579 | onExited: expect.any(Function), 580 | }); 581 | 582 | expect(children).toHaveBeenNthCalledWith(6, { 583 | node: , 584 | nodeKey: 'cre66i9s', 585 | prevNodeKey: 'piscd0jk', 586 | mode: 'simultaneous', 587 | animation: 'slide', 588 | style: { 589 | position: 'relative', 590 | }, 591 | in: true, 592 | transitioning: true, 593 | onEntered: expect.any(Function), 594 | onExited: expect.any(Function), 595 | }); 596 | 597 | expect(children).toHaveBeenNthCalledWith(7, { 598 | node: , 599 | nodeKey: 'piscd0jk', 600 | prevNodeKey: 'cre66i9s', 601 | mode: 'simultaneous', 602 | animation: 'slide', 603 | style: { 604 | position: 'fixed', 605 | top: 0, 606 | left: 0, 607 | width: 200, 608 | height: 300, 609 | pointerEvents: 'none', 610 | }, 611 | in: false, 612 | transitioning: true, 613 | onEntered: expect.any(Function), 614 | onExited: expect.any(Function), 615 | }); 616 | 617 | expect(children).toHaveBeenNthCalledWith(8, { 618 | node: , 619 | nodeKey: 'cre66i9s', 620 | prevNodeKey: 'piscd0jk', 621 | mode: 'simultaneous', 622 | animation: 'slide', 623 | style: { 624 | position: 'relative', 625 | }, 626 | in: true, 627 | transitioning: false, 628 | onEntered: expect.any(Function), 629 | onExited: expect.any(Function), 630 | }); 631 | }); 632 | 633 | it('should update node, if nodeKey is the same', async () => { 634 | const children = jest.fn(({ node }) => node); 635 | 636 | const { rerender } = render( 637 | foo } 639 | animation="fade"> 640 | { children } 641 | , 642 | ); 643 | 644 | rerender( 645 | bar } 647 | animation="fade"> 648 | { children } 649 | , 650 | ); 651 | 652 | await new Promise((resolve) => setTimeout(resolve, 50)); 653 | 654 | expect(children).toHaveBeenCalledTimes(2); 655 | 656 | expect(children).toHaveBeenNthCalledWith(1, { 657 | node: foo, 658 | nodeKey: '12a6ijitc', 659 | mode: 'simultaneous', 660 | animation: 'fade', 661 | style: { position: 'relative' }, 662 | in: true, 663 | transitioning: false, 664 | onEntered: expect.any(Function), 665 | onExited: expect.any(Function), 666 | }); 667 | 668 | expect(children).toHaveBeenNthCalledWith(2, { 669 | node: bar, 670 | nodeKey: '12a6ijitc', 671 | mode: 'simultaneous', 672 | animation: 'fade', 673 | style: { position: 'relative' }, 674 | in: true, 675 | transitioning: false, 676 | onEntered: expect.any(Function), 677 | onExited: expect.any(Function), 678 | }); 679 | }); 680 | 681 | it('should support a function as the mode prop', () => { 682 | const children = jest.fn(({ node }) => node); 683 | const mode = jest.fn(() => 'out-in'); 684 | 685 | const { rerender } = render( 686 | } 688 | mode={ mode }> 689 | { children } 690 | , 691 | ); 692 | 693 | rerender( 694 | } 696 | mode={ mode }> 697 | { children } 698 | , 699 | ); 700 | 701 | expect(mode).toHaveBeenCalledTimes(2); 702 | expect(mode).toHaveBeenNthCalledWith(1, { nodeKey: 'cre66i9s', prevNodeKey: undefined }); 703 | expect(mode).toHaveBeenNthCalledWith(2, { nodeKey: 'piscd0jk', prevNodeKey: 'cre66i9s' }); 704 | }); 705 | 706 | it('should support a function as the animation prop', () => { 707 | const children = jest.fn(({ node }) => node); 708 | const animation = jest.fn(() => 'fade'); 709 | 710 | const { rerender } = render( 711 | } 713 | animation={ animation }> 714 | { children } 715 | , 716 | ); 717 | 718 | rerender( 719 | } 721 | animation={ animation }> 722 | { children } 723 | , 724 | ); 725 | 726 | expect(animation).toHaveBeenCalledTimes(2); 727 | expect(animation).toHaveBeenNthCalledWith(1, { nodeKey: 'cre66i9s', prevNodeKey: undefined }); 728 | expect(animation).toHaveBeenNthCalledWith(2, { nodeKey: 'piscd0jk', prevNodeKey: 'cre66i9s' }); 729 | }); 730 | 731 | it('should accept a custom nodeKey prop', () => { 732 | const children = jest.fn(({ node }) => node); 733 | 734 | render( 735 | } 737 | nodeKey="foo"> 738 | { children } 739 | , 740 | ); 741 | 742 | expect(children).toHaveBeenCalledTimes(1); 743 | expect(children.mock.calls[0][0].nodeKey).toBe('foo'); 744 | }); 745 | 746 | it('should spread extraneous props into the container', () => { 747 | const { container } = render( 748 | }> 752 | { ({ node }) => node } 753 | , 754 | ); 755 | 756 | const element = container.childNodes[0]; 757 | 758 | expect(element).toHaveClass('foo'); 759 | expect(element).toHaveStyle('overflow: hidden'); 760 | }); 761 | 762 | it('should blur active element when swapping', async () => { 763 | const children = ({ node }) => node; 764 | 765 | const { rerender, getByText } = render( 766 | }> 769 | { children } 770 | , 771 | ); 772 | 773 | const element = getByText('Page 1'); 774 | 775 | element.focus(); 776 | 777 | jest.spyOn(element, 'blur'); 778 | 779 | rerender( 780 | }> 783 | { children } 784 | , 785 | ); 786 | 787 | await new Promise((resolve) => setTimeout(resolve, 50)); 788 | 789 | expect(element.blur).toHaveBeenCalledTimes(1); 790 | }); 791 | 792 | it('should call onSwapBegin and onSwapEnd props correctly', async () => { 793 | const onSwapBegin = jest.fn(); 794 | const onSwapEnd = jest.fn(); 795 | const children = ({ in: inProp, node, onEntered, onExited }) => { 796 | if (inProp) { 797 | onEntered(); 798 | } else { 799 | onExited(); 800 | } 801 | 802 | return node; 803 | }; 804 | 805 | const { rerender } = render( 806 | } 808 | onSwapBegin={ onSwapBegin } 809 | onSwapEnd={ onSwapEnd }> 810 | { children } 811 | , 812 | ); 813 | 814 | expect(onSwapBegin).toHaveBeenCalledTimes(0); 815 | expect(onSwapEnd).toHaveBeenCalledTimes(0); 816 | 817 | rerender( 818 | } 820 | onSwapBegin={ onSwapBegin } 821 | onSwapEnd={ onSwapEnd }> 822 | { children } 823 | , 824 | ); 825 | 826 | await new Promise((resolve) => setTimeout(resolve, 50)); 827 | 828 | expect(onSwapBegin).toHaveBeenCalledTimes(1); 829 | expect(onSwapBegin).toHaveBeenCalledWith({ nodeKey: 'cre66i9s', nextNodeKey: 'piscd0jk' }); 830 | expect(onSwapEnd).toHaveBeenCalledTimes(1); 831 | expect(onSwapEnd).toHaveBeenCalledWith({ nodeKey: 'piscd0jk', prevNodeKey: 'cre66i9s' }); 832 | }); 833 | -------------------------------------------------------------------------------- /src/SwapTransition.js: -------------------------------------------------------------------------------- 1 | import { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import shallowequal from 'shallowequal'; 4 | import { omit } from 'lodash'; 5 | import memoizeOne from 'memoize-one'; 6 | import once from 'once'; 7 | 8 | export default class SwapTransition extends Component { 9 | static propTypes = { 10 | node: PropTypes.element.isRequired, 11 | nodeKey: PropTypes.string.isRequired, 12 | prevNodeKey: PropTypes.string, 13 | mode: PropTypes.oneOf(['simultaneous', 'out-in']).isRequired, 14 | animation: PropTypes.string, 15 | in: PropTypes.bool, 16 | style: PropTypes.object, 17 | onEntered: PropTypes.func, 18 | onExited: PropTypes.func, 19 | children: PropTypes.func.isRequired, 20 | }; 21 | 22 | static defaultProps = { 23 | in: false, 24 | style: {}, 25 | }; 26 | 27 | static getDerivedStateFromProps(props, state) { 28 | let transitioning; 29 | 30 | // Transitioning is set to true if the `in` prop changed 31 | // There's an exception which is when mounting, which we take into consideration the `hasPrevNode` prop 32 | if (props.in !== state.in) { 33 | transitioning = state.in == null ? !!props.prevNodeKey : true; 34 | } else { 35 | transitioning = state.transitioning; 36 | } 37 | 38 | return { 39 | // TransitionGroup adds the properties below, so we filter them out 40 | ...omit(props, ['appear', 'enter', 'exit', 'onExited']), 41 | // TransitionGroup changes `onExited` in every render unnecessarily, 42 | // so we keep it constant to avoid re-renders 43 | onExited: state.onExited || props.onExited, 44 | transitioning, 45 | }; 46 | } 47 | 48 | state = { 49 | transitioning: false, 50 | }; 51 | 52 | shouldComponentUpdate(prevProps, prevState) { 53 | // Only update when state changes, which was derived in `getDerivedStateFromProps` 54 | return !shallowequal(this.state, prevState); 55 | } 56 | 57 | render() { 58 | const { children, onEntered, onExited, ...rest } = this.state; 59 | 60 | return children({ 61 | ...rest, 62 | onEntered: this.wrapOnEntered(onEntered), 63 | onExited: this.wrapOnExited(onExited), 64 | }); 65 | } 66 | 67 | // eslint-disable-next-line react/sort-comp 68 | wrapOnEntered = memoizeOne((onEntered) => once(async () => { 69 | const { in: inProp, transitioning } = this.state; 70 | 71 | if (!inProp || !transitioning) { 72 | return; 73 | } 74 | 75 | await Promise.resolve(); 76 | 77 | this.setState({ transitioning: false }, () => { 78 | onEntered?.(); 79 | }); 80 | })); 81 | 82 | // eslint-disable-next-line react/sort-comp 83 | wrapOnExited = memoizeOne((onExited) => once(async () => { 84 | const { in: inProp, transitioning } = this.state; 85 | 86 | if (inProp || !transitioning) { 87 | return; 88 | } 89 | 90 | await Promise.resolve(); 91 | 92 | // `transitioning` is not changed to false as the view will be unmounted afterwards 93 | // This effectively avoids one useless re-render 94 | onExited?.(); 95 | })); 96 | } 97 | -------------------------------------------------------------------------------- /src/SwapTransition.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import SwapTransition from './SwapTransition'; 4 | 5 | const children = jest.fn(({ node }) => node); 6 | const props = { 7 | node:
foo
, 8 | nodeKey: 'bar', 9 | mode: 'simultaneous', 10 | animation: 'baz', 11 | style: { position: 'relative' }, 12 | in: true, 13 | onEntered: jest.fn(() => {}), 14 | onExited: jest.fn(() => {}), 15 | }; 16 | 17 | beforeEach(() => { 18 | jest.clearAllMocks(); 19 | }); 20 | 21 | it('should call children correctly', () => { 22 | const { getByText } = render( 23 | 24 | { children } 25 | , 26 | ); 27 | 28 | expect(getByText('foo')).toBeInTheDocument(); 29 | expect(children).toHaveBeenCalledTimes(1); 30 | expect(children).toHaveBeenCalledWith({ 31 | ...props, 32 | transitioning: false, 33 | onEntered: expect.any(Function), 34 | onExited: expect.any(Function), 35 | }); 36 | }); 37 | 38 | it('should set transitioning correctly when entering & exiting', async () => { 39 | render( 40 | 41 | { children } 42 | , 43 | ); 44 | 45 | expect(children).toHaveBeenCalledTimes(1); 46 | expect(children.mock.calls[0][0].transitioning).toBe(false); 47 | 48 | children.mockClear(); 49 | 50 | const { rerender } = render( 51 | 52 | { children } 53 | , 54 | ); 55 | 56 | expect(children).toHaveBeenCalledTimes(1); 57 | expect(children.mock.calls[0][0].transitioning).toBe(true); 58 | 59 | // Calling `onEnter` should change `transitioning` to false 60 | const { onEntered } = children.mock.calls[0][0]; 61 | 62 | children.mockClear(); 63 | onEntered(); 64 | 65 | await new Promise((resolve) => setTimeout(resolve, 50)); 66 | 67 | expect(children).toHaveBeenCalledTimes(1); 68 | expect(children.mock.calls[0][0].transitioning).toBe(false); 69 | 70 | children.mockClear(); 71 | 72 | rerender( 73 | 74 | { children } 75 | , 76 | ); 77 | 78 | expect(children).toHaveBeenCalledTimes(1); 79 | expect(children.mock.calls[0][0].transitioning).toBe(true); 80 | 81 | // Calling `onExit` should not change `transitioning` nor re-render 82 | const { onExited } = children.mock.calls[0][0]; 83 | 84 | children.mockClear(); 85 | onExited(); 86 | 87 | await new Promise((resolve) => setTimeout(resolve, 50)); 88 | 89 | expect(children).toHaveBeenCalledTimes(0); 90 | }); 91 | 92 | it('should not change transitioning if in is unchanged on re-render', () => { 93 | const { rerender } = render( 94 | 95 | { children } 96 | , 97 | ); 98 | 99 | expect(children).toHaveBeenCalledTimes(1); 100 | expect(children.mock.calls[0][0].transitioning).toBe(true); 101 | 102 | children.mockClear(); 103 | 104 | rerender( 105 | 106 | { children } 107 | , 108 | ); 109 | 110 | expect(children).toHaveBeenCalledTimes(0); 111 | }); 112 | 113 | it('should not re-render on TransitionGroup injected properties', () => { 114 | const { rerender } = render( 115 | 116 | { children } 117 | , 118 | ); 119 | 120 | rerender( 121 | 122 | { children } 123 | , 124 | ); 125 | 126 | expect(children).toHaveBeenCalledTimes(1); 127 | }); 128 | 129 | it('should not re-render when onExited changes, due to TransitionGroup changing it all the time', () => { 130 | const { rerender } = render( 131 | 132 | { children } 133 | , 134 | ); 135 | 136 | rerender( 137 | {} }> 138 | { children } 139 | , 140 | ); 141 | 142 | expect(children).toHaveBeenCalledTimes(1); 143 | }); 144 | 145 | it('should call onEnter and onExit when transition finishes', async () => { 146 | const { rerender } = render( 147 | 148 | { children } 149 | , 150 | ); 151 | 152 | expect(children).toHaveBeenCalledTimes(1); 153 | 154 | const { onEntered, onExited } = children.mock.calls[0][0]; 155 | 156 | onEntered(); 157 | 158 | await new Promise((resolve) => setTimeout(resolve, 50)); 159 | 160 | expect(props.onEntered).toHaveBeenCalledTimes(1); 161 | expect(props.onExited).toHaveBeenCalledTimes(0); 162 | 163 | props.onEntered.mockClear(); 164 | props.onExited.mockClear(); 165 | 166 | rerender( 167 | 168 | { children } 169 | , 170 | ); 171 | 172 | onExited(); 173 | 174 | await new Promise((resolve) => setTimeout(resolve, 50)); 175 | 176 | expect(props.onEntered).toHaveBeenCalledTimes(0); 177 | expect(props.onExited).toHaveBeenCalledTimes(1); 178 | }); 179 | 180 | it('should prevent bad usage of onEnter and onExit', async () => { 181 | const { rerender } = render( 182 | 183 | { children } 184 | , 185 | ); 186 | 187 | expect(children).toHaveBeenCalledTimes(1); 188 | 189 | const { onEntered, onExited } = children.mock.calls[0][0]; 190 | 191 | children.mockClear(); 192 | onEntered(); 193 | onExited(); 194 | 195 | await new Promise((resolve) => setTimeout(resolve, 50)); 196 | 197 | expect(children).toHaveBeenCalledTimes(0); 198 | expect(props.onEntered).toHaveBeenCalledTimes(0); 199 | expect(props.onExited).toHaveBeenCalledTimes(0); 200 | 201 | rerender( 202 | 203 | { children } 204 | , 205 | ); 206 | 207 | children.mockClear(); 208 | onEntered(); 209 | 210 | await new Promise((resolve) => setTimeout(resolve, 50)); 211 | 212 | expect(children).toHaveBeenCalledTimes(0); 213 | expect(props.onEntered).toHaveBeenCalledTimes(0); 214 | expect(props.onExited).toHaveBeenCalledTimes(0); 215 | 216 | rerender( 217 | 218 | { children } 219 | , 220 | ); 221 | 222 | children.mockClear(); 223 | onExited(); 224 | 225 | await new Promise((resolve) => setTimeout(resolve, 50)); 226 | 227 | expect(children).toHaveBeenCalledTimes(0); 228 | expect(props.onEntered).toHaveBeenCalledTimes(0); 229 | expect(props.onExited).toHaveBeenCalledTimes(0); 230 | }); 231 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './PageSwapper'; 2 | export { default as isHistoryEntryFromPopState } from './pop-state'; 3 | export { getNodeKeyFromPathname } from './node-key'; 4 | -------------------------------------------------------------------------------- /src/index.test.js: -------------------------------------------------------------------------------- 1 | import * as exports from './index'; 2 | 3 | it('should export by default', () => { 4 | expect(typeof exports.default).toBe('function'); 5 | }); 6 | 7 | it('should export isHistoryEntryFromPopState', () => { 8 | expect(typeof exports.isHistoryEntryFromPopState).toBe('function'); 9 | }); 10 | 11 | it('should export getNodeKeyFromPathname', () => { 12 | expect(typeof exports.getNodeKeyFromPathname).toBe('function'); 13 | }); 14 | -------------------------------------------------------------------------------- /src/layout.js: -------------------------------------------------------------------------------- 1 | export const lockContainerSize = (element) => { 2 | const { width, height } = getComputedStyle(element); 3 | 4 | element.style.minWidth = width; 5 | element.style.minHeight = height; 6 | 7 | return () => { 8 | element.style.minWidth = ''; 9 | element.style.minHeight = ''; 10 | }; 11 | }; 12 | 13 | export const buildEnterStyle = () => ({ 14 | position: 'relative', 15 | }); 16 | 17 | export const buildExitStyle = (element) => { 18 | if (!element) { 19 | return {}; 20 | } 21 | 22 | const { width, height } = getComputedStyle(element); 23 | const { top, left } = element.getBoundingClientRect(); 24 | 25 | return { 26 | position: 'fixed', 27 | top, 28 | left, 29 | width: parseInt(width, 10), 30 | height: parseInt(height, 10), 31 | pointerEvents: 'none', 32 | }; 33 | }; 34 | -------------------------------------------------------------------------------- /src/layout.test.js: -------------------------------------------------------------------------------- 1 | import { lockContainerSize, buildEnterStyle, buildExitStyle } from './layout'; 2 | 3 | let fooDiv; 4 | 5 | beforeAll(() => { 6 | const style = document.createElement('style'); 7 | 8 | style.type = 'text/css'; 9 | style.innerHTML = ` 10 | #foo { 11 | width: 200px; 12 | height: 300px; 13 | } 14 | `; 15 | 16 | document.head.appendChild(style); 17 | }); 18 | 19 | beforeEach(() => { 20 | fooDiv = document.createElement('div'); 21 | 22 | fooDiv.setAttribute('id', 'foo'); 23 | fooDiv.innerText = 'foo'; 24 | 25 | document.body.appendChild(fooDiv); 26 | }); 27 | 28 | afterEach(() => { 29 | document.body.removeChild(fooDiv); 30 | }); 31 | 32 | describe('lockContainerSize', () => { 33 | it('should lock a element size', () => { 34 | lockContainerSize(fooDiv); 35 | 36 | expect(fooDiv.style.minWidth).toBe('200px'); 37 | expect(fooDiv.style.minHeight).toBe('300px'); 38 | }); 39 | 40 | it('should return a function to reset locking', () => { 41 | const reset = lockContainerSize(fooDiv); 42 | 43 | reset(); 44 | 45 | expect(fooDiv.style.minWidth).toBe(''); 46 | expect(fooDiv.style.minHeight).toBe(''); 47 | }); 48 | }); 49 | 50 | describe('buildEnterStyle', () => { 51 | it('should return the correct style object', () => { 52 | expect(buildEnterStyle()).toEqual({ 53 | position: 'relative', 54 | }); 55 | }); 56 | }); 57 | 58 | describe('buildExitStyle', () => { 59 | it('should return the correct style object', () => { 60 | jest.spyOn(fooDiv, 'getBoundingClientRect').mockImplementation(() => ({ 61 | width: 1000, // Should not be used 62 | height: 1000, // Should not be used 63 | top: 100, 64 | left: 150, 65 | x: 150, 66 | y: 100, 67 | right: 200, 68 | bottom: 250, 69 | })); 70 | 71 | expect(buildExitStyle(fooDiv)).toEqual({ 72 | position: 'fixed', 73 | top: 100, 74 | left: 150, 75 | width: 200, 76 | height: 300, 77 | pointerEvents: 'none', 78 | }); 79 | }); 80 | 81 | it('should return an empty style object if element is undefined', () => { 82 | expect(buildExitStyle()).toEqual({}); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /src/node-key.js: -------------------------------------------------------------------------------- 1 | const nodeKeySymbol = Symbol(); 2 | 3 | export const getRandomNodeKey = (node) => { 4 | if (process.env.NODE_ENV !== 'production' && (!['function', 'object'].includes(typeof node?.type))) { 5 | throw new TypeError('Node type must be a component. Are you passing a DOM node such as
or a fragment?'); 6 | } 7 | 8 | let nodeKey = node.type[nodeKeySymbol]; 9 | 10 | if (!nodeKey) { 11 | nodeKey = Math.floor(Math.random() * (10 ** 17)).toString(36); 12 | 13 | Object.defineProperty(node.type, nodeKeySymbol, { value: nodeKey }); 14 | } 15 | 16 | return nodeKey; 17 | }; 18 | 19 | export const getNodeKeyFromPathname = (level, pathname) => { 20 | if (!pathname) { 21 | pathname = typeof location !== 'undefined' ? location.pathname : '/'; 22 | } 23 | 24 | return pathname.split('/').slice(0, level + 2).join('/'); 25 | }; 26 | -------------------------------------------------------------------------------- /src/node-key.test.js: -------------------------------------------------------------------------------- 1 | import React, { forwardRef, memo } from 'react'; 2 | import { getRandomNodeKey, getNodeKeyFromPathname } from './node-key'; 3 | 4 | describe('getRandomNodeKey()', () => { 5 | beforeEach(() => { 6 | let counter = 0; 7 | 8 | // eslint-disable-next-line no-plusplus 9 | jest.spyOn(Math, 'random').mockImplementation(() => (++counter) * 0.00001); 10 | }); 11 | 12 | afterEach(() => { 13 | jest.restoreAllMocks(); 14 | }); 15 | 16 | it('should return the same random key for a given node', () => { 17 | const MyComponent = () => {}; 18 | const MyOtherComponent = () => {}; 19 | const ForwardedMyComponent = forwardRef(() => ); 20 | const MemoMyComponent = memo(MyComponent); 21 | 22 | expect(getRandomNodeKey()).toBe('cre66i9s'); 23 | expect(getRandomNodeKey()).toBe('cre66i9s'); 24 | expect(getRandomNodeKey()).toBe('piscd0jk'); 25 | expect(getRandomNodeKey()).toBe('piscd0jk'); 26 | expect(getRandomNodeKey()).toBe('12a6ijitc'); 27 | expect(getRandomNodeKey()).toBe('12a6ijitc'); 28 | expect(getRandomNodeKey()).toBe('1f1koq134'); 29 | expect(getRandomNodeKey()).toBe('1f1koq134'); 30 | }); 31 | 32 | it('should throw if node type is not a component', () => { 33 | expect(() => { 34 | getRandomNodeKey('foo'); 35 | }).toThrow(TypeError, /node type must be a component/i); 36 | 37 | expect(() => { 38 | getRandomNodeKey(
); 39 | }).toThrow(TypeError, /node type must be a component/i); 40 | 41 | expect(() => { 42 | getRandomNodeKey(<>foo); 43 | }).toThrow(TypeError, /node type must be a component/i); 44 | }); 45 | }); 46 | 47 | describe('getNodeKeyFromPathname()', () => { 48 | it('should return the correct key according to the level', () => { 49 | expect(getNodeKeyFromPathname(0, '/foo/bar/baz')).toBe('/foo'); 50 | expect(getNodeKeyFromPathname(1, '/foo/bar/baz')).toBe('/foo/bar'); 51 | expect(getNodeKeyFromPathname(2, '/foo/bar/baz')).toBe('/foo/bar/baz'); 52 | expect(getNodeKeyFromPathname(3, '/foo/bar/baz')).toBe('/foo/bar/baz'); 53 | }); 54 | 55 | it('should assume location.pathname by default', () => { 56 | window.history.replaceState({}, '', '/foo/bar'); 57 | 58 | expect(getNodeKeyFromPathname(0)).toBe('/foo'); 59 | expect(getNodeKeyFromPathname(1)).toBe('/foo/bar'); 60 | }); 61 | 62 | it('should work well in SSR', () => { 63 | const location = window.location; 64 | 65 | delete window.location; 66 | 67 | try { 68 | expect(getNodeKeyFromPathname(0)).toBe('/'); 69 | expect(getNodeKeyFromPathname(1)).toBe('/'); 70 | } finally { 71 | window.location = location; 72 | } 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /src/pop-state.js: -------------------------------------------------------------------------------- 1 | import { wrap } from 'lodash'; 2 | 3 | let popState = false; 4 | 5 | if (typeof window !== 'undefined') { 6 | window.addEventListener('popstate', () => { 7 | popState = true; 8 | }); 9 | 10 | history.pushState = wrap(history.pushState, (pushState, ...args) => { 11 | popState = false; 12 | pushState.apply(history, args); 13 | }); 14 | } 15 | 16 | const isHistoryEntryFromPopState = () => popState; 17 | 18 | export default isHistoryEntryFromPopState; 19 | -------------------------------------------------------------------------------- /src/pop-state.test.js: -------------------------------------------------------------------------------- 1 | import isHistoryEntryFromPopState from './pop-state'; 2 | 3 | afterEach(() => { 4 | jest.restoreAllMocks(); 5 | }); 6 | 7 | it('should return false in the beginning', () => { 8 | expect(isHistoryEntryFromPopState()).toBe(false); 9 | }); 10 | 11 | it('should return true if history entry originated from a popstate', () => { 12 | window.dispatchEvent(new Event('popstate')); 13 | 14 | expect(isHistoryEntryFromPopState()).toBe(true); 15 | }); 16 | 17 | it('should return false after a pushState', () => { 18 | window.dispatchEvent(new Event('popstate')); 19 | 20 | history.pushState({}, '', '/foo'); 21 | 22 | expect(isHistoryEntryFromPopState()).toBe(false); 23 | }); 24 | 25 | it('should return false on SSR', () => { 26 | jest.spyOn(global, 'window', 'get').mockImplementation(() => undefined); 27 | jest.resetModules(); 28 | 29 | const isHistoryEntryFromPopState = require('./pop-state'); 30 | 31 | expect(isHistoryEntryFromPopState()).toBe(false); 32 | }); 33 | --------------------------------------------------------------------------------