diff --git a/.babelrc b/.babelrc deleted file mode 100644 index 9a771d1d..00000000 --- a/.babelrc +++ /dev/null @@ -1,8 +0,0 @@ -{ - "presets": ["next/babel"], - "env": { - "production": { - "plugins": ["transform-remove-console"] - } - } -} diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index afaabb13..00000000 --- a/.eslintignore +++ /dev/null @@ -1,7 +0,0 @@ -# Ignore nested node_modules -node_modules/ -coverage/ -dist/ -.cache -.next -.vercel diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index d778d3cf..00000000 --- a/.eslintrc.js +++ /dev/null @@ -1,9 +0,0 @@ -const { version: reactVersion } = require('react/package.json') - -module.exports = { - extends: ['react-app'], - settings: { react: { version: reactVersion } }, - rules: { - 'jsx-a11y/anchor-is-valid': ['off'], - }, -} diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index 818bd4f3..7d6da65e 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -1,13 +1,14 @@ name: CI +# Pushes to main are covered by the release workflow, which runs the same checks on: - push: - branches: - - main pull_request: branches: - '**' +permissions: + contents: read + jobs: test: name: Test on Node.js ${{ matrix.node }} @@ -17,14 +18,15 @@ jobs: strategy: fail-fast: false matrix: - node: [12, 14] + node: [22, 24] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v5 - - uses: actions/setup-node@v2 + - uses: actions/setup-node@v5 with: node-version: ${{ matrix.node }} + cache: npm - run: npm ci diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 65020424..a4b56be0 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,43 +1,44 @@ name: Release and Publish on: - pull_request: push: branches: [main] +permissions: + contents: read + jobs: test: runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@v2 - - name: Setup Node.js - uses: actions/setup-node@v2 + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 with: - node-version: 14.x - - name: npm install - run: | - npm install - - name: npm test - run: | - npm test + node-version: 22 + cache: npm + - run: npm ci + - run: npm test + release: - if: github.event_name == 'push' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest needs: [test] + permissions: + contents: write + # Lets npm attach build provenance to the published package + id-token: write steps: - - name: Checkout - uses: actions/checkout@v2 - - name: Setup Node.js - uses: actions/setup-node@v2 + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-node@v5 with: - node-version: 14.x - - name: npm install - run: | - npm install - - name: npx semantic-release - run: | - npx semantic-release + node-version: 22 + cache: npm + registry-url: https://registry.npmjs.org + - run: npm ci + - run: npx semantic-release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + NPM_CONFIG_PROVENANCE: true diff --git a/.gitignore b/.gitignore index fef9369a..d1b3272d 100644 --- a/.gitignore +++ b/.gitignore @@ -136,4 +136,5 @@ yarn-error.log* .env.production.local # vercel -.vercel \ No newline at end of file +.vercel +.claude/ diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 00000000..2312dc58 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +npx lint-staged diff --git a/CHANGELOG.md b/CHANGELOG.md index 037418ee..0da1402d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,118 +1,192 @@ -## [3.4.1](https://github.com/stipsan/react-spring-bottom-sheet/compare/v3.4.0...v3.4.1) (2022-06-01) +## [3.10.2](https://github.com/Guiw5/react-spring-bottom-sheet/compare/v3.10.1...v3.10.2) (2026-10-11) + +### Bug Fixes + +- call onDismiss once per backdrop tap or swipe ([d702d31](https://github.com/Guiw5/react-spring-bottom-sheet/commit/d702d3176f78b846e96043be8d6799035adbc212)) +## [3.10.1](https://github.com/Guiw5/react-spring-bottom-sheet/compare/v3.10.0...v3.10.1) (2026-09-09) ### Bug Fixes -* add 18 to react peer dep ([d89c0bb](https://github.com/stipsan/react-spring-bottom-sheet/commit/d89c0bbbd28e89bfc9aaf5136c83b6f254d2430b)) +- stop pointing the docs at another fork's site ([f4098b6](https://github.com/Guiw5/react-spring-bottom-sheet/commit/f4098b6b3f8a69c9d03e65a550e6fc6d992f0aa7)) -# [3.4.0](https://github.com/stipsan/react-spring-bottom-sheet/compare/v3.3.0...v3.4.0) (2021-06-23) +# [3.10.0](https://github.com/Guiw5/react-spring-bottom-sheet/compare/v3.9.3...v3.10.0) (2026-09-09) + +### Features + +- motion recipes and a working springConfig ([7e6d9cc](https://github.com/Guiw5/react-spring-bottom-sheet/commit/7e6d9cc3e4d2f6c13787910c1510646fbb6f4794)) + +## [3.9.3](https://github.com/Guiw5/react-spring-bottom-sheet/compare/v3.9.2...v3.9.3) (2026-09-09) + +### Performance Improvements + +- write immediate spring updates synchronously ([9a7cf3c](https://github.com/Guiw5/react-spring-bottom-sheet/commit/9a7cf3c71643b07b354bfae21642e1b4ff16515b)) + +## [3.9.2](https://github.com/Guiw5/react-spring-bottom-sheet/compare/v3.9.1...v3.9.2) (2026-09-09) + +### Performance Improvements +- derive the ready flag and composite the divider lines ([617ab12](https://github.com/Guiw5/react-spring-bottom-sheet/commit/617ab124a0741fb2023653c3054ff98ecf7dcac8)) + +## [3.9.1](https://github.com/Guiw5/react-spring-bottom-sheet/compare/v3.9.0...v3.9.1) (2026-09-08) + +### Bug Fixes + +- stop the release from masking its own error ([f2b4280](https://github.com/Guiw5/react-spring-bottom-sheet/commit/f2b42809bd9ce47837209f3c25f4c66bcf1e5413)) + +# [3.9.0](https://github.com/Guiw5/react-spring-bottom-sheet/compare/v3.8.0...v3.9.0) (2026-09-08) ### Features -* expand on content drag ([#141](https://github.com/stipsan/react-spring-bottom-sheet/issues/141)) ([ec733a5](https://github.com/stipsan/react-spring-bottom-sheet/commit/ec733a5a64ce5324946305bc9616749fea81ddbd)) +- React 19 support, migrate to @react-spring/web and @use-gesture/react ([ced18c3](https://github.com/Guiw5/react-spring-bottom-sheet/commit/ced18c376817b92d59720ca7142fd5c3966d56f6)) -# [3.3.0](https://github.com/stipsan/react-spring-bottom-sheet/compare/v3.2.1...v3.3.0) (2021-06-15) +# [3.8.0](https://github.com/guiw5/react-spring-bottom-sheet/compare/v3.7.1...v3.8.0) (2025-05-06) + +### Features + +- exposing more props for springConfig + keepMounted ([4418438](https://github.com/guiw5/react-spring-bottom-sheet/commit/44184387e30c1b0c2951c23ed580f8940a575007)) + +## [3.7.1](https://github.com/guiw5/react-spring-bottom-sheet/compare/v3.7.0...v3.7.1) (2025-05-06) + +### Bug Fixes +- add 18 to react peer dep ([d89c0bb](https://github.com/guiw5/react-spring-bottom-sheet/commit/d89c0bbbd28e89bfc9aaf5136c83b6f254d2430b)) + +# [3.7.0](https://github.com/guiw5/react-spring-bottom-sheet/compare/v3.6.0...v3.7.0) (2022-04-07) ### Features -* allow the developer to disable focus trap ([#138](https://github.com/stipsan/react-spring-bottom-sheet/issues/138)) ([ef176b3](https://github.com/stipsan/react-spring-bottom-sheet/commit/ef176b365426ed6fa5f14277f76df4dbb04b55db)) +- keep bottomSheet mounted ([de05fb0](https://github.com/guiw5/react-spring-bottom-sheet/commit/de05fb0a4148753e235d3ee80aa06613f86181ac)) -## [3.2.1](https://github.com/stipsan/react-spring-bottom-sheet/compare/v3.2.0...v3.2.1) (2021-01-26) +# [3.6.0](https://github.com/guiw5/react-spring-bottom-sheet/compare/v3.5.2...v3.6.0) (2022-04-06) + +### Features + +- expose scrollElement into sheetRef ([829148f](https://github.com/guiw5/react-spring-bottom-sheet/commit/829148f0a5ffd33fe75f6ebd693a3c370c4f9756)) +## [3.5.2](https://github.com/guiw5/react-spring-bottom-sheet/compare/v3.5.1...v3.5.2) (2022-04-05) ### Bug Fixes -* **deps:** update dependency @reach/portal to ^0.13.0 ([#94](https://github.com/stipsan/react-spring-bottom-sheet/issues/94)) ([1cc8056](https://github.com/stipsan/react-spring-bottom-sheet/commit/1cc8056624d201414c81c8ee7c01b827064eecee)) +- allow null ref ([2652ca8](https://github.com/guiw5/react-spring-bottom-sheet/commit/2652ca89ebf8e5046fa97f123e5358616d5bb708)) -# [3.2.0](https://github.com/stipsan/react-spring-bottom-sheet/compare/v3.1.4...v3.2.0) (2021-01-21) +## [3.5.1](https://github.com/guiw5/react-spring-bottom-sheet/compare/v3.5.0...v3.5.1) (2022-04-05) + +### Bug Fixes + +- useRef ([8da8e6e](https://github.com/guiw5/react-spring-bottom-sheet/commit/8da8e6e331bb3a4513dd0eeb7712d5542a7895d9)) + +# [3.5.0](https://github.com/guiw5/react-spring-bottom-sheet/compare/v3.4.1...v3.5.0) (2022-04-05) + +### Features +- expose scrollRef ([41075d6](https://github.com/guiw5/react-spring-bottom-sheet/commit/41075d6e81ea4c4002ba9401d291239678fdb4b2)) + +## [3.4.1](https://github.com/guiw5/react-spring-bottom-sheet/compare/v3.4.0...v3.4.1) (2022-04-02) + +### Bug Fixes + +- change default duration ([1976bdf](https://github.com/guiw5/react-spring-bottom-sheet/commit/1976bdf76241c7366835ca93ee45c9f9392e3128)) + +## [3.4.1](https://github.com/stipsan/react-spring-bottom-sheet/compare/v3.4.0...v3.4.1) (2022-06-01) ### Bug Fixes -* better velocity physics ([65390c7](https://github.com/stipsan/react-spring-bottom-sheet/commit/65390c7043aa15c415cb5b19bc2ea1799c6cf4fc)) -* only animate on RESIZE if the source = element ([ceb3671](https://github.com/stipsan/react-spring-bottom-sheet/commit/ceb3671887409184924db814226a6355dd795dbe)) -* use layout effect to prevent tearing on Safari ([6b62559](https://github.com/stipsan/react-spring-bottom-sheet/commit/6b62559218d83ac00bd035ae8dc4411aed6b7fb0)) +- add 18 to react peer dep ([d89c0bb](https://github.com/stipsan/react-spring-bottom-sheet/commit/d89c0bbbd28e89bfc9aaf5136c83b6f254d2430b)) +# [3.4.0](https://github.com/stipsan/react-spring-bottom-sheet/compare/v3.3.0...v3.4.0) (2021-06-23) ### Features -* add height getter to ref ([51c8510](https://github.com/stipsan/react-spring-bottom-sheet/commit/51c8510d2f6b69476877fb549afba49aa5bda5de)) -* add source to RESIZE events ([383e206](https://github.com/stipsan/react-spring-bottom-sheet/commit/383e2062594de202ae77c25ac2ed9ae1137bcb2b)), closes [#53](https://github.com/stipsan/react-spring-bottom-sheet/issues/53) -* override source and velocity in snapTo ([1afe79f](https://github.com/stipsan/react-spring-bottom-sheet/commit/1afe79f317086d0ab24869a3e312cb766a992c86)) -* SNAP events now have `source` ([240c212](https://github.com/stipsan/react-spring-bottom-sheet/commit/240c21232c155e1c58e5b0526ab6f121706a5161)), closes [#87](https://github.com/stipsan/react-spring-bottom-sheet/issues/87) +- expand on content drag ([#141](https://github.com/stipsan/react-spring-bottom-sheet/issues/141)) ([ec733a5](https://github.com/stipsan/react-spring-bottom-sheet/commit/ec733a5a64ce5324946305bc9616749fea81ddbd)) -## [3.1.4](https://github.com/stipsan/react-spring-bottom-sheet/compare/v3.1.3...v3.1.4) (2020-12-29) +# [3.3.0](https://github.com/stipsan/react-spring-bottom-sheet/compare/v3.2.1...v3.3.0) (2021-06-15) +### Features + +- allow the developer to disable focus trap ([#138](https://github.com/stipsan/react-spring-bottom-sheet/issues/138)) ([ef176b3](https://github.com/stipsan/react-spring-bottom-sheet/commit/ef176b365426ed6fa5f14277f76df4dbb04b55db)) + +## [3.2.1](https://github.com/stipsan/react-spring-bottom-sheet/compare/v3.2.0...v3.2.1) (2021-01-26) ### Bug Fixes -* improve TS definitions ([a8102bf](https://github.com/stipsan/react-spring-bottom-sheet/commit/a8102bfd35e13238e8f09f8edc2fa8ddfa90fe1a)) +- **deps:** update dependency @reach/portal to ^0.13.0 ([#94](https://github.com/stipsan/react-spring-bottom-sheet/issues/94)) ([1cc8056](https://github.com/stipsan/react-spring-bottom-sheet/commit/1cc8056624d201414c81c8ee7c01b827064eecee)) -## [3.1.3](https://github.com/stipsan/react-spring-bottom-sheet/compare/v3.1.2...v3.1.3) (2020-12-29) +# [3.2.0](https://github.com/stipsan/react-spring-bottom-sheet/compare/v3.1.4...v3.2.0) (2021-01-21) + +### Bug Fixes + +- better velocity physics ([65390c7](https://github.com/stipsan/react-spring-bottom-sheet/commit/65390c7043aa15c415cb5b19bc2ea1799c6cf4fc)) +- only animate on RESIZE if the source = element ([ceb3671](https://github.com/stipsan/react-spring-bottom-sheet/commit/ceb3671887409184924db814226a6355dd795dbe)) +- use layout effect to prevent tearing on Safari ([6b62559](https://github.com/stipsan/react-spring-bottom-sheet/commit/6b62559218d83ac00bd035ae8dc4411aed6b7fb0)) +### Features + +- add height getter to ref ([51c8510](https://github.com/stipsan/react-spring-bottom-sheet/commit/51c8510d2f6b69476877fb549afba49aa5bda5de)) +- add source to RESIZE events ([383e206](https://github.com/stipsan/react-spring-bottom-sheet/commit/383e2062594de202ae77c25ac2ed9ae1137bcb2b)), closes [#53](https://github.com/stipsan/react-spring-bottom-sheet/issues/53) +- override source and velocity in snapTo ([1afe79f](https://github.com/stipsan/react-spring-bottom-sheet/commit/1afe79f317086d0ab24869a3e312cb766a992c86)) +- SNAP events now have `source` ([240c212](https://github.com/stipsan/react-spring-bottom-sheet/commit/240c21232c155e1c58e5b0526ab6f121706a5161)), closes [#87](https://github.com/stipsan/react-spring-bottom-sheet/issues/87) + +## [3.1.4](https://github.com/stipsan/react-spring-bottom-sheet/compare/v3.1.3...v3.1.4) (2020-12-29) ### Bug Fixes -* export prop types ts definitions ([36cf999](https://github.com/stipsan/react-spring-bottom-sheet/commit/36cf999bfd8d1a0564ba9770a401e09a8650c607)) +- improve TS definitions ([a8102bf](https://github.com/stipsan/react-spring-bottom-sheet/commit/a8102bfd35e13238e8f09f8edc2fa8ddfa90fe1a)) -## [3.1.2](https://github.com/stipsan/react-spring-bottom-sheet/compare/v3.1.1...v3.1.2) (2020-12-29) +## [3.1.3](https://github.com/stipsan/react-spring-bottom-sheet/compare/v3.1.2...v3.1.3) (2020-12-29) +### Bug Fixes + +- export prop types ts definitions ([36cf999](https://github.com/stipsan/react-spring-bottom-sheet/commit/36cf999bfd8d1a0564ba9770a401e09a8650c607)) + +## [3.1.2](https://github.com/stipsan/react-spring-bottom-sheet/compare/v3.1.1...v3.1.2) (2020-12-29) ### Bug Fixes -* avoid dividing by zero during prerender ([ca2b9ae](https://github.com/stipsan/react-spring-bottom-sheet/commit/ca2b9ae2b97d1b871976d34f6b128e9b0386932d)) +- avoid dividing by zero during prerender ([ca2b9ae](https://github.com/stipsan/react-spring-bottom-sheet/commit/ca2b9ae2b97d1b871976d34f6b128e9b0386932d)) ## [3.1.1](https://github.com/stipsan/react-spring-bottom-sheet/compare/v3.1.0...v3.1.1) (2020-12-29) - ### Bug Fixes -* iOS display cutouts caused a scrollbar in some cases ([45c182c](https://github.com/stipsan/react-spring-bottom-sheet/commit/45c182c1c360ad060a76c50fc3d167876de15317)) +- iOS display cutouts caused a scrollbar in some cases ([45c182c](https://github.com/stipsan/react-spring-bottom-sheet/commit/45c182c1c360ad060a76c50fc3d167876de15317)) # [3.1.0](https://github.com/stipsan/react-spring-bottom-sheet/compare/v3.0.3...v3.1.0) (2020-12-29) - ### Features -* add skipInitialTransition to make skipping opt-in ([f200ad0](https://github.com/stipsan/react-spring-bottom-sheet/commit/f200ad04440c34ce1391ebf7cafcf9ecee88e4ae)) +- add skipInitialTransition to make skipping opt-in ([f200ad0](https://github.com/stipsan/react-spring-bottom-sheet/commit/f200ad04440c34ce1391ebf7cafcf9ecee88e4ae)) ## [3.0.3](https://github.com/stipsan/react-spring-bottom-sheet/compare/v3.0.2...v3.0.3) (2020-12-28) - ### Bug Fixes -* xstate deprecation warning on null events ([e44e273](https://github.com/stipsan/react-spring-bottom-sheet/commit/e44e273ec2f560228084e2f688e247933f0fa232)) +- xstate deprecation warning on null events ([e44e273](https://github.com/stipsan/react-spring-bottom-sheet/commit/e44e273ec2f560228084e2f688e247933f0fa232)) ## [3.0.2](https://github.com/stipsan/react-spring-bottom-sheet/compare/v3.0.1...v3.0.2) (2020-12-28) - ### Bug Fixes -* RESIZE should never animate the content opacity ([2470ccc](https://github.com/stipsan/react-spring-bottom-sheet/commit/2470cccb554ca9cf2efe343bdb8b8e4b836a3ae2)) +- RESIZE should never animate the content opacity ([2470ccc](https://github.com/stipsan/react-spring-bottom-sheet/commit/2470cccb554ca9cf2efe343bdb8b8e4b836a3ae2)) ## [3.0.1](https://github.com/stipsan/react-spring-bottom-sheet/compare/v3.0.0...v3.0.1) (2020-12-28) - ### Bug Fixes -* filter out taps on drag ([bc709a0](https://github.com/stipsan/react-spring-bottom-sheet/commit/bc709a0becd45b37e8661ba530702e86626618b6)) +- filter out taps on drag ([bc709a0](https://github.com/stipsan/react-spring-bottom-sheet/commit/bc709a0becd45b37e8661ba530702e86626618b6)) # [3.0.0](https://github.com/stipsan/react-spring-bottom-sheet/compare/v2.3.0...v3.0.0) (2020-12-28) - ### Bug Fixes -* remove padding wrappers ([#57](https://github.com/stipsan/react-spring-bottom-sheet/issues/57)) ([60657fb](https://github.com/stipsan/react-spring-bottom-sheet/commit/60657fbe752abbf07ee77264ad9e2aaff7a32db4)) - +- remove padding wrappers ([#57](https://github.com/stipsan/react-spring-bottom-sheet/issues/57)) ([60657fb](https://github.com/stipsan/react-spring-bottom-sheet/commit/60657fbe752abbf07ee77264ad9e2aaff7a32db4)) ### BREAKING CHANGES -* The resize observer logic is rewritten to no longer require wrapper elements like `[data-rsbs-footer-padding]`. If you're not using custom CSS and are simply importing `react-spring-bottom-sheet/dist/style.css` in your app then this isn't a breaking change for you. - +- The resize observer logic is rewritten to no longer require wrapper elements like `[data-rsbs-footer-padding]`. If you're not using custom CSS and are simply importing `react-spring-bottom-sheet/dist/style.css` in your app then this isn't a breaking change for you. If you're using custom CSS, here's the breaking changes: + - `[data-rsbs-header-padding]` removed, update selectors to `[data-rsbs-header]` - `[data-rsbs-content-padding]` removed, update selectors to `[data-rsbs-scroll]` - `[data-rsbs-footer-padding]` removed, update selectors to `[data-rsbs-footer]` @@ -122,162 +196,139 @@ If you're using custom CSS, here's the breaking changes: # [2.3.0](https://github.com/stipsan/react-spring-bottom-sheet/compare/v2.2.7...v2.3.0) (2020-12-28) - ### Features -* refactor to xstate ([#46](https://github.com/stipsan/react-spring-bottom-sheet/issues/46)) ([6b2f92a](https://github.com/stipsan/react-spring-bottom-sheet/commit/6b2f92ae5556026c3cf2496d7bbb3828996e0673)) +- refactor to xstate ([#46](https://github.com/stipsan/react-spring-bottom-sheet/issues/46)) ([6b2f92a](https://github.com/stipsan/react-spring-bottom-sheet/commit/6b2f92ae5556026c3cf2496d7bbb3828996e0673)) ## [2.2.7](https://github.com/stipsan/react-spring-bottom-sheet/compare/v2.2.6...v2.2.7) (2020-12-19) - ### Bug Fixes -* dragging can overshoot if onDismiss is undefined ([#43](https://github.com/stipsan/react-spring-bottom-sheet/issues/43)) ([549ab30](https://github.com/stipsan/react-spring-bottom-sheet/commit/549ab30be66dd138fb59a9bf869eb77002349b6f)) +- dragging can overshoot if onDismiss is undefined ([#43](https://github.com/stipsan/react-spring-bottom-sheet/issues/43)) ([549ab30](https://github.com/stipsan/react-spring-bottom-sheet/commit/549ab30be66dd138fb59a9bf869eb77002349b6f)) ## [2.2.6](https://github.com/stipsan/react-spring-bottom-sheet/compare/v2.2.5...v2.2.6) (2020-12-08) - ### Bug Fixes -* better type declarations ([c19dd44](https://github.com/stipsan/react-spring-bottom-sheet/commit/c19dd446827dcb32cee664f84fea250beb86c1ff)) +- better type declarations ([c19dd44](https://github.com/stipsan/react-spring-bottom-sheet/commit/c19dd446827dcb32cee664f84fea250beb86c1ff)) ## [2.2.5](https://github.com/stipsan/react-spring-bottom-sheet/compare/v2.2.4...v2.2.5) (2020-12-08) - ### Bug Fixes -* improve rubber band effect when out of bounds ([#29](https://github.com/stipsan/react-spring-bottom-sheet/issues/29)) ([4f2fe09](https://github.com/stipsan/react-spring-bottom-sheet/commit/4f2fe090774bcfbfd5171281701cc5ba68db8a44)) +- improve rubber band effect when out of bounds ([#29](https://github.com/stipsan/react-spring-bottom-sheet/issues/29)) ([4f2fe09](https://github.com/stipsan/react-spring-bottom-sheet/commit/4f2fe090774bcfbfd5171281701cc5ba68db8a44)) ## [2.2.4](https://github.com/stipsan/react-spring-bottom-sheet/compare/v2.2.3...v2.2.4) (2020-12-07) - ### Bug Fixes -* more stable defaultSnap RESIZE sync ([230dbe2](https://github.com/stipsan/react-spring-bottom-sheet/commit/230dbe2bf0d3fe0f0314e444e0b2154fe5d2295f)) -* readjust defaultSnap on resize ([b812786](https://github.com/stipsan/react-spring-bottom-sheet/commit/b812786697d2a7c58ce87bf5106cbb524a59193d)) +- more stable defaultSnap RESIZE sync ([230dbe2](https://github.com/stipsan/react-spring-bottom-sheet/commit/230dbe2bf0d3fe0f0314e444e0b2154fe5d2295f)) +- readjust defaultSnap on resize ([b812786](https://github.com/stipsan/react-spring-bottom-sheet/commit/b812786697d2a7c58ce87bf5106cbb524a59193d)) ## [2.2.3](https://github.com/stipsan/react-spring-bottom-sheet/compare/v2.2.2...v2.2.3) (2020-12-07) - ### Bug Fixes -* make open transitions more interruptible on iOS ([#23](https://github.com/stipsan/react-spring-bottom-sheet/issues/23)) ([f898dd0](https://github.com/stipsan/react-spring-bottom-sheet/commit/f898dd035fc0087237ec993789c40675c0dcd080)) +- make open transitions more interruptible on iOS ([#23](https://github.com/stipsan/react-spring-bottom-sheet/issues/23)) ([f898dd0](https://github.com/stipsan/react-spring-bottom-sheet/commit/f898dd035fc0087237ec993789c40675c0dcd080)) ## [2.2.2](https://github.com/stipsan/react-spring-bottom-sheet/compare/v2.2.1...v2.2.2) (2020-12-05) - ### Bug Fixes -* updated readme with more API docs and examples ([6a48fab](https://github.com/stipsan/react-spring-bottom-sheet/commit/6a48fab13285d113921d2c45e7a96e00e12138f3)) +- updated readme with more API docs and examples ([6a48fab](https://github.com/stipsan/react-spring-bottom-sheet/commit/6a48fab13285d113921d2c45e7a96e00e12138f3)) ## [2.2.1](https://github.com/stipsan/react-spring-bottom-sheet/compare/v2.2.0...v2.2.1) (2020-12-05) - ### Bug Fixes -* rewrite interpolations to always be in frame sync ([cc43467](https://github.com/stipsan/react-spring-bottom-sheet/commit/cc43467062e212f121045bf377b752c64c337213)) +- rewrite interpolations to always be in frame sync ([cc43467](https://github.com/stipsan/react-spring-bottom-sheet/commit/cc43467062e212f121045bf377b752c64c337213)) # [2.2.0](https://github.com/stipsan/react-spring-bottom-sheet/compare/v2.1.0...v2.2.0) (2020-12-05) - ### Features -* Add RESIZE events ([b3ff691](https://github.com/stipsan/react-spring-bottom-sheet/commit/b3ff691f6d893e56235e207864e63b61c8740750)) +- Add RESIZE events ([b3ff691](https://github.com/stipsan/react-spring-bottom-sheet/commit/b3ff691f6d893e56235e207864e63b61c8740750)) # [2.1.0](https://github.com/stipsan/react-spring-bottom-sheet/compare/v2.0.3...v2.1.0) (2020-12-05) - ### Bug Fixes -* use rAF instead of timeout to schedule unmount ([7f3c018](https://github.com/stipsan/react-spring-bottom-sheet/commit/7f3c0181a001a78aaad0d5318dfe9f8af15c3d05)) - +- use rAF instead of timeout to schedule unmount ([7f3c018](https://github.com/stipsan/react-spring-bottom-sheet/commit/7f3c0181a001a78aaad0d5318dfe9f8af15c3d05)) ### Features -* add sibling prop support ([767215b](https://github.com/stipsan/react-spring-bottom-sheet/commit/767215b60ea08dfabe90c4a7d5d5f2a85684064e)) +- add sibling prop support ([767215b](https://github.com/stipsan/react-spring-bottom-sheet/commit/767215b60ea08dfabe90c4a7d5d5f2a85684064e)) ## [2.0.3](https://github.com/stipsan/react-spring-bottom-sheet/compare/v2.0.2...v2.0.3) (2020-12-05) - ### Bug Fixes -* add CSS sourcemap ([04bf99f](https://github.com/stipsan/react-spring-bottom-sheet/commit/04bf99f119f88cc49686b439cc3f31ec89b1041a)) -* add keywords ([05f4399](https://github.com/stipsan/react-spring-bottom-sheet/commit/05f43990d6527b50c2a38d94cdebcacf02826e1e)) -* add more NaN type guards ([7664d1a](https://github.com/stipsan/react-spring-bottom-sheet/commit/7664d1aad582f690554641ee841aae286865b60b)) -* guard against NaN in minSnap and maxSnap handlers ([7dbf037](https://github.com/stipsan/react-spring-bottom-sheet/commit/7dbf0370afe33b2c93aa91f49f794fb3a30b5a48)) -* TS in microbundle breaks on [...(new Set)], swapping to Array.from ([2402b5a](https://github.com/stipsan/react-spring-bottom-sheet/commit/2402b5adc6afa4759b6b3dd408b254b9d1693eeb)) +- add CSS sourcemap ([04bf99f](https://github.com/stipsan/react-spring-bottom-sheet/commit/04bf99f119f88cc49686b439cc3f31ec89b1041a)) +- add keywords ([05f4399](https://github.com/stipsan/react-spring-bottom-sheet/commit/05f43990d6527b50c2a38d94cdebcacf02826e1e)) +- add more NaN type guards ([7664d1a](https://github.com/stipsan/react-spring-bottom-sheet/commit/7664d1aad582f690554641ee841aae286865b60b)) +- guard against NaN in minSnap and maxSnap handlers ([7dbf037](https://github.com/stipsan/react-spring-bottom-sheet/commit/7dbf0370afe33b2c93aa91f49f794fb3a30b5a48)) +- TS in microbundle breaks on [...(new Set)], swapping to Array.from ([2402b5a](https://github.com/stipsan/react-spring-bottom-sheet/commit/2402b5adc6afa4759b6b3dd408b254b9d1693eeb)) ## [2.0.2](https://github.com/stipsan/react-spring-bottom-sheet/compare/v2.0.1...v2.0.2) (2020-12-05) - ### Bug Fixes -* microbundle defaults to preact, changed it back to react ([4f52355](https://github.com/stipsan/react-spring-bottom-sheet/commit/4f523558f1190b659a175f82ae5a0267a25d13eb)) +- microbundle defaults to preact, changed it back to react ([4f52355](https://github.com/stipsan/react-spring-bottom-sheet/commit/4f523558f1190b659a175f82ae5a0267a25d13eb)) ## [2.0.1](https://github.com/stipsan/react-spring-bottom-sheet/compare/v2.0.0...v2.0.1) (2020-12-05) - ### Bug Fixes -* **deps:** bump focus-trap ([a099a54](https://github.com/stipsan/react-spring-bottom-sheet/commit/a099a54958828ae1e27b6de3608b41000ace23a6)) +- **deps:** bump focus-trap ([a099a54](https://github.com/stipsan/react-spring-bottom-sheet/commit/a099a54958828ae1e27b6de3608b41000ace23a6)) # [2.0.0](https://github.com/stipsan/react-spring-bottom-sheet/compare/v1.0.6...v2.0.0) (2020-12-04) - ### Features -* V2 ([#17](https://github.com/stipsan/react-spring-bottom-sheet/issues/17)) ([8331800](https://github.com/stipsan/react-spring-bottom-sheet/commit/8331800f0b1e95fa495a408bdddf02c3fc8c9d83)) - +- V2 ([#17](https://github.com/stipsan/react-spring-bottom-sheet/issues/17)) ([8331800](https://github.com/stipsan/react-spring-bottom-sheet/commit/8331800f0b1e95fa495a408bdddf02c3fc8c9d83)) ### BREAKING CHANGES -* this is the real initial release, `v1` is fake software. +- this is the real initial release, `v1` is fake software. ## [1.0.6](https://github.com/stipsan/react-spring-bottom-sheet/compare/v1.0.5...v1.0.6) (2020-12-04) - ### Bug Fixes -* fade out correctly on snap ([17234bc](https://github.com/stipsan/react-spring-bottom-sheet/commit/17234bc2aebd18122c4dd324ff5ad17096486983)) +- fade out correctly on snap ([17234bc](https://github.com/stipsan/react-spring-bottom-sheet/commit/17234bc2aebd18122c4dd324ff5ad17096486983)) ## [1.0.5](https://github.com/stipsan/react-spring-bottom-sheet/compare/v1.0.4...v1.0.5) (2020-12-03) - ### Bug Fixes -* update readme ([ceb55d3](https://github.com/stipsan/react-spring-bottom-sheet/commit/ceb55d34bea9892e7fc25e063b69dd4e2d155d77)) +- update readme ([ceb55d3](https://github.com/stipsan/react-spring-bottom-sheet/commit/ceb55d34bea9892e7fc25e063b69dd4e2d155d77)) ## [1.0.4](https://github.com/stipsan/react-spring-bottom-sheet/compare/v1.0.3...v1.0.4) (2020-12-03) - ### Bug Fixes -* v2 api ready, just need to clean up and write docs ([96f158e](https://github.com/stipsan/react-spring-bottom-sheet/commit/96f158e6167e997a02e6a57f0b9395c47f0265fa)) +- v2 api ready, just need to clean up and write docs ([96f158e](https://github.com/stipsan/react-spring-bottom-sheet/commit/96f158e6167e997a02e6a57f0b9395c47f0265fa)) ## [1.0.3](https://github.com/stipsan/react-spring-bottom-sheet/compare/v1.0.2...v1.0.3) (2020-12-02) - ### Bug Fixes -* **deps:** update dependency focus-trap to v6.2.2 ([9f79d35](https://github.com/stipsan/react-spring-bottom-sheet/commit/9f79d354e4bee3ffb8bb58c220cd59744472b778)) +- **deps:** update dependency focus-trap to v6.2.2 ([9f79d35](https://github.com/stipsan/react-spring-bottom-sheet/commit/9f79d354e4bee3ffb8bb58c220cd59744472b778)) ## [1.0.2](https://github.com/stipsan/react-spring-bottom-sheet/compare/v1.0.1...v1.0.2) (2020-12-01) - ### Bug Fixes -* API cleanup ([dc2b126](https://github.com/stipsan/react-spring-bottom-sheet/commit/dc2b12641b5b1dd91599dd1d0724708d1e31f805)) +- API cleanup ([dc2b126](https://github.com/stipsan/react-spring-bottom-sheet/commit/dc2b12641b5b1dd91599dd1d0724708d1e31f805)) ## [1.0.1](https://github.com/stipsan/react-spring-bottom-sheet/compare/v1.0.0...v1.0.1) (2020-11-30) - ### Bug Fixes -* doh, semantic release pushed v1.0 instead of v0.2 :( ([a5837bd](https://github.com/stipsan/react-spring-bottom-sheet/commit/a5837bd5ae099a5471fd68ca7c52b42fcc186a5e)) +- doh, semantic release pushed v1.0 instead of v0.2 :( ([a5837bd](https://github.com/stipsan/react-spring-bottom-sheet/commit/a5837bd5ae099a5471fd68ca7c52b42fcc186a5e)) # 1.0.0 (2020-11-30) - ### Features -* initial version ([ef3ecfa](https://github.com/stipsan/react-spring-bottom-sheet/commit/ef3ecfacab7246c2d8904db189740991f2947727)) +- initial version ([ef3ecfa](https://github.com/stipsan/react-spring-bottom-sheet/commit/ef3ecfacab7246c2d8904db189740991f2947727)) diff --git a/README.md b/README.md index e89992a6..0a58d54f 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,44 @@ -[![npm stat](https://img.shields.io/npm/dm/react-spring-bottom-sheet.svg?style=flat-square)](https://npm-stat.com/charts.html?package=react-spring-bottom-sheet) -[![npm version](https://img.shields.io/npm/v/react-spring-bottom-sheet.svg?style=flat-square)](https://www.npmjs.com/package/react-spring-bottom-sheet) -[![gzip size][gzip-badge]][unpkg-dist] -[![size][size-badge]][unpkg-dist] +# React Spring Bottom Sheet (React 18 & 19) + +[![npm stat](https://img.shields.io/npm/dm/@nipe-solutions/react-spring-bottom-sheet.svg?style=flat-square)](https://npm-stat.com/charts.html?package=@nipe-solutions/react-spring-bottom-sheet) +[![npm version](https://img.shields.io/npm/v/@nipe-solutions/react-spring-bottom-sheet.svg?style=flat-square)](https://www.npmjs.com/package/@nipe-solutions/react-spring-bottom-sheet) +[![Netlify Status](https://api.netlify.com/api/v1/badges/6348db32-4930-4fca-a11d-c3098c9fda4f/deploy-status)](https://app.netlify.com/sites/react-spring-bottom-sheet-updated/deploys) [![module formats: cjs, es, and modern][module-formats-badge]][unpkg-dist] -![Logo with the text Accessible, Delightful and Performant](https://react-spring-bottom-sheet.cocody.dev/readme.svg) +## 🌟 About This Version + +### ✨ Updated Version + +This project is an updated version of the original, which was authored by Cody Olsen. I have forked this repository from Jasmine GH to include significant updates and enhancements. Notably, this version has been updated to fully support React 18 and 19 (including StrictMode), runs on [@react-spring/web] and [@use-gesture/react], and incorporates the latest features and improvements from XState v5. + +### 📝 Attribution -**react-spring-bottom-sheet** is built on top of **[react-spring]** and **[react-use-gesture]**. It busts the myth that accessibility and supporting keyboard navigation and screen readers are allegedly at odds with delightful, beautiful, and highly animated UIs. Every animation and transition use CSS custom properties instead of manipulating them directly, allowing complete control over the experience from CSS alone. +I am not the original author of this software. The original creation was by Cody Olsen, and my fork is based on the work maintained by Jasmine GH. Please refer to the original repositories for historical context and previous versions of the software. My contributions are focused on compatibility enhancements and feature updates to keep the project aligned with current development practices and library versions. -# Installation +## 🔗 Repository Links + +- Original Author's Repository: [Cody Olsen's GitHub](https://github.com/stipsan/react-spring-bottom-sheet) +- Forked Version I Based My Work On: [Jasmine GH's GitHub](https://github.com/JasGH/react-spring-bottom-sheet) + +**react-spring-bottom-sheet** is built on top of **[react-spring]** and **[@use-gesture/react]**. It busts the myth that accessibility and supporting keyboard navigation and screen readers are allegedly at odds with delightful, beautiful, and highly animated UIs. Every animation and transition use CSS custom properties instead of manipulating them directly, allowing complete control over the experience from CSS alone. + +## Installation ```bash -npm i react-spring-bottom-sheet +npm i @nipe-solutions/react-spring-bottom-sheet ``` -# Getting started +## Getting started -## Basic usage +### Basic usage ```jsx import { useState } from 'react' -import { BottomSheet } from 'react-spring-bottom-sheet' +import { BottomSheet } from '@nipe-solutions/react-spring-bottom-sheet' // if setting up the CSS is tricky, you can add this to your page somewhere: -// -import 'react-spring-bottom-sheet/dist/style.css' +// +import '@nipe-solutions/react-spring-bottom-sheet/dist/style.css' export default function Example() { const [open, setOpen] = useState(false) @@ -37,13 +51,16 @@ export default function Example() { } ``` -## TypeScript +### TypeScript TS support is baked in, and if you're using the `snapTo` API use `BottomSheetRef`: ```tsx import { useRef } from 'react' -import { BottomSheet, BottomSheetRef } from 'react-spring-bottom-sheet' +import { + BottomSheet, + BottomSheetRef, +} from '@nipe-solutions/react-spring-bottom-sheet' export default function Example() { const sheetRef = useRef() @@ -89,54 +106,64 @@ module.exports = { plugins: { // Ensures the default variables are available 'postcss-custom-properties-fallback': { - importFrom: require.resolve('react-spring-bottom-sheet/defaults.json'), + importFrom: + require.resolve('@nipe-solutions/react-spring-bottom-sheet/defaults.json'), }, }, } ``` +## Demos -# [Demos](https://react-spring-bottom-sheet.cocody.dev/) +These are not hosted anywhere yet. Run them with `npm run dev` and open +http://localhost:3000 — Next also prints a network URL, so you can open the same +page on a phone that is on the same wifi. -## [Basic](https://react-spring-bottom-sheet.cocody.dev/fixtures/simple) +### Basic > [View demo code](/pages/fixtures/simple.tsx#L44-L48) MVP example, showing what you get by implementing `open`, `onDismiss` and a single **snap point** always set to `minHeight`. -## [Snap points & overflow](https://react-spring-bottom-sheet.cocody.dev/fixtures/scrollable) +### Snap points & overflow > [View demo code](/pages/fixtures/scrollable.tsx#L86-L97) A more elaborate example that showcases how snap points work. It also shows how it behaves if you want it to be open by default, and not closable. Notice how it responds if you resize the window, or scroll to the bottom and starts adjusting the height of the sheet without scrolling back up first. -## [Sticky header & footer](https://react-spring-bottom-sheet.cocody.dev/fixtures/sticky) +### Sticky header & footer > [View demo code](/pages/fixtures/sticky.tsx#L41-L61) If you provide either a `header` or `footer` prop you'll enable the special behavior seen in this example. And they're not just sticky positioned, both areas support touch gestures. -## [Non-blocking overlay mode](https://react-spring-bottom-sheet.cocody.dev/fixtures/aside) +### Non-blocking overlay mode > [View demo code](/pages/fixtures/aside.tsx#L41-L53) In most cases you use a bottom sheet the same way you do with a dialog: you want it to overlay the page and block out distractions. But there are times when you want a bottom sheet but without it taking all the attention and overlaying the entire page. Providing `blocking={false}` helps this use case. By doing so you disable a couple of behaviors that are there for accessibility (focus-locking and more) that prevents a screen reader or a keyboard user from accidentally leaving the bottom sheet. +### Motion recipes + +> [View demo code](/pages/fixtures/motion.tsx) -# API +Switch between the four `springConfig` recipes with the sheet open and drag it between +snap points to feel the difference. A long throw is where they separate. -## props +## API + +### props All props you provide, like `className`, `style` props or whatever else are spread onto the underlying `` instance, that you can style in your custom CSS using this selector: `[data-rsbs-root]`. Just note that the component is mounted in a `@reach/portal` at the bottom of ``, and not in the DOM hierarchy you render it in. -### open +#### open Type: `boolean` The only required prop, beyond `children`. And it's controlled, so if you don't set this to `false` then it's not possible to close the bottom sheet. It's worth knowing that the bottom sheet won't render anything but a `@reach/dialog` placeholder while `open` is `false`. Thus ensure your components behave as expected with being unmounted when the sheet closed. We can't really allow it to render and mount while in a closed/hidden position as there's no stable way of preventing keyboard users or screen readers from accidentally interacting with the closed bottom sheet as long as it's in the dom. This is especially problematic given it implements ARIA to optimize for a11y. -### onDismiss +#### onDismiss Type: `() => void` @@ -146,7 +173,7 @@ Called when the user do something that signal they want to dismiss the sheet: - tap on the backdrop. - swipes the sheet to the bottom of the viewport. -### snapPoints +#### snapPoints Type: `(state) => number | number[]` @@ -165,7 +192,7 @@ This function should be pure as it's called often. You can choose to provide a s /> ``` -### defaultSnap +#### defaultSnap Type: `number | (state) => number` @@ -183,25 +210,25 @@ Provide either a number, or a callback returning a number for the default positi /> ``` -### header +#### header Type: `ReactNode` Supports the same value type as the `children` prop. -### footer +#### footer Type: `ReactNode` Supports the same value type as the `children` prop. -### sibling +#### sibling Type: `ReactNode` Supports the same value type as the `sibling` prop. Renders the node as a child of `[data-rsbs-root]`, but as a sibling to `[data-rsbs-backdrop]` and `[data-rsbs-overlay]`. This allows you to access the animation state and render elements on top of the bottom sheet, while being outside the overlay itself. -### initialFocusRef +#### initialFocusRef Type: `React.Ref | false` @@ -209,29 +236,90 @@ A react ref to the element you want to get keyboard focus when opening. If not provided it's automatically selecting the first interactive element it finds. If set to false keyboard focus when opening is disabled. -### blocking +#### blocking Type: `boolean` Enabled by default. Enables focus trapping of keyboard navigation, so you can't accidentally tab out of the bottom sheet and into the background. Also sets `aria-hidden` on the rest of the page to prevent Screen Readers from escaping as well. -### scrollLocking +#### scrollLocking Type: `boolean` iOS Safari, and some other mobile culprits, can be tricky if you're on a page that has scrolling overflow on `document.body`. Mobile browsers often prefer scrolling the page in these cases instead of letting you handle the touch interaction for UI such as the bottom sheet. Thus it's enabled by default. However it can be a bit agressive and can affect cases where you're putting a drag and drop element inside the bottom sheet. Such as `` and more. For these cases you can wrap them in a container and give them this data attribute `[data-body-scroll-lock-ignore]` to prevent intervention. Really handy if you're doing crazy stuff like putting mapbox-gl widgets inside bottom sheets. -### expandOnContentDrag +#### expandOnContentDrag Type: `boolean` Disabled by default. By default, a user can expand the bottom sheet only by dragging a header or the overlay. This option enables expanding the bottom sheet on the content dragging. -## Events +#### springConfig + +Type: `Partial`, where `SpringConfig` is +`{ mass, tension, friction, velocity, duration, easing, clamp }`. + +Customizes the movement and speed of the animations. **The one rule that decides +everything: if `duration` is set, react-spring runs a tween and ignores `mass`, `tension`, +`friction` and `velocity` entirely.** Only `easing` still applies. To get spring physics +you have to clear it with `duration: undefined`. + +This matters because the sheet ships a `duration` of its own, so passing tension and +friction alone changes nothing. + +##### Recipes + +Four ready made configs are exported, so you rarely need to tune this by hand. Compare +them side by side in the [motion demo](/pages/fixtures/motion.tsx). + +```jsx +import { BottomSheet, presets } from 'guiw5-bottom-sheet' + +; +``` + +| Recipe | Config | Feel | +| ------------------ | --------------------------------------------------- | ---------------------------------------------------------------- | +| `presets.linear` | `115ms`, linear | The default. Constant speed, stops dead on arrival. | +| `presets.eased` | `190ms`, `easeOutCubic` | Same pace, softer landing. | +| `presets.material` | `300ms`, emphasized decelerate | Matches Material 3, for apps built on Material components. | +| `presets.springy` | `duration: undefined`, tension `210`, friction `26` | Real physics. Speed follows the distance and your drag velocity. | + +The longer durations are not slower animations. An eased curve front loads the distance, +so all three tweens above cover 90% of the travel within about 10ms of each other. What +changes is the tail. + +##### Choosing a duration + +- **Below ~17ms is instant.** react-spring advances the first frame with a fixed 16.667ms + delta, so anything at or under that finishes in one frame. `duration: 0` is the explicit + way to disable the animation. +- **A frame's progress is capped at 64ms.** On a device that stutters the animation + stretches in real time instead of jumping, so it degrades gracefully rather than + teleporting. +- **For reference**, Material 3 puts a surface of this size at `medium2`, 300ms, and + reserves the 100 to 150ms range the default sits in for small controls. Shorter is a + legitimate choice for a sheet that is opened constantly; it is a taste call, not a bug. + +Anything not covered by a recipe can still be passed through: + +```jsx + +``` + +### Events All events receive `SpringEvent` as their argument. The payload varies, but `type` is always present, which can be `'OPEN' | 'RESIZE' | 'SNAP' | 'CLOSE'` depending on the scenario. -### onSpringStart +#### onSpringStart Type: `(event: SpringEvent) => void` @@ -259,13 +347,13 @@ function Example() { } ``` -### onSpringCancel +#### onSpringCancel Type: `(event: SpringEvent) => void` Fired on: `OPEN | CLOSE`. -#### OPEN +##### OPEN In order to be as fluid and delightful as possible, the open state can be interrupted and redirected by the user without waiting for the open transition to complete. Maybe they changed their mind and decided to close the sheet because they tapped a button by mistake. This interruption can happen in a number of ways: @@ -274,18 +362,18 @@ In order to be as fluid and delightful as possible, the open state can be interr - the parent component sets `open` to `false` before finishing the animation. - a `RESIZE` event happens, like when an Android device shows its soft keyboard when an text editable input receives focus, as it changes the viewport height. -#### CLOSE +##### CLOSE If the user reopens the sheet before it's done animating it'll trigger this event. Most importantly though it can fire if the bottom sheet is unmounted without enough time to clean animate itself out of the view before it rolls back things like `body-scroll-lock`, `focus-trap` and more. It'll still clean itself up even if React decides to be rude about it. But this also means that the event can fire after the component is unmounted, so you should avoid calling setState or similar without checking for the mounted status of your own wrapper component. -#### RESIZE +##### RESIZE Type: `{ source: 'window' | 'maxheightprop' | 'element }` Fires whenever there's been a window resize event, or if the header, footer or content have changed its height in such a way that the valid snap points have changed. `source` tells you what caused the resize. If the resize comes from a `window.onresize` event it's set to `'window'`. `'maxheightprop'` is if the `maxHeight` prop is used, and is fired whenever it changes. And `'element'` is whenever the header, footer or content resize observers detect a change. -#### SNAP +##### SNAP Type: `{ source: 'dragging' | 'custom' | string }` @@ -334,7 +422,7 @@ function Example() { } ``` -### onSpringEnd +#### onSpringEnd Type: `(event: SpringEvent) => void` @@ -342,13 +430,13 @@ Fired on: `CLOSE`. The `yin` to `onSpringStart`'s `yang`. It has the same characteristics. Including `async/await` and Promise support for delaying a transition. For `CLOSE` it gives you a hook into the step right after it has cleaned up everything after itself, and right before it unmounts itself. This can be useful if you have some logic that needs to perform some work before it's safe to unmount. -### skipInitialTransition +#### skipInitialTransition Type: `boolean` By default the initial open state is always transitioned to using an spring animation. Set `skipInitialTransition` to `true` and the initial `open` state will render as if it were the default state. Useful to avoid scenarios where the opening transition would be distracting. -## ref +### ref Methods available when setting a `ref` on the sheet: @@ -359,18 +447,27 @@ export default function Example() { } ``` -### snapTo +#### snapTo Type: `(numberOrCallback: number | (state => number)) => void, options?: {source?: string, velocity?: number}` Same signature as the `defaultSnap` prop, calling it will animate the sheet to the new snap point you return. You can either call it with a number, which is the height in px (it'll select the closest snap point that matches your value): `ref.current.snapTo(200)`. Or: ```js -ref.current.snapTo(({ // Showing all the available props - headerHeight, footerHeight, height, minHeight, maxHeight, snapPoints, lastSnap }) => - // Selecting the largest snap point, if you give it a number that doesn't match a snap point then it'll - // select whichever snap point is nearest the value you gave - Math.max(...snapPoints) +ref.current.snapTo( + ({ + // Showing all the available props + headerHeight, + footerHeight, + height, + minHeight, + maxHeight, + snapPoints, + lastSnap, + }) => + // Selecting the largest snap point, if you give it a number that doesn't match a snap point then it'll + // select whichever snap point is nearest the value you gave + Math.max(...snapPoints) ) ``` @@ -384,7 +481,7 @@ ref.current.snapTo(({ snapPoints }) => Math.min(...snapPoints), { }) ``` -### height +#### height Type: `number` @@ -410,15 +507,16 @@ export default function Example() { } ``` -# Credits +## Credits - Play icon used on frame overlays: [font-awesome](https://fontawesome.com/icons/play-circle?style=regular) - Phone frame used in logo: [Mono Devices 1.0](https://www.figma.com/community/file/896042888090872154/Mono-Devices-1.0) - iPhone frame used to wrap examples: [iOS 14 UI Kit for Figma]() -[gzip-badge]: http://img.badgesize.io/https://unpkg.com/react-spring-bottom-sheet/dist/index.es.js?compression=gzip&label=gzip%20size&style=flat-square -[size-badge]: http://img.badgesize.io/https://unpkg.com/react-spring-bottom-sheet/dist/index.es.js?label=size&style=flat-square -[unpkg-dist]: https://unpkg.com/react-spring-bottom-sheet/dist/ +[gzip-badge]: http://img.badgesize.io/https://unpkg.com/@nipe-solutions/react-spring-bottom-sheet/dist/index.es.js?compression=gzip&label=gzip%20size&style=flat-square +[size-badge]: http://img.badgesize.io/https://unpkg.com/@nipe-solutions/react-spring-bottom-sheet/dist/index.es.js?label=size&style=flat-square +[unpkg-dist]: https://unpkg.com/@nipe-solutions/react-spring-bottom-sheet/dist/ [module-formats-badge]: https://img.shields.io/badge/module%20formats-cjs%2C%20es%2C%20modern-green.svg?style=flat-square [react-spring]: https://github.com/pmndrs/react-spring -[react-use-gesture]: https://github.com/pmndrs/react-use-gesture +[@react-spring/web]: https://github.com/pmndrs/react-spring +[@use-gesture/react]: https://github.com/pmndrs/use-gesture diff --git a/docs/Footer.module.css b/docs/Footer.module.css index b40205a9..f45d6c0a 100644 --- a/docs/Footer.module.css +++ b/docs/Footer.module.css @@ -1,5 +1,5 @@ .skewed { z-index: -1; transform: skewY(-9deg); - box-shadow: 0 15rem 0 0 theme('backgroundColor.gray.900'); + box-shadow: 0 15rem 0 0 var(--color-gray-900); } diff --git a/docs/Footer.tsx b/docs/Footer.tsx index b68278be..69f81639 100644 --- a/docs/Footer.tsx +++ b/docs/Footer.tsx @@ -41,8 +41,8 @@ export default function Footer({ )} /> - - + + ) } diff --git a/docs/Hero.tsx b/docs/Hero.tsx index 51dd4bd4..bb657e4e 100644 --- a/docs/Hero.tsx +++ b/docs/Hero.tsx @@ -1,11 +1,12 @@ import cx from 'classnames' import { forwardRef, useEffect, useRef, useState } from 'react' -import { animated, config, useSpring } from 'react-spring' +import { animated, config, useSpring } from '@react-spring/web' import styles from './Hero.module.css' const subtitle = 'Accessible, Delightful, and Performant' const Link: React.FC<{ + children: React.ReactNode href?: string target?: string className?: string @@ -28,20 +29,20 @@ const Links = ({ className }: { className?: string }) => ( <> Get started GitHub ) // The wrapping in is because of Safari 🙄 https://bug-149617-attachments.webkit.org/attachment.cgi?id=262048 -const SvgText: React.FC<{ x?: string; y?: string; className?: string }> = ({ +const SvgText: React.FC<{ children: React.ReactNode, x?: string; y?: string; className?: string }> = ({ children, className, x = '23', @@ -78,7 +79,7 @@ export default function Hero({ className }: { className?: string }) { y: open ? '0px' : '208px', state: open ? 1 : 0, }, - onFrame: ({ state }) => { + onChange: ({ value: { state } }) => { if (state > 0) { if (!openClassRef.current) { classNameRef.current.classList.add( @@ -127,7 +128,7 @@ export default function Hero({ className }: { className?: string }) { /> ))} - - - Open example 👀 - + + Open example 👀 {process.env.NODE_ENV !== 'production' && active && ( + ))} + + {JSON.stringify(serialize(recipe.config))} + + ) + + return ( + <> + + + {/* While the sheet is open the same switcher is rendered through `sibling`, + which is the only way to stay above the backdrop and keep taking taps */} + {!open && switcher} +

