无障碍设计在AI产品中的实践

WCAG 合规、屏幕阅读器支持与键盘导航的工程实现


1. AI 产品无障碍的特殊挑战

AI 产品在无障碍方面面临传统 Web 应用没有的挑战:动态内容流式生成、非确定性输出、多模态混合、以及对话式交互模式。这些特性使得标准的 WCAG 指南需要额外的解读和扩展。

传统 Web 应用:                     AI 产品:
- 内容预先确定                     - 内容实时生成
- 页面结构固定                     - 对话流不断增长
- 交互模式标准化                   - 提示词交互非标准
- 错误消息可预测                   - AI 可能给出错误/有害内容
- 加载时间短                       - 推理时间 5-30 秒

1.1 WCAG 2.1 四原则在 AI 产品中的映射

原则 WCAG 要求 AI 产品特殊需求
可感知 文字替代、字幕、对比度 流式文本的实时朗读、代码块的语义化
可操作 键盘、无时限、防癫痫 长等待状态的操控、动画安全
可理解 可读、可预测、纠错 AI 输出的可理解性、置信度传达
健壮 兼容辅助技术 动态 DOM 更新不破坏辅助技术

2. 屏幕阅读器支持

2.1 对话流的 ARIA 标记

<!-- 对话容器 -->
<div
  role="log"
  aria-label="与 AI 助手的对话"
  aria-live="polite"
  aria-relevant="additions"
>
  <!-- 用户消息 -->
  <article role="article" aria-label="你的消息" class="message message--user">
    <div class="message__author" aria-hidden="true">你</div>
    <div class="message__content">
      请解释量子计算的基本原理。
    </div>
    <time class="message__time" datetime="2026-02-28T10:30:00">
      10:30
    </time>
  </article>

  <!-- AI 回复 -->
  <article role="article" aria-label="AI 助手的回复" class="message message--ai">
    <div class="message__author" aria-hidden="true">AI 助手</div>
    <div class="message__content">
      <!-- 流式内容在这里累积 -->
      <p>量子计算是一种利用量子力学现象进行计算的技术...</p>
    </div>
    <footer class="message__meta">
      <span class="sr-only">回复完成</span>
      <time datetime="2026-02-28T10:30:15">10:30</time>
    </footer>
  </article>

  <!-- 思考状态 -->
  <div role="status" aria-live="polite" class="thinking-status">
    <span class="sr-only">AI 正在生成回复,请稍候</span>
    <div class="thinking-animation" aria-hidden="true">
      <!-- 视觉动画 -->
    </div>
  </div>
</div>

2.2 流式输出的无障碍处理

流式输出对屏幕阅读器是一个挑战:逐字朗读会极度烦人,但完全不通知又让用户无从得知状态。

// 策略: 按段落/句子批量通知,而非逐字
function StreamingAccessibility({ text, isStreaming }) {
  const [announcedText, setAnnouncedText] = useState('');
  const lastAnnouncedIndex = useRef(0);

  useEffect(() => {
    if (!isStreaming) {
      // 流式结束,通知完整内容
      setAnnouncedText('AI 回复完成。');
      return;
    }

    // 检测新句子/段落
    const newText = text.slice(lastAnnouncedIndex.current);
    const sentenceEnd = newText.match(/[。!?\n]/);

    if (sentenceEnd) {
      const sentence = newText.slice(0, sentenceEnd.index + 1);
      setAnnouncedText(sentence);
      lastAnnouncedIndex.current += sentenceEnd.index + 1;
    }
  }, [text, isStreaming]);

  return (
    <>
      {/* 视觉流式显示 */}
      <div aria-hidden="true">{text}</div>
      {/* 屏幕阅读器批量通知 */}
      <div
        role="status"
        aria-live="polite"
        aria-atomic="true"
        className="sr-only"
      >
        {announcedText}
      </div>
    </>
  );
}

2.3 代码块无障碍

