import { useEffect, useState } from "react"; /** * Motion helpers shared by every surface that animates out: menus, popovers, the phone drawer, * overlays. Durations come from the CSS tokens in globals.css so JS timers and CSS transitions * can't drift apart, and collapse to 0 under reduced motion. */ export function prefersReducedMotion(): boolean { return ( typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches ); } /** "150ms" → 150, "0.25s" → 250; anything else → fallback. */ export function parseDurationMs(raw: string, fallback: number): number { const m = /^(-?\d*\.?\d+)(ms|s)$/.exec(raw.trim()); if (!m) return fallback; const n = parseFloat(m[1]); return m[2] === "s" ? n * 1000 : n; } /** A duration token (e.g. "--duration-quick") from :root, in ms. */ export function readDurationMs(token: string, fallback: number): number { if (typeof window === "undefined") return fallback; const raw = getComputedStyle(document.documentElement).getPropertyValue( token, ); return parseDurationMs(raw, fallback); } /** How long to keep a closing surface mounted: its exit token, or 0 under reduced motion. */ export function exitMs(token = "--duration-quick", fallback = 150): number { return prefersReducedMotion() ? 0 : readDurationMs(token, fallback); } export type PresenceState = "open" | "closing" | "closed"; /** * Keeps a surface mounted long enough to play its exit. Render it while the state isn't * "closed", put `data-state={state}` on it, and make it inert while "closing". Reopening during * the exit goes straight back to "open", so CSS transitions reverse from the live on-screen value * instead of jumping (interruptible, unlike keyframes). */ export function usePresence( open: boolean, token = "--duration-quick", fallback = 150, ): PresenceState { const [phase, setPhase] = useState(open ? "open" : "closed"); // Derived during render (React's sanctioned pattern), so there is never a frame where an // open surface is unmounted or a closing one has already vanished. if (open && phase !== "open") setPhase("open"); if (!open && phase === "open") setPhase("closing"); useEffect(() => { if (phase !== "closing") return; const t = window.setTimeout( () => setPhase("closed"), exitMs(token, fallback), ); return () => window.clearTimeout(t); }, [phase, token, fallback]); if (open) return "open"; return phase === "open" ? "closing" : phase; }