"use client"; import { AnimatePresence, motion, type PanInfo, useDragControls, useReducedMotion, } from "motion/react"; import { type ReactNode, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { EASE_DRAWER } from "@/lib/ease"; import { cn } from "@/lib/utils"; // Vaul-style glide: a long, fully-damped tween reads smoother than a spring on // open — no settle/overshoot, just one clean decel. Same curve drives the // backdrop fade so the surface and scrim move as one. const DRAWER = { duration: 0.5, ease: EASE_DRAWER } as const; 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 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); } return; } // Strong upward fling → next snap. if (velocity < -500) { setSnap((current) => Math.min(snapPoints.length - 1, current + 1)); return; } // Otherwise snap to nearest by current offset. setSnap((current) => { if (offset > 80 && current > 0) return current - 1; if (offset < -80 && current < snapPoints.length - 1) return current + 1; return current; }); }; 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)} // A dim scrim with a light blur. backdrop-blur is GPU-expensive and // re-rasterizes every frame the sheet drags over it; a small radius // plus more opacity keeps the glass look without the jank. className="pointer-events-auto absolute inset-0 bg-background/40 backdrop-blur-sm" /> { 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-background 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, ); }