<figure role="group" aria-label="Python 代码示例">
  <figcaption class="code-header">
    <span class="code-language">Python</span>
    <button
      class="copy-btn"
      aria-label="复制代码到剪贴板"
      data-copied-label="已复制"
    >
      复制
    </button>
  </figcaption>
  <pre role="region" aria-label="代码内容" tabindex="0">
    <code class="language-python">
def hello():
    print("Hello, World!")
    </code>
  </pre>
</figure>

3. 键盘导航

3.1 焦点管理策略

Tab 键导航顺序 (AI 聊天界面):
1. 模型选择器
2. 会话列表(侧栏)
3. 对话历史区域
4. 消息操作按钮(复制/赞/踩)
5. 提示词输入框
6. 附件按钮
7. 发送按钮
// 焦点陷阱: Modal 打开时焦点限制在 Modal 内
function useFocusTrap(ref: RefObject<HTMLElement>, active: boolean) {
  useEffect(() => {
    if (!active || !ref.current) return;

    const element = ref.current;
    const focusable = element.querySelectorAll(
      'a, button, input, textarea, select, [tabindex]:not([tabindex="-1"])'
    );
    const first = focusable[0] as HTMLElement;
    const last = focusable[focusable.length - 1] as HTMLElement;

    function handleKeyDown(e: KeyboardEvent) {
      if (e.key !== 'Tab') return;

      if (e.shiftKey) {
        if (document.activeElement === first) {
          e.preventDefault();
          last.focus();
        }
      } else {
        if (document.activeElement === last) {
          e.preventDefault();
          first.focus();
        }
      }
    }

    element.addEventListener('keydown', handleKeyDown);
    first?.focus();  // 打开时聚焦第一个元素

    return () => element.removeEventListener('keydown', handleKeyDown);
  }, [ref, active]);
}

3.2 快捷键设计

快捷键 功能 全局/局部
/ 聚焦提示词输入框 全局
Ctrl+Enter 发送消息 输入框内
Ctrl+Shift+C 复制最后一条 AI 回复 全局
Ctrl+N 新建对话 全局
Ctrl+K 打开命令面板 全局
Escape 关闭面板/Modal 全局
Ctrl+[ / ] 上/下一个对话 全局
Alt+T 切换侧栏 全局
// 全局快捷键注册
function useGlobalShortcuts(shortcuts: Record<string, () => void>) {
  useEffect(() => {
    function handler(e: KeyboardEvent) {
      const key = [
        e.ctrlKey && 'ctrl',
        e.shiftKey && 'shift',
        e.altKey && 'alt',
        e.key.toLowerCase()
      ].filter(Boolean).join('+');

      if (shortcuts[key]) {
        // 不在输入框中时才触发
        const target = e.target as HTMLElement;
        if (['INPUT', 'TEXTAREA'].includes(target.tagName) && key !== 'escape') {
          return;
        }
        e.preventDefault();
        shortcuts[key]();
      }
    }

    document.addEventListener('keydown', handler);
    return () => document.removeEventListener('keydown', handler);
  }, [shortcuts]);
}

3.3 焦点指示器

/* 清晰的焦点指示器 */
:focus-visible {
  outline: 2px solid var(--color-primary);
  outline-offset: 2px;
  border-radius: 2px;
}

/* 高对比度模式下增强 */
@media (forced-colors: active) {
  :focus-visible {
    outline: 3px solid Highlight;
    outline-offset: 3px;
  }
}

/* 焦点指示器不应被其他元素遮挡 */
:focus-visible {
  position: relative;
  z-index: 1;
}

4. 颜色与对比度

4.1 色彩不作为唯一信息载体

错误做法:
  成功: 绿色文字
  失败: 红色文字
  (色盲用户无法区分)

正确做法:
  成功: 绿色文字 + [v] 图标 + "成功" 文字标签
  失败: 红色文字 + [x] 图标 + "失败" 文字标签
<!-- 状态指示: 颜色 + 图标 + 文字 -->
<span class="status status--success">
  <svg aria-hidden="true" class="status__icon"><!-- 对勾图标 --></svg>
  <span>生成成功</span>
</span>

<span class="status status--error">
  <svg aria-hidden="true" class="status__icon"><!-- 错误图标 --></svg>
  <span>生成失败</span>
