feat: implement focus management and trap in ConfirmModal

This commit is contained in:
HappyOnigiri
2026-03-01 09:57:40 +09:00
parent ed41f12a35
commit d02c19d9e4
+62 -3
View File
@@ -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<ConfirmModalProps> = ({
message,
onConfirm,
onCancel,
}) => {
// Cancel with ESC key
const dialogRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<Element | null>(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<HTMLElement>(
'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<ConfirmModalProps> = ({
role="presentation"
>
<div
ref={dialogRef}
className="meld-confirm-modal__dialog"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
role="alertdialog"
aria-modal="true"
aria-label={message}
tabIndex={-1}
>
<p className="meld-confirm-modal__message">{message}</p>
<div className="meld-confirm-modal__actions">