diff --git a/src/components/ui/Modal.tsx b/src/components/ui/Modal.tsx new file mode 100644 index 00000000..3309d920 --- /dev/null +++ b/src/components/ui/Modal.tsx @@ -0,0 +1,231 @@ +import { useEffect, useRef, useCallback, ReactNode } from "react"; + +type ModalSize = "sm" | "md" | "lg" | "xl"; + +interface ModalProps { + isOpen: boolean; + onClose: () => void; + title?: string; + children: ReactNode; + size?: ModalSize; +} + +const sizeClasses: Record = { + sm: "max-w-sm", + md: "max-w-md", + lg: "max-w-lg", + xl: "max-w-xl", +}; + +export function Modal({ + isOpen, + onClose, + title, + children, + size = "md", +}: ModalProps) { + const overlayRef = useRef(null); + const modalRef = useRef(null); + const closeBtnRef = useRef(null); + + // Lock body scroll + useEffect(() => { + if (isOpen) { + document.body.classList.add("overflow-hidden"); + } else { + document.body.classList.remove("overflow-hidden"); + } + return () => { + document.body.classList.remove("overflow-hidden"); + }; + }, [isOpen]); + + // Focus the close button when modal opens + useEffect(() => { + if (isOpen) { + // Small delay to allow CSS transitions to begin + const t = setTimeout(() => closeBtnRef.current?.focus(), 50); + return () => clearTimeout(t); + } + }, [isOpen]); + + // Escape key handler + useEffect(() => { + if (!isOpen) return; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + onClose(); + } + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [isOpen, onClose]); + + // Focus trap: cycle Tab / Shift+Tab within modal + const handleModalKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key !== "Tab" || !modalRef.current) return; + + const focusable = modalRef.current.querySelectorAll( + 'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])' + ); + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + + if (e.shiftKey) { + if (document.activeElement === first) { + e.preventDefault(); + last?.focus(); + } + } else { + if (document.activeElement === last) { + e.preventDefault(); + first?.focus(); + } + } + }, + [] + ); + + // Backdrop click + const handleOverlayClick = (e: React.MouseEvent) => { + if (e.target === overlayRef.current) { + onClose(); + } + }; + + return ( + <> + {/* Inject keyframe styles once */} + + + {/* We always render but toggle visibility so exit animations can play. + For simplicity (and because the spec focuses on the open state), + we skip the unmounting animation here and simply hide when closed. */} + {isOpen && ( + /* ── Backdrop ── */ +
+ {/* ── Modal panel ── */} +
+ {/* ── Header ── */} + {title && ( +
+ + +
+ )} + + {/* Close button when no title */} + {!title && ( + + )} + + {/* ── Body ── */} +
{children}
+
+
+ )} + + ); +} + +/* ── Close button sub-component ── */ +import { forwardRef } from "react"; + +interface CloseBtnProps { + onClick: () => void; + className?: string; +} + +const CloseButton = forwardRef( + ({ onClick, className = "" }, ref) => ( + + ) +); +CloseButton.displayName = "CloseButton"; + +export default Modal; \ No newline at end of file diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index c359a9e3..679d23bf 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -1 +1,2 @@ export { default as Badge } from "./Badge"; +export { Modal } from "./Modal"; \ No newline at end of file