
本文介绍如何在 dom 树中脱离父级渲染的 tooltip 组件中,精准维护可访问性焦点流:使焦点在触发器与 tooltip 内容间自然切换,并在关闭后正确回归页面原有焦点顺序。
本文介绍如何在 dom 树中脱离父级渲染的 tooltip 组件中,精准维护可访问性焦点流:使焦点在触发器与 tooltip 内容间自然切换,并在关闭后正确回归页面原有焦点顺序。
在构建高可访问性(a11y)的自定义 Tooltip/Popover 组件时,一个常见但棘手的问题是:当 Tooltip 渲染在
或根节点(如 createPortal 到 document.body)时,其内部可聚焦元素(如 Close 按钮、操作按钮)会脱离原始 DOM 上下文,导致浏览器默认焦点顺序断裂——焦点从触发器跳转到 Tooltip 后,按 Tab 键不会回到触发器后的下一个页面元素,而是直接跳至文档末尾或不可预测位置。根本原因在于:焦点顺序(focus order)严格依赖 DOM 的源码顺序(source order),而非视觉或逻辑层级。即使 Tooltip 在语义上“属于”触发器,若其 DOM 节点被插入到
底部,它就在焦点流中被排在最后。✅ 正确解法:动态接管焦点流
我们需要在两个关键时机主动干预焦点:
- Tooltip 打开时:将焦点立即移入 Tooltip 内部第一个可聚焦元素(如 Close 按钮),确保键盘用户能立刻操作;
- Tooltip 关闭时(触发器失焦后):不被动等待,而是主动查找并聚焦「触发器之后的下一个可聚焦元素」,从而无缝续接原焦点流。
以下是一个生产就绪的 React 实现核心逻辑(基于 useRef 和 useEffect):
// 工具函数:查找指定元素之后的下一个可聚焦元素
const findNextFocusableElement = (fromElement: HTMLElement): HTMLElement | null => {
const allFocusables = Array.from(
document.querySelectorAll<htmlelement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
).filter(el =>
el.offsetParent !== null &&
getComputedStyle(el).visibility !== 'hidden' &&
getComputedStyle(el).display !== 'none'
);
const currentIndex = allFocusables.indexOf(fromElement);
return currentIndex >= 0 && currentIndex + 1 (null);
const [tooltipVisible, setTooltipVisible] = useState(false);
// Tooltip 打开时:聚焦内部首个可聚焦元素
useEffect(() => {
if (tooltipVisible && tooltipRef.current) {
const firstFocusable = tooltipRef.current.querySelector<htmlelement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (firstFocusable) {
// 使用 setTimeout 避免 React 渲染周期冲突(尤其 Portal 场景)
setTimeout(() => firstFocusable.focus(), 0);
}
}
}, [tooltipVisible]);
// 触发器失焦时:主动跳转至下一个页面焦点目标
const handleTriggerBlur = () => {
if (!tooltipVisible) return;
setTimeout(() => {
const nextEl = findNextFocusableElement(triggerRef.current!);
if (nextEl) {
nextEl.focus();
} else {
// 回退策略:聚焦触发器自身(保持上下文)
triggerRef.current?.focus();
}
setTooltipVisible(false);
}, 0);
};</htmlelement></htmlelement>
⚠️ 注意事项:
- 避免 tabindex="-1" 滥用:Tooltip 容器本身不应设 tabindex="-1",否则会破坏其内部焦点捕获能力;应确保其子元素具备合法可聚焦性。
- setTimeout 不可省略:在 useEffect 或事件回调中直接调用 .focus() 可能因 React 批处理或 Portal 渲染延迟而失败;setTimeout(..., 0) 将焦点操作推入微任务队列,确保 DOM 已就绪。
- 性能优化建议:findNextFocusableElement 在大型页面中遍历所有可聚焦元素成本较高。可预先缓存全页可聚焦元素列表,并在 DOM 变化时(如使用 MutationObserver)增量更新,或仅搜索触发器附近 DOM 片段(如兄弟节点、最近 section 等)。
- 辅助技术兼容性:务必为 Tooltip 添加 role="dialog"(若模态)、aria-labelledby / aria-describedby,并管理 aria-hidden 状态,确保屏幕阅读器正确识别焦点转移语义。
总结
维护焦点顺序不是“让 Tooltip 看起来像在触发器旁边”,而是在焦点流断裂处主动重建连接。通过精准控制打开时的首次聚焦 + 关闭时的定向跳转,我们既满足 WCAG 2.1 的 Focus Order(2.4.3)要求,又无需修改全局 DOM 结构或牺牲 Portal 的灵活性。最终效果是:Tab → 触发器 → Tooltip 内部元素 → 页面下一个可聚焦元素 —— 流畅、可预测、完全符合用户心智模型。










