Review

Accessible void-invoice confirm dialog

Finance ops kept losing keyboard focus behind the void-invoice overlay. Ports the confirm dialog, traps Tab inside it, closes on Escape, and restores focus to the Void trigger when dismissed.

ReactTier 2a11yfrontendreact

Click a line to flag it, pick one or more labels, then submit. If the change looks correct, approve it.

components/billing/VoidInvoiceDialog.tsx+29-4
2222export function VoidInvoiceDialog({ open, invoiceId, onClose, onConfirm }: Props) {
23+ const dialogRef = useRef<HTMLDivElement>(null);
24+ const previouslyFocused = useRef<HTMLElement | null>(null);
2325
26+ useEffect(() => {
27+ if (!open) return;
28+ previouslyFocused.current = document.activeElement as HTMLElement | null;
29+ dialogRef.current?.focus();
30+ const onKeyDown = (e: KeyboardEvent) => {
31+ if (e.key === "Escape") { onClose(); return; }
32+ if (e.key !== "Tab" || !dialogRef.current) return;
33+ const nodes = dialogRef.current.querySelectorAll<HTMLElement>(
34+ 'button,[href],input,select,textarea,[tabindex]:not([tabindex="-1"])',
35+ );
36+ const list = Array.from(nodes).filter((el) => !el.hasAttribute("disabled"));
37+ const [first, last] = [list[0], list[list.length - 1]];
38+ if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
39+ else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
40+ };
41+ document.addEventListener("keydown", onKeyDown);
42+ return () => {
43+ document.removeEventListener("keydown", onKeyDown);
44+ previouslyFocused.current?.focus();
45+ };
46+ }, [open, onClose]);
2447
2548 if (!open) return null;
26- return (
27- <div className="overlay">
49+ return createPortal(
50+ <div className="overlay" role="presentation" onClick={onClose}>
51+ <div ref={dialogRef} role="dialog" aria-modal="true" aria-labelledby="void-title" tabIndex={-1} onClick={(e) => e.stopPropagation()}>
2852 <h2 id="void-title">Void invoice {invoiceId}?</h2>
2953 <button type="button" onClick={onClose}>Cancel</button>
3054 <button type="button" className="danger" onClick={onConfirm}>Void</button>
3155 </div>
32- </div>
33- );
56+ </div>,
57+ document.body,
58+ );
3459}