import { ReactNode, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useIsMobile } from "../hooks/useMediaQuery"; import { useScrollLock } from "../hooks/useScrollLock"; import { CloseIcon } from "./icons"; const EXIT_MS = 240; /** * Responsive overlay primitive: a drag-dismissable bottom sheet on phones, a * centred dialog from `sm` up. Everything modal in the app renders through this * so the dismiss gesture, scroll lock and safe-area handling stay consistent. */ export function Sheet({ open, onClose, onSubmit, title, children, footer, wide, padded = true, }: { open: boolean; onClose: () => void; onSubmit?: () => void; title?: string; children: ReactNode; footer?: ReactNode; wide?: boolean; padded?: boolean; }) { const isMobile = useIsMobile(); const [mounted, setMounted] = useState(open); const [exiting, setExiting] = useState(false); const [dragY, setDragY] = useState(0); const [dragging, setDragging] = useState(false); const panelRef = useRef(null); const dragStart = useRef<{ y: number; t: number } | null>(null); // Keep the panel mounted through its exit transition. useEffect(() => { if (open) { setMounted(true); setExiting(false); setDragY(0); return; } if (!mounted) return; setExiting(true); const t = window.setTimeout(() => { setMounted(false); setExiting(false); setDragY(0); }, EXIT_MS); return () => window.clearTimeout(t); // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); useScrollLock(mounted); useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); if (e.key === "Enter" && (e.ctrlKey || e.metaKey) && onSubmit) onSubmit(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [open, onClose, onSubmit]); if (!mounted) return null; // ── drag-to-dismiss (mobile, from the grabber/header only, so it never // fights the scroll container underneath) ──────────────────────────────── const onPointerDown = (e: React.PointerEvent) => { if (!isMobile || e.pointerType === "mouse") return; dragStart.current = { y: e.clientY, t: e.timeStamp }; setDragging(true); (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); }; const onPointerMove = (e: React.PointerEvent) => { if (!dragStart.current) return; // Resist upward drags so the sheet feels anchored. const dy = e.clientY - dragStart.current.y; setDragY(dy > 0 ? dy : dy / 4); }; const endDrag = (e: React.PointerEvent) => { const start = dragStart.current; dragStart.current = null; setDragging(false); if (!start) return; const dy = e.clientY - start.y; const dt = Math.max(1, e.timeStamp - start.t); const velocity = dy / dt; // px/ms const height = panelRef.current?.offsetHeight ?? 400; if (dy > height * 0.28 || velocity > 0.5) onClose(); else setDragY(0); }; const dismissProgress = Math.min(1, Math.max(0, dragY) / 320); const panelTransform = exiting ? isMobile ? "translateY(100%)" : "scale(0.96)" : dragY ? `translateY(${dragY}px)` : undefined; // Portalled to : any ancestor with a transform (page fade-ins, the // blurred header) would otherwise become the containing block and knock the // fixed overlay out of place. return createPortal(
{/* Grab area — the whole header is draggable, like iOS sheets. */}
{title && (

{title}

)}
{/* The home-indicator inset is folded into the padding with calc so it adds to the base spacing instead of replacing it. */}
{children}
{footer && (
{footer}
)}
, document.body, ); }