import { createContext, useContext, useEffect, useRef, useState } from "react"; import { type PanelAnimationDurationMs } from "@t3tools/contracts/settings"; import { useMediaQuery } from "./hooks/useMediaQuery"; import { useClientSettings } from "./hooks/useSettings"; const PanelAnimationSuppressionContext = createContext(false); export const PanelAnimationSuppressionProvider = PanelAnimationSuppressionContext.Provider; /** * Suppresses panel motion for the first painted frame of an initial route or navigation. * State restored by a route must be visible immediately; later user actions can animate. */ export function usePanelNavigationSuppression(navigationKey: string): boolean { const [paintedNavigationKey, setPaintedNavigationKey] = useState(null); const suppressed = paintedNavigationKey !== navigationKey; useEffect(() => { if (!suppressed) return; let releaseFrame = 0; const paintFrame = window.requestAnimationFrame(() => { releaseFrame = window.requestAnimationFrame(() => setPaintedNavigationKey(navigationKey)); }); return () => { window.cancelAnimationFrame(paintFrame); window.cancelAnimationFrame(releaseFrame); }; }, [navigationKey, suppressed]); return suppressed; } export function observeResponsiveBreakpointFade(options: { target: HTMLElement; container: HTMLElement; active: boolean; durationMs: PanelAnimationDurationMs; breakpoint: { value: number; unit: "px" | "rem" }; }): () => void { const { target, container, active, durationMs, breakpoint } = options; if (!active || typeof ResizeObserver === "undefined") return () => {}; const rootFontSize = Number.parseFloat(getComputedStyle(document.documentElement).fontSize); const breakpointPx = breakpoint.unit === "px" ? breakpoint.value : breakpoint.value * (Number.isFinite(rootFontSize) ? rootFontSize : 16); let expanded = container.getBoundingClientRect().width >= breakpointPx; let animation: Animation | null = null; const observer = new ResizeObserver(([entry]) => { if (!entry) return; const nextExpanded = entry.contentRect.width >= breakpointPx; if (nextExpanded === expanded) return; expanded = nextExpanded; animation?.cancel(); animation = target.animate([{ opacity: 0 }, { opacity: 1 }], { duration: Math.min(100, durationMs), easing: "ease-out", }); }); observer.observe(container); return () => { observer.disconnect(); animation?.cancel(); }; } export function usePanelAnimationSettings(): { active: boolean; durationMs: PanelAnimationDurationMs; } { const durationMs = useClientSettings((settings) => settings.panelAnimationDurationMs); const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)"); const suppressed = useContext(PanelAnimationSuppressionContext); return { active: durationMs > 0 && !prefersReducedMotion && !suppressed, durationMs }; } /** Keeps closing panel content mounted until its opt-in transition ends. */ export function usePanelPresence( open: boolean, value: T | null, animated: boolean, scopeKey: string | null, durationMs: PanelAnimationDurationMs, ): { present: boolean; value: T | null } { const [present, setPresent] = useState(open); const retainedRef = useRef<{ scopeKey: string | null; value: T | null } | null>( open ? { scopeKey, value } : null, ); useEffect(() => { if (open) retainedRef.current = { scopeKey, value }; }, [open, scopeKey, value]); useEffect(() => { if (open) { setPresent(true); return; } if (!animated) { setPresent(false); return; } const timeout = window.setTimeout(() => setPresent(false), durationMs); return () => window.clearTimeout(timeout); }, [animated, durationMs, open]); const retainedValue = retainedRef.current?.scopeKey === scopeKey ? retainedRef.current.value : null; const visible = open || (animated && present && retainedRef.current?.scopeKey === scopeKey); return { present: visible, value: open ? value : visible ? retainedValue : null }; }