/** * Presence (web). Mount/unmount transition primitive — keeps children in * the tree while they animate out, then actually unmounts. Every overlay * in the library used to `if (!open) return null`, which meant nothing * ever animated its exit; this is the missing half of that story. * * {panel} * {panel} * queue.shift()}>{toast} * * Animates via WAAPI (element.animate) with the motion tokens — enter * defaults to motion.overlayEnter, exit to motion.overlayExit; a `duration` * or `easing` prop overrides both directions. The wrapper div carries * data-state="open|closed" so CSS can hook either state. Reduced motion * collapses to a fast opacity-only fade. */ import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react"; import { motion, type MotionSpec } from "@plyxui/core"; import { useReducedMotion } from "@plyxui/hooks"; export interface PresenceFrom { opacity?: number; translateX?: number; translateY?: number; scale?: number; } export interface PresenceProps { /** Logical mounted state. Children stay in the DOM while exiting. */ present: boolean; /** Hidden pose the content enters from and exits to. Default `{ opacity: 0, translateY: 8 }`. */ from?: PresenceFrom; /** Override duration (ms) for both enter and exit. Defaults to the motion tokens. */ duration?: number; /** Override easing (CSS string) for both enter and exit. */ easing?: string; /** Fires after the exit animation finishes and children unmount. */ onExitComplete?: () => void; className?: string; style?: CSSProperties; children?: ReactNode; } const DEFAULT_FROM: PresenceFrom = { opacity: 0, translateY: 8 }; function poseToKeyframe(from: PresenceFrom): Keyframe { const transforms: string[] = []; if (from.translateX !== undefined || from.translateY !== undefined) { transforms.push(`translate(${from.translateX ?? 0}px, ${from.translateY ?? 0}px)`); } if (from.scale !== undefined) transforms.push(`scale(${from.scale})`); const kf: Keyframe = {}; if (from.opacity !== undefined) kf.opacity = from.opacity; if (transforms.length > 0) kf.transform = transforms.join(" "); return kf; } const IDENTITY: Keyframe = { opacity: 1, transform: "translate(0px, 0px) scale(1)" }; export function Presence({ present, from = DEFAULT_FROM, duration, easing, onExitComplete, className, style, children, }: PresenceProps) { const [mounted, setMounted] = useState(present); const ref = useRef(null); const animRef = useRef(null); const reduced = useReducedMotion(); // Latest callbacks/poses without re-triggering the effects. const latest = useRef({ from, duration, easing, onExitComplete, reduced }); latest.current = { from, duration, easing, onExitComplete, reduced }; if (present && !mounted) setMounted(true); const resolve = (base: MotionSpec): MotionSpec => { const l = latest.current; if (l.reduced) return { duration: 50, easing: "linear" }; return { duration: l.duration ?? base.duration, easing: l.easing ?? base.easing }; }; const hiddenPose = (): Keyframe => latest.current.reduced ? { opacity: 0 } : poseToKeyframe(latest.current.from); useLayoutEffect(() => { const el = ref.current; if (!present || !el) return; animRef.current?.cancel(); if (typeof el.animate !== "function") return; // SSR/jsdom: appear instantly const spec = resolve(motion.overlayEnter); animRef.current = el.animate([hiddenPose(), IDENTITY], { duration: spec.duration, easing: spec.easing, fill: "backwards", }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [present]); useEffect(() => { if (present || !mounted) return; const el = ref.current; const finish = () => { animRef.current = null; setMounted(false); latest.current.onExitComplete?.(); }; if (!el || typeof el.animate !== "function") { finish(); return; } animRef.current?.cancel(); const spec = resolve(motion.overlayExit); const anim = el.animate([IDENTITY, hiddenPose()], { duration: spec.duration, easing: spec.easing, fill: "forwards", }); animRef.current = anim; anim.onfinish = finish; return () => { anim.onfinish = null; anim.cancel(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [present, mounted]); if (!mounted) return null; return (
{children}
); }