mcpField
A
accessibility-audit

accessibility-audit

github.com/mgd34msu测试与质量660 次安装10 周前更新
代码仓库

关于 accessibility-audit

accessibility-audit 是一项指导应用无障碍审查的技能,面向开发团队和审查人员。它按审计阶段覆盖语义 HTML、键盘操作、屏幕阅读器、颜色对比及表单等方面,并提供验证脚本和模式参考。

主要功能

  1. 01按阶段组织 WCAG 2.1 AA 审计
  2. 02检查语义 HTML 与 ARIA 用法
  3. 03审查键盘导航和焦点管理
  4. 04检查屏幕阅读器支持与颜色对比
  5. 05审查表单、错误提示和媒体内容
  6. 06提供验证脚本与无障碍模式参考

适用场景

  1. 01发布前检查应用的无障碍情况
  2. 02UI 重构后复核交互和焦点行为
  3. 03排查无障碍投诉或合规问题
  4. 04为团队整理无障碍审查流程

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/mgd34msu/goodvibes-plugin --skill accessibility-audit

安装后在 Claude Code 中输入/accessibility-audit确认已加载。

附带文件references/accessibility-patterns.mdscripts/validate-accessibility-audit.sh

详细介绍

资源

scripts/
  validate-accessibility-audit.sh
references/
  accessibility-patterns.md

无障碍审计

本技能将指导你执行全面的无障碍审计,确保符合 WCAG 2.1 AA 标准。在验证应用是否采用包容性设计、准备无障碍评审或修复无障碍问题时,请使用本技能。

何时使用本技能

  • 在发布前进行无障碍合规性评审
  • 响应无障碍投诉或法律要求
  • 定期审计现有应用的无障碍情况
  • 在重大 UI 重构后验证无障碍功能
  • 准备 WCAG 2.1 AA 认证
  • 帮助团队熟悉无障碍最佳实践

审计方法

系统化的无障碍审计包含以下阶段:

阶段 1:初步勘查

目标: 梳理组件并识别无障碍关键区域。

使用 discover 梳理 UI 范围:

discover:
  queries:
    - id: interactive_components
      type: grep
      pattern: "(button|input|select|textarea|a href)"
      glob: "**/*.{tsx,jsx}"
    - id: form_components
      type: grep
      pattern: "<form|onSubmit|FormProvider"
      glob: "**/*.{tsx,jsx}"
    - id: aria_usage
      type: grep
      pattern: "aria-(label|labelledby|describedby|live|role)"
      glob: "**/*.{tsx,jsx}"
    - id: image_components
      type: grep
      pattern: "(<img|<Image|next/image)"
      glob: "**/*.{tsx,jsx}"
  verbosity: files_only

识别关键区域:

  • 表单和输入字段
  • 导航菜单和路由
  • 模态对话框和覆盖层
  • 数据表格和列表
  • 媒体播放器(音频/视频)
  • 自定义交互控件
阶段 2:语义化 HTML 审计

目标: 验证 HTML5 语义元素的使用是否恰当。

检查标题层级

搜索标题用法:

precision_grep:
  queries:
    - id: heading_elements
      pattern: "<h[1-6]|heading.*level"
      glob: "**/*.{tsx,jsx}"
  output:
    format: standard

常见问题:

  • 跳过标题级别(h1 -> h3,中间没有 h2)
  • 页面中有多个 h1 元素
  • 用标题实现视觉样式,而非表达结构
  • 复杂组件中缺少标题层级

正确的标题结构:

import { ReactNode } from 'react';

interface PageLayoutProps {
  title: string;
  children: ReactNode;
}

export function PageLayout({ title, children }: PageLayoutProps) {
  return (
    <div className="page-layout">
      {/* Single h1 per page */}
      <h1 className="text-3xl font-bold">{title}</h1>
      
      <main>
        {children}
      </main>
    </div>
  );
}

interface SectionProps {
  title: string;
  children: ReactNode;
}

export function Section({ title, children }: SectionProps) {
  return (
    <section>
      {/* h2 for main sections */}
      <h2 className="text-2xl font-semibold">{title}</h2>
      {children}
    </section>
  );
}

interface SubsectionProps {
  title: string;
  children: ReactNode;
}

export function Subsection({ title, children }: SubsectionProps) {
  return (
    <div>
      {/* h3 for subsections */}
      <h3 className="text-xl font-medium">{title}</h3>
      {children}
    </div>
  );
}
检查地标区域

