陌上人如玉
公子世无双

10.12 无障碍工程化

10.12 无障碍工程化

一句话:无障碍的第一原则是「能用原生 HTML 就用原生 HTML」(语义、键盘、屏幕阅读器全都白送),第二原则是「键盘能完成一切操作且焦点始终可见可知」——ARIA 只是「原生表达不了时的补丁」,而不是「装饰性的属性堆砌」。

一、标准:POUR 四原则与三个等级

⭐⭐ WCAG 的四条原则(POUR):

   ① 【可感知 Perceivable】——信息必须能「被感知」
      · 图片有替代文本、视频有字幕、颜色不是唯一的信息载体、对比度足够
   ② 【可操作 Operable】——界面必须能「被操作」
      · ⭐ 所有功能都能用键盘完成、焦点可见、不设时间陷阱、避免闪烁
   ③ 【可理解 Understandable】——内容与操作必须「可理解」
      · 语言声明、一致的导航、输入错误有明确提示与纠正建议
   ④ 【健壮 Robust】——必须能被「各种辅助技术」正确解析
      · ⭐ 用标准语义(原生元素/正确的 ARIA 角色与状态)

⭐ 等级:**A(最低)→ AA(普遍要求,多数法规/采购以此为线)→ AAA(更高)**
   ⭐ 实践的默认目标是「**WCAG 2.2 AA**」。

⭐⭐ WCAG 2.2 新增的几条(常被问到,且很实用):
   · 【Focus Not Obscured】获得焦点的元素不能被「粘性头部/浮层」完全遮住
   · 【Dragging Movements】拖拽必须提供「非拖拽的替代操作」(见 1.16)
   · 【Target Size (Minimum)】可点击目标至少 24×24 CSS px(AA)
   · 【Consistent Help】帮助入口的位置要一致
   · 【Accessible Authentication】不要求「记忆/转写」作为唯一认证手段

二、语义化 HTML:90% 的问题在写 HTML 时就解决了

<!-- ❌ 典型反例:全是 div(屏幕阅读器只能读出「一堆文本」) -->
<div class="header">
  <div class="nav">
    <div class="nav-item" onclick="go('/')">首页</div>
    <div class="nav-item" onclick="go('/about')">关于</div>
  </div>
</div>
<div class="content">
  <div class="title">产品介绍</div>
  <div class="text">...</div>
</div>

<!-- ✅ 语义化:屏幕阅读器能「跳转」到导航/主内容,键盘能 Tab 到链接,ESC/Enter 天然可用 -->
<header>
  <nav aria-label="主导航">
    <ul>
      <li><a href="/">首页</a></li>
      <li><a href="/about">关于</a></li>
    </ul>
  </nav>
</header>
<main id="main">
  <h1>产品介绍</h1>            <!-- ⭐ 页面唯一 h1,标题层级不跳级 -->
  <p>...</p>
</main>
<footer>…</footer>
⭐⭐⭐ 「用原生元素」白送的六件事(用 div + onclick 要自己补全部):

   <button>                        <div role="button" tabindex="0">
   ─────────────────────────────   ────────────────────────────────
   ✅ 可聚焦(Tab)                 ❌ 要加 tabindex
   ✅ Enter/Space 触发              ❌ 要手写 keydown 处理(且空格要 preventDefault)
   ✅ role=button(自带)           ❌ 要手写 role
   ✅ 语音控制可说「点击提交」        ⚠️ 可能要说「点击 div」
   ✅ 表单内 submit 语义             ❌ 完全没有
   ✅ focus/active/hover 样式默认可用  ❌ 要自己写
   ✅ 被 AT 正确播报为「按钮」        ❌ 可能被播报为「分组」

   ⭐ 结论:「**能用原生就别用 ARIA**」——这条(WAI-ARIA 第一条规则)能省掉 80% 的工作。
你要做的 用这个原生元素 不要这样
按钮 <button> <div role="button">
链接(跳转) <a href> <span onclick>
折叠/展开 <details>/<summary> <div> + aria-expanded 全套
对话框 <dialog>(showModal()) <div role="dialog"> + 手写焦点陷阱
进度 <progress> <div> + aria-valuenow
开关 <input type="checkbox"> <div role="switch">
分组表单 <fieldset>/<legend> <div> + aria-labelledby
提示(无需交互) title 属性 / 文本 <div role="tooltip">(复杂时)
搜索 <form role="search"> + <input type="search"> <div> 拼装
⭐⭐ ARIA 的五条规则(WAI-ARIA Authoring Practices 的「使用规则」):

   ① 【能用原生元素/属性实现,就不要用 ARIA】⭐ 最重要
   ② 【不要改变原生语义】(除非真的必要)——如 `<h1 role="button">` 是坏味道
   ③ 【所有可交互的 ARIA 控件都必须能用键盘操作】
   ④ 【不要对「可见且可访问的元素」用 `aria-hidden="true"`】(也不要给可聚焦元素加)
      ⚠️ 后果:「屏幕上能看到,但屏幕阅读器读不到」或「聚焦到一个『不存在』的元素」
   ⑤ 【所有 ARIA 元素必须有「可访问的名字」】
      ✅ `aria-label` / `aria-labelledby` / 元素内容(`<button>保存</button>` 本身就是名字)
