import { useEffect, useId, useRef, type RefObject } from "react"; const focusableSelector = [ "button:not([disabled])", "a[href]", "input:not([disabled])", "select:not([disabled])", "textarea:not([disabled])", "summary", "[tabindex]:not([tabindex='-1'])", ].join(","); function isVisibleFocusable(element: HTMLElement) { if (element.matches(":disabled") || element.closest("[hidden], [aria-hidden='true']")) return false; const closedDetails = element.closest("details:not([open])"); if (closedDetails) { const summary = closedDetails.querySelector(":scope > summary"); if (!summary?.contains(element)) return false; } const style = window.getComputedStyle(element); return style.display !== "none" && style.visibility !== "hidden" && element.getClientRects().length > 0; } interface DialogFocusResult { dialogRef: RefObject; titleId: string; } export function useDialogFocus(open: boolean, onClose: () => void): DialogFocusResult { const dialogRef = useRef(null); const titleId = useId(); useEffect(() => { if (!open) return; const dialog = dialogRef.current; if (!dialog) return; const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null; const focusable = () => [...dialog.querySelectorAll(focusableSelector)].filter(isVisibleFocusable); const first = focusable()[0] ?? dialog; const focusFrame = window.requestAnimationFrame(() => first.focus()); const inertedBranches: Array<{ element: HTMLElement; wasInert: boolean }> = []; let activeBranch: HTMLElement = dialog; while (activeBranch.parentElement) { const parent = activeBranch.parentElement; for (const sibling of parent.children) { if (!(sibling instanceof HTMLElement) || sibling === activeBranch) continue; inertedBranches.push({ element: sibling, wasInert: sibling.inert }); sibling.inert = true; } activeBranch = parent; if (parent === document.body) break; } const onKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { event.preventDefault(); onClose(); return; } if (event.key !== "Tab") return; const items = focusable(); if (items.length === 0) { event.preventDefault(); dialog.focus(); return; } const firstItem = items[0]; const lastItem = items[items.length - 1]; if (event.shiftKey && document.activeElement === firstItem) { event.preventDefault(); lastItem.focus(); } else if (!event.shiftKey && document.activeElement === lastItem) { event.preventDefault(); firstItem.focus(); } }; const onFocusIn = (event: FocusEvent) => { if (event.target instanceof Node && dialog.contains(event.target)) return; window.requestAnimationFrame(() => (focusable()[0] ?? dialog).focus()); }; document.addEventListener("keydown", onKeyDown); document.addEventListener("focusin", onFocusIn); return () => { window.cancelAnimationFrame(focusFrame); document.removeEventListener("keydown", onKeyDown); document.removeEventListener("focusin", onFocusIn); for (const { element, wasInert } of inertedBranches.reverse()) element.inert = wasInert; window.requestAnimationFrame(() => previousFocus?.focus()); }; }, [onClose, open]); return { dialogRef, titleId }; }