import cn from "@utils/classnames"; import { X } from "lucide-react"; import { type ReactNode, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; interface ModalProps { isOpen: boolean; onClose: () => void; title?: string; children: ReactNode; className?: string; size?: "sm" | "md" | "lg" | "xl" | "full"; showCloseButton?: boolean; } const sizeStyles = { sm: "max-w-sm", md: "max-w-md", lg: "max-w-xl", xl: "max-w-3xl", full: "max-w-full md:mx-4", }; // Get all focusable elements within a container const getFocusableElements = (container: HTMLElement): HTMLElement[] => { const focusableSelectors = [ "a[href]", "button:not([disabled])", "textarea:not([disabled])", "input:not([disabled])", "select:not([disabled])", '[tabindex]:not([tabindex="-1"])', ].join(", "); return Array.from(container.querySelectorAll(focusableSelectors)); }; export default function Modal({ isOpen, onClose, title, children, className = "", size = "md", showCloseButton = true, }: ModalProps) { const modalRef = useRef(null); const [isAnimating, setIsAnimating] = useState(false); const [shouldRender, setShouldRender] = useState(false); useEffect(() => { if (isOpen) { setShouldRender(true); // Start animation after render const timer = setTimeout(() => setIsAnimating(true), 10); return () => clearTimeout(timer); } else { setIsAnimating(false); // Remove from DOM after animation completes const timer = setTimeout(() => setShouldRender(false), 200); return () => clearTimeout(timer); } }, [isOpen]); useEffect(() => { const handleEscape = (e: KeyboardEvent) => { if (e.key === "Escape") { onClose(); } }; if (isOpen) { document.addEventListener("keydown", handleEscape); document.body.style.overflow = "hidden"; } return () => { document.removeEventListener("keydown", handleEscape); document.body.style.overflow = "unset"; }; }, [isOpen, onClose]); useEffect(() => { if (!isAnimating || !modalRef.current) return; const modal = modalRef.current; const focusableElements = getFocusableElements(modal); if (focusableElements.length === 0) { modal.focus(); return; } const firstFocusable = focusableElements[0]; const lastFocusable = focusableElements[focusableElements.length - 1]; firstFocusable.focus(); const handleTabKey = (e: KeyboardEvent) => { if (e.key !== "Tab") return; // Shift + Tab (backwards) if (e.shiftKey) { if (document.activeElement === firstFocusable) { e.preventDefault(); lastFocusable.focus(); } } // Tab (forwards) else { if (document.activeElement === lastFocusable) { e.preventDefault(); firstFocusable.focus(); } } }; modal.addEventListener("keydown", handleTabKey); return () => { modal.removeEventListener("keydown", handleTabKey); }; }, [isAnimating]); const handleBackdropClick = (e: React.MouseEvent) => { if (e.target === e.currentTarget) { onClose(); } }; if (!shouldRender) return null; const modalContent = (
{/* Backdrop */}
{/* Modal */}
{/* Header */} {(title || showCloseButton) && (
{title && ( )} {showCloseButton && ( )}
)} {/* Content */}
{children}
); return createPortal(modalContent, document.body); }