⭐⭐⭐ 「可访问名字(Accessible Name)」的计算优先级(面试常问):

    ① `aria-labelledby`(引用其它元素的 id)—— 优先级最高
    ② `aria-label`(直接给字符串)
    ③ **元素自身的文本内容**(`<button>保存</button>` → 名字是「保存」)
    ④ `<label for>` / `<label>` 包裹(表单元素)
    ⑤ `title` 属性 —— 兜底(⭐ 不要依赖它:它也是「悬停提示」,会被屏幕阅读器「降级处理」)

    ⭐ 实践建议:
      · 按钮/链接:**用文本内容**(最稳)
      · 图标按钮:`aria-label="关闭"`(⭐ 必须!否则读作「按钮」)
      · 表单输入:`<label for>`
      · 区域/导航:`aria-label` 或 `aria-labelledby`
// ✅ 图标按钮的正确写法(这是最常见的无障碍缺陷)
// ❌ 错误:屏幕阅读器只读「按钮」,用户不知道点它会干嘛
<button onClick={onClose}><CloseIcon /></button>

// ✅ 正确
<button onClick={onClose} aria-label="关闭">
  <CloseIcon aria-hidden="true" />        {/* ⭐ 图标是装饰 → 从无障碍树里移除 */}
</button>

// ✅ 或者用「视觉隐藏但可读」的文本(不需要 aria-label)
<button onClick={onClose}>
  <CloseIcon aria-hidden="true" />
  <span className="sr-only">关闭</span>
</button>
/* ⭐ sr-only:视觉隐藏但「屏幕阅读器可读」(≠ display: none) */
.sr-only {
  position: absolute;
  width: 1px; height: 1px;
  padding: 0; margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}
/* ⚠️ 不要用 `display: none` 或 `visibility: hidden`(那会「从无障碍树里也移除」) */

三、键盘可达性:四条硬要求

⭐⭐⭐⭐ 键盘的「四条硬要求」(不满足就是「键盘用户无法使用」):

① 【能用 Tab 到达所有可交互元素】
   · 原生可聚焦元素(`a[href]`/`button`/`input`/`select`/`textarea`/[tabindex])天然可 Tab
   · ❌ `div onclick` 不可 Tab(必须加 `tabindex="0"` + `role` + 键盘处理 —— 见上面的表)
   · ⚠️ `tabindex="0"` 是「加入 Tab 顺序(按 DOM 顺序)」;
     `tabindex="-1"` 是「可编程聚焦但不进 Tab 顺序」(⭐ 用于「把焦点移到某个容器」)
     `tabindex > 0` ⭐ **几乎总是错的**(会打乱 DOM 顺序,维护噩梦)
   · `inert` 属性:让整棵子树「不可聚焦 + 从无障碍树移除」(⭐ 比 `aria-hidden` + `pointer-events: none` 更安全)

② 【焦点始终可见】
   · ⭐ 用 `:focus-visible` 给一个「清晰的轮廓」(见 10.8)
   · ❌ 绝对不能 `outline: none` 而不给替代(这是最严重的无障碍问题之一)
   · ⚠️ 浮层/粘性头部不能**完全遮住**获得焦点的元素(WCAG 2.2)

③ 【键盘能完成「一切」操作】
   · 点击能做的,键盘也能做(包括拖拽 —— 见 1.16)
   · 常见快捷键约定:`Enter`/`Space` 激活、`Esc` 关闭、
     `↑↓` 列表内移动(用 `aria-activedescendant` 或 roving tabindex)、
     `←→` 标签页切换、`Home`/`End` 首尾

④ 【焦点顺序合理】
   · 默认按 DOM 顺序(✅ 所以「DOM 顺序 = 视觉顺序 = 逻辑顺序」是根本原则)
   · ⚠️ 用 CSS 的 `order`/`flex-direction: row-reverse`/`grid` 重排时,
     **视觉顺序与 DOM 顺序不一致** → 键盘顺序会「跳来跳去」
   · ✅ 解法:改 DOM 顺序(而不是改 CSS 顺序);或接受并测一遍
