"use client"; import { AnimatePresence, motion, useDragControls, useMotionValue, useReducedMotion, type PanInfo, } from "motion/react"; import { useEffect, useRef, useState, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { EASE_OUT, SPRING_PANEL } from "@/lib/ease"; import { cn } from "@/lib/utils"; export interface BottomSheetProps { open: boolean; onOpenChange: (open: boolean) => void; /** Heights (0-1 = fraction of viewport, or "auto"). First entry is default. */ snapPoints?: (number | "auto")[]; defaultSnap?: number; title?: string; description?: string; children?: ReactNode; className?: string; /** Min drag distance (px) past current snap to dismiss. */ dismissThreshold?: number; } export function BottomSheet({ open, onOpenChange, snapPoints = [0.5, 0.92], defaultSnap = 0, title, description, children, className, dismissThreshold = 120, }: BottomSheetProps) { const [snap, setSnap] = useState(defaultSnap); const [mounted, setMounted] = useState(false); const dragY = useMotionValue(0); const dragControls = useDragControls(); const sheetRef = useRef(null); const reduce = useReducedMotion(); const heightRef = useRef(0); useEffect(() => { setMounted(true); }, []); useEffect(() => { if (open) setSnap(defaultSnap); }, [open, defaultSnap]); // Lock background scroll while open. overflow:hidden alone is ignored by // iOS Safari — boundary scrolls inside the sheet chain to the page, which // scrolls underneath and ends up somewhere else on close. position:fixed // is the lock that actually holds; restore the scroll position after. useEffect(() => { if (!open) return; const body = document.body; const scrollY = window.scrollY; const prev = { position: body.style.position, top: body.style.top, left: body.style.left, right: body.style.right, overflow: body.style.overflow, }; body.style.position = "fixed"; body.style.top = `-${scrollY}px`; body.style.left = "0"; body.style.right = "0"; body.style.overflow = "hidden"; return () => { body.style.position = prev.position; body.style.top = prev.top; body.style.left = prev.left; body.style.right = prev.right; body.style.overflow = prev.overflow; window.scrollTo(0, scrollY); }; }, [open]); const onDragEnd = (_: unknown, info: PanInfo) => { const velocity = info.velocity.y; const offset = info.offset.y; // Strong downward fling or large drag → dismiss. if (velocity > 600 || offset > dismissThreshold) { const smaller = snapPoints.map((_, i) => i).filter((i) => i < snap); if (smaller.length && velocity < 800 && offset < dismissThreshold * 1.6) { setSnap(smaller[smaller.length - 1]); } else { onOpenChange(false); } dragY.set(0); return; } // Strong upward fling → next snap. if (velocity < -500) { setSnap(Math.min(snapPoints.length - 1, snap + 1)); dragY.set(0); return; } // Otherwise snap to nearest by current offset. if (offset > 80 && snap > 0) setSnap(snap - 1); else if (offset < -80 && snap < snapPoints.length - 1) setSnap(snap + 1); dragY.set(0); }; const snapValue = snapPoints[snap]; const heightStyle = snapValue === "auto" ? { maxHeight: "92vh" } : { height: `${snapValue * 100}vh` }; // Portal to : an ancestor with backdrop-filter or transform becomes // the containing block for fixed descendants, which would position the // sheet against that ancestor instead of the viewport. if (!mounted) return null; return createPortal( {open ? (
onOpenChange(false)} className="pointer-events-auto absolute inset-0 bg-background/5 backdrop-blur-md backdrop-saturate-150" /> dragY.set(Math.max(0, info.offset.y))} onDragEnd={onDragEnd} initial={reduce ? { y: 0, opacity: 0 } : { y: "100%" }} animate={reduce ? { y: 0, opacity: 1 } : { y: 0 }} exit={reduce ? { y: 0, opacity: 0 } : { y: "100%" }} transition={ reduce ? { duration: 0.18, ease: EASE_OUT } : SPRING_PANEL } onAnimationComplete={() => { if (sheetRef.current) heightRef.current = sheetRef.current.offsetHeight; }} style={heightStyle} className={cn( "pointer-events-auto absolute bottom-0 left-0 right-0 mx-auto flex max-w-2xl flex-col overflow-hidden rounded-t-3xl will-change-transform", "border border-border bg-card shadow-xl", className, )} role="dialog" aria-modal="true" aria-label={title} >
dragControls.start(e)} className="flex cursor-grab touch-none flex-col items-center px-4 pb-2 pt-3 active:cursor-grabbing" >
{title || description ? (
{title ? (

{title}

) : null} {description ? (

{description}

) : null}
) : null}
{/* overscroll-contain stops boundary scrolls from chaining to the page. */}
{children}
) : null} , document.body, ); }