刚接触原生弹窗组件那会儿,遇到一个事儿让不少人挠头:明明用 <dialog> 元素的 showModal 方法把模态框弹出来了,结果按着 Tab 键一路切,焦点居然溜到了地址栏上。按照以前混社区学到的“铁律”,模态框一出现,焦点必须在弹窗内部循环打转,这跑出去的情况怎么看都像个大 BUG。
焦点陷阱
所谓焦点陷阱,指的是在模态框显示时,通过脚本强制将键盘焦点锁定在弹窗内部。当用 Tab 键或 Shift+Tab 键切换时,焦点会在弹窗内的按钮、输入框等可交互元素之间循环,无论如何都切不到页面背后的主内容。这种做法在过去很长一段时间里,被视为实现无障碍模态框的必要手段。主要是为了确保使用屏幕阅读器的用户或者纯键盘操作的用户,不会因为焦点跑到弹窗外面,导致无法操作弹窗,或者误操作了被遮挡的页面内容。
为啥传统建议过时
以前要实现这种效果,得写一堆 JavaScript 去监听 focusin 事件,手动把焦点拉回来。现在情况不同了。浏览器原生的 <dialog> 元素配上 showModal 方法,自己就带了一套模态行为。背后的页面会被自动加上 inert 的效果,屏幕上只有弹窗能交互。至于焦点能不能切到地址栏,这属于浏览器自己管辖的范围。无障碍领域的大佬们讨论过这件事,结论挺明确:用原生 <dialog> 的模态模式,压根不用再写代码去额外拦截焦点。而且,允许焦点跑到地址栏,其实是给了键盘用户一条退路。万一弹窗卡住了,或者需要临时开个新标签查点东西,直接切到地址栏就能操作,不用硬记一堆快捷键。
实操经验分享
下面用两种方案来演示。第一种是基础用法,完全依赖原生行为。第二种稍微加了点增强,确保弹窗打开时焦点落到期望的按钮上,同时处理好关闭后焦点归位的事情。
基础版
这段代码直接使用 <dialog> 元素,不添加任何额外的焦点拦截逻辑。打开弹窗时,焦点会默认落在弹窗内第一个可聚焦元素上,也就是“关闭”按钮。按 Tab 键可以切到地址栏,或者切回弹窗内的按钮。
<dialog id="demoDialog">
<p>这个弹窗允许焦点跑到地址栏。</p>
<button id="closeBtn">关闭</button>
</dialog>
<button id="openBtn">打开弹窗</button>
<script>
const dialog = document.getElementById('demoDialog');
const openBtn = document.getElementById('openBtn');
const closeBtn = document.getElementById('closeBtn');
openBtn.addEventListener('click', () => {
dialog.showModal();
});
closeBtn.addEventListener('click', () => {
dialog.close();
});
</script>在这个例子里,不用操心 focusin 事件,不用监听 keydown 事件去拦 Tab。打开弹窗后,试试 Tab 键,焦点会从“关闭”按钮切到地址栏,再从地址栏切回来。这完全正常,不是 BUG。开发的时候可以放心地把那些复杂的焦点陷阱逻辑删掉,代码瞬间清爽不少。这里有个点需要留意,原生 <dialog> 打开后,焦点落的位置不一定是想要的,万一弹窗内容复杂,可能需要手动指定焦点,这会在进阶版里解决。
进阶版
这个版本在原生行为基础上,加了两处控制:一是弹窗打开时,主动把焦点放到指定的“确认”按钮上;二是弹窗关闭后,把焦点还给当初打开弹窗的那个按钮。整个过程依然没有去拦截 Tab 键,所以焦点还是能自由进出地址栏。
<dialog id="advancedDialog">
<p>弹窗内容加载完毕,焦点自动落到确认按钮。</p>
<button id="cancelAction">取消</button>
<button id="confirmAction">确认</button>
</dialog>
<button id="triggerBtn">触发弹窗</button>
<script>
const advDialog = document.getElementById('advancedDialog');
const trigger = document.getElementById('triggerBtn');
const cancelBtn = document.getElementById('cancelAction');
const confirmBtn = document.getElementById('confirmAction');
// 监听弹窗打开事件,将焦点移到确认按钮
advDialog.addEventListener('showmodal', () => {
confirmBtn.focus();
});
// 弹窗关闭后,焦点归还给触发按钮
advDialog.addEventListener('close', () => {
trigger.focus();
});
trigger.addEventListener('click', () => {
advDialog.showModal();
});
cancelBtn.addEventListener('click', () => {
advDialog.close();
});
confirmBtn.addEventListener('click', () => {
// 这里可以放具体的业务逻辑
advDialog.close();
});
</script>写这段代码时,得注意 showmodal 事件的使用。有些老旧的资料里会用 show 事件,但那是针对非模态状态。用 showModal 打开时,事件名称是 showmodal,全小写。如果不确定,可以先用 console.log 打印一下,确保事件触发。另外,关闭弹窗后归还焦点这一步很关键,要不然键盘用户可能迷失在页面上,找不到刚才的操作位置。
另一个方案:保留传统方式
如果项目需要兼容非常古老的浏览器,或者有特殊的安全策略要求弹窗内的焦点绝对不许离开弹窗边界,也可以选择继续使用焦点陷阱。这种方案本质上是在模拟 <dialog> 的原生行为,但工作量会大不少。需要自己管理焦点列表,监听 focusout 事件,判断焦点是否移出弹窗区域,如果移出就强制拉回。同时还得处理页面背后可交互元素的 tabindex 属性,让它们暂时无法被聚焦。下面的代码演示了这种“纯手工”的做法,但坦白说,除非环境极其特殊,不然真没必要这么折腾。
<div id="customModal" style="display: none; position: fixed; top: 20%; left: 30%; background: white; border: 1px solid #ccc; padding: 20px;">
<button id="customClose">关闭</button>
<button id="customConfirm">确认</button>
</div>
<button id="customOpen">手工弹窗</button>
<script>
const modal = document.getElementById('customModal');
const openCustom = document.getElementById('customOpen');
const closeCustom = document.getElementById('customClose');
const confirmCustom = document.getElementById('customConfirm');
let focusableElements = [];
// 获取弹窗内所有可聚焦元素
function getFocusable() {
return modal.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
}
// 聚焦陷阱逻辑
function trapFocus(e) {
const focusable = getFocusable();
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && e.target === first) {
last.focus();
e.preventDefault();
} else if (!e.shiftKey && e.target === last) {
first.focus();
e.preventDefault();
}
}
function openModal() {
modal.style.display = 'block';
const focusable = getFocusable();
if (focusable.length) focusable[0].focus();
document.addEventListener('focusin', trapFocus);
}
function closeModal() {
modal.style.display = 'none';
document.removeEventListener('focusin', trapFocus);
openCustom.focus();
}
openCustom.addEventListener('click', openModal);
closeCustom.addEventListener('click', closeModal);
confirmCustom.addEventListener('click', closeModal);
</script>这个手工方案里有不少坑。比如,页面背景上原本可点击的按钮虽然不会被焦点切到,但如果鼠标去点,依然能触发点击事件,所以还得额外加一层遮罩层,并且给背景元素加上 aria-hidden 属性。还要处理滚动条的问题,防止背景页面滚动。相比原生 <dialog> 两行代码搞定,这完全是杀鸡用牛刀。
在实战里,能上原生 <dialog> 的就直接上。那个允许焦点溜到地址栏的行为,不仅不是缺陷,反而是个贴心的设计。下次再看到测试报告里写“弹窗焦点跑到地址栏”,可以理直气壮地说:这功能,就是这么设计的。
