import { useEffect, useRef } from "react"; const FOCUSABLE = [ "a[href]", "button:not([disabled])", "input:not([disabled])", "textarea:not([disabled])", "select:not([disabled])", "[tabindex]:not([tabindex='-1'])", ].join(","); let titleSequence = 0; /** Adds the small amount of focus management TDesign Drawer does not expose. */ export function useDrawerFocus(active: boolean, selector: string) { const returnFocusRef = useRef(null); useEffect(() => { if (!active) return; const previous = document.activeElement; if (previous instanceof HTMLElement && !previous.closest(".t-drawer")) { returnFocusRef.current = previous; } let disposed = false; let root: HTMLElement | null = null; let cleanupRoot: (() => void) | undefined; let frame = 0; let timer: number | undefined; let focusTimer: number | undefined; let initialized = false; const restoreTimers: number[] = []; const focusFirst = () => { if (disposed || !root) return; const current = document.activeElement; if (current && root.contains(current) && current !== root) return; const first = Array.from(root.querySelectorAll(FOCUSABLE)).find(node => { const rect = node.getBoundingClientRect(); const style = window.getComputedStyle(node); return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"; }); (first || root).focus(); }; const setup = () => { if (disposed) return; root = document.querySelector(`${selector}.t-drawer--open`) || document.querySelector(selector); if (!root) { timer = window.setTimeout(setup, 40); return; } if (!root.hasAttribute("tabindex")) root.setAttribute("tabindex", "-1"); if (initialized) { // Lazy Drawer content can be inserted after the first animation frame. // Re-run only the initial focus lookup without duplicating listeners. focusTimer = window.setTimeout(focusFirst, 80); return; } initialized = true; root.setAttribute("role", "dialog"); root.setAttribute("aria-modal", "true"); const header = root.querySelector(".t-drawer__header"); if (header) { if (!header.id) { titleSequence += 1; header.id = `tn-drawer-title-${titleSequence}`; } root.setAttribute("aria-labelledby", header.id); } else if (!root.getAttribute("aria-label")) { root.setAttribute("aria-label", "抽屉"); } const closeButton = root.querySelector(".t-drawer__close-btn"); 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 isNestedDialogOpen = () => Array.from(document.querySelectorAll(".t-dialog, .t-dialog__wrapper")).some(dialog => { if (dialog === root || root?.contains(dialog)) return false; const style = window.getComputedStyle(dialog); const rect = dialog.getBoundingClientRect(); return style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0; }); const onKeyDown = (event: KeyboardEvent) => { if (event.key !== "Tab" || !root || isNestedDialogOpen()) return; const items = Array.from(root.querySelectorAll(FOCUSABLE)).filter(node => { const rect = node.getBoundingClientRect(); const style = window.getComputedStyle(node); return (rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none") || node === document.activeElement; }); if (!items.length) { event.preventDefault(); event.stopPropagation(); root.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(); }; root.addEventListener("keydown", onKeyDown, true); cleanupRoot = () => { root?.removeEventListener("keydown", onKeyDown, true); closeButton?.removeEventListener("keydown", onCloseKeyDown); }; frame = window.requestAnimationFrame(() => { focusFirst(); focusTimer = window.setTimeout(focusFirst, 90); }); }; frame = window.requestAnimationFrame(setup); timer = window.setTimeout(setup, 180); return () => { disposed = true; window.cancelAnimationFrame(frame); if (timer !== undefined) window.clearTimeout(timer); if (focusTimer !== undefined) window.clearTimeout(focusTimer); cleanupRoot?.(); const restore = () => { if (!disposed) return; if (document.querySelector(`${selector}.t-drawer--open`)) return; const target = returnFocusRef.current; const current = document.activeElement; if (!target?.isConnected) return; // Do not steal focus if the user has already moved to another page // control while the close animation was running. if (current && current !== document.body && !current.closest(".t-drawer")) return; target.focus(); }; [0, 180, 380].forEach(delay => { restoreTimers.push(window.setTimeout(restore, delay)); }); restore(); }; }, [active, selector]); }