搜索地标用法:

precision_grep:
  queries:
    - id: landmarks
      pattern: "(<header|<main|<nav|<aside|<footer|role=\"(banner|navigation|main|complementary|contentinfo)\")"
      glob: "**/*.{tsx,jsx}"
  output:
    format: files_only

必需的地标:

  • 使用 <header> 或 role="banner" 表示网站页眉
  • 使用 <nav> 或 role="navigation" 表示导航
  • 使用 <main> 或 role="main" 表示主要内容(每页恰好一个)
  • 使用 <aside> 或 role="complementary" 表示侧边栏
  • 使用 <footer> 或 role="contentinfo" 表示网站页脚

正确的地标结构:

import { ReactNode } from 'react';

interface AppLayoutProps {
  navigation: ReactNode;
  sidebar?: ReactNode;
  children: ReactNode;
}

export function AppLayout({ navigation, sidebar, children }: AppLayoutProps) {
  return (
    <div className="app-layout">
      <header className="site-header">
        <div className="logo">MyApp</div>
        {navigation}
      </header>
      
      <div className="content-container">
        {sidebar && (
          <aside className="sidebar" aria-label="Filters">
            {sidebar}
          </aside>
        )}
        
        {/* Exactly one main per page */}
        <main className="main-content">
          {children}
        </main>
      </div>
      
      <footer className="site-footer">
        <p>&copy; 2026 MyApp. All rights reserved.</p>
      </footer>
    </div>
  );
}
检查列表语义

搜索列表模式:

precision_grep:
  queries:
    - id: list_elements
      pattern: "<(ul|ol|li|dl|dt|dd)"
      glob: "**/*.{tsx,jsx}"
  output:
    format: files_only

常见问题:

  • 使用 <div> 表示列表,而非 <ul> 或 <ol>
  • 在 <ul> 或 <ol> 之外嵌套 <li>
  • 用列表进行布局,而非表示语义分组

语义化列表用法:

interface User {
  id: string;
  name: string;
  email: string;
}

interface UserListProps {
  users: User[];
}

export function UserList({ users }: UserListProps) {
  return (
    <ul className="user-list" aria-label="Team members">
      {users.map((user) => (
        <li key={user.id}>
          <span className="user-name">{user.name}</span>
          <span className="user-email">{user.email}</span>
        </li>
      ))}
    </ul>
  );
}
阶段 3:ARIA 模式审计

目标: 验证 ARIA 的使用和模式是否正确。

检查 ARIA 角色

搜索 ARIA 角色用法:

precision_grep:
  queries:
    - id: aria_roles
      pattern: 'role="(button|link|dialog|alertdialog|menu|menuitem|tab|tabpanel|listbox|option)"'
      glob: "**/*.{tsx,jsx}"
  output:
    format: standard

常见问题:

  • 在语义元素上使用冗余角色(例如 <button role="button">)
  • 使用 role="button",却未提供键盘事件处理程序
  • 复杂角色缺少必需的 ARIA 属性
  • 使用抽象角色(例如 role="widget")

正确的 ARIA 按钮模式:

import { MouseEvent, KeyboardEvent } from 'react';

interface CustomButtonProps {
  onClick: () => void;
  disabled?: boolean;
  children: React.ReactNode;
}

export function CustomButton({ onClick, disabled, children }: CustomButtonProps) {
  const handleClick = (e: MouseEvent<HTMLDivElement>) => {
    if (!disabled) {
      onClick();
    }
  };

  const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
    if (!disabled && (e.key === 'Enter' || e.key === ' ')) {
      e.preventDefault();
      onClick();
    }
  };

  return (
    <div
      role="button"
      tabIndex={disabled ? -1 : 0}
      aria-disabled={disabled}
      onClick={handleClick}
      onKeyDown={handleKeyDown}
      className="custom-button"
    >
      {children}
    </div>
  );
}

注意: 尽可能优先使用原生 <button> 元素。只有在绝对必要时,才在非按钮元素上使用 role="button"。

检查 ARIA 标签和描述

搜索标签模式:

precision_grep:
  queries:
    - id: aria_labels
      pattern: "aria-(label|labelledby|describedby)"
      glob: "**/*.{tsx,jsx}"
  output:
    format: context

