"use client"; import { AnimatePresence, motion, useReducedMotion, } from "motion/react"; import { type ReactNode, useEffect, useId, useLayoutEffect, useRef, useState, } from "react"; import { EASE_OUT, SPRING_PANEL } from "@/lib/ease"; import { focusablesIn, useFocusTrap } from "@/lib/hooks/use-focus-trap"; 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"; /** * Accessible name for the dialog. Without it, the dialog is named by the * first heading in the current view. */ ariaLabel?: string; className?: string; } export function MorphingModal({ viewId, onClose, children, placement = "bottom", ariaLabel, className, }: MorphingModalProps) { const open = viewId !== null; const reduce = useReducedMotion(); const panelRef = useRef(null); const uid = useId(); const [headingId, setHeadingId] = useState(); // Focus moves into the panel on open, Tab stays inside it, Escape closes, // and focus returns to the opener on close. useFocusTrap(open, panelRef, { onEscape: onClose }); // The current view, not the one still fading out under it. const currentView = () => Array.from( panelRef.current?.querySelectorAll("[data-view]") ?? [], ).find((element) => element.dataset.view === viewId); // Name the dialog by the current view's first heading unless a label is set. useLayoutEffect(() => { if (!viewId || ariaLabel) return; const heading = currentView()?.querySelector( "h1, h2, h3, h4, h5, h6", ); if (heading && !heading.id) heading.id = `${uid}-${viewId}-title`; setHeadingId(heading?.id); }); // The focused control leaves with the old view, so a view change hands focus // to the first control of the new one. const shownView = useRef(viewId); useEffect(() => { const previous = shownView.current; shownView.current = viewId; if (!viewId || !previous || previous === viewId) return; const panel = panelRef.current; const active = document.activeElement; if (!panel || (active && active !== document.body && !panel.contains(active))) return; const view = currentView(); (focusablesIn(view ?? null)[0] ?? panel).focus({ preventScroll: true }); }); 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. 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.
{/* The motion element is the direct child so popLayout can take its ref and lift the outgoing view out of flow; otherwise both views stack for the exit and the panel briefly grows to their combined height. */} {({ isPresent: viewPresent }) => ( // The outgoing view stays for its exit; inert keeps // the trap and assistive technology on the incoming one.
{children}
)}
)}
) : null}
); }