react-spring-bottom-sheet-updated
Version:
✨ Accessible, 🪄 Delightful, and 🤯 Performant. Built on react-spring for the web, and react-use-gesture.
1 lines • 85.1 kB
Source Map (JSON)
{"version":3,"file":"index.modern.mjs","sources":["../src/hooks/useLayoutEffect.tsx","../src/utils.ts","../src/hooks/useSnapPoints.tsx","../src/machines/overlay.ts","../src/BottomSheet.tsx","../src/hooks/useReady.tsx","../src/hooks/useSpring.tsx","../src/hooks/useReducedMotion.tsx","../src/hooks/useScrollLock.tsx","../src/hooks/useAriaHider.tsx","../src/hooks/useFocusTrap.tsx","../src/hooks/useSpringInterpolations.tsx","../src/index.tsx"],"sourcesContent":["import { useEffect, useLayoutEffect as useLayoutEffectSafely } from 'react'\n\n// Ensure the name used in components is useLayoutEffect so the eslint react hooks plugin works\nexport const useLayoutEffect =\n typeof window !== 'undefined' ? useLayoutEffectSafely : useEffect\n","/* eslint-disable no-self-compare */\n\n// stolen from lodash\nexport function clamp(number: number, lower: number, upper: number) {\n number = +number\n lower = +lower\n upper = +upper\n lower = lower === lower ? lower : 0\n upper = upper === upper ? upper : 0\n if (number === number) {\n number = number <= upper ? number : upper\n number = number >= lower ? number : lower\n }\n return number\n}\n\n// Mwahaha easiest way to filter out NaN I ever saw! >:3\nexport function deleteNaN(arr) {\n const set = new Set(arr)\n set.delete(NaN)\n return [...set]\n}\n\nexport function roundAndCheckForNaN(unrounded) {\n const rounded = Math.round(unrounded)\n if (Number.isNaN(unrounded)) {\n throw new TypeError(\n 'Found a NaN! Check your snapPoints / defaultSnap / snapTo '\n )\n }\n\n return rounded\n}\n\n// Validate, sanitize, round and dedupe snap points, as well as extracting the minSnap and maxSnap points\nexport function processSnapPoints(unsafeSnaps: number | number[], maxHeight) {\n const safeSnaps = [].concat(unsafeSnaps).map(roundAndCheckForNaN)\n\n const snapPointsDedupedSet = safeSnaps.reduce((acc, snapPoint) => {\n acc.add(clamp(snapPoint, 0, maxHeight))\n return acc\n }, new Set<number>())\n\n const snapPoints = Array.from(snapPointsDedupedSet)\n\n const minSnap = Math.min(...snapPoints)\n if (Number.isNaN(minSnap)) {\n throw new TypeError('minSnap is NaN')\n }\n const maxSnap = Math.max(...snapPoints)\n if (Number.isNaN(maxSnap)) {\n throw new TypeError('maxSnap is NaN')\n }\n\n return {\n snapPoints,\n minSnap,\n maxSnap,\n }\n}\n\nexport const debugging =\n process.env.NODE_ENV === 'development' && typeof window !== 'undefined'\n ? window.location.search === '?debug'\n : false\n","import React, {\n useCallback,\n useDebugValue,\n useEffect,\n useMemo,\n useRef,\n useState,\n} from 'react'\nimport { ResizeObserver, ResizeObserverEntry } from '@juggle/resize-observer'\nimport type { defaultSnapProps, ResizeSource, snapPoints } from '../types'\nimport { processSnapPoints, roundAndCheckForNaN } from '../utils'\nimport { useReady } from './useReady'\nimport { ResizeObserverOptions } from '@juggle/resize-observer/lib/ResizeObserverOptions'\nimport { useLayoutEffect } from './useLayoutEffect'\n\nexport function useSnapPoints({\n contentRef,\n controlledMaxHeight,\n footerEnabled,\n footerRef,\n getSnapPoints,\n headerEnabled,\n headerRef,\n heightRef,\n lastSnapRef,\n ready,\n registerReady,\n resizeSourceRef,\n}: {\n contentRef: React.RefObject<Element>\n controlledMaxHeight?: number\n footerEnabled: boolean\n footerRef: React.RefObject<Element>\n getSnapPoints: snapPoints\n headerEnabled: boolean\n headerRef: React.RefObject<Element>\n heightRef: React.RefObject<number>\n lastSnapRef: React.RefObject<number>\n ready: boolean\n registerReady: ReturnType<typeof useReady>['registerReady']\n resizeSourceRef: React.MutableRefObject<ResizeSource>\n}) {\n const { maxHeight, minHeight, headerHeight, footerHeight } = useDimensions({\n contentRef: contentRef,\n controlledMaxHeight,\n footerEnabled,\n footerRef,\n headerEnabled,\n headerRef,\n registerReady,\n resizeSourceRef,\n })\n\n const { snapPoints, minSnap, maxSnap } = processSnapPoints(\n ready\n ? getSnapPoints({\n height: heightRef.current,\n footerHeight,\n headerHeight,\n minHeight,\n maxHeight,\n })\n : [0],\n maxHeight\n )\n //console.log({ snapPoints, minSnap, maxSnap })\n\n // @TODO investigate the gains from memoizing this\n function findSnap(\n numberOrCallback: number | ((state: defaultSnapProps) => number)\n ) {\n let unsafeSearch: number\n if (typeof numberOrCallback === 'function') {\n unsafeSearch = numberOrCallback({\n footerHeight,\n headerHeight,\n height: heightRef.current,\n minHeight,\n maxHeight,\n snapPoints,\n lastSnap: lastSnapRef.current,\n })\n } else {\n unsafeSearch = numberOrCallback\n }\n const querySnap = roundAndCheckForNaN(unsafeSearch)\n return snapPoints.reduce(\n (prev, curr) =>\n Math.abs(curr - querySnap) < Math.abs(prev - querySnap) ? curr : prev,\n minSnap\n )\n }\n\n useDebugValue(`minSnap: ${minSnap}, maxSnap:${maxSnap}`)\n\n return { minSnap, maxSnap, findSnap, maxHeight }\n}\n\nfunction useDimensions({\n contentRef,\n controlledMaxHeight,\n footerEnabled,\n footerRef,\n headerEnabled,\n headerRef,\n registerReady,\n resizeSourceRef,\n}: {\n contentRef: React.RefObject<Element>\n controlledMaxHeight?: number\n footerEnabled: boolean\n footerRef: React.RefObject<Element>\n headerEnabled: boolean\n headerRef: React.RefObject<Element>\n registerReady: ReturnType<typeof useReady>['registerReady']\n resizeSourceRef: React.MutableRefObject<ResizeSource>\n}) {\n const setReady = useMemo(() => registerReady('contentHeight'), [\n registerReady,\n ])\n const maxHeight = useMaxHeight(\n controlledMaxHeight,\n registerReady,\n resizeSourceRef\n )\n\n // @TODO probably better to forward props instead of checking refs to decide if it's enabled\n const headerHeight = useElementSizeObserver(headerRef, {\n label: 'headerHeight',\n enabled: headerEnabled,\n resizeSourceRef,\n })\n const contentHeight = useElementSizeObserver(contentRef, {\n label: 'contentHeight',\n enabled: true,\n resizeSourceRef,\n })\n const footerHeight = useElementSizeObserver(footerRef, {\n label: 'footerHeight',\n enabled: footerEnabled,\n resizeSourceRef,\n })\n const minHeight =\n Math.min(maxHeight - headerHeight - footerHeight, contentHeight) +\n headerHeight +\n footerHeight\n\n useDebugValue(`minHeight: ${minHeight}`)\n\n const ready = contentHeight > 0\n useEffect(() => {\n if (ready) {\n setReady()\n }\n }, [ready, setReady])\n\n return {\n maxHeight,\n minHeight,\n headerHeight,\n footerHeight,\n }\n}\n\nconst observerOptions: ResizeObserverOptions = {\n // Respond to changes to padding, happens often on iOS when using env(safe-area-inset-bottom)\n // And the user hides or shows the Safari browser toolbar\n box: 'border-box',\n}\n/**\n * Hook for determining the size of an element using the Resize Observer API.\n *\n * @param ref - A React ref to an element\n */\nfunction useElementSizeObserver(\n ref: React.RefObject<Element>,\n {\n label,\n enabled,\n resizeSourceRef,\n }: {\n label: string\n enabled: boolean\n resizeSourceRef: React.MutableRefObject<ResizeSource>\n }\n): number {\n let [size, setSize] = useState(0)\n\n useDebugValue(`${label}: ${size}`)\n\n const handleResize = useCallback(\n (entries: ResizeObserverEntry[]) => {\n // we only observe one element, so accessing the first entry here is fine\n setSize(entries[0].borderBoxSize[0].blockSize)\n resizeSourceRef.current = 'element'\n },\n [resizeSourceRef]\n )\n\n useLayoutEffect(() => {\n if (!ref.current || !enabled) {\n return\n }\n\n const resizeObserver = new ResizeObserver(handleResize)\n resizeObserver.observe(ref.current, observerOptions)\n\n return () => {\n resizeObserver.disconnect()\n }\n }, [ref, handleResize, enabled])\n\n return enabled ? size : 0\n}\n\n// Blazingly keep track of the current viewport height without blocking the thread, keeping that sweet 60fps on smartphones\nfunction useMaxHeight(\n controlledMaxHeight,\n registerReady: ReturnType<typeof useReady>['registerReady'],\n resizeSourceRef: React.MutableRefObject<ResizeSource>\n) {\n const setReady = useMemo(() => registerReady('maxHeight'), [registerReady])\n const [maxHeight, setMaxHeight] = useState(() =>\n roundAndCheckForNaN(controlledMaxHeight) || typeof window !== 'undefined'\n ? window.innerHeight\n : 0\n )\n const ready = maxHeight > 0\n const raf = useRef(0)\n\n useDebugValue(controlledMaxHeight ? 'controlled' : 'auto')\n\n useEffect(() => {\n if (ready) {\n setReady()\n }\n }, [ready, setReady])\n\n useLayoutEffect(() => {\n // Bail if the max height is a controlled prop\n if (controlledMaxHeight) {\n setMaxHeight(roundAndCheckForNaN(controlledMaxHeight))\n resizeSourceRef.current = 'maxheightprop'\n\n return\n }\n\n const handleResize = () => {\n if (raf.current) {\n // bail to throttle the amount of resize changes\n return\n }\n\n // throttle state changes using rAF\n raf.current = requestAnimationFrame(() => {\n setMaxHeight(window.innerHeight)\n resizeSourceRef.current = 'window'\n\n raf.current = 0\n })\n }\n window.addEventListener('resize', handleResize)\n setMaxHeight(window.innerHeight)\n resizeSourceRef.current = 'window'\n setReady()\n\n return () => {\n window.removeEventListener('resize', handleResize)\n cancelAnimationFrame(raf.current)\n }\n }, [controlledMaxHeight, setReady, resizeSourceRef])\n\n return maxHeight\n}\n","import { Machine, assign } from 'xstate'\n\n// This is the root machine, composing all the other machines and is the brain of the bottom sheet\n\ninterface OverlayStateSchema {\n states: {\n // the overlay usually starts in the closed position\n closed: {}\n opening: {\n states: {\n // Used to fire off the springStart event\n start: {}\n // Decide how to transition to the open state based on what the initialState is\n transition: {}\n // Fast enter animation, sheet is open by default\n immediately: {\n states: {\n open: {}\n activating: {}\n }\n }\n smoothly: {\n states: {\n // This state only happens when the overlay should start in an open state, instead of animating from the bottom\n // openImmediately: {}\n // visuallyHidden will render the overlay in the open state, but with opacity 0\n // doing this solves two problems:\n // on Android focusing an input element will trigger the softkeyboard to show up, which will change the viewport height\n // 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\n // 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.\n // 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\n visuallyHidden: {}\n // In this state we're activating focus traps, scroll locks and more, this will sometimes trigger soft keyboards and scrollIntoView\n // @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\n activating: {}\n // Animates from the bottom\n open: {}\n }\n }\n // Used to fire off the springEnd event\n end: {}\n // And finally we're ready to transition to open\n done: {}\n }\n }\n open: {}\n // dragging responds to user gestures, which may interrupt the opening state, closing state or snapping\n // when interrupting an opening event, it fires onSpringEnd(OPEN) before onSpringStart(DRAG)\n // when interrupting a closing event, it fires onSpringCancel(CLOSE) before onSpringStart(DRAG)\n // when interrupting a dragging event, it fires onSpringCancel(SNAP) before onSpringStart(DRAG)\n dragging: {}\n // snapping happens whenever transitioning to a new snap point, often after dragging\n snapping: {\n states: {\n start: {}\n snappingSmoothly: {}\n end: {}\n done: {}\n }\n }\n resizing: {\n states: {\n start: {}\n resizingSmoothly: {}\n end: {}\n done: {}\n }\n }\n closing: {\n states: {\n start: {}\n deactivating: {}\n closingSmoothly: {}\n end: {}\n done: {}\n }\n }\n }\n}\n\ntype OverlayEvent =\n | { type: 'OPEN' }\n | {\n type: 'SNAP'\n payload: {\n y: number\n velocity: number\n source: 'dragging' | 'custom' | string\n }\n }\n | { type: 'CLOSE' }\n | { type: 'DRAG' }\n | { type: 'RESIZE' }\n\n// The context (extended state) of the machine\ninterface OverlayContext {\n initialState: 'OPEN' | 'CLOSED'\n snapSource?: 'string'\n}\nfunction sleep(ms = 1000) {\n return new Promise((resolve) => setTimeout(resolve, ms))\n}\n\nconst cancelOpen = {\n CLOSE: { target: '#overlay.closing', actions: 'onOpenCancel' },\n}\nconst openToDrag = {\n DRAG: { target: '#overlay.dragging', actions: 'onOpenEnd' },\n}\nconst openToResize = {\n RESIZE: { target: '#overlay.resizing', actions: 'onOpenEnd' },\n}\n\nconst initiallyOpen = ({ initialState }) => initialState === 'OPEN'\nconst initiallyClosed = ({ initialState }) => initialState === 'CLOSED'\n\n// Copy paste the machine into https://xstate.js.org/viz/ to make sense of what's going on in here ;)\n\nexport const overlayMachine = Machine<\n OverlayContext,\n OverlayStateSchema,\n OverlayEvent\n>(\n {\n id: 'overlay',\n initial: 'closed',\n context: { initialState: 'CLOSED' },\n states: {\n closed: { on: { OPEN: 'opening', CLOSE: undefined } },\n opening: {\n initial: 'start',\n states: {\n start: {\n invoke: {\n src: 'onOpenStart',\n onDone: 'transition',\n },\n },\n transition: {\n always: [\n { target: 'immediately', cond: 'initiallyOpen' },\n { target: 'smoothly', cond: 'initiallyClosed' },\n ],\n },\n immediately: {\n initial: 'open',\n states: {\n open: {\n invoke: { src: 'openImmediately', onDone: 'activating' },\n },\n activating: {\n invoke: { src: 'activate', onDone: '#overlay.opening.end' },\n on: { ...openToDrag, ...openToResize },\n },\n },\n },\n smoothly: {\n initial: 'visuallyHidden',\n states: {\n visuallyHidden: {\n invoke: { src: 'renderVisuallyHidden', onDone: 'activating' },\n },\n activating: {\n invoke: { src: 'activate', onDone: 'open' },\n },\n open: {\n invoke: { src: 'openSmoothly', onDone: '#overlay.opening.end' },\n on: { ...openToDrag, ...openToResize },\n },\n },\n },\n end: {\n invoke: { src: 'onOpenEnd', onDone: 'done' },\n on: { CLOSE: '#overlay.closing', DRAG: '#overlay.dragging' },\n },\n done: {\n type: 'final',\n },\n },\n on: { ...cancelOpen },\n onDone: 'open',\n },\n open: {\n on: { DRAG: '#overlay.dragging', SNAP: 'snapping', RESIZE: 'resizing' },\n },\n dragging: {\n on: { SNAP: 'snapping' },\n },\n snapping: {\n initial: 'start',\n states: {\n start: {\n invoke: {\n src: 'onSnapStart',\n onDone: 'snappingSmoothly',\n },\n entry: [\n assign({\n // @ts-expect-error\n y: (_, { payload: { y } }) => y,\n // @ts-expect-error\n velocity: (_, { payload: { velocity } }) => velocity,\n // @ts-expect-error\n snapSource: (_, { payload: { source = 'custom' } }) => source,\n }),\n ],\n },\n snappingSmoothly: {\n invoke: { src: 'snapSmoothly', onDone: 'end' },\n },\n end: {\n invoke: { src: 'onSnapEnd', onDone: 'done' },\n on: {\n RESIZE: '#overlay.resizing',\n SNAP: '#overlay.snapping',\n CLOSE: '#overlay.closing',\n DRAG: '#overlay.dragging',\n },\n },\n done: { type: 'final' },\n },\n on: {\n SNAP: { target: 'snapping', actions: 'onSnapEnd' },\n RESIZE: { target: '#overlay.resizing', actions: 'onSnapCancel' },\n DRAG: { target: '#overlay.dragging', actions: 'onSnapCancel' },\n CLOSE: { target: '#overlay.closing', actions: 'onSnapCancel' },\n },\n onDone: 'open',\n },\n resizing: {\n initial: 'start',\n states: {\n start: {\n invoke: {\n src: 'onResizeStart',\n onDone: 'resizingSmoothly',\n },\n },\n resizingSmoothly: {\n invoke: { src: 'resizeSmoothly', onDone: 'end' },\n },\n end: {\n invoke: { src: 'onResizeEnd', onDone: 'done' },\n on: {\n SNAP: '#overlay.snapping',\n CLOSE: '#overlay.closing',\n DRAG: '#overlay.dragging',\n },\n },\n done: { type: 'final' },\n },\n on: {\n RESIZE: { target: 'resizing', actions: 'onResizeEnd' },\n SNAP: { target: 'snapping', actions: 'onResizeCancel' },\n DRAG: { target: '#overlay.dragging', actions: 'onResizeCancel' },\n CLOSE: { target: '#overlay.closing', actions: 'onResizeCancel' },\n },\n onDone: 'open',\n },\n closing: {\n initial: 'start',\n states: {\n start: {\n invoke: {\n src: 'onCloseStart',\n onDone: 'deactivating',\n },\n on: { OPEN: { target: '#overlay.open', actions: 'onCloseCancel' } },\n },\n deactivating: {\n invoke: { src: 'deactivate', onDone: 'closingSmoothly' },\n },\n closingSmoothly: {\n invoke: { src: 'closeSmoothly', onDone: 'end' },\n },\n end: {\n invoke: { src: 'onCloseEnd', onDone: 'done' },\n on: {\n OPEN: { target: '#overlay.opening', actions: 'onCloseCancel' },\n },\n },\n done: { type: 'final' },\n },\n on: {\n CLOSE: undefined,\n OPEN: { target: '#overlay.opening', actions: 'onCloseCancel' },\n },\n onDone: 'closed',\n },\n },\n on: {\n CLOSE: 'closing',\n },\n },\n {\n actions: {\n onOpenCancel: (context, event) => {\n console.log('onOpenCancel', { context, event })\n },\n onSnapCancel: (context, event) => {\n console.log('onSnapCancel', { context, event })\n },\n onResizeCancel: (context, event) => {\n console.log('onResizeCancel', { context, event })\n },\n onCloseCancel: (context, event) => {\n console.log('onCloseCancel', { context, event })\n },\n onOpenEnd: (context, event) => {\n console.log('onOpenCancel', { context, event })\n },\n onSnapEnd: (context, event) => {\n console.log('onSnapEnd', { context, event })\n },\n onResizeEnd: (context, event) => {\n console.log('onResizeEnd', { context, event })\n },\n },\n services: {\n onSnapStart: async () => {\n await sleep()\n },\n onOpenStart: async () => {\n await sleep()\n },\n onCloseStart: async () => {\n await sleep()\n },\n onResizeStart: async () => {\n await sleep()\n },\n onSnapEnd: async () => {\n await sleep()\n },\n onOpenEnd: async () => {\n await sleep()\n },\n onCloseEnd: async () => {\n await sleep()\n },\n onResizeEnd: async () => {\n await sleep()\n },\n renderVisuallyHidden: async (context, event) => {\n console.group('renderVisuallyHidden')\n console.log({ context, event })\n await sleep()\n console.groupEnd()\n },\n activate: async (context, event) => {\n console.group('activate')\n console.log({ context, event })\n await sleep()\n console.groupEnd()\n },\n deactivate: async (context, event) => {\n console.group('deactivate')\n console.log({ context, event })\n await sleep()\n console.groupEnd()\n },\n openSmoothly: async (context, event) => {\n console.group('openSmoothly')\n console.log({ context, event })\n await sleep()\n console.groupEnd()\n },\n openImmediately: async (context, event) => {\n console.group('openImmediately')\n console.log({ context, event })\n await sleep()\n console.groupEnd()\n },\n snapSmoothly: async (context, event) => {\n console.group('snapSmoothly')\n console.log({ context, event })\n await sleep()\n console.groupEnd()\n },\n resizeSmoothly: async (context, event) => {\n console.group('resizeSmoothly')\n console.log({ context, event })\n await sleep()\n console.groupEnd()\n },\n closeSmoothly: async (context, event) => {\n console.group('closeSmoothly')\n console.log({ context, event })\n await sleep()\n console.groupEnd()\n },\n },\n guards: { initiallyClosed, initiallyOpen },\n }\n)\n","//\n// In order to greatly reduce complexity this component is designed to always transition to open on mount, and then\n// transition to a closed state later. This ensures that all memory used to keep track of animation and gesture state\n// can be reclaimed after the sheet is closed and then unmounted.\n// It also ensures that when transitioning to open on mount the state is always clean, not affected by previous states that could\n// cause race conditions.\n\nimport { useMachine } from '@xstate/react'\nimport React, {\n useCallback,\n useEffect,\n useImperativeHandle,\n useRef,\n} from 'react'\nimport { animated, config } from 'react-spring'\nimport { rubberbandIfOutOfBounds, useDrag } from 'react-use-gesture'\nimport {\n useAriaHider,\n useFocusTrap,\n useLayoutEffect,\n useReady,\n useReducedMotion,\n useScrollLock,\n useSnapPoints,\n useSpring,\n useSpringInterpolations,\n} from './hooks'\nimport { overlayMachine } from './machines/overlay'\nimport type {\n defaultSnapProps,\n Props,\n RefHandles,\n ResizeSource,\n SnapPointProps,\n} from './types'\nimport { debugging } from './utils'\n\nconst { tension, friction } = config.default\n\n// @TODO implement AbortController to deal with race conditions\n\n// @TODO rename to SpringBottomSheet and allow userland to import it directly, for those who want maximum control and minimal bundlesize\nexport const BottomSheet = React.forwardRef<\n RefHandles,\n {\n initialState: 'OPEN' | 'CLOSED'\n lastSnapRef: React.MutableRefObject<number | null>\n } & Props\n>(function BottomSheetInternal(\n {\n children,\n sibling,\n className,\n footer,\n header,\n open: _open,\n initialState,\n lastSnapRef,\n initialFocusRef,\n onDismiss,\n maxHeight: controlledMaxHeight,\n defaultSnap: getDefaultSnap = _defaultSnap,\n snapPoints: getSnapPoints = _snapPoints,\n blocking = true,\n scrollLocking = true,\n style,\n onSpringStart,\n onSpringCancel,\n onSpringEnd,\n reserveScrollBarGap = blocking,\n expandOnContentDrag = false,\n disableExpandList = [],\n preventPullUp = false,\n ...props\n },\n forwardRef\n) {\n // Before any animations can start we need to measure a few things, like the viewport and the dimensions of content, and header + footer if they exist\n // @TODO make ready its own state perhaps, before open or closed\n const { ready, registerReady } = useReady()\n\n // Controls the drag handler, used by spring operations that happen outside the render loop in React\n const canDragRef = useRef(false)\n\n // This way apps don't have to remember to wrap their callbacks in useCallback to avoid breaking the sheet\n const onSpringStartRef = useRef(onSpringStart)\n const onSpringCancelRef = useRef(onSpringCancel)\n const onSpringEndRef = useRef(onSpringEnd)\n useEffect(() => {\n onSpringStartRef.current = onSpringStart\n onSpringCancelRef.current = onSpringCancel\n onSpringEndRef.current = onSpringEnd\n }, [onSpringCancel, onSpringStart, onSpringEnd])\n\n // Behold, the engine of it all!\n const [spring, set] = useSpring()\n\n const containerRef = useRef<HTMLDivElement>(null)\n const scrollRef = useRef<HTMLDivElement>(null)\n const contentRef = useRef<HTMLDivElement>(null)\n const headerRef = useRef<HTMLDivElement>(null)\n const footerRef = useRef<HTMLDivElement>(null)\n const overlayRef = useRef<HTMLDivElement | null>(null)\n\n // Keeps track of the current height, or the height transitioning to\n const heightRef = useRef(0)\n const resizeSourceRef = useRef<ResizeSource>()\n const preventScrollingRef = useRef(false)\n\n const prefersReducedMotion = useReducedMotion()\n\n // \"Plugins\" huhuhu\n const scrollLockRef = useScrollLock({\n targetRef: scrollRef,\n enabled: ready && scrollLocking,\n reserveScrollBarGap,\n })\n const ariaHiderRef = useAriaHider({\n targetRef: containerRef,\n enabled: ready && blocking,\n })\n const focusTrapRef = useFocusTrap({\n targetRef: containerRef,\n fallbackRef: overlayRef,\n initialFocusRef: initialFocusRef || undefined,\n enabled: ready && blocking && initialFocusRef !== false,\n })\n\n const { minSnap, maxSnap, maxHeight, findSnap } = useSnapPoints({\n contentRef,\n controlledMaxHeight,\n footerEnabled: !!footer,\n footerRef,\n getSnapPoints,\n headerEnabled: header !== false,\n headerRef,\n heightRef,\n lastSnapRef,\n ready,\n registerReady,\n resizeSourceRef,\n })\n\n // Setup refs that are used in cases where full control is needed over when a side effect is executed\n const maxHeightRef = useRef(maxHeight)\n const minSnapRef = useRef(minSnap)\n const maxSnapRef = useRef(maxSnap)\n const findSnapRef = useRef(findSnap)\n const defaultSnapRef = useRef(0)\n // Sync the refs with current state, giving the spring full control over when to respond to changes\n useLayoutEffect(() => {\n maxHeightRef.current = maxHeight\n maxSnapRef.current = maxSnap\n minSnapRef.current = minSnap\n findSnapRef.current = findSnap\n defaultSnapRef.current = findSnap(getDefaultSnap)\n }, [findSnap, getDefaultSnap, maxHeight, maxSnap, minSnap])\n\n // New utility for using events safely\n const asyncSet = useCallback<typeof set>(\n // @ts-ignore\n ({ onRest, config: { velocity = 1, ...config } = {}, ...opts }) =>\n // @ts-expect-error\n new Promise((resolve) =>\n set({\n ...opts,\n config: {\n velocity,\n ...config,\n // @see https://springs.pomb.us\n mass: 1,\n // \"stiffness\"\n tension,\n // \"damping\"\n friction: Math.max(\n friction,\n friction + (friction - friction * velocity)\n ),\n },\n onRest: (...args) => {\n // @ts-expect-error\n resolve(...args)\n onRest?.(...args)\n },\n })\n ),\n [set]\n )\n const [current, send] = useMachine(overlayMachine, {\n devTools: debugging,\n actions: {\n onOpenCancel: useCallback(\n () => onSpringCancelRef.current?.({ type: 'OPEN' }),\n []\n ),\n onSnapCancel: useCallback(\n (context) =>\n onSpringCancelRef.current?.({\n type: 'SNAP',\n source: context.snapSource,\n }),\n []\n ),\n onCloseCancel: useCallback(\n () => onSpringCancelRef.current?.({ type: 'CLOSE' }),\n []\n ),\n onResizeCancel: useCallback(\n () =>\n onSpringCancelRef.current?.({\n type: 'RESIZE',\n source: resizeSourceRef.current,\n }),\n []\n ),\n onOpenEnd: useCallback(\n () => onSpringEndRef.current?.({ type: 'OPEN' }),\n []\n ),\n onSnapEnd: useCallback(\n (context, event) =>\n onSpringEndRef.current?.({\n type: 'SNAP',\n source: context.snapSource,\n }),\n []\n ),\n onResizeEnd: useCallback(\n () =>\n onSpringEndRef.current?.({\n type: 'RESIZE',\n source: resizeSourceRef.current,\n }),\n []\n ),\n },\n context: { initialState },\n services: {\n onSnapStart: useCallback(\n async (context, event) =>\n onSpringStartRef.current?.({\n type: 'SNAP',\n source: event.payload.source || 'custom',\n }),\n []\n ),\n onOpenStart: useCallback(\n async () => onSpringStartRef.current?.({ type: 'OPEN' }),\n []\n ),\n onCloseStart: useCallback(\n async () => onSpringStartRef.current?.({ type: 'CLOSE' }),\n []\n ),\n onResizeStart: useCallback(\n async () =>\n onSpringStartRef.current?.({\n type: 'RESIZE',\n source: resizeSourceRef.current,\n }),\n []\n ),\n onSnapEnd: useCallback(\n async (context, event) =>\n onSpringEndRef.current?.({\n type: 'SNAP',\n source: context.snapSource,\n }),\n []\n ),\n onOpenEnd: useCallback(\n async () => onSpringEndRef.current?.({ type: 'OPEN' }),\n []\n ),\n onCloseEnd: useCallback(\n async () => onSpringEndRef.current?.({ type: 'CLOSE' }),\n []\n ),\n onResizeEnd: useCallback(\n async () =>\n onSpringEndRef.current?.({\n type: 'RESIZE',\n source: resizeSourceRef.current,\n }),\n []\n ),\n renderVisuallyHidden: useCallback(\n async (context, event) => {\n await asyncSet({\n y: defaultSnapRef.current,\n ready: 0,\n maxHeight: maxHeightRef.current,\n maxSnap: maxSnapRef.current,\n // Using defaultSnapRef instead of minSnapRef to avoid animating `height` on open\n minSnap: defaultSnapRef.current,\n immediate: true,\n })\n },\n [asyncSet]\n ),\n activate: useCallback(\n async (context, event) => {\n canDragRef.current = true\n await Promise.all([\n scrollLockRef.current.activate(),\n focusTrapRef.current.activate(),\n ariaHiderRef.current.activate(),\n ])\n },\n [ariaHiderRef, focusTrapRef, scrollLockRef]\n ),\n deactivate: useCallback(async () => {\n scrollLockRef.current.deactivate()\n focusTrapRef.current.deactivate()\n ariaHiderRef.current.deactivate()\n canDragRef.current = false\n }, [ariaHiderRef, focusTrapRef, scrollLockRef]),\n openImmediately: useCallback(async () => {\n heightRef.current = defaultSnapRef.current\n await asyncSet({\n y: defaultSnapRef.current,\n ready: 1,\n maxHeight: maxHeightRef.current,\n maxSnap: maxSnapRef.current,\n // Using defaultSnapRef instead of minSnapRef to avoid animating `height` on open\n minSnap: defaultSnapRef.current,\n immediate: true,\n })\n }, [asyncSet]),\n openSmoothly: useCallback(async () => {\n await asyncSet({\n y: 0,\n ready: 1,\n maxHeight: maxHeightRef.current,\n maxSnap: maxSnapRef.current,\n // Using defaultSnapRef instead of minSnapRef to avoid animating `height` on open\n minSnap: defaultSnapRef.current,\n immediate: true,\n })\n\n heightRef.current = defaultSnapRef.current\n\n await asyncSet({\n y: defaultSnapRef.current,\n ready: 1,\n maxHeight: maxHeightRef.current,\n maxSnap: maxSnapRef.current,\n // Using defaultSnapRef instead of minSnapRef to avoid animating `height` on open\n minSnap: defaultSnapRef.current,\n immediate: prefersReducedMotion.current,\n })\n }, [asyncSet, prefersReducedMotion]),\n snapSmoothly: useCallback(\n async (context, event) => {\n const snap = findSnapRef.current(context.y)\n heightRef.current = snap\n lastSnapRef.current = snap\n await asyncSet({\n y: snap,\n ready: 1,\n maxHeight: maxHeightRef.current,\n maxSnap: maxSnapRef.current,\n minSnap: minSnapRef.current,\n immediate: prefersReducedMotion.current,\n config: { velocity: context.velocity },\n })\n },\n [asyncSet, lastSnapRef, prefersReducedMotion]\n ),\n resizeSmoothly: useCallback(async () => {\n const snap = findSnapRef.current(heightRef.current)\n heightRef.current = snap\n lastSnapRef.current = snap\n await asyncSet({\n y: snap,\n ready: 1,\n maxHeight: maxHeightRef.current,\n maxSnap: maxSnapRef.current,\n minSnap: minSnapRef.current,\n immediate:\n resizeSourceRef.current === 'element'\n ? prefersReducedMotion.current\n : true,\n })\n }, [asyncSet, lastSnapRef, prefersReducedMotion]),\n closeSmoothly: useCallback(\n async (context, event) => {\n // Avoid animating the height property on close and stay within FLIP bounds by upping the minSnap\n asyncSet({\n minSnap: heightRef.current,\n immediate: true,\n })\n\n heightRef.current = 0\n\n await asyncSet({\n y: 0,\n maxHeight: maxHeightRef.current,\n maxSnap: maxSnapRef.current,\n immediate: prefersReducedMotion.current,\n })\n\n await asyncSet({ ready: 0, immediate: true })\n },\n [asyncSet, prefersReducedMotion]\n ),\n },\n })\n\n useEffect(() => {\n if (!ready) return\n\n if (_open) {\n send('OPEN')\n } else {\n send('CLOSE')\n }\n }, [_open, send, ready])\n useLayoutEffect(() => {\n // Adjust the height whenever the snap points are changed due to resize events\n if (maxHeight || maxSnap || minSnap) {\n send('RESIZE')\n }\n }, [maxHeight, maxSnap, minSnap, send])\n useEffect(\n () => () => {\n // Ensure effects are cleaned up on unmount, in case they're not cleaned up otherwise\n scrollLockRef.current.deactivate()\n focusTrapRef.current.deactivate()\n ariaHiderRef.current.deactivate()\n },\n [ariaHiderRef, focusTrapRef, scrollLockRef]\n )\n\n useImperativeHandle(\n forwardRef,\n () => ({\n snapTo: (numberOrCallback, { velocity = 1, source = 'custom' } = {}) => {\n send('SNAP', {\n payload: {\n y: findSnapRef.current(numberOrCallback),\n velocity,\n source,\n },\n })\n },\n get height() {\n return heightRef.current\n },\n }),\n [send]\n )\n\n useEffect(() => {\n const elem = scrollRef.current\n\n const preventScrolling = e => {\n const disableExpandListNodes = disableExpandList.map(selector => containerRef.current.querySelector(selector)).filter(Boolean);\n if (disableExpandListNodes.length && disableExpandListNodes.some(disableNode => disableNode.contains(e.target))) {\n return true\n } else if (preventScrollingRef.current && elem.scrollTop <= 0) {\n e.preventDefault()\n }\n }\n\n let prevValue = 0;\n const preventSafariOverscrollOnStart = e => {\n if (elem.scrollTop < 0) {\n prevValue = elem.scrollTop;\n }\n }\n \n const preventSafariOverscrollOnMove = (e) => {\n if (elem.scrollTop < 0 && elem.scrollTop < prevValue) {\n e.preventDefault();\n }\n };\n\n if (expandOnContentDrag) {\n elem.addEventListener('scroll', preventScrolling)\n elem.addEventListener('touchmove', preventScrolling)\n elem.addEventListener('touchmove', preventSafariOverscrollOnMove);\n elem.addEventListener('touchstart', preventSafariOverscrollOnStart, {\n passive: true\n });\n }\n return () => {\n elem.removeEventListener('scroll', preventScrolling)\n elem.removeEventListener('touchmove', preventScrolling)\n elem.removeEventListener('touchmove', preventSafariOverscrollOnMove);\n elem.removeEventListener('touchstart', preventSafariOverscrollOnStart);\n }\n }, [expandOnContentDrag, scrollRef, disableExpandList])\n\n const handleDrag = ({\n args: [{ closeOnTap = false, isContentDragging = false } = {}] = [],\n cancel,\n direction: [, direction],\n down,\n first,\n last,\n memo = spring.y.get(),\n movement: [, _my],\n tap,\n velocity,\n event,\n }) => {\n const my = _my * -1\n const hasScroll = scrollRef.current.scrollHeight > scrollRef.current.clientHeight;\n if (containerRef.current && disableExpandList.length) {\n const disableExpandListNodes = disableExpandList.map(selector => containerRef.current.querySelector(selector)).filter(Boolean);\n if (disableExpandListNodes.length && disableExpandListNodes.some(disableNode => disableNode.contains(event.target))) {\n cancel()\n return memo\n }\n }\n \n // Cancel the drag operation if the canDrag state changed\n if (!canDragRef.current) {\n console.log('handleDrag cancelled dragging because canDragRef is false')\n cancel()\n return memo\n }\n\n if (onDismiss && closeOnTap && tap) {\n cancel()\n // Runs onDismiss in a timeout to avoid tap events on the backdrop from triggering click events on elements underneath\n setTimeout(() => onDismiss(), 10)\n return memo\n }\n\n // Filter out taps\n if (tap) {\n return memo\n }\n\n const rawY = memo + my\n const predictedDistance = my * velocity\n const predictedY = Math.max(\n minSnapRef.current,\n Math.min(maxSnapRef.current, rawY + predictedDistance * 2)\n )\n\n if (\n !down &&\n onDismiss &&\n direction > 0 &&\n rawY + predictedDistance < minSnapRef.current / 2\n && (!hasScroll || scrollRef.current.scrollTop <= 0)\n ) {\n cancel()\n onDismiss()\n return memo\n }\n\n let newY = down\n ? // @TODO figure out a better way to deal with rubberband overshooting if min and max have the same value\n !onDismiss && minSnapRef.current === maxSnapRef.current\n ? rawY < minSnapRef.current\n ? rubberbandIfOutOfBounds(\n rawY,\n minSnapRef.current,\n maxSnapRef.current * 2,\n 0.55\n )\n : rubberbandIfOutOfBounds(\n rawY,\n minSnapRef.current / 2,\n maxSnapRef.current,\n 0.55\n )\n : rubberbandIfOutOfBounds(\n rawY,\n onDismiss ? 0 : minSnapRef.current,\n maxSnapRef.current,\n 0.55\n )\n : predictedY\n \n if (preventPullUp) {\n if (direction === 0) {\n return memo\n }\n if ((direction < 0 && newY > maxSnap && _my <= 0) || (direction > 0 && newY > maxSnap && _my <= 0)) {\n // realize feature: all pop-ups shouldn't be pulled up by certain if it is fully open\n // if direction up, and newY coordinate >= maxSnap, and distance Y from start point to current point (_my) <= 0 don't change height modal\n // or if direction down, and newY coordinate >= maxSnap, and distance Y from start point to current point (_my) <= 0 don't change height modal\n return memo;\n }\n }\n\n if (expandOnContentDrag && isContentDragging) {\n if (newY >= maxSnapRef.current) {\n newY = maxSnapRef.current\n }\n\n if (memo === maxSnapRef.current && scrollRef.current.scrollTop > 0) {\n newY = maxSnapRef.current\n }\n\n preventScrollingRef.current = newY < maxSnapRef.current;\n } else {\n preventScrollingRef.current = false\n }\n\n if (first) {\n send('DRAG')\n }\n\n if (last) {\n send('SNAP', {\n payload: {\n y: newY,\n velocity: velocity > 0.05 ? velocity : 1,\n source: 'dragging',\n },\n })\n\n return memo\n }\n\n // @TODO too many rerenders\n //send('DRAG', { y: newY, velocity })\n //*\n set({\n y: newY,\n ready: 1,\n maxHeight: maxHeightRef.current,\n maxSnap: maxSnapRef.current,\n minSnap: minSnapRef.current,\n immediate: true,\n config: { velocity },\n })\n // */\n\n return memo\n }\n\n const bind = useDrag(handleDrag, {\n filterTaps: true,\n })\n\n if (Number.isNaN(maxSnapRef.current)) {\n throw new TypeError('maxSnapRef is NaN!!')\n }\n if (Number.isNaN(minSnapRef.current)) {\n throw new TypeError('minSnapRef is NaN!!')\n }\n\n const interpolations = useSpringInterpolations({ spring })\n\n return (\n <animated.div\n {...props}\n data-rsbs-root\n data-rsbs-state={publicStates.find(current.matches)}\n data-rsbs-is-blocking={blocking}\n data-rsbs-is-dismissable={!!onDismiss}\n data-rsbs-has-header={!!header}\n data-rsbs-has-footer={!!footer}\n className={className}\n ref={containerRef}\n style={{\n // spread in the interpolations yeees\n ...interpolations,\n // but allow overriding them/disabling them\n ...style,\n // Not overridable as the \"focus lock with opacity 0\" trick rely on it\n // @TODO the line below only fails on TS <4\n // @ts-ignore\n opacity: spring.ready,\n }}\n >\n {sibling}\n {blocking && (\n <div\n // This component needs to be placed outside bottom-sheet, as bottom-sheet uses transform and thus creates a new context\n // that clips this element to the container, not allowing it to cover the full page.\n key=\"backdrop\"\n data-rsbs-backdrop\n {...bind({ closeOnTap: true })}\n />\n )}\n <div\n key=\"overlay\"\n aria-modal=\"true\"\n role=\"dialog\"\n data-rsbs-overlay\n tabIndex={-1}\n ref={overlayRef}\n onKeyDown={(event) => {\n if (event.key === 'Escape') {\n // Always stop propagation, to avoid weirdness for bottom sheets inside other bottom sheets\n event.stopPropagation()\n if (onDismiss) onDismiss()\n }\n }}\n >\n {header !== false && (\n <div key=\"header\" data-rsbs-header ref={headerRef} {...bind()}>\n {header}\n </div>\n )}\n <div key=\"scroll\" data-rsbs-scroll ref={scrollRef} {...(expandOnContentDrag ? bind({ isContentDragging: true }) : {})}>\n <div data-rsbs-content ref={contentRef}>\n {children}\n </div>\n </div>\n {footer && (\n <div key=\"footer\" ref={footerRef} data-rsbs-footer {...bind()}>\n {footer}\n </div>\n )}\n </div>\n </animated.div>\n )\n})\n\n// Used for the data attribute, list over states available to CSS selectors\nconst publicStates = [\n 'closed',\n 'opening',\n 'open',\n 'closing',\n 'dragging',\n 'snapping',\n 'resizing',\n]\n\n// Default prop values that are callbacks, and it's nice to save some memory and reuse their instances since they're pure\nfunction _defaultSnap({ snapPoints, lastSnap }: defaultSnapProps) {\n return lastSnap ?? Math.min(...snapPoints)\n}\nfunction _snapPoints({ minHeight }: SnapPointProps) {\n return minHeight\n}\n","// Keeps track of wether everything is good to go or not, in the most efficient way possible\n\nimport { useCallback, useEffect, useState } from 'react'\n\nexport function useReady() {\n const [ready, setReady] = useState(false)\n const [readyMap, updateReadyMap] = useState<{ [key: string]: boolean }>({})\n\n const registerReady = useCallback((key: string) => {\n console.count(`registerReady:${key}`)\n // Register the check we're gonna wait for until it's ready\n updateReadyMap((ready) => ({ ...ready, [key]: false }))\n\n return () => {\n console.count(`setReady:${key}`)\n // Set it to ready\n updateReadyMap((ready) => ({ ...ready, [key]: true }))\n }\n }, [])\n\n useEffect(() => {\n const states = Object.values(readyMap)\n\n if (states.length === 0) {\n console.log('nope nothing registered yet')\n return\n }\n\n const isReady = states.every(Boolean)\n console.log('check if we are rready', readyMap, isReady)\n if (isReady) {\n console.warn('ready!')\n setReady(true)\n }\n }, [readyMap])\n\n return { ready, registerReady }\n}\n","import { useSpring as useReactSpring } from 'react-spring'\n\n// Behold, the engine of it all!\n// Put in this file befause it makes it easier to type and I'm lazy! :D\n\nexport function useSpring() {\n return useReactSpring(() => ({\n y: 0,\n ready: 0,\n maxHeight: 0,\n minSnap: 0,\n maxSnap: 0,\n }))\n}\n\nexport type Spring = ReturnType<typeof useSpring>[0]\nexport type SpringSet = ReturnType<typeof useSpring>[1]\n","import { useDebugValue, useEffect, useMemo, useRef } from 'react'\n\n// @TODO refactor to addEventListener\nexport function useReducedMotion() {\n const mql = useMemo(\n () =>\n typeof window !== 'undefined'\n ? window.matchMedia('(prefers-reduced-motion: reduce)')\n : null,\n []\n )\n const ref = useRef(mql?.matches)\n\n useDebugValue(ref.current ? 'reduce' : 'no-preference')\n\n useEffect(() => {\n const handler = (event) => {\n ref.current = event.matches\n }\n mql?.addListener(handler)\n\n return () => mql?.removeListener(handler)\n }, [mql])\n\n return ref\n}\n","import { disableBodyScroll, enableBodyScroll } from 'body-scroll-lock'\nimport { useDebugValue, useEffect, useRef } from 'react'\n\n/**\n * Handle scroll locking to ensure a good dragging experience on Android and iOS.\n *\n * On iOS the following may happen if scroll isn't locked:\n * - When dragging the sheet the background gets dragged at the same time.\n * - When dragging the page scroll is also affected, causing the drag to feel buggy and \"slow\".\n *\n * On Android it causes the chrome toolbar to pop down as you drag down, and hide as you drag up.\n * When it's in between two toolbar states it causes the framerate to drop way below 60fps on\n * the bottom sheet drag interaction.\n */\nexport function useScrollLock({\n targetRef,\n enabled,\n reserveScrollBarGap,\n}: {\n targetRef: React.RefObject<Element>\n enabled: boolean\n reserveScrollBarGap: boolean\n}) {\n const ref = useRef<{ activate: () => void; deactivate: () => void }>({\n activate: () => {\n throw new TypeError('Tried to activate scroll lock too early')\n },\n deactivate: () => {},\n })\n\n useDebugValue(enabled ? 'Enabled' : 'Disabled')\n\n useEffect(() => {\n if (!enabled) {\n ref.current.deactivate()\n ref.current = { activate: () => {}, deactivate: () => {} }\n return\n }\n\n const target = targetRef.current\n let active = false\n\n ref.current = {\n activate: () => {\n if (active) return\n active = true\n disableBodyScroll(target, {\n allowTouchMove: (el) => el.closest('[data-body-scroll-lock-ignore]'),\n reserveScrollBarGap,\n })\n },\n deactivate: () => {\n if (!active) return\n active = false\n enableBodyScroll(target)\n },\n }\n }, [enabled, targetRef, reserveScrollBarGap])\n\n return ref\n}\n","import React, { useDebugValue, useEffect, useRef } from 'react'\n\n// Handle hiding and restoring aria-hidden attributes\nexport function useAriaHider({\n targetRef,\n enabled,\n}: {\n targetRef: React.RefObject<Element>\n enabled: boolean\n}) {\n const ref = useRef<{ activate: () => void; deactivate: () => void }>({\n activate: () => {\n throw new TypeError('Tried to activate aria hider too early')\n },\n deactivate: () => {},\n })\n\n useDebugValue(enabled ? 'Enabled' : 'Disabled')\n\n useEffect(() => {\n if (!enabled) {\n ref.c