97 lines
3.4 KiB
TypeScript
97 lines
3.4 KiB
TypeScript
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<HTMLDivElement>;
|
|
titleId: string;
|
|
}
|
|
|
|
export function useDialogFocus(open: boolean, onClose: () => void): DialogFocusResult {
|
|
const dialogRef = useRef<HTMLDivElement>(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<HTMLElement>(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 };
|
|
}
|