Spaces:
Sleeping
Sleeping
| /** | |
| * 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; | |