网页可访问性Skill accessibility

网页可访问性技能专注于确保网站和应用程序对所有人,包括残障人士,都是可用的。它涵盖WCAG 2.1合规性检查、语义HTML编写、ARIA属性使用、键盘导航实现、屏幕阅读器测试、颜色对比度优化、焦点管理以及自动化可访问性测试。关键词:网页可访问性、WCAG、ARIA、键盘导航、屏幕阅读器、语义HTML、焦点管理、自动化测试、包容性设计、视觉可访问性。

前端开发 0 次安装 0 次浏览 更新于 3/24/2026

name: accessibility description: 网页可访问性模式、WCAG合规性和包容性设计。当实现可访问的用户界面、键盘导航、屏幕阅读器支持、焦点管理、语义HTML或审核合规性时使用。触发词:可访问性、a11y、WCAG、ARIA、屏幕阅读器、键盘导航、焦点、标签顺序、tabindex、替代文本、颜色对比度、语义HTML、地标、角色、aria-label、aria-labelledby、aria-describedby、aria-live、aria-expanded、aria-selected、aria-hidden、焦点陷阱、轮转tabindex、跳过链接、辅助技术。

可访问性

概述

网页可访问性确保网站和应用程序对所有人,包括残障人士,都是可用的。这个技能涵盖WCAG 2.1合规性、语义HTML、ARIA模式、键盘导航、屏幕阅读器测试、视觉可访问性、焦点管理和自动化测试。

这个技能结合了工程实施和设计考虑 - 在构建可访问的用户界面、审核合规性或设计包容性用户体验时使用。

说明

1. WCAG 2.1 合规性检查清单

WCAG围绕四个原则(POUR)组织:

原则 描述 关键指南
可感知 信息必须对用户可呈现 文本替代、字幕、可适应内容
可操作 界面必须可操作 键盘可访问、足够时间、无发作
可理解 信息和操作必须可理解 可读、可预测、输入协助
健壮 内容必须对辅助技术健壮 与当前和未来工具兼容

一致性级别

  • 级别 A: 最小可访问性(必须有)
  • 级别 AA: 解决主要障碍(在许多司法管辖区是法律要求)
  • 级别 AAA: 最高级别(对特定受众来说是锦上添花)

快速合规性检查清单

级别 A(必需):

  • 所有图像都有替代文本(1.1.1)
  • 视频有字幕(1.2.2)
  • 颜色不是传达信息的唯一视觉手段(1.4.1)
  • 所有功能都可以通过键盘访问(2.1.1)
  • 用户可以暂停、停止或隐藏移动内容(2.2.2)
  • 页面有标题(2.4.2)
  • 焦点顺序保持意义(2.4.3)
  • 链接目的从文本或上下文中清晰(2.4.4)
  • 页面有标题和标签(2.4.6)
  • 表单有标签或说明(3.3.2)
  • 解析:无重复ID、适当嵌套(4.1.1)
  • 所有UI组件的名称、角色、值可用(4.1.2)

级别 AA(标准):

  • 所有实时音频的字幕(1.2.4)
  • 视频的音频描述(1.2.5)
  • 正常文本的对比度至少为4.5:1,大文本为3:1(1.4.3)
  • 文本可以调整到200%而不失去功能(1.4.4)
  • 避免使用文本图像(1.4.5)
  • 多种方式查找页面(2.4.5)
  • 标题和标签描述主题或目的(2.4.6)
  • 焦点可见(2.4.7)
  • 页面语言标识(3.1.1)
  • 输入错误建议(3.3.3)
  • 法律/金融交易的错误预防(3.3.4)

级别 AAA(增强):

  • 视频的手语解释(1.2.6)
  • 扩展音频描述(1.2.7)
  • 对比度至少为7:1(1.4.6)
  • 无背景音频或易于关闭(1.4.7)
  • 使用部分标题(2.4.10)
  • 上下文相关的帮助可用(3.3.5)
// WCAG 2.1 检查清单实现
interface WCAGChecklistItem {
  id: string;
  level: "A" | "AA" | "AAA";
  principle: "perceivable" | "operable" | "understandable" | "robust";
  description: string;
  howToTest: string;
}

