Files
genshin-assistant/src/features/common/useDialogFocus.ts
T

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 };
}