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
| 22 | 22 | export function VoidInvoiceDialog({ open, invoiceId, onClose, onConfirm }: Props) { | |
| 23 | + | const dialogRef = useRef<HTMLDivElement>(null); | |
| 24 | + | const previouslyFocused = useRef<HTMLElement | null>(null); | |
| 23 | 25 | ||
| 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]); | |
| 24 | 47 | ||
| 25 | 48 | 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()}> | |
| 28 | 52 | <h2 id="void-title">Void invoice {invoiceId}?</h2> | |
| 29 | 53 | <button type="button" onClick={onClose}>Cancel</button> | |
| 30 | 54 | <button type="button" className="danger" onClick={onConfirm}>Void</button> | |
| 31 | 55 | </div> | |
| 32 | - | </div> | |
| 33 | - | ); | |
| 56 | + | </div>, | |
| 57 | + | document.body, | |
| 58 | + | ); | |
| 34 | 59 | } |