// ✅ SkipLink:给键盘用户一个「跳过导航直达主内容」的入口
export function SkipLink({ targetId = 'main' }: { targetId?: string }) {
  return (
    <a
      href={`#${targetId}`}
      style={{
        position: 'absolute', left: 8, top: 8,
        // ⭐ 默认「移出视口」(视觉隐藏,但仍可聚焦)
        transform: 'translateY(-200%)',
        padding: '8px 12px', background: '#fff', color: '#000',
        border: '2px solid currentColor', borderRadius: 6, zIndex: 10000,
        transition: 'transform 120ms',
      }}
      onFocus={(e) => { e.currentTarget.style.transform = 'translateY(0)'; }}
      onBlur={(e) => { e.currentTarget.style.transform = 'translateY(-200%)'; }}
    >
      跳到主要内容
    </a>
  );
}
// ⚠️ 必须放在「body 的最前面」(第一个可聚焦元素)
// ⚠️ 目标元素需要 `tabIndex={-1}` 才能被「聚焦」(否则只是滚动过去)
//    <main id="main" tabIndex={-1}>…</main>
// ✅ 焦点陷阱(Modal 用):Tab 只在浮层内循环
export function useFocusTrap<T extends HTMLElement>(
  active: boolean,
  options: { initialFocus?: () => HTMLElement | null; restoreFocus?: boolean } = {}
) {
  const ref = useRef<T | null>(null);
  const previouslyFocused = useRef<HTMLElement | null>(null);

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

    // ⭐ 记住「打开前的焦点」,关闭时还回去
    previouslyFocused.current = document.activeElement as HTMLElement | null;

    const getFocusable = (): HTMLElement[] =>
      Array.from(
        container.querySelectorAll<HTMLElement>(
          'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), ' +
          'select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
        )
      ).filter((el) => el.offsetParent !== null || el === document.activeElement);   // ⭐ 过滤「不可见」的

    // ⭐ ① 初始聚焦(优先「业务指定」→ 第一个可聚焦元素 → 容器本身)
    const initial = options.initialFocus?.() ?? getFocusable()[0] ?? container;
    // ⚠️ 用 rAF 确保「浮层已完成布局」(否则聚焦可能失败)
    requestAnimationFrame(() => initial.focus());

    // ⭐ ② 拦截 Tab(首尾循环)
    const onKeyDown = (e: KeyboardEvent) => {
      if (e.key !== 'Tab') return;
      const focusable = getFocusable();
      if (focusable.length === 0) { e.preventDefault(); container.focus(); return; }

      const first = focusable[0]!;
      const last = focusable[focusable.length - 1]!;
      const current = document.activeElement;

      // 首部 Shift+Tab → 跳到最后
      if (e.shiftKey && (current === first || current === container)) {
        e.preventDefault();
        last.focus();
      }
      // 尾部 Tab → 跳到第一个
      else if (!e.shiftKey && current === last) {
        e.preventDefault();
        first.focus();
      }
    };

    // ⭐ ③ 焦点「意外逃出」时拉回来(如程序性 focus 到外部)
    const onFocusIn = (e: FocusEvent) => {
      if (!container.contains(e.target as Node)) {
        getFocusable()[0]?.focus();
      }
    };

    document.addEventListener('keydown', onKeyDown, true);
    document.addEventListener('focusin', onFocusIn);

    return () => {
      document.removeEventListener('keydown', onKeyDown, true);
      document.removeEventListener('focusin', onFocusIn);
      // ⭐ 关闭时「把焦点还给打开它的那个元素」
      if (options.restoreFocus !== false) previouslyFocused.current?.focus();
    };
  }, [active, options]);

  return ref;
}

四、屏幕阅读器:三个必须做对的地方

⭐⭐⭐ ① 【动态内容要「播报」】(aria-live)

   问题:React 更新 DOM 时,屏幕阅读器**不会主动播报**(用户不知道发生了什么)
   ✅ 解法:把「需要播报的消息」放进一个 `aria-live` 容器

   · `role="status"` = `aria-live="polite"`(⭐ 等用户空闲时播报)
   · `role="alert"` = `aria-live="assertive"`(⭐ 立刻打断播报,⚠️ 少用)
   · `aria-atomic="true"` → 播报「整个区域」而不是只播报变化的部分
   ⚠️ 容器必须「一开始就在 DOM 里」(先有容器、后改内容)——
     「插入一个带 aria-live 的新元素」通常**不会**被播报

⭐⭐⭐ ② 【图标/装饰内容要从无障碍树里移除】(aria-hidden)
   <span aria-hidden="true">▾</span>              {/* 装饰箭头 */}
   ❌ 不要给「可聚焦元素」加 aria-hidden
   ⚠️ 不要给「包含可聚焦子元素」的容器加 aria-hidden(会形成「可聚焦但不可见」的陷阱)

⭐⭐⭐ ③ 【状态的表达要用「状态属性」而不是「视觉类名」】
   ❌ <div className="active">当前页</div>
   ✅ <a aria-current="page">当前页</a>
   ✅ <button aria-expanded="true">已展开</button>
   ✅ <input aria-invalid="true" aria-describedby="err-1" />
   ✅ <div role="progressbar" aria-valuenow={30} aria-valuemin={0} aria-valuemax={100} />
// ✅ 一个可用的「播报器」(全局单例 + Hook)
let container: HTMLElement | null = null;

function ensureContainer() {
  if (container) return container;
  container = document.createElement('div');
  container.setAttribute('aria-live', 'polite');
  container.setAttribute('aria-atomic', 'true');
  // ⭐ 视觉隐藏但「留在无障碍树里」
  container.className = 'sr-only';
  container.id = 'a11y-announcer';
  document.body.appendChild(container);
  return container;
}

export function announce(message: string, priority: 'polite' | 'assertive' = 'polite') {
  if (typeof document === 'undefined') return;
  const el = ensureContainer();
  el.setAttribute('aria-live', priority);

  // ⚠️ 关键:**先清空再写入**(否则「内容没变」时不会重新播报)
  el.textContent = '';
  // ⭐ 用 rAF/微任务让「清空」与「写入」成为两次变更
  setTimeout(() => { el.textContent = message; }, 30);
}

/** ⭐ 在 React 里用(配合「操作结果」播报) */
export function useAnnounce() {
  return useCallback((message: string, priority: 'polite' | 'assertive' = 'polite') => {
    announce(message, priority);
  }, []);
}