const wcagChecklist: WCAGChecklistItem[] = [
  {
    id: "1.1.1",
    level: "A",
    principle: "perceivable",
    description:
      "非文本内容:所有非文本内容都有文本替代",
    howToTest: "检查所有图像都有有意义的替代文本",
  },
  {
    id: "1.4.3",
    level: "AA",
    principle: "perceivable",
    description:
      "对比度(最小):文本对比度至少为4.5:1",
    howToTest: "对所有文本元素使用对比度检查工具",
  },
  {
    id: "2.1.1",
    level: "A",
    principle: "operable",
    description: "键盘:所有功能都可以通过键盘操作",
    howToTest: "不使用鼠标,通过Tab键遍历整个页面",
  },
  // ... 附加项目
];

2. 语义HTML

<!-- 坏:非语义结构 -->
<div class="header">
  <div class="nav">
    <div class="nav-item">主页</div>
    <div class="nav-item">关于</div>
  </div>
</div>
<div class="main-content">
  <div class="article">
    <div class="title">文章标题</div>
    <div class="content">内容在这里...</div>
  </div>
</div>
<div class="footer">页脚内容</div>

<!-- 好:语义结构 -->
<header>
  <nav aria-label="主导航">
    <ul>
      <li><a href="/">主页</a></li>
      <li><a href="/about">关于</a></li>
    </ul>
  </nav>
</header>
<main>
  <article>
    <h1>文章标题</h1>
    <p>内容在这里...</p>
  </article>
</main>
<footer>页脚内容</footer>

语义元素参考

// 语义HTML元素映射
const semanticElements = {
  // 分区
  header: "介绍性内容,通常包含导航",
  nav: "导航链接",
  main: "文档的主要内容(每个页面只有一个)",
  article: "可独立分发的自包含内容",
  section: "带有标题的内容主题分组",
  aside: "与主要内容相关的内容",
  footer: "最近分区内容的页脚",

  // 文本内容
  h1_h6: "标题级别(保持层次结构,每个页面一个h1)",
  p: "段落",
  ul_ol: "无序/有序列表",
  blockquote: "扩展引用",
  figure: "带有可选标题的自包含内容",
  figcaption: "图的标题",

  // 交互式
  button: "可点击按钮(不用于链接)",
  a: "超链接到另一个页面或资源",
  details: "带有摘要的披露小部件",
  dialog: "模态或非模态对话框",

  // 表单元素
  form: "交互式表单",
  label: "表单元素的标题(始终与输入一起使用)",
  fieldset: "相关表单元素组",
  legend: "fieldset的标题",
};

3. ARIA 属性

// ARIA 角色、状态和属性

// 地标角色
const landmarkRoles = [
  "banner", // 页面头部(与<header>一起使用)
  "navigation", // 导航(与<nav>一起使用)
  "main", // 主要内容(与<main>一起使用)
  "complementary", // 支持内容(与<aside>一起使用)
  "contentinfo", // 页脚(与<footer>一起使用)
  "search", // 搜索功能
  "form", // 表单(与<form>一起使用)
  "region", // 通用地标(需要aria-label)
];

// 小部件角色
const widgetRoles = [
  "button",
  "checkbox",
  "dialog",
  "menu",
  "menuitem",
  "progressbar",
  "slider",
  "tab",
  "tablist",
  "tabpanel",
  "tooltip",
  "tree",
  "treeitem",
];

// 常见ARIA属性
interface AriaAttributes {
  // 标签和描述
  "aria-label": string; // 可访问名称
  "aria-labelledby": string; // 标签元素的ID
  "aria-describedby": string; // 描述元素的ID

  // 状态
  "aria-expanded": boolean; // 可扩展元素状态
  "aria-selected": boolean; // 选择状态
  "aria-checked": boolean | "mixed"; // 复选框/开关状态
  "aria-pressed": boolean | "mixed"; // 切换按钮状态
  "aria-disabled": boolean; // 禁用状态
  "aria-hidden": boolean; // 从辅助技术隐藏

  // 实时区域
  "aria-live": "off" | "polite" | "assertive";
  "aria-atomic": boolean;
  "aria-relevant": string;

  // 关系
  "aria-controls": string; // 控制元素的ID
  "aria-owns": string; // 拥有元素的ID
  "aria-haspopup": boolean | "menu" | "dialog";

  // 其他
  "aria-current": "page" | "step" | "location" | "date" | "time" | boolean;
  "aria-invalid": boolean | "grammar" | "spelling";
  "aria-required": boolean;
}

ARIA 示例

