This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
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();
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
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]);
|
||||
}
|
||||
Reference in New Issue
Block a user