// 用法:拖拽排序后
announce(`已移动到第 ${index + 1} 位,共 ${total} 项`);
// 用法:表单提交失败
announce('提交失败:请检查表单中的错误', 'assertive');
// ✅ 一个「无障碍完整」的表单字段(label / 描述 / 错误三者关联)
export function Field({
  id: idProp,
  label,
  description,
  error,
  required,
  ...inputProps
}: {
  id?: string;
  label: string;
  description?: string;
  error?: string;
  required?: boolean;
} & React.InputHTMLAttributes<HTMLInputElement>) {
  const autoId = useId();
  const id = idProp ?? autoId;
  const descId = description ? `${id}-desc` : undefined;
  const errId = error ? `${id}-err` : undefined;

  return (
    <div>
      <label htmlFor={id}>
        {label}
        {/* ⭐ 必填用「文本」而不是只用星号(星号对屏幕阅读器没意义) */}
        {required && <span aria-hidden="true"> *</span>}
        {required && <span className="sr-only">(必填)</span>}
      </label>

      {description && <p id={descId}>{description}</p>}

      <input
        {...inputProps}
        id={id}
        required={required}
        // ⭐ 状态:「无效」用 aria-invalid,而不是只加红色边框
        aria-invalid={error ? true : undefined}
        // ⭐ 把「描述」与「错误」都关联上(屏幕阅读器会一起读)
        aria-describedby={[descId, errId].filter(Boolean).join(' ') || undefined}
      />

      {/* ⭐ 错误用 role="alert"(立刻播报)+ 文本说明(不只是颜色) */}
      {error && (
        <p id={errId} role="alert" style={{ color: 'var(--color-text-danger)' }}>
          {error}
        </p>
      )}
    </div>
  );
}

五、视觉无障碍:对比度与偏好

⭐⭐⭐ 三件事:

① 【对比度】(WCAG AA)
   · 普通文本:**≥ 4.5:1**
   · 大文本(≥18.66px 粗体 或 ≥24px):**≥ 3:1**
   · **UI 组件边界与图形**(如图标、输入框边框、焦点轮廓):**≥ 3:1**
   ⚠️ 最常见的缺陷:「浅灰占位符文字」「禁用态文字」「浅色主题下的次要文字」
   ✅ 工具:浏览器 DevTools 的「对比度」显示;或 axe / Lighthouse

② 【不要把颜色当成唯一的信息载体】
   ❌ 「红色表示错误」(色盲用户看不出)
   ✅ 「红色 + 图标 + 文本」(三重表达)
   ❌ 「只靠颜色区分图表系列」
   ✅ 「颜色 + 图案/形状 + 图例文字」

③ 【尊重用户偏好】
   · `prefers-reduced-motion: reduce` → 降低/取消动画(见 1.15)
   · `prefers-contrast: more` → 提高对比度
   · `prefers-color-scheme` → 明暗主题(见 10.8)
   · `forced-colors`(Windows 高对比模式)→ ⭐ 不要用硬编码颜色,用系统颜色关键字
/* ✅ 尊重「减少动态效果」 */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

/* ✅ 尊重「更高对比度」 */
@media (prefers-contrast: more) {
  :root {
    --color-border: #000;
    --color-text-secondary: #333;
  }
  .btn { border-width: 2px; }
}

/* ✅ 尊重「强制颜色模式(Windows 高对比)」(用系统颜色关键字) */
@media (forced-colors: active) {
  .card {
    /* ⭐ 不要硬编码颜色,用系统关键字(浏览器会替换成用户的配色) */
    border: 1px solid CanvasText;
    background: Canvas;
    color: CanvasText;
    /* ⚠️ 用 box-shadow 做的「边框」在强制颜色下会消失 → 用真实 border */
  }
  /* ⭐ 图标用 currentColor 才能在强制颜色下跟随文字颜色 */
  .icon { fill: currentColor; }
}

六、测试:四层方法(只有第一层能自动化)

⭐⭐⭐ 无障碍测试的四层(覆盖率互补,缺一不可):

① 【自动工具】(能覆盖约 **30~50%** 的问题)
   · axe-core(jest-axe / @axe-core/playwright)、Lighthouse、Pa11y
   · 能查:缺 label、对比度、role 错误、重复 id、缺失 lang…
   · ⭐ 集成到 CI(每次 PR 自动跑),但**绝不能只靠它**

② 【键盘走查】(成本低、收益极高)⭐
   · 只用 Tab/Shift+Tab/Enter/Space/Esc/方向键,走完「主要流程」
   · 检查:能否到达所有交互元素、焦点是否可见、顺序是否合理、
     能否完成「提交表单」「关闭弹窗」「拖拽排序」

③ 【屏幕阅读器实测】(成本中、能发现语义问题)
   · macOS: VoiceOver(⌘F5);Windows: NVDA(免费);iOS: VoiceOver
   · ⭐ 重点听:按钮/链接是否被正确播报、表单错误是否被播报、
     动态内容(toast/加载完成/拖拽结果)是否被播报

④ 【真实用户测试】(成本高、最有价值)
   · ⭐ 只有「用辅助技术的真实用户」能发现「能操作但很难用」的问题
// ✅ 把 axe 集成到测试里(Vitest + jsdom)
// setup.ts
import { expect } from 'vitest';
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);

// Field.test.tsx
import { render } from '@testing-library/react';
import { describe, it } from 'vitest';

describe('Field', () => {
  it('没有无障碍违规', async () => {
    const { container } = render(<Field label="邮箱" required description="用于登录" />);
    expect(await axe(container)).toHaveNoViolations();
  });

  it('错误能被屏幕阅读器关联', () => {
    const { getByLabelText, getByRole } = render(
      <Field label="邮箱" error="格式不正确" />
    );
    // ⭐ 通过 label 找到输入(这本身就在校验「label 关联正确」)
    const input = getByLabelText(/邮箱/);
    // ⭐ 错误用 role="alert"(可被播报)
    expect(getByRole('alert')).toHaveTextContent('格式不正确');
    // ⭐ 且输入框通过 aria-describedby 关联了错误
    expect(input).toHaveAttribute('aria-describedby', expect.stringContaining('-err'));
  });
});

七、完整实现:一个无障碍的 Modal(230 行)