// 可访问的模态对话框
function Modal({ isOpen, onClose, title, children }) {
  const titleId = useId();

  useEffect(() => {
    if (isOpen) {
      // 将焦点困在模态内
      const focusableElements = modalRef.current.querySelectorAll(
        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
      );
      const firstElement = focusableElements[0];
      const lastElement = focusableElements[focusableElements.length - 1];

      firstElement?.focus();

      const handleTab = (e: KeyboardEvent) => {
        if (e.key === "Tab") {
          if (e.shiftKey && document.activeElement === firstElement) {
            e.preventDefault();
            lastElement?.focus();
          } else if (!e.shiftKey && document.activeElement === lastElement) {
            e.preventDefault();
            firstElement?.focus();
          }
        }
        if (e.key === "Escape") {
          onClose();
        }
      };

      document.addEventListener("keydown", handleTab);
      return () => document.removeEventListener("keydown", handleTab);
    }
  }, [isOpen, onClose]);

  if (!isOpen) return null;

  return (
    <div
      role="dialog"
      aria-modal="true"
      aria-labelledby={titleId}
      ref={modalRef}
    >
      <h2 id={titleId}>{title}</h2>
      {children}
      <button onClick={onClose} aria-label="关闭对话框">
        关闭
      </button>
    </div>
  );
}

// 可访问的标签页
function Tabs({ tabs }) {
  const [activeIndex, setActiveIndex] = useState(0);

  const handleKeyDown = (e: KeyboardEvent, index: number) => {
    let newIndex = index;

    switch (e.key) {
      case "ArrowRight":
        newIndex = (index + 1) % tabs.length;
        break;
      case "ArrowLeft":
        newIndex = (index - 1 + tabs.length) % tabs.length;
        break;
      case "Home":
        newIndex = 0;
        break;
      case "End":
        newIndex = tabs.length - 1;
        break;
      default:
        return;
    }

    e.preventDefault();
    setActiveIndex(newIndex);
    document.getElementById(`tab-${newIndex}`)?.focus();
  };

  return (
    <div>
      <div role="tablist" aria-label="内容标签页">
        {tabs.map((tab, index) => (
          <button
            key={index}
            id={`tab-${index}`}
            role="tab"
            aria-selected={activeIndex === index}
            aria-controls={`panel-${index}`}
            tabIndex={activeIndex === index ? 0 : -1}
            onClick={() => setActiveIndex(index)}
            onKeyDown={(e) => handleKeyDown(e, index)}
          >
            {tab.label}
          </button>
        ))}
      </div>

      {tabs.map((tab, index) => (
        <div
          key={index}
          id={`panel-${index}`}
          role="tabpanel"
          aria-labelledby={`tab-${index}`}
          hidden={activeIndex !== index}
          tabIndex={0}
        >
          {tab.content}
        </div>
      ))}
    </div>
  );
}

// 实时区域用于动态更新
function Notification({ message, type }) {
  return (
    <div
      role="alert"
      aria-live={type === "error" ? "assertive" : "polite"}
      aria-atomic="true"
    >
      {message}
    </div>
  );
}

4. 键盘导航

标准键盘模式

模式 行为
Tab导航 Tab, Shift+Tab 向前/向后移动焦点通过交互元素
箭头导航 箭头键 在复合小部件内导航(菜单、标签页、列表)
动作键 Enter, Space 激活按钮/链接(两者都可用Enter,Space用于按钮)
Escape Esc 关闭对话框、取消操作、退出模式
Home/End Home, End 移动到组中的第一个/最后一个项目
Page Up/Down PgUp, PgDn 滚动或按页面导航
类型前导 字母键 通过输入第一个字母查找项目

常见小部件模式

标签页:

  • Tab: 进入标签列表
  • 左/右箭头:在标签之间导航
  • Home/End: 第一个/最后一个标签
  • Tab: 退出到标签面板内容

菜单:

  • 上/下箭头:导航菜单项
  • 右箭头:打开子菜单
  • 左箭头:关闭子菜单
  • Home/End: 第一个/最后一个项目
  • Escape: 关闭菜单
  • 字母键:跳转到以字母开头的项目

对话框:

  • Tab/Shift+Tab: 循环通过对话框元素(焦点陷阱)
  • Escape: 关闭对话框
  • 关闭时焦点返回到触发元素

手风琴:

  • Tab: 移动到下一个手风琴标题
  • 上/下箭头:导航手风琴标题(可选)
  • Enter/Space: 切换手风琴面板
  • Home/End: 第一个/最后一个标题(可选)

