"use client"; import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react"; import { XIcon } from "lucide-react"; import { type KeyboardEvent, type ReactNode, useEffect, useId, useLayoutEffect, useRef, useState } from "react"; import { cn } from "@/lib/utils"; export interface LiftToOpenItem { id: string; title: string; meta?: string; cover: string; art?: ReactNode; details: ReactNode; } export interface LiftToOpenProps { items: LiftToOpenItem[]; className?: string; label?: string; } const frameSpring = { type: "spring", duration: 0.46, bounce: 0.12 } as const; const resting = "0 1px 2px rgb(0 0 0 / 0.05)"; const hover = "0 8px 20px -8px rgb(0 0 0 / 0.18), 0 1px 2px rgb(0 0 0 / 0.05)"; const raised = "0 24px 48px -16px rgb(0 0 0 / 0.28), 0 2px 6px rgb(0 0 0 / 0.06)"; function useCalmMotion() { const prefers = useReducedMotion(); const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); return mounted && !!prefers; } function useThemeRadius(scale: number) { const probe = useRef(null); const [radius, setRadius] = useState(scale * 10); useLayoutEffect(() => { const el = probe.current; if (!el) return; const read = () => setRadius(el.offsetWidth); read(); const observer = new ResizeObserver(read); observer.observe(el); return () => observer.disconnect(); }, []); const marker = ( ); return [radius, marker] as const; } function Cover({ item, scope, shared, radius, className, }: { item: LiftToOpenItem; scope: string; shared: boolean; radius: number; className?: string; }) { return ( {item.art && ( {item.art} )} ); } function Heading({ item, scope, shared, size }: { item: LiftToOpenItem; scope: string; shared: boolean; size: "sm" | "lg" }) { return ( {item.title} {item.meta && {item.meta}} ); } export function LiftToOpen({ items, className, label = "Gallery" }: LiftToOpenProps) { const scope = useId(); const reduce = useCalmMotion(); const [radius, radiusProbe] = useThemeRadius(1); const shared = !reduce; const [openId, setOpenId] = useState(null); const [closing, setClosing] = useState(false); const [lifted, setLifted] = useState(null); const thumbs = useRef(new Map()); const dialog = useRef(null); const closeButton = useRef(null); const returnTo = useRef(null); const timer = useRef | null>(null); const open = items.find((item) => item.id === openId) ?? null; useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []); useEffect(() => { if (open) closeButton.current?.focus({ preventScroll: true }); else if (returnTo.current) { thumbs.current.get(returnTo.current)?.focus({ preventScroll: true }); returnTo.current = null; } }, [open]); const show = (id: string) => { if (timer.current) clearTimeout(timer.current); setClosing(false); setLifted(id); setOpenId(id); }; const close = () => { if (!openId || closing) return; returnTo.current = openId; setClosing(true); timer.current = setTimeout(() => { setOpenId(null); setClosing(false); }, 90); }; const closeRef = useRef(close); useEffect(() => { closeRef.current = close; }); useEffect(() => { if (!openId) return; const outside = (event: PointerEvent) => { if (dialog.current && !dialog.current.contains(event.target as Node)) closeRef.current(); }; document.addEventListener("pointerdown", outside); return () => document.removeEventListener("pointerdown", outside); }, [openId]); const onKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { event.stopPropagation(); close(); return; } if (event.key !== "Tab" || !dialog.current) return; const focusable = [...dialog.current.querySelectorAll("button, a[href], input, select, textarea, [tabindex]:not([tabindex='-1'])")]; if (!focusable.length) return; const first = focusable[0]; const last = focusable[focusable.length - 1]; if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); } else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); } }; return (
{radiusProbe}
    {items.map((item) => (
  • {openId === item.id ? (
    {item.title}
    {item.meta}
    ) : ( show(item.id)} onLayoutAnimationComplete={() => setLifted((id) => (id === item.id ? null : id))} ref={(el) => { if (el) thumbs.current.set(item.id, el); else thumbs.current.delete(item.id); }} style={{ borderRadius: radius * 1.6 }} transition={{ ...frameSpring, boxShadow: { duration: 0.3, ease: "easeOut" } }} type="button" whileHover={reduce ? undefined : { y: -2, boxShadow: hover }} whileTap={reduce ? undefined : { scale: 0.97 }} >
    )}
  • ))}
{open && ( )} {open && (
{open.details}
)}
); }