From 9e9f5e5f1457c8c48dc2f9bf7c261cb695d258bb Mon Sep 17 00:00:00 2001 From: AYo101o <107185167+AYo101o@users.noreply.github.com> Date: Fri, 20 Feb 2026 18:11:01 +0000 Subject: [PATCH] feat: add reusable Modal component with ARIA accessibility --- frontend/src/components/ui/modal.tsx | 229 +++++++++++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 frontend/src/components/ui/modal.tsx diff --git a/frontend/src/components/ui/modal.tsx b/frontend/src/components/ui/modal.tsx new file mode 100644 index 0000000..38469a0 --- /dev/null +++ b/frontend/src/components/ui/modal.tsx @@ -0,0 +1,229 @@ +"use client"; + +import React, { useEffect, useRef, useCallback } from "react"; +import { X } from "lucide-react"; + +interface ModalProps { + /** Controls modal visibility */ + isOpen: boolean; + /** Called when modal should close (backdrop click, X button, Escape key) */ + onClose: () => void; + /** Modal heading shown in the header */ + title: string; + /** Optional subheading shown below the title */ + subtitle?: string; + /** Main content of the modal */ + children: React.ReactNode; + /** Optional footer content (e.g. action buttons) */ + footer?: React.ReactNode; + /** Max width of the modal. Defaults to 'md' */ + size?: "sm" | "md" | "lg" | "xl"; + /** Prevent closing when clicking the backdrop */ + disableBackdropClose?: boolean; +} + +const sizeMap: Record, string> = { + sm: "max-w-sm", + md: "max-w-md", + lg: "max-w-lg", + xl: "max-w-xl", +}; + +export const Modal: React.FC = ({ + isOpen, + onClose, + title, + subtitle, + children, + footer, + size = "md", + disableBackdropClose = false, +}) => { + const modalRef = useRef(null); + const previousFocusRef = useRef(null); + + // Save focus and restore on close + useEffect(() => { + if (isOpen) { + previousFocusRef.current = document.activeElement as HTMLElement; + // Focus the modal panel after open + setTimeout(() => modalRef.current?.focus(), 0); + } else { + previousFocusRef.current?.focus(); + } + }, [isOpen]); + + // Trap focus within the modal + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + onClose(); + return; + } + + if (e.key !== "Tab") return; + + const focusable = modalRef.current?.querySelectorAll( + 'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])' + ); + + if (!focusable || focusable.length === 0) return; + + 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(); + } + } + }, + [onClose] + ); + + const handleBackdropClick = (e: React.MouseEvent) => { + if (!disableBackdropClose && e.target === e.currentTarget) { + onClose(); + } + }; + + if (!isOpen) return null; + + return ( +
+
+ {/* Top accent line */} +
+ + {/* Header */} +
+
+ + {subtitle && ( + + )} +
+ + {/* Close button */} + +
+ + {/* Body */} +
{children}
+ + {/* Footer */} + {footer && ( +
+ {footer} +
+ )} +
+
+ ); +}; + +export default Modal; + + +// ─── USAGE EXAMPLE ─────────────────────────────────────────────────────────── +// +// import { useState } from "react"; +// import { Modal } from "@/components/ui/Modal"; +// +// export function ExamplePage() { +// const [open, setOpen] = useState(false); +// +// return ( +// <> +// +// +// setOpen(false)} +// title="Confirm Action" +// subtitle="This action cannot be undone." +// size="md" +// footer={ +//
+// +// +//
+// } +// > +//

Your modal content goes here.

+//
+// +// ); +// } \ No newline at end of file