组合框:

  • 下箭头:打开列表框
  • 上/下箭头:导航选项
  • Enter: 选择选项并关闭
  • Escape: 关闭而不选择
  • 类型前导:过滤/跳转到选项
// 键盘导航实用程序

// 焦点管理钩子
function useFocusManagement() {
  const focusableSelector = [
    "a[href]",
    "button:not([disabled])",
    "input:not([disabled])",
    "select:not([disabled])",
    "textarea:not([disabled])",
    '[tabindex]:not([tabindex="-1"])',
  ].join(", ");

  const getFocusableElements = (container: HTMLElement) => {
    return Array.from(container.querySelectorAll(focusableSelector));
  };

  const trapFocus = (container: HTMLElement) => {
    const elements = getFocusableElements(container);
    const first = elements[0] as HTMLElement;
    const last = elements[elements.length - 1] as HTMLElement;

    const 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();
        }
      }
    };

    container.addEventListener("keydown", handleKeyDown);
    return () => container.removeEventListener("keydown", handleKeyDown);
  };

  return { getFocusableElements, trapFocus };
}

// 轮转tabindex用于分组元素
function useRovingTabindex<T extends HTMLElement>(
  items: T[],
  options: { orientation: "horizontal" | "vertical" | "both" } = {
    orientation: "horizontal",
  },
) {
  const [focusedIndex, setFocusedIndex] = useState(0);

  const handleKeyDown = (e: KeyboardEvent, index: number) => {
    const { orientation } = options;
    let newIndex = index;

    const prevKeys = orientation === "vertical" ? ["ArrowUp"] : ["ArrowLeft"];
    const nextKeys =
      orientation === "vertical" ? ["ArrowDown"] : ["ArrowRight"];

    if (orientation === "both") {
      prevKeys.push("ArrowUp", "ArrowLeft");
      nextKeys.push("ArrowDown", "ArrowRight");
    }

    if (prevKeys.includes(e.key)) {
      newIndex = (index - 1 + items.length) % items.length;
    } else if (nextKeys.includes(e.key)) {
      newIndex = (index + 1) % items.length;
    } else if (e.key === "Home") {
      newIndex = 0;
    } else if (e.key === "End") {
      newIndex = items.length - 1;
    } else {
      return;
    }

    e.preventDefault();
    setFocusedIndex(newIndex);
    items[newIndex]?.focus();
  };

  return {
    focusedIndex,
    getTabIndex: (index: number) => (index === focusedIndex ? 0 : -1),
    handleKeyDown,
  };
}

// 跳过链接组件
function SkipLink({ targetId, children = "跳到主要内容" }) {
  return (
    <a
      href={`#${targetId}`}
      className="skip-link"
      style={{
        position: "absolute",
        left: "-9999px",
        top: "auto",
        width: "1px",
        height: "1px",
        overflow: "hidden",
      }}
      onFocus={(e) => {
        e.currentTarget.style.left = "0";
        e.currentTarget.style.width = "auto";
        e.currentTarget.style.height = "auto";
      }}
      onBlur={(e) => {
        e.currentTarget.style.left = "-9999px";
        e.currentTarget.style.width = "1px";
        e.currentTarget.style.height = "1px";
      }}
    >
      {children}
    </a>
  );
}

5. 屏幕阅读器测试

屏幕阅读器参考

阅读器 平台 成本 启用 关键快捷键
NVDA Windows 免费 自动启动 开始: NVDA+Down, 停止: Ctrl, 下一个标题: H, 下一个链接: K, 下一个表单: F, 地标: D, 元素列表: NVDA+F7
JAWS Windows 付费 自动启动 开始: Insert+Down, 停止: Ctrl, 下一个标题: H, 标题列表: Insert+F6
VoiceOver macOS/iOS 内置 Cmd+F5 下一个: VO+Right, 上一个: VO+Left, 转子: VO+U, 激活: VO+Space
TalkBack Android 内置 设置 下一个: 向右滑动, 上一个: 向左滑动, 激活: 双击, 滚动: 双指滑动
Narrator Windows 内置 Ctrl+Win+Enter 扫描模式: Caps Lock+Space, 下一个标题: H, 下一个链接: K

屏幕阅读器测试检查清单

