Files
writer-work-flow/apps/web/lib/a11y/focusTrap.ts
Yaojia Wang c6651b74b9 fix(web): stale-closure + focus trap + 去边界强转 + a11y/性能打磨 + 类型化客户端
P1-6 useStyleLearn/useKimiOauth 修 stale-closure 依赖。
P1-7 CommandPalette/Drawer 加 focus trap(新 lib/a11y/focusTrap)。
P1-8 SSE 解析与 server.ts 去 as 强转改类型守卫/运行时校验。
P2 删冗余强转、开 noUncheckedIndexedAccess、Toast 清理+稳定 key、列表 key 稳定化、
  rel=noopener、useMemo 大文本、ConflictCard role=group、useInjection 加锁、
  client.test 真断言。
codegen 重生成 schema.d.ts + 消费 DimensionEntry/ReviewConflictView 强类型。
2026-06-21 19:32:49 +02:00

81 lines
2.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 模态对话框焦点陷阱WCAG 2.1.2:把焦点关在对话框内循环)。
// 自写 querySelectorAll 焦点循环,不引入新依赖。
// 纯决策核trapTarget可 node 单测)+ 薄 DOM 包装handleTabTrap
// 可聚焦元素选择器(排除 disabled / tabindex=-1 / 隐藏)。
const FOCUSABLE_SELECTOR = [
"a[href]",
"button:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
'[tabindex]:not([tabindex="-1"])',
].join(",");
// 取容器内当前可聚焦(且可见)的元素,按文档顺序。
export function focusableElements(container: HTMLElement): HTMLElement[] {
const nodes = container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);
return Array.from(nodes).filter(
(el) => el.offsetParent !== null || el === document.activeElement,
);
}
export type TrapAction = "none" | "first" | "last" | "container";
// 纯决策:给定可聚焦数量、当前焦点在循环中的位置、容器是否含焦点、是否 Shift+Tab
// 决定 Tab 应跳到何处(环回首/尾/容器)。-1 表示 active 不在 focusable 列表内。
export function trapTarget(args: {
focusableCount: number;
activeIndex: number; // active 在 focusable 中的下标;-1 = 不在列表
containsActive: boolean; // 容器是否包含当前焦点
shiftKey: boolean;
}): TrapAction {
const { focusableCount, activeIndex, containsActive, shiftKey } = args;
if (focusableCount === 0) return "container";
if (shiftKey) {
// 在首元素或焦点已逃出容器 → 环回到尾。
if (activeIndex === 0 || !containsActive) return "last";
return "none";
}
// 在尾元素或焦点已逃出容器 → 环回到首。
if (activeIndex === focusableCount - 1 || !containsActive) return "first";
return "none";
}
// 处理 Tab / Shift+Tab在容器内循环。返回 true 表示已接管(已 preventDefault
export function handleTabTrap(
container: HTMLElement,
event: { key: string; shiftKey: boolean; preventDefault: () => void },
): boolean {
if (event.key !== "Tab") return false;
const focusable = focusableElements(container);
const active = document.activeElement as HTMLElement | null;
const containsActive = active !== null && container.contains(active);
const activeIndex =
active !== null ? focusable.indexOf(active) : -1;
const action = trapTarget({
focusableCount: focusable.length,
activeIndex,
containsActive,
shiftKey: event.shiftKey,
});
switch (action) {
case "none":
return false;
case "container":
event.preventDefault();
container.focus();
return true;
case "first":
event.preventDefault();
focusable[0]?.focus();
return true;
case "last":
event.preventDefault();
focusable[focusable.length - 1]?.focus();
return true;
}
}