Files
Notes/src/focus-trap.js
2026-09-12 14:15:26 +08:00

173 lines
8.5 KiB
JavaScript
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.
// 焦点陷阱 —— 通用最小可工作版
//
// 解决两件事:
// 1. Tab / Shift+Tab 在容器内循环,不外溢到背景 DOM
// 2. 关闭时把焦点还给打开前的元素
//
// 不做(避免过度工程):
// - 自动找"第一个 focusable"作为初始焦点 —— 由调用方传入 initialFocus 更明确
// - 屏幕阅读器特殊处理 —— aria-modal + aria-hidden 已交给调用方
// - 焦点恢复时滚屏校正 —— focus({ preventScroll: true }) 即可
//
// 调用约定:
// const trap = createFocusTrap(overlay, {
// initialFocus: overlay.querySelector('input, button, [tabindex]:not([tabindex="-1"])'),
// signal: abortController.signal, // 可选signal abort 时自动 dispose
// });
// ...
// trap.dispose(); // 把焦点还给 prevFocus
const FOCUSABLE_SELECTOR = [
'a[href]',
'button:not([disabled])',
'input:not([disabled]):not([type="hidden"])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(',');
export function createFocusTrap(container, { initialFocus = null, signal = null, fallback = null } = {}) {
if (!container) throw new Error('createFocusTrap: container 必填');
// audit fix原条件只排除 body但 <html>documentElement也是
// document.activeElement 的常见值(焦点漂移到根节点 —— 例如布局 reflow
// 期间、点击非可聚焦区域后)。<html>.focus() 是合法但无视觉焦点的
// no-op导致 dispose 后屏幕阅读器 / 键盘用户的虚拟焦点被困在已关闭
// 的模态内。这里把 documentElement / body 都视为「无效 prevFocus」
// 返回 nulldispose 会直接跳过 focus 调用,焦点自然落到 body 上。
const ae = document.activeElement;
const prevFocus = (ae && ae !== document.body && ae !== document.documentElement
&& typeof ae.focus === 'function') ? ae : null;
// 容器自身需要能接收焦点,否则 Tab 从末尾跳到第一个时容器不会被激活
const hadTabindex = container.hasAttribute('tabindex');
if (!hadTabindex) {
container.setAttribute('tabindex', '-1');
}
/** @returns {HTMLElement[]} */
function getFocusable() {
// H1 fix (audit):原 filter 用 `el.offsetParent !== null` 判断可见性,
// 但 `position: fixed` 元素的 offsetParent === null会被误判成「不可见」
// 从 Tab 序列里剔除。模态里有 sticky 按钮 / 浮动操作按钮(如未来加的
// 「在此打开文件夹」Tab 会跳过它们到末尾后跳回首项,无法到达。
// 用 `getClientRects().length > 0` 兜住 fixed / sticky且对 display:none
// 仍然返回空hidden 元素没有 layout box。已聚焦的 fixed 元素保留。
//
// 审计修复 (Round 11):还要排除以下三类元素:
// - visibility: hidden —— 仍有 client rectsfilter 里已放过),但 .focus()
// 是 silent no-opTab 序列走到这里 → first.focus() 落在 no-op 元素上 →
// 「Tab 死掉」的假象
// - inert 子树 —— W3C 新标准把子树标记为不可交互closest('[inert]')
// 命中即剔除
// - aria-hidden=true 子树 —— 屏幕阅读器跳过;但键盘 Tab 仍可能命中,
// 排除防止 Tab 看着「消失」
// contenteditable 显式加入选择器CM 编辑器嵌入 modal 时能正常 Tab 进。
return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR + ',[contenteditable]:not([contenteditable="false"])'))
.filter((el) => {
if (el.getClientRects().length === 0 && el !== document.activeElement) return false;
if (el.closest && el.closest('[inert],[aria-hidden="true"]')) return false;
// visibility:hidden 元素的 clientWidth/Height 仍可能 > 0但 getComputedStyle
// 拿到 visibility === 'hidden'。已经聚焦的元素activeElement保留——可能
// 是「用户主动聚焦后外部样式改了 visibility」的边角dispose 时回退路径不应
// 把已聚焦元素从序列里踢出。
if (el !== document.activeElement) {
const cs = (typeof window !== 'undefined' && window.getComputedStyle) ? window.getComputedStyle(el) : null;
if (cs && cs.visibility === 'hidden') return false;
}
return true;
});
}
function onKeydown(e) {
// audit fix (Phase O-L14)Tab 也守 IME 合成。
// 与 shortcuts.js:84 / markdown-editor.js 风格一致CJK 拼音输入中途按 Tab
// 选候选词Windows IME 习惯focus-trap 不能把焦点甩到下一个按钮。
// 当前 modal 没有 IME 重输入控件settings / confirm / prompt 都是普通
// input潜在风险。但保持一致性所有 document-level keydown listener
// 都守 isComposing / keyCode=229。
if (e.isComposing || e.keyCode === 229) return;
if (e.key !== 'Tab') return;
const items = getFocusable();
if (items.length === 0) {
// 没有可聚焦元素:把焦点留在容器上,避免跳出去
e.preventDefault();
container.focus();
return;
}
const first = items[0];
const last = items[items.length - 1];
const active = document.activeElement;
if (e.shiftKey) {
if (active === first || !container.contains(active)) {
e.preventDefault();
last.focus();
}
} else {
if (active === last || !container.contains(active)) {
e.preventDefault();
first.focus();
}
}
}
container.addEventListener('keydown', onKeydown);
let disposed = false;
function dispose() {
if (disposed) return;
disposed = true;
container.removeEventListener('keydown', onKeydown);
// audit fix (renderer-M10):只在是我们自己注入的 tabindex 时移除,
// 宿主原有值(如 HMR 复用场景下节点本身带 tabindex原样保留。
if (!hadTabindex) {
container.removeAttribute('tabindex');
}
if (prevFocus && typeof prevFocus.focus === 'function') {
// 审计修复 (Round 11)dispose 时重验 isConnected。
// prevFocus 在 capture 阶段(第 38-40 行)只检查 !== body/rootElement
// 但 modal 期间 prevFocus 可能被外部重渲染 / 撕下 DOM —— 例如:
// - file-list 右键菜单 → 「重命名」 → 弹 prompt-dialog
// 菜单 close 时整块被 unmountprevFocus 已 detached
// .focus() 是 silent no-op焦点落到 <body>,键盘用户失位。
// - 文件列表 items 在 files:changed 事件里重建prevFocus 同样 detached。
// 重新检查 isConnected连接不上时回退到稳定锚点caller 提供
// 的 fallback—— 而不是让焦点散落到 <body>。
//
// audit fix (Settings P3 / focus-trap fallback lift):原来这里
// 硬编码 `document.getElementById('file-list')` 作为兜底 —— 把
// 「renderer 侧具体 DOM 节点」写进通用 focus-trap跨上下文错位
// - settings-dialog 关掉 → 焦点跳到 #file-list语义错位
// (用户在设置里改东西,期望焦点回设置前的位置)
// - confirm / prompt / 未来 AI 弹窗同样问题
// - 通用包里写死具体 id一旦 #file-list 改名/删名静默退化到 <body>
// 现在让 caller 通过 fallback 选项传一个语义匹配的节点;未传则按
// fallback 节点 → document.body 的顺序找第一个仍 connected 的。
// 之前 modal.js 调用点没传 fallback下面 mountModal 那侧会传入
// 一个 caller 级别的稳定锚点modal.options.fallbackFocus
let restoreTarget = prevFocus;
if (!prevFocus.isConnected) {
const candidates = [fallback, document.body].filter(Boolean);
restoreTarget = candidates.find((el) => {
try { return el && typeof el.isConnected === 'boolean' && el.isConnected; }
catch { return false; }
}) || document.body;
}
try { restoreTarget.focus({ preventScroll: true }); } catch { /* ignore */ }
}
}
if (signal) {
if (signal.aborted) dispose();
else signal.addEventListener('abort', dispose, { once: true });
}
// 初始焦点:异步让出渲染时间,避免被外层 setTimeout(0) 抢走
queueMicrotask(() => {
if (disposed) return;
const target = initialFocus || getFocusable()[0] || container;
try { target.focus({ preventScroll: true }); } catch { /* ignore */ }
});
return { dispose };
}