import { useEffect, useRef, useState } from "preact/hooks"; import type { ComponentChildren } from "preact"; type ModalAction = { label: string; onClick: () => void; variant?: "primary" | "secondary" | "link"; }; type ModalProps = { title?: string; /** * Called after the modal has finished its exit animation. * The Modal will run the exit animation internally and then call onClose(). */ onClose: () => void; children: ComponentChildren; ariaLabel?: string; actions?: ModalAction[]; // rendered in footer; each button will be given flex-1 so buttons fill the width together /** * Optional: duration (ms) of enter/exit animation. Defaults to 200. * Keep this in sync with the CSS transition-duration classes used below. */ animationDurationMs?: number; }; export function Modal({ title, onClose, children, ariaLabel, actions, animationDurationMs = 200, }: ModalProps) { // Controls the entrance/exit animation state. true => visible (enter), false => hidden (exit) const [isVisible, setIsVisible] = useState(false); // Prevent double-triggering the close flow const closingRef = useRef(false); // Hold the timeout id for cleanup const timeoutRef = useRef(null); useEffect(() => { // Defer to next frame so initial "hidden" styles are applied before animating to visible. const raf = requestAnimationFrame(() => setIsVisible(true)); function onKey(e: KeyboardEvent) { if (e.key === "Escape") { startCloseFlow(); } } document.addEventListener("keydown", onKey); return () => { cancelAnimationFrame(raf); document.removeEventListener("keydown", onKey); if (timeoutRef.current !== null) { clearTimeout(timeoutRef.current); } }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // If no actions provided, render a single Close button const footerActions: ModalAction[] = actions && actions.length > 0 ? actions : [{ label: "Close", onClick: startCloseFlow, variant: "primary" }]; // Start exit animation and call onClose after animationDurationMs function startCloseFlow() { if (closingRef.current) return; closingRef.current = true; setIsVisible(false); // Wait for the CSS transition to finish before signalling parent to actually unmount timeoutRef.current = window.setTimeout(() => { timeoutRef.current = null; onClose(); }, animationDurationMs); } // Animation classes (enter & exit): // - panel: transitions opacity + transform for a subtle fade + pop // - backdrop: transitions opacity for fade const panelBase = "relative z-10 max-w-lg w-full bg-white dark:bg-[#1f2937] rounded-lg shadow-xl p-6 mx-4 transform transition-all"; // We explicitly set the CSS transition duration inline to keep class + timeout in sync. const panelVisible = "opacity-100 translate-y-0 scale-100"; const panelHidden = "opacity-0 translate-y-2 scale-95"; const backdropBase = "absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity"; const backdropVisible = "opacity-100"; const backdropHidden = "opacity-0"; // Footer button class generator const renderActionButton = (act: ModalAction) => { const base = "flex-1 w-full px-4 py-2 rounded-md font-semibold focus:outline-none"; const styles = act.variant === "primary" ? "bg-[#94e2d5] text-black hover:brightness-95" : act.variant === "link" ? "bg-transparent text-[#075985] underline" : "bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 hover:brightness-95"; return ( ); }; return (