// AccessibleModal.tsx —— 把所有无障碍要点集成到一个组件里
import { useRef, useEffect, useId, useState } from 'react';
import { createPortal } from 'react-dom';
import { useFocusTrap } from './useFocusTrap';
import { usePortalContainer } from './portal';   // 见 1.12
import { useScrollLock } from './useScrollLock';

export function AccessibleModal({
  open,
  onClose,
  title,
  description,
  children,
  /** 是否「必须做出选择」(不能点背景关闭) */
  dismissible = true,
}: {
  open: boolean;
  onClose: () => void;
  title: string;
  description?: string;
  children: React.ReactNode;
  dismissible?: boolean;
}) {
  const container = usePortalContainer('modal');
  const titleId = useId();
  const descId = useId();
  const panelRef = useRef<HTMLDivElement | null>(null);

  // ⭐ 焦点陷阱(含「关闭后焦点还原」)
  useFocusTrap<HTMLDivElement>(open, { restoreFocus: true });
  // ⚠️ 上面这个 Hook 需要把 ref 绑到容器上(这里演示用另一种写法)
  const trapRef = useFocusTrap<HTMLDivElement>(open);

  // ⭐ 滚动锁(用「计数器」,支持嵌套弹窗,见 1.12)
  useScrollLock(open);

  // ⭐ ESC 关闭(层叠时只关最上层 —— 真实项目用「浮层栈」,见 1.12)
  useEffect(() => {
    if (!open || !dismissible) return;
    const onKeyDown = (e: KeyboardEvent) => {
      if (e.key === 'Escape') { e.stopPropagation(); onClose(); }
    };
    document.addEventListener('keydown', onKeyDown, true);
    return () => document.removeEventListener('keydown', onKeyDown, true);
  }, [open, dismissible, onClose]);

  // ⭐⭐ 关键:把「弹窗之外的整棵应用」设为 inert
  //    —— 这是「最安全」的背景隔离方式:
  //       它同时做到「不可聚焦」「不可点击」「从无障碍树移除」
  useEffect(() => {
    if (!open) return;
    const root = document.getElementById('root') ?? document.body;
    const siblings = Array.from(root.children).filter((el) => el !== container);
    siblings.forEach((el) => el.setAttribute('inert', ''));

    // ⚠️ 兼容性兜底:老浏览器不支持 inert → 用 aria-hidden + 阻止焦点
    return () => { siblings.forEach((el) => el.removeAttribute('inert')); };
  }, [open, container]);

  if (!open || !container) return null;

  return createPortal(
    <>
      {/* ⭐ 遮罩:点击关闭(但只在 dismissible 时) */}
      {dismissible && (
        <div
          onClick={onClose}
          aria-hidden="true"                       // ⭐ 遮罩是「装饰」→ 不参与无障碍树
          style={{
            position: 'fixed', inset: 0, zIndex: 'var(--z-backdrop, 2000)',
            background: 'rgba(0,0,0,.45)',
          }}
        />
      )}

      <div
        ref={trapRef}
        role="dialog"
        // ⭐⭐ aria-modal="true" 告诉辅助技术「这是模态,外面的内容不可用」
        aria-modal="true"
        // ⭐ 用 aria-labelledby 指向标题(而不是 aria-label 写死字符串)
        aria-labelledby={titleId}
        aria-describedby={description ? descId : undefined}
        style={{
          position: 'fixed', top: '50%', left: '50%',
          transform: 'translate(-50%, -50%)',
          zIndex: 'var(--z-modal, 2100)',
          background: 'var(--color-bg-surface)',
          color: 'var(--color-text-primary)',
          borderRadius: 'var(--radius-lg)',
          padding: 'var(--space-6)',
          minWidth: 320, maxWidth: 'min(560px, 92vw)',
          maxHeight: '85vh', overflowY: 'auto',
        }}
      >
        {/* ⭐ h2 与 aria-labelledby 关联(标题层级也要正确) */}
        <h2 id={titleId} style={{ marginTop: 0 }}>{title}</h2>

        {description && <p id={descId} style={{ color: 'var(--color-text-secondary)' }}>{description}</p>}

        {children}

        <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 20 }}>
          <button onClick={onClose}>关闭</button>
        </div>
      </div>
    </>,
    container
  );
}
/** ============ 一个「无障碍的 Tabs」(roving tabindex 模式) ============ */
export function Tabs({
  tabs,
  value,
  onChange,
}: {
  tabs: Array<{ id: string; label: string; content: React.ReactNode }>;
  value: string;
  onChange: (id: string) => void;
}) {
  const listRef = useRef<HTMLDivElement | null>(null);

  /** ⭐ 键盘:左右键在标签间移动(roving tabindex),Home/End 跳首尾 */
  const onKeyDown = (e: React.KeyboardEvent) => {
    const index = tabs.findIndex((t) => t.id === value);
    let next = -1;

    if (e.key === 'ArrowRight') next = (index + 1) % tabs.length;
    else if (e.key === 'ArrowLeft') next = (index - 1 + tabs.length) % tabs.length;
    else if (e.key === 'Home') next = 0;
    else if (e.key === 'End') next = tabs.length - 1;
    else return;

    e.preventDefault();                       // ⭐ 阻止「页面横向滚动」
    const nextId = tabs[next]!.id;
    onChange(nextId);
    // ⭐ 移动后把焦点移到新的标签上(否则焦点还在原地)
    requestAnimationFrame(() => {
      listRef.current
        ?.querySelector<HTMLElement>(`[data-tab="${CSS.escape(nextId)}"]`)
        ?.focus();
    });
  };

  const active = tabs.find((t) => t.id === value);

  return (
    <div>
      <div ref={listRef} role="tablist" aria-label="内容切换" onKeyDown={onKeyDown}>
        {tabs.map((tab) => {
          const selected = tab.id === value;
          return (
            <button
              key={tab.id}
              data-tab={tab.id}
              role="tab"
              id={`tab-${tab.id}`}
              // ⭐ roving tabindex:只有「选中的」在 Tab 顺序里,其余用方向键访问
              tabIndex={selected ? 0 : -1}
              aria-selected={selected}
              // ⭐ 关联面板
              aria-controls={`panel-${tab.id}`}
              onClick={() => onChange(tab.id)}
              style={{ borderBottom: selected ? '2px solid var(--color-primary)' : 'none' }}
            >
              {tab.label}
            </button>
          );
        })}
      </div>

      {active && (
        <div
          role="tabpanel"
          id={`panel-${active.id}`}
          aria-labelledby={`tab-${active.id}`}
          // ⭐ 面板需要可聚焦(否则键盘用户无法「进入面板内容」阅读)
          tabIndex={0}
          style={{ paddingTop: 12 }}
        >
          {active.content}
        </div>
      )}
    </div>
  );
}
⭐⭐⭐ 这两个组件演示了四个「进阶但必要」的无障碍模式:

