diff --git a/.babelrc.js b/.babelrc.js index f0f2cf57..a12cc6d4 100644 --- a/.babelrc.js +++ b/.babelrc.js @@ -8,4 +8,4 @@ module.exports = { presets: [['babel-preset-jason', { modules: false }]], }, }, -} +}; diff --git a/.eslintignore b/.eslintignore index f170734d..38ee6bb2 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,3 +1,4 @@ **/node_modules www/.cache/ www/public/ +lib diff --git a/.eslintrc.yml b/.eslintrc.yml index 1062ba58..0428d894 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -1,10 +1,21 @@ - parser: babel-eslint extends: - jason/react - plugin:jsx-a11y/recommended + - prettier +settings: + react: + version: detect env: node: true browser: true plugins: - jsx-a11y + +overrides: + - files: www/**/* + env: + es6: true + - files: stories/**/* + rules: + no-console: off diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md deleted file mode 100644 index b79d202c..00000000 --- a/.github/ISSUE_TEMPLATE.md +++ /dev/null @@ -1,10 +0,0 @@ -**Do you want to request a *feature* or report a *bug*?** - -**What is the current behavior?** - -**If the current behavior is a bug, please provide the steps to reproduce and if possible a minimal demo of the problem via [CodeSandbox](https://codesandbox.io/) or similar (Template: https://codesandbox.io/s/lvnpplww9).** - -**What is the expected behavior?** - - -**Which versions, and which browser / OS are affected by this issue? Did this work in previous versions?** diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md new file mode 100644 index 00000000..b66cbd90 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -0,0 +1,18 @@ +--- +name: Bug report +about: Something isn't working as expected. +--- + +> What is the current behavior? + + + +> What is the expected behavior? + + + + + +> Could you provide a [CodeSandbox](https://codesandbox.io/) demo reproducing the bug? + + diff --git a/.github/ISSUE_TEMPLATE/feature-request.md b/.github/ISSUE_TEMPLATE/feature-request.md new file mode 100644 index 00000000..d21cee25 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.md @@ -0,0 +1,8 @@ +--- +name: Feature request +about: I have a suggestion on how to improve the library. +--- + +> What would you like improved? + + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..08d7ad2d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,76 @@ +name: CI + +on: + push: + branches: [master, alpha] + pull_request: + branches: [master, alpha] + +jobs: + test: + runs-on: ubuntu-latest + + strategy: + # Otherwise how would we know if a specific React version caused the failure? + fail-fast: false + matrix: + REACT_DIST: [16, 17, 18] + steps: + - uses: actions/checkout@v2 + - name: Use Node.js 14 + uses: actions/setup-node@v2 + with: + node-version: 14 + cache: 'npm' + - run: yarn + - run: yarn add react@${{ matrix.REACT_DIST }} react-dom@${{ matrix.REACT_DIST }} + - run: yarn add @testing-library/react@12 + if: matrix.REACT_DIST == '17' || matrix.REACT_DIST == '16' + - run: yarn --cwd www + # Test whether the web page can be built successfully or not + - run: yarn --cwd www build + - run: yarn test + - name: Dry release + uses: cycjimmy/semantic-release-action@v2 + with: + dry_run: true + semantic_version: 17 + branches: | + [ + 'master', + {name: 'alpha', prerelease: true} + ] + env: + # These are not available on forks but we need them on actual runs to verify everything is set up. + # Otherwise we might fail in the middle of a release + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + + release: + needs: test + runs-on: ubuntu-latest + if: ${{ github.repository == 'reactjs/react-transition-group' && + contains('refs/heads/master,refs/heads/alpha', + github.ref) && github.event_name == 'push' }} + steps: + - uses: styfle/cancel-workflow-action@0.9.0 + - uses: actions/checkout@v2 + - name: Use Node.js 14 + uses: actions/setup-node@v2 + with: + node-version: 14 + cache: 'npm' + - run: yarn + - run: yarn build + - name: Release + uses: cycjimmy/semantic-release-action@v2 + with: + semantic_version: 17 + branches: | + [ + 'master', + {name: 'alpha', prerelease: true} + ] + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..c979173c --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +**/node_modules +www/.cache/ +www/public/ +lib +*.md \ No newline at end of file diff --git a/.size-snapshot.json b/.size-snapshot.json index 3018ea08..28cd868b 100644 --- a/.size-snapshot.json +++ b/.size-snapshot.json @@ -1,12 +1,12 @@ { "./lib/dist/react-transition-group.js": { - "bundled": 79410, - "minified": 22589, - "gzipped": 6905 + "bundled": 82684, + "minified": 22426, + "gzipped": 6876 }, "./lib/dist/react-transition-group.min.js": { - "bundled": 46139, - "minified": 14975, - "gzipped": 4685 + "bundled": 47269, + "minified": 14623, + "gzipped": 4616 } } diff --git a/.storybook/config.js b/.storybook/config.js deleted file mode 100644 index 9154670a..00000000 --- a/.storybook/config.js +++ /dev/null @@ -1,7 +0,0 @@ -import { configure } from '@storybook/react'; - -function loadStories() { - require('../stories'); -} - -configure(loadStories, module); diff --git a/.storybook/main.js b/.storybook/main.js new file mode 100644 index 00000000..6d952e40 --- /dev/null +++ b/.storybook/main.js @@ -0,0 +1,14 @@ +const { plugins, rules } = require('webpack-atoms'); + +module.exports = { + stories: ['../stories/index.js'], + webpackFinal: (config) => { + config.module = { + rules: [rules.js(), rules.astroturf(), rules.css({ extract: false })], + }; + + config.plugins.push(plugins.extractCss({ disable: true })); + + return config; + }, +}; diff --git a/.storybook/preview.js b/.storybook/preview.js new file mode 100644 index 00000000..203766f9 --- /dev/null +++ b/.storybook/preview.js @@ -0,0 +1,9 @@ +import React from 'react'; + +export const decorators = [ + (Story) => ( + + + + ), +]; diff --git a/.storybook/webpack.config.js b/.storybook/webpack.config.js deleted file mode 100644 index 7327f8be..00000000 --- a/.storybook/webpack.config.js +++ /dev/null @@ -1,15 +0,0 @@ -const { plugins, rules } = require('webpack-atoms'); - -module.exports = (config) => { - config.module = { - rules: [ - rules.js.inlineCss(), - rules.css({ modules: true }), - ], - }; - - config.plugins.push( - plugins.extractCss({ disable: true }) - ) - return config; -}; diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index f7036d93..00000000 --- a/.travis.yml +++ /dev/null @@ -1,14 +0,0 @@ -sudo: false -language: node_js -node_js: - - stable -cache: yarn -script: - - yarn test - -after_success: - - npm run build - - npm run semantic-release -branches: - only: - - master diff --git a/CHANGELOG.md b/CHANGELOG.md index f6b54c7d..c8006511 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,192 @@ +## [4.4.5](https://github.com/reactjs/react-transition-group/compare/v4.4.4...v4.4.5) (2022-08-01) + + +### Bug Fixes + +* apply entering animation synchronously when unmountOnExit or mountOnEnter is enabled ([#847](https://github.com/reactjs/react-transition-group/issues/847)) ([1043549](https://github.com/reactjs/react-transition-group/commit/10435492f5a5675b0e80ca6a435834ce4a0f270e)) + +## [4.4.4](https://github.com/reactjs/react-transition-group/compare/v4.4.3...v4.4.4) (2022-07-30) + + +### Bug Fixes + +* missing build files ([#845](https://github.com/reactjs/react-transition-group/issues/845)) ([97af789](https://github.com/reactjs/react-transition-group/commit/97af7893b0a5bbf69211bc3287aee814123ddeea)) + +## [4.4.3](https://github.com/reactjs/react-transition-group/compare/v4.4.2...v4.4.3) (2022-07-30) + + +### Bug Fixes + +* enter animations with mountOnEnter or unmountOnExit ([#749](https://github.com/reactjs/react-transition-group/issues/749)) ([51bdceb](https://github.com/reactjs/react-transition-group/commit/51bdceb96c8b6a79f417c32326ef1b31160edb97)) + +## [4.4.2](https://github.com/reactjs/react-transition-group/compare/v4.4.1...v4.4.2) (2021-05-29) + + +### Bug Fixes + +* `nodeRef` prop type for cross-realm elements ([#732](https://github.com/reactjs/react-transition-group/issues/732)) ([8710c01](https://github.com/reactjs/react-transition-group/commit/8710c01549e09f55eeefec2aadb3af0a23a00f82)) + +## [4.4.1](https://github.com/reactjs/react-transition-group/compare/v4.4.0...v4.4.1) (2020-05-06) + + +### Bug Fixes + +* transition SSR ([#619](https://github.com/reactjs/react-transition-group/issues/619)) ([2722bb6](https://github.com/reactjs/react-transition-group/commit/2722bb6b755943b8292f0f2bc2fdca55df5c28f0)) + +# [4.4.0](https://github.com/reactjs/react-transition-group/compare/v4.3.0...v4.4.0) (2020-05-05) + + +### Features + +* add `nodeRef` alternative instead of internal `findDOMNode` ([#559](https://github.com/reactjs/react-transition-group/issues/559)) ([85016bf](https://github.com/reactjs/react-transition-group/commit/85016bfddd3831e6d7bb27926f9f178d25502913)) + - react-transition-group internally uses `findDOMNode`, which is deprecated and produces warnings in [Strict Mode](https://reactjs.org/docs/strict-mode.html), so now you can optionally pass `nodeRef` to `Transition` and `CSSTransition`, it's a ref object that should point to the transitioning child: + + ```jsx + import React from "react" + import { CSSTransition } from "react-transition-group" + + const MyComponent = () => { + const nodeRef = React.useRef(null) + return ( + +
Fade
+
+ ) + } + ``` +### Bug Fixes + +* set the values of constants attached to `Transition` to match the exported ones ([#554](https://github.com/reactjs/react-transition-group/pull/554)) + +# [4.3.0](https://github.com/reactjs/react-transition-group/compare/v4.2.2...v4.3.0) (2019-09-05) + + +### Features + +* upgrade dom-helpers ([#549](https://github.com/reactjs/react-transition-group/issues/549)) ([b017e18](https://github.com/reactjs/react-transition-group/commit/b017e18)) + +## [4.2.2](https://github.com/reactjs/react-transition-group/compare/v4.2.1...v4.2.2) (2019-08-02) + + +### Bug Fixes + +* Fix imports to play nicely with rollup ([#530](https://github.com/reactjs/react-transition-group/issues/530)) ([3d9003e](https://github.com/reactjs/react-transition-group/commit/3d9003e)) + +## [4.2.1](https://github.com/reactjs/react-transition-group/compare/v4.2.0...v4.2.1) (2019-07-02) + + +### Bug Fixes + +* updated SwitchTransition component to be default export and exported from index.js ([#516](https://github.com/reactjs/react-transition-group/issues/516)) ([cfd0070](https://github.com/reactjs/react-transition-group/commit/cfd0070)) + +# [4.2.0](https://github.com/reactjs/react-transition-group/compare/v4.1.1...v4.2.0) (2019-06-28) + + +### Features + +* add SwitchTransition component ([#470](https://github.com/reactjs/react-transition-group/issues/470)) ([c5e379d](https://github.com/reactjs/react-transition-group/commit/c5e379d)) + +## [4.1.1](https://github.com/reactjs/react-transition-group/compare/v4.1.0...v4.1.1) (2019-06-10) + + +### Bug Fixes + +* adds missing dependency [@babel](https://github.com/babel)/runtime ([#507](https://github.com/reactjs/react-transition-group/issues/507)) ([228bf5f](https://github.com/reactjs/react-transition-group/commit/228bf5f)) + +# [4.1.0](https://github.com/reactjs/react-transition-group/compare/v4.0.1...v4.1.0) (2019-05-30) + + +### Features + +* add global transition disable switch ([#506](https://github.com/reactjs/react-transition-group/issues/506)) ([4c5ba98](https://github.com/reactjs/react-transition-group/commit/4c5ba98)) + +## [4.0.1](https://github.com/reactjs/react-transition-group/compare/v4.0.0...v4.0.1) (2019-05-09) + + +### Bug Fixes + +* issue with dynamically applied classes not being properly removed for reentering items ([#499](https://github.com/reactjs/react-transition-group/issues/499)) ([129cb11](https://github.com/reactjs/react-transition-group/commit/129cb11)) + +# [4.0.0](https://github.com/reactjs/react-transition-group/compare/v3.0.0...v4.0.0) (2019-04-16) + + +### Features + +* support esm via package.json routes ([#488](https://github.com/reactjs/react-transition-group/issues/488)) ([6337bf5](https://github.com/reactjs/react-transition-group/commit/6337bf5)), closes [#77](https://github.com/reactjs/react-transition-group/issues/77) + + +### BREAKING CHANGES + +* in environments where esm is supported importing from commonjs requires explicitly adding the `.default` after `require()` when resolving to the esm build + +# [3.0.0](https://github.com/reactjs/react-transition-group/compare/v2.9.0...v3.0.0) (2019-04-15) + + +### Features + +* use stable context API ([#471](https://github.com/reactjs/react-transition-group/issues/471)) ([aee4901](https://github.com/reactjs/react-transition-group/commit/aee4901)), closes [#429](https://github.com/reactjs/react-transition-group/issues/429) + + +### BREAKING CHANGES + +* use new style react context + +```diff +// package.json +-"react": "^15.0.0", ++"react": "^16.6.0", +-"react-dom": "^15.0.0", ++"react-dom": "^16.6.0", +``` + +# [2.9.0](https://github.com/reactjs/react-transition-group/compare/v2.8.0...v2.9.0) (2019-04-06) + + +### Features + +* **CSSTransition:** add "done" class for appear ([fe3c156](https://github.com/reactjs/react-transition-group/commit/fe3c156)), closes [#383](https://github.com/reactjs/react-transition-group/issues/383) [#327](https://github.com/reactjs/react-transition-group/issues/327) [#327](https://github.com/reactjs/react-transition-group/issues/327) + + +### Reverts + +* bump semantic release dependencies ([1bdcaec](https://github.com/reactjs/react-transition-group/commit/1bdcaec)) + +# [2.8.0](https://github.com/reactjs/react-transition-group/compare/v2.7.1...v2.8.0) (2019-04-02) + + +### Features + +* add support for empty classNames ([#481](https://github.com/reactjs/react-transition-group/issues/481)) ([d755dc6](https://github.com/reactjs/react-transition-group/commit/d755dc6)) + +## [2.7.1](https://github.com/reactjs/react-transition-group/compare/v2.7.0...v2.7.1) (2019-03-25) + + +### Bug Fixes + +* revert tree-shaking support because it was a breaking change ([271364c](https://github.com/reactjs/react-transition-group/commit/271364c)) + +# [2.7.0](https://github.com/reactjs/react-transition-group/compare/v2.6.1...v2.7.0) (2019-03-22) + + +### Features + +* support ESM (tree-shaking) ([#455](https://github.com/reactjs/react-transition-group/issues/455)) ([ef3e357](https://github.com/reactjs/react-transition-group/commit/ef3e357)) + +## [2.6.1](https://github.com/reactjs/react-transition-group/compare/v2.6.0...v2.6.1) (2019-03-14) + + +### Bug Fixes + +* **Transition:** make `exit` key optional when passing an object to the `timeout` prop ([#464](https://github.com/reactjs/react-transition-group/pull/464)) ([3a4cf9c](https://github.com/reactjs/react-transition-group/commit/3a4cf9c91ab5f25caaa9501b129bce66ec9bb56b)) +* **package.json:** mark react-transition-group as side-effect free for webpack tree shaking ([#472](https://github.com/reactjs/react-transition-group/issues/472)) ([b81dc89](https://github.com/reactjs/react-transition-group/commit/b81dc89)) + +# [2.6.0](https://github.com/reactjs/react-transition-group/compare/v2.5.3...v2.6.0) (2019-02-26) + + +### Features + +* add appear timeout ([#462](https://github.com/reactjs/react-transition-group/issues/462)) ([52cdc34](https://github.com/reactjs/react-transition-group/commit/52cdc34)) + ## [2.5.3](https://github.com/reactjs/react-transition-group/compare/v2.5.2...v2.5.3) (2019-01-14) diff --git a/README.md b/README.md index 3a7fe082..566a11a6 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # react-transition-group [![npm][npm-badge]][npm] -> **ATTENTION!** To address many issues that have come up over the years, the API in v2 is not backwards compatible with the original [`React addon (v1-stable)`](https://github.com/reactjs/react-transition-group/tree/v1-stable). +> **ATTENTION!** To address many issues that have come up over the years, the API in v2 and above is not backwards compatible with the original [`React addon (v1-stable)`](https://github.com/reactjs/react-transition-group/tree/v1-stable). > > **For a drop-in replacement for `react-addons-transition-group` and `react-addons-css-transition-group`, use the v1 release. Documentation and code for that release are available on the [`v1-stable`](https://github.com/reactjs/react-transition-group/tree/v1-stable) branch.** > -> We are no longer updating the v1 codebase, please upgrade to v2 when possible +> We are no longer updating the v1 codebase, please upgrade to the latest version when possible A set of components for managing component states (including mounting and unmounting) over time, specifically designed with animation in mind. @@ -13,6 +13,13 @@ A set of components for managing component states (including mounting and unmoun - [**Main documentation**](https://reactcommunity.org/react-transition-group/) - [Migration guide from v1](/Migration.md) +## TypeScript +TypeScript definitions are published via [**DefinitelyTyped**](https://github.com/DefinitelyTyped/DefinitelyTyped) and can be installed via the following command: + +``` +npm install @types/react-transition-group +``` + ## Examples Clone the repo first: diff --git a/package.json b/package.json index 1ccb9652..72a435e0 100644 --- a/package.json +++ b/package.json @@ -1,24 +1,32 @@ { "name": "react-transition-group", - "version": "2.5.3", + "version": "4.4.5", "description": "A react component toolset for managing animations", - "main": "lib/index.js", + "main": "lib/cjs/index.js", + "module": "lib/esm/index.js", "scripts": { "test": "npm run lint && npm run testonly", "testonly": "jest --verbose", "tdd": "jest --watch", - "build": "babel src --out-dir lib --delete-dir-on-start && npm run build:dist && cp README.md LICENSE ./lib", - "build:docs": "npm -C www run build", - "build:dist": "cross-env BABEL_ENV=esm yarn rollup -c", + "build": "rimraf lib && yarn build:cjs && yarn build:esm && yarn build:pick && yarn build:dist && cp README.md LICENSE ./lib", + "build:docs": "yarn --cwd www run build", + "build:cjs": "babel src --out-dir lib/cjs", + "build:esm": "cross-env BABEL_ENV=esm babel src --out-dir lib/esm", + "build:pick": "cherry-pick --cwd=lib --input-dir=../src --cjs-dir=cjs --esm-dir=esm", + "build:dist": "cross-env BABEL_ENV=esm rollup -c", "bootstrap": "yarn && yarn --cwd www", - "lint": "eslint src test", + "fix": "run-s fix:eslint fix:prettier", + "fix:eslint": "yarn lint:eslint --fix", + "fix:prettier": "yarn lint:prettier --write", + "lint": "run-p lint:*", + "lint:eslint": "eslint .", + "lint:prettier": "prettier . --check", "release": "release", "release:next": "release --preid beta --tag next", - "deploy-docs": "npm -C www run deploy", - "start": "npm -C www run develop", + "deploy-docs": "yarn --cwd www run deploy", + "start": "yarn --cwd www run develop", "storybook": "start-storybook -p 6006", "build-storybook": "build-storybook", - "travis-deploy-once": "travis-deploy-once", "semantic-release": "semantic-release" }, "repository": { @@ -45,64 +53,67 @@ "setupFiles": [ "./test/setup.js" ], + "setupFilesAfterEnv": [ + "./test/setupAfterEnv.js" + ], "roots": [ "/test" ] }, "peerDependencies": { - "react": ">=15.0.0", - "react-dom": ">=15.0.0" + "react": ">=16.6.0", + "react-dom": ">=16.6.0" }, "dependencies": { - "dom-helpers": "^3.3.1", + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", - "prop-types": "^15.6.2", - "react-lifecycles-compat": "^3.0.4" + "prop-types": "^15.6.2" }, "devDependencies": { - "@babel/cli": "^7.1.0", - "@babel/core": "^7.1.0", - "@semantic-release/changelog": "^3.0.0", - "@semantic-release/git": "^7.0.4", - "@semantic-release/github": "^5.0.5", - "@semantic-release/npm": "^5.0.4", - "@storybook/addon-actions": "^4.1.4", - "@storybook/react": "^4.1.4", - "babel-core": "^7.0.0-bridge.0", - "babel-eslint": "^9.0.0", - "babel-jest": "^23.6.0", - "babel-loader": "^8.0.2", - "babel-plugin-transform-react-remove-prop-types": "^0.4.18", - "babel-preset-jason": "^6.0.1", - "cross-env": "^5.2.0", - "enzyme": "^3.6.0", - "enzyme-adapter-react-16": "^1.5.0", - "eslint": "^5.6.0", - "eslint-config-jason": "^4.1.0", - "eslint-config-prettier": "^3.1.0", - "eslint-plugin-import": "^2.14.0", - "eslint-plugin-jsx-a11y": "^6.1.1", - "eslint-plugin-prettier": "^2.6.2", - "eslint-plugin-react": "^7.11.1", - "husky": "^1.0.0-rc.15", - "jest": "^23.6.0", - "prettier": "^1.14.3", - "react": "^16.5.2", - "react-dom": "^16.5.2", - "react-test-renderer": "^16.5.2", + "@babel/cli": "^7.8.4", + "@babel/core": "^7.9.0", + "@restart/hooks": "^0.3.22", + "@semantic-release/changelog": "^5.0.1", + "@semantic-release/git": "^9.0.0", + "@semantic-release/github": "^7.0.5", + "@semantic-release/npm": "^7.0.5", + "@storybook/addon-actions": "^6.3.4", + "@storybook/react": "^6.3.4", + "@testing-library/react": "alpha", + "@typescript-eslint/eslint-plugin": "^4.26.1", + "astroturf": "^0.10.4", + "babel-eslint": "^10.1.0", + "babel-loader": "^8.1.0", + "babel-plugin-transform-react-remove-prop-types": "^0.4.24", + "babel-preset-jason": "^6.2.0", + "cherry-pick": "^0.5.0", + "cross-env": "^7.0.2", + "eslint": "^7.28.0", + "eslint-config-jason": "^8.1.1", + "eslint-config-prettier": "^8.3.0", + "eslint-plugin-import": "^2.23.4", + "eslint-plugin-jsx-a11y": "^6.4.1", + "eslint-plugin-react": "^7.24.0", + "eslint-plugin-react-hooks": "^4.2.0", + "jest": "^25.3.0", + "npm-run-all": "^4.1.5", + "prettier": "^2.3.1", + "react": "^18.0.0", + "react-dom": "^18.0.0", "release-script": "^1.0.2", - "rimraf": "^2.6.1", - "rollup": "^1.1.0", - "rollup-plugin-babel": "^4.3.0", - "rollup-plugin-commonjs": "^9.2.0", - "rollup-plugin-node-resolve": "^4.0.0", - "rollup-plugin-replace": "^2.1.0", - "rollup-plugin-size-snapshot": "^0.8.0", - "rollup-plugin-terser": "^4.0.2", - "semantic-release": "^15.9.16", - "semantic-release-alt-publish-dir": "^2.1.1", - "sinon": "^6.3.4", - "travis-deploy-once": "^5.0.8" + "rimraf": "^3.0.2", + "rollup": "^2.6.1", + "rollup-plugin-babel": "^4.4.0", + "rollup-plugin-commonjs": "^10.1.0", + "rollup-plugin-node-resolve": "^5.2.0", + "rollup-plugin-replace": "^2.2.0", + "rollup-plugin-size-snapshot": "^0.11.0", + "rollup-plugin-terser": "^5.3.0", + "semantic-release": "^17.0.6", + "semantic-release-alt-publish-dir": "^3.0.0", + "typescript": "^4.3.2", + "webpack-atoms": "14.0.0" }, "release": { "pkgRoot": "lib", @@ -123,5 +134,6 @@ "transform": [ "loose-envify" ] - } + }, + "sideEffects": false } diff --git a/prettier.config.js b/prettier.config.js new file mode 100644 index 00000000..e340799c --- /dev/null +++ b/prettier.config.js @@ -0,0 +1,3 @@ +module.exports = { + singleQuote: true, +}; diff --git a/rollup.config.js b/rollup.config.js index 09f2650c..5000c878 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -1,64 +1,64 @@ -import nodeResolve from "rollup-plugin-node-resolve"; -import babel from "rollup-plugin-babel"; -import commonjs from "rollup-plugin-commonjs"; -import replace from "rollup-plugin-replace"; -import { sizeSnapshot } from "rollup-plugin-size-snapshot"; -import { terser } from "rollup-plugin-terser"; +import nodeResolve from 'rollup-plugin-node-resolve'; +import babel from 'rollup-plugin-babel'; +import commonjs from 'rollup-plugin-commonjs'; +import replace from 'rollup-plugin-replace'; +import { sizeSnapshot } from 'rollup-plugin-size-snapshot'; +import { terser } from 'rollup-plugin-terser'; -const input = "./src/umd.js"; -const name = "ReactTransitionGroup"; +const input = './src/index.js'; +const name = 'ReactTransitionGroup'; const globals = { - react: "React", - "react-dom": "ReactDOM" + react: 'React', + 'react-dom': 'ReactDOM', }; const babelOptions = { exclude: /node_modules/, - runtimeHelpers: true -} + runtimeHelpers: true, +}; const commonjsOptions = { include: /node_modules/, namedExports: { - "prop-types": ["object", "oneOfType", "element", "bool", "func"] - } + 'prop-types': ['object', 'oneOfType', 'element', 'bool', 'func'], + }, }; export default [ { input, output: { - file: "./lib/dist/react-transition-group.js", - format: "umd", + file: './lib/dist/react-transition-group.js', + format: 'umd', name, - globals + globals, }, external: Object.keys(globals), plugins: [ nodeResolve(), babel(babelOptions), commonjs(commonjsOptions), - replace({ "process.env.NODE_ENV": JSON.stringify("development") }), - sizeSnapshot() - ] + replace({ 'process.env.NODE_ENV': JSON.stringify('development') }), + sizeSnapshot(), + ], }, { input, output: { - file: "./lib/dist/react-transition-group.min.js", - format: "umd", + file: './lib/dist/react-transition-group.min.js', + format: 'umd', name, - globals + globals, }, external: Object.keys(globals), plugins: [ nodeResolve(), babel(babelOptions), commonjs(commonjsOptions), - replace({ "process.env.NODE_ENV": JSON.stringify("production") }), + replace({ 'process.env.NODE_ENV': JSON.stringify('production') }), sizeSnapshot(), - terser() - ] - } + terser(), + ], + }, ]; diff --git a/src/CSSTransition.js b/src/CSSTransition.js index 0f5b4965..61592105 100644 --- a/src/CSSTransition.js +++ b/src/CSSTransition.js @@ -1,137 +1,232 @@ -import * as PropTypes from 'prop-types'; -import addOneClass from 'dom-helpers/class/addClass'; +import PropTypes from 'prop-types'; +import addOneClass from 'dom-helpers/addClass'; -import removeOneClass from 'dom-helpers/class/removeClass'; +import removeOneClass from 'dom-helpers/removeClass'; import React from 'react'; import Transition from './Transition'; import { classNamesShape } from './utils/PropTypes'; +import { forceReflow } from './utils/reflow'; -const addClass = (node, classes) => node && classes && classes.split(' ').forEach(c => addOneClass(node, c)); -const removeClass = (node, classes) => node && classes && classes.split(' ').forEach(c => removeOneClass(node, c)); +const addClass = (node, classes) => + node && classes && classes.split(' ').forEach((c) => addOneClass(node, c)); +const removeClass = (node, classes) => + node && classes && classes.split(' ').forEach((c) => removeOneClass(node, c)); /** - * A `Transition` component using CSS transitions and animations. - * It's inspired by the excellent [ng-animate](http://www.nganimate.org/) library. + * A transition component inspired by the excellent + * [ng-animate](https://docs.angularjs.org/api/ngAnimate) library, you should + * use it if you're using CSS transitions or animations. It's built upon the + * [`Transition`](https://reactcommunity.org/react-transition-group/transition) + * component, so it inherits all of its props. * * `CSSTransition` applies a pair of class names during the `appear`, `enter`, - * and `exit` stages of the transition. The first class is applied and then a - * second "active" class in order to activate the css animation. After the animation, - * matching `done` class names are applied to persist the animation state. + * and `exit` states of the transition. The first class is applied and then a + * second `*-active` class in order to activate the CSS transition. After the + * transition, matching `*-done` class names are applied to persist the + * transition state. * - * When the `in` prop is toggled to `true` the Component will get - * the `example-enter` CSS class and the `example-enter-active` CSS class - * added in the next tick. This is a convention based on the `classNames` prop. + * ```jsx + * function App() { + * const [inProp, setInProp] = useState(false); + * const nodeRef = useRef(null); + * return ( + *
+ * + *
+ * {"I'll receive my-node-* classes"} + *
+ *
+ * + *
+ * ); + * } + * ``` + * + * When the `in` prop is set to `true`, the child component will first receive + * the class `example-enter`, then the `example-enter-active` will be added in + * the next tick. `CSSTransition` [forces a + * reflow](https://github.com/reactjs/react-transition-group/blob/5007303e729a74be66a21c3e2205e4916821524b/src/CSSTransition.js#L208-L215) + * between before adding the `example-enter-active`. This is an important trick + * because it allows us to transition between `example-enter` and + * `example-enter-active` even though they were added immediately one after + * another. Most notably, this is what makes it possible for us to animate + * _appearance_. + * + * ```css + * .my-node-enter { + * opacity: 0; + * } + * .my-node-enter-active { + * opacity: 1; + * transition: opacity 200ms; + * } + * .my-node-exit { + * opacity: 1; + * } + * .my-node-exit-active { + * opacity: 0; + * transition: opacity 200ms; + * } + * ``` + * + * `*-active` classes represent which styles you want to animate **to**, so it's + * important to add `transition` declaration only to them, otherwise transitions + * might not behave as intended! This might not be obvious when the transitions + * are symmetrical, i.e. when `*-enter-active` is the same as `*-exit`, like in + * the example above (minus `transition`), but it becomes apparent in more + * complex transitions. + * + * **Note**: If you're using the + * [`appear`](http://reactcommunity.org/react-transition-group/transition#Transition-prop-appear) + * prop, make sure to define styles for `.appear-*` classes as well. */ class CSSTransition extends React.Component { - onEnter = (node, appearing) => { - const { className } = this.getClassNames(appearing ? 'appear' : 'enter') - + static defaultProps = { + classNames: '', + }; + + appliedClasses = { + appear: {}, + enter: {}, + exit: {}, + }; + + onEnter = (maybeNode, maybeAppearing) => { + const [node, appearing] = this.resolveArguments(maybeNode, maybeAppearing); this.removeClasses(node, 'exit'); - addClass(node, className) + this.addClass(node, appearing ? 'appear' : 'enter', 'base'); if (this.props.onEnter) { - this.props.onEnter(node, appearing) + this.props.onEnter(maybeNode, maybeAppearing); } - } + }; - onEntering = (node, appearing) => { - const { activeClassName } = this.getClassNames( - appearing ? 'appear' : 'enter' - ); - - this.reflowAndAddClass(node, activeClassName) + onEntering = (maybeNode, maybeAppearing) => { + const [node, appearing] = this.resolveArguments(maybeNode, maybeAppearing); + const type = appearing ? 'appear' : 'enter'; + this.addClass(node, type, 'active'); if (this.props.onEntering) { - this.props.onEntering(node, appearing) + this.props.onEntering(maybeNode, maybeAppearing); } - } + }; - onEntered = (node, appearing) => { - const { doneClassName } = this.getClassNames('enter'); - - this.removeClasses(node, appearing ? 'appear' : 'enter'); - addClass(node, doneClassName); + onEntered = (maybeNode, maybeAppearing) => { + const [node, appearing] = this.resolveArguments(maybeNode, maybeAppearing); + const type = appearing ? 'appear' : 'enter'; + this.removeClasses(node, type); + this.addClass(node, type, 'done'); if (this.props.onEntered) { - this.props.onEntered(node, appearing) + this.props.onEntered(maybeNode, maybeAppearing); } - } - - onExit = (node) => { - const { className } = this.getClassNames('exit') + }; + onExit = (maybeNode) => { + const [node] = this.resolveArguments(maybeNode); this.removeClasses(node, 'appear'); this.removeClasses(node, 'enter'); - addClass(node, className) + this.addClass(node, 'exit', 'base'); if (this.props.onExit) { - this.props.onExit(node) + this.props.onExit(maybeNode); } - } - - onExiting = (node) => { - const { activeClassName } = this.getClassNames('exit') + }; - this.reflowAndAddClass(node, activeClassName) + onExiting = (maybeNode) => { + const [node] = this.resolveArguments(maybeNode); + this.addClass(node, 'exit', 'active'); if (this.props.onExiting) { - this.props.onExiting(node) + this.props.onExiting(maybeNode); } - } - - onExited = (node) => { - const { doneClassName } = this.getClassNames('exit'); + }; + onExited = (maybeNode) => { + const [node] = this.resolveArguments(maybeNode); this.removeClasses(node, 'exit'); - addClass(node, doneClassName); + this.addClass(node, 'exit', 'done'); if (this.props.onExited) { - this.props.onExited(node) + this.props.onExited(maybeNode); } - } + }; + + // when prop `nodeRef` is provided `node` is excluded + resolveArguments = (maybeNode, maybeAppearing) => + this.props.nodeRef + ? [this.props.nodeRef.current, maybeNode] // here `maybeNode` is actually `appearing` + : [maybeNode, maybeAppearing]; // `findDOMNode` was used getClassNames = (type) => { const { classNames } = this.props; + const isStringClassNames = typeof classNames === 'string'; + const prefix = isStringClassNames && classNames ? `${classNames}-` : ''; - let className = typeof classNames !== 'string' ? - classNames[type] : classNames + '-' + type; + let baseClassName = isStringClassNames + ? `${prefix}${type}` + : classNames[type]; - let activeClassName = typeof classNames !== 'string' ? - classNames[type + 'Active'] : className + '-active'; + let activeClassName = isStringClassNames + ? `${baseClassName}-active` + : classNames[`${type}Active`]; - let doneClassName = typeof classNames !== 'string' ? - classNames[type + 'Done'] : className + '-done'; + let doneClassName = isStringClassNames + ? `${baseClassName}-done` + : classNames[`${type}Done`]; return { - className, + baseClassName, activeClassName, - doneClassName + doneClassName, }; - } + }; - removeClasses(node, type) { - const { className, activeClassName, doneClassName } = this.getClassNames(type) - className && removeClass(node, className); - activeClassName && removeClass(node, activeClassName); - doneClassName && removeClass(node, doneClassName); - } + addClass(node, type, phase) { + let className = this.getClassNames(type)[`${phase}ClassName`]; + const { doneClassName } = this.getClassNames('enter'); + + if (type === 'appear' && phase === 'done' && doneClassName) { + className += ` ${doneClassName}`; + } - reflowAndAddClass(node, className) { - // This is for to force a repaint, + // This is to force a repaint, // which is necessary in order to transition styles when adding a class name. + if (phase === 'active') { + if (node) forceReflow(node); + } + if (className) { - /* eslint-disable no-unused-expressions */ - node && node.scrollTop; - /* eslint-enable no-unused-expressions */ + this.appliedClasses[type][phase] = className; addClass(node, className); } } - render() { - const props = { ...this.props }; + removeClasses(node, type) { + const { + base: baseClassName, + active: activeClassName, + done: doneClassName, + } = this.appliedClasses[type]; + + this.appliedClasses[type] = {}; - delete props.classNames; + if (baseClassName) { + removeClass(node, baseClassName); + } + if (activeClassName) { + removeClass(node, activeClassName); + } + if (doneClassName) { + removeClass(node, doneClassName); + } + } + + render() { + const { classNames: _, ...props } = this.props; return ( ` callback fired immediately after the 'enter' or 'appear' class is * applied. * + * **Note**: when `nodeRef` prop is passed, `node` is not passed, so `isAppearing` is being passed as the first argument. + * * @type Function(node: HtmlElement, isAppearing: bool) */ onEnter: PropTypes.func, @@ -209,6 +329,8 @@ CSSTransition.propTypes = { * A `` callback fired immediately after the 'enter-active' or * 'appear-active' class is applied. * + * **Note**: when `nodeRef` prop is passed, `node` is not passed, so `isAppearing` is being passed as the first argument. + * * @type Function(node: HtmlElement, isAppearing: bool) */ onEntering: PropTypes.func, @@ -217,15 +339,18 @@ CSSTransition.propTypes = { * A `` callback fired immediately after the 'enter' or * 'appear' classes are **removed** and the `done` class is added to the DOM node. * + * **Note**: when `nodeRef` prop is passed, `node` is not passed, so `isAppearing` is being passed as the first argument. + * * @type Function(node: HtmlElement, isAppearing: bool) */ onEntered: PropTypes.func, - /** * A `` callback fired immediately after the 'exit' class is * applied. * + * **Note**: when `nodeRef` prop is passed, `node` is not passed + * * @type Function(node: HtmlElement) */ onExit: PropTypes.func, @@ -233,6 +358,8 @@ CSSTransition.propTypes = { /** * A `` callback fired immediately after the 'exit-active' is applied. * + * **Note**: when `nodeRef` prop is passed, `node` is not passed + * * @type Function(node: HtmlElement) */ onExiting: PropTypes.func, @@ -241,9 +368,11 @@ CSSTransition.propTypes = { * A `` callback fired immediately after the 'exit' classes * are **removed** and the `exit-done` class is added to the DOM node. * + * **Note**: when `nodeRef` prop is passed, `node` is not passed + * * @type Function(node: HtmlElement) */ onExited: PropTypes.func, }; -export default CSSTransition +export default CSSTransition; diff --git a/src/ReplaceTransition.js b/src/ReplaceTransition.js index c1081c59..7c0b9092 100644 --- a/src/ReplaceTransition.js +++ b/src/ReplaceTransition.js @@ -1,6 +1,6 @@ import PropTypes from 'prop-types'; import React from 'react'; -import { findDOMNode } from 'react-dom' +import ReactDOM from 'react-dom'; import TransitionGroup from './TransitionGroup'; /** @@ -15,28 +15,30 @@ import TransitionGroup from './TransitionGroup'; * ``` */ class ReplaceTransition extends React.Component { - handleEnter = (...args) => this.handleLifecycle('onEnter', 0, args) - handleEntering = (...args) => this.handleLifecycle('onEntering', 0, args) - handleEntered = (...args) => this.handleLifecycle('onEntered', 0, args) + handleEnter = (...args) => this.handleLifecycle('onEnter', 0, args); + handleEntering = (...args) => this.handleLifecycle('onEntering', 0, args); + handleEntered = (...args) => this.handleLifecycle('onEntered', 0, args); - handleExit = (...args) => this.handleLifecycle('onExit', 1, args) - handleExiting = (...args) => this.handleLifecycle('onExiting', 1, args) - handleExited = (...args) => this.handleLifecycle('onExited', 1, args) + handleExit = (...args) => this.handleLifecycle('onExit', 1, args); + handleExiting = (...args) => this.handleLifecycle('onExiting', 1, args); + handleExited = (...args) => this.handleLifecycle('onExited', 1, args); handleLifecycle(handler, idx, originalArgs) { const { children } = this.props; const child = React.Children.toArray(children)[idx]; - if (child.props[handler]) child.props[handler](...originalArgs) - if (this.props[handler]) this.props[handler](findDOMNode(this)) + if (child.props[handler]) child.props[handler](...originalArgs); + if (this.props[handler]) { + const maybeNode = child.props.nodeRef + ? undefined + : ReactDOM.findDOMNode(this); + + this.props[handler](maybeNode); + } } render() { - const { - children, - in: inProp, - ...props - } = this.props; + const { children, in: inProp, ...props } = this.props; const [first, second] = React.Children.toArray(children); delete props.onEnter; @@ -48,21 +50,19 @@ class ReplaceTransition extends React.Component { return ( - {inProp ? - React.cloneElement(first, { - key: 'first', - onEnter: this.handleEnter, - onEntering: this.handleEntering, - onEntered: this.handleEntered, - - }) : - React.cloneElement(second, { - key: 'second', - onEnter: this.handleExit, - onEntering: this.handleExiting, - onEntered: this.handleExited, - }) - } + {inProp + ? React.cloneElement(first, { + key: 'first', + onEnter: this.handleEnter, + onEntering: this.handleEntering, + onEntered: this.handleEntered, + }) + : React.cloneElement(second, { + key: 'second', + onEnter: this.handleExit, + onEntering: this.handleExiting, + onEntered: this.handleExited, + })} ); } @@ -72,7 +72,9 @@ ReplaceTransition.propTypes = { in: PropTypes.bool.isRequired, children(props, propName) { if (React.Children.count(props[propName]) !== 2) - return new Error(`"${propName}" must be exactly two transition components.`) + return new Error( + `"${propName}" must be exactly two transition components.` + ); return null; }, diff --git a/src/SwitchTransition.js b/src/SwitchTransition.js new file mode 100644 index 00000000..78dc375d --- /dev/null +++ b/src/SwitchTransition.js @@ -0,0 +1,222 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { ENTERED, ENTERING, EXITING } from './Transition'; +import TransitionGroupContext from './TransitionGroupContext'; + +function areChildrenDifferent(oldChildren, newChildren) { + if (oldChildren === newChildren) return false; + if ( + React.isValidElement(oldChildren) && + React.isValidElement(newChildren) && + oldChildren.key != null && + oldChildren.key === newChildren.key + ) { + return false; + } + return true; +} + +/** + * Enum of modes for SwitchTransition component + * @enum { string } + */ +export const modes = { + out: 'out-in', + in: 'in-out', +}; + +const callHook = + (element, name, cb) => + (...args) => { + element.props[name] && element.props[name](...args); + cb(); + }; + +const leaveRenders = { + [modes.out]: ({ current, changeState }) => + React.cloneElement(current, { + in: false, + onExited: callHook(current, 'onExited', () => { + changeState(ENTERING, null); + }), + }), + [modes.in]: ({ current, changeState, children }) => [ + current, + React.cloneElement(children, { + in: true, + onEntered: callHook(children, 'onEntered', () => { + changeState(ENTERING); + }), + }), + ], +}; + +const enterRenders = { + [modes.out]: ({ children, changeState }) => + React.cloneElement(children, { + in: true, + onEntered: callHook(children, 'onEntered', () => { + changeState(ENTERED, React.cloneElement(children, { in: true })); + }), + }), + [modes.in]: ({ current, children, changeState }) => [ + React.cloneElement(current, { + in: false, + onExited: callHook(current, 'onExited', () => { + changeState(ENTERED, React.cloneElement(children, { in: true })); + }), + }), + React.cloneElement(children, { + in: true, + }), + ], +}; + +/** + * A transition component inspired by the [vue transition modes](https://vuejs.org/v2/guide/transitions.html#Transition-Modes). + * You can use it when you want to control the render between state transitions. + * Based on the selected mode and the child's key which is the `Transition` or `CSSTransition` component, the `SwitchTransition` makes a consistent transition between them. + * + * If the `out-in` mode is selected, the `SwitchTransition` waits until the old child leaves and then inserts a new child. + * If the `in-out` mode is selected, the `SwitchTransition` inserts a new child first, waits for the new child to enter and then removes the old child. + * + * **Note**: If you want the animation to happen simultaneously + * (that is, to have the old child removed and a new child inserted **at the same time**), + * you should use + * [`TransitionGroup`](https://reactcommunity.org/react-transition-group/transition-group) + * instead. + * + * ```jsx + * function App() { + * const [state, setState] = useState(false); + * const helloRef = useRef(null); + * const goodbyeRef = useRef(null); + * const nodeRef = state ? goodbyeRef : helloRef; + * return ( + * + * node.addEventListener("transitionend", done, false)} + * classNames='fade' + * > + * + * + * + * ); + * } + * ``` + * + * ```css + * .fade-enter{ + * opacity: 0; + * } + * .fade-exit{ + * opacity: 1; + * } + * .fade-enter-active{ + * opacity: 1; + * } + * .fade-exit-active{ + * opacity: 0; + * } + * .fade-enter-active, + * .fade-exit-active{ + * transition: opacity 500ms; + * } + * ``` + */ +class SwitchTransition extends React.Component { + state = { + status: ENTERED, + current: null, + }; + + appeared = false; + + componentDidMount() { + this.appeared = true; + } + + static getDerivedStateFromProps(props, state) { + if (props.children == null) { + return { + current: null, + }; + } + + if (state.status === ENTERING && props.mode === modes.in) { + return { + status: ENTERING, + }; + } + + if (state.current && areChildrenDifferent(state.current, props.children)) { + return { + status: EXITING, + }; + } + + return { + current: React.cloneElement(props.children, { + in: true, + }), + }; + } + + changeState = (status, current = this.state.current) => { + this.setState({ + status, + current, + }); + }; + + render() { + const { + props: { children, mode }, + state: { status, current }, + } = this; + + const data = { children, current, changeState: this.changeState, status }; + let component; + switch (status) { + case ENTERING: + component = enterRenders[mode](data); + break; + case EXITING: + component = leaveRenders[mode](data); + break; + case ENTERED: + component = current; + } + + return ( + + {component} + + ); + } +} + +SwitchTransition.propTypes = { + /** + * Transition modes. + * `out-in`: Current element transitions out first, then when complete, the new element transitions in. + * `in-out`: New element transitions in first, then when complete, the current element transitions out. + * + * @type {'out-in'|'in-out'} + */ + mode: PropTypes.oneOf([modes.in, modes.out]), + /** + * Any `Transition` or `CSSTransition` component. + */ + children: PropTypes.oneOfType([PropTypes.element.isRequired]), +}; + +SwitchTransition.defaultProps = { + mode: modes.out, +}; + +export default SwitchTransition; diff --git a/src/Transition.js b/src/Transition.js index b7669646..41cd1f38 100644 --- a/src/Transition.js +++ b/src/Transition.js @@ -1,15 +1,17 @@ -import * as PropTypes from 'prop-types' -import React from 'react' -import ReactDOM from 'react-dom' -import { polyfill } from 'react-lifecycles-compat' +import PropTypes from 'prop-types'; +import React from 'react'; +import ReactDOM from 'react-dom'; -import { timeoutsShape } from './utils/PropTypes' +import config from './config'; +import { timeoutsShape } from './utils/PropTypes'; +import TransitionGroupContext from './TransitionGroupContext'; +import { forceReflow } from './utils/reflow'; -export const UNMOUNTED = 'unmounted' -export const EXITED = 'exited' -export const ENTERING = 'entering' -export const ENTERED = 'entered' -export const EXITING = 'exiting' +export const UNMOUNTED = 'unmounted'; +export const EXITED = 'exited'; +export const ENTERING = 'entering'; +export const ENTERED = 'entered'; +export const EXITING = 'exiting'; /** * The Transition component lets you describe a transition from one component @@ -17,13 +19,25 @@ export const EXITING = 'exiting' * it's used to animate the mounting and unmounting of a component, but can also * be used to describe in-place transition states as well. * + * --- + * + * **Note**: `Transition` is a platform-agnostic base component. If you're using + * transitions in CSS, you'll probably want to use + * [`CSSTransition`](https://reactcommunity.org/react-transition-group/css-transition) + * instead. It inherits all the features of `Transition`, but contains + * additional features necessary to play nice with CSS transitions (hence the + * name of the component). + * + * --- + * * By default the `Transition` component does not alter the behavior of the - * component it renders, it only tracks "enter" and "exit" states for the components. - * It's up to you to give meaning and effect to those states. For example we can - * add styles to a component when it enters or exits: + * component it renders, it only tracks "enter" and "exit" states for the + * components. It's up to you to give meaning and effect to those states. For + * example we can add styles to a component when it enters or exits: * * ```jsx - * import Transition from 'react-transition-group/Transition'; + * import { Transition } from 'react-transition-group'; + * import { useRef } from 'react'; * * const duration = 300; * @@ -33,128 +47,111 @@ export const EXITING = 'exiting' * } * * const transitionStyles = { - * entering: { opacity: 0 }, + * entering: { opacity: 1 }, * entered: { opacity: 1 }, + * exiting: { opacity: 0 }, + * exited: { opacity: 0 }, * }; * - * const Fade = ({ in: inProp }) => ( - * - * {(state) => ( - *
- * I'm a fade Transition! - *
- * )} - *
- * ); + * function Fade({ in: inProp }) { + * const nodeRef = useRef(null); + * return ( + * + * {state => ( + *
+ * I'm a fade Transition! + *
+ * )} + *
+ * ); + * } * ``` * - * As noted the `Transition` component doesn't _do_ anything by itself to its child component. - * What it does do is track transition states over time so you can update the - * component (such as by adding styles or classes) when it changes states. - * * There are 4 main states a Transition can be in: * - `'entering'` * - `'entered'` * - `'exiting'` * - `'exited'` * - * Transition state is toggled via the `in` prop. When `true` the component begins the - * "Enter" stage. During this stage, the component will shift from its current transition state, - * to `'entering'` for the duration of the transition and then to the `'entered'` stage once - * it's complete. Let's take the following example: + * Transition state is toggled via the `in` prop. When `true` the component + * begins the "Enter" stage. During this stage, the component will shift from + * its current transition state, to `'entering'` for the duration of the + * transition and then to the `'entered'` stage once it's complete. Let's take + * the following example (we'll use the + * [useState](https://reactjs.org/docs/hooks-reference.html#usestate) hook): * * ```jsx - * state = { in: false }; - * - * toggleEnterState = () => { - * this.setState({ in: true }); - * } + * import { Transition } from 'react-transition-group'; + * import { useState, useRef } from 'react'; * - * render() { + * function App() { + * const [inProp, setInProp] = useState(false); + * const nodeRef = useRef(null); * return ( *
- * - * + * + * {state => ( + * // ... + * )} + * + * *
* ); * } * ``` * - * When the button is clicked the component will shift to the `'entering'` state and - * stay there for 500ms (the value of `timeout`) before it finally switches to `'entered'`. - * - * When `in` is `false` the same thing happens except the state moves from `'exiting'` to `'exited'`. - * - * ## Timing - * - * Timing is often the trickiest part of animation, mistakes can result in slight delays - * that are hard to pin down. A common example is when you want to add an exit transition, - * you should set the desired final styles when the state is `'exiting'`. That's when the - * transition to those styles will start and, if you matched the `timeout` prop with the - * CSS Transition duration, it will end exactly when the state changes to `'exited'`. + * When the button is clicked the component will shift to the `'entering'` state + * and stay there for 500ms (the value of `timeout`) before it finally switches + * to `'entered'`. * - * > **Note**: For simpler transitions the `Transition` component might be enough, but - * > take into account that it's platform-agnostic, while the `CSSTransition` component - * > [forces reflows](https://github.com/reactjs/react-transition-group/blob/5007303e729a74be66a21c3e2205e4916821524b/src/CSSTransition.js#L208-L215) - * > in order to make more complex transitions more predictable. For example, even though - * > classes `example-enter` and `example-enter-active` are applied immediately one after - * > another, you can still transition from one to the other because of the forced reflow - * > (read [this issue](https://github.com/reactjs/react-transition-group/issues/159#issuecomment-322761171) - * > for more info). Take this into account when choosing between `Transition` and - * > `CSSTransition`. + * When `in` is `false` the same thing happens except the state moves from + * `'exiting'` to `'exited'`. */ class Transition extends React.Component { - static contextTypes = { - transitionGroup: PropTypes.object, - } - static childContextTypes = { - transitionGroup: () => {}, - } + static contextType = TransitionGroupContext; constructor(props, context) { - super(props, context) + super(props, context); - let parentGroup = context.transitionGroup + let parentGroup = context; // In the context of a TransitionGroup all enters are really appears let appear = - parentGroup && !parentGroup.isMounting ? props.enter : props.appear + parentGroup && !parentGroup.isMounting ? props.enter : props.appear; - let initialStatus + let initialStatus; - this.appearStatus = null + this.appearStatus = null; if (props.in) { if (appear) { - initialStatus = EXITED - this.appearStatus = ENTERING + initialStatus = EXITED; + this.appearStatus = ENTERING; } else { - initialStatus = ENTERED + initialStatus = ENTERED; } } else { if (props.unmountOnExit || props.mountOnEnter) { - initialStatus = UNMOUNTED + initialStatus = UNMOUNTED; } else { - initialStatus = EXITED + initialStatus = EXITED; } } - this.state = { status: initialStatus } - - this.nextCallback = null - } + this.state = { status: initialStatus }; - getChildContext() { - return { transitionGroup: null } // allows for nested Transitions + this.nextCallback = null; } static getDerivedStateFromProps({ in: nextIn }, prevState) { if (nextIn && prevState.status === UNMOUNTED) { - return { status: EXITED } + return { status: EXITED }; } - return null + return null; } // getSnapshotBeforeUpdate(prevProps) { @@ -178,120 +175,133 @@ class Transition extends React.Component { // } componentDidMount() { - this.updateStatus(true, this.appearStatus) + this.updateStatus(true, this.appearStatus); } componentDidUpdate(prevProps) { - let nextStatus = null + let nextStatus = null; if (prevProps !== this.props) { - const { status } = this.state + const { status } = this.state; if (this.props.in) { if (status !== ENTERING && status !== ENTERED) { - nextStatus = ENTERING + nextStatus = ENTERING; } } else { if (status === ENTERING || status === ENTERED) { - nextStatus = EXITING + nextStatus = EXITING; } } } - this.updateStatus(false, nextStatus) + this.updateStatus(false, nextStatus); } componentWillUnmount() { - this.cancelNextCallback() + this.cancelNextCallback(); } getTimeouts() { - const { timeout } = this.props - let exit, enter, appear + const { timeout } = this.props; + let exit, enter, appear; - exit = enter = appear = timeout + exit = enter = appear = timeout; if (timeout != null && typeof timeout !== 'number') { - exit = timeout.exit - enter = timeout.enter - appear = timeout.appear + exit = timeout.exit; + enter = timeout.enter; + // TODO: remove fallback for next major + appear = timeout.appear !== undefined ? timeout.appear : enter; } - return { exit, enter, appear } + return { exit, enter, appear }; } updateStatus(mounting = false, nextStatus) { if (nextStatus !== null) { // nextStatus will always be ENTERING or EXITING. - this.cancelNextCallback() - const node = ReactDOM.findDOMNode(this) + this.cancelNextCallback(); if (nextStatus === ENTERING) { - this.performEnter(node, mounting) + if (this.props.unmountOnExit || this.props.mountOnEnter) { + const node = this.props.nodeRef + ? this.props.nodeRef.current + : ReactDOM.findDOMNode(this); + // https://github.com/reactjs/react-transition-group/pull/749 + // With unmountOnExit or mountOnEnter, the enter animation should happen at the transition between `exited` and `entering`. + // To make the animation happen, we have to separate each rendering and avoid being processed as batched. + if (node) forceReflow(node); + } + this.performEnter(mounting); } else { - this.performExit(node) + this.performExit(); } } else if (this.props.unmountOnExit && this.state.status === EXITED) { - this.setState({ status: UNMOUNTED }) + this.setState({ status: UNMOUNTED }); } } - performEnter(node, mounting) { - const { enter } = this.props - const appearing = this.context.transitionGroup - ? this.context.transitionGroup.isMounting - : mounting - - const timeouts = this.getTimeouts() + performEnter(mounting) { + const { enter } = this.props; + const appearing = this.context ? this.context.isMounting : mounting; + const [maybeNode, maybeAppearing] = this.props.nodeRef + ? [appearing] + : [ReactDOM.findDOMNode(this), appearing]; + const timeouts = this.getTimeouts(); + const enterTimeout = appearing ? timeouts.appear : timeouts.enter; // no enter animation skip right to ENTERED // if we are mounting and running this it means appear _must_ be set - if (!mounting && !enter) { + if ((!mounting && !enter) || config.disabled) { this.safeSetState({ status: ENTERED }, () => { - this.props.onEntered(node) - }) - return + this.props.onEntered(maybeNode); + }); + return; } - this.props.onEnter(node, appearing) + this.props.onEnter(maybeNode, maybeAppearing); this.safeSetState({ status: ENTERING }, () => { - this.props.onEntering(node, appearing) + this.props.onEntering(maybeNode, maybeAppearing); - // FIXME: appear timeout? - this.onTransitionEnd(node, timeouts.enter, () => { + this.onTransitionEnd(enterTimeout, () => { this.safeSetState({ status: ENTERED }, () => { - this.props.onEntered(node, appearing) - }) - }) - }) + this.props.onEntered(maybeNode, maybeAppearing); + }); + }); + }); } - performExit(node) { - const { exit } = this.props - const timeouts = this.getTimeouts() + performExit() { + const { exit } = this.props; + const timeouts = this.getTimeouts(); + const maybeNode = this.props.nodeRef + ? undefined + : ReactDOM.findDOMNode(this); // no exit animation skip right to EXITED - if (!exit) { + if (!exit || config.disabled) { this.safeSetState({ status: EXITED }, () => { - this.props.onExited(node) - }) - return + this.props.onExited(maybeNode); + }); + return; } - this.props.onExit(node) + + this.props.onExit(maybeNode); this.safeSetState({ status: EXITING }, () => { - this.props.onExiting(node) + this.props.onExiting(maybeNode); - this.onTransitionEnd(node, timeouts.exit, () => { + this.onTransitionEnd(timeouts.exit, () => { this.safeSetState({ status: EXITED }, () => { - this.props.onExited(node) - }) - }) - }) + this.props.onExited(maybeNode); + }); + }); + }); } cancelNextCallback() { if (this.nextCallback !== null) { - this.nextCallback.cancel() - this.nextCallback = null + this.nextCallback.cancel(); + this.nextCallback = null; } } @@ -299,87 +309,133 @@ class Transition extends React.Component { // This shouldn't be necessary, but there are weird race conditions with // setState callbacks and unmounting in testing, so always make sure that // we can cancel any pending setState callbacks after we unmount. - callback = this.setNextCallback(callback) - this.setState(nextState, callback) + callback = this.setNextCallback(callback); + this.setState(nextState, callback); } setNextCallback(callback) { - let active = true + let active = true; - this.nextCallback = event => { + this.nextCallback = (event) => { if (active) { - active = false - this.nextCallback = null + active = false; + this.nextCallback = null; - callback(event) + callback(event); } - } + }; this.nextCallback.cancel = () => { - active = false - } + active = false; + }; - return this.nextCallback + return this.nextCallback; } - onTransitionEnd(node, timeout, handler) { - this.setNextCallback(handler) + onTransitionEnd(timeout, handler) { + this.setNextCallback(handler); + const node = this.props.nodeRef + ? this.props.nodeRef.current + : ReactDOM.findDOMNode(this); + + const doesNotHaveTimeoutOrListener = + timeout == null && !this.props.addEndListener; + if (!node || doesNotHaveTimeoutOrListener) { + setTimeout(this.nextCallback, 0); + return; + } - if (node) { - if (this.props.addEndListener) { - this.props.addEndListener(node, this.nextCallback) - } - if (timeout != null) { - setTimeout(this.nextCallback, timeout) - } - } else { - setTimeout(this.nextCallback, 0) + if (this.props.addEndListener) { + const [maybeNode, maybeNextCallback] = this.props.nodeRef + ? [this.nextCallback] + : [node, this.nextCallback]; + this.props.addEndListener(maybeNode, maybeNextCallback); + } + + if (timeout != null) { + setTimeout(this.nextCallback, timeout); } } render() { - const status = this.state.status - if (status === UNMOUNTED) { - return null - } + const status = this.state.status; - const { children, ...childProps } = this.props - // filter props for Transtition - delete childProps.in - delete childProps.mountOnEnter - delete childProps.unmountOnExit - delete childProps.appear - delete childProps.enter - delete childProps.exit - delete childProps.timeout - delete childProps.addEndListener - delete childProps.onEnter - delete childProps.onEntering - delete childProps.onEntered - delete childProps.onExit - delete childProps.onExiting - delete childProps.onExited - - if (typeof children === 'function') { - return children(status, childProps) + if (status === UNMOUNTED) { + return null; } - const child = React.Children.only(children) - return React.cloneElement(child, childProps) + const { + children, + // filter props for `Transition` + in: _in, + mountOnEnter: _mountOnEnter, + unmountOnExit: _unmountOnExit, + appear: _appear, + enter: _enter, + exit: _exit, + timeout: _timeout, + addEndListener: _addEndListener, + onEnter: _onEnter, + onEntering: _onEntering, + onEntered: _onEntered, + onExit: _onExit, + onExiting: _onExiting, + onExited: _onExited, + nodeRef: _nodeRef, + ...childProps + } = this.props; + + return ( + // allows for nested Transitions + + {typeof children === 'function' + ? children(status, childProps) + : React.cloneElement(React.Children.only(children), childProps)} + + ); } } Transition.propTypes = { /** - * A `function` child can be used instead of a React element. - * This function is called with the current transition status - * ('entering', 'entered', 'exiting', 'exited', 'unmounted'), which can be used - * to apply context specific props to a component. + * A React reference to the DOM element that needs to transition: + * https://stackoverflow.com/a/51127130/4671932 + * + * - This prop is optional, but recommended in order to avoid defaulting to + * [`ReactDOM.findDOMNode`](https://reactjs.org/docs/react-dom.html#finddomnode), + * which is deprecated in `StrictMode` + * - When `nodeRef` prop is used, `node` is not passed to callback functions + * (e.g. `onEnter`) because user already has direct access to the node. + * - When changing `key` prop of `Transition` in a `TransitionGroup` a new + * `nodeRef` need to be provided to `Transition` with changed `key` prop + * (see + * [test/CSSTransition-test.js](https://github.com/reactjs/react-transition-group/blob/13435f897b3ab71f6e19d724f145596f5910581c/test/CSSTransition-test.js#L362-L437)). + */ + nodeRef: PropTypes.shape({ + current: + typeof Element === 'undefined' + ? PropTypes.any + : (propValue, key, componentName, location, propFullName, secret) => { + const value = propValue[key]; + + return PropTypes.instanceOf( + value && 'ownerDocument' in value + ? value.ownerDocument.defaultView.Element + : Element + )(propValue, key, componentName, location, propFullName, secret); + }, + }), + + /** + * A `function` child can be used instead of a React element. This function is + * called with the current transition status (`'entering'`, `'entered'`, + * `'exiting'`, `'exited'`), which can be used to apply context + * specific props to a component. * * ```jsx - * - * {(status) => ( - * + * + * {state => ( + * * )} * * ``` @@ -409,11 +465,15 @@ Transition.propTypes = { unmountOnExit: PropTypes.bool, /** - * Normally a component is not transitioned if it is shown when the `` component mounts. - * If you want to transition on the first mount set `appear` to `true`, and the - * component will transition in as soon as the `` mounts. + * By default the child component does not perform the enter transition when + * it first mounts, regardless of the value of `in`. If you want this + * behavior, set both `appear` and `in` to `true`. * - * > Note: there are no specific "appear" states. `appear` only adds an additional `enter` transition. + * > **Note**: there are no special appear states like `appearing`/`appeared`, this prop + * > only adds an additional enter transition. However, in the + * > `` component that first enter transition does result in + * > additional `.appear-*` classes, that way you can choose to style it + * > differently. */ appear: PropTypes.bool, @@ -429,30 +489,42 @@ Transition.propTypes = { /** * The duration of the transition, in milliseconds. - * Required unless `addEndListener` is provided + * Required unless `addEndListener` is provided. + * + * You may specify a single timeout for all transitions: + * + * ```jsx + * timeout={500} + * ``` * - * You may specify a single timeout for all transitions like: `timeout={500}`, - * or individually like: + * or individually: * * ```jsx * timeout={{ + * appear: 500, * enter: 300, * exit: 500, * }} * ``` * - * @type {number | { enter?: number, exit?: number }} + * - `appear` defaults to the value of `enter` + * - `enter` defaults to `0` + * - `exit` defaults to `0` + * + * @type {number | { enter?: number, exit?: number, appear?: number }} */ timeout: (props, ...args) => { - let pt = timeoutsShape - if (!props.addEndListener) pt = pt.isRequired - return pt(props, ...args) + let pt = timeoutsShape; + if (!props.addEndListener) pt = pt.isRequired; + return pt(props, ...args); }, /** * Add a custom transition end trigger. Called with the transitioning * DOM node and a `done` callback. Allows for more fine grained transition end - * logic. **Note:** Timeouts are still used as a fallback if provided. + * logic. Timeouts are still used as a fallback if provided. + * + * **Note**: when `nodeRef` prop is passed, `node` is not passed, so `done` is being passed as the first argument. * * ```jsx * addEndListener={(node, done) => { @@ -467,6 +539,8 @@ Transition.propTypes = { * Callback fired before the "entering" status is applied. An extra parameter * `isAppearing` is supplied to indicate if the enter stage is occurring on the initial mount * + * **Note**: when `nodeRef` prop is passed, `node` is not passed, so `isAppearing` is being passed as the first argument. + * * @type Function(node: HtmlElement, isAppearing: bool) -> void */ onEnter: PropTypes.func, @@ -475,6 +549,8 @@ Transition.propTypes = { * Callback fired after the "entering" status is applied. An extra parameter * `isAppearing` is supplied to indicate if the enter stage is occurring on the initial mount * + * **Note**: when `nodeRef` prop is passed, `node` is not passed, so `isAppearing` is being passed as the first argument. + * * @type Function(node: HtmlElement, isAppearing: bool) */ onEntering: PropTypes.func, @@ -483,6 +559,8 @@ Transition.propTypes = { * Callback fired after the "entered" status is applied. An extra parameter * `isAppearing` is supplied to indicate if the enter stage is occurring on the initial mount * + * **Note**: when `nodeRef` prop is passed, `node` is not passed, so `isAppearing` is being passed as the first argument. + * * @type Function(node: HtmlElement, isAppearing: bool) -> void */ onEntered: PropTypes.func, @@ -490,6 +568,8 @@ Transition.propTypes = { /** * Callback fired before the "exiting" status is applied. * + * **Note**: when `nodeRef` prop is passed, `node` is not passed. + * * @type Function(node: HtmlElement) -> void */ onExit: PropTypes.func, @@ -497,6 +577,8 @@ Transition.propTypes = { /** * Callback fired after the "exiting" status is applied. * + * **Note**: when `nodeRef` prop is passed, `node` is not passed. + * * @type Function(node: HtmlElement) -> void */ onExiting: PropTypes.func, @@ -504,10 +586,12 @@ Transition.propTypes = { /** * Callback fired after the "exited" status is applied. * + * **Note**: when `nodeRef` prop is passed, `node` is not passed + * * @type Function(node: HtmlElement) -> void */ onExited: PropTypes.func, -} +}; // Name the function so it is clearer in the documentation function noop() {} @@ -527,12 +611,12 @@ Transition.defaultProps = { onExit: noop, onExiting: noop, onExited: noop, -} +}; -Transition.UNMOUNTED = 0 -Transition.EXITED = 1 -Transition.ENTERING = 2 -Transition.ENTERED = 3 -Transition.EXITING = 4 +Transition.UNMOUNTED = UNMOUNTED; +Transition.EXITED = EXITED; +Transition.ENTERING = ENTERING; +Transition.ENTERED = ENTERED; +Transition.EXITING = EXITING; -export default polyfill(Transition) +export default Transition; diff --git a/src/TransitionGroup.js b/src/TransitionGroup.js index 11127ba3..14783c0f 100644 --- a/src/TransitionGroup.js +++ b/src/TransitionGroup.js @@ -1,20 +1,19 @@ -import PropTypes from 'prop-types' -import React from 'react' -import { polyfill } from 'react-lifecycles-compat' - +import PropTypes from 'prop-types'; +import React from 'react'; +import TransitionGroupContext from './TransitionGroupContext'; import { getChildMapping, getInitialChildMapping, getNextChildMapping, -} from './utils/ChildMapping' +} from './utils/ChildMapping'; -const values = Object.values || (obj => Object.keys(obj).map(k => obj[k])) +const values = Object.values || ((obj) => Object.keys(obj).map((k) => obj[k])); const defaultProps = { component: 'div', - childFactory: child => child, -} + childFactory: (child) => child, +}; /** * The `` component manages a set of transition components @@ -31,35 +30,28 @@ const defaultProps = { * items. */ class TransitionGroup extends React.Component { - static childContextTypes = { - transitionGroup: PropTypes.object.isRequired, - } - constructor(props, context) { - super(props, context) + super(props, context); - const handleExited = this.handleExited.bind(this) + const handleExited = this.handleExited.bind(this); // Initial children should all be entering, dependent on appear this.state = { + contextValue: { isMounting: true }, handleExited, firstRender: true, - } - } - - getChildContext() { - return { - transitionGroup: { isMounting: !this.appeared }, - } + }; } componentDidMount() { - this.appeared = true - this.mounted = true + this.mounted = true; + this.setState({ + contextValue: { isMounting: false }, + }); } componentWillUnmount() { - this.mounted = false + this.mounted = false; } static getDerivedStateFromProps( @@ -71,40 +63,50 @@ class TransitionGroup extends React.Component { ? getInitialChildMapping(nextProps, handleExited) : getNextChildMapping(nextProps, prevChildMapping, handleExited), firstRender: false, - } + }; } + // node is `undefined` when user provided `nodeRef` prop handleExited(child, node) { - let currentChildMapping = getChildMapping(this.props.children) + let currentChildMapping = getChildMapping(this.props.children); - if (child.key in currentChildMapping) return + if (child.key in currentChildMapping) return; if (child.props.onExited) { - child.props.onExited(node) + child.props.onExited(node); } if (this.mounted) { - this.setState(state => { - let children = { ...state.children } + this.setState((state) => { + let children = { ...state.children }; - delete children[child.key] - return { children } - }) + delete children[child.key]; + return { children }; + }); } } render() { - const { component: Component, childFactory, ...props } = this.props - const children = values(this.state.children).map(childFactory) + const { component: Component, childFactory, ...props } = this.props; + const { contextValue } = this.state; + const children = values(this.state.children).map(childFactory); - delete props.appear - delete props.enter - delete props.exit + delete props.appear; + delete props.enter; + delete props.exit; if (Component === null) { - return children + return ( + + {children} + + ); } - return {children} + return ( + + {children} + + ); } } @@ -122,6 +124,13 @@ TransitionGroup.propTypes = { * leave. the `` will inject specific transition props, so * remember to spread them through if you are wrapping the `` as * with our `` example. + * + * While this component is meant for multiple `Transition` or `CSSTransition` + * children, sometimes you may want to have a single transition child with + * content that you want to be transitioned out and in when you change it + * (e.g. routes, images etc.) In that case you can change the `key` prop of + * the transition child as you change its content, this will cause + * `TransitionGroup` to transition the child out and back in. */ children: PropTypes.node, @@ -155,8 +164,8 @@ TransitionGroup.propTypes = { * @type Function(child: ReactElement) -> ReactElement */ childFactory: PropTypes.func, -} +}; -TransitionGroup.defaultProps = defaultProps +TransitionGroup.defaultProps = defaultProps; -export default polyfill(TransitionGroup) +export default TransitionGroup; diff --git a/src/TransitionGroupContext.js b/src/TransitionGroupContext.js new file mode 100644 index 00000000..51b82c7d --- /dev/null +++ b/src/TransitionGroupContext.js @@ -0,0 +1,3 @@ +import React from 'react'; + +export default React.createContext(null); diff --git a/src/config.js b/src/config.js new file mode 100644 index 00000000..fb43f3d8 --- /dev/null +++ b/src/config.js @@ -0,0 +1,3 @@ +export default { + disabled: false, +}; diff --git a/src/index.js b/src/index.js index e1ce1316..4e7160c7 100644 --- a/src/index.js +++ b/src/index.js @@ -1,12 +1,6 @@ -import CSSTransition from './CSSTransition'; -import ReplaceTransition from './ReplaceTransition'; -import TransitionGroup from './TransitionGroup'; -import Transition from './Transition'; - - -module.exports = { - Transition, - TransitionGroup, - ReplaceTransition, - CSSTransition, -}; +export { default as CSSTransition } from './CSSTransition'; +export { default as ReplaceTransition } from './ReplaceTransition'; +export { default as SwitchTransition } from './SwitchTransition'; +export { default as TransitionGroup } from './TransitionGroup'; +export { default as Transition } from './Transition'; +export { default as config } from './config'; diff --git a/src/umd.js b/src/umd.js deleted file mode 100644 index bbf1835e..00000000 --- a/src/umd.js +++ /dev/null @@ -1,4 +0,0 @@ -export { default as CSSTransition } from './CSSTransition'; -export { default as ReplaceTransition } from './ReplaceTransition'; -export { default as TransitionGroup } from './TransitionGroup'; -export { default as Transition } from './Transition'; diff --git a/src/utils/ChildMapping.js b/src/utils/ChildMapping.js index 60a360d9..cdd33a3e 100644 --- a/src/utils/ChildMapping.js +++ b/src/utils/ChildMapping.js @@ -1,4 +1,4 @@ -import { Children, cloneElement, isValidElement } from 'react' +import { Children, cloneElement, isValidElement } from 'react'; /** * Given `this.props.children`, return an object mapping key to child. @@ -7,15 +7,16 @@ import { Children, cloneElement, isValidElement } from 'react' * @return {object} Mapping of key to child */ export function getChildMapping(children, mapFn) { - let mapper = child => (mapFn && isValidElement(child) ? mapFn(child) : child) + let mapper = (child) => + mapFn && isValidElement(child) ? mapFn(child) : child; - let result = Object.create(null) + let result = Object.create(null); if (children) - Children.map(children, c => c).forEach(child => { + Children.map(children, (c) => c).forEach((child) => { // run the map function here instead so that the key is the computed one - result[child.key] = mapper(child) - }) - return result + result[child.key] = mapper(child); + }); + return result; } /** @@ -36,81 +37,80 @@ export function getChildMapping(children, mapFn) { * in `next` in a reasonable order. */ export function mergeChildMappings(prev, next) { - prev = prev || {} - next = next || {} + prev = prev || {}; + next = next || {}; function getValueForKey(key) { - return key in next ? next[key] : prev[key] + return key in next ? next[key] : prev[key]; } // For each key of `next`, the list of keys to insert before that key in // the combined list - let nextKeysPending = Object.create(null) + let nextKeysPending = Object.create(null); - let pendingKeys = [] + let pendingKeys = []; for (let prevKey in prev) { if (prevKey in next) { if (pendingKeys.length) { - nextKeysPending[prevKey] = pendingKeys - pendingKeys = [] + nextKeysPending[prevKey] = pendingKeys; + pendingKeys = []; } } else { - pendingKeys.push(prevKey) + pendingKeys.push(prevKey); } } - let i - let childMapping = {} + let i; + let childMapping = {}; for (let nextKey in next) { if (nextKeysPending[nextKey]) { for (i = 0; i < nextKeysPending[nextKey].length; i++) { - let pendingNextKey = nextKeysPending[nextKey][i] - childMapping[nextKeysPending[nextKey][i]] = getValueForKey( - pendingNextKey - ) + let pendingNextKey = nextKeysPending[nextKey][i]; + childMapping[nextKeysPending[nextKey][i]] = + getValueForKey(pendingNextKey); } } - childMapping[nextKey] = getValueForKey(nextKey) + childMapping[nextKey] = getValueForKey(nextKey); } // Finally, add the keys which didn't appear before any key in `next` for (i = 0; i < pendingKeys.length; i++) { - childMapping[pendingKeys[i]] = getValueForKey(pendingKeys[i]) + childMapping[pendingKeys[i]] = getValueForKey(pendingKeys[i]); } - return childMapping + return childMapping; } function getProp(child, prop, props) { - return props[prop] != null ? props[prop] : child.props[prop] + return props[prop] != null ? props[prop] : child.props[prop]; } export function getInitialChildMapping(props, onExited) { - return getChildMapping(props.children, child => { + return getChildMapping(props.children, (child) => { return cloneElement(child, { onExited: onExited.bind(null, child), in: true, appear: getProp(child, 'appear', props), enter: getProp(child, 'enter', props), exit: getProp(child, 'exit', props), - }) - }) + }); + }); } export function getNextChildMapping(nextProps, prevChildMapping, onExited) { - let nextChildMapping = getChildMapping(nextProps.children) - let children = mergeChildMappings(prevChildMapping, nextChildMapping) + let nextChildMapping = getChildMapping(nextProps.children); + let children = mergeChildMappings(prevChildMapping, nextChildMapping); - Object.keys(children).forEach(key => { - let child = children[key] + Object.keys(children).forEach((key) => { + let child = children[key]; - if (!isValidElement(child)) return + if (!isValidElement(child)) return; - const hasPrev = key in prevChildMapping - const hasNext = key in nextChildMapping + const hasPrev = key in prevChildMapping; + const hasNext = key in nextChildMapping; - const prevChild = prevChildMapping[key] - const isLeaving = isValidElement(prevChild) && !prevChild.props.in + const prevChild = prevChildMapping[key]; + const isLeaving = isValidElement(prevChild) && !prevChild.props.in; // item is new (entering) if (hasNext && (!hasPrev || isLeaving)) { @@ -120,11 +120,11 @@ export function getNextChildMapping(nextProps, prevChildMapping, onExited) { in: true, exit: getProp(child, 'exit', nextProps), enter: getProp(child, 'enter', nextProps), - }) + }); } else if (!hasNext && hasPrev && !isLeaving) { // item is old (exiting) // console.log('leaving', key) - children[key] = cloneElement(child, { in: false }) + children[key] = cloneElement(child, { in: false }); } else if (hasNext && hasPrev && isValidElement(prevChild)) { // item hasn't changed transition states // copy over the last transition props; @@ -134,9 +134,9 @@ export function getNextChildMapping(nextProps, prevChildMapping, onExited) { in: prevChild.props.in, exit: getProp(child, 'exit', nextProps), enter: getProp(child, 'enter', nextProps), - }) + }); } - }) + }); - return children + return children; } diff --git a/src/utils/PropTypes.js b/src/utils/PropTypes.js index ce0fd85d..5284b53a 100644 --- a/src/utils/PropTypes.js +++ b/src/utils/PropTypes.js @@ -6,8 +6,9 @@ export const timeoutsShape = PropTypes.number, PropTypes.shape({ enter: PropTypes.number, - exit: PropTypes.number - }).isRequired + exit: PropTypes.number, + appear: PropTypes.number, + }).isRequired, ]) : null; @@ -18,7 +19,7 @@ export const classNamesShape = PropTypes.shape({ enter: PropTypes.string, exit: PropTypes.string, - active: PropTypes.string + active: PropTypes.string, }), PropTypes.shape({ enter: PropTypes.string, @@ -26,7 +27,7 @@ export const classNamesShape = enterActive: PropTypes.string, exit: PropTypes.string, exitDone: PropTypes.string, - exitActive: PropTypes.string - }) + exitActive: PropTypes.string, + }), ]) : null; diff --git a/src/utils/SimpleSet.js b/src/utils/SimpleSet.js index 1a49ec91..1617ecba 100644 --- a/src/utils/SimpleSet.js +++ b/src/utils/SimpleSet.js @@ -1,18 +1,21 @@ - export default class SimpleSet { constructor() { - this.v = [] + this.v = []; + } + clear() { + this.v.length = 0; + } + has(k) { + return this.v.indexOf(k) !== -1; } - clear() { this.v.length = 0; } - has(k) { return this.v.indexOf(k) !== -1; } add(k) { if (this.has(k)) return; - this.v.push(k) + this.v.push(k); } delete(k) { const idx = this.v.indexOf(k); if (idx === -1) return false; - this.v.splice(idx, 1) + this.v.splice(idx, 1); return true; } } diff --git a/src/utils/reflow.js b/src/utils/reflow.js new file mode 100644 index 00000000..f02dd2ab --- /dev/null +++ b/src/utils/reflow.js @@ -0,0 +1 @@ +export const forceReflow = (node) => node.scrollTop; diff --git a/stories/.eslintrc.yml b/stories/.eslintrc.yml index 77b9f34e..85b854c0 100644 --- a/stories/.eslintrc.yml +++ b/stories/.eslintrc.yml @@ -1,5 +1,3 @@ -globals: - css: false rules: react/prop-types: off no-unused-vars: diff --git a/stories/CSSTransition.js b/stories/CSSTransition.js new file mode 100644 index 00000000..73379400 --- /dev/null +++ b/stories/CSSTransition.js @@ -0,0 +1,50 @@ +import React, { useState } from 'react'; +import { storiesOf } from '@storybook/react'; + +import StoryFixture from './StoryFixture'; +import Fade from './transitions/CSSFade'; + +function ToggleFixture({ defaultIn, description, children }) { + const [show, setShow] = useState(defaultIn || false); + + return ( + +
+ +
+ {React.cloneElement(children, { in: show })} +
+ ); +} + +storiesOf('CSSTransition', module) + .add('Fade', () => ( + + asaghasg asgasg + + )) + .add('Fade with appear', () => ( + + asaghasg asgasg + + )) + .add('Fade with mountOnEnter', () => { + return ( + + Fade with mountOnEnter + + ); + }) + .add('Fade with unmountOnExit', () => { + return ( + + Fade with unmountOnExit + + ); + }); diff --git a/stories/CSSTransitionGroupFixture.js b/stories/CSSTransitionGroupFixture.js index c5e0e927..091ec848 100644 --- a/stories/CSSTransitionGroupFixture.js +++ b/stories/CSSTransitionGroupFixture.js @@ -4,24 +4,20 @@ import TransitionGroup from '../src/TransitionGroup'; import StoryFixture from './StoryFixture'; class CSSTransitionGroupFixture extends React.Component { - constructor(props, context) { - super(props, context); + static defaultProps = { + items: [], + }; - let items = props.items || []; + count = this.props.items.length; + state = { + items: this.props.items, + }; - this.count = items.length; - this.state = { - items, - }; - } handleAddItem = () => { this.setState(({ items }) => ({ - items: [ - ...items, - `Item number: ${++this.count}`, - ], + items: [...items, `Item number: ${++this.count}`], })); - } + }; handleRemoveItems = () => { this.setState(({ items }) => { @@ -29,40 +25,35 @@ class CSSTransitionGroupFixture extends React.Component { items.splice(1, 3); return { items }; }); - } + }; handleRemoveItem = (item) => { this.setState(({ items }) => ({ - items: items.filter(i => i !== item), + items: items.filter((i) => i !== item), })); - } + }; render() { - const { items: _, description, children, ...props } = this.props; + const { items: _, description, children, ...rest } = this.props; + // e.g. `Fade`, see where `CSSTransitionGroupFixture` is used + const { type: TransitionType, props: transitionTypeProps } = + React.Children.only(children); return (
- - {' '} - + {' '} +
- - {this.state.items.map(item => React.cloneElement(children, { - key: item, - children: ( -
- {item} - -
- ) - }))} + + {this.state.items.map((item) => ( + + {item} + + + ))}
); diff --git a/stories/NestedTransition.js b/stories/NestedTransition.js index 3b835a02..58fe59cb 100644 --- a/stories/NestedTransition.js +++ b/stories/NestedTransition.js @@ -1,51 +1,44 @@ -import React from 'react'; -import StoryFixture from './StoryFixture'; +import React, { useState } from 'react'; -import Fade from './transitions/Fade'; +import StoryFixture from './StoryFixture'; +import Fade from './transitions/CSSFadeForTransitionGroup'; import Scale from './transitions/Scale'; -const FadeAndScale = (props) => ( - -
+function FadeAndScale(props) { + return ( +
I will fade
{/* - We also want to scale in at the same time so we pass the `in` state here as well, so it enters - at the same time as the Fade. + We also want to scale in at the same time so we pass the `in` state here as well, so it enters + at the same time as the Fade. - Note also the `appear` since the Fade will happen when the item mounts, the Scale transition - will mount at the same time as the div we want to scale, so we need to tell it to animate as - it _appears_. - */} + Note also the `appear` since the Fade will happen when the item mounts, the Scale transition + will mount at the same time as the div we want to scale, so we need to tell it to animate as + it _appears_. + */} -
I should scale
+ I should scale
-
-
-) - - -export default class Example extends React.Component { - constructor(props, context) { - super(props, context); - this.state = { showNested: false }; - } - - toggleNested = () => { - this.setState({ showNested: !this.state.showNested }) - } - - render() { - return ( - -

Nested Animations

- - -
- ); - } +
+ ); } +function Example() { + const [showNested, setShowNested] = useState(false); + + return ( + +

Nested Animations

+ + +
+ ); +} - +export default Example; diff --git a/stories/ReplaceTransition.js b/stories/ReplaceTransition.js index 58499e8d..2aec7907 100644 --- a/stories/ReplaceTransition.js +++ b/stories/ReplaceTransition.js @@ -1,4 +1,5 @@ -import React from 'react'; +import { css } from 'astroturf'; +import React, { useState } from 'react'; import { storiesOf } from '@storybook/react'; import ReplaceTransition from '../src/ReplaceTransition'; @@ -6,17 +7,18 @@ import CSSTransition from '../src/CSSTransition'; const FADE_TIMEOUT = 1000; -let styles = css` +const styles = css` .enter { opacity: 0.01; } - .enter.enter-active { + .enter.enter-active { position: absolute; - left: 0; right: 0; + left: 0; + right: 0; opacity: 1; transition: opacity ${FADE_TIMEOUT * 2}ms ease-in; - transition-delay: ${FADE_TIMEOUT}ms + transition-delay: ${FADE_TIMEOUT}ms; } .exit { @@ -37,44 +39,43 @@ let styles = css` } `; -export default class Fade extends React.Component { - static defaultProps = { - in: false, - delay: false, - timeout: FADE_TIMEOUT * 2, - }; - render() { - const { ...props } = this.props; - return ( - - ); - } -} +const defaultProps = { + in: false, + timeout: FADE_TIMEOUT * 2, +}; -class Example extends React.Component { - state = { in: false } - render() { - return ( -
- - {React.cloneElement(this.props.children, this.state)} -
- ); - } +function Fade(props) { + return ( + + ); } +Fade.defaultProps = defaultProps; -storiesOf('Replace Transition', module) - .add('Animates on all', () => ( +function Example({ children }) { + const [show, setShow] = useState(false); + + return ( +
+ + {React.cloneElement(children, { in: show })} +
+ ); +} +storiesOf('Replace Transition', module).add('Animates on all', () => { + const firstNodeRef = React.createRef(); + const secondNodeRef = React.createRef(); + return ( console.log('onEnter')} onEntering={() => console.log('onEntering')} @@ -83,8 +84,13 @@ storiesOf('Replace Transition', module) onExiting={() => console.log('onExiting')} onExited={() => console.log('onExited')} > -
in True
-
in False
+ +
in True
+
+ +
in False
+
- )) + ); +}); diff --git a/stories/StoryFixture.js b/stories/StoryFixture.js index 163310f0..cb0fb591 100644 --- a/stories/StoryFixture.js +++ b/stories/StoryFixture.js @@ -5,18 +5,14 @@ const propTypes = { description: PropTypes.string, }; -class StoryFixture extends React.Component { - render() { - const { children, description } = this.props; +function StoryFixture({ description, children }) { + return ( +
+

{description}

- return ( -
-

{description}

- - {children} -
- ); - } + {children} +
+ ); } StoryFixture.propTypes = propTypes; diff --git a/stories/Transition.js b/stories/Transition.js index db8093d1..da050927 100644 --- a/stories/Transition.js +++ b/stories/Transition.js @@ -1,48 +1,77 @@ -import React from 'react'; +import React, { useState } from 'react'; import { storiesOf } from '@storybook/react'; -import { Fade, Collapse } from './transitions/Bootstrap' import StoryFixture from './StoryFixture'; +import { + Fade, + Collapse, + FadeForwardRef, + FadeInnerRef, +} from './transitions/Bootstrap'; +function ToggleFixture({ defaultIn, description, children }) { + const [show, setShow] = useState(defaultIn); -class ToggleFixture extends React.Component { - state = { show: this.props.defaultIn } - render() { - return ( - -
- -
- {React.cloneElement(this.props.children, { - in: this.state.show - })} -
- ) - } + return ( + +
+ +
+ {React.cloneElement(children, { in: show })} +
+ ); } storiesOf('Transition', module) .add('Bootstrap Fade', () => ( - -
asaghasg asgasg
-
+ asaghasg asgasg
)) .add('Bootstrap Collapse', () => ( -
- asaghasg asgasg -
foo
-
bar
-
+ asaghasg asgasg +
foo
+
bar
- )); + )) + .add('Fade using React.forwardRef', () => { + const nodeRef = React.createRef(); + return ( + + + Fade using React.forwardRef + + + ); + }) + .add('Fade using innerRef', () => { + const nodeRef = React.createRef(); + return ( + + Fade using innerRef + + ); + }) + .add('Fade with mountOnEnter', () => { + return ( + + Fade with mountOnEnter + + ); + }) + .add('Fade with unmountOnExit', () => { + return ( + + Fade with unmountOnExit + + ); + }); diff --git a/stories/TransitionGroup.js b/stories/TransitionGroup.js index 7e8183ef..12f9d1a5 100644 --- a/stories/TransitionGroup.js +++ b/stories/TransitionGroup.js @@ -1,12 +1,12 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { storiesOf } from '@storybook/react'; import TransitionGroup from '../src/TransitionGroup'; + import CSSTransitionGroupFixture from './CSSTransitionGroupFixture'; -import NestedTransition from './NestedTransition' +import NestedTransition from './NestedTransition'; import StoryFixture from './StoryFixture'; -import Fade, { FADE_TIMEOUT } from './transitions/Fade'; - +import Fade, { FADE_TIMEOUT } from './transitions/CSSFadeForTransitionGroup'; storiesOf('Css Transition Group', module) .add('Animates on all', () => ( @@ -16,7 +16,7 @@ storiesOf('Css Transition Group', module) removed or on initial appear `} appear - items={[ 'Item number: 1' ]} + items={['Item number: 1']} > @@ -29,9 +29,9 @@ storiesOf('Css Transition Group', module) `} exit={false} timeout={{ enter: FADE_TIMEOUT }} - items={[ 'Item number: 1' ]} + items={['Item number: 1']} > - + )) .add('Animates on exit', () => ( @@ -40,11 +40,7 @@ storiesOf('Css Transition Group', module) Should animate when items are removed to the list but not when they are added or on initial appear `} - items={[ - 'Item number: 1', - 'Item number: 2', - 'Item number: 3', - ]} + items={['Item number: 1', 'Item number: 2', 'Item number: 3']} > @@ -55,11 +51,7 @@ storiesOf('Css Transition Group', module) Should animate when items first mount but not when added or removed `} appear - items={[ - 'Item number: 1', - 'Item number: 2', - 'Item number: 3', - ]} + items={['Item number: 1', 'Item number: 2', 'Item number: 3']} > @@ -76,69 +68,66 @@ storiesOf('Css Transition Group', module) .add('Re-entering while leaving', () => ( - + )) - .add('Nested Transitions', () => ( - - )) - ; + .add('Nested Transitions', () => ); class DynamicTransition extends React.Component { - state = { count: 0 } + state = { count: 0 }; handleClick = () => { - this.setState({ hide: !this.state.hide }) - } + this.setState({ hide: !this.state.hide }); + }; componentDidMount() { this.interval = setInterval(() => { - this.setState({ count: this.state.count + 1 }) + this.setState({ count: this.state.count + 1 }); }, 700); } - componentWillUnmount() { clearInterval(this.interval); } + componentWillUnmount() { + clearInterval(this.interval); + } render() { - const { hide, count } = this.state + const { hide, count } = this.state; return (
- {!hide && - -
Changing! {count}
-
- } + {!hide && Changing! {count}}
- ) + ); } } -class RenterTransition extends React.Component { - handleClick = () => { - this.setState({ hide: true }, () => { +function ReEnterTransition() { + const [hide, setHide] = useState(false); + + useEffect(() => { + if (hide) { setTimeout(() => { - console.log('re-entering!') - this.setState({ hide: false }) - }, FADE_TIMEOUT / 2); - }) - } - render() { - const { hide } = this.state || {} - return ( -
- - - {!hide && - -
I'm entering!
-
- } -
-
- ) - } + console.log('re-entering!'); + setHide(false); + }, 0.5 * FADE_TIMEOUT); + } + }, [hide]); + + return ( +
+ + + {!hide && I'm entering!} + +
+ ); } diff --git a/stories/index.js b/stories/index.js index f038fac2..535c80a5 100644 --- a/stories/index.js +++ b/stories/index.js @@ -1,3 +1,4 @@ import './Transition'; +import './CSSTransition'; import './TransitionGroup'; import './ReplaceTransition'; diff --git a/stories/transitions/Bootstrap.js b/stories/transitions/Bootstrap.js index 5b5cdd2c..8c84949b 100644 --- a/stories/transitions/Bootstrap.js +++ b/stories/transitions/Bootstrap.js @@ -1,14 +1,18 @@ -import React from 'react'; -import style from 'dom-helpers/style'; - -import Transition, { EXITED, ENTERED, ENTERING, EXITING } - from '../../src/Transition'; +import { css } from 'astroturf'; +import React, { useEffect, useRef } from 'react'; +import style from 'dom-helpers/css'; +import Transition, { + EXITED, + ENTERED, + ENTERING, + EXITING, +} from '../../src/Transition'; const styles = css` .fade { opacity: 0; - transition: opacity .15s linear; + transition: opacity 0.5s linear; } .fade.in { opacity: 1; @@ -18,82 +22,95 @@ const styles = css` display: none; } - .collapse.in { display: block; } + .collapse.in { + display: block; + } .collapsing { position: relative; height: 0; overflow: hidden; - transition: .35s ease; + transition: 0.35s ease; transition-property: height, visibility; } `; - const fadeStyles = { [ENTERING]: styles.in, [ENTERED]: styles.in, +}; + +export function Fade(props) { + const nodeRef = useRef(); + return ( + + {(status) => ( +
+ {props.children} +
+ )} +
+ ); } -export const Fade = props => ( - - {status => React.cloneElement(props.children, { - className: `${styles.fade} ${fadeStyles[status] || ''}` - })} - -) - function getHeight(elem) { let value = elem.offsetHeight; let margins = ['marginTop', 'marginBottom']; - return (value + + return ( + value + parseInt(style(elem, margins[0]), 10) + parseInt(style(elem, margins[1]), 10) ); } - const collapseStyles = { [EXITED]: styles.collapse, [EXITING]: styles.collapsing, [ENTERING]: styles.collapsing, [ENTERED]: `${styles.collapse} ${styles.in}`, -} +}; export class Collapse extends React.Component { + nodeRef = React.createRef(); + /* -- Expanding -- */ - handleEnter = (elem) => { - elem.style.height = '0'; - } + handleEnter = () => { + this.nodeRef.current.style.height = '0'; + }; - handleEntering = (elem) => { - elem.style.height = `${elem.scrollHeight}px`; - } + handleEntering = () => { + this.nodeRef.current.style.height = `${this.nodeRef.current.scrollHeight}px`; + }; - handleEntered = (elem) => { - elem.style.height = null; - } + handleEntered = () => { + this.nodeRef.current.style.height = null; + }; /* -- Collapsing -- */ - handleExit = (elem) => { - elem.style.height = getHeight(elem) + 'px'; - elem.offsetHeight; // eslint-disable-line no-unused-expressions - } + handleExit = () => { + this.nodeRef.current.style.height = getHeight(this.nodeRef.current) + 'px'; + this.nodeRef.current.offsetHeight; // eslint-disable-line no-unused-expressions + }; - handleExiting = (elem) => { - elem.style.height = '0'; - } + handleExiting = () => { + this.nodeRef.current.style.height = '0'; + }; render() { - const { children } = this.props; + const { children, ...rest } = this.props; return ( - {(state, props) => React.cloneElement(children, { - ...props, - className: collapseStyles[state] - })} + {(state, props) => ( +
+ {children} +
+ )}
); } } + +export function FadeInnerRef(props) { + const nodeRef = useMergedRef(props.innerRef); + return ( + + {(status) => ( +
+ {props.children} +
+ )} +
+ ); +} + +export const FadeForwardRef = React.forwardRef((props, ref) => { + return ; +}); + +/** + * Compose multiple refs, there may be different implementations + * This one is derived from + * e.g. https://github.com/react-restart/hooks/blob/ed37bf3dfc8fc1d9234a6d8fe0af94d69fad3b74/src/useMergedRefs.ts + * Also here are good discussion about this + * https://github.com/facebook/react/issues/13029 + * @param ref + * @returns {React.MutableRefObject} + */ +function useMergedRef(ref) { + const nodeRef = React.useRef(); + useEffect(function () { + if (ref) { + if (typeof ref === 'function') { + ref(nodeRef.current); + } else { + ref.current = nodeRef.current; + } + } + }); + return nodeRef; +} diff --git a/stories/transitions/CSSFade.js b/stories/transitions/CSSFade.js new file mode 100644 index 00000000..9ab381c8 --- /dev/null +++ b/stories/transitions/CSSFade.js @@ -0,0 +1,53 @@ +import { css } from 'astroturf'; +import React, { useRef } from 'react'; + +import CSSTransition from '../../src/CSSTransition'; + +export const FADE_TIMEOUT = 1000; + +const styles = css` + .default { + opacity: 0; + } + .enter-done { + opacity: 1; + } + + .enter, + .appear { + opacity: 0.01; + } + + .enter.enter-active, + .appear.appear-active { + opacity: 1; + transition: opacity ${FADE_TIMEOUT}ms ease-in; + } + + .exit { + opacity: 1; + } + .exit.exit-active { + opacity: 0.01; + transition: opacity ${0.8 * FADE_TIMEOUT}ms ease-in; + } +`; + +const defaultProps = { + in: false, + timeout: FADE_TIMEOUT, +}; +function Fade(props) { + const nodeRef = useRef(); + return ( + +
+ {props.children} +
+
+ ); +} + +Fade.defaultProps = defaultProps; + +export default Fade; diff --git a/stories/transitions/CSSFadeForTransitionGroup.js b/stories/transitions/CSSFadeForTransitionGroup.js new file mode 100644 index 00000000..e043f720 --- /dev/null +++ b/stories/transitions/CSSFadeForTransitionGroup.js @@ -0,0 +1,45 @@ +import { css } from 'astroturf'; +import React, { useRef } from 'react'; + +import CSSTransition from '../../src/CSSTransition'; + +export const FADE_TIMEOUT = 1000; + +const styles = css` + .enter, + .appear { + opacity: 0.01; + } + + .enter.enter-active, + .appear.appear-active { + opacity: 1; + transition: opacity ${FADE_TIMEOUT}ms ease-in; + } + + .exit { + opacity: 1; + } + .exit.exit-active { + opacity: 0.01; + transition: opacity ${0.8 * FADE_TIMEOUT}ms ease-in; + } +`; + +const defaultProps = { + in: false, + timeout: FADE_TIMEOUT, +}; + +function Fade(props) { + const nodeRef = useRef(); + return ( + +
{props.children}
+
+ ); +} + +Fade.defaultProps = defaultProps; + +export default Fade; diff --git a/stories/transitions/Fade.js b/stories/transitions/Fade.js deleted file mode 100644 index ce90acdd..00000000 --- a/stories/transitions/Fade.js +++ /dev/null @@ -1,41 +0,0 @@ -import React from 'react'; - -import CSSTransition from '../../src/CSSTransition'; - -export const FADE_TIMEOUT = 3000; - -let styles = css` - .enter, - .appear { - opacity: 0.01; - } - - .enter.enter-active, - .appear.appear-active { - opacity: 1; - transition: opacity 1000ms ease-in; - } - - .exit { - opacity: 1; - } - .exit.exit-active { - opacity: 0.01; - transition: opacity 800ms ease-in; - } -`; - -export default class Fade extends React.Component { - static defaultProps = { - in: false, - timeout: FADE_TIMEOUT, - }; - render() { - return ( - - ); - } -} diff --git a/stories/transitions/Scale.js b/stories/transitions/Scale.js index 8189f758..dc30012f 100644 --- a/stories/transitions/Scale.js +++ b/stories/transitions/Scale.js @@ -1,10 +1,11 @@ -import React from 'react'; +import { css } from 'astroturf'; +import React, { useRef } from 'react'; import CSSTransition from '../../src/CSSTransition'; export const SCALE_TIMEOUT = 1000; -let styles = css` +const styles = css` .enter, .appear { transform: scale(0); @@ -25,17 +26,20 @@ let styles = css` } `; -export default class Scale extends React.Component { - static defaultProps = { - in: false, - timeout: SCALE_TIMEOUT, - }; - render() { - return ( - - ); - } +const defaultProps = { + in: false, + timeout: SCALE_TIMEOUT, +}; + +function Scale(props) { + const nodeRef = useRef(); + return ( + +
{props.children}
+
+ ); } + +Scale.defaultProps = defaultProps; + +export default Scale; diff --git a/test/.eslintrc.yml b/test/.eslintrc.yml index 2cfb7601..3c0a3a9e 100644 --- a/test/.eslintrc.yml +++ b/test/.eslintrc.yml @@ -1,7 +1,6 @@ - env: jest: true - jasmine: true + es6: true rules: no-require: off global-require: off diff --git a/test/CSSTransition-test.js b/test/CSSTransition-test.js index b04fff16..9ec4672a 100644 --- a/test/CSSTransition-test.js +++ b/test/CSSTransition-test.js @@ -1,253 +1,346 @@ import React from 'react'; -import { mount } from 'enzyme'; +import { render, waitFor } from './utils'; import CSSTransition from '../src/CSSTransition'; - -jasmine.addMatchers({ - toExist: () => ({ - compare: actual => ({ - pass: actual != null, - }) - }) -}); +import TransitionGroup from '../src/TransitionGroup'; describe('CSSTransition', () => { - it('should flush new props to the DOM before initiating a transition', (done) => { - mount( + const nodeRef = React.createRef(); + const { setProps } = render( { - expect(node.classList.contains('test-class')).toEqual(true) - expect(node.classList.contains('test-entering')).toEqual(false) - done() + onEnter={() => { + expect(nodeRef.current.classList.contains('test-class')).toEqual( + true + ); + expect(nodeRef.current.classList.contains('test-entering')).toEqual( + false + ); + done(); }} > -
+
- ) - .tap(inst => { + ); + + expect(nodeRef.current.classList.contains('test-class')).toEqual(false); - expect(inst.getDOMNode().classList.contains('test-class')).toEqual(false) - }) - .setProps({ + setProps({ in: true, - className: 'test-class' - }) + className: 'test-class', + }); }); describe('entering', () => { - let instance; - - beforeEach(() => { - instance = mount( - -
- - ) - }); - - it('should apply classes at each transition state', done => { + it('should apply classes at each transition state', async () => { let count = 0; + let done = false; + const nodeRef = React.createRef(); + const { setProps } = render( + +
+ + ); - instance.setProps({ + setProps({ in: true, - onEnter(node) { + onEnter() { count++; - expect(node.className).toEqual('test-enter'); + expect(nodeRef.current.className).toEqual('test-enter'); }, - onEntering(node){ + onEntering() { count++; - expect(node.className).toEqual('test-enter test-enter-active'); + expect(nodeRef.current.className).toEqual( + 'test-enter test-enter-active' + ); }, - onEntered(node){ - expect(node.className).toEqual('test-enter-done'); + onEntered() { + expect(nodeRef.current.className).toEqual('test-enter-done'); expect(count).toEqual(2); - done(); - } + done = true; + }, + }); + + await waitFor(() => { + expect(done).toBe(true); }); }); - it('should apply custom classNames names', done => { + it('should apply custom classNames names', async () => { let count = 0; - instance = mount( + const nodeRef = React.createRef(); + const { setProps } = render( -
+
); - instance.setProps({ + setProps({ in: true, - onEnter(node){ + onEnter() { count++; - expect(node.className).toEqual('custom'); + expect(nodeRef.current.className).toEqual('custom'); }, - onEntering(node){ + onEntering() { count++; - expect(node.className).toEqual('custom custom-super-active'); + expect(nodeRef.current.className).toEqual( + 'custom custom-super-active' + ); }, - onEntered(node){ - expect(node.className).toEqual('custom-super-done'); - expect(count).toEqual(2); - done(); - } + onEntered() { + expect(nodeRef.current.className).toEqual('custom-super-done'); + }, + }); + + await waitFor(() => { + expect(count).toEqual(2); }); }); }); describe('appearing', () => { - it('should apply appear classes at each transition state', done => { + it('should apply appear classes at each transition state', async () => { let count = 0; - mount( + const nodeRef = React.createRef(); + render( { + onEnter={(isAppearing) => { count++; expect(isAppearing).toEqual(true); - expect(node.className).toEqual('appear-test-appear'); + expect(nodeRef.current.className).toEqual('appear-test-appear'); }} - onEntering={(node, isAppearing) => { + onEntering={(isAppearing) => { count++; expect(isAppearing).toEqual(true); - expect(node.className).toEqual('appear-test-appear appear-test-appear-active'); + expect(nodeRef.current.className).toEqual( + 'appear-test-appear appear-test-appear-active' + ); }} + onEntered={(isAppearing) => { + expect(isAppearing).toEqual(true); + expect(nodeRef.current.className).toEqual( + 'appear-test-appear-done appear-test-enter-done' + ); + }} + > +
+ + ); - onEntered={(node, isAppearing) => { + await waitFor(() => { + expect(count).toEqual(2); + }); + }); + + it('should lose the "*-appear-done" class after leaving and entering again', async () => { + const nodeRef = React.createRef(); + let entered = false; + let exited = false; + const { setProps } = render( + { + entered = true; + }} + > +
+ + ); + + await waitFor(() => { + expect(entered).toEqual(true); + }); + setProps({ + in: false, + onEntered: () => {}, + onExited: () => { + exited = true; + }, + }); + + await waitFor(() => { + expect(exited).toEqual(true); + }); + expect(nodeRef.current.className).toBe('appear-test-exit-done'); + entered = false; + setProps({ + in: true, + onEntered: () => { + entered = true; + }, + }); + + await waitFor(() => { + expect(entered).toEqual(true); + }); + expect(nodeRef.current.className).toBe('appear-test-enter-done'); + }); + + it('should not add undefined when appearDone is not defined', async () => { + const nodeRef = React.createRef(); + let done = false; + render( + { expect(isAppearing).toEqual(true); - expect(node.className).toEqual('appear-test-enter-done'); - expect(count).toEqual(2); - done(); + expect(nodeRef.current.className).toEqual('appear-test'); + }} + onEntered={(isAppearing) => { + expect(isAppearing).toEqual(true); + expect(nodeRef.current.className).toEqual(''); + done = true; }} > -
+
); + + await waitFor(() => { + expect(done).toEqual(true); + }); }); - it('should not be appearing in normal enter mode', done => { + it('should not be appearing in normal enter mode', async () => { let count = 0; - mount( + const nodeRef = React.createRef(); + render( -
+
).setProps({ in: true, - onEnter(node, isAppearing){ + onEnter(isAppearing) { count++; expect(isAppearing).toEqual(false); - expect(node.className).toEqual('not-appear-test-enter'); + expect(nodeRef.current.className).toEqual('not-appear-test-enter'); }, - onEntering(node, isAppearing){ + onEntering(isAppearing) { count++; expect(isAppearing).toEqual(false); - expect(node.className).toEqual('not-appear-test-enter not-appear-test-enter-active'); + expect(nodeRef.current.className).toEqual( + 'not-appear-test-enter not-appear-test-enter-active' + ); }, - onEntered(node, isAppearing){ + onEntered(isAppearing) { expect(isAppearing).toEqual(false); - expect(node.className).toEqual('not-appear-test-enter-done'); - expect(count).toEqual(2); - done(); - } + expect(nodeRef.current.className).toEqual( + 'not-appear-test-enter-done' + ); + }, + }); + + await waitFor(() => { + expect(count).toEqual(2); }); }); it('should not enter the transition states when appear=false', () => { - mount( + const nodeRef = React.createRef(); + render( { - throw Error('Enter called!') + throw Error('Enter called!'); }} onEntering={() => { - throw Error('Entring called!') + throw Error('Entring called!'); }} onEntered={() => { - throw Error('Entred called!') + throw Error('Entred called!'); }} > -
+
); }); - - }); - describe('exiting', ()=> { - let instance; - - beforeEach(() => { - instance = mount( - -
- - ) - }); - - it('should apply classes at each transition state', done => { + describe('exiting', () => { + it('should apply classes at each transition state', async () => { let count = 0; + const nodeRef = React.createRef(); + const { setProps } = render( + +
+ + ); - instance.setProps({ + setProps({ in: false, - onExit(node){ + onExit() { count++; - expect(node.className).toEqual('test-exit'); + expect(nodeRef.current.className).toEqual('test-exit'); }, - onExiting(node){ + onExiting() { count++; - expect(node.className).toEqual('test-exit test-exit-active'); + expect(nodeRef.current.className).toEqual( + 'test-exit test-exit-active' + ); }, - onExited(node){ - expect(node.className).toEqual('test-exit-done'); - expect(count).toEqual(2); - done(); - } + onExited() { + expect(nodeRef.current.className).toEqual('test-exit-done'); + }, + }); + + await waitFor(() => { + expect(count).toEqual(2); }); }); - it('should apply custom classNames names', done => { + it('should apply custom classNames names', async () => { let count = 0; - instance = mount( + const nodeRef = React.createRef(); + const { setProps } = render( { exitDone: 'custom-super-done', }} > -
+
); - instance.setProps({ + setProps({ in: false, - onExit(node){ + onExit() { count++; - expect(node.className).toEqual('custom'); + expect(nodeRef.current.className).toEqual('custom'); }, - onExiting(node){ + onExiting() { count++; - expect(node.className).toEqual('custom custom-super-active'); + expect(nodeRef.current.className).toEqual( + 'custom custom-super-active' + ); }, - onExited(node){ - expect(node.className).toEqual('custom-super-done'); - expect(count).toEqual(2); - done(); + onExited() { + expect(nodeRef.current.className).toEqual('custom-super-done'); + }, + }); + + await waitFor(() => { + expect(count).toEqual(2); + }); + }); + + it('should support empty prefix', async () => { + let count = 0; + + const nodeRef = React.createRef(); + const { setProps } = render( + +
+ + ); + + setProps({ + in: false, + + onExit() { + count++; + expect(nodeRef.current.className).toEqual('exit'); + }, + + onExiting() { + count++; + expect(nodeRef.current.className).toEqual('exit exit-active'); + }, + + onExited() { + expect(nodeRef.current.className).toEqual('exit-done'); + }, + }); + + await waitFor(() => { + expect(count).toEqual(2); + }); + }); + }); + + describe('reentering', () => { + it('should remove dynamically applied classes', async () => { + let count = 0; + class Test extends React.Component { + render() { + const { direction, text, nodeRef, ...props } = this.props; + + return ( + + React.cloneElement(child, { + classNames: direction, + }) + } + > + + {text} + + + ); } + } + + const nodeRef = { + foo: React.createRef(), + bar: React.createRef(), + }; + + const { setProps } = render( + + ); + + setProps({ + direction: 'up', + text: 'bar', + nodeRef: nodeRef.bar, + + onEnter() { + count++; + expect(nodeRef.bar.current.className).toEqual('up-enter'); + }, + onEntering() { + count++; + expect(nodeRef.bar.current.className).toEqual( + 'up-enter up-enter-active' + ); + }, + }); + + await waitFor(() => { + expect(count).toEqual(2); + }); + + setProps({ + direction: 'down', + text: 'foo', + nodeRef: nodeRef.foo, + + onEntering() { + count++; + expect(nodeRef.foo.current.className).toEqual( + 'down-enter down-enter-active' + ); + }, + onEntered() { + count++; + expect(nodeRef.foo.current.className).toEqual('down-enter-done'); + }, + }); + + await waitFor(() => { + expect(count).toEqual(4); }); }); }); diff --git a/test/CSSTransitionGroup-test.js b/test/CSSTransitionGroup-test.js index 472d8385..3f37106c 100644 --- a/test/CSSTransitionGroup-test.js +++ b/test/CSSTransitionGroup-test.js @@ -1,21 +1,25 @@ -import hasClass from 'dom-helpers/class/hasClass'; +import hasClass from 'dom-helpers/hasClass'; import CSSTransition from '../src/CSSTransition'; let React; let ReactDOM; let TransitionGroup; +let act; +let render; // Most of the real functionality is covered in other unit tests, this just // makes sure we're wired up correctly. describe('CSSTransitionGroup', () => { let container; + let consoleErrorSpy; function YoloTransition({ id, ...props }) { + const nodeRef = React.useRef(); return ( - - + + - ) + ); } beforeEach(() => { @@ -24,174 +28,187 @@ describe('CSSTransitionGroup', () => { React = require('react'); ReactDOM = require('react-dom'); + const testUtils = require('./utils'); + act = testUtils.act; + const baseRender = testUtils.render; + + render = (element, container) => + baseRender({element}, { container }); TransitionGroup = require('../src/TransitionGroup'); container = document.createElement('div'); - spyOn(console, 'error'); + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); }); + afterEach(() => { + consoleErrorSpy.mockRestore(); + jest.useRealTimers(); + }); it('should clean-up silently after the timeout elapses', () => { - let a = ReactDOM.render( + render( - + , - container, + container ); - expect(ReactDOM.findDOMNode(a).childNodes.length).toBe(1); + const transitionGroupDiv = container.childNodes[0]; + + expect(transitionGroupDiv.childNodes.length).toBe(1); - ReactDOM.render( + render( - + , - container, + container ); - expect(ReactDOM.findDOMNode(a).childNodes.length).toBe(2); - expect(ReactDOM.findDOMNode(a).childNodes[0].id).toBe('two'); - expect(ReactDOM.findDOMNode(a).childNodes[1].id).toBe('one'); - jest.runAllTimers(); + expect(transitionGroupDiv.childNodes.length).toBe(2); + expect(transitionGroupDiv.childNodes[0].id).toBe('two'); + expect(transitionGroupDiv.childNodes[1].id).toBe('one'); + + act(() => { + jest.runAllTimers(); + }); // No warnings - expect(console.error.calls.count()).toBe(0); + expect(consoleErrorSpy).not.toHaveBeenCalled(); // The leaving child has been removed - expect(ReactDOM.findDOMNode(a).childNodes.length).toBe(1); - expect(ReactDOM.findDOMNode(a).childNodes[0].id).toBe('two'); + expect(transitionGroupDiv.childNodes.length).toBe(1); + expect(transitionGroupDiv.childNodes[0].id).toBe('two'); }); it('should keep both sets of DOM nodes around', () => { - let a = ReactDOM.render( + render( , - container, + container ); - expect(ReactDOM.findDOMNode(a).childNodes.length).toBe(1); - ReactDOM.render( + + const transitionGroupDiv = container.childNodes[0]; + + expect(transitionGroupDiv.childNodes.length).toBe(1); + + render( , - container, + container ); - expect(ReactDOM.findDOMNode(a).childNodes.length).toBe(2); - expect(ReactDOM.findDOMNode(a).childNodes[0].id).toBe('two'); - expect(ReactDOM.findDOMNode(a).childNodes[1].id).toBe('one'); + + expect(transitionGroupDiv.childNodes.length).toBe(2); + expect(transitionGroupDiv.childNodes[0].id).toBe('two'); + expect(transitionGroupDiv.childNodes[1].id).toBe('one'); }); it('should switch transitionLeave from false to true', () => { - let a = ReactDOM.render( + render( , - container, + container ); - expect(ReactDOM.findDOMNode(a).childNodes.length).toBe(1); - ReactDOM.render( + + const transitionGroupDiv = container.childNodes[0]; + + expect(transitionGroupDiv.childNodes.length).toBe(1); + + render( , - container, + container ); - jest.runAllTimers(); - expect(ReactDOM.findDOMNode(a).childNodes.length).toBe(1); - ReactDOM.render( + act(() => { + jest.runAllTimers(); + }); + + expect(transitionGroupDiv.childNodes.length).toBe(1); + + render( , - container, + container ); - expect(ReactDOM.findDOMNode(a).childNodes.length).toBe(2); - expect(ReactDOM.findDOMNode(a).childNodes[0].id).toBe('three'); - expect(ReactDOM.findDOMNode(a).childNodes[1].id).toBe('two'); - }); + expect(transitionGroupDiv.childNodes.length).toBe(2); + expect(transitionGroupDiv.childNodes[0].id).toBe('three'); + expect(transitionGroupDiv.childNodes[1].id).toBe('two'); + }); it('should work with a null child', () => { - ReactDOM.render( - - {[null]} - , - container, - ); + render({[null]}, container); }); it('should work with a child which renders as null', () => { const NullComponent = () => null; // Testing the whole lifecycle of entering and exiting, // because those lifecycle methods used to fail when the DOM node was null. - ReactDOM.render( - , - container, - ); - ReactDOM.render( + render(, container); + render( - + , - container, - ); - ReactDOM.render( - , - container, + container ); + render(, container); }); it('should transition from one to null', () => { - let a = ReactDOM.render( + render( , - container, - ); - expect(ReactDOM.findDOMNode(a).childNodes.length).toBe(1); - ReactDOM.render( - - {null} - , - container, + container ); + + const transitionGroupDiv = container.childNodes[0]; + + expect(transitionGroupDiv.childNodes.length).toBe(1); + + render({null}, container); + // (Here, we expect the original child to stick around but test that no // exception is thrown) - expect(ReactDOM.findDOMNode(a).childNodes.length).toBe(1); - expect(ReactDOM.findDOMNode(a).childNodes[0].id).toBe('one'); + expect(transitionGroupDiv.childNodes.length).toBe(1); + expect(transitionGroupDiv.childNodes[0].id).toBe('one'); }); it('should transition from false to one', () => { - let a = ReactDOM.render( - - {false} - , - container, - ); - expect(ReactDOM.findDOMNode(a).childNodes.length).toBe(0); - ReactDOM.render( + render({false}, container); + + const transitionGroupDiv = container.childNodes[0]; + + expect(transitionGroupDiv.childNodes.length).toBe(0); + + render( , - container, + container ); - expect(ReactDOM.findDOMNode(a).childNodes.length).toBe(1); - expect(ReactDOM.findDOMNode(a).childNodes[0].id).toBe('one'); + + expect(transitionGroupDiv.childNodes.length).toBe(1); + expect(transitionGroupDiv.childNodes[0].id).toBe('one'); }); it('should clear transition timeouts when unmounted', () => { class Component extends React.Component { render() { - return ( - - {this.props.children} - - ); + return {this.props.children}; } } - ReactDOM.render(, container); - ReactDOM.render( + render(, container); + render( , @@ -201,7 +218,9 @@ describe('CSSTransitionGroup', () => { ReactDOM.unmountComponentAtNode(container); // Testing that no exception is thrown here, as the timeout has been cleared. - jest.runAllTimers(); + act(() => { + jest.runAllTimers(); + }); }); it('should handle unmounted elements properly', () => { @@ -228,10 +247,12 @@ describe('CSSTransitionGroup', () => { } } - ReactDOM.render(, container); + render(, container); // Testing that no exception is thrown here, as the timeout has been cleared. - jest.runAllTimers(); + act(() => { + jest.runAllTimers(); + }); }); it('should work with custom component wrapper cloning children', () => { @@ -240,10 +261,9 @@ describe('CSSTransitionGroup', () => { render() { return (
- { - React.Children.map(this.props.children, - child => React.cloneElement(child, { className: extraClassNameProp })) - } + {React.Children.map(this.props.children, (child) => + React.cloneElement(child, { className: extraClassNameProp }) + )}
); } @@ -258,20 +278,22 @@ describe('CSSTransitionGroup', () => { class Component extends React.Component { render() { return ( - + ); } } - const a = ReactDOM.render(, container); - const child = ReactDOM.findDOMNode(a).childNodes[0]; - expect(hasClass(child, extraClassNameProp)).toBe(true); + render(, container); + const transitionGroupDiv = container.childNodes[0]; + transitionGroupDiv.childNodes.forEach((child) => { + expect(hasClass(child, extraClassNameProp)).toBe(true); + }); // Testing that no exception is thrown here, as the timeout has been cleared. - jest.runAllTimers(); + act(() => { + jest.runAllTimers(); + }); }); }); diff --git a/test/ChildMapping-test.js b/test/ChildMapping-test.js index 554b8e0b..7afb2574 100644 --- a/test/ChildMapping-test.js +++ b/test/ChildMapping-test.js @@ -10,9 +10,19 @@ describe('ChildMapping', () => { it('should support getChildMapping', () => { let oneone =
; let onetwo =
; - let one =
{oneone}{onetwo}
; + let one = ( +
+ {oneone} + {onetwo} +
+ ); let two =
foo
; - let component =
{one}{two}
; + let component = ( +
+ {one} + {two} +
+ ); let mapping = ChildMapping.getChildMapping(component.props.children); diff --git a/test/SSR-test.js b/test/SSR-test.js new file mode 100644 index 00000000..629acacd --- /dev/null +++ b/test/SSR-test.js @@ -0,0 +1,10 @@ +/** + * @jest-environment node + */ + +// test that import does not crash +import * as ReactTransitionGroup from '../src'; // eslint-disable-line no-unused-vars + +describe('SSR', () => { + it('should import react-transition-group in node env', () => {}); +}); diff --git a/test/SwitchTransition-test.js b/test/SwitchTransition-test.js new file mode 100644 index 00000000..9250a586 --- /dev/null +++ b/test/SwitchTransition-test.js @@ -0,0 +1,160 @@ +import React from 'react'; + +import { act, render } from './utils'; + +import Transition, { ENTERED } from '../src/Transition'; +import SwitchTransition from '../src/SwitchTransition'; + +describe('SwitchTransition', () => { + let log, Parent; + beforeEach(() => { + log = []; + let events = { + onEnter: (m) => log.push(m ? 'appear' : 'enter'), + onEntering: (m) => log.push(m ? 'appearing' : 'entering'), + onEntered: (m) => log.push(m ? 'appeared' : 'entered'), + onExit: () => log.push('exit'), + onExiting: () => log.push('exiting'), + onExited: () => log.push('exited'), + }; + + const nodeRef = React.createRef(); + Parent = function Parent({ on, rendered = true }) { + return ( + + {rendered ? ( + + {on ? 'first' : 'second'} + + ) : null} + + ); + }; + + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should have default status ENTERED', () => { + const nodeRef = React.createRef(); + render( + + + {(status) => { + return status: {status}; + }} + + + ); + + expect(nodeRef.current.textContent).toBe(`status: ${ENTERED}`); + }); + + it('should have default mode: out-in', () => { + const firstNodeRef = React.createRef(); + const secondNodeRef = React.createRef(); + const { rerender } = render( + + + {(status) => { + return first status: {status}; + }} + + + ); + rerender( + + + {(status) => { + return second status: {status}; + }} + + + ); + + expect(firstNodeRef.current.textContent).toBe('first status: exiting'); + expect(secondNodeRef.current).toBe(null); + }); + + it('should work without childs', () => { + const nodeRef = React.createRef(); + expect(() => { + render( + + + + + + ); + }).not.toThrow(); + }); + + it('should switch between components on change state', () => { + const { container, setProps } = render(); + + expect(container.textContent).toBe('first'); + setProps({ on: false }); + expect(log).toEqual(['exit', 'exiting']); + act(() => { + jest.runAllTimers(); + }); + act(() => { + jest.runAllTimers(); + }); + expect(log).toEqual([ + 'exit', + 'exiting', + 'exited', + 'enter', + 'entering', + 'entered', + ]); + expect(container.textContent).toBe('second'); + }); + + it('should switch between null and component', () => { + const { container, setProps } = render( + + ); + + expect(container.textContent).toBe(''); + + jest.useFakeTimers(); + + setProps({ rendered: true }); + act(() => { + jest.runAllTimers(); + }); + expect(log).toEqual(['enter', 'entering', 'entered']); + expect(container.textContent).toBe('first'); + + setProps({ on: false, rendered: true }); + act(() => { + jest.runAllTimers(); + }); + act(() => { + jest.runAllTimers(); + }); + expect(log).toEqual([ + 'enter', + 'entering', + 'entered', + 'exit', + 'exiting', + 'exited', + 'enter', + 'entering', + 'entered', + ]); + + expect(container.textContent).toBe('second'); + }); +}); diff --git a/test/Transition-test.js b/test/Transition-test.js index 6034d86f..e9124e14 100644 --- a/test/Transition-test.js +++ b/test/Transition-test.js @@ -1,8 +1,7 @@ -import React from 'react' -import ReactDOM from 'react-dom' +import React from 'react'; +import ReactDOM from 'react-dom'; -import { mount } from 'enzyme' -import sinon from 'sinon' +import { render, waitFor } from './utils'; import Transition, { UNMOUNTED, @@ -10,64 +9,87 @@ import Transition, { ENTERING, ENTERED, EXITING, -} from '../src/Transition' - -jasmine.addMatchers({ - toExist: () => ({ - compare: actual => ({ - pass: actual != null, - }), - }), -}) +} from '../src/Transition'; + +expect.extend({ + toExist(received) { + const pass = received != null; + return pass + ? { + message: () => `expected ${received} to be null or undefined`, + pass: true, + } + : { + message: () => `expected ${received} not to be null or undefined`, + pass: false, + }; + }, +}); describe('Transition', () => { it('should not transition on mount', () => { - let wrapper = mount( + const nodeRef = React.createRef(); + render( { - throw new Error('should not Enter') + throw new Error('should not Enter'); }} > -
+ {(status) =>
status: {status}
} - ) + ); - expect(wrapper.state('status')).toEqual(ENTERED) - }) + expect(nodeRef.current.textContent).toEqual(`status: ${ENTERED}`); + }); - it('should transition on mount with `appear`', done => { - mount( + it('should transition on mount with `appear`', (done) => { + const nodeRef = React.createRef(); + render( { - throw Error('Animated!') + throw Error('Animated!'); }} > -
+
- ) + ); - mount( - done()}> -
+ render( + done()} + > +
- ) - }) + ); + }); it('should pass filtered props to children', () => { class Child extends React.Component { render() { - return
child
+ return ( +
+ foo: {this.props.foo}, bar: {this.props.bar} +
+ ); } } - const child = mount( + const nodeRef = React.createRef(); + render( { onExiting={() => {}} onExited={() => {}} > - + - ).find(Child) + ); - expect(child.props()).toEqual({ foo: 'foo', bar: 'bar' }) - }) + expect(nodeRef.current.textContent).toBe('foo: foo, bar: bar'); + }); - it('should allow addEndListener instead of timeouts', done => { - let listener = sinon.spy((node, end) => setTimeout(end, 0)) + it('should allow addEndListener instead of timeouts', async () => { + let listener = jest.fn((end) => setTimeout(end, 0)); + let done = false; - let inst = mount( + const nodeRef = React.createRef(); + const { setProps } = render( { - expect(listener.callCount).toEqual(1) - done() + expect(listener).toHaveBeenCalledTimes(1); + done = true; }} > -
+
- ) + ); - inst.setProps({ in: true }) - }) + setProps({ in: true }); - it('should fallback to timeouts with addEndListener', done => { - let calledEnd = false - let listener = (node, end) => + await waitFor(() => { + expect(done).toEqual(true); + }); + }); + + it('should fallback to timeouts with addEndListener', async () => { + let calledEnd = false; + let done = false; + let listener = (end) => setTimeout(() => { - calledEnd = true - end() - }, 100) + calledEnd = true; + end(); + }, 100); - let inst = mount( + const nodeRef = React.createRef(); + const { setProps } = render( { - expect(calledEnd).toEqual(false) - done() + expect(calledEnd).toEqual(false); + done = true; }} > +
+ + ); + + setProps({ in: true }); + + await waitFor(() => { + expect(done).toEqual(true); + }); + }); + + it('should mount/unmount immediately if not have enter/exit timeout', async () => { + const nodeRef = React.createRef(); + let done = false; + const { setProps } = render( + + {(status) =>
status: {status}
} +
+ ); + + expect(nodeRef.current.textContent).toEqual(`status: ${ENTERED}`); + let calledAfterTimeout = false; + setTimeout(() => { + calledAfterTimeout = true; + }, 10); + setProps({ + in: false, + onExited() { + expect(nodeRef.current.textContent).toEqual(`status: ${EXITED}`); + if (calledAfterTimeout) { + throw new Error('wrong timeout'); + } + done = true; + }, + }); + + await waitFor(() => { + expect(done).toEqual(true); + }); + }); + + it('should use `React.findDOMNode` when `nodeRef` is not provided', () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + const findDOMNodeSpy = jest.spyOn(ReactDOM, 'findDOMNode'); + + render( +
- ) + ); - inst.setProps({ in: true }) - }) + expect(findDOMNodeSpy).toHaveBeenCalled(); + findDOMNodeSpy.mockRestore(); + consoleSpy.mockRestore(); + }); - describe('entering', () => { - let wrapper + it('should not use `React.findDOMNode` when `nodeRef` is provided', () => { + const findDOMNodeSpy = jest.spyOn(ReactDOM, 'findDOMNode'); + + const nodeRef = React.createRef(); + render( + +
+ + ); + + expect(findDOMNodeSpy).not.toHaveBeenCalled(); + findDOMNodeSpy.mockRestore(); + }); - beforeEach(() => { - wrapper = mount( - -
+ describe('appearing timeout', () => { + it('should use enter timeout if appear not set', async () => { + let calledBeforeEntered = false; + let done = false; + setTimeout(() => { + calledBeforeEntered = true; + }, 10); + const nodeRef = React.createRef(); + const { setProps } = render( + +
- ) - }) + ); + + setProps({ + onEntered() { + if (calledBeforeEntered) { + done = true; + } else { + throw new Error('wrong timeout'); + } + }, + }); + + await waitFor(() => { + expect(done).toEqual(true); + }); + }); + + it('should use appear timeout if appear is set', async () => { + let done = false; + const nodeRef = React.createRef(); + const { setProps } = render( + +
+ + ); + + let isCausedLate = false; + setTimeout(() => { + isCausedLate = true; + }, 15); - it('should fire callbacks', done => { - let onEnter = sinon.spy() - let onEntering = sinon.spy() + setProps({ + onEntered() { + if (isCausedLate) { + throw new Error('wrong timeout'); + } else { + done = true; + } + }, + }); - expect(wrapper.state('status')).toEqual(EXITED) + await waitFor(() => { + expect(done).toEqual(true); + }); + }); + }); - wrapper.setProps({ + describe('entering', () => { + it('should fire callbacks', async () => { + let callOrder = []; + let done = false; + let onEnter = jest.fn(() => callOrder.push('onEnter')); + let onEntering = jest.fn(() => callOrder.push('onEntering')); + const nodeRef = React.createRef(); + const { setProps } = render( + + {(status) =>
status: {status}
} +
+ ); + + expect(nodeRef.current.textContent).toEqual(`status: ${EXITED}`); + + setProps({ in: true, onEnter, @@ -156,59 +318,69 @@ describe('Transition', () => { onEntering, onEntered() { - expect(onEnter.calledOnce).toEqual(true) - expect(onEntering.calledOnce).toEqual(true) - expect(onEnter.calledBefore(onEntering)).toEqual(true) - done() + expect(onEnter).toHaveBeenCalledTimes(1); + expect(onEntering).toHaveBeenCalledTimes(1); + expect(callOrder).toEqual(['onEnter', 'onEntering']); + done = true; }, - }) - }) - - it('should move to each transition state', done => { - let count = 0 + }); + + await waitFor(() => { + expect(done).toEqual(true); + }); + }); + + it('should move to each transition state', async () => { + let count = 0; + const nodeRef = React.createRef(); + const { setProps } = render( + + {(status) =>
status: {status}
} +
+ ); - expect(wrapper.state('status')).toEqual(EXITED) + expect(nodeRef.current.textContent).toEqual(`status: ${EXITED}`); - wrapper.setProps({ + setProps({ in: true, onEnter() { - count++ - expect(wrapper.state('status')).toEqual(EXITED) + count++; + expect(nodeRef.current.textContent).toEqual(`status: ${EXITED}`); }, onEntering() { - count++ - expect(wrapper.state('status')).toEqual(ENTERING) + count++; + expect(nodeRef.current.textContent).toEqual(`status: ${ENTERING}`); }, onEntered() { - expect(wrapper.state('status')).toEqual(ENTERED) - expect(count).toEqual(2) - done() + expect(nodeRef.current.textContent).toEqual(`status: ${ENTERED}`); }, - }) - }) - }) + }); - describe('exiting', () => { - let wrapper + await waitFor(() => { + expect(count).toEqual(2); + }); + }); + }); - beforeEach(() => { - wrapper = mount( - -
+ describe('exiting', () => { + it('should fire callbacks', async () => { + let callOrder = []; + let done = false; + let onExit = jest.fn(() => callOrder.push('onExit')); + let onExiting = jest.fn(() => callOrder.push('onExiting')); + const nodeRef = React.createRef(); + const { setProps } = render( + + {(status) =>
status: {status}
}
- ) - }) - - it('should fire callbacks', done => { - let onExit = sinon.spy() - let onExiting = sinon.spy() + ); - expect(wrapper.state('status')).toEqual(ENTERED) + expect(nodeRef.current.textContent).toEqual(`status: ${ENTERED}`); - wrapper.setProps({ + setProps({ in: false, onExit, @@ -216,180 +388,214 @@ describe('Transition', () => { onExiting, onExited() { - expect(onExit.calledOnce).toEqual(true) - expect(onExiting.calledOnce).toEqual(true) - expect(onExit.calledBefore(onExiting)).toEqual(true) - done() + expect(onExit).toHaveBeenCalledTimes(1); + expect(onExiting).toHaveBeenCalledTimes(1); + expect(callOrder).toEqual(['onExit', 'onExiting']); + done = true; }, - }) - }) - - it('should move to each transition state', done => { - let count = 0 + }); + + await waitFor(() => { + expect(done).toEqual(true); + }); + }); + + it('should move to each transition state', async () => { + let count = 0; + let done = false; + const nodeRef = React.createRef(); + const { setProps } = render( + + {(status) =>
status: {status}
} +
+ ); - expect(wrapper.state('status')).toEqual(ENTERED) + expect(nodeRef.current.textContent).toEqual(`status: ${ENTERED}`); - wrapper.setProps({ + setProps({ in: false, onExit() { - count++ - expect(wrapper.state('status')).toEqual(ENTERED) + count++; + expect(nodeRef.current.textContent).toEqual(`status: ${ENTERED}`); }, onExiting() { - count++ - expect(wrapper.state('status')).toEqual(EXITING) + count++; + expect(nodeRef.current.textContent).toEqual(`status: ${EXITING}`); }, onExited() { - expect(wrapper.state('status')).toEqual(EXITED) - expect(count).toEqual(2) - done() + expect(nodeRef.current.textContent).toEqual(`status: ${EXITED}`); + expect(count).toEqual(2); + done = true; }, - }) - }) - }) + }); + + await waitFor(() => { + expect(done).toEqual(true); + }); + }); + }); describe('mountOnEnter', () => { class MountTransition extends React.Component { - constructor(props) { - super(props) - this.state = { in: props.initialIn } - } + nodeRef = React.createRef(); render() { - const { ...props } = this.props - delete props.initialIn + const { ...props } = this.props; + delete props.initialIn; return ( this.transition = this.transition || transition} + ref={(transition) => + (this.transition = this.transition || transition) + } + nodeRef={this.nodeRef} mountOnEnter - in={this.state.in} + in={this.props.in} timeout={10} {...props} > -
+ {(status) =>
status: {status}
} - ) + ); } getStatus = () => { - return this.transition.state.status - } + return this.transition.state.status; + }; } - it('should mount when entering', done => { - const wrapper = mount( + it('should mount when entering', (done) => { + const { container, setProps } = render( { - expect(wrapper.instance().getStatus()).toEqual(EXITED) - expect(wrapper.getDOMNode()).toExist() - done() + expect(container.textContent).toEqual(`status: ${EXITED}`); + done(); }} /> - ) + ); - expect(wrapper.instance().getStatus()).toEqual(UNMOUNTED) + expect(container.textContent).toEqual(''); - expect(wrapper.getDOMNode()).not.toExist() + setProps({ in: true }); + }); - wrapper.setProps({ in: true }) - }) - - it('should stay mounted after exiting', done => { - const wrapper = mount( + it('should stay mounted after exiting', async () => { + let entered = false; + let exited = false; + const { container, setProps } = render( { - expect(wrapper.instance().getStatus()).toEqual(ENTERED) - expect(wrapper.getDOMNode()).toExist() - - wrapper.setState({ in: false }) + entered = true; }} onExited={() => { - expect(wrapper.instance().getStatus()).toEqual(EXITED) - expect(wrapper.getDOMNode()).toExist() - - done() + exited = true; }} /> - ) + ); + + expect(container.textContent).toEqual(''); + setProps({ in: true }); + + await waitFor(() => { + expect(entered).toEqual(true); + }); + expect(container.textContent).toEqual(`status: ${ENTERED}`); - expect(wrapper.getDOMNode()).not.toExist() - wrapper.setState({ in: true }) - }) - }) + setProps({ in: false }); + + await waitFor(() => { + expect(exited).toEqual(true); + }); + expect(container.textContent).toEqual(`status: ${EXITED}`); + }); + }); describe('unmountOnExit', () => { class UnmountTransition extends React.Component { - constructor(props) { - super(props) - - this.state = { in: props.initialIn } - } + nodeRef = React.createRef(); render() { - const { ...props } = this.props - delete props.initialIn + const { ...props } = this.props; + delete props.initialIn; return ( this.transition = this.transition || transition} + ref={(transition) => + (this.transition = this.transition || transition) + } + nodeRef={this.nodeRef} unmountOnExit - in={this.state.in} + in={this.props.in} timeout={10} {...props} > -
+
- ) + ); } getStatus = () => { - return this.transition.state.status - } + return this.transition.state.status; + }; } - it('should mount when entering', done => { - const wrapper = mount( + it('should mount when entering', async () => { + let done = false; + const instanceRef = React.createRef(); + const { setProps } = render( { - expect(wrapper.getStatus()).toEqual(EXITED) - expect(ReactDOM.findDOMNode(wrapper)).toExist() + expect(instanceRef.current.getStatus()).toEqual(EXITED); + expect(instanceRef.current.nodeRef.current).toExist(); - done() + done = true; }} /> - ).instance() + ); - expect(wrapper.getStatus()).toEqual(UNMOUNTED) - expect(ReactDOM.findDOMNode(wrapper)).toBeNull() + expect(instanceRef.current.getStatus()).toEqual(UNMOUNTED); + expect(instanceRef.current.nodeRef.current).toBeNull(); - wrapper.setState({ in: true }) - }) + setProps({ in: true }); - it('should unmount after exiting', done => { - const wrapper = mount( + await waitFor(() => { + expect(done).toEqual(true); + }); + }); + + it('should unmount after exiting', async () => { + let exited = false; + const instanceRef = React.createRef(); + const { setProps } = render( { setTimeout(() => { - expect(wrapper.getStatus()).toEqual(UNMOUNTED) - expect(ReactDOM.findDOMNode(wrapper)).not.toExist() - done() - }) + exited = true; + }); }} /> - ).instance() + ); + + expect(instanceRef.current.getStatus()).toEqual(ENTERED); + expect(instanceRef.current.nodeRef.current).toExist(); + + setProps({ in: false }); - expect(wrapper.getStatus()).toEqual(ENTERED) - expect(ReactDOM.findDOMNode(wrapper)).toExist() + await waitFor(() => { + expect(exited).toEqual(true); + }); - wrapper.setState({ in: false }) - }) - }) -}) + expect(instanceRef.current.getStatus()).toEqual(UNMOUNTED); + expect(instanceRef.current.nodeRef.current).not.toExist(); + }); + }); +}); diff --git a/test/TransitionGroup-test.js b/test/TransitionGroup-test.js index 9f453c9d..da07db44 100644 --- a/test/TransitionGroup-test.js +++ b/test/TransitionGroup-test.js @@ -1,186 +1,121 @@ -import { mount } from 'enzyme' - -let React -let ReactDOM -let TransitionGroup -let Transition +let React; +let TransitionGroup; +let Transition; // Most of the real functionality is covered in other unit tests, this just // makes sure we're wired up correctly. describe('TransitionGroup', () => { - let container, log, Child + let act, container, log, Child, renderStrict, render; beforeEach(() => { - React = require('react') - ReactDOM = require('react-dom') - Transition = require('../src/Transition').default - TransitionGroup = require('../src/TransitionGroup') + React = require('react'); + Transition = require('../src/Transition').default; + TransitionGroup = require('../src/TransitionGroup'); + const testUtils = require('./utils'); + act = testUtils.act; + render = testUtils.render; + + renderStrict = (element, container) => + render({element}, { container }); - container = document.createElement('div') + container = document.createElement('div'); - log = [] + log = []; let events = { - onEnter: (_, m) => log.push(m ? 'appear' : 'enter'), - onEntering: (_, m) => log.push(m ? 'appearing' : 'entering'), - onEntered: (_, m) => log.push(m ? 'appeared' : 'entered'), + onEnter: (m) => log.push(m ? 'appear' : 'enter'), + onEntering: (m) => log.push(m ? 'appearing' : 'entering'), + onEntered: (m) => log.push(m ? 'appeared' : 'entered'), onExit: () => log.push('exit'), onExiting: () => log.push('exiting'), onExited: () => log.push('exited'), - } + }; + const nodeRef = React.createRef(); Child = function Child(props) { return ( - - + + - ) - } - }) + ); + }; + }); it('should allow null components', () => { function FirstChild(props) { - const childrenArray = React.Children.toArray(props.children) - return childrenArray[0] || null + const childrenArray = React.Children.toArray(props.children); + return childrenArray[0] || null; } - mount( + render( - ) - }) + ); + }); it('should allow callback refs', () => { - const ref = jest.fn() + const ref = jest.fn(); class Child extends React.Component { render() { - return + return ; } } - mount( + render( - ) + ); - expect(ref).toHaveBeenCalled() - }) + expect(ref).toHaveBeenCalled(); + }); it('should work with no children', () => { - ReactDOM.render(, container) - }) + renderStrict(, container); + }); it('should handle transitioning correctly', () => { function Parent({ count = 1 }) { - let children = [] - for (let i = 0; i < count; i++) children.push() + let children = []; + for (let i = 0; i < count; i++) children.push(); return ( {children} - ) - } - - jest.useFakeTimers() - ReactDOM.render(, container) - - jest.runAllTimers() - expect(log).toEqual(['appear', 'appearing', 'appeared']) - - log = [] - ReactDOM.render(, container) - jest.runAllTimers() - expect(log).toEqual(['enter', 'entering', 'entered']) - - log = [] - ReactDOM.render(, container) - jest.runAllTimers() - expect(log).toEqual(['exit', 'exiting', 'exited']) - }) - - it('should not throw when enter callback is called and is now leaving', () => { - class Child extends React.Component { - componentWillReceiveProps() { - if (this.callback) { - this.callback() - } - } - - componentWillEnter(callback) { - this.callback = callback - } - - render() { - return - } - } - - class Component extends React.Component { - render() { - return {this.props.children} - } - } - - // render the base component - ReactDOM.render(, container) - // now make the child enter - ReactDOM.render( - - - , - container - ) - // rendering the child leaving will call 'componentWillProps' which will trigger the - // callback. This would throw an error previously. - expect(ReactDOM.render.bind(this, , container)).not.toThrow() - }) - - it('should not throw when leave callback is called and is now entering', () => { - class Child extends React.Component { - componentWillReceiveProps() { - if (this.callback) { - this.callback() - } - } - - componentWillLeave(callback) { - this.callback = callback - } - - render() { - return - } - } - - class Component extends React.Component { - render() { - return {this.props.children} - } + ); } - // render the base component - ReactDOM.render(, container) - // now make the child enter - ReactDOM.render( - - - , - container - ) - // make the child leave - ReactDOM.render(, container) - // rendering the child entering again will call 'componentWillProps' which will trigger the - // callback. This would throw an error previously. - expect( - ReactDOM.render.bind( - this, - - - , - container - ) - ).not.toThrow() - }) -}) + jest.useFakeTimers(); + renderStrict(, container); + + act(() => { + jest.runAllTimers(); + }); + expect(log).toEqual( + // React 18 StrictEffects will call `componentDidMount` twice causing two `onEnter` calls. + React.useTransition !== undefined + ? ['appear', 'appear', 'appearing', 'appeared'] + : ['appear', 'appearing', 'appeared'] + ); + + log = []; + renderStrict(, container); + act(() => { + jest.runAllTimers(); + }); + expect(log).toEqual( + // React 18 StrictEffects will call `componentDidMount` twice causing two `onEnter` calls. + React.useTransition !== undefined + ? ['enter', 'enter', 'entering', 'entered'] + : ['enter', 'entering', 'entered'] + ); + + log = []; + renderStrict(, container); + act(() => { + jest.runAllTimers(); + }); + expect(log).toEqual(['exit', 'exiting', 'exited']); + }); +}); diff --git a/test/setup.js b/test/setup.js index 4b7566f9..85c00e4d 100644 --- a/test/setup.js +++ b/test/setup.js @@ -1,9 +1,3 @@ - -global.requestAnimationFrame = function(callback) { +global.requestAnimationFrame = function (callback) { setTimeout(callback, 0); }; - -const Enzyme = require('enzyme'); -const Adapter = require('enzyme-adapter-react-16'); - -Enzyme.configure({ adapter: new Adapter() }) diff --git a/test/setupAfterEnv.js b/test/setupAfterEnv.js new file mode 100644 index 00000000..aa245bf1 --- /dev/null +++ b/test/setupAfterEnv.js @@ -0,0 +1,5 @@ +import { cleanup } from '@testing-library/react/pure'; + +afterEach(() => { + cleanup(); +}); diff --git a/test/utils.js b/test/utils.js new file mode 100644 index 00000000..e42aae60 --- /dev/null +++ b/test/utils.js @@ -0,0 +1,14 @@ +import { render as baseRender } from '@testing-library/react/pure'; +import React from 'react'; + +export * from '@testing-library/react'; +export function render(element, options) { + const result = baseRender(element, options); + + return { + ...result, + setProps(props) { + result.rerender(React.cloneElement(element, props)); + }, + }; +} diff --git a/www/.babelrc.js b/www/.babelrc.js index d7ccfcd3..760cc86e 100644 --- a/www/.babelrc.js +++ b/www/.babelrc.js @@ -1 +1,3 @@ -module.exports = require('../.babelrc'); +module.exports = { + presets: ['babel-preset-gatsby'], +}; diff --git a/www/.eslintrc.yml b/www/.eslintrc.yml deleted file mode 100644 index 790c9f25..00000000 --- a/www/.eslintrc.yml +++ /dev/null @@ -1,8 +0,0 @@ - -extends: - - prettier - - prettier/react -plugins: - - prettier -rules: - prettier/prettier: error diff --git a/www/.npmrc b/www/.npmrc new file mode 100644 index 00000000..b6f27f13 --- /dev/null +++ b/www/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/www/.prettierrc.yml b/www/.prettierrc.yml deleted file mode 100644 index b42ddd0a..00000000 --- a/www/.prettierrc.yml +++ /dev/null @@ -1,3 +0,0 @@ - -singleQuote: true -trailingComma: es5 diff --git a/www/gatsby-config.js b/www/gatsby-config.js index 0544da36..200b3664 100644 --- a/www/gatsby-config.js +++ b/www/gatsby-config.js @@ -9,13 +9,18 @@ module.exports = { { path: '/transition', displayName: 'Transition', - codeSandboxId: '741op4mmj0', + codeSandboxId: null, }, { path: '/css-transition', displayName: 'CSSTransition', codeSandboxId: 'm77l2vp00x', }, + { + path: '/switch-transition', + displayName: 'SwitchTransition', + codeSandboxId: 'switchtransition-component-iqm0d', + }, { path: '/transition-group', displayName: 'TransitionGroup', @@ -24,6 +29,7 @@ module.exports = { ], }, plugins: [ + 'gatsby-plugin-react-helmet', { resolve: 'gatsby-source-filesystem', options: { diff --git a/www/gatsby-node.js b/www/gatsby-node.js index 7abed0a6..28b70c5a 100644 --- a/www/gatsby-node.js +++ b/www/gatsby-node.js @@ -21,19 +21,19 @@ exports.createPages = ({ actions, graphql }) => { } } } - `).then(result => { + `).then((result) => { if (result.errors) { reject(result.errors); } const { componentPages } = config.siteMetadata; result.data.allComponentMetadata.edges .filter(({ node: { displayName } }) => - componentPages.some(page => page.displayName === displayName) + componentPages.some((page) => page.displayName === displayName) ) .forEach(({ node: { displayName } }) => { createPage({ path: componentPages.find( - page => page.displayName === displayName + (page) => page.displayName === displayName ).path, component: componentTemplate, context: { diff --git a/www/package.json b/www/package.json index 66b558d1..7bea35a4 100644 --- a/www/package.json +++ b/www/package.json @@ -1,4 +1,5 @@ { + "private": true, "name": "react-transition-group-docs", "version": "1.0.0", "description": "", @@ -11,21 +12,29 @@ }, "author": "", "license": "MIT", + "engines": { + "node": "<=16" + }, "dependencies": { - "bootstrap": "^3.3.7", - "gatsby": "^2.0.0-beta.12", - "gatsby-plugin-sass": "^2.0.0-beta.3", - "gatsby-remark-prismjs": "^1.0.0", - "gatsby-source-filesystem": "^2.0.1-beta.3", - "gatsby-transformer-react-docgen": "^2.1.1-beta.3", - "gatsby-transformer-remark": "^1.0.0", - "lodash": "^4.17.4", - "react": "^16.4.1", - "react-bootstrap": "^0.32.1", - "react-dom": "^16.4.1" + "@babel/core": "^7.3.4", + "babel-preset-gatsby": "^2.7.0", + "bootstrap": "^4.3.1", + "gatsby": "^2.1.22", + "gatsby-plugin-react-helmet": "^3.0.10", + "gatsby-plugin-sass": "^2.0.10", + "gatsby-remark-prismjs": "^3.2.4", + "gatsby-source-filesystem": "^2.0.23", + "gatsby-transformer-react-docgen": "^3.0.5", + "gatsby-transformer-remark": "^2.3.0", + "lodash": "^4.17.19", + "prismjs": "^1.25.0", + "react": "^16.8.3", + "react-bootstrap": "^1.0.0-beta.5", + "react-dom": "^16.8.3", + "react-helmet": "^5.2.0", + "sass": "^1.49.7" }, "devDependencies": { - "gh-pages": "^1.0.0", - "node-sass": "^4.9.0" + "gh-pages": "^2.0.1" } } diff --git a/www/src/components/Example.js b/www/src/components/Example.js index 9ba0d7a5..a8380da2 100644 --- a/www/src/components/Example.js +++ b/www/src/components/Example.js @@ -1,6 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { Grid } from 'react-bootstrap'; +import { Container } from 'react-bootstrap'; const propTypes = { codeSandbox: PropTypes.shape({ @@ -10,20 +10,22 @@ const propTypes = { }; const Example = ({ codeSandbox }) => ( -
- +
+

Example

- -
`, + +