常见问题:

  • 交互元素没有可访问名称
  • aria-labelledby 引用了不存在的 ID
  • 标签冗余(同时使用 aria-label 和 aria-labelledby)
  • 在非交互元素上使用 aria-label

正确的标签模式:

import { useId } from 'react';

interface SearchFormProps {
  onSearch: (query: string) => void;
}

export function SearchForm({ onSearch }: SearchFormProps) {
  const searchId = useId();
  const hintId = useId();

  return (
    <form role="search" onSubmit={(e) => {
      e.preventDefault();
      const formData = new FormData(e.currentTarget);
      onSearch(formData.get('query') as string);
    }}>
      <label htmlFor={searchId} className="sr-only">
        Search articles
      </label>
      <input
        id={searchId}
        type="search"
        name="query"
        placeholder="Search..."
        aria-describedby={hintId}
      />
      <p id={hintId} className="text-sm text-gray-600">
        Search by title, author, or keyword
      </p>
      <button type="submit" aria-label="Submit search">
        <SearchIcon aria-hidden="true" />
      </button>
    </form>
  );
}
检查实时区域

搜索实时区域用法:

precision_grep:
  queries:
    - id: live_regions
      pattern: 'aria-live="(polite|assertive|off)"|role="(status|alert)"'
      glob: "**/*.{tsx,jsx}"
  output:
    format: standard

实时区域最佳实践:

  • 对非关键更新使用 aria-live="polite"
  • 对紧急消息使用 aria-live="assertive" 或 role="alert"
  • 对状态更新使用 role="status"(隐式设置 aria-live="polite")
  • 确保实时区域在内容更新前已存在于 DOM 中

无障碍通知模式:

import { ReactNode, useEffect, useState } from 'react';

interface NotificationProps {
  message: string;
  type: 'success' | 'error' | 'info';
  onDismiss: () => void;
}

export function Notification({ message, type, onDismiss }: NotificationProps) {
  useEffect(() => {
    const timer = setTimeout(onDismiss, 5000);
    return () => clearTimeout(timer);
  }, [onDismiss]);

  return (
    <div
      role={type === 'error' ? 'alert' : 'status'}
      aria-live={type === 'error' ? 'assertive' : 'polite'}
      className={`notification notification-${type}`}
    >
      <p>{message}</p>
      <button onClick={onDismiss} aria-label="Dismiss notification">
        <CloseIcon aria-hidden="true" />
      </button>
    </div>
  );
}
阶段 4:键盘导航审计

目标: 确保完整的键盘无障碍支持。

检查焦点管理

搜索焦点相关代码:

precision_grep:
  queries:
    - id: focus_management
      pattern: "(focus\\(\\)|autoFocus|tabIndex|useRef.*focus)"
      glob: "**/*.{tsx,jsx,ts}"
  output:
    format: standard

常见问题:

  • 缺少焦点指示器(:focus 样式)
  • 模态框中存在焦点陷阱,却没有退出机制
  • 应可通过键盘访问的交互元素设置了 tabIndex="-1"
  • 页面加载时不必要地自动聚焦元素

带焦点陷阱的无障碍模态框:

import { useEffect, useRef, ReactNode } from 'react';
import { createPortal } from 'react-dom';

interface ModalProps {
  isOpen: boolean;
  onClose: () => void;
  title: string;
  children: ReactNode;
}

export function Modal({ isOpen, onClose, title, children }: ModalProps) {
  const modalRef = useRef<HTMLDivElement>(null);
  const previousFocusRef = useRef<HTMLElement | null>(null);

  useEffect(() => {
    if (!isOpen) return;

    // Save previously focused element
    previousFocusRef.current = document.activeElement as HTMLElement;

    // Focus first focusable element in modal
    const focusableElements = modalRef.current?.querySelectorAll(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );
    
    if (focusableElements && focusableElements.length > 0) {
      (focusableElements[0] as HTMLElement).focus();
    }

    const handleKeyDown = (e: KeyboardEvent) => {
      if (e.key === 'Escape') {
        onClose();
      }

      // Trap focus within modal
      if (e.key === 'Tab' && focusableElements) {
        const firstElement = focusableElements[0] as HTMLElement;
        const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;

        if (e.shiftKey && document.activeElement === firstElement) {
          e.preventDefault();
          lastElement.focus();
        } else if (!e.shiftKey && document.activeElement === lastElement) {
          e.preventDefault();
          firstElement.focus();
        }
      }
    };

    document.addEventListener('keydown', handleKeyDown);

    return () => {
      document.removeEventListener('keydown', handleKeyDown);
      // Restore focus when modal closes
      previousFocusRef.current?.focus();
    };
  }, [isOpen, onClose]);

  if (!isOpen) return null;

  return createPortal(
    <div className="modal-overlay" onClick={onClose}>
      <div
        ref={modalRef}
        role="dialog"
        aria-modal="true"
        aria-labelledby="modal-title"
        className="modal-content"
        onClick={(e) => e.stopPropagation()}
      >
        <h2 id="modal-title">{title}</h2>
        <div className="modal-body">{children}</div>
        <button onClick={onClose} className="modal-close">
          Close
        </button>
      </div>
    </div>,
    document.body
  );
}
检查跳过链接

