// 模态对话框焦点陷阱(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(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; } }