① 【`inert` 做背景隔离】(比 `aria-hidden` + `pointer-events: none` 更彻底)
   它同时做到「不可聚焦 + 不可点击 + 从无障碍树移除」——
   ⭐ 避免「Tab 跑到背景内容上」这个最常见的模态缺陷。

② 【`aria-modal` + `aria-labelledby` + `aria-describedby`】
   · `aria-modal="true"` 明确「模态」语义
   · 标题/描述用「引用」(而不是把字符串写进 `aria-label`)——
     ⭐ 好处:文案改了不用同步改 aria-label(单一事实源)

③ 【roving tabindex(Tabs 的标准模式)】
   只有「当前选中项」在 Tab 顺序里(`tabIndex=0`),其余是 `-1`,
   用**方向键**在它们之间移动。
   ⭐ 这样「Tab 一次就能跳过整组标签」(而不是「按 8 次 Tab 才走到下一个控件」)。

④ 【`role="tabpanel"` 要 `tabIndex={0}`】
   因为面板里可能「没有可聚焦元素」(纯文本)——
   不给它 `tabIndex`,键盘用户**无法「进入」面板滚动/阅读**。

八、本篇特有的坑

// ① 用 `div` + `onclick` 做按钮(键盘无法使用)
<div className="btn" onClick={save}>保存</div>                  // ❌
// ✅ <button onClick={save}>保存</button>

// ② `outline: none` 去掉焦点轮廓且不给替代(键盘用户「迷路」)
*:focus { outline: none; }                                     // ❌❌
// ✅ :focus-visible + 清晰的 outline

// ③ 图标按钮没有可访问名字(读作「按钮」)
<button onClick={x}><IconClose /></button>                      // ❌
// ✅ aria-label="关闭"

// ④ `aria-hidden="true"` 加在「包含可聚焦元素」的容器上
<div aria-hidden="true"><button>提交</button></div>             // ❌ 可聚焦但不可见
// ✅ 用 `inert`(或把焦点移走,或改成 display:none)

// ⑤ 用 `display: none` 做「视觉隐藏的辅助文本」(屏幕阅读器也读不到)
.sr-only { display: none; }                                     // ❌
// ✅ clip + position: absolute(见上面的 sr-only)

// ⑥ 弹窗打开后「焦点没被移进去」(用户还在背景上 Tab)
// ✅ useFocusTrap:初始聚焦 + 拦住 Tab

// ⑦ 弹窗关闭后「焦点丢了」(回到 body)→ 用户「不知道自己在哪」
// ✅ 记住打开前的 activeElement,关闭时 focus 回去

// ⑧ 背景内容仍可被 Tab 到(模态缺陷)
// ✅ `inert` 或 `aria-hidden` + 真正的焦点陷阱

// ⑨ 动态内容不播报(toast/加载完成/错误)
// ✅ aria-live 容器 + announce()

// ⑩ 用 `aria-live` 但「容器是后插入的」(不会被播报)
// ✅ 容器一开始就在 DOM 里(本实现的 ensureContainer 是「懒创建 + 复用」,
//    所以第一次播报可能失效 → ⭐ 更好的做法是「在应用入口就创建好」)

// ⑪ 「错误只用颜色表达」
<span style={{ color: 'red' }}>{error}</span>                   // ⚠️ 色盲用户看不出
// ✅ 颜色 + 图标 + 文本 + aria-invalid

// ⑫ 表单输入没有关联 label(屏幕阅读器读作「编辑框」)
<input placeholder="请输入邮箱" />                              // ⚠️ placeholder 不是 label
// ✅ <label htmlFor> + 关联(placeholder 只能作「补充」)

// ⑬ 必填只用一个星号
<label>邮箱 *</label>                                          // ⚠️ 「*」对 AT 无意义
// ✅ `required` 属性 + `<span class="sr-only">(必填)</span>`

// ⑭ `tabindex="1"` 之类的大于 0(打乱 DOM 顺序)
// ✅ 只用 0 与 -1;调整顺序靠「改 DOM 顺序」

// ⑮ 用 CSS `order`/`row-reverse` 重排(视觉与 DOM 顺序不一致)
// ⚠️ 键盘顺序会「跳来跳去」;屏幕阅读器读的顺序也与视觉不符
// ✅ 改 DOM 顺序