+ Pick a recipe, then drag the sheet between snap points. The difference + shows up most on a long throw. Switching stays available while the + sheet is open. +

+ + setOpen(false)} + springConfig={recipe.config} + sibling={switcher} + defaultSnap={({ snapPoints }) => Math.min(...snapPoints)} + snapPoints={({ maxHeight }) => [ + maxHeight * 0.25, + maxHeight * 0.6, + maxHeight * 0.95, + ]} + header={{recipe.label}} + > + +

+ Drag the handle up and down. With Linear a short snap + and a long one take exactly the same time, so the long one looks + slow and the short one looks abrupt. +

+

+ Spring is the only recipe where the speed follows the + distance and how hard you flick. +

+ +
+
+
+ + ) +} + +/** easing is a function, so it needs a readable stand-in for display */ +function serialize(config: Partial) { + const out: Record = {} + for (const [key, value] of Object.entries(config)) { + out[key] = typeof value === 'function' ? 'easing fn' : value + } + return out +} + +export default MotionFixturePage diff --git a/pages/fixtures/scrollable.tsx b/pages/fixtures/scrollable.tsx index e3c7fc9c..828fb09f 100644 --- a/pages/fixtures/scrollable.tsx +++ b/pages/fixtures/scrollable.tsx @@ -56,8 +56,8 @@ const ScrollableFixturePage: NextPage = ({ name, }) => { const [expandOnContentDrag, setExpandOnContentDrag] = useState(true) - const focusRef = useRef() - const sheetRef = useRef() + const focusRef = useRef(null) + const sheetRef = useRef(null) return ( <> diff --git a/pages/fixtures/sticky.tsx b/pages/fixtures/sticky.tsx index 27a26b0e..51c4ee9e 100644 --- a/pages/fixtures/sticky.tsx +++ b/pages/fixtures/sticky.tsx @@ -41,13 +41,11 @@ const StickyFixturePage: NextPage = ({ lastSnap ?? Math.min(...snapPoints) } - snapPoints={({ maxHeight }) => [ - maxHeight - maxHeight / 5, - maxHeight * 0.6, - ]} + snapPoints={({ maxHeight }) => [maxHeight * 0.75, maxHeight * 0.99]} header={

Sticky! diff --git a/pages/index.tsx b/pages/index.tsx index fc5d029f..db54ca40 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -30,7 +30,7 @@ const IndexPage: NextPage = ({
stipsan/renovate-presets:auto"], + "extends": ["github>JasGH/renovate-presets:auto"], "packageRules": [ { "depTypeList": ["dependencies", "peerDependencies"], diff --git a/src/BottomSheet.tsx b/src/BottomSheet.tsx index 83234c94..58e658c2 100644 --- a/src/BottomSheet.tsx +++ b/src/BottomSheet.tsx @@ -5,20 +5,21 @@ // It also ensures that when transitioning to open on mount the state is always clean, not affected by previous states that could // cause race conditions. -import { useMachine } from '@xstate/react' +import { useActorRef } from '@xstate/react' import React, { useCallback, useEffect, useImperativeHandle, + useMemo, useRef, } from 'react' -import { animated, config } from 'react-spring' -import { rubberbandIfOutOfBounds, useDrag } from 'react-use-gesture' +import { animated, config } from '@react-spring/web' +import { useDrag } from '@use-gesture/react' +import { fromPromise } from 'xstate' import { useAriaHider, useFocusTrap, useLayoutEffect, - useReady, useReducedMotion, useScrollLock, useSnapPoints, @@ -26,25 +27,35 @@ import { useSpringInterpolations, } from './hooks' import { overlayMachine } from './machines/overlay' +import type { OverlayEvent, SnapSource } from './machines/overlay' import type { defaultSnapProps, + MutableRef, Props, RefHandles, ResizeSource, SnapPointProps, + SpringConfig, } from './types' -import { debugging } from './utils' +import { rubberbandIfOutOfBounds } from './utils' const { tension, friction } = config.default -// @TODO implement AbortController to deal with race conditions +type SpringUpdate = { + y?: number + ready?: number + maxHeight?: number + minSnap?: number + maxSnap?: number + immediate?: boolean + config?: Partial +} -// @TODO rename to SpringBottomSheet and allow userland to import it directly, for those who want maximum control and minimal bundlesize export const BottomSheet = React.forwardRef< RefHandles, { initialState: 'OPEN' | 'CLOSED' - lastSnapRef: React.MutableRefObject + lastSnapRef: MutableRef } & Props >(function BottomSheetInternal( { @@ -62,8 +73,10 @@ export const BottomSheet = React.forwardRef< defaultSnap: getDefaultSnap = _defaultSnap, snapPoints: getSnapPoints = _snapPoints, blocking = true, + scrollerRef, scrollLocking = true, style, + springConfig, onSpringStart, onSpringCancel, onSpringEnd, @@ -73,10 +86,6 @@ export const BottomSheet = React.forwardRef< }, forwardRef ) { - // Before any animations can start we need to measure a few things, like the viewport and the dimensions of content, and header + footer if they exist - // @TODO make ready its own state perhaps, before open or closed - const { ready, registerReady } = useReady() - // Controls the drag handler, used by spring operations that happen outside the render loop in React const canDragRef = useRef(false) @@ -84,29 +93,46 @@ export const BottomSheet = React.forwardRef< const onSpringStartRef = useRef(onSpringStart) const onSpringCancelRef = useRef(onSpringCancel) const onSpringEndRef = useRef(onSpringEnd) + const springConfigRef = useRef(springConfig) useEffect(() => { onSpringStartRef.current = onSpringStart onSpringCancelRef.current = onSpringCancel onSpringEndRef.current = onSpringEnd - }, [onSpringCancel, onSpringStart, onSpringEnd]) + springConfigRef.current = springConfig + }, [onSpringCancel, onSpringStart, onSpringEnd, springConfig]) // Behold, the engine of it all! - const [spring, set] = useSpring() + const [spring, api] = useSpring() + const defaultScrollerRef = useRef(null) + const scrollRef = scrollerRef ?? defaultScrollerRef const containerRef = useRef(null) - const scrollRef = useRef(null) const contentRef = useRef(null) const headerRef = useRef(null) const footerRef = useRef(null) - const overlayRef = useRef(null) + const overlayRef = useRef(null) // Keeps track of the current height, or the height transitioning to const heightRef = useRef(0) - const resizeSourceRef = useRef() + const resizeSourceRef = useRef(undefined) const preventScrollingRef = useRef(false) const prefersReducedMotion = useReducedMotion() + // Nothing can start until the viewport and the content have been measured + const { minSnap, maxSnap, maxHeight, findSnap, ready } = useSnapPoints({ + contentRef, + controlledMaxHeight, + footerEnabled: !!footer, + footerRef, + getSnapPoints, + headerEnabled: header !== false, + headerRef, + heightRef, + lastSnapRef, + resizeSourceRef, + }) + // "Plugins" huhuhu const scrollLockRef = useScrollLock({ targetRef: scrollRef, @@ -124,21 +150,6 @@ export const BottomSheet = React.forwardRef< enabled: ready && blocking && initialFocusRef !== false, }) - const { minSnap, maxSnap, maxHeight, findSnap } = useSnapPoints({ - contentRef, - controlledMaxHeight, - footerEnabled: !!footer, - footerRef, - getSnapPoints, - headerEnabled: header !== false, - headerRef, - heightRef, - lastSnapRef, - ready, - registerReady, - resizeSourceRef, - }) - // Setup refs that are used in cases where full control is needed over when a side effect is executed const maxHeightRef = useRef(maxHeight) const minSnapRef = useRef(minSnap) @@ -154,16 +165,15 @@ export const BottomSheet = React.forwardRef< defaultSnapRef.current = findSnap(getDefaultSnap) }, [findSnap, getDefaultSnap, maxHeight, maxSnap, minSnap]) - // New utility for using events safely - const asyncSet = useCallback( - // @ts-expect-error - ({ onRest, config: { velocity = 1, ...config } = {}, ...opts }) => - new Promise((resolve) => - set({ + // Starts a spring transition and resolves when it settles (or gets interrupted) + const asyncSet = useCallback( + ({ config: { velocity = 1, ...userConfig } = {}, ...opts }: SpringUpdate) => + Promise.all( + api.start({ ...opts, config: { velocity, - ...config, + ...userConfig, // @see https://springs.pomb.us mass: 1, // "stiffness" @@ -173,251 +183,232 @@ export const BottomSheet = React.forwardRef< friction, friction + (friction - friction * velocity) ), - }, - onRest: (...args) => { - resolve(...args) - onRest?.(...args) + // When duration is set the spring physics above are ignored in favor of a fixed length tween + duration: 115, + ...springConfigRef.current, }, }) ), - [set] + [api] ) - const [current, send] = useMachine(overlayMachine, { - devTools: debugging, - actions: { - onOpenCancel: useCallback( - () => onSpringCancelRef.current?.({ type: 'OPEN' }), - [] - ), - onSnapCancel: useCallback( - (context) => - onSpringCancelRef.current?.({ - type: 'SNAP', - source: context.snapSource, + + // Every implementation reads from refs, so the machine only needs to be wired up once + const machine = useMemo( + () => + overlayMachine.provide({ + actions: { + onOpenCancel: () => onSpringCancelRef.current?.({ type: 'OPEN' }), + onSnapCancel: ({ context }) => + onSpringCancelRef.current?.({ + type: 'SNAP', + source: context.snapSource, + }), + onCloseCancel: () => onSpringCancelRef.current?.({ type: 'CLOSE' }), + onResizeCancel: () => + onSpringCancelRef.current?.({ + type: 'RESIZE', + source: resizeSourceRef.current, + }), + onOpenEnd: () => onSpringEndRef.current?.({ type: 'OPEN' }), + onSnapEnd: ({ context }) => + onSpringEndRef.current?.({ + type: 'SNAP', + source: context.snapSource, + }), + onResizeEnd: () => + onSpringEndRef.current?.({ + type: 'RESIZE', + source: resizeSourceRef.current, + }), + }, + actors: { + onSnapStart: fromPromise( + async ({ input }) => { + await onSpringStartRef.current?.({ + type: 'SNAP', + source: input.snapSource || 'custom', + }) + } + ), + onOpenStart: fromPromise(async () => { + await onSpringStartRef.current?.({ type: 'OPEN' }) }), - [] - ), - onCloseCancel: useCallback( - () => onSpringCancelRef.current?.({ type: 'CLOSE' }), - [] - ), - onResizeCancel: useCallback( - () => - onSpringCancelRef.current?.({ - type: 'RESIZE', - source: resizeSourceRef.current, + onCloseStart: fromPromise(async () => { + await onSpringStartRef.current?.({ type: 'CLOSE' }) }), - [] - ), - onOpenEnd: useCallback( - () => onSpringEndRef.current?.({ type: 'OPEN' }), - [] - ), - onSnapEnd: useCallback( - (context, event) => - onSpringEndRef.current?.({ - type: 'SNAP', - source: context.snapSource, + onResizeStart: fromPromise(async () => { + await onSpringStartRef.current?.({ + type: 'RESIZE', + source: resizeSourceRef.current, + }) }), - [] - ), - onResizeEnd: useCallback( - () => - onSpringEndRef.current?.({ - type: 'RESIZE', - source: resizeSourceRef.current, + onSnapEnd: fromPromise( + async ({ input }) => { + await onSpringEndRef.current?.({ + type: 'SNAP', + source: input.snapSource, + }) + } + ), + onOpenEnd: fromPromise(async () => { + await onSpringEndRef.current?.({ type: 'OPEN' }) }), - [] - ), - }, - context: { initialState }, - services: { - onSnapStart: useCallback( - async (context, event) => - onSpringStartRef.current?.({ - type: 'SNAP', - source: event.payload.source || 'custom', + onCloseEnd: fromPromise(async () => { + await onSpringEndRef.current?.({ type: 'CLOSE' }) }), - [] - ), - onOpenStart: useCallback( - async () => onSpringStartRef.current?.({ type: 'OPEN' }), - [] - ), - onCloseStart: useCallback( - async () => onSpringStartRef.current?.({ type: 'CLOSE' }), - [] - ), - onResizeStart: useCallback( - async () => - onSpringStartRef.current?.({ - type: 'RESIZE', - source: resizeSourceRef.current, + onResizeEnd: fromPromise(async () => { + await onSpringEndRef.current?.({ + type: 'RESIZE', + source: resizeSourceRef.current, + }) }), - [] - ), - onSnapEnd: useCallback( - async (context, event) => - onSpringEndRef.current?.({ - type: 'SNAP', - source: context.snapSource, + renderVisuallyHidden: fromPromise(async () => { + api.set({ + y: defaultSnapRef.current, + ready: 0, + maxHeight: maxHeightRef.current, + maxSnap: maxSnapRef.current, + // Using defaultSnapRef instead of minSnapRef to avoid animating `height` on open + minSnap: defaultSnapRef.current, + }) }), - [] - ), - onOpenEnd: useCallback( - async () => onSpringEndRef.current?.({ type: 'OPEN' }), - [] - ), - onCloseEnd: useCallback( - async () => onSpringEndRef.current?.({ type: 'CLOSE' }), - [] - ), - onResizeEnd: useCallback( - async () => - onSpringEndRef.current?.({ - type: 'RESIZE', - source: resizeSourceRef.current, + activate: fromPromise(async () => { + canDragRef.current = true + await Promise.all([ + scrollLockRef.current.activate(), + focusTrapRef.current.activate(), + ariaHiderRef.current.activate(), + ]) }), - [] - ), - renderVisuallyHidden: useCallback( - async (context, event) => { - await asyncSet({ - y: defaultSnapRef.current, - ready: 0, - maxHeight: maxHeightRef.current, - maxSnap: maxSnapRef.current, - // Using defaultSnapRef instead of minSnapRef to avoid animating `height` on open - minSnap: defaultSnapRef.current, - immediate: true, - }) - }, - [asyncSet] - ), - activate: useCallback( - async (context, event) => { - canDragRef.current = true - await Promise.all([ - scrollLockRef.current.activate(), - focusTrapRef.current.activate(), - ariaHiderRef.current.activate(), - ]) - }, - [ariaHiderRef, focusTrapRef, scrollLockRef] - ), - deactivate: useCallback(async () => { - scrollLockRef.current.deactivate() - focusTrapRef.current.deactivate() - ariaHiderRef.current.deactivate() - canDragRef.current = false - }, [ariaHiderRef, focusTrapRef, scrollLockRef]), - openImmediately: useCallback(async () => { - heightRef.current = defaultSnapRef.current - await asyncSet({ - y: defaultSnapRef.current, - ready: 1, - maxHeight: maxHeightRef.current, - maxSnap: maxSnapRef.current, - // Using defaultSnapRef instead of minSnapRef to avoid animating `height` on open - minSnap: defaultSnapRef.current, - immediate: true, - }) - }, [asyncSet]), - openSmoothly: useCallback(async () => { - await asyncSet({ - y: 0, - ready: 1, - maxHeight: maxHeightRef.current, - maxSnap: maxSnapRef.current, - // Using defaultSnapRef instead of minSnapRef to avoid animating `height` on open - minSnap: defaultSnapRef.current, - immediate: true, - }) + deactivate: fromPromise(async () => { + scrollLockRef.current.deactivate() + focusTrapRef.current.deactivate() + ariaHiderRef.current.deactivate() + canDragRef.current = false + }), + openImmediately: fromPromise(async () => { + heightRef.current = defaultSnapRef.current + api.set({ + y: defaultSnapRef.current, + ready: 1, + maxHeight: maxHeightRef.current, + maxSnap: maxSnapRef.current, + // Using defaultSnapRef instead of minSnapRef to avoid animating `height` on open + minSnap: defaultSnapRef.current, + }) + }), + openSmoothly: fromPromise(async () => { + api.set({ + y: 0, + ready: 1, + maxHeight: maxHeightRef.current, + maxSnap: maxSnapRef.current, + // Using defaultSnapRef instead of minSnapRef to avoid animating `height` on open + minSnap: defaultSnapRef.current, + }) + + heightRef.current = defaultSnapRef.current + + await asyncSet({ + y: defaultSnapRef.current, + ready: 1, + maxHeight: maxHeightRef.current, + maxSnap: maxSnapRef.current, + // Using defaultSnapRef instead of minSnapRef to avoid animating `height` on open + minSnap: defaultSnapRef.current, + immediate: prefersReducedMotion.current, + }) + }), + snapSmoothly: fromPromise( + async ({ input }) => { + const snap = findSnapRef.current(input.y) + heightRef.current = snap + lastSnapRef.current = snap + await asyncSet({ + y: snap, + ready: 1, + maxHeight: maxHeightRef.current, + maxSnap: maxSnapRef.current, + minSnap: minSnapRef.current, + immediate: prefersReducedMotion.current, + config: { velocity: input.velocity }, + }) + } + ), + resizeSmoothly: fromPromise(async () => { + const snap = findSnapRef.current(heightRef.current) + heightRef.current = snap + lastSnapRef.current = snap + await asyncSet({ + y: snap, + ready: 1, + maxHeight: maxHeightRef.current, + maxSnap: maxSnapRef.current, + minSnap: minSnapRef.current, + immediate: + resizeSourceRef.current === 'element' + ? prefersReducedMotion.current + : true, + }) + }), + closeSmoothly: fromPromise(async () => { + // Avoid animating the height property on close and stay within FLIP bounds by upping the minSnap + api.set({ minSnap: heightRef.current }) - heightRef.current = defaultSnapRef.current + heightRef.current = 0 - await asyncSet({ - y: defaultSnapRef.current, - ready: 1, - maxHeight: maxHeightRef.current, - maxSnap: maxSnapRef.current, - // Using defaultSnapRef instead of minSnapRef to avoid animating `height` on open - minSnap: defaultSnapRef.current, - immediate: prefersReducedMotion.current, - }) - }, [asyncSet, prefersReducedMotion]), - snapSmoothly: useCallback( - async (context, event) => { - const snap = findSnapRef.current(context.y) - heightRef.current = snap - lastSnapRef.current = snap - await asyncSet({ - y: snap, - ready: 1, - maxHeight: maxHeightRef.current, - maxSnap: maxSnapRef.current, - minSnap: minSnapRef.current, - immediate: prefersReducedMotion.current, - config: { velocity: context.velocity }, - }) - }, - [asyncSet, lastSnapRef, prefersReducedMotion] - ), - resizeSmoothly: useCallback(async () => { - const snap = findSnapRef.current(heightRef.current) - heightRef.current = snap - lastSnapRef.current = snap - await asyncSet({ - y: snap, - ready: 1, - maxHeight: maxHeightRef.current, - maxSnap: maxSnapRef.current, - minSnap: minSnapRef.current, - immediate: - resizeSourceRef.current === 'element' - ? prefersReducedMotion.current - : true, - }) - }, [asyncSet, lastSnapRef, prefersReducedMotion]), - closeSmoothly: useCallback( - async (context, event) => { - // Avoid animating the height property on close and stay within FLIP bounds by upping the minSnap - asyncSet({ - minSnap: heightRef.current, - immediate: true, - }) - - heightRef.current = 0 - - await asyncSet({ - y: 0, - maxHeight: maxHeightRef.current, - maxSnap: maxSnapRef.current, - immediate: prefersReducedMotion.current, - }) - - await asyncSet({ ready: 0, immediate: true }) + await asyncSet({ + y: 0, + maxHeight: maxHeightRef.current, + maxSnap: maxSnapRef.current, + immediate: prefersReducedMotion.current, + }) + + api.set({ ready: 0 }) + }), }, - [asyncSet, prefersReducedMotion] - ), - }, - }) + }), + [ + api, + ariaHiderRef, + asyncSet, + focusTrapRef, + lastSnapRef, + prefersReducedMotion, + scrollLockRef, + ] + ) + const actorRef = useActorRef(machine, { input: { initialState } }) + const send = useCallback( + (event: OverlayEvent) => actorRef.send(event), + [actorRef] + ) + + // Machine transitions only affect the data-rsbs-state attribute, so they are applied straight to the DOM + // instead of re-rendering React (and the sheet contents) on every step of an animation sequence + useLayoutEffect(() => { + const root = containerRef.current + if (!root) return + + const sub = actorRef.subscribe((snapshot) => { + root.setAttribute('data-rsbs-state', publicState(snapshot)) + }) + return () => sub.unsubscribe() + }, [actorRef]) useEffect(() => { if (!ready) return - if (_open) { - send('OPEN') - } else { - send('CLOSE') - } + send({ type: _open ? 'OPEN' : 'CLOSE' }) }, [_open, send, ready]) + useLayoutEffect(() => { // Adjust the height whenever the snap points are changed due to resize events if (maxHeight || maxSnap || minSnap) { - send('RESIZE') + send({ type: 'RESIZE' }) } }, [maxHeight, maxSnap, minSnap, send]) + useEffect( () => () => { // Ensure effects are cleaned up on unmount, in case they're not cleaned up otherwise @@ -432,7 +423,8 @@ export const BottomSheet = React.forwardRef< forwardRef, () => ({ snapTo: (numberOrCallback, { velocity = 1, source = 'custom' } = {}) => { - send('SNAP', { + send({ + type: 'SNAP', payload: { y: findSnapRef.current(numberOrCallback), velocity, @@ -443,20 +435,24 @@ export const BottomSheet = React.forwardRef< get height() { return heightRef.current }, + get scrollElement() { + return scrollRef.current + }, }), - [send] + [send, scrollRef] ) useEffect(() => { const elem = scrollRef.current + if (!elem || !expandOnContentDrag) return - const preventScrolling = e => { + const preventScrolling = (e: Event) => { if (preventScrollingRef.current) { e.preventDefault() } } - const preventSafariOverscroll = e => { + const preventSafariOverscroll = (e: Event) => { if (elem.scrollTop < 0) { requestAnimationFrame(() => { elem.style.overflow = 'hidden' @@ -467,13 +463,12 @@ export const BottomSheet = React.forwardRef< } } - if (expandOnContentDrag) { - elem.addEventListener('scroll', preventScrolling) - elem.addEventListener('touchmove', preventScrolling) - elem.addEventListener('touchstart', preventSafariOverscroll) - } + elem.addEventListener('touchmove', preventScrolling, { passive: false }) + elem.addEventListener('touchstart', preventSafariOverscroll, { + passive: false, + }) + return () => { - elem.removeEventListener('scroll', preventScrolling) elem.removeEventListener('touchmove', preventScrolling) elem.removeEventListener('touchstart', preventSafariOverscroll) } @@ -482,20 +477,24 @@ export const BottomSheet = React.forwardRef< const handleDrag = ({ args: [{ closeOnTap = false, isContentDragging = false } = {}] = [], cancel, + canceled, direction: [, direction], down, first, last, - memo = spring.y.getValue() as number, + memo = spring.y.get(), movement: [, _my], tap, - velocity, + velocity: [, velocity], }) => { + if (canceled) { + return memo + } + const my = _my * -1 // Cancel the drag operation if the canDrag state changed if (!canDragRef.current) { - console.log('handleDrag cancelled dragging because canDragRef is false') cancel() return memo } @@ -563,17 +562,18 @@ export const BottomSheet = React.forwardRef< newY = maxSnapRef.current } - preventScrollingRef.current = newY < maxSnapRef.current; + preventScrollingRef.current = newY < maxSnapRef.current } else { preventScrollingRef.current = false } if (first) { - send('DRAG') + send({ type: 'DRAG' }) } if (last) { - send('SNAP', { + send({ + type: 'SNAP', payload: { y: newY, velocity: velocity > 0.05 ? velocity : 1, @@ -584,19 +584,13 @@ export const BottomSheet = React.forwardRef< return memo } - // @TODO too many rerenders - //send('DRAG', { y: newY, velocity }) - //* - set({ + api.set({ y: newY, ready: 1, maxHeight: maxHeightRef.current, maxSnap: maxSnapRef.current, minSnap: minSnapRef.current, - immediate: true, - config: { velocity }, }) - // */ return memo } @@ -618,7 +612,7 @@ export const BottomSheet = React.forwardRef< @@ -646,9 +638,11 @@ export const BottomSheet = React.forwardRef< {...bind({ closeOnTap: true })} /> )} + {/* A dialog handling Escape is the expected pattern; the rule assumes a static element */} + {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}
)} -
+
{children}
@@ -690,7 +689,11 @@ const publicStates = [ 'dragging', 'snapping', 'resizing', -] +] as const + +function publicState(snapshot: { matches: (state: string) => boolean }) { + return publicStates.find((state) => snapshot.matches(state)) ?? '' +} // Default prop values that are callbacks, and it's nice to save some memory and reuse their instances since they're pure function _defaultSnap({ snapPoints, lastSnap }: defaultSnapProps) { diff --git a/src/Portal.tsx b/src/Portal.tsx new file mode 100644 index 00000000..3267b6ff --- /dev/null +++ b/src/Portal.tsx @@ -0,0 +1,47 @@ +import { useLayoutEffect, useState } from 'react' +import { createPortal } from 'react-dom' + +// Renders children into an element appended to document.body, so the sheet isn't clipped +// by ancestor overflow or transforms. Replaces the unmaintained @reach/portal. +// +// The container is created in a lazy state initializer rather than in an effect: rendering +// into a detached node and attaching it afterwards avoids the extra render (and the +// placeholder element) that the create-then-forceUpdate approach needs. + +const canUseDOM = () => + typeof window !== 'undefined' && + typeof window.document?.createElement === 'function' + +export function Portal({ + children, + type = 'reach-portal', + ...props +}: { + children: React.ReactNode + /** Tag name used for the container element */ + type?: string +} & Record<`data-${string}`, string | boolean | undefined>) { + const [node] = useState(() => + canUseDOM() ? document.createElement(type) : null + ) + + useLayoutEffect(() => { + if (!node) return + + for (const [key, value] of Object.entries(props)) { + if (value !== undefined && value !== false) { + node.setAttribute(key, value === true ? '' : String(value)) + } + } + + document.body.appendChild(node) + return () => { + node.remove() + } + // Attributes are static in practice; re-running on every prop identity change + // would detach and reattach the sheet mid-animation. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [node]) + + return node ? createPortal(children, node) : null +} diff --git a/src/hooks/index.tsx b/src/hooks/index.tsx index 26af5072..8110ede4 100644 --- a/src/hooks/index.tsx +++ b/src/hooks/index.tsx @@ -1,7 +1,6 @@ export { useAriaHider } from './useAriaHider' export { useFocusTrap } from './useFocusTrap' export { useLayoutEffect } from './useLayoutEffect' -export { useReady } from './useReady' export { useReducedMotion } from './useReducedMotion' export { useScrollLock } from './useScrollLock' export { useSnapPoints } from './useSnapPoints' diff --git a/src/hooks/useAriaHider.tsx b/src/hooks/useAriaHider.tsx index 1413969b..33a37f01 100644 --- a/src/hooks/useAriaHider.tsx +++ b/src/hooks/useAriaHider.tsx @@ -5,7 +5,7 @@ export function useAriaHider({ targetRef, enabled, }: { - targetRef: React.RefObject + targetRef: React.RefObject enabled: boolean }) { const ref = useRef<{ activate: () => void; deactivate: () => void }>({ @@ -40,8 +40,8 @@ export function useAriaHider({ if (node === parentNode) { return } - let attr = node.getAttribute('aria-hidden') - let alreadyHidden = attr !== null && attr !== 'false' + const attr = node.getAttribute('aria-hidden') + const alreadyHidden = attr !== null && attr !== 'false' if (alreadyHidden) { return } @@ -55,7 +55,7 @@ export function useAriaHider({ active = false rootNodes.forEach((node, index) => { - let originalValue = originalValues[index] + const originalValue = originalValues[index] if (originalValue === null) { node.removeAttribute('aria-hidden') } else { diff --git a/src/hooks/useFocusTrap.tsx b/src/hooks/useFocusTrap.tsx index 3d1ffb58..a866d5be 100644 --- a/src/hooks/useFocusTrap.tsx +++ b/src/hooks/useFocusTrap.tsx @@ -7,9 +7,9 @@ export function useFocusTrap({ initialFocusRef, enabled, }: { - targetRef: React.RefObject - fallbackRef: React.RefObject - initialFocusRef?: React.RefObject + targetRef: React.RefObject + fallbackRef: React.RefObject + initialFocusRef?: React.RefObject enabled: boolean }) { const ref = useRef<{ activate: () => void; deactivate: () => void }>({ @@ -30,12 +30,6 @@ export function useFocusTrap({ const fallback = fallbackRef.current const trap = createFocusTrap(targetRef.current, { - onActivate: - process.env.NODE_ENV !== 'production' - ? () => { - console.log('focus activate') - } - : undefined, // If initialFocusRef is manually specified we don't want the first tabbable element to receive focus if initialFocusRef can't be found initialFocus: initialFocusRef ? () => initialFocusRef?.current || fallback diff --git a/src/hooks/useReady.tsx b/src/hooks/useReady.tsx deleted file mode 100644 index 2073a227..00000000 --- a/src/hooks/useReady.tsx +++ /dev/null @@ -1,38 +0,0 @@ -// Keeps track of wether everything is good to go or not, in the most efficient way possible - -import { useCallback, useEffect, useState } from 'react' - -export function useReady() { - const [ready, setReady] = useState(false) - const [readyMap, updateReadyMap] = useState<{ [key: string]: boolean }>({}) - - const registerReady = useCallback((key: string) => { - console.count(`registerReady:${key}`) - // Register the check we're gonna wait for until it's ready - updateReadyMap((ready) => ({ ...ready, [key]: false })) - - return () => { - console.count(`setReady:${key}`) - // Set it to ready - updateReadyMap((ready) => ({ ...ready, [key]: true })) - } - }, []) - - useEffect(() => { - const states = Object.values(readyMap) - - if (states.length === 0) { - console.log('nope nothing registered yet') - return - } - - const isReady = states.every(Boolean) - console.log('check if we are rready', readyMap, isReady) - if (isReady) { - console.warn('ready!') - setReady(true) - } - }, [readyMap]) - - return { ready, registerReady } -} diff --git a/src/hooks/useReducedMotion.tsx b/src/hooks/useReducedMotion.tsx index 21a3edd2..093ebc6c 100644 --- a/src/hooks/useReducedMotion.tsx +++ b/src/hooks/useReducedMotion.tsx @@ -1,25 +1,30 @@ import { useDebugValue, useEffect, useMemo, useRef } from 'react' -// @TODO refactor to addEventListener export function useReducedMotion() { const mql = useMemo( () => - typeof window !== 'undefined' + typeof window !== 'undefined' && 'matchMedia' in window ? window.matchMedia('(prefers-reduced-motion: reduce)') : null, [] ) - const ref = useRef(mql?.matches) + const ref = useRef(mql?.matches ?? false) useDebugValue(ref.current ? 'reduce' : 'no-preference') useEffect(() => { - const handler = (event) => { + if (!mql) return + + const handler = (event: MediaQueryListEvent) => { ref.current = event.matches } - mql?.addListener(handler) - - return () => mql?.removeListener(handler) + if ('addEventListener' in mql) { + mql.addEventListener('change', handler) + return () => mql.removeEventListener('change', handler) + } + // Safari < 14 + ;(mql as MediaQueryList).addListener(handler) + return () => (mql as MediaQueryList).removeListener(handler) }, [mql]) return ref diff --git a/src/hooks/useScrollLock.tsx b/src/hooks/useScrollLock.tsx index 5cc1386c..37d27982 100644 --- a/src/hooks/useScrollLock.tsx +++ b/src/hooks/useScrollLock.tsx @@ -17,7 +17,7 @@ export function useScrollLock({ enabled, reserveScrollBarGap, }: { - targetRef: React.RefObject + targetRef: React.RefObject enabled: boolean reserveScrollBarGap: boolean }) { diff --git a/src/hooks/useSnapPoints.tsx b/src/hooks/useSnapPoints.tsx index c9127ef1..dad970ef 100644 --- a/src/hooks/useSnapPoints.tsx +++ b/src/hooks/useSnapPoints.tsx @@ -1,16 +1,11 @@ -import React, { - useCallback, - useDebugValue, - useEffect, - useMemo, - useRef, - useState, -} from 'react' -import { ResizeObserver, ResizeObserverEntry } from '@juggle/resize-observer' -import type { defaultSnapProps, ResizeSource, snapPoints } from '../types' +import React, { useCallback, useDebugValue, useRef, useState } from 'react' +import type { + defaultSnapProps, + MutableRef, + ResizeSource, + snapPoints, +} from '../types' import { processSnapPoints, roundAndCheckForNaN } from '../utils' -import { useReady } from './useReady' -import { ResizeObserverOptions } from '@juggle/resize-observer/lib/ResizeObserverOptions' import { useLayoutEffect } from './useLayoutEffect' export function useSnapPoints({ @@ -23,33 +18,29 @@ export function useSnapPoints({ headerRef, heightRef, lastSnapRef, - ready, - registerReady, resizeSourceRef, }: { - contentRef: React.RefObject + contentRef: React.RefObject controlledMaxHeight?: number footerEnabled: boolean - footerRef: React.RefObject + footerRef: React.RefObject getSnapPoints: snapPoints headerEnabled: boolean - headerRef: React.RefObject + headerRef: React.RefObject heightRef: React.RefObject - lastSnapRef: React.RefObject - ready: boolean - registerReady: ReturnType['registerReady'] - resizeSourceRef: React.MutableRefObject + lastSnapRef: React.RefObject + resizeSourceRef: MutableRef }) { - const { maxHeight, minHeight, headerHeight, footerHeight } = useDimensions({ - contentRef: contentRef, - controlledMaxHeight, - footerEnabled, - footerRef, - headerEnabled, - headerRef, - registerReady, - resizeSourceRef, - }) + const { maxHeight, minHeight, headerHeight, footerHeight, ready } = + useDimensions({ + contentRef, + controlledMaxHeight, + footerEnabled, + footerRef, + headerEnabled, + headerRef, + resizeSourceRef, + }) const { snapPoints, minSnap, maxSnap } = processSnapPoints( ready @@ -63,9 +54,7 @@ export function useSnapPoints({ : [0], maxHeight ) - //console.log({ snapPoints, minSnap, maxSnap }) - // @TODO investigate the gains from memoizing this function findSnap( numberOrCallback: number | ((state: defaultSnapProps) => number) ) { @@ -93,7 +82,7 @@ export function useSnapPoints({ useDebugValue(`minSnap: ${minSnap}, maxSnap:${maxSnap}`) - return { minSnap, maxSnap, findSnap, maxHeight } + return { minSnap, maxSnap, findSnap, maxHeight, ready } } function useDimensions({ @@ -103,28 +92,18 @@ function useDimensions({ footerRef, headerEnabled, headerRef, - registerReady, resizeSourceRef, }: { - contentRef: React.RefObject + contentRef: React.RefObject controlledMaxHeight?: number footerEnabled: boolean - footerRef: React.RefObject + footerRef: React.RefObject headerEnabled: boolean - headerRef: React.RefObject - registerReady: ReturnType['registerReady'] - resizeSourceRef: React.MutableRefObject + headerRef: React.RefObject + resizeSourceRef: MutableRef }) { - const setReady = useMemo(() => registerReady('contentHeight'), [ - registerReady, - ]) - const maxHeight = useMaxHeight( - controlledMaxHeight, - registerReady, - resizeSourceRef - ) + const maxHeight = useMaxHeight(controlledMaxHeight, resizeSourceRef) - // @TODO probably better to forward props instead of checking refs to decide if it's enabled const headerHeight = useElementSizeObserver(headerRef, { label: 'headerHeight', enabled: headerEnabled, @@ -147,33 +126,40 @@ function useDimensions({ useDebugValue(`minHeight: ${minHeight}`) - const ready = contentHeight > 0 - useEffect(() => { - if (ready) { - setReady() - } - }, [ready, setReady]) - return { maxHeight, minHeight, headerHeight, footerHeight, + // Nothing can be measured or animated until the viewport and the content have a size. + // Deriving this instead of tracking it in state saves a render pass per measurement. + ready: maxHeight > 0 && contentHeight > 0, } } -const observerOptions: ResizeObserverOptions = { - // Respond to changes to padding, happens often on iOS when using env(safe-area-inset-bottom) - // And the user hides or shows the Safari browser toolbar - box: 'border-box', +// Respond to changes to padding, happens often on iOS when using env(safe-area-inset-bottom) +// And the user hides or shows the Safari browser toolbar +const observerOptions: ResizeObserverOptions = { box: 'border-box' } + +function borderBoxHeight(entry: ResizeObserverEntry): number { + const boxSize = entry.borderBoxSize as unknown as + | ResizeObserverSize + | ResizeObserverSize[] + | undefined + const size = Array.isArray(boxSize) ? boxSize[0] : boxSize + if (size && typeof size.blockSize === 'number') { + return size.blockSize + } + return entry.target.getBoundingClientRect().height } + /** * Hook for determining the size of an element using the Resize Observer API. * * @param ref - A React ref to an element */ function useElementSizeObserver( - ref: React.RefObject, + ref: React.RefObject, { label, enabled, @@ -181,17 +167,17 @@ function useElementSizeObserver( }: { label: string enabled: boolean - resizeSourceRef: React.MutableRefObject + resizeSourceRef: MutableRef } ): number { - let [size, setSize] = useState(0) + const [size, setSize] = useState(0) useDebugValue(`${label}: ${size}`) const handleResize = useCallback( (entries: ResizeObserverEntry[]) => { // we only observe one element, so accessing the first entry here is fine - setSize(entries[0].borderBoxSize[0].blockSize) + setSize(borderBoxHeight(entries[0])) resizeSourceRef.current = 'element' }, [resizeSourceRef] @@ -215,27 +201,19 @@ function useElementSizeObserver( // Blazingly keep track of the current viewport height without blocking the thread, keeping that sweet 60fps on smartphones function useMaxHeight( - controlledMaxHeight, - registerReady: ReturnType['registerReady'], - resizeSourceRef: React.MutableRefObject + controlledMaxHeight: number | undefined, + resizeSourceRef: MutableRef ) { - const setReady = useMemo(() => registerReady('maxHeight'), [registerReady]) - const [maxHeight, setMaxHeight] = useState(() => - roundAndCheckForNaN(controlledMaxHeight) || typeof window !== 'undefined' - ? window.innerHeight - : 0 - ) - const ready = maxHeight > 0 + const [maxHeight, setMaxHeight] = useState(() => { + if (controlledMaxHeight) { + return roundAndCheckForNaN(controlledMaxHeight) + } + return typeof window !== 'undefined' ? window.innerHeight : 0 + }) const raf = useRef(0) useDebugValue(controlledMaxHeight ? 'controlled' : 'auto') - useEffect(() => { - if (ready) { - setReady() - } - }, [ready, setReady]) - useLayoutEffect(() => { // Bail if the max height is a controlled prop if (controlledMaxHeight) { @@ -262,13 +240,12 @@ function useMaxHeight( window.addEventListener('resize', handleResize) setMaxHeight(window.innerHeight) resizeSourceRef.current = 'window' - setReady() return () => { window.removeEventListener('resize', handleResize) cancelAnimationFrame(raf.current) } - }, [controlledMaxHeight, setReady, resizeSourceRef]) + }, [controlledMaxHeight, resizeSourceRef]) return maxHeight } diff --git a/src/hooks/useSpring.tsx b/src/hooks/useSpring.tsx index f1e65fce..ada6a12e 100644 --- a/src/hooks/useSpring.tsx +++ b/src/hooks/useSpring.tsx @@ -1,7 +1,6 @@ -import { useSpring as useReactSpring } from 'react-spring' +import { useSpring as useReactSpring } from '@react-spring/web' // Behold, the engine of it all! -// Put in this file befause it makes it easier to type and I'm lazy! :D export function useSpring() { return useReactSpring(() => ({ @@ -14,4 +13,4 @@ export function useSpring() { } export type Spring = ReturnType[0] -export type SpringSet = ReturnType[1] +export type SpringApi = ReturnType[1] diff --git a/src/hooks/useSpringInterpolations.tsx b/src/hooks/useSpringInterpolations.tsx index 3f5d38bc..3a35780b 100644 --- a/src/hooks/useSpringInterpolations.tsx +++ b/src/hooks/useSpringInterpolations.tsx @@ -1,49 +1,34 @@ -import { interpolate } from 'react-spring' +import { useMemo } from 'react' +import { to } from '@react-spring/web' import type { Spring } from './useSpring' import { clamp } from '../utils' // It's a bit easier to ensure interpolations don't accidentally use the wrong variables by -// putting them here, in their own closure with explicitly defined variables used - -// Note that the callbacks in the interpolation functions close over their scope every time react renders -// so it's important that if anything can change outside of render that needs to be available -// in the interpolation then a ref must be used +// putting them here, in their own closure with explicitly defined variables used. +// The SpringValue instances are stable for the lifetime of the component, so the derived +// interpolations only need to be created once. export function useSpringInterpolations({ spring, }: { spring: Spring }): React.CSSProperties { - // This effect is for removing rounded corners on phones when the sheet touches the top of the browser chrome - // as it's really ugly with the gaps border radius creates. This ensures it looks sleek. - // @TODO the ts-ignore comments are because the `extrapolate` param isn't in the TS defs for some reason - const interpolateBorderRadius = interpolate( - // @ts-expect-error - [spring.y, spring.maxHeight], - (y, maxHeight) => { - return `${Math.round(clamp(maxHeight - y, 0, 16))}px` - } - ) + const { y, maxHeight, minSnap, maxSnap } = spring + + return useMemo(() => { + // Removes rounded corners on phones when the sheet touches the top of the browser chrome + const interpolateBorderRadius = to( + [y, maxHeight], + (y, maxHeight) => `${Math.round(clamp(maxHeight - y, 0, 16))}px` + ) - /* - * Only animate the height when absolute necessary - * @TODO currently it's only able to opt out of changing the height if there's just a single snapshot - * but it should be possible to do it in other scenarios too, like on window resize, - * or maybe even while dragging, but probably requires a more restrictive CSS. - * As in now the sticky footer isn't overlapping the content, allowing `backdrop-filter: blur(8px)` effects. - * A FLIP resize flow for content height would likely require the sticky elements to overlap the content area. - * Could be done as a separat mode though, or a separate example CSS for max performance. - */ - const interpolateHeight = interpolate( - // @ts-ignore - [spring.y, spring.minSnap, spring.maxSnap], - (y, minSnap, maxSnap) => `${clamp(y, minSnap, maxSnap)}px` - ) + // Only animate the height when absolutely necessary + const interpolateHeight = to( + [y, minSnap, maxSnap], + (y, minSnap, maxSnap) => `${clamp(y, minSnap, maxSnap)}px` + ) - const interpolateY = interpolate( - // @ts-ignore - [spring.y, spring.minSnap, spring.maxSnap], - (y, minSnap, maxSnap) => { + const interpolateY = to([y, minSnap, maxSnap], (y, minSnap, maxSnap) => { if (y < minSnap) { return `${minSnap - y}px` } @@ -51,56 +36,39 @@ export function useSpringInterpolations({ return `${maxSnap - y}px` } return '0px' - } - ) + }) - const interpolateFiller = interpolate( - // @ts-ignore - [spring.y, spring.maxSnap], - (y, maxSnap) => { - if (y >= maxSnap) { - return Math.ceil(y - maxSnap) - } - return 0 - } - ) + const interpolateFiller = to([y, maxSnap], (y, maxSnap) => + y >= maxSnap ? Math.ceil(y - maxSnap) : 0 + ) - const interpolateContentOpacity = interpolate( - // @ts-ignore - [spring.y, spring.minSnap], - (y, minSnap) => { + const interpolateContentOpacity = to([y, minSnap], (y, minSnap) => { if (!minSnap) { return 0 } const minX = Math.max(minSnap / 2 - 45, 0) const maxX = Math.min(minSnap / 2 + 45, minSnap) - const minY = 0 - const maxY = 1 - - const slope = (maxY - minY) / (maxX - minX) - const res = (y - minX) * (slope + minY) - return clamp(res, 0, 1) - } - ) + const slope = 1 / (maxX - minX) + return clamp((y - minX) * slope, 0, 1) + }) - const interpolateBackdrop = interpolate( - // @ts-ignore - [spring.y, spring.minSnap], - (y, minSnap) => (minSnap ? clamp(y / minSnap, 0, 1) : 0) - ) + const interpolateBackdrop = to([y, minSnap], (y, minSnap) => + minSnap ? clamp(y / minSnap, 0, 1) : 0 + ) - return { - // Fancy content fade-in effect - ['--rsbs-content-opacity' as any]: interpolateContentOpacity, - // Fading in the backdrop - ['--rsbs-backdrop-opacity' as any]: interpolateBackdrop, - // Scaling the antigap in the bottom - ['--rsbs-antigap-scale-y' as any]: interpolateFiller, - // Shifts the position of the bottom sheet, used on open and close primarily as snap point changes usually only interpolate the height - ['--rsbs-overlay-translate-y' as any]: interpolateY, - // Remove rounded borders when full height, it looks much better this way - ['--rsbs-overlay-rounded' as any]: interpolateBorderRadius, - // Animates the height state, not the most performant way but it's the safest with regards to mobile browser and focus/scrolling that could happen while animating - ['--rsbs-overlay-h' as any]: interpolateHeight, - } + return { + // Fancy content fade-in effect + ['--rsbs-content-opacity' as any]: interpolateContentOpacity, + // Fading in the backdrop + ['--rsbs-backdrop-opacity' as any]: interpolateBackdrop, + // Scaling the antigap in the bottom + ['--rsbs-antigap-scale-y' as any]: interpolateFiller, + // Shifts the position of the bottom sheet, used on open and close primarily as snap point changes usually only interpolate the height + ['--rsbs-overlay-translate-y' as any]: interpolateY, + // Remove rounded borders when full height, it looks much better this way + ['--rsbs-overlay-rounded' as any]: interpolateBorderRadius, + // Animates the height state, not the most performant way but it's the safest with regards to mobile browser and focus/scrolling that could happen while animating + ['--rsbs-overlay-h' as any]: interpolateHeight, + } + }, [y, maxHeight, minSnap, maxSnap]) } diff --git a/src/index.tsx b/src/index.tsx index bb6f5c2d..7e3c11e4 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,25 +1,26 @@ -/* eslint-disable react/jsx-pascal-case */ -import Portal from '@reach/portal' import React, { forwardRef, useRef, useState, useCallback } from 'react' import { BottomSheet as _BottomSheet } from './BottomSheet' import type { Props, RefHandles, SpringEvent } from './types' import { useLayoutEffect } from './hooks' +import { Portal } from './Portal' export type { RefHandles as BottomSheetRef, Props as BottomSheetProps, + SpringConfig, } from './types' +export * as presets from './presets' // Because SSR is annoying to deal with, and all the million complaints about window, navigator and dom elenents! export const BottomSheet = forwardRef(function BottomSheet( - { onSpringStart, onSpringEnd, skipInitialTransition, ...props }, + { onSpringStart, onSpringEnd, keepMounted, skipInitialTransition, ...props }, ref ) { // Mounted state, helps SSR but also ensures you can't tab into the sheet while it's closed, or nav there in a screen reader const [mounted, setMounted] = useState(false) - const timerRef = useRef>() + const timerRef = useRef(undefined) // The last point that the user snapped to, useful for open/closed toggling and the user defined height is remembered - const lastSnapRef = useRef(null) + const lastSnapRef = useRef(null) // @TODO refactor to an initialState: OPEN | CLOSED property as it's much easier to understand // And informs what we should animate from. If the sheet is mounted with open = true, then initialState = OPEN. // When initialState = CLOSED, then internal sheet must first render with open={false} before setting open={props.open} @@ -33,14 +34,14 @@ export const BottomSheet = forwardRef(function BottomSheet( useLayoutEffect(() => { if (props.open) { cancelAnimationFrame(timerRef.current) - setMounted(true) + if (!keepMounted) setMounted(true) // Cleanup defaultOpen state on close return () => { initialStateRef.current = 'CLOSED' } } - }, [props.open]) + }, [props.open, keepMounted]) const handleSpringStart = useCallback( async function handleSpringStart(event: SpringEvent) { @@ -60,16 +61,16 @@ export const BottomSheet = forwardRef(function BottomSheet( // Forward the event await onSpringEnd?.(event) - if (event.type === 'CLOSE') { + if (event.type === 'CLOSE' && !keepMounted) { // Unmount from the dom to avoid contents being tabbable or visible to screen readers while closed timerRef.current = requestAnimationFrame(() => setMounted(false)) } }, - [onSpringEnd] + [onSpringEnd, keepMounted] ) // This isn't just a performance optimization, it's also to avoid issues when running a non-browser env like SSR - if (!mounted) { + if (!keepMounted && !mounted) { return null } diff --git a/src/machines/overlay.ts b/src/machines/overlay.ts index b5211895..15fc0e68 100644 --- a/src/machines/overlay.ts +++ b/src/machines/overlay.ts @@ -1,103 +1,33 @@ -import { Machine, assign } from 'xstate' +import { assign, createMachine } from 'xstate' // This is the root machine, composing all the other machines and is the brain of the bottom sheet +// Copy paste the machine into https://stately.ai/viz to make sense of what is going on in here ;) -interface OverlayStateSchema { - states: { - // the overlay usually starts in the closed position - closed: {} - opening: { - states: { - // Used to fire off the springStart event - start: {} - // Decide how to transition to the open state based on what the initialState is - transition: {} - // Fast enter animation, sheet is open by default - immediately: { - states: { - open: {} - activating: {} - } - } - smoothly: { - states: { - // This state only happens when the overlay should start in an open state, instead of animating from the bottom - // openImmediately: {} - // visuallyHidden will render the overlay in the open state, but with opacity 0 - // doing this solves two problems: - // on Android focusing an input element will trigger the softkeyboard to show up, which will change the viewport height - // on iOS the focus event will break the view by triggering a scrollIntoView event if focus happens while the overlay is below the viewport and body got overflow:hidden - // by rendering things with opacity 0 we ensure keyboards and scrollIntoView all happen in a way that match up with what the sheet will look like. - // we can then move it to the opening position below the viewport, and animate it into view without worrying about height changes or scrolling overflow:hidden events - visuallyHidden: {} - // In this state we're activating focus traps, scroll locks and more, this will sometimes trigger soft keyboards and scrollIntoView - // @TODO we might want to add a delay here before proceeding to open, to give android and iOS enough time to adjust the viewport when focusing an interactive element - activating: {} - // Animates from the bottom - open: {} - } - } - // Used to fire off the springEnd event - end: {} - // And finally we're ready to transition to open - done: {} - } - } - open: {} - // dragging responds to user gestures, which may interrupt the opening state, closing state or snapping - // when interrupting an opening event, it fires onSpringEnd(OPEN) before onSpringStart(DRAG) - // when interrupting a closing event, it fires onSpringCancel(CLOSE) before onSpringStart(DRAG) - // when interrupting a dragging event, it fires onSpringCancel(SNAP) before onSpringStart(DRAG) - dragging: {} - // snapping happens whenever transitioning to a new snap point, often after dragging - snapping: { - states: { - start: {} - snappingSmoothly: {} - end: {} - done: {} - } - } - resizing: { - states: { - start: {} - resizingSmoothly: {} - end: {} - done: {} - } - } - closing: { - states: { - start: {} - deactivating: {} - closingSmoothly: {} - end: {} - done: {} - } - } - } -} +export type SnapSource = 'dragging' | 'custom' | string -type OverlayEvent = +export type OverlayEvent = | { type: 'OPEN' } | { type: 'SNAP' payload: { y: number velocity: number - source: 'dragging' | 'custom' | string + source: SnapSource } } | { type: 'CLOSE' } | { type: 'DRAG' } | { type: 'RESIZE' } -// The context (extended state) of the machine -interface OverlayContext { +export interface OverlayContext { initialState: 'OPEN' | 'CLOSED' + snapSource?: SnapSource + y?: number + velocity?: number } -function sleep(ms = 1000) { - return new Promise((resolve) => setTimeout(resolve, ms)) + +export interface OverlayInput { + initialState: 'OPEN' | 'CLOSED' } const cancelOpen = { @@ -110,37 +40,34 @@ const openToResize = { RESIZE: { target: '#overlay.resizing', actions: 'onOpenEnd' }, } -const initiallyOpen = ({ initialState }) => initialState === 'OPEN' -const initiallyClosed = ({ initialState }) => initialState === 'CLOSED' - -// Copy paste the machine into https://xstate.js.org/viz/ to make sense of what's going on in here ;) - -export const overlayMachine = Machine< - OverlayContext, - OverlayStateSchema, - OverlayEvent ->( +export const overlayMachine = createMachine( { + types: {} as { + context: OverlayContext + events: OverlayEvent + input: OverlayInput + }, id: 'overlay', initial: 'closed', - context: { initialState: 'CLOSED' }, + context: ({ input }) => ({ initialState: input.initialState }), states: { + // the overlay usually starts in the closed position closed: { on: { OPEN: 'opening', CLOSE: undefined } }, opening: { initial: 'start', states: { + // Used to fire off the springStart event start: { - invoke: { - src: 'onOpenStart', - onDone: 'transition', - }, + invoke: { src: 'onOpenStart', onDone: 'transition' }, }, + // Decide how to transition to the open state based on what the initialState is transition: { always: [ - { target: 'immediately', cond: 'initiallyOpen' }, - { target: 'smoothly', cond: 'initiallyClosed' }, + { target: 'immediately', guard: 'initiallyOpen' }, + { target: 'smoothly', guard: 'initiallyClosed' }, ], }, + // Fast enter animation, sheet is open by default immediately: { initial: 'open', states: { @@ -156,25 +83,30 @@ export const overlayMachine = Machine< smoothly: { initial: 'visuallyHidden', states: { + // visuallyHidden renders the overlay in the open state, but with opacity 0. + // On Android focusing an input triggers the soft keyboard, which changes the viewport height. + // On iOS the focus event triggers scrollIntoView if focus happens while the overlay is below the viewport. + // Rendering with opacity 0 first ensures keyboards and scrollIntoView happen in a way that match the final layout. visuallyHidden: { invoke: { src: 'renderVisuallyHidden', onDone: 'activating' }, }, + // Activates focus traps, scroll locks and more activating: { invoke: { src: 'activate', onDone: 'open' }, }, + // Animates from the bottom open: { invoke: { src: 'openSmoothly', onDone: '#overlay.opening.end' }, on: { ...openToDrag, ...openToResize }, }, }, }, + // Used to fire off the springEnd event end: { invoke: { src: 'onOpenEnd', onDone: 'done' }, on: { CLOSE: '#overlay.closing', DRAG: '#overlay.dragging' }, }, - done: { - type: 'final', - }, + done: { type: 'final' }, }, on: { ...cancelOpen }, onDone: 'open', @@ -182,31 +114,46 @@ export const overlayMachine = Machine< open: { on: { DRAG: '#overlay.dragging', SNAP: 'snapping', RESIZE: 'resizing' }, }, + // dragging responds to user gestures, which may interrupt opening, closing or snapping dragging: { on: { SNAP: 'snapping' }, }, + // snapping happens whenever transitioning to a new snap point, often after dragging snapping: { initial: 'start', states: { start: { + entry: assign(({ event }) => + event.type === 'SNAP' + ? { + y: event.payload.y, + velocity: event.payload.velocity, + snapSource: event.payload.source || 'custom', + } + : {} + ), invoke: { src: 'onSnapStart', onDone: 'snappingSmoothly', + input: ({ context }) => ({ snapSource: context.snapSource }), }, - entry: [ - assign({ - // @ts-expect-error - y: (_, { payload: { y } }) => y, - velocity: (_, { payload: { velocity } }) => velocity, - snapSource: (_, { payload: { source = 'custom' } }) => source, - }), - ], }, snappingSmoothly: { - invoke: { src: 'snapSmoothly', onDone: 'end' }, + invoke: { + src: 'snapSmoothly', + onDone: 'end', + input: ({ context }) => ({ + y: context.y, + velocity: context.velocity, + }), + }, }, end: { - invoke: { src: 'onSnapEnd', onDone: 'done' }, + invoke: { + src: 'onSnapEnd', + onDone: 'done', + input: ({ context }) => ({ snapSource: context.snapSource }), + }, on: { RESIZE: '#overlay.resizing', SNAP: '#overlay.snapping', @@ -228,10 +175,7 @@ export const overlayMachine = Machine< initial: 'start', states: { start: { - invoke: { - src: 'onResizeStart', - onDone: 'resizingSmoothly', - }, + invoke: { src: 'onResizeStart', onDone: 'resizingSmoothly' }, }, resizingSmoothly: { invoke: { src: 'resizeSmoothly', onDone: 'end' }, @@ -258,10 +202,7 @@ export const overlayMachine = Machine< initial: 'start', states: { start: { - invoke: { - src: 'onCloseStart', - onDone: 'deactivating', - }, + invoke: { src: 'onCloseStart', onDone: 'deactivating' }, on: { OPEN: { target: '#overlay.open', actions: 'onCloseCancel' } }, }, deactivating: { @@ -286,107 +227,13 @@ export const overlayMachine = Machine< }, }, on: { - CLOSE: 'closing', + CLOSE: '.closing', }, }, { - actions: { - onOpenCancel: (context, event) => { - console.log('onOpenCancel', { context, event }) - }, - onSnapCancel: (context, event) => { - console.log('onSnapCancel', { context, event }) - }, - onResizeCancel: (context, event) => { - console.log('onResizeCancel', { context, event }) - }, - onCloseCancel: (context, event) => { - console.log('onCloseCancel', { context, event }) - }, - onOpenEnd: (context, event) => { - console.log('onOpenCancel', { context, event }) - }, - onSnapEnd: (context, event) => { - console.log('onSnapEnd', { context, event }) - }, - onRezizeEnd: (context, event) => { - console.log('onRezizeEnd', { context, event }) - }, - }, - services: { - onSnapStart: async () => { - await sleep() - }, - onOpenStart: async () => { - await sleep() - }, - onCloseStart: async () => { - await sleep() - }, - onResizeStart: async () => { - await sleep() - }, - onSnapEnd: async () => { - await sleep() - }, - onOpenEnd: async () => { - await sleep() - }, - onCloseEnd: async () => { - await sleep() - }, - onResizeEnd: async () => { - await sleep() - }, - renderVisuallyHidden: async (context, event) => { - console.group('renderVisuallyHidden') - console.log({ context, event }) - await sleep() - console.groupEnd() - }, - activate: async (context, event) => { - console.group('activate') - console.log({ context, event }) - await sleep() - console.groupEnd() - }, - deactivate: async (context, event) => { - console.group('deactivate') - console.log({ context, event }) - await sleep() - console.groupEnd() - }, - openSmoothly: async (context, event) => { - console.group('openSmoothly') - console.log({ context, event }) - await sleep() - console.groupEnd() - }, - openImmediately: async (context, event) => { - console.group('openImmediately') - console.log({ context, event }) - await sleep() - console.groupEnd() - }, - snapSmoothly: async (context, event) => { - console.group('snapSmoothly') - console.log({ context, event }) - await sleep() - console.groupEnd() - }, - resizeSmoothly: async (context, event) => { - console.group('resizeSmoothly') - console.log({ context, event }) - await sleep() - console.groupEnd() - }, - closeSmoothly: async (context, event) => { - console.group('closeSmoothly') - console.log({ context, event }) - await sleep() - console.groupEnd() - }, + guards: { + initiallyOpen: ({ context }) => context.initialState === 'OPEN', + initiallyClosed: ({ context }) => context.initialState === 'CLOSED', }, - guards: { initiallyClosed, initiallyOpen }, } ) diff --git a/src/presets.ts b/src/presets.ts new file mode 100644 index 00000000..199376fa --- /dev/null +++ b/src/presets.ts @@ -0,0 +1,82 @@ +import { easings } from '@react-spring/web' +import type { SpringConfig } from './types' + +/** + * Ready made values for the `springConfig` prop. + * + * A note on how react-spring reads these: when `duration` is set it runs a tween and + * ignores `tension`, `friction`, `mass` and `velocity` entirely. Only a config without + * `duration` reaches the spring solver, which is why `springy` sets it to undefined. + * + * The floor for any duration is one frame. react-spring advances the first frame with a + * fixed 16.667ms delta, so anything at or below that finishes instantly. It also caps a + * frame's progress at 64ms, so on a device that stutters the animation stretches in real + * time rather than jumping. + */ + +/** Solves a CSS style `cubic-bezier(x1, y1, x2, y2)` curve, which react-spring has no helper for. */ +export function cubicBezier(x1: number, y1: number, x2: number, y2: number) { + const axis = (a: number, b: number, t: number) => { + const u = 1 - t + return 3 * u * u * t * a + 3 * u * t * t * b + t * t * t + } + return (progress: number) => { + if (progress <= 0) return 0 + if (progress >= 1) return 1 + let lo = 0 + let hi = 1 + let t = progress + for (let i = 0; i < 20; i++) { + const x = axis(x1, x2, t) + if (Math.abs(x - progress) < 1e-4) break + if (x < progress) lo = t + else hi = t + t = (lo + hi) / 2 + } + return axis(y1, y2, t) + } +} + +/** The historical default. A linear tween, so it starts and stops abruptly. */ +export const linear: Partial = { + duration: 115, +} + +/** + * Same pace, but eased so it leaves quickly and settles instead of stopping dead. + * + * The longer duration is not a slower animation. `easeOutCubic` front loads the distance: + * it covers 90% of the travel in 54% of its duration, against 90% in 90% for a linear + * tween. 190ms here reaches 90% at the same moment 115ms linear does, so it reads as the + * same speed with a softer landing. + */ +export const eased: Partial = { + duration: 190, + easing: easings.easeOutCubic, +} + +/** + * Material 3's motion for a surface entering the screen, for apps that sit next to + * Material components and should not feel faster or slower than the rest of them. + * + * 300ms is `md.sys.motion.duration.medium2` and the curve is `emphasized.decelerate`, + * both taken from Google's own token repository. Noticeably more deliberate than the + * default, which sits between M3's short2 and short3, the range meant for small controls + * rather than a full width sheet. + */ +export const material: Partial = { + duration: 300, + easing: cubicBezier(0.05, 0.7, 0.1, 1), +} + +/** + * Actual spring physics, which is what this library was built on. + * + * Unlike the tweens, the speed follows the distance travelled and the velocity of your + * drag, so a short snap is quick and a long one carries momentum. Costs a little overshoot. + */ +export const springy: Partial = { + duration: undefined, + tension: 210, + friction: 26, +} diff --git a/src/style.css b/src/style.css index 3f8d6346..88855fdf 100644 --- a/src/style.css +++ b/src/style.css @@ -60,17 +60,29 @@ [data-rsbs-header] { flex-shrink: 0; cursor: ns-resize; + touch-action: none; padding: 16px; } [data-rsbs-header] { + position: relative; text-align: center; user-select: none; - box-shadow: 0 1px 0 - rgba(46, 59, 66, calc(var(--rsbs-content-opacity) * 0.125)); z-index: 1; padding-top: calc(20px + env(safe-area-inset-top)); padding-bottom: 8px; } +[data-rsbs-header]:after { + position: absolute; + content: ''; + left: 0; + right: 0; + bottom: 0; + height: 1px; + pointer-events: none; + background-color: rgba(46, 59, 66, 0.125); + opacity: var(--rsbs-content-opacity); + will-change: opacity; +} [data-rsbs-header]:before { position: absolute; content: ''; @@ -89,9 +101,11 @@ } } [data-rsbs-has-header='false'] [data-rsbs-header] { - box-shadow: none; padding-top: calc(12px + env(safe-area-inset-top)); } +[data-rsbs-has-header='false'] [data-rsbs-header]:after { + display: none; +} [data-rsbs-scroll] { flex-shrink: 1; flex-grow: 1; @@ -115,16 +129,28 @@ overflow: hidden; } [data-rsbs-footer] { - box-shadow: 0 -1px 0 rgba(46, 59, 66, calc(var(--rsbs-content-opacity) * 0.125)), - 0 2px 0 var(--rsbs-bg); + position: relative; + box-shadow: 0 2px 0 var(--rsbs-bg); overflow: hidden; z-index: 1; padding-bottom: calc(16px + env(safe-area-inset-bottom)); } +[data-rsbs-footer]:before { + position: absolute; + content: ''; + left: 0; + right: 0; + top: 0; + height: 1px; + pointer-events: none; + background-color: rgba(46, 59, 66, 0.125); + opacity: var(--rsbs-content-opacity); + will-change: opacity; +} [data-rsbs-is-dismissable='true'], -[data-rsbs-is-dismissable='false']:matches([data-rsbs-state='opening'], [data-rsbs-state='closing']) { - & :matches([data-rsbs-header], [data-rsbs-scroll], [data-rsbs-footer]) > * { +[data-rsbs-is-dismissable='false']:is([data-rsbs-state='opening'], [data-rsbs-state='closing']) { + & :is([data-rsbs-header], [data-rsbs-scroll], [data-rsbs-footer]) > * { opacity: var(--rsbs-content-opacity); } & [data-rsbs-backdrop] { @@ -137,3 +163,7 @@ /* Allows interactions on the rest of the page before the close transition is finished */ pointer-events: none; } +[data-rsbs-state='closed'] { + /* Keeps a closed but still mounted sheet out of the tab order and the accessibility tree */ + visibility: hidden; +} diff --git a/src/types.ts b/src/types.ts index a572656b..60939c0b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,8 @@ +import type React from 'react' + +/** A ref whose current value can be written to, works with both the React 18 and React 19 typings */ +export type MutableRef = { current: T } + export type SnapPointProps = { /** * The height of the sticky header, if there's one @@ -42,6 +47,26 @@ export type SpringEvent = | { type: 'RESIZE'; source: ResizeSource } | { type: 'SNAP'; source: 'dragging' | 'custom' | string } +/** + * Properties that can be used to customize the animation. + * + * Setting `duration` runs a tween and makes react-spring ignore `mass`, `tension`, + * `friction` and `velocity`; only `easing` still applies. Set `duration` to `undefined` + * to get spring physics instead. See the `presets` export for ready made values. + * @see https://react-spring.dev/docs/advanced/config + */ +export type SpringConfig = { + mass: number + tension: number + friction: number + velocity: number + duration: number | undefined + /** Shapes a tween's progress curve. Ignored unless `duration` is set. Defaults to linear. */ + easing: (t: number) => number + /** Stops a spring from overshooting its target. Ignored when `duration` is set. */ + clamp: boolean +} + export type Props = { /** * Ensure that whatever you put in here have at least 1px height, or else the bottom sheet won't open @@ -55,11 +80,21 @@ export type Props = { */ sibling?: React.ReactNode + /** + * Pass the spring configurations (to change animation) in this format: { mass, tension, friction, duration }. + */ + springConfig?: Partial + + /** + * Scroller target + */ + scrollerRef?: React.RefObject + /** * Start a transition from closed to open, open to closed, or snap to snap. * Return a promise or async to delay the start of the transition, just remember it can be cancelled. */ - onSpringStart?: (event: SpringEvent) => void + onSpringStart?: (event: SpringEvent) => void | Promise /** * A running transition didn't finish or got stopped, this event isn't awaited on and might happen * after the sheet is unmounted (if it were in the middle of something). @@ -70,7 +105,7 @@ export type Props = { * the sheet without interrupting the closing animation. * Return a promise or async to delay the start of the transition, just remember it can be cancelled. */ - onSpringEnd?: (event: SpringEvent) => void + onSpringEnd?: (event: SpringEvent) => void | Promise /** Whether the bottom sheet is open or not. */ open: boolean @@ -95,7 +130,7 @@ export type Props = { * A reference to the element that should be focused. By default it'll be the first interactive element. * Set to false to disable keyboard focus when opening. */ - initialFocusRef?: React.RefObject | false + initialFocusRef?: React.RefObject | false /** * Handler that is called when the user presses *esc*, clicks outside the dialog or drags the sheet to the bottom of the display. @@ -143,14 +178,20 @@ export type Props = { /** * Open immediatly instead of initially animating from a closed => open state, useful if the bottom sheet is visible by default and the animation would be distracting */ - skipInitialTransition?: boolean, + skipInitialTransition?: boolean /** * Expand the bottom sheet on the content dragging. By default user can expand the bottom sheet only by dragging the header or overlay. This option enables expanding on dragging the content. * @default expandOnContentDrag === false */ - expandOnContentDrag?: boolean, -} & Omit, 'children'> + expandOnContentDrag?: boolean + + /** + * Whether the bottom sheet should be mounted when it's closed. + * @default false + */ + keepMounted?: boolean +} & Omit, 'children'> export interface RefHandles { /** @@ -171,4 +212,6 @@ export interface RefHandles { * It's update lifecycle with events are onSpringStart and onSpringCancel will give you the old value, while onSpringEnd will give you the current one. */ height: number + + scrollElement: HTMLDivElement | null } diff --git a/src/utils.ts b/src/utils.ts index 2b27ed4c..1660929a 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,5 +1,3 @@ -/* eslint-disable no-self-compare */ - // stolen from lodash export function clamp(number: number, lower: number, upper: number) { number = +number @@ -59,7 +57,24 @@ export function processSnapPoints(unsafeSnaps: number | number[], maxHeight) { } } -export const debugging = - process.env.NODE_ENV === 'development' && typeof window !== 'undefined' - ? window.location.search === '?debug' - : false +// Same curve @use-gesture uses internally, inlined to avoid depending on its private utils entry point +function rubberband(distance: number, dimension: number, constant: number) { + if (dimension === 0 || Math.abs(dimension) === Infinity) { + return Math.pow(distance, constant * 5) + } + return (distance * dimension * constant) / (dimension + constant * distance) +} + +export function rubberbandIfOutOfBounds( + position: number, + min: number, + max: number, + constant = 0.15 +) { + if (constant === 0) return clamp(position, min, max) + if (position < min) + return -rubberband(min - position, max - min, constant) + min + if (position > max) + return +rubberband(position - max, max - min, constant) + max + return position +} diff --git a/tailwind.config.js b/tailwind.config.js deleted file mode 100644 index d697486d..00000000 --- a/tailwind.config.js +++ /dev/null @@ -1,41 +0,0 @@ -const colors = require('tailwindcss/colors') - -module.exports = { - purge: ['./docs/**/*.{js,ts,jsx,tsx}', './pages/**/*.{js,ts,jsx,tsx}'], - darkMode: 'media', // 'media' or 'class' - theme: { - // https://fonts.google.com/specimen/Source+Sans+Pro?query=Source&sidebar.open=true&selection.family=Montserrat:wght@700;900|Source+Sans+Pro - fontFamily: { - display: ['Montserrat', 'sans-serif'], - body: ['Source Sans Pro', 'sans-serif'], - }, - extend: { - colors: { - gray: colors.blueGray, - hero: { - DEFAULT: '#592340', - lighter: '#FC9CC3', - }, - }, - ringOffsetColor: { - 'rsbs-bg': 'var(--rsbs-bg)', - }, - transitionDuration: { - 0: '0ms', - }, - }, - }, - variants: { - extend: { - backgroundColor: ['active', 'focus-visible'], - textColor: ['active', 'focus-visible'], - transitionDuration: ['focus', 'focus-visible'], - ringColor: ['group-focus', 'focus-visible'], - ringOffsetColor: ['group-focus', 'focus-visible'], - ringOffsetWidth: ['group-focus', 'focus-visible'], - ringOpacity: ['group-focus', 'focus-visible'], - ringWidth: ['group-focus', 'focus-visible'], - }, - }, - plugins: [require('@tailwindcss/forms')], -} diff --git a/test/bottom-sheet.test.tsx b/test/bottom-sheet.test.tsx new file mode 100644 index 00000000..72154c47 --- /dev/null +++ b/test/bottom-sheet.test.tsx @@ -0,0 +1,147 @@ +import { StrictMode, useRef } from 'react' +import { describe, expect, it, vi } from 'vitest' +import { render, screen, waitFor, act, fireEvent } from '@testing-library/react' +import { BottomSheet } from '../src' +import type { BottomSheetRef } from '../src' + +const content =
sheet content
+ +const sheetRoot = () => document.querySelector('[data-rsbs-root]') +const sheetState = () => sheetRoot()?.getAttribute('data-rsbs-state') + +describe('BottomSheet', () => { + it('renders nothing while closed', () => { + render({content}) + expect(sheetRoot()).toBeNull() + }) + + it('stays mounted but hidden when keepMounted is set', async () => { + render( + + {content} + + ) + await waitFor(() => expect(sheetRoot()).not.toBeNull()) + expect(sheetState()).toBe('closed') + }) + + it('opens and exposes the state as a data attribute', async () => { + render({content}) + + await waitFor(() => expect(sheetState()).toBe('open'), { timeout: 4000 }) + expect(screen.getByText('sheet content')).toBeTruthy() + expect( + document.querySelector('[data-rsbs-overlay]')?.getAttribute('role') + ).toBe('dialog') + }) + + // The sheet mounts twice under StrictMode; react-spring v8 left the springs dead after that + it('still opens under StrictMode', async () => { + render( + + {content} + + ) + + await waitFor(() => expect(sheetState()).toBe('open'), { timeout: 4000 }) + }) + + it('sets aria-modal from the blocking prop', async () => { + render( + + {content} + + ) + + await waitFor(() => expect(sheetState()).toBe('open'), { timeout: 4000 }) + expect( + document.querySelector('[data-rsbs-overlay]')?.getAttribute('aria-modal') + ).toBe('false') + expect(document.querySelector('[data-rsbs-backdrop]')).toBeNull() + }) + + it('dismisses on Escape', async () => { + const onDismiss = vi.fn() + render( + + {content} + + ) + + await waitFor(() => expect(sheetState()).toBe('open'), { timeout: 4000 }) + + const overlay = document.querySelector('[data-rsbs-overlay]')! + act(() => { + overlay.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }) + ) + }) + + expect(onDismiss).toHaveBeenCalledOnce() + }) + + it('dismisses once per backdrop tap', async () => { + const onDismiss = vi.fn() + render( + + {content} + + ) + await waitFor(() => expect(sheetState()).toBe('open'), { timeout: 4000 }) + + const backdrop = document.querySelector('[data-rsbs-backdrop]')! + const pointer = { pointerId: 1, buttons: 1, clientX: 10, clientY: 10 } + fireEvent.pointerDown(backdrop, pointer) + fireEvent.pointerUp(backdrop, { ...pointer, buttons: 0 }) + + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(onDismiss).toHaveBeenCalledOnce() + }) + + it('dismisses once per swipe down', async () => { + const onDismiss = vi.fn() + render( + header
}> + {content} + + ) + await waitFor(() => expect(sheetState()).toBe('open'), { timeout: 4000 }) + + const header = document.querySelector('[data-rsbs-header]')! + const pointer = { pointerId: 1, buttons: 1, clientX: 10 } + fireEvent.pointerDown(header, { ...pointer, clientY: 0 }) + fireEvent.pointerMove(header, { ...pointer, clientY: 150 }) + fireEvent.pointerMove(header, { ...pointer, clientY: 400 }) + fireEvent.pointerUp(header, { ...pointer, buttons: 0, clientY: 400 }) + + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(onDismiss).toHaveBeenCalledOnce() + }) + + it('reports the current height through the ref', async () => { + let sheetRef: React.RefObject + + function Wrapper() { + sheetRef = useRef(null) + return ( + + {content} + + ) + } + + render() + await waitFor(() => expect(sheetState()).toBe('open'), { timeout: 4000 }) + expect(sheetRef!.current?.height).toBeGreaterThan(0) + expect(sheetRef!.current?.scrollElement).not.toBeNull() + }) + + it('unmounts after closing', async () => { + const { rerender } = render({content}) + await waitFor(() => expect(sheetState()).toBe('open'), { timeout: 4000 }) + + rerender({content}) + await waitFor(() => expect(sheetRoot()).toBeNull(), { timeout: 4000 }) + expect(document.body.style.overflow).not.toBe('hidden') + }) +}) diff --git a/test/overlay-machine.test.ts b/test/overlay-machine.test.ts new file mode 100644 index 00000000..2bbb8f65 --- /dev/null +++ b/test/overlay-machine.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest' +import { createActor, fromPromise } from 'xstate' +import { overlayMachine } from '../src/machines/overlay' + +const actorNames = [ + 'onOpenStart', + 'onSnapStart', + 'onCloseStart', + 'onResizeStart', + 'onOpenEnd', + 'onSnapEnd', + 'onCloseEnd', + 'onResizeEnd', + 'renderVisuallyHidden', + 'activate', + 'deactivate', + 'openImmediately', + 'openSmoothly', + 'snapSmoothly', + 'resizeSmoothly', + 'closeSmoothly', +] as const + +/** Starts the machine with every actor recording its name (and input) into a shared log */ +function startMachine(initialState: 'OPEN' | 'CLOSED') { + const calls: { name: string; input?: unknown }[] = [] + const actors = Object.fromEntries( + actorNames.map((name) => [ + name, + fromPromise(async ({ input }) => { + calls.push({ name, input }) + }), + ]) + ) + + const actor = createActor( + overlayMachine.provide({ actors: actors as never }), + { + input: { initialState }, + } + ).start() + + return { actor, calls, names: () => calls.map((c) => c.name) } +} + +const settle = () => new Promise((resolve) => setTimeout(resolve, 50)) + +describe('overlay machine', () => { + it('starts closed', () => { + const { actor } = startMachine('CLOSED') + expect(actor.getSnapshot().value).toBe('closed') + }) + + it('ignores CLOSE while already closed', async () => { + const { actor, names } = startMachine('CLOSED') + actor.send({ type: 'CLOSE' }) + await settle() + expect(actor.getSnapshot().value).toBe('closed') + expect(names()).not.toContain('onCloseStart') + }) + + // initialState is what skipInitialTransition drives: OPEN must not animate in from the bottom + it('opens immediately when it starts open', async () => { + const { actor, names } = startMachine('OPEN') + actor.send({ type: 'OPEN' }) + await settle() + expect(names()).toContain('openImmediately') + expect(names()).not.toContain('openSmoothly') + expect(names()).not.toContain('renderVisuallyHidden') + expect(actor.getSnapshot().value).toBe('open') + }) + + it('opens smoothly when it starts closed', async () => { + const { actor, names } = startMachine('CLOSED') + actor.send({ type: 'OPEN' }) + await settle() + expect(names()).toEqual([ + 'onOpenStart', + 'renderVisuallyHidden', + 'activate', + 'openSmoothly', + 'onOpenEnd', + ]) + expect(actor.getSnapshot().value).toBe('open') + }) + + it('passes the snap payload to the spring', async () => { + const { actor, calls } = startMachine('OPEN') + actor.send({ type: 'OPEN' }) + await settle() + actor.send({ + type: 'SNAP', + payload: { y: 240, velocity: 2, source: 'dragging' }, + }) + await settle() + + expect(calls.find((c) => c.name === 'snapSmoothly')?.input).toEqual({ + y: 240, + velocity: 2, + }) + expect(calls.find((c) => c.name === 'onSnapStart')?.input).toEqual({ + snapSource: 'dragging', + }) + expect(actor.getSnapshot().value).toBe('open') + }) + + it('deactivates scroll lock and focus trap before animating closed', async () => { + const { actor, names } = startMachine('OPEN') + actor.send({ type: 'OPEN' }) + await settle() + actor.send({ type: 'CLOSE' }) + await settle() + + const order = names() + expect(order.indexOf('deactivate')).toBeLessThan( + order.indexOf('closeSmoothly') + ) + expect(actor.getSnapshot().value).toBe('closed') + }) + + it('lets a drag interrupt an in-flight open', async () => { + const { actor } = startMachine('CLOSED') + actor.send({ type: 'OPEN' }) + await new Promise((resolve) => setTimeout(resolve, 0)) + actor.send({ type: 'DRAG' }) + await settle() + expect(actor.getSnapshot().value).toBe('dragging') + }) +}) diff --git a/test/presets.test.ts b/test/presets.test.ts new file mode 100644 index 00000000..9fbd8c99 --- /dev/null +++ b/test/presets.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' +import { cubicBezier, eased, linear, material, springy } from '../src/presets' + +/** First progress value at which the curve has covered `fraction` of the travel */ +function timeToFraction(easing: (t: number) => number, fraction: number) { + for (let p = 0; p <= 1; p += 0.0005) { + if (easing(p) >= fraction) return p + } + return 1 +} + +describe('cubicBezier', () => { + it('is pinned at both ends', () => { + const curve = cubicBezier(0.05, 0.7, 0.1, 1) + expect(curve(0)).toBe(0) + expect(curve(1)).toBe(1) + expect(curve(-1)).toBe(0) + expect(curve(2)).toBe(1) + }) + + it('reproduces linear for the identity curve', () => { + const curve = cubicBezier(0, 0, 1, 1) + for (const p of [0.1, 0.25, 0.5, 0.75, 0.9]) { + expect(curve(p)).toBeCloseTo(p, 2) + } + }) + + it('never goes backwards', () => { + const curve = cubicBezier(0.05, 0.7, 0.1, 1) + let previous = 0 + for (let p = 0; p <= 1; p += 0.01) { + const value = curve(p) + expect(value).toBeGreaterThanOrEqual(previous - 1e-9) + previous = value + } + }) + + it('decelerates, so most of the travel happens early', () => { + const curve = cubicBezier(0.05, 0.7, 0.1, 1) + expect(curve(0.25)).toBeGreaterThan(0.75) + }) +}) + +describe('presets', () => { + it('only the spring one reaches the spring solver', () => { + // react-spring runs a tween whenever duration is set, ignoring tension and friction + expect(linear.duration).toBeDefined() + expect(eased.duration).toBeDefined() + expect(material.duration).toBeDefined() + expect(springy.duration).toBeUndefined() + expect(springy.tension).toBeDefined() + }) + + it('every duration clears the one frame floor', () => { + // react-spring advances the first frame with a fixed 16.667ms delta + for (const preset of [linear, eased, material]) { + expect(preset.duration).toBeGreaterThan(16.667) + } + }) + + it('the eased and material recipes match linear where it is noticed', () => { + // linear covers 90% of the travel in 90% of its duration + const linearAt90 = 0.9 * linear.duration! + + const easedAt90 = timeToFraction(eased.easing!, 0.9) * eased.duration! + const materialAt90 = + timeToFraction(material.easing!, 0.9) * material.duration! + + // within 15ms of each other, so none of them reads as slower than the default + expect(Math.abs(easedAt90 - linearAt90)).toBeLessThan(15) + expect(Math.abs(materialAt90 - linearAt90)).toBeLessThan(15) + + // and both still take longer overall, which is the soft landing + expect(eased.duration!).toBeGreaterThan(linear.duration!) + expect(material.duration!).toBeGreaterThan(linear.duration!) + }) +}) diff --git a/test/setup.ts b/test/setup.ts new file mode 100644 index 00000000..a1ab4ec9 --- /dev/null +++ b/test/setup.ts @@ -0,0 +1,82 @@ +import { afterEach, vi } from 'vitest' +import { cleanup } from '@testing-library/react' + +// happy-dom ships neither of these, and the sheet cannot measure itself without them +class ResizeObserverMock { + private callback: ResizeObserverCallback + private targets = new Set() + + constructor(callback: ResizeObserverCallback) { + this.callback = callback + observers.add(this) + } + + observe(target: Element) { + this.targets.add(target) + this.emit() + } + + unobserve(target: Element) { + this.targets.delete(target) + } + + disconnect() { + this.targets.clear() + observers.delete(this) + } + + emit() { + const entries = [...this.targets].map((target) => ({ + target, + borderBoxSize: [{ blockSize: measuredHeight(target), inlineSize: 0 }], + contentBoxSize: [{ blockSize: measuredHeight(target), inlineSize: 0 }], + contentRect: { height: measuredHeight(target) } as DOMRectReadOnly, + devicePixelContentBoxSize: [], + })) as unknown as ResizeObserverEntry[] + + if (entries.length) { + this.callback(entries, this as unknown as ResizeObserver) + } + } +} + +const observers = new Set() + +/** Heights the mock reports, keyed by the data attribute on the element */ +const heights: Record = { + 'data-rsbs-content': 200, + 'data-rsbs-header': 50, + 'data-rsbs-footer': 40, +} + +function measuredHeight(target: Element) { + for (const [attr, height] of Object.entries(heights)) { + if (target.hasAttribute(attr)) return height + } + return 0 +} + +/** Re-runs every observer, e.g. after content is added */ +export function flushResizeObservers() { + observers.forEach((observer) => observer.emit()) +} + +vi.stubGlobal('ResizeObserver', ResizeObserverMock) + +vi.stubGlobal( + 'matchMedia', + vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })) +) + +afterEach(() => { + cleanup() + observers.clear() +}) diff --git a/test/utils.test.ts b/test/utils.test.ts new file mode 100644 index 00000000..77549055 --- /dev/null +++ b/test/utils.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import { + clamp, + processSnapPoints, + roundAndCheckForNaN, + rubberbandIfOutOfBounds, +} from '../src/utils' + +describe('clamp', () => { + it('keeps values inside the bounds', () => { + expect(clamp(5, 0, 10)).toBe(5) + expect(clamp(-1, 0, 10)).toBe(0) + expect(clamp(11, 0, 10)).toBe(10) + }) + + it('treats NaN bounds as zero', () => { + expect(clamp(5, NaN, 10)).toBe(5) + expect(clamp(5, 0, NaN)).toBe(0) + }) +}) + +describe('roundAndCheckForNaN', () => { + it('rounds', () => { + expect(roundAndCheckForNaN(10.4)).toBe(10) + expect(roundAndCheckForNaN(10.6)).toBe(11) + }) + + it('throws on NaN so misconfigured snap points fail loudly', () => { + expect(() => roundAndCheckForNaN(NaN)).toThrow(TypeError) + }) +}) + +describe('processSnapPoints', () => { + it('sorts, rounds, dedupes and clamps to maxHeight', () => { + const { snapPoints, minSnap, maxSnap } = processSnapPoints( + [100.4, 100, 300, 900], + 500 + ) + expect(snapPoints).toEqual([100, 300, 500]) + expect(minSnap).toBe(100) + expect(maxSnap).toBe(500) + }) + + it('accepts a single number', () => { + expect(processSnapPoints(120, 500).snapPoints).toEqual([120]) + }) + + it('never returns a negative snap point', () => { + expect(processSnapPoints([-50, 100], 500).minSnap).toBe(0) + }) +}) + +describe('rubberbandIfOutOfBounds', () => { + it('passes through values inside the bounds', () => { + expect(rubberbandIfOutOfBounds(50, 0, 100)).toBe(50) + }) + + it('dampens overshoot on both ends', () => { + const over = rubberbandIfOutOfBounds(150, 0, 100, 0.55) + expect(over).toBeGreaterThan(100) + expect(over).toBeLessThan(150) + + const under = rubberbandIfOutOfBounds(-50, 0, 100, 0.55) + expect(under).toBeLessThan(0) + expect(under).toBeGreaterThan(-50) + }) + + it('clamps when the constant is zero', () => { + expect(rubberbandIfOutOfBounds(150, 0, 100, 0)).toBe(100) + }) +}) diff --git a/tsconfig.declarations.json b/tsconfig.build.json similarity index 56% rename from tsconfig.declarations.json rename to tsconfig.build.json index bf0cad53..eeb31a77 100644 --- a/tsconfig.declarations.json +++ b/tsconfig.build.json @@ -1,9 +1,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "outDir": "dist", - "emitDeclarationOnly": true, - "declaration": true, + "incremental": false, "noEmit": false }, "include": ["src"] diff --git a/tsconfig.json b/tsconfig.json index 4bcda4db..ff0eb5ca 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,11 @@ { "compilerOptions": { - "target": "es5", - "lib": ["dom", "dom.iterable", "esnext"], + "target": "es2019", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, "strict": false, @@ -9,12 +13,18 @@ "noEmit": true, "esModuleInterop": true, "module": "esnext", - "downlevelIteration": true, - "moduleResolution": "node", + "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "preserve" + "jsx": "react-jsx", + "incremental": true }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], - "exclude": ["node_modules"] + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx" + ], + "exclude": [ + "node_modules" + ] } diff --git a/tsconfig.microbundle.json b/tsconfig.microbundle.json deleted file mode 100644 index 53ee8731..00000000 --- a/tsconfig.microbundle.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "compilerOptions": { - "allowSyntheticDefaultImports": true, - "module": "ESNext", - "target": "ESNext", - "declaration": false - } -} diff --git a/tsup.config.ts b/tsup.config.ts new file mode 100644 index 00000000..fe7161d1 --- /dev/null +++ b/tsup.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'tsup' + +export default defineConfig({ + entry: ['src/index.tsx'], + format: ['cjs', 'esm'], + dts: true, + tsconfig: 'tsconfig.build.json', + sourcemap: true, + clean: true, + treeshake: true, + // Matches the browserslist field: optional chaining is Chrome 80+, so it gets downleveled + target: 'es2019', + external: ['react', 'react-dom'], +}) diff --git a/vitest.config.mts b/vitest.config.mts new file mode 100644 index 00000000..e003492a --- /dev/null +++ b/vitest.config.mts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + test: { + environment: 'happy-dom', + setupFiles: ['./test/setup.ts'], + include: ['test/**/*.test.{ts,tsx}'], + }, +})