焦点管理
为动态内容移动、困和恢复焦点使键盘和屏幕阅读器用户总知在哪。
为什么
焦点是键盘用户的光标——是接收下个键击的元素,须可见且在对的地方。模态开时焦点须移入(否则用户 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——宣读到用户不用焦点变使他们迷失。