// ⑯ 「自动聚焦」抢走用户的焦点(页面加载就把焦点抢到搜索框)
// ⚠️ 会让屏幕阅读器用户「错过前面的内容」
// ✅ 只在「明确需要」时 autoFocus(如表单校验失败后聚焦第一个错误字段)

// ⑰ 拖拽没有键盘替代(WCAG 2.2 明确要求)
// ✅ 见 1.16(空格拾起 + 方向键 + 上移/下移按钮)

// ⑱ 「点击目标太小」(移动端误触)
// ⚠️ WCAG 2.2 要求 ≥ 24×24(AA);实际建议 ≥ 44×44
// ✅ 用 padding 扩大「点击热区」而不是只放大图标

// ⑲ 粘性头部「遮住」获得焦点的元素(WCAG 2.2 明确点名)
// ✅ 给可聚焦元素加 `scroll-margin-top: var(--sticky-header-height)`(见 1.14)

// ⑳ 只在「最终开发完」才做无障碍(返工成本极高)
// ⭐ ✅ 正确做法:① 用「语义化 HTML」写(白送大半)② Lint 约束(eslint-plugin-jsx-a11y)
//    ③ CI 跑 axe ④ 键盘走查纳入 DoD

// ㉑ `role="alert"` 用得太频繁(每次输入都「立刻打断」播报)
// ⚠️ assertive 会打断用户当前正在听的内容
// ✅ 只有「错误/紧急」用 assertive;「成功/一般状态」用 polite

// ㉒ 忽略「屏幕阅读器的『浏览模式』与『表单模式』」
// ⭐ 在浏览模式下「方向键是读内容」;在表单模式下「方向键是操作控件」
//    → 自定义控件(如用 div 做的列表)行为要与「模式预期」一致
// ⑲ 的完整处理(焦点不被粘性头部遮住)
// 全局样式:
// :root { --sticky-header-height: 64px; }
// a:focus-visible, button:focus-visible, input:focus-visible, [tabindex]:focus-visible {
//   scroll-margin-top: calc(var(--sticky-header-height) + 12px);
// }
// ⭐ `scroll-margin-top` 让「浏览器滚动到该元素」时留出空间(不会被吸顶头挡住)
// ⑳ 的完整处理(把 a11y 纳入「完成定义」)
// .eslintrc / eslint.config.js
import jsxA11y from 'eslint-plugin-jsx-a11y';

export default [
  {
    plugins: { 'jsx-a11y': jsxA11y },
    rules: {
      ...jsxA11y.flatConfigs.recommended.rules,
      // ⭐ 把最关键的几条「升级为 error」
      'jsx-a11y/alt-text': 'error',
      'jsx-a11y/anchor-is-valid': 'error',
      'jsx-a11y/click-events-have-key-events': 'error',
      'jsx-a11y/no-static-element-interactions': 'error',
      'jsx-a11y/label-has-associated-control': 'error',
      'jsx-a11y/no-autofocus': 'warn',
    },
  },
];