</span>

4.2 动态内容的对比度

AI 生成的内容可能包含代码高亮、引用块、表格等,需要确保所有元素的对比度:

function checkDynamicContentContrast(container: HTMLElement) {
  const elements = container.querySelectorAll('*');
  const issues: ContrastIssue[] = [];

  elements.forEach(el => {
    const style = getComputedStyle(el);
    const color = style.color;
    const bgColor = getEffectiveBackground(el);

    if (color && bgColor) {
      const ratio = calculateContrastRatio(color, bgColor);
      const fontSize = parseFloat(style.fontSize);
      const isBold = parseInt(style.fontWeight) >= 700;
      const isLarge = fontSize >= 18 || (fontSize >= 14 && isBold);
      const threshold = isLarge ? 3.0 : 4.5;

      if (ratio < threshold) {
        issues.push({
          element: el,
          ratio,
          threshold,
          color,
          bgColor
        });
      }
    }
  });

  return issues;
}

5. 表单无障碍

5.1 提示词输入框

<div class="prompt-area" role="search" aria-label="AI 对话">
  <label for="prompt-input" class="sr-only">
    输入您的问题或指令
  </label>

  <textarea
    id="prompt-input"
    aria-describedby="prompt-help token-count"
    aria-required="true"
    placeholder="输入您的问题..."
    rows="1"
  ></textarea>

  <div id="prompt-help" class="sr-only">
    按 Ctrl+Enter 发送消息。支持 Markdown 格式。
  </div>

  <div id="token-count" aria-live="polite" class="token-counter">
    <span class="sr-only">当前使用</span>
    <span>234</span>
    <span class="sr-only">个 Token,共</span>
    <span>/</span>
    <span>4096</span>
    <span class="sr-only">个可用</span>
  </div>

  <button
    type="submit"
    aria-label="发送消息"
    disabled={!hasContent}
  >
    <svg aria-hidden="true"><!-- 发送图标 --></svg>
  </button>
</div>

5.2 模型选择器

<div class="model-selector">
  <label id="model-label">选择模型</label>
  <div
    role="listbox"
    aria-labelledby="model-label"
    aria-activedescendant="model-sonnet"
    tabindex="0"
  >
    <div
      id="model-opus"
      role="option"
      aria-selected="false"
      aria-label="Claude Opus -- 最强能力,适合复杂任务,每次约0.15元"
    >
      <span class="model-name">Claude Opus</span>
      <span class="model-desc">最强能力</span>
    </div>
    <div
      id="model-sonnet"
      role="option"
      aria-selected="true"
      aria-label="Claude Sonnet -- 平衡推荐,适合日常使用,每次约0.03元"
    >
      <span class="model-name">Claude Sonnet</span>
      <span class="model-desc">平衡推荐</span>
    </div>
  </div>
</div>

6. 动态内容更新

6.1 ARIA Live Region 策略

区域 aria-live 值 用途
AI 回复区 polite 流式内容按句通知
错误通知 assertive 立即中断朗读
Token 计数 off (手动查询) 不自动朗读
状态栏 polite 模型切换、连接状态
Toast 通知 polite 操作反馈

6.2 批量 DOM 更新

// 避免频繁的 aria-live 触发
function useBatchedAnnounce(interval = 500) {
  const pending = useRef<string[]>([]);
  const announcer = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const timer = setInterval(() => {
      if (pending.current.length > 0 && announcer.current) {
        const message = pending.current.join('');
        pending.current = [];
        announcer.current.textContent = message;
      }
    }, interval);

    return () => clearInterval(timer);
  }, [interval]);

  function announce(text: string) {
    pending.current.push(text);
  }

  return { announce, announcer };
}

7. 运动与动画安全

7.1 前庭功能障碍防护

