使用Popover API与details/summary元素时的键盘交互问题
问题描述
将details元素作为带popover属性元素的直接子元素时,会出现键盘Tab遍历中断的问题:Tab键可以正常遍历popover内的可聚焦内容,但触发第一个details(比如按回车展开)后,无法继续Tab跳转到后续元素,导致Button 4及之后的details等可聚焦元素无法通过键盘访问。
令人困惑的是,将details放在dialog元素内或非模态环境下时,该问题不会出现。由于使用场景不契合dialog的语义,不想强制替换为dialog组件。
复现代码
<button popovertarget="popover">Open popover</button> <div id="popover" popover> <a href="#">Link 1</a> <a href="#">Link 2</a> <button>Button 1</button> <button>Button 2</button> <button>Button 3</button> <details> <summary>First summary</summary> <p>First paragraph</p> </details> <button>Button 4</button> <details> <summary>Second summary</summary> <p>Second paragraph</p> </details> <details> <summary>Third summary</summary> <p>Third paragraph</p> </details> <details> <summary>Forth summary</summary> <p>Forth paragraph</p> </details> </div>
原因分析
这是浏览器原生popover的焦点管理逻辑与details元素交互冲突导致的。原生popover默认具备焦点捕获(focus trapping)特性,当details展开时,浏览器的焦点管理机制可能错误地将焦点锁定在details内部,未正确将焦点传递到后续可聚焦元素。而dialog组件的焦点管理逻辑更完善,已处理这类内嵌交互元素的场景,因此不会出现该问题。
解决方案
无需替换为dialog,以下几种方法可解决该问题:
1. 手动监听details切换事件,引导焦点
给所有details元素添加toggle事件监听,在details展开后手动将焦点引导到下一个可聚焦元素:
const popover = document.getElementById('popover'); const detailsList = popover.querySelectorAll('details'); // 定义获取下一个可聚焦元素的工具函数 function getNextFocusable(currentEl) { const focusableElements = Array.from(popover.querySelectorAll( 'a[href], button, details, input, select, textarea, [tabindex]:not([tabindex="-1"])' )); const currentIndex = focusableElements.indexOf(currentEl); // 若当前是最后一个元素,循环回到第一个;否则取下一个 return focusableElements[currentIndex + 1] || focusableElements[0]; } // 给每个details绑定切换事件 detailsList.forEach(details => { details.addEventListener('toggle', (e) => { if (e.target.open) { const nextEl = getNextFocusable(e.target); nextEl?.focus(); } }); });
2. 给details添加外层容器
将details元素包裹在一个普通容器(如div)内,避免作为popover的直接子元素,可绕过浏览器焦点捕获的逻辑冲突:
<div id="popover" popover> <!-- 其他元素保持不变 --> <div> <details> <summary>First summary</summary> <p>First paragraph</p> </details> </div> <button>Button 4</button> <!-- 后续details同样添加外层容器 --> <div> <details> <summary>Second summary</summary> <p>Second paragraph</p> </details> </div> <!-- 其余details依此类推 --> </div>
3. 自定义popover焦点捕获逻辑
禁用原生popover的焦点捕获行为(需通过JS实现),自定义Tab键遍历的循环逻辑,确保焦点能正常遍历所有元素:
const popover = document.getElementById('popover'); popover.addEventListener('keydown', (e) => { if (e.key !== 'Tab') return; const focusableElements = Array.from(popover.querySelectorAll( 'a[href], button, details, input, select, textarea, [tabindex]:not([tabindex="-1"])' )); const firstEl = focusableElements[0]; const lastEl = focusableElements[focusableElements.length - 1]; if (e.shiftKey) { // Shift+Tab:若当前焦点是第一个元素,跳转到最后一个 if (document.activeElement === firstEl) { e.preventDefault(); lastEl.focus(); } } else { // Tab:若当前焦点是最后一个元素,跳转到第一个 if (document.activeElement === lastEl) { e.preventDefault(); firstEl.focus(); } } });
总结
通过上述方法,既可以解决popover与details的键盘交互冲突,又能保持组件语义与使用场景匹配,无需强制替换为dialog。
内容的提问来源于stack exchange,提问作者Miłosz Drogowski

