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 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(FOCUSABLE)).filter(isVisible); const enhance = (dialog: HTMLElement) => { if (enhanced.has(dialog)) return; const context = dialog.closest(".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(".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(".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(".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(".t-dialog__cancel:not([disabled])") ?? dialog.querySelector(".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(".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(".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(); }; }, []); }