导航:

  • 页面标题在加载时宣布
  • 标题创建逻辑大纲(h1 → h6层次结构保持)
  • 地标区域正确标记(banner、navigation、main、complementary、contentinfo)
  • 跳过链接允许跳过重复内容
  • 焦点顺序遵循视觉阅读顺序

内容:

  • 所有图像都有适当的替代文本(装饰性图像有alt=“”)
  • 链接有描述性文本(避免“点击这里”)
  • 按钮清晰指示其操作
  • 动态内容更新被宣布(aria-live区域)
  • 表格有适当的标题和说明

表单:

  • 表单标签正确与输入关联
  • 错误消息与表单字段关联(aria-describedby)
  • 必填字段指示(aria-required)
  • 无效字段指示(aria-invalid)
  • 提交时宣布错误摘要

交互:

  • 模态焦点被困在对话框内
  • 模态宣布其角色和标签
  • 键盘快捷键不与屏幕阅读器快捷键冲突
  • 自定义小部件宣布状态变化(aria-expanded、aria-selected)
// 屏幕阅读器测试实用程序

const screenReaderTesting = {
  // 常见屏幕阅读器
  readers: {
    nvda: {
      platform: "Windows",
      cost: "免费",
      shortcuts: {
        startReading: "NVDA + 下箭头",
        stopReading: "Ctrl",
        nextHeading: "H",
        nextLink: "K",
        nextFormField: "F",
        nextLandmark: "D",
        elementsList: "NVDA + F7",
      },
    },
    jaws: {
      platform: "Windows",
      cost: "付费",
      shortcuts: {
        startReading: "Insert + 下箭头",
        stopReading: "Ctrl",
        nextHeading: "H",
        nextLink: "Tab",
        headingsList: "Insert + F6",
      },
    },
    voiceover: {
      platform: "macOS/iOS",
      cost: "内置",
      shortcuts: {
        toggle: "Cmd + F5",
        nextElement: "VO + 右箭头",
        previousElement: "VO + 左箭头",
        rotor: "VO + U",
        activate: "VO + 空格",
      },
    },
    talkback: {
      platform: "Android",
      cost: "内置",
      gestures: {
        nextElement: "向右滑动",
        previousElement: "向左滑动",
        activate: "双击",
        scrollForward: "双指向上滑动",
      },
    },
  },
};

// 屏幕阅读器宣布实用程序
function announce(
  message: string,
  priority: "polite" | "assertive" = "polite",
) {
  const announcer = document.createElement("div");
  announcer.setAttribute("aria-live", priority);
  announcer.setAttribute("aria-atomic", "true");
  announcer.setAttribute("class", "sr-only");
  announcer.style.cssText = `
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
  `;

  document.body.appendChild(announcer);

  // 延迟以确保屏幕阅读器检测到变化
  setTimeout(() => {
    announcer.textContent = message;
  }, 100);

  // 清理
  setTimeout(() => {
    document.body.removeChild(announcer);
  }, 1000);
}

// 用法
announce("表单提交成功");
announce("错误:请填写所有必填字段", "assertive");

6. 颜色对比度和视觉可访问性

// 颜色对比度实用程序

function getLuminance(r: number, g: number, b: number): number {
  const [rs, gs, bs] = [r, g, b].map((c) => {
    c = c / 255;
    return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
  });
  return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
}

function getContrastRatio(color1: string, color2: string): number {
  const rgb1 = hexToRgb(color1);
  const rgb2 = hexToRgb(color2);

  const l1 = getLuminance(rgb1.r, rgb1.g, rgb1.b);
  const l2 = getLuminance(rgb2.r, rgb2.g, rgb2.b);

  const lighter = Math.max(l1, l2);
  const darker = Math.min(l1, l2);

  return (lighter + 0.05) / (darker + 0.05);
}

function hexToRgb(hex: string): { r: number; g: number; b: number } {
  const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
  return result
    ? {
        r: parseInt(result[1], 16),
        g: parseInt(result[2], 16),
        b: parseInt(result[3], 16),
      }
    : { r: 0, g: 0, b: 0 };
}

function meetsWCAGContrast(
  foreground: string,
  background: string,
  level: "AA" | "AAA" = "AA",
  isLargeText: boolean = false,
): boolean {
  const ratio = getContrastRatio(foreground, background);

  const requirements = {
    AA: { normal: 4.5, large: 3 },
    AAA: { normal: 7, large: 4.5 },
  };

  const required = requirements[level][isLargeText ? "large" : "normal"];
  return ratio >= required;
}

