"use client"; import { X } from "lucide-react"; import { useCallback, useEffect, useId, useRef, useState, useSyncExternalStore, type PointerEvent as RPointerEvent, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { cn } from "./cn"; import { IconButton } from "./button"; const noop = (): (() => void) => () => {}; export interface SheetProps { open: boolean; onClose: () => void; title?: ReactNode; description?: ReactNode; children: ReactNode; footer?: ReactNode; /** desktop side (≥ md); mobile is always a bottom sheet */ side?: "right" | "left"; /** desktop width */ width?: number; /** mobile: initial height as vh (default 70), drag handle allows expand/collapse */ heightVh?: number; className?: string; /** render without backdrop (map quick-profile) so the map stays interactive */ modal?: boolean; id?: string; } const FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])'; /** * Bottom sheet on mobile (drag handle: swipe down to close, up to expand), side drawer on ≥ md. * Focus trap, ESC to close, restores focus, locks body scroll when modal. */ export function Sheet({ open, onClose, title, description, children, footer, side = "right", width = 400, heightVh = 70, className, modal = true, id }: SheetProps) { const panelRef = useRef(null); const lastActive = useRef(null); const mounted = useSyncExternalStore(noop, () => true, () => false); const [drag, setDrag] = useState<{ startY: number; dy: number } | null>(null); const [expanded, setExpanded] = useState(false); const labelId = useId(); const descId = useId(); useEffect(() => { if (!open) return; lastActive.current = document.activeElement; const prevOverflow = document.body.style.overflow; if (modal) document.body.style.overflow = "hidden"; const t = setTimeout(() => { const first = panelRef.current?.querySelector(FOCUSABLE); (first ?? panelRef.current)?.focus(); }, 20); const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") { e.stopPropagation(); onClose(); } if (e.key === "Tab" && panelRef.current) { const nodes = Array.from(panelRef.current.querySelectorAll(FOCUSABLE)).filter((n) => n.offsetParent !== null); if (!nodes.length) return; const first = nodes[0]!; const last = nodes[nodes.length - 1]!; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } } }; document.addEventListener("keydown", onKey); return () => { clearTimeout(t); document.removeEventListener("keydown", onKey); if (modal) document.body.style.overflow = prevOverflow; setExpanded(false); (lastActive.current as HTMLElement | null)?.focus?.(); }; }, [open, onClose, modal]); const onPointerDown = useCallback((e: RPointerEvent) => { setDrag({ startY: e.clientY, dy: 0 }); (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); }, []); const onPointerMove = useCallback((e: RPointerEvent) => { setDrag((d) => (d ? { ...d, dy: e.clientY - d.startY } : d)); }, []); const onPointerUp = useCallback(() => { setDrag((d) => { if (d) { if (d.dy > 80) onClose(); else if (d.dy < -60) setExpanded(true); else if (d.dy > 30 && expanded) setExpanded(false); } return null; }); }, [onClose, expanded]); if (!mounted || !open) return null; const isDesktop = typeof window !== "undefined" && window.matchMedia("(min-width: 768px)").matches; const translate = drag && drag.dy > 0 && !isDesktop ? `translateY(${drag.dy}px)` : undefined; const node = (
{modal &&
); return createPortal(node, document.body); }