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