diff --git a/README.md b/README.md index cca2cc2b..0ab3366c 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ ![Logo with the text Accessible, Delightful and Performant](https://react-spring-bottom-sheet.cocody.dev/readme.svg) -**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 is implemented using 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 **[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. # Install @@ -241,3 +241,5 @@ ref.current.snapTo(({ // Showing all the available props [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/ [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 diff --git a/defaults.json b/defaults.json index ff51a554..c1df8836 100644 --- a/defaults.json +++ b/defaults.json @@ -9,8 +9,8 @@ "--rsbs-max-w": "auto", "--rsbs-ml": "env(safe-area-inset-left)", "--rsbs-mr": "env(safe-area-inset-right)", + "--rsbs-overlay-h": "0px", "--rsbs-overlay-rounded": "16px", - "--rsbs-overlay-translate-y": "0px", - "--rsbs-overlay-h": "0px" + "--rsbs-overlay-translate-y": "0px" } } diff --git a/docs/style.css b/docs/style.css index a119cb23..3fce268c 100644 --- a/docs/style.css +++ b/docs/style.css @@ -31,10 +31,10 @@ --rsbs-mr: 0px; /* the bottom sheet doesn't need display cutout paddings when in the iframe */ - & [data-rsbs-has-footer='false'] [data-rsbs-content-padding] { + & [data-rsbs-has-footer='false'] [data-rsbs-scroll] { padding-bottom: 0px !important; } - & [data-rsbs-footer-padding] { + & [data-rsbs-footer] { padding-bottom: 16px !important; } } diff --git a/next.config.js b/next.config.js deleted file mode 100644 index 4ba52ba2..00000000 --- a/next.config.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = {} diff --git a/package-lock.json b/package-lock.json index 0b44a7f4..ca67f98a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1331,6 +1331,11 @@ "integrity": "sha512-CAEbWH7OIur6jEOzaai83jq3FmKmv4PmX1JYfs9IrYcGEVI/lyL1EXJGCj7eFVJ0bg5QR8LMxBlEtA+xKiLpFw==", "dev": true }, + "@juggle/resize-observer": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.2.0.tgz", + "integrity": "sha512-fsLxt0CHx2HCV9EL8lDoVkwHffsA0snUpddYjdLyXcG5E41xaamn9ZyQqOE9TUJdrRlH8/hjIf+UdOdDeKCUgg==" + }, "@next/env": { "version": "10.0.4", "resolved": "https://registry.npmjs.org/@next/env/-/env-10.0.4.tgz", @@ -1713,6 +1718,15 @@ "picomatch": "^2.2.2" } }, + "@rooks/use-raf": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@rooks/use-raf/-/use-raf-4.5.0.tgz", + "integrity": "sha512-BKIY0UZs34fT5Up3q8PP2akvBJKuX/0DXRhSmAXlfMzoNdWxA5ccZOfZPsgetUXtF4EJCNb/p9F7oRXecDawrg==", + "dev": true, + "requires": { + "raf": "3.4.1" + } + }, "@semantic-release/changelog": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/@semantic-release/changelog/-/changelog-5.0.1.tgz", @@ -3042,6 +3056,16 @@ "integrity": "sha512-1Yj8h9Q+QDF5FzhMs/c9+6UntbD5MkRfRwac8DoEm9ZfUBZ7tZ55YcGVAzEe4bXsdQHEk+s9S5wsOKVdZrw0tQ==", "dev": true }, + "bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dev": true, + "optional": true, + "requires": { + "file-uri-to-path": "1.0.0" + } + }, "bl": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/bl/-/bl-4.0.3.tgz", @@ -6155,6 +6179,13 @@ "flat-cache": "^3.0.4" } }, + "file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "dev": true, + "optional": true + }, "filesize": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/filesize/-/filesize-6.1.0.tgz", @@ -8927,6 +8958,13 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "dev": true }, + "nan": { + "version": "2.14.2", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.14.2.tgz", + "integrity": "sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ==", + "dev": true, + "optional": true + }, "nanoid": { "version": "3.1.20", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.20.tgz", @@ -13337,6 +13375,12 @@ "sha.js": "^2.4.8" } }, + "performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=", + "dev": true + }, "picomatch": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz", @@ -16627,6 +16671,15 @@ "integrity": "sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==", "dev": true }, + "raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "dev": true, + "requires": { + "performance-now": "^2.1.0" + } + }, "randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -16981,11 +17034,6 @@ "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", "dev": true }, - "resize-observer-polyfill": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", - "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==" - }, "resolve": { "version": "1.19.0", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.19.0.tgz", @@ -19801,7 +19849,11 @@ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz", "integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==", "dev": true, - "optional": true + "optional": true, + "requires": { + "bindings": "^1.5.0", + "nan": "^2.12.1" + } }, "glob-parent": { "version": "3.1.0", diff --git a/package.json b/package.json index 600c3487..6c0fc3de 100644 --- a/package.json +++ b/package.json @@ -33,19 +33,20 @@ ], "types": "dist/index.d.ts", "dependencies": { + "@juggle/resize-observer": "^3.2.0", "@reach/portal": "^0.12.1", "@xstate/react": "^1.2.0", "body-scroll-lock": "^3.1.5", "focus-trap": "^6.2.2", "react-spring": "^8.0.27", "react-use-gesture": "^8.0.1", - "resize-observer-polyfill": "^1.5.1", "xstate": "^4.15.1" }, "peerDependencies": { "react": "^16.14.0 || 17" }, "devDependencies": { + "@rooks/use-raf": "^4.5.0", "@semantic-release/changelog": "^5.0.1", "@semantic-release/git": "^9.0.0", "@tailwindcss/forms": "^0.2.1", diff --git a/pages/fixtures/experiments.tsx b/pages/fixtures/experiments.tsx index 84d283d2..7936a0c3 100644 --- a/pages/fixtures/experiments.tsx +++ b/pages/fixtures/experiments.tsx @@ -1,3 +1,4 @@ +import useRaf from '@rooks/use-raf' import useInterval from '@use-it/interval' import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import Button from '../../docs/fixtures/Button' @@ -14,7 +15,7 @@ const MemoBottomSheet = memo(BottomSheet) function One() { const [open, setOpen] = useState(false) - const [seconds, setSeconds] = useState(1) + const [renders, setRenders] = useState(1) const style = useMemo(() => ({ ['--rsbs-bg' as any]: '#EFF6FF' }), []) const onDismiss = useCallback(() => setOpen(false), []) @@ -37,23 +38,21 @@ function One() { [onDismiss] ) - useInterval(() => { - if (open) { - setSeconds(seconds + 1) - } - }, 100) + useRaf(() => { + setRenders(renders + 1) + }, open) useEffect(() => { if (open) { return () => { - setSeconds(1) + setRenders(1) } } }, [open]) return ( <> - + -
{expandContent && ( )} diff --git a/src/BottomSheet.tsx b/src/BottomSheet.tsx index 1e90a41d..11a4d23c 100644 --- a/src/BottomSheet.tsx +++ b/src/BottomSheet.tsx @@ -89,8 +89,8 @@ export const BottomSheet = React.forwardRef< const [spring, set] = useSpring() const containerRef = useRef(null) + const scrollRef = useRef(null) const contentRef = useRef(null) - const contentContainerRef = useRef(null) const headerRef = useRef(null) const footerRef = useRef(null) const overlayRef = useRef(null) @@ -102,7 +102,7 @@ export const BottomSheet = React.forwardRef< // "Plugins" huhuhu const scrollLockRef = useScrollLock({ - targetRef: contentRef, + targetRef: scrollRef, enabled: ready && scrollLocking, reserveScrollBarGap, }) @@ -118,7 +118,7 @@ export const BottomSheet = React.forwardRef< }) const { minSnap, maxSnap, maxHeight, findSnap } = useSnapPoints({ - contentContainerRef, + contentRef, controlledMaxHeight, footerEnabled: !!footer, footerRef, @@ -542,25 +542,20 @@ export const BottomSheet = React.forwardRef< > {header !== false && (
-
{header}
+ {header}
)} -
- -
) }) diff --git a/src/hooks/useSnapPoints.tsx b/src/hooks/useSnapPoints.tsx index 695edcfa..cac45767 100644 --- a/src/hooks/useSnapPoints.tsx +++ b/src/hooks/useSnapPoints.tsx @@ -6,13 +6,14 @@ import React, { useRef, useState, } from 'react' -import ResizeObserver from 'resize-observer-polyfill' +import { ResizeObserver, ResizeObserverEntry } from '@juggle/resize-observer' import type { defaultSnapProps, snapPoints } from '../types' import { processSnapPoints, roundAndCheckForNaN } from '../utils' import { useReady } from './useReady' +import { ResizeObserverOptions } from '@juggle/resize-observer/lib/ResizeObserverOptions' export function useSnapPoints({ - contentContainerRef, + contentRef, controlledMaxHeight, footerEnabled, footerRef, @@ -24,7 +25,7 @@ export function useSnapPoints({ ready, registerReady, }: { - contentContainerRef: React.RefObject + contentRef: React.RefObject controlledMaxHeight?: number footerEnabled: boolean footerRef: React.RefObject @@ -37,7 +38,7 @@ export function useSnapPoints({ registerReady: ReturnType['registerReady'] }) { const { maxHeight, minHeight, headerHeight, footerHeight } = useDimensions({ - contentContainerRef, + contentRef: contentRef, controlledMaxHeight, footerEnabled, footerRef, @@ -58,7 +59,7 @@ export function useSnapPoints({ : [0], maxHeight ) - console.log({ snapPoints, minSnap, maxSnap }) + //console.log({ snapPoints, minSnap, maxSnap }) // @TODO investigate the gains from memoizing this function findSnap( @@ -92,7 +93,7 @@ export function useSnapPoints({ } function useDimensions({ - contentContainerRef, + contentRef, controlledMaxHeight, footerEnabled, footerRef, @@ -100,7 +101,7 @@ function useDimensions({ headerRef, registerReady, }: { - contentContainerRef: React.RefObject + contentRef: React.RefObject controlledMaxHeight?: number footerEnabled: boolean footerRef: React.RefObject @@ -114,15 +115,15 @@ function useDimensions({ const maxHeight = useMaxHeight(controlledMaxHeight, registerReady) // @TODO probably better to forward props instead of checking refs to decide if it's enabled - const { height: headerHeight } = useElementSizeObserver(headerRef, { + const headerHeight = useElementSizeObserver(headerRef, { label: 'headerHeight', enabled: headerEnabled, }) - const { height: contentHeight } = useElementSizeObserver( - contentContainerRef, - { label: 'contentHeight', enabled: true } - ) - const { height: footerHeight } = useElementSizeObserver(footerRef, { + const contentHeight = useElementSizeObserver(contentRef, { + label: 'contentHeight', + enabled: true, + }) + const footerHeight = useElementSizeObserver(footerRef, { label: 'footerHeight', enabled: footerEnabled, }) @@ -148,26 +149,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', +} /** * Hook for determining the size of an element using the Resize Observer API. * * @param ref - A React ref to an element */ -const defaultSize = { width: 0, height: 0 } function useElementSizeObserver( ref: React.RefObject, { label, enabled }: { label: string; enabled: boolean } -): { width: number; height: number } { - let [size, setSize] = useState(defaultSize) +): number { + let [size, setSize] = useState(0) - useDebugValue(`${label}: ${size.height}`) + useDebugValue(`${label}: ${size}`) const handleResize = useCallback((entries: ResizeObserverEntry[]) => { - setSize({ - // we only observe one element, so accessing the first entry here is fine - width: entries[0].contentRect.width, - height: entries[0].contentRect.height, - }) + // we only observe one element, so accessing the first entry here is fine + setSize(entries[0].borderBoxSize[0].blockSize) }, []) useEffect(() => { @@ -175,19 +177,15 @@ function useElementSizeObserver( return } - // Set initial size here, as the one from the observer fires too late on iOS safari - const { width, height } = ref.current.getBoundingClientRect() - setSize({ width, height }) - const resizeObserver = new ResizeObserver(handleResize) - resizeObserver.observe(ref.current) + resizeObserver.observe(ref.current, observerOptions) return () => { resizeObserver.disconnect() } }, [ref, handleResize, enabled]) - return enabled ? size : defaultSize + return enabled ? size : 0 } // Blazingly keep track of the current viewport height without blocking the thread, keeping that sweet 60fps on smartphones diff --git a/src/index.tsx b/src/index.tsx index b55ca742..bf861667 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -65,18 +65,21 @@ export const BottomSheet = forwardRef(function BottomSheet( [onSpringEnd] ) + // This isn't just a performance optimization, it's also to avoid issues when running a non-browser env like SSR + if (!mounted) { + return null + } + return ( - {mounted && ( - <_BottomSheet - {...props} - lastSnapRef={lastSnapRef} - ref={ref} - initialState={initialStateRef.current} - onSpringStart={handleSpringStart} - onSpringEnd={handleSpringEnd} - /> - )} + <_BottomSheet + {...props} + lastSnapRef={lastSnapRef} + ref={ref} + initialState={initialStateRef.current} + onSpringStart={handleSpringStart} + onSpringEnd={handleSpringEnd} + /> ) }) diff --git a/src/style.css b/src/style.css index bca5726a..58fc6ac1 100644 --- a/src/style.css +++ b/src/style.css @@ -16,24 +16,21 @@ 0 -1px 0 rgba(38, 89, 115, 0.05); } [data-rsbs-overlay], -[data-rsbs-antigap] { +[data-rsbs-root]:after { max-width: var(--rsbs-max-w); margin-left: var(--rsbs-ml); margin-right: var(--rsbs-mr); } [data-rsbs-overlay], [data-rsbs-backdrop], -[data-rsbs-antigap] { +[data-rsbs-root]:after { z-index: 3; - -ms-scroll-chaining: none; overscroll-behavior: none; touch-action: none; position: fixed; right: 0; bottom: 0; left: 0; - -webkit-user-select: none; - -ms-user-select: none; user-select: none; -webkit-tap-highlight-color: transparent; -webkit-touch-callout: none; @@ -50,7 +47,8 @@ cursor: ns-resize; } -[data-rsbs-antigap] { +[data-rsbs-root]:after { + content: ''; pointer-events: none; background: var(--rsbs-bg); height: 1px; @@ -62,21 +60,14 @@ [data-rsbs-header] { flex-shrink: 0; cursor: ns-resize; -} -[data-rsbs-footer-padding], -[data-rsbs-header-padding] { padding: 16px; } [data-rsbs-header] { text-align: center; - -webkit-user-select: none; - -ms-user-select: none; user-select: none; box-shadow: 0 1px 0 rgba(46, 59, 66, calc(var(--rsbs-content-opacity) * 0.125)); z-index: 1; -} -[data-rsbs-header-padding] { padding-top: calc(20px + env(safe-area-inset-top)); padding-bottom: 8px; } @@ -99,11 +90,9 @@ } [data-rsbs-has-header='false'] [data-rsbs-header] { box-shadow: none; -} -[data-rsbs-has-header='false'] [data-rsbs-header-padding] { padding-top: calc(12px + env(safe-area-inset-top)); } -[data-rsbs-content] { +[data-rsbs-scroll] { flex-shrink: 1; flex-grow: 1; -webkit-tap-highlight-color: revert; @@ -112,30 +101,30 @@ -ms-user-select: auto; user-select: auto; overflow: auto; - -ms-scroll-chaining: none; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; } -[data-rsbs-content]:focus { +[data-rsbs-scroll]:focus { outline: none; } -[data-rsbs-has-footer='false'] [data-rsbs-content-padding] { +[data-rsbs-has-footer='false'] [data-rsbs-scroll] { padding-bottom: env(safe-area-inset-bottom); } +[data-rsbs-content] { + /* The overflow hidden is to ensure any margin on child nodes are included when the resize observer is measuring the height */ + 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); overflow: hidden; z-index: 1; -} -[data-rsbs-footer-padding] { padding-bottom: calc(16px + env(safe-area-inset-bottom)); } [data-rsbs-is-dismissable='true'], [data-rsbs-is-dismissable='false']:matches([data-rsbs-state='opening'], [data-rsbs-state='closing']) { - & - :matches([data-rsbs-header-padding], [data-rsbs-content-padding], [data-rsbs-footer-padding]) { + & :matches([data-rsbs-header], [data-rsbs-scroll], [data-rsbs-footer]) > * { opacity: var(--rsbs-content-opacity); } & [data-rsbs-backdrop] {