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

153 lines
5.9 KiB
TypeScript

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<HTMLElement | null>(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<HTMLElement>(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<HTMLElement>(`${selector}.t-drawer--open`) || document.querySelector<HTMLElement>(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<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>(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]);
}