|
|
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); |
|
|
} |
|
|
|