// 用法
const passes = meetsWCAGContrast("#333333", "#ffffff", "AA");
console.log(
  `对比度比率: ${getContrastRatio("#333333", "#ffffff").toFixed(2)}:1`,
);

// 可访问调色板生成器
function generateAccessiblePalette(
  baseColor: string,
  background: string = "#ffffff",
) {
  const shades = [];
  const rgb = hexToRgb(baseColor);

  for (let i = 0; i <= 100; i += 10) {
    const factor = i / 100;
    const shade = {
      r: Math.round(rgb.r * factor),
      g: Math.round(rgb.g * factor),
      b: Math.round(rgb.b * factor),
    };
    const hex = `#${shade.r.toString(16).padStart(2, "0")}${shade.g.toString(16).padStart(2, "0")}${shade.b.toString(16).padStart(2, "0")}`;
    const ratio = getContrastRatio(hex, background);

    shades.push({
      shade: i,
      hex,
      contrastRatio: ratio.toFixed(2),
      passesAA: ratio >= 4.5,
      passesAAA: ratio >= 7,
    });
  }

  return shades;
}

// 焦点可见样式
const focusStyles = `
  /* 移除默认轮廓 */
  :focus {
    outline: none;
  }

  /* 为键盘用户添加可见焦点 */
  :focus-visible {
    outline: 2px solid #005fcc;
    outline-offset: 2px;
  }

  /* 高对比度模式支持 */
  @media (prefers-contrast: high) {
    :focus-visible {
      outline: 3px solid currentColor;
      outline-offset: 3px;
    }
  }

  /* 减少运动支持 */
  @media (prefers-reduced-motion: reduce) {
    *,
    *::before,
    *::after {
      animation-duration: 0.01ms !important;
      animation-iteration-count: 1 !important;
      transition-duration: 0.01ms !important;
    }
  }
`;

7. 自动化A11y测试工具

// 自动化可访问性测试设置

// Jest + Testing Library
import { render, screen } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';

expect.extend(toHaveNoViolations);

describe('可访问性测试', () => {
  it('应无可访问性违规', async () => {
    const { container } = render(<MyComponent />);
    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });

  it('应有可访问表单', () => {
    render(<LoginForm />);

    // 检查正确标签
    expect(screen.getByLabelText('电子邮件')).toBeInTheDocument();
    expect(screen.getByLabelText('密码')).toBeInTheDocument();

    // 检查正确角色
    expect(screen.getByRole('form')).toBeInTheDocument();
    expect(screen.getByRole('button', { name: '登录' })).toBeInTheDocument();
  });

  it('应正确管理焦点', () => {
    render(<Modal isOpen={true} />);

    // 第一个可聚焦元素应被聚焦
    expect(document.activeElement).toBe(screen.getByRole('button', { name: '关闭' }));
  });
});

// Playwright可访问性测试
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

test.describe('可访问性', () => {
  test('首页应无可访问性问题', async ({ page }) => {
    await page.goto('/');

    const accessibilityScanResults = await new AxeBuilder({ page })
      .withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
      .analyze();

    expect(accessibilityScanResults.violations).toEqual([]);
  });

  test('应可通过键盘导航', async ({ page }) => {
    await page.goto('/');

    // Tab到跳过链接
    await page.keyboard.press('Tab');
    await expect(page.getByText('跳到主要内容')).toBeFocused();

    // Tab到导航
    await page.keyboard.press('Tab');
    await expect(page.getByRole('link', { name: '主页' })).toBeFocused();
  });
});

// CI/CD集成可访问性
const accessibilityConfig = {
  // Lighthouse CI配置
  lighthouse: {
    assertions: {
      'categories:accessibility': ['error', { minScore: 0.9 }],
      'color-contrast': 'error',
      'document-title': 'error',
      'html-has-lang': 'error',
      'image-alt': 'error',
      'label': 'error',
      'link-name': 'error',
      'meta-viewport': 'error',
    },
  },

  // Pa11y CI配置
  pa11y: {
    standard: 'WCAG2AA',
    runners: ['axe', 'htmlcs'],
    ignore: [
      'WCAG2AA.Principle1.Guideline1_4.1_4_3.G18.Fail', // 如果需要,忽略特定规则
    ],
  },

  // Axe配置
  axe: {
    runOnly: {
      type: 'tag',
      values: ['wcag2a', 'wcag2aa', 'wcag21aa', 'best-practice'],
    },
    rules: {
      'color-contrast': { enabled: true },
      'valid-lang': { enabled: true },
    },
  },
};

