Files
TallyNote/web-next/src/hooks/useDialogAccessibility.ts
T
Qiufeng 12495fb6a4
TallyNote release / linux-x64 (push) Successful in 6m24s
release: 1.1.0
2026-08-31 20:11:34 +08:00

143 lines
5.7 KiB
TypeScript

import { useEffect } from "react";
const FOCUSABLE = [
"a[href]",
"button:not([disabled])",
"input:not([disabled])",
"textarea:not([disabled])",
"select:not([disabled])",
"[tabindex]:not([tabindex='-1'])",
].join(",");
let dialogTitleSequence = 0;
function isVisible(node: HTMLElement): boolean {
const rect = node.getBoundingClientRect();
const style = window.getComputedStyle(node);
return rect.width > 0 && rect.height > 0 && style.display !== "none" && style.visibility !== "hidden";
}
/** Normalizes the semantics and keyboard behavior missing from TDesign Dialog. */
export function useDialogAccessibility() {
useEffect(() => {
let lastFocusOutside: HTMLElement | null = document.activeElement instanceof HTMLElement ? document.activeElement : null;
let scheduledFrame = 0;
const enhanced = new Map<HTMLElement, () => void>();
const onFocusIn = (event: FocusEvent) => {
const target = event.target;
if (!(target instanceof HTMLElement) || target.closest(".t-dialog__ctx")) return;
lastFocusOutside = target;
};
const focusableItems = (dialog: HTMLElement) => Array.from(dialog.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(isVisible);
const enhance = (dialog: HTMLElement) => {
if (enhanced.has(dialog)) return;
const context = dialog.closest<HTMLElement>(".t-dialog__ctx") ?? dialog;
const returnFocus = lastFocusOutside;
dialogTitleSequence += 1;
const dialogSequence = dialogTitleSequence;
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
if (!dialog.hasAttribute("tabindex")) dialog.setAttribute("tabindex", "-1");
const header = dialog.querySelector<HTMLElement>(".t-dialog__header-content, .t-dialog__header");
if (header) {
if (!header.id) {
header.id = `tn-dialog-title-${dialogSequence}`;
}
dialog.setAttribute("aria-labelledby", header.id);
} else {
dialog.setAttribute("aria-label", "确认操作");
}
const body = dialog.querySelector<HTMLElement>(".t-dialog__body");
if (body?.textContent?.trim()) {
if (!body.id) body.id = `tn-dialog-description-${dialogSequence}`;
dialog.setAttribute("aria-describedby", body.id);
}
const closeButton = dialog.querySelector<HTMLElement>(".t-dialog__close");
const onCloseKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
closeButton?.click();
};
if (closeButton) {
closeButton.setAttribute("role", "button");
closeButton.setAttribute("tabindex", "0");
closeButton.setAttribute("aria-label", "关闭弹窗");
closeButton.addEventListener("keydown", onCloseKeyDown);
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Tab") return;
const items = focusableItems(dialog);
if (!items.length) {
event.preventDefault();
event.stopPropagation();
dialog.focus();
return;
}
event.preventDefault();
event.stopPropagation();
const activeIndex = items.indexOf(document.activeElement as HTMLElement);
const nextIndex = event.shiftKey
? activeIndex <= 0 ? items.length - 1 : activeIndex - 1
: activeIndex < 0 || activeIndex === items.length - 1 ? 0 : activeIndex + 1;
items[nextIndex]!.focus();
};
context.addEventListener("keydown", onKeyDown, true);
const focusInitial = () => {
if (!isVisible(dialog) || dialog.contains(document.activeElement)) return;
const preferred = dialog.querySelector<HTMLElement>(".t-dialog__cancel:not([disabled])")
?? dialog.querySelector<HTMLElement>(".t-dialog__close[tabindex='0']");
(preferred && isVisible(preferred) ? preferred : focusableItems(dialog)[0] ?? dialog).focus();
};
const frame = window.requestAnimationFrame(focusInitial);
const timer = window.setTimeout(focusInitial, 80);
enhanced.set(dialog, () => {
window.cancelAnimationFrame(frame);
window.clearTimeout(timer);
context.removeEventListener("keydown", onKeyDown, true);
closeButton?.removeEventListener("keydown", onCloseKeyDown);
window.setTimeout(() => {
const anotherDialogOpen = Array.from(document.querySelectorAll<HTMLElement>(".t-dialog")).some(candidate => candidate !== dialog && isVisible(candidate));
const active = document.activeElement;
if (!anotherDialogOpen && returnFocus?.isConnected && (!active || active === document.body || context.contains(active))) returnFocus.focus();
}, 0);
});
};
const sync = () => {
scheduledFrame = 0;
const visibleDialogs = new Set(Array.from(document.querySelectorAll<HTMLElement>(".t-dialog")).filter(isVisible));
visibleDialogs.forEach(enhance);
enhanced.forEach((cleanup, dialog) => {
if (visibleDialogs.has(dialog)) return;
cleanup();
enhanced.delete(dialog);
});
};
const scheduleSync = () => {
if (scheduledFrame) return;
scheduledFrame = window.requestAnimationFrame(sync);
};
document.addEventListener("focusin", onFocusIn);
const observer = new MutationObserver(scheduleSync);
observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["class", "style"] });
scheduleSync();
return () => {
document.removeEventListener("focusin", onFocusIn);
observer.disconnect();
window.cancelAnimationFrame(scheduledFrame);
enhanced.forEach(cleanup => cleanup());
enhanced.clear();
};
}, []);
}