name: animated-focus description: 本文档记录了修复浮动组件(Select、DropdownMenu、Popover)带有CSS开/关动画时的键盘导航问题的经验总结。
CSS动画下的焦点管理
本文档记录了修复浮动组件(Select、DropdownMenu、Popover)带有CSS开/关动画时的键盘导航问题的经验总结。
问题描述
当浮动内容元素具有从opacity: 0开始的CSS动画(如Tailwind的animate-in fade-in-0)时,浏览器可能会拒绝element.focus()调用,因为该元素不可见。
症状表现
- 通过鼠标点击可以正确打开组件
- 使用键盘打开后,键盘导航(方向键、Escape键)失效
- 在没有动画类的演示中工作正常
- 在带有动画类的演示中失效
根本原因
- 像
fade-in-0这样的CSS动画从opacity: 0开始 - 当在渲染后立即调用
focus()时,元素仍然不可见 - 浏览器拒绝将焦点设置在不可见元素上
- 焦点停留在触发按钮上,而不是移动到内容区域
- 键盘事件发送到触发按钮(在打开状态下不执行任何操作)而不是内容区域
控制台调试证据
// 打开选择器后,键盘事件发送到触发按钮,而不是内容区域:
Document keydown: ArrowDown Target: <button role="combobox" ...>
// 活动元素是触发按钮,而不是内容区域:
Active: BUTTON summit-select-...-trigger
解决方案
实现一个焦点重试机制,允许动画在放弃之前进展到超过opacity: 0的状态。
JavaScript实现
// src/SummitUI/Scripts/floating.js
/**
* 使用重试机制聚焦元素,适用于动画元素。
* 从opacity:0开始的CSS动画元素最初可能会拒绝焦点。
* 此函数最多重试5次,每次间隔20ms,以允许动画
* 进展到不可见状态之后。
* @param {HTMLElement} element - 要聚焦的元素
*/
export function focusElement(element) {
if (!element) return;
function tryFocus(attempts) {
element.focus();
// 如果焦点未成功设置且还有重试次数,则重试
if (document.activeElement !== element && attempts > 0) {
setTimeout(() => tryFocus(attempts - 1), 20);
}
}
// 首次尝试在一帧之后,让CSS应用
requestAnimationFrame(() => tryFocus(5));
}
关键要点
- 首先使用
requestAnimationFrame- 确保在尝试聚焦之前CSS已应用 - 检查
document.activeElement- 验证焦点是否实际设置成功 - 延迟重试 - 20ms间隔允许动画进展
- 限制尝试次数 - 5次重试 = 最长等待100ms,足够应对典型动画
- 应用于所有焦点函数 -
focusElement(element)和focusElementById(id)都需要此模式
更新的函数
floating.js:focusElement(element)- 由SelectContent使用floating.js:focusElementById(elementId)- 由DropdownMenuContent使用
测试
在测试演示页面中添加了“带动画”部分,并编写了相应的Playwright测试。
用于测试的CSS动画类
/* tests/SummitUI.Tests.Manual/SummitUI.Tests.Manual/wwwroot/app.css */
@keyframes fadeInZoomIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes fadeOutZoomOut {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.95);
}
}
.animated-content[data-state="open"] {
animation: fadeInZoomIn 150ms ease-out forwards;
}
.animated-content[data-state="closed"] {
animation: fadeOutZoomOut 150ms ease-in forwards;
}
测试用例
对于每个组件(Select、DropdownMenu、Popover):
| 测试 | 验证内容 |
|---|---|
Animated*_ShouldOpen_OnEnterKey |
存在动画时可通过键盘打开 |
Animated*_ShouldNavigate_WithArrowKeys |
动画打开后方向键可用 |
Animated*_ShouldSelect/Activate_OnEnterKey |
动画打开后可通过Enter键选择/激活项目 |
Animated*_ShouldClose_OnEscape |
Escape键可触发关闭动画 |
运行动画测试
dotnet run --project tests/SummitUI.Tests.Playwright -- --treenode-filter '/*/*/*/Animated*'
涉及文件
| 文件 | 用途 |
|---|---|
src/SummitUI/Scripts/floating.js |
包含focusElement和focusElementById函数 |
src/SummitUI/Components/Select/SelectContent.cs |
打开时调用FocusElementAsync |
src/SummitUI/Components/DropdownMenu/DropdownMenuContent.cs |
为菜单项调用FocusElementByIdAsync |
src/SummitUI/Components/Popover/PopoverContent.cs |
管理弹出框内容的焦点 |
考虑的替代方案
- 更长的初始延迟 - 可以在首次焦点尝试前使用50-100ms延迟,但会带来明显的延迟感
- 在动画开始前聚焦 - 需要更改渲染顺序,复杂
- 聚焦时禁用动画 - 会导致视觉故障
- 使用CSS
visibility代替opacity- 需要更改动画编写方式
选择重试机制是因为它:
- 适用于任何动画时长
- 不需要更改CSS编写方式
- 对性能影响最小
- 如果焦点从未成功,可以优雅地失败
相关模式
此模式类似于bits-ui在Svelte组件中处理动画存在的方式。关键见解是,DOM操作(如焦点设置)可能需要等待CSS动画达到可聚焦状态。