
本文介绍如何在 dom 顶层渲染的 tooltip 组件中,精准维护可访问性焦点流:当 tooltip 显示时将焦点移入其内部首个可聚焦元素,关闭时自动跳转至原触发器后的下一个页面级可聚焦元素,避免焦点跳跃或丢失。
本文介绍如何在 dom 顶层渲染的 tooltip 组件中,精准维护可访问性焦点流:当 tooltip 显示时将焦点移入其内部首个可聚焦元素,关闭时自动跳转至原触发器后的下一个页面级可聚焦元素,避免焦点跳跃或丢失。
在构建符合 WCAG 2.1 标准的自定义 Tooltip/Popover 组件时,一个常见但关键的可访问性挑战是:当 Tooltip 被挂载到 document.body 或其他顶层容器(而非嵌套在触发器附近)时,其内部可聚焦元素(如“确认”“取消”按钮)会脱离原有的视觉与逻辑阅读顺序,导致键盘用户 Tab 导航时出现意外跳转——例如从触发按钮直接跳到页面底部元素,完全跳过 Tooltip 内容。
单纯在 Tooltip 显示后调用 firstButton.focus() 只解决了“进入”问题,却未解决“退出”时的焦点归位——即用户按 Tab 离开 Tooltip 后,焦点应自然落到触发器之后的下一个页面元素(而非文档末尾),从而形成闭环、线性的焦点路径:触发器 → Tooltip 内首个按钮 → Tooltip 内次个按钮 → … → Tooltip 关闭 → 页面中触发器之后的下一个可聚焦元素。
✅ 正确的焦点流设计原则
- 进入时(Tooltip 打开):立即将焦点捕获至 Tooltip 内部第一个可聚焦子元素(通常为 Close 按钮或主操作按钮),并添加 inert 或 aria-hidden=true 到页面其余内容(可选,增强隔离性);
- 退出时(Tooltip 失焦/关闭):主动计算并聚焦「触发器元素在原始 DOM 顺序中的下一个可聚焦兄弟节点」,而非依赖浏览器默认 Tab 行为。
? 实现关键:findNextFocusableElement
以下是一个轻量、健壮的 findNextFocusableElement 工具函数(已适配 Shadow DOM 边界,支持现代浏览器):
function findNextFocusableElement(
startEl: HTMLElement,
root: Document | ShadowRoot = document
): HTMLElement | null {
const focusableSelector =
'button:not([disabled]), [href], input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
// 获取当前文档所有可聚焦元素(按 DOM 顺序)
const allFocusables = Array.from(
root.querySelectorAll<htmlelement>(focusableSelector)
);
const startIndex = allFocusables.indexOf(startEl);
if (startIndex === -1) return null;
// 从触发器之后开始查找第一个可聚焦元素
for (let i = startIndex + 1; i <h3>? 在 React 组件中集成(含防抖与时机控制)</h3>
<pre class="brush:php;toolbar:false;">const Tooltip = ({ children, trigger }: { children: React.ReactNode; trigger: React.ReactNode }) => {
const [isVisible, setVisibility] = useState(false);
const triggerRef = useRef<htmlbuttonelement>(null);
const tooltipRef = useRef<htmldivelement>(null);
// Tooltip 显示时:聚焦首个按钮
useEffect(() => {
if (isVisible && tooltipRef.current) {
const firstBtn = tooltipRef.current.querySelector<htmlelement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (firstBtn) {
// 使用 setTimeout 确保 DOM 渲染完成后再聚焦
setTimeout(() => firstBtn.focus(), 0);
}
}
}, [isVisible]);
// Tooltip 失焦时:定位并聚焦下一个页面级可聚焦元素
const handleTooltipBlur = () => {
if (!triggerRef.current) return;
const nextEl = findNextFocusableElement(triggerRef.current);
if (nextEl) {
// 强制聚焦(React 中需延迟确保 ref 更新)
setTimeout(() => {
nextEl.focus();
}, 50);
}
setVisibility(false);
};
return (
<button ref="{triggerRef}" onclick="{()"> setVisibility(!isVisible)}
onFocus={() => setVisibility(true)}
>
{trigger}
</button>
{isVisible && (
<div ref="{tooltipRef}" role="dialog" aria-modal="true" aria-labelledby="tooltip-title" onblur="{handleTooltipBlur}" style="{{" position: top: right: background: border: solid padding: zindex:>
<h3 id="tooltip-title">提示标题</h3>
{children}
<button onclick="{()"> setVisibility(false)}>关闭</button>
</div>
)}
>
);
};</htmlelement></htmldivelement></htmlbuttonelement>
⚠️ 注意事项与最佳实践
- 不要依赖 tabIndex 重排:修改全局 tabIndex 值(如设为 -1 或 0)无法改变 DOM 顺序,且易引发不可预测行为;应始终基于真实 DOM 位置计算焦点流。
- 处理动态内容:若页面可聚焦元素频繁增删(如 SPA 路由切换),建议在 useEffect 中监听变化并缓存焦点序列,或每次调用时重新查询(如示例所示)。
- 键盘导航兜底:为 Tooltip 内部添加 onKeyDown 监听 Tab 键,实现内部循环(Shift+Tab 返回上一个),并在离开 Tooltip 区域时主动触发 handleTooltipBlur。
- 屏幕阅读器友好:配合 aria-describedby 或 aria-labelledby 关联触发器与 Tooltip,使用 role="dialog" 并设置 aria-modal="true",确保语义正确。
通过以上方案,Tooltip 不再是焦点流中的“孤岛”,而是成为触发器逻辑延伸的一部分——既满足无障碍要求,也提升了所有键盘用户的操作效率与确定性。