搜索跳过链接的实现:

precision_grep:
  queries:
    - id: skip_links
      pattern: "(skip.*main|skip.*content|skip.*navigation)"
      glob: "**/*.{tsx,jsx}"
  output:
    format: files_only

跳过链接的实现:

export function SkipLink() {
  return (
    <a
      href="#main-content"
      className="skip-link"
    >
      Skip to main content
    </a>
  );
}

// CSS (in global styles)
// .skip-link {
//   position: absolute;
//   top: -40px;
//   left: 0;
//   background: #000;
//   color: #fff;
//   padding: 8px;
//   text-decoration: none;
//   z-index: 100;
// }
//
// .skip-link:focus {
//   top: 0;
// }
检查键盘事件处理程序

搜索不支持键盘操作的 onClick:

precision_grep:
  queries:
    - id: onclick_handlers
      pattern: "onClick=\\{"
      glob: "**/*.{tsx,jsx}"
  output:
    format: locations

手动检查: 对每个非按钮、非链接元素上的 onClick 处理程序,确认:

  • 元素具有 role="button" 或适当的角色
  • 元素具有 tabIndex={0},可通过键盘聚焦
  • onKeyDown 处理程序会响应 Enter 和 Space 键

支持无障碍访问的点击处理程序模式:

import { MouseEvent, KeyboardEvent } from 'react';

interface ClickableCardProps {
  title: string;
  onClick: () => void;
}

export function ClickableCard({ title, onClick }: ClickableCardProps) {
  const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
    if (e.key === 'Enter' || e.key === ' ') {
      e.preventDefault();
      onClick();
    }
  };

  return (
    <div
      role="button"
      tabIndex={0}
      onClick={onClick}
      onKeyDown={handleKeyDown}
      className="clickable-card"
    >
      <h3>{title}</h3>
    </div>
  );
}
阶段 5:屏幕阅读器支持审查

目标: 验证与屏幕阅读器的兼容性。

检查视觉隐藏文本

搜索 sr-only 模式:

precision_grep:
  queries:
    - id: sr_only
      pattern: "(sr-only|visually-hidden|screen-reader)"
      glob: "**/*.{tsx,jsx,css,scss}"
  output:
    format: files_only

用于图标的视觉隐藏文本:

interface IconButtonProps {
  onClick: () => void;
  ariaLabel: string;
  icon: React.ReactNode;
}

export function IconButton({ onClick, ariaLabel, icon }: IconButtonProps) {
  return (
    <button onClick={onClick} aria-label={ariaLabel}>
      {icon}
      <span className="sr-only">{ariaLabel}</span>
    </button>
  );
}

// CSS for sr-only class:
// .sr-only {
//   position: absolute;
//   width: 1px;
//   height: 1px;
//   padding: 0;
//   margin: -1px;
//   overflow: hidden;
//   clip: rect(0, 0, 0, 0);
//   white-space: nowrap;
//   border-width: 0;
// }
检查 aria-hidden 的用法

搜索 aria-hidden:

precision_grep:
  queries:
    - id: aria_hidden
      pattern: 'aria-hidden="true"'
      glob: "**/*.{tsx,jsx}"
  output:
    format: standard

常见问题:

  • 在交互元素(按钮、链接)上使用 aria-hidden="true"
  • 对应可访问内容使用 aria-hidden="true"
  • 装饰性图标未设置 aria-hidden="true"

正确使用 aria-hidden:

interface ButtonWithIconProps {
  onClick: () => void;
  label: string;
}

