| | 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", |
| | }; |
| |
|
| | |
| | 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<HTMLDivElement>(null); |
| | const [isAnimating, setIsAnimating] = useState(false); |
| | const [shouldRender, setShouldRender] = useState(false); |
| |
|
| | useEffect(() => { |
| | if (isOpen) { |
| | setShouldRender(true); |
| | |
| | const timer = setTimeout(() => setIsAnimating(true), 10); |
| | return () => clearTimeout(timer); |
| | } else { |
| | setIsAnimating(false); |
| | |
| | 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; |
| |
|
| | |
| | if (e.shiftKey) { |
| | if (document.activeElement === firstFocusable) { |
| | e.preventDefault(); |
| | lastFocusable.focus(); |
| | } |
| | } |
| | |
| | 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 = ( |
| | <div |
| | className={cn( |
| | "fixed inset-0 z-50 flex items-center justify-center p-4 transition-all duration-200", |
| | isAnimating ? "opacity-100" : "opacity-0" |
| | )} |
| | onClick={handleBackdropClick} |
| | > |
| | {/* Backdrop */} |
| | <div |
| | className={cn( |
| | "absolute inset-0 bg-black/50 transition-opacity duration-200", |
| | isAnimating ? "opacity-100" : "opacity-0" |
| | )} |
| | /> |
| | |
| | {/* Modal */} |
| | <div |
| | ref={modalRef} |
| | tabIndex={-1} |
| | className={cn( |
| | "relative w-full rounded-lg border border-neutral-200 bg-white shadow-xl transition-all duration-200 dark:border-gray-700", |
| | "dark:bg-gray-900", |
| | "flex max-h-[90vh] flex-col", |
| | sizeStyles[size], |
| | isAnimating ? "scale-100 opacity-100" : "scale-95 opacity-0", |
| | className |
| | )} |
| | role="dialog" |
| | aria-modal="true" |
| | aria-labelledby={title ? "modal-title" : undefined} |
| | > |
| | {/* Header */} |
| | {(title || showCloseButton) && ( |
| | <div className="flex flex-shrink-0 items-center justify-between border-b border-neutral-200 p-4 dark:border-gray-700"> |
| | {title && ( |
| | <h2 |
| | id="modal-title" |
| | className="text-lg font-semibold text-neutral-900 dark:text-gray-100" |
| | > |
| | {title} |
| | </h2> |
| | )} |
| | {showCloseButton && ( |
| | <button |
| | onClick={onClose} |
| | className="ml-auto cursor-pointer rounded-full p-1 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-600 dark:hover:bg-gray-800 dark:hover:text-gray-300" |
| | aria-label="Close modal" |
| | > |
| | <X size={20} /> |
| | </button> |
| | )} |
| | </div> |
| | )} |
| | |
| | {/* Content */} |
| | <div |
| | className={cn( |
| | "min-h-0 flex-1 overflow-y-auto", |
| | title || showCloseButton ? "p-4" : "p-0" |
| | )} |
| | > |
| | {children} |
| | </div> |
| | </div> |
| | </div> |
| | ); |
| |
|
| | return createPortal(modalContent, document.body); |
| | } |
| |
|