React 可访问性实战:用 focus trap 与 ARIA 把弹窗做成无障碍组件

test12026-10-2418 次阅读

为什么可访问性不是加分项

很多团队的组件库只保证鼠标能点、键盘能敲,却忘了屏幕阅读器用户。一个没有焦点管理、没有 ARIA 语义的弹窗,对读屏软件来说就是一团乱序的文本。可访问性(a11y)的核心是把"视觉交互"翻译成"语义交互",让键盘和读屏器也能走通同一条路。

用 useFocusTrap 锁住焦点

弹窗打开时,Tab 键不应该跑出对话框;Esc 应该关闭它;打开时要主动把焦点移到弹窗内。下面这个 hook 处理了焦点捕获与还原。

import { useEffect, useRef } from "react";

function useFocusTrap(active: boolean) {
  const containerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!active) return;
    const container = containerRef.current;
    if (!container) return;

    const selector =
      'a[href], button:not([disabled]), input, select, textarea, [tabindex]:not([tabindex="-1"])';
    const focusables = () =>
      Array.from(container.querySelectorAll<HTMLElement>(selector)).filter(
        (el) => el.offsetParent !== null
      );

    const prevFocus = document.activeElement as HTMLElement | null;
    const first = focusables()[0];
    first?.focus();

    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") {
        container.dispatchEvent(new CustomEvent("dialog-close"));
        return;
      }
      if (e.key !== "Tab") return;
      const items = focusables();
      if (items.length === 0) return;
      const firstEl = items[0];
      const lastEl = items[items.length - 1];
      if (e.shiftKey && document.activeElement === firstEl) {
        e.preventDefault();
        lastEl.focus();
      } else if (!e.shiftKey && document.activeElement === lastEl) {
        e.preventDefault();
        firstEl.focus();
      }
    };

    container.addEventListener("keydown", onKey);
    return () => {
      container.removeEventListener("keydown", onKey);
      prevFocus?.focus();
    };
  }, [active]);

  return containerRef;
}

给弹窗补上 ARIA 语义

光有焦点还不够,读屏器需要知道"这是一个对话"。role、aria-modal、aria-labelledby 三件套把结构讲清楚。

export function Dialog({ open, title, onClose, children }: DialogProps) {
  const ref = useFocusTrap(open);
  if (!open) return null;
  return (
    <div className="overlay" onClick={onClose}>
      <div
        ref={ref}
        role="dialog"
        aria-modal="true"
        aria-labelledby="dialog-title"
        className="dialog"
        onClick={(e) => e.stopPropagation()}
        onDialogClose={onClose}
      >
        <h2 id="dialog-title">{title}</h2>
        <div>{children}</div>
      </div>
    </div>
  );
}

把错误用 aria-live 播报出来

表单校验失败时,别只靠红色文字。加一段 aria-live="polite" 的区域,读屏器会自动把错误念出来。

<p aria-live="polite" className="error">{error ?? ""}</p>

做好这几步,弹窗对键盘用户和读屏器都可用。可访问性不是事后补丁,而是组件设计的第一公民。

T

test1

文章作者

为什么可访问性不是加分项 很多团队的组件库只保证鼠标能点、键盘能敲,却忘了屏幕阅读器用户。一个没有焦点管理、没有 AR...

分类
技术
发布时间
2026-10-24
字数
约 2555 字
阅读
18 次