"use client"; import { AnimatePresence, motion, type PanInfo, useDragControls, useReducedMotion, } from "motion/react"; import { type ReactNode, useEffect, useId, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { EASE_DRAWER } from "@/lib/ease"; import { PresenceGate } from "@/lib/presence-gate"; import { TOUCH_GESTURE_CONTENT_CLASS } from "@/lib/touch"; 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); const uid = useId(); const titleId = `${uid}-title`; const descriptionId = `${uid}-description`; 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"; const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") { event.preventDefault(); onOpenChange(false); } }; window.addEventListener("keydown", onKey); return () => { window.removeEventListener("keydown", onKey); 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, onOpenChange]); 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; // Two fixed siblings, no wrapper: the scrim spans the viewport edges but // carries a colour, and the sheet is pinned to the bottom, stops short of the // top edge at every snap point the component ships, and paints an opaque // surface either way. Both hang off `PresenceGate`, so interaction releases in // the same commit that starts the exit rather than when it ends. See // tests/fixed-overlay-edge-sampling.test.tsx. return createPortal( {open ? ( {({ gate }) => ( 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 fixed inset-0 z-50 bg-background/40 backdrop-blur-sm" /> )} ) : null} {open ? ( {({ gate }) => ( { if (sheetRef.current) heightRef.current = sheetRef.current.offsetHeight; }} {...gate} style={{ ...heightStyle, ...gate.style }} className={cn( "pointer-events-auto fixed bottom-0 left-0 right-0 z-50 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-labelledby={title ? titleId : undefined} aria-describedby={description ? descriptionId : undefined} aria-label={title ? undefined : "Bottom sheet"} >
{/* Drag only the pill so the title and description stay selectable. */}
dragControls.start(event)} // A slow pull must not hand the gesture to iOS's callout, // which would leave the sheet frozen mid-drag. className={cn( "flex cursor-grab touch-none items-center justify-center py-1 active:cursor-grabbing", TOUCH_GESTURE_CONTENT_CLASS, )} >
{title || description ? (
{title ? (

{title}

) : null} {description ? (

{description}

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