diff --git a/apps/web/src/components/ui/Modal.tsx b/apps/web/src/components/ui/Modal.tsx index de42ec47..ed0c87e5 100644 --- a/apps/web/src/components/ui/Modal.tsx +++ b/apps/web/src/components/ui/Modal.tsx @@ -1,4 +1,4 @@ -import { useEffect, type ReactNode } from 'react'; +import { useEffect, useId, useRef, type ReactNode } from 'react'; import { createPortal } from 'react-dom'; import { cn } from '../../lib/utils'; @@ -11,17 +11,58 @@ export interface ModalProps { className?: string; } +const FOCUSABLE_SELECTOR = + 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'; + export function Modal({ open, onClose, title, children, footer, className }: ModalProps) { + const titleId = useId(); + const dialogRef = useRef(null); + useEffect(() => { if (!open) return; - const handleEscape = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose(); + // Remember what had focus so we can restore it when the dialog closes. + const previouslyFocused = document.activeElement as HTMLElement | null; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose(); + return; + } + if (e.key !== 'Tab') return; + // Trap focus inside the dialog so Tab can't reach the page behind it. + const root = dialogRef.current; + if (!root) return; + const focusable = Array.from(root.querySelectorAll(FOCUSABLE_SELECTOR)).filter( + (el) => el.offsetParent !== null, + ); + if (focusable.length === 0) { + e.preventDefault(); + root.focus(); + return; + } + const first = focusable[0]!; + const last = focusable[focusable.length - 1]!; + const active = document.activeElement; + if (e.shiftKey && (active === first || !root.contains(active))) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && active === last) { + e.preventDefault(); + first.focus(); + } }; - document.addEventListener('keydown', handleEscape); + + document.addEventListener('keydown', handleKeyDown); document.body.style.overflow = 'hidden'; + + // Move focus into the dialog (first field, or the dialog itself). + const root = dialogRef.current; + (root?.querySelector(FOCUSABLE_SELECTOR) ?? root)?.focus(); + return () => { - document.removeEventListener('keydown', handleEscape); + document.removeEventListener('keydown', handleKeyDown); document.body.style.overflow = ''; + previouslyFocused?.focus?.(); }; }, [open, onClose]); @@ -32,18 +73,20 @@ export function Modal({ open, onClose, title, children, footer, className }: Mod className="fixed inset-0 z-10050 flex items-center justify-center p-4" role="dialog" aria-modal="true" - aria-labelledby="modal-title" + aria-labelledby={titleId} >
e.stopPropagation()} >
-

{title}