UNPKG

react-modal-sheet

Version:

Flexible bottom sheet component for your React apps

1,372 lines (1,358 loc) 42.6 kB
import { motion, useMotionValue, useTransform, useReducedMotion, animate } from 'motion/react'; import React4, { createContext, forwardRef, useRef, useState, useImperativeHandle, useContext, useCallback, useEffect, useLayoutEffect } from 'react'; import { createPortal } from 'react-dom'; import useMeasure from 'react-use-measure'; import { transform } from 'motion'; // src/SheetBackdrop.tsx var SheetContext = createContext( void 0 ); function useSheetContext() { const context = useContext(SheetContext); if (!context) throw new Error("Sheet context error"); return context; } // src/styles.ts var styles = { root: { base: { position: "fixed", top: 0, bottom: 0, left: 0, right: 0, overflow: "hidden", pointerEvents: "none" }, decorative: {} }, backdrop: { base: { zIndex: 1, position: "fixed", top: 0, left: 0, width: "100%", height: "100%", touchAction: "none", userSelect: "none" }, decorative: { backgroundColor: "rgba(0, 0, 0, 0.2)", border: "none", WebkitTapHighlightColor: "transparent" } }, container: { base: { zIndex: 2, position: "absolute", left: 0, bottom: 0, width: "100%", pointerEvents: "auto", display: "flex", flexDirection: "column" }, decorative: { backgroundColor: "#fff", borderTopRightRadius: "8px", borderTopLeftRadius: "8px", boxShadow: "0px -2px 16px rgba(0, 0, 0, 0.3)" } }, headerWrapper: { base: { width: "100%" }, decorative: {} }, header: { base: { width: "100%", position: "relative" }, decorative: { height: "40px", display: "flex", alignItems: "center", justifyContent: "center" } }, indicatorWrapper: { base: { display: "flex" }, decorative: {} }, indicator: { base: { display: "inline-block" }, decorative: { width: "18px", height: "4px", borderRadius: "99px", backgroundColor: "#ddd" } }, content: { base: { minHeight: "0px", position: "relative", flexGrow: 1, display: "flex", flexDirection: "column" }, decorative: {} }, scroller: { base: { height: "100%", overflowY: "auto", overscrollBehaviorY: "none" }, decorative: {} } }; // src/constants.ts var DEFAULT_HEIGHT = "calc(100% - env(safe-area-inset-top) - 34px)"; var IS_SSR = typeof window === "undefined"; var DEFAULT_TWEEN_CONFIG = { ease: "easeOut", duration: 0.2 }; var REDUCED_MOTION_TWEEN_CONFIG = { ease: "linear", duration: 0.01 }; var DEFAULT_DRAG_CLOSE_THRESHOLD = 0.6; var DEFAULT_DRAG_VELOCITY_THRESHOLD = 500; // src/utils.ts function applyStyles(styles2, unstyled) { return unstyled ? styles2.base : { ...styles2.base, ...styles2.decorative }; } function isAscendingOrder(arr) { for (let i = 0; i < arr.length; i++) { if (arr[i + 1] < arr[i]) return false; } return true; } function mergeRefs(refs) { return (value) => { refs.forEach((ref) => { if (typeof ref === "function") { ref(value); } else if (ref) { ref.current = value; } }); }; } function testPlatform(re) { var _a; return typeof window !== "undefined" && window.navigator != null ? re.test( // @ts-expect-error ((_a = window.navigator.userAgentData) == null ? void 0 : _a.platform) || window.navigator.platform ) : false; } function cached(fn) { let res = null; return () => { if (res == null) { res = fn(); } return res; }; } var isMac = cached(function() { return testPlatform(/^Mac/i); }); var isIPhone = cached(function() { return testPlatform(/^iPhone/i); }); var isIPad = cached(function() { return testPlatform(/^iPad/i) || isMac() && navigator.maxTouchPoints > 1; }); var isIOS = cached(function() { return isIPhone() || isIPad(); }); function waitForElement(className, interval = 50, maxAttempts = 20) { return new Promise((resolve) => { let attempts = 0; const timer = setInterval(() => { const element = document.getElementsByClassName( className )[0]; attempts++; if (element || attempts >= maxAttempts) { clearInterval(timer); resolve(element); } }, interval); }); } // src/SheetBackdrop.tsx var isClickable = (props) => !!props.onClick || !!props.onTap; var SheetBackdrop = forwardRef( ({ style, className = "", unstyled, ...rest }, ref) => { const sheetContext = useSheetContext(); const clickable = isClickable(rest); const Comp = clickable ? motion.button : motion.div; const pointerEvents = clickable ? "auto" : "none"; const isUnstyled = unstyled ?? sheetContext.unstyled; const backdropStyle = { ...applyStyles(styles.backdrop, isUnstyled), ...style, pointerEvents }; return /* @__PURE__ */ React4.createElement( Comp, { ...rest, ref, className: `react-modal-sheet-backdrop ${className}`, style: backdropStyle, initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 }, transition: { duration: 1 } } ); } ); SheetBackdrop.displayName = "SheetBackdrop"; var SheetContainer = forwardRef( ({ children, style, className = "", unstyled, ...rest }, ref) => { const sheetContext = useSheetContext(); const isUnstyled = unstyled ?? sheetContext.unstyled; const containerStyle = { ...applyStyles(styles.container, isUnstyled), ...style, y: sheetContext.y }; if (sheetContext.detent === "default") { containerStyle.height = DEFAULT_HEIGHT; } if (sheetContext.detent === "full") { containerStyle.height = "100%"; containerStyle.maxHeight = "100%"; } if (sheetContext.detent === "content") { containerStyle.height = "auto"; containerStyle.maxHeight = DEFAULT_HEIGHT; } return /* @__PURE__ */ React4.createElement( motion.div, { ...rest, ref: mergeRefs([ ref, sheetContext.sheetRef, sheetContext.sheetBoundsRef ]), className: `react-modal-sheet-container ${className}`, style: containerStyle }, children ); } ); SheetContainer.displayName = "SheetContainer"; var constraints = { bottom: 0, top: 0, left: 0, right: 0 }; function useDragConstraints() { const ref = useRef(null); const onMeasure = useCallback(() => constraints, []); return { ref, onMeasure }; } function useScrollPosition(options = {}) { const { debounceDelay = 32, isEnabled = true } = options; const [element, setElement] = useState(null); const [scrollPosition, setScrollPosition] = useState(void 0); useEffect(() => { if (!element || !isEnabled) return; let scrollTimeout = null; function determineScrollPosition(element2) { const { scrollTop, scrollHeight, clientHeight } = element2; const isScrollable2 = scrollHeight > clientHeight; if (!isScrollable2) { if (scrollPosition) setScrollPosition(void 0); return; } const isAtTop = scrollTop <= 0; const isAtBottom = Math.ceil(scrollHeight) - Math.ceil(scrollTop) === Math.ceil(clientHeight); let position; if (isAtTop) { position = "top"; } else if (isAtBottom) { position = "bottom"; } else { position = "middle"; } if (position === scrollPosition) return; setScrollPosition(position); } function onScroll(event) { if (event.currentTarget instanceof HTMLElement) { const el = event.currentTarget; if (scrollTimeout) clearTimeout(scrollTimeout); if (debounceDelay === 0) { determineScrollPosition(el); } else { scrollTimeout = setTimeout( () => determineScrollPosition(el), debounceDelay ); } } } function onTouchStart(event) { if (event.currentTarget instanceof HTMLElement) { determineScrollPosition(event.currentTarget); } } determineScrollPosition(element); element.addEventListener("scroll", onScroll); element.addEventListener("touchstart", onTouchStart); return () => { if (scrollTimeout) clearTimeout(scrollTimeout); element.removeEventListener("scroll", onScroll); element.removeEventListener("touchstart", onTouchStart); }; }, [element, isEnabled]); return { scrollRef: (element2) => setElement(element2), scrollPosition }; } // src/SheetContent.tsx var SheetContent = forwardRef( ({ disableScroll: disableScrollProp, disableDrag: disableDragProp, children, style: styleProp, className = "", scrollClassName = "", scrollStyle: scrollStyleProp, scrollRef: scrollRefProp = null, unstyled, ...rest }, ref) => { const sheetContext = useSheetContext(); const dragConstraints = useDragConstraints(); const scroll = useScrollPosition(); const disableScroll = typeof disableScrollProp === "function" ? disableScrollProp({ scrollPosition: scroll.scrollPosition, currentSnap: sheetContext.currentSnap }) : Boolean(disableScrollProp); const disableDragDueToScroll = !disableScroll && scroll.scrollPosition && scroll.scrollPosition !== "top"; const disableDragDueToProp = typeof disableDragProp === "function" ? disableDragProp({ scrollPosition: scroll.scrollPosition, currentSnap: sheetContext.currentSnap }) : Boolean(disableDragProp); const disableDrag = disableDragDueToProp || disableDragDueToScroll || sheetContext.disableDrag; const dragProps = disableDrag || sheetContext.disableDrag ? void 0 : sheetContext.dragProps; const isUnstyled = unstyled ?? sheetContext.unstyled; const contentStyle = { ...applyStyles(styles.content, isUnstyled), ...styleProp }; const scrollStyle = applyStyles(styles.scroller, isUnstyled); if (sheetContext.avoidKeyboard) { scrollStyle.paddingBottom = "env(keyboard-inset-height, var(--keyboard-inset-height, 0px))"; } if (disableScroll) { scrollStyle.overflowY = "hidden"; } return /* @__PURE__ */ React4.createElement( motion.div, { ...rest, ref: mergeRefs([ref, dragConstraints.ref]), className: `react-modal-sheet-content ${className}`, style: contentStyle, ...dragProps, dragConstraints: dragConstraints.ref, onMeasureDragConstraints: dragConstraints.onMeasure }, /* @__PURE__ */ React4.createElement( motion.div, { ref: mergeRefs([scroll.scrollRef, scrollRefProp]), style: { ...scrollStyle, ...scrollStyleProp }, className: `react-modal-sheet-content-scroller ${scrollClassName}` }, children ) ); } ); SheetContent.displayName = "SheetContent"; function SheetDragIndicator({ style, className = "", unstyled, ...rest }) { const sheetContext = useSheetContext(); const indicator1Transform = useTransform( sheetContext.indicatorRotation, (r) => `translateX(2px) rotate(${r}deg)` ); const indicator2Transform = useTransform( sheetContext.indicatorRotation, (r) => `translateX(-2px) rotate(${ -1 * r}deg)` ); const isUnstyled = unstyled ?? sheetContext.unstyled; const indicatorWrapperStyle = { ...applyStyles(styles.indicatorWrapper, isUnstyled), ...style }; const indicatorStyle = applyStyles(styles.indicator, isUnstyled); return /* @__PURE__ */ React4.createElement( "div", { className: `react-modal-sheet-drag-indicator-container ${className}`, style: indicatorWrapperStyle, ...rest }, /* @__PURE__ */ React4.createElement( motion.span, { className: "react-modal-sheet-drag-indicator", style: { ...indicatorStyle, transform: indicator1Transform } } ), /* @__PURE__ */ React4.createElement( motion.span, { className: "react-modal-sheet-drag-indicator", style: { ...indicatorStyle, transform: indicator2Transform } } ) ); } var SheetHeader = forwardRef( ({ children, style, disableDrag, unstyled, className = "", ...rest }, ref) => { const sheetContext = useSheetContext(); const dragConstraints = useDragConstraints(); const dragProps = disableDrag || sheetContext.disableDrag ? void 0 : sheetContext.dragProps; const isUnstyled = unstyled ?? sheetContext.unstyled; const headerWrapperStyle = { ...applyStyles(styles.headerWrapper, isUnstyled), ...style }; const headerStyle = applyStyles(styles.header, isUnstyled); return /* @__PURE__ */ React4.createElement( motion.div, { ...rest, ref: mergeRefs([ref, dragConstraints.ref]), style: headerWrapperStyle, className: `react-modal-sheet-header-container ${className}`, ...dragProps, dragConstraints: dragConstraints.ref, onMeasureDragConstraints: dragConstraints.onMeasure }, children || /* @__PURE__ */ React4.createElement("div", { className: "react-modal-sheet-header", style: headerStyle }, /* @__PURE__ */ React4.createElement(SheetDragIndicator, null)) ); } ); SheetHeader.displayName = "SheetHeader"; var useIsomorphicLayoutEffect = IS_SSR ? useEffect : useLayoutEffect; // src/hooks/use-dimensions.ts function useDimensions() { const [dimensions, setDimensions] = useState(() => ({ windowHeight: !IS_SSR ? window.innerHeight : 0, windowWidth: !IS_SSR ? window.innerWidth : 0 })); useIsomorphicLayoutEffect(() => { function handler() { setDimensions({ windowHeight: window.innerHeight, windowWidth: window.innerWidth }); } handler(); window.addEventListener("resize", handler); return () => { window.removeEventListener("resize", handler); }; }, []); return dimensions; } function useSafeAreaInsets() { const [insets] = useState(() => { const fallback = { top: 0, left: 0, right: 0, bottom: 0 }; if (IS_SSR) return fallback; const root = document.querySelector(":root"); if (!root) return fallback; root.style.setProperty("--rms-sat", "env(safe-area-inset-top)"); root.style.setProperty("--rms-sal", "env(safe-area-inset-left)"); root.style.setProperty("--rms-sar", "env(safe-area-inset-right)"); root.style.setProperty("--rms-sab", "env(safe-area-inset-bottom)"); const computedStyle = getComputedStyle(root); const sat = getComputedValue(computedStyle, "--rms-sat"); const sal = getComputedValue(computedStyle, "--rms-sal"); const sar = getComputedValue(computedStyle, "--rms-sar"); const sab = getComputedValue(computedStyle, "--rms-sab"); root.style.removeProperty("--rms-sat"); root.style.removeProperty("--rms-sal"); root.style.removeProperty("--rms-sar"); root.style.removeProperty("--rms-sab"); return { top: sat, left: sal, right: sar, bottom: sab }; }); return insets; } function getComputedValue(computed, property) { const strValue = computed.getPropertyValue(property).replace("px", "").trim(); return parseInt(strValue, 10) || 0; } // src/hooks/use-modal-effect.ts function useModalEffect({ y, detent, rootId: _rootId, sheetHeight, snapPoints, startThreshold }) { const insetTop = useSafeAreaInsets().top; let rootId = _rootId; if (rootId && detent === "full") { console.warn('Using "full" detent with modal effect is not supported.'); rootId = void 0; } useIsomorphicLayoutEffect(() => { return () => { if (rootId) cleanupModalEffect(rootId); }; }, []); useIsomorphicLayoutEffect(() => { if (!rootId) return; const root = document.querySelector(`#${rootId}`); if (!root) return; const removeStartListener = y.on("animationStart", () => { setupModalEffect(rootId); }); const removeChangeListener = y.on("change", (yValue) => { if (!root) return; let progress = Math.max(0, 1 - yValue / sheetHeight); const snapThresholdPoint = snapPoints.length > 1 ? snapPoints[snapPoints.length - 2] : void 0; if (snapThresholdPoint !== void 0) { const snapThresholdValue = snapThresholdPoint.snapValueY; if (yValue <= snapThresholdValue) { progress = (snapThresholdValue - yValue) / snapThresholdValue; } else { progress = 0; } } if (startThreshold !== void 0) { const startThresholdValue = sheetHeight - Math.min(Math.floor(startThreshold * sheetHeight), sheetHeight); if (yValue <= startThresholdValue) { progress = (startThresholdValue - yValue) / startThresholdValue; } else { progress = 0; } } progress = Math.max(0, Math.min(1, progress)); const pageWidth = window.innerWidth; const ty = transform(progress, [0, 1], [0, 24 + insetTop]); const s = transform(progress, [0, 1], [1, (pageWidth - 16) / pageWidth]); const borderRadius = transform(progress, [0, 1], [0, 10]); root.style.transform = `scale(${s}) translate3d(0, ${ty}px, 0)`; root.style.borderTopRightRadius = `${borderRadius}px`; root.style.borderTopLeftRadius = `${borderRadius}px`; }); function onCompleted() { if (y.get() - 5 >= sheetHeight) { cleanupModalEffect(rootId); } } const removeCompleteListener = y.on("animationComplete", onCompleted); const removeCancelListener = y.on("animationCancel", onCompleted); return () => { removeStartListener(); removeChangeListener(); removeCompleteListener(); removeCancelListener(); }; }, [y, rootId, insetTop, startThreshold, sheetHeight]); } function setupModalEffect(rootId) { const root = document.querySelector(`#${rootId}`); const body = document.querySelector("body"); if (!root) return; body.style.backgroundColor = "#000"; root.style.overflow = "hidden"; root.style.transitionTimingFunction = "cubic-bezier(0.32, 0.72, 0, 1)"; root.style.transitionProperty = "transform, border-radius"; root.style.transitionDuration = "0.5s"; root.style.transformOrigin = "center top"; } function cleanupModalEffect(rootId) { const root = document.querySelector(`#${rootId}`); const body = document.querySelector("body"); if (!root) return; body.style.removeProperty("background-color"); root.style.removeProperty("overflow"); root.style.removeProperty("transition-timing-function"); root.style.removeProperty("transition-property"); root.style.removeProperty("transition-duration"); root.style.removeProperty("transform-origin"); root.style.removeProperty("transform"); root.style.removeProperty("border-top-right-radius"); root.style.removeProperty("border-top-left-radius"); } // src/hooks/use-prevent-scroll.ts var KEYBOARD_BUFFER = 24; function chain(...callbacks) { return (...args) => { for (const callback of callbacks) { if (typeof callback === "function") { callback(...args); } } }; } var visualViewport = typeof document !== "undefined" && window.visualViewport; function isScrollable(node, checkForOverflow) { if (!node) { return false; } const style = window.getComputedStyle(node); let isScrollable2 = /(auto|scroll)/.test( style.overflow + style.overflowX + style.overflowY ); if (isScrollable2 && checkForOverflow) { isScrollable2 = node.scrollHeight !== node.clientHeight || node.scrollWidth !== node.clientWidth; } return isScrollable2; } function getScrollParent(node, checkForOverflow) { let scrollableNode = node; if (isScrollable(scrollableNode, checkForOverflow)) { scrollableNode = scrollableNode.parentElement; } while (scrollableNode && !isScrollable(scrollableNode, checkForOverflow)) { scrollableNode = scrollableNode.parentElement; } return scrollableNode || document.scrollingElement || document.documentElement; } var nonTextInputTypes = /* @__PURE__ */ new Set([ "checkbox", "radio", "range", "color", "file", "image", "button", "submit", "reset" ]); var preventScrollCount = 0; var restore; function usePreventScroll(options = {}) { const { isDisabled } = options; useIsomorphicLayoutEffect(() => { if (isDisabled) { return; } preventScrollCount++; if (preventScrollCount === 1) { if (isIOS()) { restore = preventScrollMobileSafari(); } else { restore = preventScrollStandard(); } } return () => { preventScrollCount--; if (preventScrollCount === 0) { restore == null ? void 0 : restore(); } }; }, [isDisabled]); } function preventScrollStandard() { return chain( setStyle( document.documentElement, "paddingRight", `${window.innerWidth - document.documentElement.clientWidth}px` ), setStyle(document.documentElement, "overflow", "hidden") ); } function preventScrollMobileSafari() { let scrollable; let lastY = 0; const onTouchStart = (e) => { var _a; const target = (_a = e.composedPath()) == null ? void 0 : _a[0]; scrollable = getScrollParent(target, true); if (scrollable === document.documentElement && scrollable === document.body) { return; } lastY = e.changedTouches[0].pageY; }; const onTouchMove = (e) => { if (scrollable === void 0) { return; } if (!scrollable || scrollable === document.documentElement || scrollable === document.body) { e.preventDefault(); return; } const y = e.changedTouches[0].pageY; const scrollTop = scrollable.scrollTop; const bottom = scrollable.scrollHeight - scrollable.clientHeight; if (bottom === 0) { return; } if (scrollTop <= 0 && y > lastY || scrollTop >= bottom && y < lastY) { e.preventDefault(); } lastY = y; }; const onTouchEnd = (e) => { var _a; const target = (_a = e.composedPath()) == null ? void 0 : _a[0]; if (willOpenKeyboard(target) && target !== document.activeElement) { e.preventDefault(); target.style.transform = "translateY(-2000px)"; target.focus(); requestAnimationFrame(() => { target.style.transform = ""; }); } }; const onFocus = (e) => { var _a; const target = (_a = e.composedPath()) == null ? void 0 : _a[0]; if (willOpenKeyboard(target)) { target.style.transform = "translateY(-2000px)"; requestAnimationFrame(() => { target.style.transform = ""; if (visualViewport) { if (visualViewport.height < window.innerHeight) { requestAnimationFrame(() => { scrollIntoView(target); }); } else { visualViewport.addEventListener( "resize", () => scrollIntoView(target), { once: true } ); } } }); } }; const onWindowScroll = () => { window.scrollTo(0, 0); }; const scrollX = window.pageXOffset; const scrollY = window.pageYOffset; const restoreStyles = chain( setStyle( document.documentElement, "paddingRight", `${window.innerWidth - document.documentElement.clientWidth}px` ), setStyle(document.documentElement, "overflow", "hidden"), setStyle(document.body, "marginTop", `-${scrollY}px`) ); window.scrollTo(0, 0); const removeEvents = chain( addEvent(document, "touchstart", onTouchStart, { passive: false, capture: true }), addEvent(document, "touchmove", onTouchMove, { passive: false, capture: true }), addEvent(document, "touchend", onTouchEnd, { passive: false, capture: true }), addEvent(document, "focus", onFocus, true), addEvent(window, "scroll", onWindowScroll) ); return () => { restoreStyles(); removeEvents(); window.scrollTo(scrollX, scrollY); }; } function setStyle(element, style, value) { const cur = element.style[style]; element.style[style] = value; return () => { element.style[style] = cur; }; } function addEvent(target, event, handler, options) { target.addEventListener(event, handler, options); return () => { target.removeEventListener(event, handler, options); }; } function scrollIntoView(target) { const root = document.scrollingElement || document.documentElement; while (target && target !== root) { const scrollable = getScrollParent(target); if (scrollable !== document.documentElement && scrollable !== document.body && scrollable !== target) { const scrollableTop = scrollable.getBoundingClientRect().top; const targetTop = target.getBoundingClientRect().top; const targetBottom = target.getBoundingClientRect().bottom; const keyboardHeight = scrollable.getBoundingClientRect().bottom + KEYBOARD_BUFFER; if (targetBottom > keyboardHeight) { scrollable.scrollTop += targetTop - scrollableTop; } } target = scrollable.parentElement; } } function willOpenKeyboard(target) { return target instanceof HTMLInputElement && !nonTextInputTypes.has(target.type) || target instanceof HTMLTextAreaElement || target instanceof HTMLElement && target.isContentEditable; } function useStableCallback(handler) { const handlerRef = useRef(void 0); useIsomorphicLayoutEffect(() => { handlerRef.current = handler; }); return useCallback((...args) => { const fn = handlerRef.current; return fn == null ? void 0 : fn(...args); }, []); } // src/hooks/use-sheet-state.ts function useSheetState({ isOpen, onClosed: _onClosed, onOpening: _onOpening, onOpen: _onOpen, onClosing: _onClosing }) { const [state, setState] = useState(isOpen ? "opening" : "closed"); const onClosed = useStableCallback(() => _onClosed == null ? void 0 : _onClosed()); const onOpening = useStableCallback(() => _onOpening == null ? void 0 : _onOpening()); const onOpen = useStableCallback(() => _onOpen == null ? void 0 : _onOpen()); const onClosing = useStableCallback(() => _onClosing == null ? void 0 : _onClosing()); useEffect(() => { if (isOpen && state === "closed") { setState("opening"); } else if (!isOpen && (state === "open" || state === "opening")) { setState("closing"); } }, [isOpen, state]); useEffect(() => { async function handle() { switch (state) { case "closed": await (onClosed == null ? void 0 : onClosed()); break; case "opening": await (onOpening == null ? void 0 : onOpening()); setState("open"); break; case "open": await (onOpen == null ? void 0 : onOpen()); break; case "closing": await (onClosing == null ? void 0 : onClosing()); setState("closed"); break; } } handle().catch((error) => { console.error("Internal sheet state error:", error); }); }, [state]); return state; } function useVirtualKeyboard(options = {}) { const { containerRef, isEnabled = true, debounceDelay = 100, includeContentEditable = true, visualViewportThreshold = 100 } = options; const [state, setState] = useState({ isVisible: false, height: 0 }); const focusedElementRef = useRef(null); const debounceTimer = useRef(null); const isTextInput = useStableCallback((el) => { return (el == null ? void 0 : el.tagName) === "INPUT" || (el == null ? void 0 : el.tagName) === "TEXTAREA" || includeContentEditable && el instanceof HTMLElement && el.isContentEditable; }); useEffect(() => { if (!isEnabled) return; const vv = window.visualViewport; const vk = navigator.virtualKeyboard; function setKeyboardInsetHeightEnv(height) { const element = (containerRef == null ? void 0 : containerRef.current) || document.documentElement; if (window.isSecureContext) { element.style.setProperty( "--keyboard-inset-height", `env(keyboard-inset-height, ${height}px)` ); } else { element.style.setProperty("--keyboard-inset-height", `${height}px`); } } function handleFocusIn(e) { if (e.target instanceof HTMLElement && isTextInput(e.target)) { focusedElementRef.current = e.target; updateKeyboardState(); } } function handleFocusOut() { focusedElementRef.current = null; updateKeyboardState(); } function updateKeyboardState() { if (debounceTimer.current) { clearTimeout(debounceTimer.current); } debounceTimer.current = setTimeout(() => { const active = focusedElementRef.current; const inputIsFocused = isTextInput(active); if (!inputIsFocused) { setKeyboardInsetHeightEnv(0); setState({ isVisible: false, height: 0 }); return; } if (vv) { const heightDiff = window.innerHeight - vv.height; if (heightDiff > visualViewportThreshold) { setKeyboardInsetHeightEnv(heightDiff); setState({ isVisible: true, height: heightDiff }); } else { setKeyboardInsetHeightEnv(0); setState({ isVisible: false, height: 0 }); } } }, debounceDelay); } window.addEventListener("focusin", handleFocusIn); window.addEventListener("focusout", handleFocusOut); if (vv) { vv.addEventListener("resize", updateKeyboardState); vv.addEventListener("scroll", updateKeyboardState); } let currentOverlaysContent = false; if (vk) { currentOverlaysContent = vk.overlaysContent; vk.overlaysContent = true; } return () => { window.removeEventListener("focusin", handleFocusIn); window.removeEventListener("focusout", handleFocusOut); if (vv) { vv.removeEventListener("resize", updateKeyboardState); vv.removeEventListener("scroll", updateKeyboardState); } if (vk) { vk.overlaysContent = currentOverlaysContent; } if (debounceTimer.current) { clearTimeout(debounceTimer.current); } }; }, [ debounceDelay, includeContentEditable, isEnabled, visualViewportThreshold ]); return { keyboardHeight: state.height, isKeyboardOpen: state.isVisible }; } // src/snap.ts function computeSnapPoints({ snapPointsProp, sheetHeight }) { if (snapPointsProp[0] !== 0) { console.error( `First snap point should be 0 to ensure the sheet can be fully closed. Got: [${snapPointsProp.join(", ")}]` ); snapPointsProp.unshift(0); } if (snapPointsProp[snapPointsProp.length - 1] !== 1) { console.error( `Last snap point should be 1 to ensure the sheet can be fully opened. Got: [${snapPointsProp.join(", ")}]` ); snapPointsProp.push(1); } if (sheetHeight <= 0) { console.error( `Sheet height is ${sheetHeight}, cannot compute snap points. Make sure the sheet is mounted and has a valid height.` ); return []; } const snapPointValues = snapPointsProp.map((point) => { if (point > 0 && point <= 1) { return Math.round(point * sheetHeight); } return point < 0 ? sheetHeight + point : point; }); console.assert( isAscendingOrder(snapPointValues), `Snap points need to be in ascending order got: [${snapPointsProp.join(", ")}]` ); snapPointValues.forEach((snap) => { if (snap < 0 || snap > sheetHeight) { console.warn( `Snap point ${snap} is outside of the sheet height ${sheetHeight}. This can cause unexpected behavior. Consider adjusting your snap points.` ); } }); if (!snapPointValues.includes(sheetHeight)) { console.warn( "Snap points do not include the sheet height.Please include `1` as the last snap point or it will be included automatically.This is to ensure the sheet can be fully opened." ); snapPointValues.push(sheetHeight); } return snapPointValues.map((snap, index) => ({ snapIndex: index, snapValue: snap, // Absolute value from the bottom of the sheet snapValueY: sheetHeight - snap // Y value is inverted as `y = 0` means sheet is at the top })); } function findClosestSnapPoint({ snapPoints, currentY }) { return snapPoints.reduce( (closest, snap) => Math.abs(snap.snapValueY - currentY) < Math.abs(closest.snapValueY - currentY) ? snap : closest ); } function findNextSnapPointInDirection({ y, snapPoints, dragDirection }) { if (dragDirection === "down") { return snapPoints.slice().reverse().find((s) => s.snapValueY > y); } else { return snapPoints.find((s) => s.snapValueY < y); } } function handleHighVelocityDrag({ dragDirection, snapPoints }) { const bottomSnapPoint = snapPoints[0]; const topSnapPoint = snapPoints[snapPoints.length - 1]; if (dragDirection === "down") { return { yTo: bottomSnapPoint.snapValueY, snapIndex: bottomSnapPoint.snapIndex }; } return { yTo: topSnapPoint.snapValueY, snapIndex: topSnapPoint.snapIndex }; } function handleLowVelocityDrag({ currentSnapPoint, currentY, dragDirection, snapPoints, velocity }) { const closestSnapRelativeToCurrentY = findClosestSnapPoint({ snapPoints, currentY }); if (Math.abs(velocity) < 20) { return { yTo: closestSnapRelativeToCurrentY.snapValueY, snapIndex: closestSnapRelativeToCurrentY.snapIndex }; } const nextSnapInDirectionRelativeToCurrentY = findNextSnapPointInDirection({ y: currentY, snapPoints, dragDirection }); if (nextSnapInDirectionRelativeToCurrentY) { return { yTo: nextSnapInDirectionRelativeToCurrentY.snapValueY, snapIndex: nextSnapInDirectionRelativeToCurrentY.snapIndex }; } return { yTo: currentSnapPoint.snapValueY, snapIndex: currentSnapPoint.snapIndex }; } // src/sheet.tsx var Sheet = forwardRef( ({ avoidKeyboard = true, children, className = "", detent = "default", disableDismiss = false, disableDrag: disableDragProp = false, disableScrollLocking = false, dragCloseThreshold = DEFAULT_DRAG_CLOSE_THRESHOLD, dragVelocityThreshold = DEFAULT_DRAG_VELOCITY_THRESHOLD, initialSnap, isOpen, modalEffectRootId, modalEffectThreshold, mountPoint, prefersReducedMotion = false, snapPoints: snapPointsProp, style, tweenConfig = DEFAULT_TWEEN_CONFIG, unstyled = false, onOpenStart, onOpenEnd, onClose, onCloseStart, onCloseEnd, onSnap, onDrag: onDragProp, onDragStart: onDragStartProp, onDragEnd: onDragEndProp, ...rest }, ref) => { const [sheetBoundsRef, sheetBounds] = useMeasure(); const sheetRef = useRef(null); const sheetHeight = Math.round(sheetBounds.height); const [currentSnap, setCurrentSnap] = useState(initialSnap); const snapPoints = snapPointsProp && sheetHeight > 0 ? computeSnapPoints({ sheetHeight, snapPointsProp }) : []; const { windowHeight } = useDimensions(); const closedY = sheetHeight > 0 ? sheetHeight : windowHeight; const y = useMotionValue(closedY); const yInverted = useTransform(y, (val) => Math.max(sheetHeight - val, 0)); const indicatorRotation = useMotionValue(0); const shouldReduceMotion = useReducedMotion(); const reduceMotion = Boolean(prefersReducedMotion || shouldReduceMotion); const animationOptions = { type: "tween", ...reduceMotion ? REDUCED_MOTION_TWEEN_CONFIG : tweenConfig }; const keyboard = useVirtualKeyboard({ isEnabled: isOpen && avoidKeyboard, containerRef: sheetRef }); const disableDrag = keyboard.isKeyboardOpen || disableDragProp; const zIndex = useTransform( y, (val) => val + 2 >= closedY ? -1 : (style == null ? void 0 : style.zIndex) ?? 9999 ); const visibility = useTransform( y, (val) => val + 2 >= closedY ? "hidden" : "visible" ); const updateSnap = useStableCallback((snapIndex) => { setCurrentSnap(snapIndex); onSnap == null ? void 0 : onSnap(snapIndex); }); const getSnapPoint = useStableCallback((snapIndex) => { if (snapPointsProp && snapPoints) { if (snapIndex < 0 || snapIndex >= snapPoints.length) { console.warn( `Invalid snap index ${snapIndex}. Snap points are: [${snapPointsProp.join(", ")}] and their computed values are: [${snapPoints.map((point) => point.snapValue).join(", ")}]` ); return null; } return snapPoints[snapIndex]; } return null; }); const snapTo = useStableCallback(async (snapIndex) => { if (!snapPointsProp) { console.warn("Snapping is not possible without `snapPoints` prop."); return; } const snapPoint = getSnapPoint(snapIndex); if (snapPoint === null) { console.warn(`Invalid snap index ${snapIndex}.`); return; } if (snapIndex === 0) { onClose(); return; } await animate(y, snapPoint.snapValueY, { ...animationOptions, onComplete: () => updateSnap(snapIndex) }); }); const blurActiveInput = useStableCallback(() => { const focusedElement = document.activeElement; if (!focusedElement || !sheetRef.current) return; const isInput = focusedElement.tagName === "INPUT" || focusedElement.tagName === "TEXTAREA"; if (isInput && sheetRef.current.contains(focusedElement)) { focusedElement.blur(); } }); const onDrag = useStableCallback((event, info) => { onDragProp == null ? void 0 : onDragProp(event, info); const currentY = y.get(); const velocity = y.getVelocity(); if (velocity > 0) indicatorRotation.set(10); if (velocity < 0) indicatorRotation.set(-10); y.set(Math.max(currentY + info.delta.y, 0)); }); const onDragStart = useStableCallback((event, info) => { blurActiveInput(); onDragStartProp == null ? void 0 : onDragStartProp(event, info); }); const onDragEnd = useStableCallback((event, info) => { blurActiveInput(); onDragEndProp == null ? void 0 : onDragEndProp(event, info); const currentY = y.get(); let yTo = 0; const currentSnapPoint = currentSnap !== void 0 ? getSnapPoint(currentSnap) : null; if (currentSnapPoint) { const dragOffsetDirection = info.offset.y > 0 ? "down" : "up"; const dragVelocityDirection = info.velocity.y > 0 ? "down" : "up"; const isHighVelocity = Math.abs(info.velocity.y) > dragVelocityThreshold; let result; if (isHighVelocity) { result = handleHighVelocityDrag({ snapPoints, dragDirection: dragVelocityDirection }); } else { result = handleLowVelocityDrag({ currentSnapPoint, currentY, dragDirection: dragOffsetDirection, snapPoints, velocity: info.velocity.y }); } yTo = result.yTo; if (disableDismiss && yTo + 1 >= sheetHeight) { const bottomSnapPoint = snapPoints.find((s) => s.snapValue > 0); if (bottomSnapPoint) { yTo = bottomSnapPoint.snapValueY; updateSnap(bottomSnapPoint.snapIndex); } else { yTo = currentY; } } else if (result.snapIndex !== void 0) { updateSnap(result.snapIndex); } } else if (info.velocity.y > dragVelocityThreshold || currentY > sheetHeight * dragCloseThreshold) { if (disableDismiss) { yTo = 0; } else { yTo = closedY; } } animate(y, yTo, animationOptions); if (yTo + 1 >= sheetHeight && !disableDismiss) { onClose(); } indicatorRotation.set(0); }); useImperativeHandle(ref, () => ({ y, yInverted, height: sheetHeight, snapTo })); useModalEffect({ y, detent, sheetHeight, snapPoints, rootId: modalEffectRootId, startThreshold: modalEffectThreshold }); usePreventScroll({ isDisabled: disableScrollLocking || !isOpen }); const state = useSheetState({ isOpen, onOpen: async () => { onOpenStart == null ? void 0 : onOpenStart(); await waitForElement("react-modal-sheet-container"); const initialSnapPoint = initialSnap !== void 0 ? getSnapPoint(initialSnap) : null; const yTo = (initialSnapPoint == null ? void 0 : initialSnapPoint.snapValueY) ?? 0; await animate(y, yTo, animationOptions); if (initialSnap !== void 0) { updateSnap(initialSnap); } onOpenEnd == null ? void 0 : onOpenEnd(); }, onClosing: async () => { onCloseStart == null ? void 0 : onCloseStart(); await animate(y, closedY, animationOptions); onCloseEnd == null ? void 0 : onCloseEnd(); } }); const dragProps = { drag: "y", dragElastic: 0, dragMomentum: false, dragPropagation: false, onDrag, onDragStart, onDragEnd }; const context = { currentSnap, detent, disableDrag, dragProps, indicatorRotation, avoidKeyboard, sheetBoundsRef, sheetRef, unstyled, y }; const sheet = /* @__PURE__ */ React4.createElement(SheetContext.Provider, { value: context }, /* @__PURE__ */ React4.createElement( motion.div, { ...rest, ref, "data-sheet-state": state, className: `react-modal-sheet-root ${className}`, style: { ...applyStyles(styles.root, unstyled), zIndex, visibility, ...style } }, state !== "closed" ? children : null )); if (IS_SSR) return sheet; return createPortal(sheet, mountPoint ?? document.body); } ); Sheet.displayName = "Sheet"; // src/index.tsx var Sheet2 = Object.assign(Sheet, { Container: SheetContainer, Header: SheetHeader, DragIndicator: SheetDragIndicator, Content: SheetContent, Backdrop: SheetBackdrop }); export { Sheet2 as Sheet, useScrollPosition, useVirtualKeyboard }; //# sourceMappingURL=index.mjs.map //# sourceMappingURL=index.mjs.map