CSS动画焦点管理 animated-focus

CSS动画焦点管理是一种前端开发技术,专门解决浮动组件(如Select、DropdownMenu、Popover)在带有CSS开/关动画时键盘导航失效的问题。通过实现智能重试机制,确保在元素从不可见(opacity:0)过渡到可见状态后正确设置焦点,提升Web应用的无障碍访问性和用户体验。关键词:CSS动画、焦点管理、键盘导航、无障碍访问、前端开发、浮动组件、用户体验、Web开发。

前端开发 0 次安装 0 次浏览 更新于 3/1/2026

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键)失效
  • 在没有动画类的演示中工作正常
  • 在带有动画类的演示中失效

根本原因

  1. fade-in-0这样的CSS动画从opacity: 0开始
  2. 当在渲染后立即调用focus()时,元素仍然不可见
  3. 浏览器拒绝将焦点设置在不可见元素上
  4. 焦点停留在触发按钮上,而不是移动到内容区域
  5. 键盘事件发送到触发按钮(在打开状态下不执行任何操作)而不是内容区域

控制台调试证据

// 打开选择器后,键盘事件发送到触发按钮,而不是内容区域:
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));
}

关键要点

  1. 首先使用requestAnimationFrame - 确保在尝试聚焦之前CSS已应用
  2. 检查document.activeElement - 验证焦点是否实际设置成功
  3. 延迟重试 - 20ms间隔允许动画进展
  4. 限制尝试次数 - 5次重试 = 最长等待100ms,足够应对典型动画
  5. 应用于所有焦点函数 - 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 包含focusElementfocusElementById函数
src/SummitUI/Components/Select/SelectContent.cs 打开时调用FocusElementAsync
src/SummitUI/Components/DropdownMenu/DropdownMenuContent.cs 为菜单项调用FocusElementByIdAsync
src/SummitUI/Components/Popover/PopoverContent.cs 管理弹出框内容的焦点

考虑的替代方案

  1. 更长的初始延迟 - 可以在首次焦点尝试前使用50-100ms延迟,但会带来明显的延迟感
  2. 在动画开始前聚焦 - 需要更改渲染顺序,复杂
  3. 聚焦时禁用动画 - 会导致视觉故障
  4. 使用CSSvisibility代替opacity - 需要更改动画编写方式

选择重试机制是因为它:

  • 适用于任何动画时长
  • 不需要更改CSS编写方式
  • 对性能影响最小
  • 如果焦点从未成功,可以优雅地失败

相关模式

此模式类似于bits-ui在Svelte组件中处理动画存在的方式。关键见解是,DOM操作(如焦点设置)可能需要等待CSS动画达到可聚焦状态。