"use client"; import { AnimatePresence, motion, useReducedMotion, } from "motion/react"; import { type ReactNode, useEffect } from "react"; import { EASE_OUT, SPRING_PANEL } from "@/lib/ease"; import { PresenceGate } from "@/lib/presence-gate"; import { cn } from "@/lib/utils"; export interface MorphingModalProps { /** Which view is currently shown. `null` closes the modal. */ viewId: string | null; onClose: () => void; children: ReactNode; /** "bottom" anchors to the viewport bottom (mobile-like). "center" centers vertically. */ placement?: "bottom" | "center"; className?: string; } export function MorphingModal({ viewId, onClose, children, placement = "bottom", className, }: MorphingModalProps) { const open = viewId !== null; const reduce = useReducedMotion(); const enterY = reduce ? 0 : placement === "bottom" ? 40 : 20; const enterScale = reduce ? 1 : 0.97; useEffect(() => { if (!open) return; const prev = document.body.style.overflow; document.body.style.overflow = "hidden"; return () => { document.body.style.overflow = prev; }; }, [open]); // Mounted only while open, and while open the chrome is two fixed siblings // rather than one wrapper: the backdrop spans the viewport edges but carries // the scrim colour, and the layer positioning the panel sits inset off every // edge (`inset-4`, with the bottom placement's `pb-4` on top of it). Both hang // off `PresenceGate`, so interaction releases in the same commit that starts // the exit rather than when it ends — `open` is already false for those // frames. See tests/fixed-overlay-edge-sampling.test.tsx. return ( {open ? ( {({ gate }) => ( )} ) : null} {open ? ( {({ isPresent, gate }) => ( // The layer itself never takes pointer events, so it carries // `inert` alone rather than the gate's pointer-events value.
{children}
)}
) : null}
); }