/* 系统级减少运动偏好 */
@media (prefers-reduced-motion: reduce) {
  /* 禁用所有非必要动画 */
  .thinking-animation {
    animation: none;
  }

  .streaming-cursor {
    animation: none;
    /* 用静态指示替代闪烁 */
    border-right: 2px solid var(--color-primary);
  }

  /* 流式文字直接显示,不逐字出现 */
  .stream-char {
    animation: none;
    opacity: 1;
  }

  /* 页面过渡瞬间完成 */
  .page-transition {
    transition-duration: 0.01ms;
  }
}

7.2 闪烁检测

// 检测是否有元素闪烁超过 3 次/秒
function checkFlashingContent(element) {
  const observer = new MutationObserver((mutations) => {
    let visibilityChanges = 0;
    const startTime = Date.now();

    mutations.forEach(mutation => {
      if (mutation.type === 'attributes' &&
          (mutation.attributeName === 'class' ||
           mutation.attributeName === 'style')) {
        visibilityChanges++;
      }
    });

    const elapsed = (Date.now() - startTime) / 1000;
    const flashRate = visibilityChanges / elapsed;

    if (flashRate > 3) {
      console.warn(`[A11y] Element flashing at ${flashRate.toFixed(1)}/s, exceeds 3/s limit`);
    }
  });

  observer.observe(element, { attributes: true, subtree: true });
  return observer;
}

8. 测试方法

8.1 自动化测试

// Jest + axe-core 自动化测试
import { axe, toHaveNoViolations } from 'jest-axe';
import { render } from '@testing-library/react';

expect.extend(toHaveNoViolations);

describe('ChatInterface Accessibility', () => {
  it('should have no accessibility violations', async () => {
    const { container } = render(<ChatInterface />);
    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });

  it('should announce new messages to screen readers', async () => {
    const { getByRole } = render(<ChatInterface />);
    const log = getByRole('log');
    expect(log).toHaveAttribute('aria-live', 'polite');
  });

  it('should trap focus in modal', async () => {
    const { getByRole } = render(<SettingsModal open />);
    const modal = getByRole('dialog');
    expect(modal).toContainFocusedElement();
  });
});

8.2 手动测试检查清单

测试项 方法 通过标准
键盘导航 纯键盘操作完整流程 所有功能可达
屏幕阅读器 VoiceOver / NVDA 内容正确朗读
对比度 axe DevTools 全部 AA 通过
缩放 200% 浏览器缩放到 200% 无内容丢失
减少运动 系统开启减少运动 无动画干扰
强制颜色 Windows 高对比度 界面可用

8.3 屏幕阅读器测试矩阵

屏幕阅读器 浏览器 平台 市场份额
NVDA Firefox / Chrome Windows 40%
JAWS Chrome / Edge Windows 30%
VoiceOver Safari macOS / iOS 20%
TalkBack Chrome Android 8%

9. WCAG 2.1 AA 合规清单

9.1 AI 产品关键合规项

WCAG 标准 要求 AI 产品实现
1.1.1 非文本内容 所有非文本内容有替代文本 图表有 alt text,图标有 aria-label
1.3.1 信息与关系 结构可通过程序确定 正确使用 heading、list、table 语义
1.4.3 对比度 文字对比度 >= 4.5:1 Token 系统保证所有主题下满足
1.4.4 文字缩放 200% 缩放不丢失内容 响应式布局,rem/em 单位
2.1.1 键盘 所有功能可通过键盘操作 完整的键盘导航方案
2.1.2 无键盘陷阱 焦点可以移出任何区域 正确的焦点管理
2.4.7 焦点可见 焦点指示器清晰可见 2px 实线轮廓
3.3.1 错误标识 自动识别输入错误 表单验证 + 错误消息
4.1.2 名称角色值 所有组件有名称和角色 ARIA 标记完整

10. 工具与资源

工具 用途 集成方式
axe-core 自动化检测 CI / Jest / 浏览器扩展
Lighthouse 综合审计 Chrome DevTools / CI
WAVE 可视化检测 浏览器扩展
pa11y CLI 检测 CI
eslint-plugin-jsx-a11y JSX 无障碍 lint ESLint
@testing-library/react 无障碍优先测试 Jest
Stark Figma 无障碍插件 设计阶段

Maurice | maurice_wen@proton.me