export function ButtonWithIcon({ onClick, label }: ButtonWithIconProps) {
  return (
    <button onClick={onClick}>
      {/* Icon is decorative, hide from screen readers */}
      <CheckIcon aria-hidden="true" />
      {/* Label provides accessible text */}
      <span>{label}</span>
    </button>
  );
}
阶段 6:颜色与对比度审查

目标: 确保颜色对比度充足,且信息不只通过颜色传达。

检查对比度

使用浏览器 DevTools 或外部工具:

  • Chrome DevTools:检查元素 -> Accessibility 面板 -> 对比度
  • Firefox DevTools:检查器 -> Accessibility 面板
  • axe DevTools 浏览器扩展
  • Lighthouse 无障碍审查

WCAG 2.1 AA 要求:

  • 普通文本(< 18pt):最低对比度为 4.5:1
  • 大号文本(>= 18pt 或 >= 14pt 粗体):最低对比度为 3:1
  • UI 组件和图形:最低对比度为 3:1

搜索颜色定义:

precision_grep:
  queries:
    - id: color_definitions
      pattern: "(bg-|text-|color:|background:)"
      glob: "**/*.{tsx,jsx,css,scss}"
  output:
    format: count_only

无障碍配色方案(Tailwind 示例):

// tailwind.config.ts
import type { Config } from 'tailwindcss';

const config: Config = {
  theme: {
    extend: {
      colors: {
        // Accessible color palette with documented contrast ratios
        primary: {
          50: '#f0f9ff',
          100: '#e0f2fe',
          600: '#0284c7', // 4.54:1 on white (WCAG AA)
          700: '#0369a1', // 7.09:1 on white (WCAG AAA)
          900: '#0c4a6e', // 13.14:1 on white
        },
        // Error color with good contrast
        error: {
          600: '#dc2626', // 4.51:1 on white
          700: '#b91c1c', // 6.70:1 on white
        },
        // Success color
        success: {
          600: '#16a34a', // 4.51:1 on white
          700: '#15803d', // 6.68:1 on white
        },
      },
    },
  },
};

export default config;
检查仅通过颜色传达的信息

搜索状态指示器:

precision_grep:
  queries:
    - id: status_colors
      pattern: "(bg-red|bg-green|bg-yellow|text-red|text-green|text-yellow)"
      glob: "**/*.{tsx,jsx}"
  output:
    format: standard

手动检查: 确保通过多种方式传达状态(颜色 + 图标 + 文本)。

无障碍状态指示器:

type Status = 'success' | 'warning' | 'error' | 'info';

interface StatusBadgeProps {
  status: Status;
  message: string;
}

const statusConfig: Record<Status, { icon: React.ReactNode; className: string }> = {
  success: { icon: <CheckCircleIcon />, className: 'bg-green-100 text-green-800' },
  warning: { icon: <AlertTriangleIcon />, className: 'bg-yellow-100 text-yellow-800' },
  error: { icon: <XCircleIcon />, className: 'bg-red-100 text-red-800' },
  info: { icon: <InfoIcon />, className: 'bg-blue-100 text-blue-800' },
};

export function StatusBadge({ status, message }: StatusBadgeProps) {
  const { icon, className } = statusConfig[status];

  return (
    <div className={`flex items-center gap-2 px-3 py-2 rounded ${className}`}>
      {/* Icon provides visual indicator beyond color */}
      <span aria-hidden="true">{icon}</span>
      {/* Text provides clear status information */}
      <span>{message}</span>
    </div>
  );
}
检查强制颜色模式

搜索 forced-colors 媒体查询:

precision_grep:
  queries:
    - id: forced_colors
      pattern: "@media.*forced-colors|prefers-contrast"
      glob: "**/*.{css,scss,tsx,jsx}"
  output:
    format: files_only

支持 Windows 高对比度模式:

/* Ensure borders are visible in forced-colors mode */
.card {
  border: 1px solid #e5e7eb;
}

@media (forced-colors: active) {
  .card {
    border: 1px solid CanvasText;
  }
}

/* Ensure custom controls are visible */
.custom-checkbox {
  border: 2px solid #3b82f6;
}

@media (forced-colors: active) {
  .custom-checkbox {
    border: 2px solid ButtonText;
  }
  
  .custom-checkbox:checked {
    background-color: Highlight;
  }
}
阶段 7:表单与验证审查

目标: 确保表单可访问,且错误处理清晰明了。

检查表单标签

搜索 input 元素:

