From ced18c376817b92d59720ca7142fd5c3966d56f6 Mon Sep 17 00:00:00 2001 From: Guido Wagner Date: Tue, 8 Sep 2026 03:50:37 -0300 Subject: [PATCH 1/3] feat: React 19 support, migrate to @react-spring/web and @use-gesture/react Drop react-spring v8 (broken under React 18/19 StrictMode), react-use-gesture and the ResizeObserver polyfill. Wire the XState machine once via useActorRef so transitions no longer re-render, fix skipInitialTransition and the data-rsbs-state attribute, remove debug logging. --- .claude/launch.json | 11 + .gitignore | 2 +- README.md | 9 +- docs/Footer.tsx | 4 +- docs/Hero.tsx | 10 +- next.config.js | 3 + package-lock.json | 229 +++++++++--- package.json | 39 +- pages/_app.tsx | 4 +- src/BottomSheet.tsx | 488 +++++++++++++------------- src/hooks/useAriaHider.tsx | 2 +- src/hooks/useFocusTrap.tsx | 12 +- src/hooks/useReady.tsx | 9 +- src/hooks/useReducedMotion.tsx | 19 +- src/hooks/useScrollLock.tsx | 2 +- src/hooks/useSnapPoints.tsx | 76 ++-- src/hooks/useSpring.tsx | 5 +- src/hooks/useSpringInterpolations.tsx | 122 +++---- src/index.tsx | 4 +- src/machines/overlay.ts | 285 +++------------ src/style.css | 9 +- src/types.ts | 24 +- src/utils.ts | 23 +- 23 files changed, 678 insertions(+), 713 deletions(-) create mode 100644 .claude/launch.json create mode 100644 next.config.js diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 00000000..204cdb2e --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "docs", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + "port": 3000 + } + ] +} diff --git a/.gitignore b/.gitignore index fef9369a..34ecaf92 100644 --- a/.gitignore +++ b/.gitignore @@ -136,4 +136,4 @@ yarn-error.log* .env.production.local # vercel -.vercel \ No newline at end of file +.vercel.claude/ diff --git a/README.md b/README.md index 60c00a16..c6171557 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# React Spring Bottom Sheet (React 18) +# 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) @@ -12,7 +12,7 @@ ### ✨ 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 incorporates the latest features and improvements from XState v5. +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 @@ -23,7 +23,7 @@ I am not the original author of this software. The original creation was by Cody - 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 **[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. +**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 @@ -450,4 +450,5 @@ export default function Example() { [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.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 327e99e0..bb657e4e 100644 --- a/docs/Hero.tsx +++ b/docs/Hero.tsx @@ -1,6 +1,6 @@ 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' @@ -79,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( @@ -128,7 +128,7 @@ export default function Hero({ className }: { className?: string }) { /> =12" } }, + "node_modules/@react-spring/animated": { + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-10.1.2.tgz", + "integrity": "sha512-yAsQ/bbp6+vko7WNCI1M00c6KLE9XKTGCrgRhQqS4JcK3oF5qBV4rHYrQEprvEvYXxt+1H5FsLS2eVValEPfFw==", + "license": "MIT", + "dependencies": { + "@react-spring/shared": "~10.1.2", + "@react-spring/types": "~10.1.2" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@react-spring/core": { + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/@react-spring/core/-/core-10.1.2.tgz", + "integrity": "sha512-lPGOAg0V+PV3ucopOajD+YCxoe8twALqAeG4c/+sqVjBsyUNNdx8qfz/DcNSPKA8PV3+AyQELlCxlDfap9cmBQ==", + "license": "MIT", + "dependencies": { + "@react-spring/animated": "~10.1.2", + "@react-spring/shared": "~10.1.2", + "@react-spring/types": "~10.1.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-spring/donate" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@react-spring/rafz": { + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/@react-spring/rafz/-/rafz-10.1.2.tgz", + "integrity": "sha512-KC6vSFZyPnRJ2rXqipV9QqR4SaYbYXjvKfdYKWipNK2mWuV79gr20WmpVUOsTiEHGRY3WSOdWCHN+P9Pdaqb7Q==", + "license": "MIT" + }, + "node_modules/@react-spring/shared": { + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/@react-spring/shared/-/shared-10.1.2.tgz", + "integrity": "sha512-47/8bNQ/o0uEmxEnPBuERlC29VSqiTn5P9ln9tUMSVkYKYxBtouYE686F5kAGj+eHRPoNCw7drxWE9nv2d1LMw==", + "license": "MIT", + "dependencies": { + "@react-spring/rafz": "~10.1.2", + "@react-spring/types": "~10.1.2" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@react-spring/types": { + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/@react-spring/types/-/types-10.1.2.tgz", + "integrity": "sha512-G4CWowmVPz+rDG1y9QRVq/prZNxiNwQaHC0kgT/MgY6jAGgdRgTV4VThpvJDwWvUitk3xZB4soP4d36fjeQ09g==", + "license": "MIT" + }, + "node_modules/@react-spring/web": { + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/@react-spring/web/-/web-10.1.2.tgz", + "integrity": "sha512-KxDB3zaDqy9qFsu7fdxjyraAxweHH4k5TW5WGT/OuMK6hKaxSDfhrQfRBzfFaSVvMBf+NghbJFanllV4ia6i7A==", + "license": "MIT", + "dependencies": { + "@react-spring/animated": "~10.1.2", + "@react-spring/core": "~10.1.2", + "@react-spring/shared": "~10.1.2", + "@react-spring/types": "~10.1.2", + "csstype": "^3.2.3" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@rollup/plugin-alias": { "version": "3.1.9", "dev": true, @@ -4371,6 +4443,7 @@ }, "node_modules/@trysound/sax": { "version": "0.2.0", + "dev": true, "license": "ISC", "engines": { "node": ">=10.13.0" @@ -4432,6 +4505,16 @@ "csstype": "^3.0.2" } }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, "node_modules/@types/resolve": { "version": "1.17.1", "dev": true, @@ -4807,6 +4890,24 @@ "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", "dev": true }, + "node_modules/@use-gesture/core": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz", + "integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==", + "license": "MIT" + }, + "node_modules/@use-gesture/react": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz", + "integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==", + "license": "MIT", + "dependencies": { + "@use-gesture/core": "10.3.1" + }, + "peerDependencies": { + "react": ">= 16.8.0" + } + }, "node_modules/@use-it/interval": { "version": "1.0.0", "dev": true, @@ -4819,16 +4920,17 @@ } }, "node_modules/@xstate/react": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@xstate/react/-/react-4.1.1.tgz", - "integrity": "sha512-pFp/Y+bnczfaZ0V8B4LOhx3d6Gd71YKAPbzerGqydC2nsYN/mp7RZu3q/w6/kvI2hwR/jeDeetM7xc3JFZH2NA==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@xstate/react/-/react-6.1.0.tgz", + "integrity": "sha512-ep9F0jGTI63B/jE8GHdMpUqtuz7yRebNaKv8EMUaiSi29NOglywc2X2YSOV/ygbIK+LtmgZ0q9anoEA2iBSEOw==", + "license": "MIT", "dependencies": { "use-isomorphic-layout-effect": "^1.1.2", "use-sync-external-store": "^1.2.0" }, "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "xstate": "^5.11.0" + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "xstate": "^5.28.0" }, "peerDependenciesMeta": { "xstate": { @@ -5469,6 +5571,7 @@ }, "node_modules/boolbase": { "version": "1.0.0", + "dev": true, "license": "ISC" }, "node_modules/bottleneck": { @@ -6095,6 +6198,25 @@ "node": ">=10" } }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "dev": true, @@ -6265,6 +6387,7 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", + "dev": true, "engines": { "node": ">= 6" }, @@ -6431,8 +6554,9 @@ } }, "node_modules/csstype": { - "version": "3.0.10", - "dev": true, + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -6639,6 +6763,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, "funding": [ { "type": "github", @@ -8356,10 +8481,12 @@ "license": "ISC" }, "node_modules/focus-trap": { - "version": "6.7.1", + "version": "6.9.4", + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-6.9.4.tgz", + "integrity": "sha512-v2NTsZe2FF59Y+sDykKY+XjqZ0cPfhq/hikWVL88BqLivnNiEffAsac6rP6H45ff9wG9LL5ToiDqrLEP9GX9mw==", "license": "MIT", "dependencies": { - "tabbable": "^5.2.1" + "tabbable": "^5.3.3" } }, "node_modules/for-each": { @@ -10790,6 +10917,7 @@ "version": "3.3.7", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "dev": true, "funding": [ { "type": "github", @@ -13738,6 +13866,7 @@ }, "node_modules/nth-check": { "version": "2.0.1", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "boolbase": "^1.0.0" @@ -13756,6 +13885,7 @@ }, "node_modules/object-assign": { "version": "4.1.1", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -14187,6 +14317,7 @@ }, "node_modules/picocolors": { "version": "1.0.0", + "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -14254,6 +14385,7 @@ "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "dev": true, "funding": [ { "type": "opencollective", @@ -15635,6 +15767,8 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-6.0.3.tgz", "integrity": "sha512-dlrahRmxP22bX6iKEjOM+c8/1p+81asjKT+V5lrgOH944ryx/OHpclnIbGsKVd3uWOXFLYJwCVf0eEkJGvO96g==", + "dev": true, + "license": "MIT", "dependencies": { "postcss-value-parser": "^4.2.0", "svgo": "^3.2.0" @@ -15663,6 +15797,7 @@ }, "node_modules/postcss-value-parser": { "version": "4.2.0", + "dev": true, "license": "MIT" }, "node_modules/postcss-values-parser": { @@ -15783,6 +15918,7 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -15903,27 +16039,9 @@ }, "node_modules/react-is": { "version": "16.13.1", + "dev": true, "license": "MIT" }, - "node_modules/react-spring": { - "version": "8.0.27", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.3.1", - "prop-types": "^15.5.8" - }, - "peerDependencies": { - "react": ">= 16.8.0", - "react-dom": ">= 16.8.0" - } - }, - "node_modules/react-use-gesture": { - "version": "8.0.1", - "license": "MIT", - "peerDependencies": { - "react": ">= 16.8.0" - } - }, "node_modules/read-cache": { "version": "1.0.0", "dev": true, @@ -16114,7 +16232,8 @@ "node_modules/regenerator-runtime": { "version": "0.14.1", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", + "dev": true }, "node_modules/regenerator-transform": { "version": "0.14.5", @@ -17229,6 +17348,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -17719,6 +17839,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.2.0.tgz", "integrity": "sha512-4PP6CMW/V7l/GmKRKzsLR8xxjdHTV4IMvhTnpuHwwBazSIlw5W/5SmPjN8Dwyt7lKbSJrRDgp4t9ph0HgChFBQ==", + "dev": true, "dependencies": { "@trysound/sax": "0.2.0", "commander": "^7.2.0", @@ -17743,6 +17864,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "dev": true, "engines": { "node": ">= 10" } @@ -17751,6 +17873,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", + "dev": true, "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", @@ -17766,6 +17889,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz", "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==", + "dev": true, "dependencies": { "mdn-data": "2.0.30", "source-map-js": "^1.0.1" @@ -17778,6 +17902,7 @@ "version": "5.0.5", "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==", + "dev": true, "dependencies": { "css-tree": "~2.2.0" }, @@ -17790,6 +17915,7 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz", "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==", + "dev": true, "dependencies": { "mdn-data": "2.0.28", "source-map-js": "^1.0.1" @@ -17802,12 +17928,14 @@ "node_modules/svgo/node_modules/csso/node_modules/mdn-data": { "version": "2.0.28", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz", - "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==" + "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", + "dev": true }, "node_modules/svgo/node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", @@ -17821,6 +17949,7 @@ "version": "5.0.3", "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, "dependencies": { "domelementtype": "^2.3.0" }, @@ -17835,6 +17964,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "dev": true, "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", @@ -17848,6 +17978,7 @@ "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, "engines": { "node": ">=0.12" }, @@ -17858,10 +17989,13 @@ "node_modules/svgo/node_modules/mdn-data": { "version": "2.0.30", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", - "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==" + "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==", + "dev": true }, "node_modules/tabbable": { - "version": "5.2.1", + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-5.3.3.tgz", + "integrity": "sha512-QD9qKY3StfbZqWOPLp0++pOrAVb/HbUi5xCc8cUo4XjP19808oaMiDzn0leBY5mCespIBM0CIZePzZjgzR83kA==", "license": "MIT" }, "node_modules/tailwindcss": { @@ -18720,9 +18854,10 @@ "license": "ISC" }, "node_modules/xstate": { - "version": "5.11.0", - "resolved": "https://registry.npmjs.org/xstate/-/xstate-5.11.0.tgz", - "integrity": "sha512-0MqTLpc7dr/hXFHY25oN4sdnO3Ey6MYy9WkWxOgiwjPV0S6rWwLb5nZlRlPDSku2GEV4/y6AR8bX+GNCOxnEwA==", + "version": "5.32.6", + "resolved": "https://registry.npmjs.org/xstate/-/xstate-5.32.6.tgz", + "integrity": "sha512-WfA8WNrh6r9osuGwVm+aIPM4jmMW2LKHV1Yv9thYpOtL4HVF/kobh95K/O8v2jDCpDmP2EoY+6uvuqHXCZ6ZLw==", + "license": "MIT", "funding": { "type": "opencollective", "url": "https://opencollective.com/xstate" diff --git a/package.json b/package.json index fb67ef61..3b2a397a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "guiw5-bottom-sheet", - "description": "React 18, XState v5 - ✨ Accessible, 🪄 Delightful, and 🤯 Performant. Built on react-spring for the web, and react-use-gesture.", + "description": "✨ Accessible, 🪄 Delightful, and 🤯 Performant. Built on @react-spring/web and @use-gesture/react. React 18 & 19, XState v5.", "license": "MIT", "author": "Cody Olsen", "authors": [ @@ -28,7 +28,7 @@ "build:declarations": "tsc --project tsconfig.declarations.json", "prebuild:dist": "rimraf dist/**", "build:dist": "npm run build:postcss && npm run build:microbundle && npm run build:declarations", - "build:microbundle": "NODE_ENV=production microbundle --define process.env.NODE_ENV=production --tsconfig tsconfig.microbundle.json -f cjs,es,modern --css-modules false --jsx React.createElement --jsxFragment React.Fragment", + "build:microbundle": "cross-env NODE_ENV=production microbundle --define process.env.NODE_ENV=production --tsconfig tsconfig.microbundle.json -f cjs,es,modern --css-modules false --jsx React.createElement --jsxFragment React.Fragment", "build:postcss": "postcss -d dist src/style.css", "dev": "next", "lint": "eslint . --ext ts,tsx,js,jsx --max-warnings 0 && tsc", @@ -41,17 +41,16 @@ ], "types": "dist/index.d.ts", "dependencies": { - "@juggle/resize-observer": "^3.2.0", - "@xstate/react": "^4.1.1", + "@react-spring/web": "^10.1.2", + "@use-gesture/react": "^10.3.1", + "@xstate/react": "^6.1.0", "body-scroll-lock": "^3.1.5", - "focus-trap": "^6.2.2", - "postcss-svgo": "^6.0.3", - "react-spring": "^8.0.27", - "react-use-gesture": "^8.0.1", - "xstate": "^5.11.0" + "focus-trap": "^6.9.4", + "xstate": "^5.32.6" }, "peerDependencies": { - "react": "^16.14.0 || 17 || 18" + "react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "devDependencies": { "@rooks/use-raf": "^4.5.0", @@ -61,6 +60,7 @@ "@types/classnames": "^2.2.11", "@types/node": "^14.14.10", "@types/react": "^18.2.0", + "@types/react-dom": "^18.3.7", "@typescript-eslint/eslint-plugin": "^7.7.0", "@typescript-eslint/parser": "^7.7.0", "@use-it/interval": "^1.0.0", @@ -68,6 +68,7 @@ "babel-eslint": "^10.1.0", "babel-plugin-transform-remove-console": "^6.9.4", "classnames": "^2.2.6", + "cross-env": "^7.0.3", "eslint": "^8.56.0", "eslint-config-react-app": "^7.0.1", "eslint-plugin-flowtype": "^8.0.3", @@ -84,6 +85,7 @@ "postcss-custom-properties-fallback": "^1.0.1", "postcss-import-svg": "^1.0.1", "postcss-preset-env": "^9.5.6", + "postcss-svgo": "^6.0.3", "prettier": "^2.2.1", "prettier-package-json": "^2.1.3", "react": "^18.2.0", @@ -110,18 +112,17 @@ "overlay", "popup", "react", - "react-spring", - "react-use-gesture", "sheet", - "typescript" + "typescript", + "use-gesture" ], "browserslist": [ - "Chrome >= 49", - "Android >= 58", - "Safari >= 9.1", - "iOS >= 9.3", - "Firefox >= 31", - "Edge >= 16" + "Chrome >= 64", + "Android >= 64", + "Safari >= 13.1", + "iOS >= 13.4", + "Firefox >= 69", + "Edge >= 79" ], "husky": { "hooks": { diff --git a/pages/_app.tsx b/pages/_app.tsx index 4babaf21..2ef6f018 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -14,8 +14,8 @@ export async function getStaticProps() { { version: reactUseGestureVersion }, ] = await Promise.all([ import('../package.json'), - import('react-spring/package.json'), - import('react-use-gesture/package.json'), + import('@react-spring/web/package.json'), + import('@use-gesture/react/package.json'), ]) if (!meta['og:site_name']) { meta['og:site_name'] = capitalize(name) diff --git a/src/BottomSheet.tsx b/src/BottomSheet.tsx index 7c96ed65..cc18055f 100644 --- a/src/BottomSheet.tsx +++ b/src/BottomSheet.tsx @@ -5,15 +5,17 @@ // 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, @@ -26,25 +28,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 { fromPromise } from 'xstate' +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( { @@ -76,7 +88,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 @@ -86,14 +97,16 @@ 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 @@ -101,11 +114,11 @@ export const BottomSheet = React.forwardRef< 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() @@ -157,243 +170,246 @@ 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" - duration: 115, tension, // "damping" friction: Math.max( friction, friction + (friction - friction * velocity) ), - ...springConfig, - }, - 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.provide({ - actions: { - onOpenCancel: useCallback( - () => onSpringCancelRef.current?.({ type: 'OPEN' }), - [] - ), - onSnapCancel: useCallback( - (state) => + + // 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: state.context.snapSource, + source: context.snapSource, }), - [] - ), - onCloseCancel: useCallback( - () => onSpringCancelRef.current?.({ type: 'CLOSE' }), - [] - ), - onResizeCancel: useCallback( - () => + onCloseCancel: () => onSpringCancelRef.current?.({ type: 'CLOSE' }), + onResizeCancel: () => onSpringCancelRef.current?.({ type: 'RESIZE', source: resizeSourceRef.current, }), - [] - ), - onOpenEnd: useCallback( - () => onSpringEndRef.current?.({ type: 'OPEN' }), - [] - ), - onSnapEnd: useCallback( - (state, event) => + onOpenEnd: () => onSpringEndRef.current?.({ type: 'OPEN' }), + onSnapEnd: ({ context }) => onSpringEndRef.current?.({ type: 'SNAP', - source: state.context.snapSource, + source: context.snapSource, }), - [] - ), - onResizeEnd: useCallback( - () => + 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' }) + }), + onCloseStart: fromPromise(async () => { + await onSpringStartRef.current?.({ type: 'CLOSE' }) + }), + onResizeStart: fromPromise(async () => { + await onSpringStartRef.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' }) + }), + onCloseEnd: fromPromise(async () => { + await onSpringEndRef.current?.({ type: 'CLOSE' }) + }), + onResizeEnd: fromPromise(async () => { + await onSpringEndRef.current?.({ + type: 'RESIZE', + source: resizeSourceRef.current, + }) + }), + renderVisuallyHidden: fromPromise(async () => { + 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, + }) + }), + activate: fromPromise(async () => { + canDragRef.current = true + await Promise.all([ + scrollLockRef.current.activate(), + focusTrapRef.current.activate(), + ariaHiderRef.current.activate(), + ]) + }), + deactivate: fromPromise(async () => { + scrollLockRef.current.deactivate() + focusTrapRef.current.deactivate() + ariaHiderRef.current.deactivate() + canDragRef.current = false + }), + openImmediately: fromPromise(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, + }) + }), + openSmoothly: fromPromise(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, + }) + + 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 + 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 }) + }), + }, + }), + [ + ariaHiderRef, + asyncSet, + focusTrapRef, + lastSnapRef, + prefersReducedMotion, + scrollLockRef, + ] + ) + const actorRef = useActorRef(machine, { input: { initialState } }) + const send = useCallback( + (event: OverlayEvent) => actorRef.send(event), + [actorRef] + ) - actors: { - onSnapStart: fromPromise(async ({ input }) => { - onSpringStartRef.current?.({ - type: 'SNAP', - source: input.snapSource || 'custom', - }) - }), - onOpenStart: fromPromise(async (props) => { - onSpringStartRef.current?.({ type: 'OPEN' }) - console.log('props:', props) - }), - onCloseStart: fromPromise(async () => - onSpringStartRef.current?.({ type: 'CLOSE' }) - ), - onResizeStart: fromPromise(async () => - onSpringStartRef.current?.({ - type: 'RESIZE', - source: resizeSourceRef.current, - }) - ), - onSnapEnd: fromPromise(async () => { - onSpringEndRef.current?.({ - type: 'SNAP', - source: current.context.snapSource, - }) - }), - onOpenEnd: fromPromise(async () => - onSpringEndRef.current?.({ type: 'OPEN' }) - ), - onCloseEnd: fromPromise(async () => - onSpringEndRef.current?.({ type: 'CLOSE' }) - ), - onResizeEnd: fromPromise(async () => - onSpringEndRef.current?.({ - type: 'RESIZE', - source: resizeSourceRef.current, - }) - ), - renderVisuallyHidden: fromPromise(async () => { - 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, - }) - }), - activate: fromPromise(async () => { - canDragRef.current = true - await Promise.all([ - scrollLockRef.current.activate(), - focusTrapRef.current.activate(), - ariaHiderRef.current.activate(), - ]) - }), - deactivate: fromPromise(async () => { - scrollLockRef.current.deactivate() - focusTrapRef.current.deactivate() - ariaHiderRef.current.deactivate() - canDragRef.current = false - }), - openImmediately: fromPromise(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, - }) - }), - openSmoothly: fromPromise(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, - }) - - 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: context }) => { - 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 }, - }) - }), - 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 - 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 }) - }), - }, + // 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({ type: 'OPEN' }) - } else { - send({ type: 'CLOSE' }) - } + send({ type: _open ? 'OPEN' : 'CLOSE' }) }, [_open, send, ready]) useLayoutEffect(() => { @@ -433,19 +449,20 @@ export const BottomSheet = React.forwardRef< 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' @@ -456,13 +473,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) } @@ -475,16 +491,15 @@ export const BottomSheet = React.forwardRef< down, first, last, - memo = spring.y.getValue() as number, + memo = spring.y.get(), movement: [, _my], tap, - velocity, + velocity: [, velocity], }) => { 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 } @@ -574,19 +589,14 @@ export const BottomSheet = React.forwardRef< return memo } - // @TODO too many rerenders - //send('DRAG', { y: newY, velocity }) - //* - set({ + api.start({ y: newY, ready: 1, maxHeight: maxHeightRef.current, maxSnap: maxSnapRef.current, minSnap: minSnapRef.current, immediate: true, - config: { velocity }, }) - // */ return memo } @@ -604,14 +614,11 @@ export const BottomSheet = React.forwardRef< const interpolations = useSpringInterpolations({ spring }) - console.log('publicStates', publicStates) - console.log('current', current) - return ( current?.value === state)} + data-rsbs-state={publicState(actorRef.getSnapshot())} data-rsbs-is-blocking={blocking} data-rsbs-is-dismissable={!!onDismiss} data-rsbs-has-header={!!header} @@ -624,8 +631,6 @@ export const BottomSheet = React.forwardRef< // but allow overriding them/disabling them ...style, // Not overridable as the "focus lock with opacity 0" trick rely on it - // @TODO the line below only fails on TS <4 - // @ts-ignore opacity: spring.ready, }} > @@ -641,7 +646,7 @@ export const BottomSheet = React.forwardRef< )}
)}
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/hooks/useAriaHider.tsx b/src/hooks/useAriaHider.tsx index 1413969b..2fcb3434 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 }>({ 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 index 2073a227..5c7fd454 100644 --- a/src/hooks/useReady.tsx +++ b/src/hooks/useReady.tsx @@ -7,13 +7,10 @@ export function useReady() { 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 })) } }, []) @@ -22,14 +19,10 @@ export function useReady() { 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!') + if (states.every(Boolean)) { setReady(true) } }, [readyMap]) 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..f9877688 100644 --- a/src/hooks/useSnapPoints.tsx +++ b/src/hooks/useSnapPoints.tsx @@ -6,11 +6,14 @@ import React, { useRef, useState, } from 'react' -import { ResizeObserver, ResizeObserverEntry } from '@juggle/resize-observer' -import type { defaultSnapProps, ResizeSource, snapPoints } from '../types' +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({ @@ -27,21 +30,21 @@ export function useSnapPoints({ 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 + lastSnapRef: React.RefObject ready: boolean registerReady: ReturnType['registerReady'] - resizeSourceRef: React.MutableRefObject + resizeSourceRef: MutableRef }) { const { maxHeight, minHeight, headerHeight, footerHeight } = useDimensions({ - contentRef: contentRef, + contentRef, controlledMaxHeight, footerEnabled, footerRef, @@ -63,9 +66,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) ) { @@ -106,14 +107,14 @@ function useDimensions({ registerReady, resizeSourceRef, }: { - contentRef: React.RefObject + contentRef: React.RefObject controlledMaxHeight?: number footerEnabled: boolean - footerRef: React.RefObject + footerRef: React.RefObject headerEnabled: boolean - headerRef: React.RefObject + headerRef: React.RefObject registerReady: ReturnType['registerReady'] - resizeSourceRef: React.MutableRefObject + resizeSourceRef: MutableRef }) { const setReady = useMemo(() => registerReady('contentHeight'), [ registerReady, @@ -124,7 +125,6 @@ function useDimensions({ 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, @@ -162,18 +162,27 @@ function useDimensions({ } } -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 +190,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,16 +224,17 @@ function useElementSizeObserver( // Blazingly keep track of the current viewport height without blocking the thread, keeping that sweet 60fps on smartphones function useMaxHeight( - controlledMaxHeight, + controlledMaxHeight: number | undefined, registerReady: ReturnType['registerReady'], - resizeSourceRef: React.MutableRefObject + resizeSourceRef: MutableRef ) { const setReady = useMemo(() => registerReady('maxHeight'), [registerReady]) - const [maxHeight, setMaxHeight] = useState(() => - roundAndCheckForNaN(controlledMaxHeight) || typeof window !== 'undefined' - ? window.innerHeight - : 0 - ) + const [maxHeight, setMaxHeight] = useState(() => { + if (controlledMaxHeight) { + return roundAndCheckForNaN(controlledMaxHeight) + } + return typeof window !== 'undefined' ? window.innerHeight : 0 + }) const ready = maxHeight > 0 const raf = useRef(0) 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 98fe1d42..94415ccc 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -17,9 +17,9 @@ export const BottomSheet = forwardRef(function BottomSheet( ) { // 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} diff --git a/src/machines/overlay.ts b/src/machines/overlay.ts index 0a90e9db..15fc0e68 100644 --- a/src/machines/overlay.ts +++ b/src/machines/overlay.ts @@ -1,106 +1,33 @@ -import { assign, createMachine, fromPromise } 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?: 'dragging' | 'custom' | string + 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 = { @@ -113,56 +40,34 @@ const openToResize = { RESIZE: { target: '#overlay.resizing', actions: 'onOpenEnd' }, } -const initiallyOpen = ({ context }) => context.initialState === 'OPEN' -const initiallyClosed = ({ context }) => context.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 = createMachine( { types: {} as { context: OverlayContext - services: { - onSnapStart: () => Promise - onOpenStart: () => Promise - onCloseStart: () => Promise - onResizeStart: () => Promise - onSnapEnd: () => Promise - onOpenEnd: () => Promise - onCloseEnd: () => Promise - onResizeEnd: () => Promise - renderVisuallyHidden: () => Promise - activate: () => Promise - deactivate: () => Promise - openSmoothly: () => Promise - openImmediately: () => Promise - snapSmoothly: () => Promise - resizeSmoothly: () => Promise - closeSmoothly: () => Promise - } + 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', guard: 'initiallyOpen' }, { target: 'smoothly', guard: 'initiallyClosed' }, ], }, + // Fast enter animation, sheet is open by default immediately: { initial: 'open', states: { @@ -178,25 +83,30 @@ export const overlayMachine = createMachine( 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', @@ -204,31 +114,29 @@ export const overlayMachine = createMachine( 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: ({ event: { payload } }) => ({ - y: payload.y, - velocity: payload.velocity, - snapSource: payload.source || 'custom', - }), + input: ({ context }) => ({ snapSource: context.snapSource }), }, - entry: [ - assign(({ event: { payload } }) => { - return { - y: payload.y, - velocity: payload.velocity, - snapSource: payload.source || 'custom', - } - }), - ], }, snappingSmoothly: { invoke: { @@ -237,12 +145,15 @@ export const overlayMachine = createMachine( input: ({ context }) => ({ y: context.y, velocity: context.velocity, - snapSource: context.snapSource, }), }, }, end: { - invoke: { src: 'onSnapEnd', onDone: 'done' }, + invoke: { + src: 'onSnapEnd', + onDone: 'done', + input: ({ context }) => ({ snapSource: context.snapSource }), + }, on: { RESIZE: '#overlay.resizing', SNAP: '#overlay.snapping', @@ -264,10 +175,7 @@ export const overlayMachine = createMachine( initial: 'start', states: { start: { - invoke: { - src: 'onResizeStart', - onDone: 'resizingSmoothly', - }, + invoke: { src: 'onResizeStart', onDone: 'resizingSmoothly' }, }, resizingSmoothly: { invoke: { src: 'resizeSmoothly', onDone: 'end' }, @@ -294,10 +202,7 @@ export const overlayMachine = createMachine( initial: 'start', states: { start: { - invoke: { - src: 'onCloseStart', - onDone: 'deactivating', - }, + invoke: { src: 'onCloseStart', onDone: 'deactivating' }, on: { OPEN: { target: '#overlay.open', actions: 'onCloseCancel' } }, }, deactivating: { @@ -326,103 +231,9 @@ export const overlayMachine = createMachine( }, }, { - 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 }) - }, - }, - actors: { - onSnapStart: fromPromise(async () => { - await sleep() - }), - onOpenStart: fromPromise(async () => { - await sleep() - }), - onCloseStart: fromPromise(async () => { - await sleep() - }), - onResizeStart: fromPromise(async () => { - await sleep() - }), - onSnapEnd: fromPromise(async () => { - await sleep() - }), - onOpenEnd: fromPromise(async () => { - await sleep() - }), - onCloseEnd: fromPromise(async () => { - await sleep() - }), - onResizeEnd: fromPromise(async () => { - await sleep() - }), - renderVisuallyHidden: fromPromise(async ({ input, system }) => { - console.group('renderVisuallyHidden') - console.log({ input, system }) - await sleep() - console.groupEnd() - }), - activate: fromPromise(async ({ input, system }) => { - console.group('activate') - console.log({ input, system }) - await sleep() - console.groupEnd() - }), - deactivate: fromPromise(async ({ input, system }) => { - console.group('deactivate') - console.log({ input, system }) - await sleep() - console.groupEnd() - }), - openSmoothly: fromPromise(async ({ input, system }) => { - console.group('openSmoothly') - console.log({ input, system }) - await sleep() - console.groupEnd() - }), - openImmediately: fromPromise(async ({ input, system }) => { - console.group('openImmediately') - console.log({ input, system }) - await sleep() - console.groupEnd() - }), - snapSmoothly: fromPromise(async ({ input, system }) => { - console.group('snapSmoothly') - console.log({ input, system }) - await sleep() - console.groupEnd() - }), - resizeSmoothly: fromPromise(async ({ input, system }) => { - console.group('resizeSmoothly') - console.log({ input, system }) - await sleep() - console.groupEnd() - }), - closeSmoothly: fromPromise(async ({ input, system }) => { - console.group('closeSmoothly') - console.log({ input, system }) - await sleep() - console.groupEnd() - }), + guards: { + initiallyOpen: ({ context }) => context.initialState === 'OPEN', + initiallyClosed: ({ context }) => context.initialState === 'CLOSED', }, - guards: { initiallyClosed, initiallyOpen }, } ) diff --git a/src/style.css b/src/style.css index 3f8d6346..95982fbc 100644 --- a/src/style.css +++ b/src/style.css @@ -60,6 +60,7 @@ [data-rsbs-header] { flex-shrink: 0; cursor: ns-resize; + touch-action: none; padding: 16px; } [data-rsbs-header] { @@ -123,8 +124,8 @@ } [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 +138,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 8fa78c30..a78cfde6 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 @@ -44,6 +49,7 @@ export type SpringEvent = /** * Properties that can be used to customize the animation. + * By default transitions use a 115ms tween, remove `duration` (set it to `undefined`) to get spring physics driven by `tension` and `friction`. * see https://react-spring.dev/docs/advanced/config#config-visualizer */ export type SpringConfig = { @@ -51,7 +57,7 @@ export type SpringConfig = { tension: number friction: number velocity: number - duration: number + duration: number | undefined } export type Props = { @@ -68,20 +74,20 @@ export type Props = { sibling?: React.ReactNode /** - * Pass the spring configurations (to change animation) in this format: { mass, tension, friction }. + * Pass the spring configurations (to change animation) in this format: { mass, tension, friction, duration }. */ - springConfig?: SpringConfig + springConfig?: Partial /** * Scroller target */ - scrollerRef?: React.MutableRefObject + 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). @@ -92,7 +98,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 @@ -117,7 +123,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. @@ -178,7 +184,7 @@ export type Props = { * @default false */ keepMounted?: boolean -} & Omit, 'children'> +} & Omit, 'children'> export interface RefHandles { /** @@ -200,5 +206,5 @@ export interface RefHandles { */ height: number - scrollElement: HTMLDivElement + scrollElement: HTMLDivElement | null } diff --git a/src/utils.ts b/src/utils.ts index 2b27ed4c..b46bc8c4 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -59,7 +59,22 @@ 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 +} From 3259c4804554566b5875cee2cf887d44781279e3 Mon Sep 17 00:00:00 2001 From: Guido Wagner Date: Tue, 8 Sep 2026 03:50:53 -0300 Subject: [PATCH 2/3] chore: ignore local .claude directory --- .claude/launch.json | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 .claude/launch.json diff --git a/.claude/launch.json b/.claude/launch.json deleted file mode 100644 index 204cdb2e..00000000 --- a/.claude/launch.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "version": "0.0.1", - "configurations": [ - { - "name": "docs", - "runtimeExecutable": "npm", - "runtimeArgs": ["run", "dev"], - "port": 3000 - } - ] -} From 5063cd3b7cdcebbf0e273834d7ac64b99570bda3 Mon Sep 17 00:00:00 2001 From: Guido Wagner Date: Tue, 8 Sep 2026 03:51:02 -0300 Subject: [PATCH 3/3] chore: fix .gitignore entry for .claude --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 34ecaf92..d1b3272d 100644 --- a/.gitignore +++ b/.gitignore @@ -136,4 +136,5 @@ yarn-error.log* .env.production.local # vercel -.vercel.claude/ +.vercel +.claude/