九、面试延伸

  1. 「WCAG 的四条原则是什么?」
    POUR:① 可感知(Perceivable)——信息必须能被感知(图片有替文本、视频有字幕、颜色不是唯一信息载体、对比度足够);② 可操作(Operable)——⭐ 所有功能都能用键盘完成、焦点可见、不设时间陷阱、避免闪烁;③ 可理解(Understandable)——语言声明、导航一致、错误有明确提示与纠正建议;④ 健壮(Robust)——⭐ 用标准语义(原生元素或正确的 ARIA 角色与状态)以便各种辅助技术解析。等级是 A → AA(多数法规/采购的线) → AAA,实践目标是「WCAG 2.2 AA」。

  2. 「为什么说『能用原生 HTML 就别用 ARIA』?」
    因为原生元素白送六件事:① 可聚焦(Tab);② 键盘激活(Enter/Space,且空格会自动 preventDefault);③ 正确的 role(被播报为「按钮」);④ 语音控制友好(能说「点击提交」);⑤ 表单语义(表单内 submit、label 关联、required 校验);⑥ 默认的 focus/active/hover 样式行为。而用 <div role="button"> 要全部自己补,补漏一项就是缺陷。⭐ 这就是 WAI-ARIA 第一条规则:「如果原生 HTML 元素或属性能实现相同的语义与行为,就不要用 ARIA」——ARIA 只是「原生表达不了时的补丁」。

  3. 「ARIA 的五条规则是什么?」
    ① ⭐ 能用原生实现就别的用 ARIA;② 不要改变原生语义(如 <h1 role="button"> 是坏味道);③ ⭐ 所有可交互的 ARIA 控件都必须能用键盘操作;④ ⭐ 不要对「可见且可访问的元素」用 aria-hidden="true"(也不要给可聚焦元素加——会形成「能聚焦但不可见」的陷阱,正确的做法是 inert);⑤ 所有 ARIA 元素必须有「可访问的名字」(aria-labelledby > aria-label > 内容 > label > title)。⭐ 实践总结:「ARIA 的第一规则是不用 ARIA」。

  4. 「可访问名字(Accessible Name)是怎么算的?」
    优先级:① aria-labelledby(引用其它元素的 id)——最高;② aria-label(直接字符串);③ ⭐ 元素自身的文本内容(<button>保存</button> → 名字是「保存」);④ <label for> / <label> 包裹(表单元素);⑤ title——兜底(⚠️ 不要依赖:它同时是「悬停提示」,且屏幕阅读器的处理不一致)。实践建议:按钮/链接用文本内容(最稳);图标按钮必须给 aria-label(否则读作「按钮」);表单输入用 <label for>;区域/导航用 aria-label 或 aria-labelledby。

  5. 「键盘可达性有哪些硬要求?」
    四条:① ⭐ 能用 Tab 到达所有可交互元素——原生可聚焦元素天然可以,div onclick 必须加 tabindex="0" + role + 键盘处理(所以直接用 <button>);⚠️ tabindex > 0 几乎总是错的(打乱 DOM 顺序);② ⭐ 焦点始终可见——用 :focus-visible 给清晰轮廓,绝不能 outline: none 而不给替代,且浮层/粘性头部不能「完全遮住」获得焦点的元素(WCAG 2.2,用 scroll-margin-top 解决);③ ⭐ 键盘能完成一切操作(包括拖拽——见 1.16);④ 焦点顺序合理——默认按 DOM 顺序,⚠️ 所以「用 CSS order/row-reverse 重排会让键盘顺序与视觉顺序不一致」,正确做法是改 DOM 顺序。

  6. 「模态弹窗的无障碍要点有哪些?」
    六个:① ⭐ 焦点陷阱——打开时把焦点移进去(优先「业务指定」→ 第一个可聚焦元素),Tab 在首尾循环,且焦点意外逃出时拉回来;② ⭐ 背景隔离——用 inert 把「弹窗之外的整棵应用」设为不可聚焦 + 不可点击 + 从无障碍树移除(比 aria-hidden + pointer-events: none 更彻底,避免「Tab 跑到背景」);③ 语义——role="dialog" + ⭐ aria-modal="true" + aria-labelledby 指向标题 + aria-describedby 指向描述(⭐ 用引用而不是把字符串写进 aria-label——单一事实源);④ 关闭后焦点还原——记住打开前的 activeElement,关闭时 focus() 回去(否则用户「不知道自己在哪」);⑤ ESC 关闭(嵌套时只关最上层);⑥ 滚动锁用计数器(嵌套弹窗不会提前解锁,见 1.12)。⭐ 更省心的做法是直接用原生 <dialog showModal()>(自动焦点陷阱 + ::backdrop + ESC)。

  7. 「动态内容怎么让屏幕阅读器知道?」
    用 aria-live 区域:① role="status"(= aria-live="polite" + aria-atomic="true")——⭐ 等用户空闲时播报,适合「加载完成」「已保存」;② role="alert"(= assertive)——⭐ 立刻打断播报,只用于「错误/紧急」,⚠️ 滥用会让用户「一直被打断」;③ aria-atomic="true" 表示「播报整个区域」而不是只播报变化的那部分。⚠️ 两个关键细节:容器必须一开始就在 DOM 里(「新插入一个带 aria-live 的元素」通常不会被播报);要「先清空再写入」(内容没变时不会重新播报)。实践做法是「在应用入口创建一个全局 sr-only 的播报容器 + 一个 announce(message) 函数」。

  8. 「无障碍怎么测?」
    四层,只有第一层能自动化:① ⭐ 自动工具(覆盖约 30~50%)——axe-core(jest-axe / @axe-core/playwright)、Lighthouse、Pa11y,能查「缺 label、对比度、role 错误、重复 id、缺 lang」,应集成进 CI,但绝不能只靠它;② ⭐ 键盘走查(成本最低、收益最高)——只用 Tab/Shift+Tab/Enter/Space/Esc/方向键走完「主要流程」(能否到达、焦点是否可见、顺序是否合理、能否完成提交/关闭/拖拽);③ 屏幕阅读器实测——VoiceOver(macOS ⌘F5)/ NVDA(Windows 免费),⭐ 重点听「按钮/链接是否被正确播报、表单错误是否被播报、动态内容是否被播报」;④ ⭐ 真实用户测试——只有「用辅助技术的真实用户」能发现「能操作但很难用」的问题。工程化:把「键盘走查」写进 DoD + ESLint 的 jsx-a11y 规则 + CI 跑 axe。

一句话速记

无障碍的第一原则是「能用原生 HTML 就用原生 HTML」(语义/键盘/role/语音控制/表单语义/默认样式全都白送,用 div + onclick 要自己补全全部)——这就是 ARIA 第一规则;ARIA 五规则:能原生就不用 ARIA、不改原生语义、ARIA 控件必须键盘可用、别对可见可访问元素用 aria-hidden(用 inert)、必须有可访问名字(aria-labelledby > aria-label > 内容 > label > title);键盘四条硬要求:Tab 能到达一切(tabindex > 0 几乎总是错的)、焦点必须可见(:focus-visible,绝不能 outline: none 无替代)、键盘能完成一切(含拖拽)、焦点顺序合理(DOM 顺序 = 视觉顺序,别用 CSS order 重排);模态六要点:焦点陷阱 + ⭐ inert 背景隔离 + role="dialog"/aria-modal/aria-labelledby + 关闭还焦点 + ESC + 滚动锁计数器(或直接用原生 <dialog>);动态内容用 aria-live(role="status" 宽松 / role="alert" 打断,⚠️ 容器必须一开始就在 DOM 里 + 先清空再写入);测试四层:axe(30~50%,进 CI)→ 键盘走查(最划算) → 屏幕阅读器 → 真实用户;视觉无障碍:对比度 4.5:1/3:1、不靠颜色传达信息、尊重 prefers-reduced-motion/prefers-contrast/forced-colors。

赞(0) 打赏
未经允许不得转载:陌上寒 » 10.12 无障碍工程化

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