最佳实践

  1. 从语义HTML开始:正确的HTML元素提供内置可访问性。使用button表示按钮,a表示链接,nav表示导航等。

  2. ARIA第一规则:如果语义HTML可以做到,就不要使用ARIA。ARIA修复HTML无法表达的内容。

  3. 维护焦点管理:确保逻辑焦点顺序、可见焦点指示器以及模态中的正确焦点陷阱。

  4. 键盘优先:所有功能必须仅使用键盘即可工作。通过拔掉鼠标来测试。

  5. 使用真实工具测试:使用屏幕阅读器(NVDA、VoiceOver)、仅键盘以及自动化工具(axe、Lighthouse)。

  6. 提供文本替代:所有图像都需要替代文本(装饰性图像用alt=“”),视频需要字幕。

  7. 设计包容性:从一开始就考虑色盲、低视力、运动障碍、认知障碍。

  8. 渐进增强:核心功能应在没有JavaScript的情况下工作。为有能力的浏览器提供增强体验。

  9. 及早并经常测试:可访问性更容易在构建时加入,而不是后加。包括在每次代码审查中。

  10. 向用户学习:可能的话,在用户测试中包括残障人士。

示例

完整的可访问表单

function AccessibleForm() {
  const [errors, setErrors] = useState<Record<string, string>>({});
  const errorSummaryRef = useRef<HTMLDivElement>(null);

  const handleSubmit = (e: FormEvent) => {
    e.preventDefault();
    const newErrors: Record<string, string> = {};

    // 验证
    if (!formData.email) {
      newErrors.email = "电子邮件是必填的";
    }
    if (!formData.password) {
      newErrors.password = "密码是必填的";
    }

    if (Object.keys(newErrors).length > 0) {
      setErrors(newErrors);
      // 为屏幕阅读器聚焦错误摘要
      errorSummaryRef.current?.focus();
      announce(
        "表单有错误。请更正它们并重试。",
        "assertive",
      );
    } else {
      // 提交表单
      announce("表单提交成功", "polite");
    }
  };

  return (
    <form onSubmit={handleSubmit} aria-labelledby="form-title" noValidate>
      <h1 id="form-title">注册</h1>

      {Object.keys(errors).length > 0 && (
        <div
          ref={errorSummaryRef}
          role="alert"
          aria-labelledby="error-summary-title"
          tabIndex={-1}
          className="error-summary"
        >
          <h2 id="error-summary-title">表单中存在错误</h2>
          <ul>
            {Object.entries(errors).map(([field, message]) => (
              <li key={field}>
                <a href={`#${field}`}>{message}</a>
              </li>
            ))}
          </ul>
        </div>
      )}

      <div className="form-field">
        <label htmlFor="email">
          电子邮件地址
          <span aria-hidden="true">*</span>
          <span className="sr-only">(必填)</span>
        </label>
        <input
          type="email"
          id="email"
          name="email"
          autoComplete="email"
          aria-required="true"
          aria-invalid={!!errors.email}
          aria-describedby={errors.email ? "email-error" : undefined}
        />
        {errors.email && (
          <span id="email-error" role="alert" className="error">
            {errors.email}
          </span>
        )}
      </div>

      <div className="form-field">
        <label htmlFor="password">
          密码
          <span aria-hidden="true">*</span>
          <span className="sr-only">(必填)</span>
        </label>
        <input
          type="password"
          id="password"
          name="password"
          autoComplete="new-password"
          aria-required="true"
          aria-invalid={!!errors.password}
          aria-describedby="password-hint password-error"
        />
        <span id="password-hint" className="hint">
          必须至少8个字符
        </span>
        {errors.password && (
          <span id="password-error" role="alert" className="error">
            {errors.password}
          </span>
        )}
      </div>

      <button type="submit">创建账户</button>
    </form>
  );
}

快速参考

常见ARIA模式速查表

// 按钮
<button type="button">点击我</button>

// 切换按钮
<button aria-pressed="true">静音</button>

// 图标按钮
<button aria-label="关闭">×</button>

// 看起来像按钮的链接(不要 - 改用按钮)
// 如果必须:<a href="#" role="button" aria-pressed="false">

// 禁用状态
<button disabled>无法点击</button>
<button aria-disabled="true">视觉上禁用但可聚焦</button>

