diff --git a/ui/src/components/shared/ConfirmModal.tsx b/ui/src/components/shared/ConfirmModal.tsx index 28f1851..101008d 100644 --- a/ui/src/components/shared/ConfirmModal.tsx +++ b/ui/src/components/shared/ConfirmModal.tsx @@ -1,5 +1,5 @@ import type React from "react"; -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; // Use the same CSS as the light-table's ConfirmModal for consistency import "../../features/light-table/components/ConfirmModal.css"; @@ -16,21 +16,78 @@ interface ConfirmModalProps { /** * Global confirmation modal component. * Can be cancelled with ESC key or overlay click. + * Implements focus management for accessibility. */ export const ConfirmModal: React.FC = ({ message, onConfirm, onCancel, }) => { - // Cancel with ESC key + const dialogRef = useRef(null); + const previousFocusRef = useRef(null); + + // Focus management: Trap Tab, ESC handler, and focus restore useEffect(() => { + // Save current focus to restore on unmount + previousFocusRef.current = document.activeElement; + + // Focus the dialog or first button on mount + if (dialogRef.current) { + const focusableElements = dialogRef.current.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ); + if (focusableElements.length > 0) { + (focusableElements[0] as HTMLElement).focus(); + } else { + dialogRef.current.focus(); + } + } + const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { onCancel(); + return; + } + + if (e.key === "Tab") { + if (!dialogRef.current) return; + + const focusableElements = + dialogRef.current.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ); + if (focusableElements.length === 0) return; + + const firstElement = focusableElements[0]; + const lastElement = focusableElements[focusableElements.length - 1]; + + if (e.shiftKey) { + // Shift + Tab: Wrap from first to last + if (document.activeElement === firstElement) { + lastElement.focus(); + e.preventDefault(); + } + } else { + // Tab: Wrap from last to first + if (document.activeElement === lastElement) { + firstElement.focus(); + e.preventDefault(); + } + } } }; + document.addEventListener("keydown", handleKeyDown); - return () => document.removeEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("keydown", handleKeyDown); + // Restore focus + if ( + previousFocusRef.current && + typeof (previousFocusRef.current as HTMLElement).focus === "function" + ) { + (previousFocusRef.current as HTMLElement).focus(); + } + }; }, [onCancel]); return ( @@ -43,12 +100,14 @@ export const ConfirmModal: React.FC = ({ role="presentation" >
e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()} role="alertdialog" aria-modal="true" aria-label={message} + tabIndex={-1} >

{message}