Files
tyx_AI_xhs/apps/web/src/dialog-focus.ts
T

57 lines
2.1 KiB
TypeScript

import { useLayoutEffect, useRef, type RefObject } from "react";
const focusableSelector = [
"a[href]", "button:not([disabled])", "input:not([disabled])", "select:not([disabled])", "textarea:not([disabled])",
'[tabindex]:not([tabindex="-1"])',
].join(",");
function focusableElements(dialog: HTMLElement) {
return [...dialog.querySelectorAll<HTMLElement>(focusableSelector)].filter((element) => !element.hidden && element.getClientRects().length > 0);
}
export function useDialogFocus(onClose: () => void): RefObject<HTMLElement | null> {
const dialogRef = useRef<HTMLElement>(null);
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
useLayoutEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return undefined;
const returnTarget = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const initial = dialog.querySelector<HTMLElement>("[data-dialog-initial-focus]") ?? focusableElements(dialog)[0] ?? dialog;
initial.focus();
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
onCloseRef.current();
return;
}
if (event.key !== "Tab") return;
const focusable = focusableElements(dialog);
if (focusable.length === 0) {
event.preventDefault();
dialog.focus();
return;
}
const first = focusable[0]!;
const last = focusable.at(-1)!;
if (event.shiftKey && (document.activeElement === first || !dialog.contains(document.activeElement))) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && (document.activeElement === last || !dialog.contains(document.activeElement))) {
event.preventDefault();
first.focus();
}
};
document.addEventListener("keydown", handleKeyDown, true);
return () => {
document.removeEventListener("keydown", handleKeyDown, true);
if (returnTarget?.isConnected) returnTarget.focus();
};
}, []);
return dialogRef;
}