dialog.showmodal() 不自动循环 tab 焦点,因其仅实现视觉模态,未启用键盘模态;需手动聚焦首个可聚焦子元素并监听 focusin 实现边界跳转。

dialog.showModal() 为什么没自动循环 Tab 焦点
因为 showModal() 只触发视觉模态(遮罩、Esc 响应、堆叠层级),不保证键盘模态。Safari 全版本、旧 Edge、甚至部分 Chrome 场景下,document.activeElement 仍停留在 body,Tab 键直接穿透到页脚或地址栏——这不是“延迟”,是原生行为根本未启动。
它只在打开时「尝试」聚焦第一个可聚焦子元素,但若此时 DOM 尚未渲染完成、子元素被 display: none 隐藏、或全是 div + tabindex="-1",就会静默失败,焦点落到页面任意位置。
- 别依赖
autofocus属性:它只在元素挂载时生效,而showModal()不触发重挂载 - 别给
<dialog></dialog>自身设tabindex="0":该元素默认不可键盘聚焦,设了也无效 - 必须手动确保至少一个子元素满足可聚焦条件:如
<button></button>、<input>、<select></select>、<textarea></textarea>,或带tabindex="0"的容器
如何安全聚焦第一个可聚焦元素
不能用 querySelector(':focusable')(Safari 不支持),也不能只查 button——会漏掉 [href] 和合法 [tabindex="0"] 元素,导致屏幕阅读器用户卡住。
正确做法是用精确选择器预查,并包裹在 requestAnimationFrame() 中,确保 DOM 渲染就绪:
const dialog = document.getElementById('myDialog');
dialog.addEventListener('click', () => {
dialog.showModal();
requestAnimationFrame(() => {
const focusable = dialog.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
focusable[0]?.focus();
});
});
- 若
focusable.length === 0,给<dialog></dialog>设tabindex="-1"后再调.focus(),至少让它能接收键盘事件 - 不要等 CSS 动画结束再聚焦:动画可能被系统禁用,或因性能延迟导致焦点丢失
- 异步加载内容(如 fetch 后插入表单)必须等插入完成再查,不能在
showModal()后立刻querySelector
怎么实现真正可用的 Tab 循环逻辑
监听 keydown 拦 Tab 是错的——这会破坏 NVDA/JAWS 的 Insert+F7 列表导航,也干扰 VoiceOver 方向键模式。正确方式是监听 focusin,只在焦点即将越界时干预。
关键不是“每次按 Tab 都跳”,而是识别两个边界点:焦点到达最后一个可聚焦项且按 Tab(无 Shift),或到达第一个且按 Shift+Tab。
const focusable = Array.from(dialog.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'));
const first = focusable[0];
const last = focusable[focusable.length - 1];
<p>dialog.addEventListener('focusin', (e) => {
if (!focusable.includes(e.target)) return;</p><p>if (e.target === last && !e.shiftKey) {
e.preventDefault();
first.focus();
} else if (e.target === first && e.shiftKey) {
e.preventDefault();
last.focus();
}
});
</p>
- 监听必须绑定在
<dialog></dialog>上,不是document:多层模态时 document 级监听会冲突 - 务必在
dialog.close()前移除该监听器,否则多个弹窗叠加时互相干扰 - 缓存
focusable列表为实例属性,避免每次重复查询
关闭后焦点没回原处,问题出在哪
不是没存触发源,而是存的方式让引用失效了。SPA 路由切换或组件重渲染后,闭包里直接引用的 document.querySelector('#edit-btn') 返回的 DOM 节点很可能已从文档中 detach,.focus() 静默失败且不报错。
稳妥做法是把触发源 ID 存进 dialog 元素本身:
toggleBtn.addEventListener('click', () => {
dialog.dataset.triggerId = toggleBtn.id;
dialog.showModal();
});
<p>dialog.addEventListener('close', () => {
const triggerId = dialog.dataset.triggerId;
const triggerEl = document.getElementById(triggerId);
triggerEl?.focus();
});
</p>
- 如果
triggerEl找不到,退回到逻辑父容器(比如表格<table>),而不是 <code>document.body或document - 动态列表中的按钮(如带
data-id="123")需在关闭时通过[data-id="123"]查找,而非依赖节点引用
复杂点在于:dialog 必须是 body 直系子元素,且不能用 open 属性或 display: block 强行显示——那会绕过所有原生焦点逻辑,Esc 失效、Tab 穿透、::backdrop 不响应点击。
前端入门到VUE实战笔记:立即使用
在学习笔记中,你将探索 前端 的入门与实战技巧!