precision_grep:
  queries:
    - id: input_elements
      pattern: "<input|<textarea|<select"
      glob: "**/*.{tsx,jsx}"
  output:
    format: locations

手动检查: 确认每个输入框都通过以下方式关联了标签:

  • 使用 htmlFor 属性匹配的 <label>
  • aria-label 属性
  • aria-labelledby 指向标签元素

常见问题:

  • 只有占位符而没有标签的输入框
  • 未设置 htmlFor 属性的标签
  • 多个输入框共用一个标签

无障碍表单模式:

import { useId, FormEvent } from 'react';

interface FormData {
  email: string;
  password: string;
}

interface LoginFormProps {
  onSubmit: (data: FormData) => Promise<void>;
}

export function LoginForm({ onSubmit }: LoginFormProps) {
  const emailId = useId();
  const passwordId = useId();
  const emailErrorId = useId();
  const passwordErrorId = useId();

  const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);
    await onSubmit({
      email: formData.get('email') as string,
      password: formData.get('password') as string,
    });
  };

  return (
    <form onSubmit={handleSubmit} noValidate>
      <div className="form-field">
        {/* Explicit label with htmlFor */}
        <label htmlFor={emailId}>
          Email address
        </label>
        <input
          id={emailId}
          type="email"
          name="email"
          required
          aria-required="true"
          aria-describedby={emailErrorId}
          autoComplete="email"
        />
        {/* Error message linked via aria-describedby */}
        <p id={emailErrorId} className="error-message" role="alert">
          {/* Error text populated on validation */}
        </p>
      </div>

      <div className="form-field">
        <label htmlFor={passwordId}>
          Password
        </label>
        <input
          id={passwordId}
          type="password"
          name="password"
          required
          aria-required="true"
          aria-describedby={passwordErrorId}
          autoComplete="current-password"
        />
        <p id={passwordErrorId} className="error-message" role="alert">
          {/* Error text populated on validation */}
        </p>
      </div>

      <button type="submit">
        Sign in
      </button>
    </form>
  );
}
检查错误处理

搜索错误模式:

precision_grep:
  queries:
    - id: error_messages
      pattern: "(error|invalid|required).*message"
      glob: "**/*.{tsx,jsx,ts}"
  output:
    format: standard

无障碍错误处理:

import { useState, useId } from 'react';

interface FieldError {
  field: string;
  message: string;
}

interface FormWithValidationProps {
  onSubmit: (data: Record<string, string>) => void;
}

export function FormWithValidation({ onSubmit }: FormWithValidationProps) {
  const [errors, setErrors] = useState<FieldError[]>([]);
  const nameId = useId();
  const nameErrorId = useId();
  const errorSummaryId = useId();

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);
    const name = formData.get('name') as string;

    // Validation
    const newErrors: FieldError[] = [];
    if (!name || name.length < 2) {
      newErrors.push({ field: 'name', message: 'Name must be at least 2 characters' });
    }

    if (newErrors.length > 0) {
      setErrors(newErrors);
      // Focus error summary for screen readers
      document.getElementById(errorSummaryId)?.focus();
      return;
    }

    setErrors([]);
    onSubmit({ name });
  };

  const nameError = errors.find((e) => e.field === 'name');

  return (
    <form onSubmit={handleSubmit} noValidate>
      {/* Error summary at top of form */}
      {errors.length > 0 && (
        <div
          id={errorSummaryId}
          role="alert"
          aria-labelledby="error-summary-title"
          className="error-summary"
          tabIndex={-1}
        >
          <h2 id="error-summary-title">There are {errors.length} errors</h2>
          <ul>
            {errors.map((error) => (
              <li key={error.field}>
                <a href={`#${error.field}`}>{error.message}</a>
              </li>
            ))}
          </ul>
        </div>
      )}

      <div className="form-field">
        <label htmlFor={nameId}>Name</label>
        <input
          id={nameId}
          name="name"
          aria-required="true"
          aria-invalid={nameError ? 'true' : 'false'}
          aria-describedby={nameError ? nameErrorId : undefined}
        />
        {nameError && (
          <p id={nameErrorId} className="error-message" role="alert">
            {nameError.message}
          </p>
        )}
      </div>

      <button type="submit">Submit</button>
    </form>
  );
}
检查必填字段

搜索必填字段指示器:

precision_grep:
  queries:
    - id: required_fields
      pattern: "(required|aria-required)"
      glob: "**/*.{tsx,jsx}"
  output:
    format: files_only

