feat: implement focus management and trap in ConfirmModal
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user