Skip to content
返回质量保障

焦点管理

为动态内容移动、困和恢复焦点使键盘和屏幕阅读器用户总知在哪。

为什么

焦点是键盘用户的光标——是接收下个键击的元素,须可见且在对的地方。模态开时焦点须移入(否则用户 Tab 过后面页)。模态关时焦点须返回触发器(否则用户迷在页顶)。单页应用导航时焦点须移到新内容(否则屏幕阅读器续读旧页)。列表过滤时焦点须移到首个结果(否则用户不知列表变了)。焦点管理对鼠标用户不可见但对键盘用户必要——无它,应用是用户不知在哪或如何回的迷宫。

何时使用

每个动态内容变: 模态开/关、路由导航、标签切换、列表过滤、toast 出现、手风琴展开。

如何做

1. 内容变时移焦点
   - 模态开: 聚焦首个交互元素(或模态容器)
   - 路由变: 聚焦新页 <main> 或 <h1>
   - 标签切: 聚焦选中标签的面板
   - 列表过滤: 聚焦首个结果或用 aria-live 宣"N 结果"

2. 容器开时困焦点在内
   - 模态: Tab 应在模态内循环非逃到后面页
   - 用焦点困库(focus-trap-react、@headlessui/react)或手动实现
   - 手动: 最后元素 Tab 时移到首个;首个 Shift+Tab 时移到最后

3. 容器关时恢复焦点
   - 模态关: 焦点返回开它的按钮
   - 这使用户从原处继续非页顶
   - 开前在 ref 存触发器元素,关时恢复

4. 使焦点目标可聚焦
   - <div> 默认不可聚焦——加 tabIndex={-1} 使可编程聚焦
   - tabIndex={-1} 可通过 .focus() 聚焦但不可通过 Tab(对模态容器正确)
   - tabIndex={0} 两者都可(用于应在 tab 顺序的元素)

5. 宣不移焦点的变更
   - 某些变更不应抢焦点(toast、购物车更新、后台刷新)
   - 用 aria-live="polite" 不移焦点宣读
   - 紧急宣读(错误)用 aria-live="assertive"(或 role="alert")

示例

import { useRef, useEffect } from "react";

function Modal({ isOpen, onClose, children }) {
  const modalRef = useRef(null);
  const triggerRef = useRef(null);

  useEffect(() => {
    if (isOpen) {
      // 开前存当前聚焦元素(触发器)
      triggerRef.current = document.activeElement as HTMLElement;
      // 焦点移入模态
      modalRef.current?.focus();
    } else if (triggerRef.current) {
      // 关时恢复焦点到触发器
      triggerRef.current.focus();
      triggerRef.current = null;
    }
  }, [isOpen]);

  if (!isOpen) return null;
  return (
    <div role="dialog" aria-modal="true" ref={modalRef} tabIndex={-1}>
      {children}
      <button onClick={onClose}>关</button>
    </div>
  );
}

// 路由变: 焦点移到新页主内容
function Page({ title, children }) {
  const mainRef = useRef(null);
  useEffect(() => {
    // 每路由变时焦点移到 <main>
    mainRef.current?.focus();
  }, [title]); // 依赖页
  return (
    <main ref={mainRef} tabIndex={-1}>
      <h1>{title}</h1>
      {children}
    </main>
  );
}

// 焦点困: Tab 保持在模态内
import FocusTrap from "focus-trap-react";
function Modal({ isOpen, children }) {
  if (!isOpen) return null;
  return (
    <FocusTrap>
      <div role="dialog" aria-modal="true">
        {children}
      </div>
    </FocusTrap>
  );
}

// 不移焦点宣读(toast)
function Toast({ message }) {
  return (
    <div role="status" aria-live="polite" className="toast">
      {message}
    </div>
  );
}
// toast 出现,屏幕阅读器宣读,但焦点留用户原处

常见问题

  • ⚠开模态不移焦点入——屏幕阅读器续读模态后页,键盘用户 Tab 过不可见元素,模态不可用。
  • ⚠关模态不恢复焦点到触发器——焦点跳到页顶,键盘用户迷,须 Tab 过整页回原处。
  • ⚠模态内不困焦点——Tab 逃到后面页,用户 Tab 进不可见内容,模态非自包含体验。
  • ⚠为非紧急宣读(toast、购物车更新)移焦点——焦点从用户当前任务偷,意外焦点变使他们迷失。

最佳实践

  • ✓模态开时总移焦点入、关时恢复到触发器——这是最重要的焦点管理模式,是模态对键盘和屏幕阅读器用户可用的原因。
  • ✓模态用焦点困库(focus-trap-react、@headlessui/react Dialog)——手动焦点困易错(iframe、shadow DOM、动态加元素的边缘情况),库处理它们。
  • ✓单页应用路由变时焦点移到新页 <main> 或 <h1>——否则屏幕阅读器续读旧页,用户不知页变了。
  • ✓不应移焦点的宣读(toast、购物车更新、后台刷新)用 aria-live——宣读到用户不用焦点变使他们迷失。