无障碍必填字段指示器:

interface RequiredFieldLabelProps {
  htmlFor: string;
  children: React.ReactNode;
}

export function RequiredFieldLabel({ htmlFor, children }: RequiredFieldLabelProps) {
  return (
    <label htmlFor={htmlFor}>
      {children}
      {/* Visual indicator */}
      <span className="text-red-600" aria-hidden="true"> *</span>
      {/* Screen reader text */}
      <span className="sr-only"> (required)</span>
    </label>
  );
}
阶段 8:媒体无障碍审查

目标: 确保图像、视频和音频内容均可访问。

检查图像替代文本

搜索图像:

precision_grep:
  queries:
    - id: img_elements
      pattern: "(<img|<Image|next/image)"
      glob: "**/*.{tsx,jsx}"
  output:
    format: standard

手动检查: 确认每张图像都具有:

  • 内容图像具有有意义的 alt 文本
  • 装饰性图像使用空的 alt=""
  • 链接图像的替代文本描述其功能

常见问题:

  • 缺少 alt 属性
  • 通用替代文本(“图像”“照片”“图标”)
  • 冗余替代文本(“……的图像”“……的照片”)
  • 替代文本描述的是文件名(“IMG_1234.jpg”)

无障碍图像模式:

import Image from 'next/image';

interface ProductImageProps {
  src: string;
  productName: string;
}

// Content image with descriptive alt
export function ProductImage({ src, productName }: ProductImageProps) {
  return (
    <Image
      src={src}
      alt={`${productName} product photo`}
      width={400}
      height={400}
    />
  );
}

// Decorative image with empty alt
export function DecorativePattern() {
  return (
    <div className="background-pattern">
      <Image
        src="/patterns/dots.svg"
        alt=""
        fill
        aria-hidden="true"
      />
    </div>
  );
}

// Functional image (icon button)
interface DeleteButtonProps {
  onDelete: () => void;
}

export function DeleteButton({ onDelete }: DeleteButtonProps) {
  return (
    <button onClick={onDelete} aria-label="Delete item">
      <Image
        src="/icons/trash.svg"
        alt=""
        width={20}
        height={20}
        aria-hidden="true"
      />
    </button>
  );
}
检查视频字幕

搜索视频元素:

precision_grep:
  queries:
    - id: video_elements
      pattern: "(<video|<track)"
      glob: "**/*.{tsx,jsx}"
  output:
    format: standard

带字幕的无障碍视频:

interface AccessibleVideoProps {
  src: string;
  captionsSrc: string;
  title: string;
}

export function AccessibleVideo({ src, captionsSrc, title }: AccessibleVideoProps) {
  return (
    <video controls aria-label={title}>
      <source src={src} type="video/mp4" />
      {/* Captions for deaf/hard of hearing users */}
      <track
        kind="captions"
        src={captionsSrc}
        srcLang="en"
        label="English captions"
        default
      />
      {/* Fallback text */}
      <p>
        Your browser does not support the video element.
        <a href={src}>Download the video</a>
      </p>
    </video>
  );
}
检查减少动态效果

搜索动画:

precision_grep:
  queries:
    - id: animations
      pattern: "(animate|transition|prefers-reduced-motion)"
      glob: "**/*.{tsx,jsx,css,scss}"
  output:
    format: files_only

遵循 prefers-reduced-motion 设置:

import { motion, useReducedMotion } from 'framer-motion';

interface AnimatedCardProps {
  children: React.ReactNode;
}

export function AnimatedCard({ children }: AnimatedCardProps) {
  const shouldReduceMotion = useReducedMotion();

  return (
    <motion.div
      initial={{ opacity: 0, y: shouldReduceMotion ? 0 : 20 }}
      animate={{ opacity: 1, y: 0 }}
      transition={{
        duration: shouldReduceMotion ? 0 : 0.3,
      }}
    >
      {children}
    </motion.div>
  );
}

CSS 方法:

/* Default: animated */
.card {
  transition: transform 0.3s ease;
}

.card:hover {
  transform: scale(1.05);
}

/* Reduced motion: disable animations */
@media (prefers-reduced-motion: reduce) {
  .card {
    transition: none;
  }
  
  .card:hover {
    transform: none;
  }
}

自动化测试

Precision Tool 工作流

运行全面的无障碍检查:

