import React, { useEffect, useRef } from "react";
import "./AccessibleModal.css";
const FOCUSABLE =
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])';
/**
* AccessibleModal
* - open: boolean
* - onClose: callback
* - title: heading text (required for accessible name)
* - returnFocusTo: element ref whose .focus() runs on close (optional;
* defaults to whatever had focus when opened)
* - children: modal body
*/
export default function AccessibleModal({
open,
onClose,
title,
returnFocusTo,
children,
}) {
const panelRef = useRef(null);
const prevActive = useRef(null);
// Remember what had focus when opening; move focus into the modal
useEffect(() => {
if (!open) return;
prevActive.current = document.activeElement;
const first = panelRef.current?.querySelector(FOCUSABLE);
first?.focus();
}, [open]);
// Return focus on close
useEffect(() => {
if (open) return;
const target = returnFocusTo?.current ?? prevActive.current;
target?.focus?.();
}, [open, returnFocusTo]);
// Focus trap + Escape
useEffect(() => {
if (!open) return;
function onKey(e) {
if (e.key === "Escape") { e.preventDefault(); onClose(); return; }
if (e.key !== "Tab") return;
const nodes = panelRef.current?.querySelectorAll(FOCUSABLE);
if (!nodes || nodes.length === 0) return;
const first = nodes[0];
const last = nodes[nodes.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault(); last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault(); first.focus();
}
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onClose]);
if (!open) return null;
return (
<div
className="modal"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
className="modal__panel"
>
<h2 id="modal-title">{title}</h2>
{children}
</div>
</div>
);
}
import { useState } from "react";
import AccessibleModal from "./AccessibleModal";
export default function Demo() {
const [open, setOpen] = useState(false);
return (
<>
<button type="button" onClick={() => setOpen(true)}>Delete account</button>
<AccessibleModal
open={open}
onClose={() => setOpen(false)}
title="Delete account"
>
<p>This cannot be undone. Are you sure?</p>
<div className="modal__actions">
<button type="button" onClick={() => setOpen(false)}>Cancel</button>
<button type="button" onClick={handleDelete}>Yes, delete</button>
</div>
</AccessibleModal>
</>
);
}