"use client"; import { useEffect, useRef, type RefObject } from "react"; // Everything a dialog owes the keyboard, in one place so Modal and SlideOver // cannot drift apart. Both previously handled only Escape: focus stayed on // whatever was behind the overlay, Tab walked straight out of the dialog into // the page underneath, and closing left focus on — so the next Tab // started again from the top of the document. const FOCUSABLE = [ "a[href]", "button:not([disabled])", "input:not([disabled]):not([type='hidden'])", "select:not([disabled])", "textarea:not([disabled])", "[tabindex]:not([tabindex='-1'])", ].join(","); function focusableWithin(container: HTMLElement): HTMLElement[] { return [...container.querySelectorAll(FOCUSABLE)].filter( (el) => el.offsetParent !== null || el.getClientRects().length > 0 ); } export function useDialogFocus(open: boolean, onClose: () => void, containerRef: RefObject) { const restoreToRef = useRef(null); useEffect(() => { if (!open) return; const container = containerRef.current; restoreToRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null; // First field rather than the close button: the point of opening these is // to fill them in. autoFocus on a child wins, since it has already run. if (container && !container.contains(document.activeElement)) { const [first] = focusableWithin(container); (first ?? container).focus(); } function onKeyDown(e: KeyboardEvent) { if (e.key === "Escape") { onClose(); return; } if (e.key !== "Tab" || !container) return; const focusable = focusableWithin(container); if (focusable.length === 0) { e.preventDefault(); return; } const first = focusable[0]; const last = focusable[focusable.length - 1]; const active = document.activeElement; // Wrap around at both ends, and pull focus back in if it somehow // escaped (a click on the backdrop, say). if (!container.contains(active)) { e.preventDefault(); (e.shiftKey ? last : first).focus(); } else if (e.shiftKey && active === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && active === last) { e.preventDefault(); first.focus(); } } window.addEventListener("keydown", onKeyDown); return () => { window.removeEventListener("keydown", onKeyDown); // Back to whatever opened the dialog, so the next Tab continues from // there instead of restarting at the top of the page. restoreToRef.current?.focus(); }; }, [open, onClose, containerRef]); }