discover:
  queries:
    # Missing alt attributes
    - id: missing_alt
      type: grep
      pattern: "<img(?![^>]*alt=)"
      glob: "**/*.{tsx,jsx}"
    
    # onClick without keyboard handler
    - id: onclick_no_keyboard
      type: grep
      pattern: 'onClick=\\{(?!.*onKeyDown)'
      glob: "**/*.{tsx,jsx}"
    
    # Non-button with role=button without keyboard
    - id: role_button_no_keyboard
      type: grep
      pattern: 'role="button"(?!.*onKeyDown)'
      glob: "**/*.{tsx,jsx}"
    
    # Missing form labels
    - id: unlabeled_inputs
      type: grep
      pattern: "<input(?![^>]*aria-label)(?![^>]*id=)"
      glob: "**/*.{tsx,jsx}"
    
    # Heading level skips
    - id: heading_usage
      type: grep
      pattern: "<h[1-6]"
      glob: "**/*.{tsx,jsx}"
  verbosity: locations
基于浏览器的测试

运行以下工具,进行全面的自动化测试:

  1. Lighthouse 无障碍审计

    npx lighthouse https://localhost:3000 --only-categories=accessibility --view
    
  2. axe DevTools

  3. Pa11y CI

    npm install --save-dev pa11y-ci
    npx pa11y-ci --sitemap https://yoursite.com/sitemap.xml
    

审计报告

按 WCAG 标准组织问题

报告模板:

# Accessibility Audit Report

**Date:** 2026-02-16
**Auditor:** [Name]
**WCAG Version:** 2.1 Level AA
**Pages Audited:** 12

## Executive Summary

- **Critical Issues:** 3
- **Serious Issues:** 8
- **Moderate Issues:** 15
- **Minor Issues:** 22

## Findings by WCAG Principle

### 1. Perceivable

#### 1.1.1 Non-text Content (Level A) - FAIL

**Issue:** 12 images missing alt text

**Location:**
- `src/components/ProductCard.tsx:45`
- `src/components/Gallery.tsx:78`

**Impact:** Screen reader users cannot understand image content

**Recommendation:**
```typescript
// Before
<img src="/product.jpg" />

// After
<img src="/product.jpg" alt="Blue cotton t-shirt" />
1.4.3 对比度(最低)(AA 级)- 未通过

问题: 主按钮文本的对比度为 3.2:1(要求为 4.5:1)

位置: src/styles/globals.css:45

影响: 低视力用户可能无法阅读按钮文本

建议:

/* Before: #60a5fa on #3b82f6 = 3.2:1 */
.btn-primary {
  background: #3b82f6;
  color: #60a5fa;
}

/* After: white on #2563eb = 4.5:1 */
.btn-primary {
  background: #2563eb;
  color: #ffffff;
}
2. 可操作性
2.1.1 键盘(A 级)- 未通过

问题: 自定义下拉菜单无法通过键盘访问

位置: src/components/Dropdown.tsx

影响: 键盘用户无法操作下拉菜单

建议: 实现方向键导航,以及按 Enter/Space 键激活

3. 可理解性
3.3.2 标签或说明(A 级)- 未通过

问题: 表单输入缺少可见标签

位置: src/components/ContactForm.tsx:23-45

建议: 添加带有 htmlFor 属性的显式 <label> 元素

4. 稳健性
4.1.2 名称、角色、值(A 级)- 未通过

问题: 自定义复选框缺少 ARIA 状态

位置: src/components/CustomCheckbox.tsx

建议: 添加 aria-checked 状态和 role="checkbox"

优先处理事项

  1. 紧急: 为所有图片添加替代文本(影响 100% 的屏幕阅读器用户)
  2. 紧急: 修复下拉菜单的键盘导航(阻碍键盘用户)
  3. 高优先级: 修复主按钮的颜色对比度(影响约 4.5% 的用户)
  4. 高优先级: 添加表单标签(影响屏幕阅读器用户和有认知障碍的用户)

## Related Skills

- **code-review** - Apply accessibility checks during code review
- **component-architecture** - Design accessible component APIs
- **testing-strategy** - Include accessibility in test suites

## References

- [WCAG 2.1 Guidelines](https://www.w3.org/WAI/WCAG21/quickref/)
- [ARIA Authoring Practices Guide (APG)](https://www.w3.org/WAI/ARIA/apg/)
- [WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/)
- [A11y Project Checklist](https://www.a11yproject.com/checklist/)

评论

支持 Markdown