import { type PointerEvent as ReactPointerEvent, type ReactNode, useEffect, useRef, useState, } from "react"; import { createPortal } from "react-dom"; import { Typography } from "@/components/NouiTypography"; import { cn } from "@/lib/utils"; const CLOSE_DRAG_MIN_PX = 72; const CLOSE_DRAG_RATIO = 0.18; const SHEET_TRANSITION_MS = 280; /** * Mobile-first picker shell: fixed backdrop + bottom sheet, portaled to `body` * so nested overflow/transform in the sidebar cannot clip menus (theme / * language switchers). Open/close uses slide + fade; teardown is delayed until * the exit animation finishes so animations can complete. * * Drag the header/handle downward to dismiss (skipped when reduced motion is on). */ export function BottomPickSheet({ backdropDismissLabel = "Dismiss", children, onClose, open, title, }: BottomPickSheetProps) { const [renderPortal, setRenderPortal] = useState(open); const [entered, setEntered] = useState(false); const [dragOffsetPx, setDragOffsetPx] = useState(0); const [dragActive, setDragActive] = useState(false); const closeTimerRef = useRef | null>(null); const sheetRef = useRef(null); const dragTrackingRef = useRef(false); const dragStartYRef = useRef(0); const dragOffsetRef = useRef(0); const reducedMotion = typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches; const syncDragPx = (next: number) => { dragOffsetRef.current = next; setDragOffsetPx(next); }; useEffect(() => { if (closeTimerRef.current) { clearTimeout(closeTimerRef.current); closeTimerRef.current = null; } const ms = reducedMotion ? 0 : SHEET_TRANSITION_MS; let openRafId = 0; let exitRafId = 0; if (open) { openRafId = requestAnimationFrame(() => { dragTrackingRef.current = false; dragOffsetRef.current = 0; setDragActive(false); setDragOffsetPx(0); setRenderPortal(true); requestAnimationFrame(() => { requestAnimationFrame(() => setEntered(true)); }); }); } else { exitRafId = requestAnimationFrame(() => { dragTrackingRef.current = false; setDragActive(false); setEntered(false); closeTimerRef.current = window.setTimeout(() => { dragOffsetRef.current = 0; setDragOffsetPx(0); setRenderPortal(false); closeTimerRef.current = null; }, ms); }); } return () => { cancelAnimationFrame(openRafId); cancelAnimationFrame(exitRafId); if (closeTimerRef.current) { clearTimeout(closeTimerRef.current); closeTimerRef.current = null; } }; }, [open, reducedMotion]); useEffect(() => { if (!renderPortal) return; const prev = document.body.style.overflow; document.body.style.overflow = "hidden"; return () => { document.body.style.overflow = prev; }; }, [renderPortal]); if (!renderPortal || typeof document === "undefined") return null; const durationClass = reducedMotion ? "duration-0" : "duration-[280ms]"; const draggingVisual = dragActive || dragOffsetPx > 0; const onDragPointerDown = (e: ReactPointerEvent) => { if (reducedMotion || !entered) return; if (e.pointerType === "mouse" && e.button !== 0) return; dragTrackingRef.current = true; setDragActive(true); dragStartYRef.current = e.clientY; syncDragPx(0); e.currentTarget.setPointerCapture(e.pointerId); }; const onDragPointerMove = (e: ReactPointerEvent) => { if (!dragTrackingRef.current) return; const dy = e.clientY - dragStartYRef.current; const next = Math.max(0, dy); const sheetH = sheetRef.current?.offsetHeight ?? 560; syncDragPx(Math.min(next, sheetH)); }; const endDrag = (e: ReactPointerEvent) => { if (!dragTrackingRef.current) return; dragTrackingRef.current = false; setDragActive(false); try { e.currentTarget.releasePointerCapture(e.pointerId); } catch { /* already released */ } const sheetH = sheetRef.current?.offsetHeight ?? 560; const threshold = Math.max(CLOSE_DRAG_MIN_PX, sheetH * CLOSE_DRAG_RATIO); const d = dragOffsetRef.current; if (d >= threshold) { onClose(); return; } syncDragPx(0); }; return createPortal(