)}
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
+}