/** * @file useDialog.ts * @description The one modal focus contract, shared by every dialog: focus * moves in on open (the `initialFocus` element, else the panel's first * focusable), Tab and Shift+Tab cycle inside the panel, Escape calls `onClose`, * and focus returns to whatever held it before the dialog opened. Extracted * from `ConfirmModal` so the other modals stop re-implementing (or skipping) it. * Dialogs STACK: only the most recently opened one answers Escape and Tab, so * a confirm opened over the sidebar drawer closes alone and traps focus alone. * @author Nguyễn Ngọc Trí Vĩ */ import { useEffect, useRef, type RefObject } from "react"; /** Open dialogs, oldest first; only the last one handles keys. */ const stack: object[] = []; const FOCUSABLE = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; /** * Wire focus-in, a Tab trap, Escape and focus restore to a dialog panel. * @param open Whether the dialog is shown. * @param panelRef The dialog panel (the element carrying `role="dialog"`). * @param onClose Called on Escape. Pass `undefined` for a dialog Escape must not close. * @param initialFocus Element to focus on open; defaults to the first focusable. */ export function useDialog( open: boolean, panelRef: RefObject, onClose?: () => void, initialFocus?: RefObject ): void { // Read through a ref so an inline `onClose` does not re-run the effect, which // would steal focus back to the first control on every parent render. const closeRef = useRef(onClose); closeRef.current = onClose; useEffect(() => { if (!open) return; const token = {}; stack.push(token); const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null; const timer = window.setTimeout(() => { const panel = panelRef.current; if (!panel || panel.contains(document.activeElement)) return; (initialFocus?.current ?? panel.querySelector(FOCUSABLE) ?? panel).focus(); }, 0); const onKey = (e: KeyboardEvent) => { const panel = panelRef.current; if (!panel || stack[stack.length - 1] !== token) return; if (e.key === "Escape" && closeRef.current) { e.preventDefault(); e.stopPropagation(); closeRef.current(); return; } if (e.key !== "Tab") return; const items = panel.querySelectorAll(FOCUSABLE); if (items.length === 0) return; const first = items[0]!; const last = items[items.length - 1]!; if ( e.shiftKey && (document.activeElement === first || !panel.contains(document.activeElement)) ) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } }; document.addEventListener("keydown", onKey); return () => { window.clearTimeout(timer); document.removeEventListener("keydown", onKey); stack.splice(stack.indexOf(token), 1); previous?.focus?.(); }; // eslint-disable-next-line react-hooks/exhaustive-deps -- refs are stable }, [open]); }