// 加载状态
<button aria-busy="true">保存中...</button>

// 可扩展部分
<button aria-expanded="false" aria-controls="panel-id">切换</button>
<div id="panel-id" hidden>内容</div>

// 自定义复选框
<div role="checkbox" aria-checked="false" tabindex="0">选项</div>

// 模态对话框
<div role="dialog" aria-modal="true" aria-labelledby="title-id">
  <h2 id="title-id">对话框标题</h2>
</div>

// 警报(立即宣布)
<div role="alert">错误:表单提交失败</div>

// 状态(在当前话语后宣布)
<div role="status" aria-live="polite">5条新消息</div>

// 标签页
<div role="tablist">
  <button role="tab" aria-selected="true" aria-controls="panel1">标签1</button>
  <button role="tab" aria-selected="false" aria-controls="panel2">标签2</button>
</div>
<div id="panel1" role="tabpanel">面板1内容</div>
<div id="panel2" role="tabpanel" hidden>面板2内容</div>

// 带有描述和错误的表单字段
<label for="email">电子邮件</label>
<input
  id="email"
  type="email"
  aria-describedby="email-hint email-error"
  aria-invalid="true"
  aria-required="true"
/>
<span id="email-hint">我们永远不会分享您的电子邮件</span>
<span id="email-error" role="alert">请输入有效的电子邮件</span>

// 视觉隐藏但屏幕阅读器可用
<span className="sr-only">仅屏幕阅读器文本</span>
// CSS: .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; }

// 从屏幕阅读器隐藏
<div aria-hidden="true">装饰性内容</div>

替代文本指南

// 信息性图像 - 描述信息
<img src="chart.png" alt="销售从第一季度到第二季度增加了25%" />

// 功能性图像 - 描述动作
<img src="print-icon.png" alt="打印此页面" />

// 装饰性图像 - 空alt
<img src="decorative-border.png" alt="" />

// 复杂图像 - 使用长描述
<img
  src="complex-diagram.png"
  alt="系统架构图"
  aria-describedby="diagram-desc"
/>
<div id="diagram-desc">
  详细描述:系统由...组成
</div>

// 文本图像 - 尽可能避免,否则复制文本
<img src="logo.png" alt="Acme公司" />

// 背景图像 - 使用空alt,其他地方提供文本替代
<div style="background-image: url(hero.jpg)" role="img" aria-label="团队庆祝">

焦点管理模式

// 焦点陷阱(模态、侧边栏)
// 1. 保存之前聚焦的元素
const previousFocus = document.activeElement;

// 2. 聚焦陷阱中的第一个元素
modal.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])')?.focus();

// 3. 在Tab时,在陷阱内循环
// 参见上面的useFocusManagement钩子

// 4. 关闭时,恢复焦点
previousFocus?.focus();

// 轮转tabindex(工具栏、菜单)
// 组中只有一个项目有tabindex="0",其他有tabindex="-1"
// 箭头键移动焦点并更新tabindex值
<div role="toolbar">
  <button tabindex="0">剪切</button>
  <button tabindex="-1">复制</button>
  <button tabindex="-1">粘贴</button>
</div>

// 跳过链接(第一个可聚焦元素)
<a href="#main-content" className="skip-link">跳到主要内容</a>
<main id="main-content" tabindex="-1">...</main>

// 删除后的焦点管理
// 聚焦下一个项目,如果是最后一个则前一个,如果空则容器
listItems.splice(index, 1);
if (listItems[index]) {
  listItems[index].focus();
} else if (listItems[index - 1]) {
  listItems[index - 1].focus();
} else {
  containerElement.focus();
}

测试检查清单

自动化(在每个PR上运行):

  • axe-core(通过jest-axe或@axe-core/playwright)
  • Lighthouse可访问性分数
  • ESLint插件:eslint-plugin-jsx-a11y

手动(发布前运行):

  • 仅键盘导航(拔掉鼠标)
  • 屏幕阅读器测试(NVDA/VoiceOver)
  • 浏览器缩放至200%
  • 颜色对比度检查器(WCAG AA最低)
  • Tab遍历整个应用程序
  • 检查焦点指示器可见
  • 测试减少运动偏好
  • 测试高对比度模式

浏览器开发工具:

  • Chrome: Lighthouse、可访问性树视图
  • Firefox: 可访问性检查器
  • Safari: 可访问性审计