Gankit12's picture
All Code
b64de39
Raw
History Blame Contribute Delete
5.19 kB
/**
* Modal - Accessible dialog overlay with animation.
*
* Uses a portal rendered into document.body. Locks background
* scroll when open. Closes on Escape key and optional backdrop click.
* Animated with framer-motion for enter/exit transitions.
*/
import { useEffect, useCallback, useRef } from "react";
import { createPortal } from "react-dom";
// eslint-disable-next-line no-unused-vars -- motion is used via <motion.div>
import { motion, AnimatePresence } from "framer-motion";
import PropTypes from "prop-types";
const SIZE_CLASSES = {
sm: "max-w-sm",
md: "max-w-md",
lg: "max-w-lg",
xl: "max-w-xl",
full: "max-w-4xl",
};
const BACKDROP_VARIANTS = {
hidden: { opacity: 0 },
visible: { opacity: 1 },
};
const PANEL_VARIANTS = {
hidden: { opacity: 0, y: 20, scale: 0.97 },
visible: { opacity: 1, y: 0, scale: 1 },
};
function Modal({
isOpen,
onClose,
title,
children,
footer,
size = "md",
closeOnBackdrop = true,
showCloseButton = true,
className = "",
}) {
const panelRef = useRef(null);
// Lock body scroll when modal is open
useEffect(() => {
if (isOpen) {
document.body.style.overflow = "hidden";
}
return () => {
document.body.style.overflow = "";
};
}, [isOpen]);
// Close on Escape key
const handleKeyDown = useCallback(
(event) => {
if (event.key === "Escape" && onClose) {
onClose();
}
},
[onClose],
);
useEffect(() => {
if (isOpen) {
document.addEventListener("keydown", handleKeyDown);
}
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, handleKeyDown]);
// Focus trap: focus the panel when it opens
useEffect(() => {
if (isOpen && panelRef.current) {
panelRef.current.focus();
}
}, [isOpen]);
const handleBackdropClick = () => {
if (closeOnBackdrop && onClose) {
onClose();
}
};
const content = (
<AnimatePresence>
{isOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<motion.div
className="absolute inset-0 bg-black/50"
variants={BACKDROP_VARIANTS}
initial="hidden"
animate="visible"
exit="hidden"
transition={{ duration: 0.2 }}
onClick={handleBackdropClick}
aria-hidden="true"
/>
{/* Panel */}
<motion.div
ref={panelRef}
className={[
"relative w-full rounded-xl bg-white shadow-xl",
"focus:outline-none",
SIZE_CLASSES[size] || SIZE_CLASSES.md,
className,
].join(" ")}
variants={PANEL_VARIANTS}
initial="hidden"
animate="visible"
exit="hidden"
transition={{ duration: 0.2, ease: "easeOut" }}
role="dialog"
aria-modal="true"
aria-label={title || "Dialog"}
tabIndex={-1}
>
{/* Header */}
{(title || showCloseButton) && (
<div className="flex items-center justify-between border-b border-neutral-200 px-6 py-4">
{title && (
<h2 className="text-lg font-semibold text-neutral-900">
{title}
</h2>
)}
{showCloseButton && (
<button
type="button"
onClick={onClose}
className="rounded-lg p-1 text-neutral-400 hover:text-neutral-600 hover:bg-neutral-100 transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500"
aria-label="Close dialog"
>
<svg
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
)}
</div>
)}
{/* Body */}
<div className="px-6 py-4">{children}</div>
{/* Footer */}
{footer && (
<div className="flex items-center justify-end gap-3 border-t border-neutral-200 px-6 py-4 bg-neutral-50 rounded-b-xl">
{footer}
</div>
)}
</motion.div>
</div>
)}
</AnimatePresence>
);
return createPortal(content, document.body);
}
Modal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
title: PropTypes.string,
children: PropTypes.node.isRequired,
footer: PropTypes.node,
size: PropTypes.oneOf(["sm", "md", "lg", "xl", "full"]),
closeOnBackdrop: PropTypes.bool,
showCloseButton: PropTypes.bool,
className: PropTypes.string,
};
export default Modal;