mcpField
A
accessibility-auditor

accessibility-auditor

github.com/patricio0312rev测试与质量63357 次安装42 周前更新
代码仓库

关于 accessibility-auditor

面向 Web 开发者的无障碍审查技能,协助检查 WCAG 2.1、语义 HTML、键盘操作和屏幕阅读器支持。涵盖自动化与人工测试,并提供常见界面模式的实现指导。

主要功能

  1. 01按 WCAG 2.1 检查无障碍问题
  2. 02改进语义 HTML 与 ARIA 属性
  3. 03检查键盘导航和焦点管理
  4. 04检查色彩对比度与表单标注
  5. 05结合自动化及人工测试

适用场景

  1. 01审查现有 Web 界面的无障碍问题
  2. 02为组件添加键盘操作与焦点管理
  3. 03检查表单错误提示和屏幕阅读器播报
  4. 04测试页面对比度与 WCAG AA 要求

安装

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

$npx skills add https://github.com/patricio0312rev/skills --skill accessibility-auditor

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

详细介绍

无障碍审计

构建符合 WCAG 2.1 标准并采用全面无障碍模式的包容性 Web 体验。

核心工作流

  1. 审计现有代码:识别无障碍问题
  2. 检查 WCAG 合规性:依据成功标准进行验证
  3. 修正语义化 HTML:使用恰当的元素和地标
  4. 添加 ARIA 属性:增强对辅助技术的支持
  5. 实现键盘导航:确保全面支持键盘操作
  6. 使用工具测试:进行自动化和手动测试
  7. 使用屏幕阅读器验证:进行真实场景测试

WCAG 2.1 快速参考

合规等级
等级说明要求
A基本无障碍要求必须达到
AA标准合规行业标准
AAA增强无障碍体验最好达到
四项原则(POUR)
  1. 可感知:内容必须能通过所有感官呈现
  2. 可操作:所有用户都必须能够导航界面
  3. 可理解:内容必须清晰且可预测
  4. 稳健:内容必须能与辅助技术协同工作

语义化 HTML

使用恰当的元素

<div class="header">
  <div class="nav">
    <div onclick="navigate()">Home</div>
  </div>
</div>


<header>
  <nav aria-label="Main navigation">
    <a href="/">Home</a>
  </nav>
</header>
文档地标
<body>
  <header>
    <nav aria-label="Main">...</nav>
  </header>

  <main id="main-content">
    <article>
      <h1>Page Title</h1>
      <section aria-labelledby="section-heading">
        <h2 id="section-heading">Section</h2>
      </section>
    </article>
    <aside aria-label="Related content">...</aside>
  </main>

  <footer>...</footer>
</body>
标题层级

<h1>Page Title</h1>
  <h2>Section</h2>
    <h3>Subsection</h3>
    <h3>Subsection</h3>
  <h2>Section</h2>
    <h3>Subsection</h3>



ARIA 模式

按钮
// Interactive element that looks like a button
<button type="button" onClick={handleClick}>
  Click me
</button>

// If you must use a div (avoid if possible)
<div
  role="button"
  tabIndex={0}
  onClick={handleClick}
  onKeyDown={(e) => {
    if (e.key === 'Enter' || e.key === ' ') {
      e.preventDefault();
      handleClick();
    }
  }}
>
  Click me
</div>
模态框 / 对话框
// components/Modal.tsx
import { useEffect, useRef } from 'react';

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

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

  useEffect(() => {
    if (isOpen) {
      // Store current focus
      previousActiveElement.current = document.activeElement;
      // Focus modal
      modalRef.current?.focus();
      // Prevent body scroll
      document.body.style.overflow = 'hidden';
    } else {
      // Restore focus
      (previousActiveElement.current as HTMLElement)?.focus();
      document.body.style.overflow = '';
    }

    return () => {
      document.body.style.overflow = '';
    };
  }, [isOpen]);

  // Handle escape key
  useEffect(() => {
    const handleEscape = (e: KeyboardEvent) => {
      if (e.key === 'Escape' && isOpen) {
        onClose();
      }
    };

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

  if (!isOpen) return null;

  return (
    <div
      className="fixed inset-0 z-50 flex items-center justify-center"
      role="presentation"
    >
      {/* Backdrop */}
      <div
        className="absolute inset-0 bg-black/50"
        onClick={onClose}
        aria-hidden="true"
      />

      {/* Modal */}
      <div
        ref={modalRef}
        role="dialog"
        aria-modal="true"
        aria-labelledby="modal-title"
        tabIndex={-1}
        className="relative z-10 bg-white rounded-lg p-6 max-w-md w-full"
      >
        <h2 id="modal-title" className="text-xl font-bold">
          {title}
        </h2>

        <div className="mt-4">{children}</div>

        <button
          onClick={onClose}
          className="absolute top-4 right-4"
          aria-label="Close modal"
        >
          ×
        </button>
      </div>
    </div>
  );
}
标签页
// components/Tabs.tsx
import { useState, useRef, KeyboardEvent } from 'react';

interface Tab {
  id: string;
  label: string;
  content: React.ReactNode;
}

export function Tabs({ tabs }: { tabs: Tab[] }) {
  const [activeTab, setActiveTab] = useState(tabs[0].id);
  const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);

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

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

    e.preventDefault();
    setActiveTab(tabs[newIndex].id);
    tabRefs.current[newIndex]?.focus();
  };

  return (
    <div>
      <div role="tablist" aria-label="Content tabs" className="flex border-b">
        {tabs.map((tab, index) => (
          <button
            key={tab.id}
            ref={(el) => (tabRefs.current[index] = el)}
            role="tab"
            id={`tab-${tab.id}`}
            aria-selected={activeTab === tab.id}
            aria-controls={`panel-${tab.id}`}
            tabIndex={activeTab === tab.id ? 0 : -1}
            onClick={() => setActiveTab(tab.id)}
            onKeyDown={(e) => handleKeyDown(e, index)}
            className={`px-4 py-2 ${
              activeTab === tab.id
                ? 'border-b-2 border-blue-500'
                : 'text-gray-500'
            }`}
          >
            {tab.label}
          </button>
        ))}
      </div>

      {tabs.map((tab) => (
        <div
          key={tab.id}
          role="tabpanel"
          id={`panel-${tab.id}`}
          aria-labelledby={`tab-${tab.id}`}
          hidden={activeTab !== tab.id}
          tabIndex={0}
          className="p-4"
        >
          {tab.content}
        </div>
      ))}
    </div>
  );
}
下拉菜单
// components/Dropdown.tsx
import { useState, useRef, useEffect, KeyboardEvent } from 'react';

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

export function Dropdown({ label, items }: { label: string; items: MenuItem[] }) {
  const [isOpen, setIsOpen] = useState(false);
  const [activeIndex, setActiveIndex] = useState(-1);
  const menuRef = useRef<HTMLUListElement>(null);
  const buttonRef = useRef<HTMLButtonElement>(null);

  const handleKeyDown = (e: KeyboardEvent) => {
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault();
        if (!isOpen) {
          setIsOpen(true);
          setActiveIndex(0);
        } else {
          setActiveIndex((prev) => (prev + 1) % items.length);
        }
        break;
      case 'ArrowUp':
        e.preventDefault();
        setActiveIndex((prev) => (prev - 1 + items.length) % items.length);
        break;
      case 'Enter':
      case ' ':
        e.preventDefault();
        if (isOpen && activeIndex >= 0) {
          items[activeIndex].onClick();
          setIsOpen(false);
          buttonRef.current?.focus();
        } else {
          setIsOpen(true);
        }
        break;
      case 'Escape':
        setIsOpen(false);
        buttonRef.current?.focus();
        break;
    }
  };

  // Close on outside click
  useEffect(() => {
    const handleClickOutside = (e: MouseEvent) => {
      if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
        setIsOpen(false);
      }
    };

    document.addEventListener('mousedown', handleClickOutside);
    return () => document.removeEventListener('mousedown', handleClickOutside);
  }, []);

  return (
    <div className="relative">
      <button
        ref={buttonRef}
        aria-haspopup="true"
        aria-expanded={isOpen}
        aria-controls="dropdown-menu"
        onClick={() => setIsOpen(!isOpen)}
        onKeyDown={handleKeyDown}
        className="px-4 py-2 bg-gray-100 rounded"
      >
        {label}
      </button>

      {isOpen && (
        <ul
          ref={menuRef}
          id="dropdown-menu"
          role="menu"
          aria-labelledby="dropdown-button"
          onKeyDown={handleKeyDown}
          className="absolute mt-1 bg-white border rounded shadow-lg"
        >
          {items.map((item, index) => (
            <li
              key={item.id}
              role="menuitem"
              tabIndex={-1}
              onClick={() => {
                item.onClick();
                setIsOpen(false);
              }}
              className={`px-4 py-2 cursor-pointer ${
                index === activeIndex ? 'bg-blue-100' : ''
              }`}
            >
              {item.label}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

焦点管理

跳过链接

<a href="#main-content" class="sr-only focus:not-sr-only focus:absolute focus:p-4 focus:bg-white focus:z-50">
  Skip to main content
</a>


<main id="main-content" tabindex="-1">
  ...
</main>
模态框焦点陷阱
// hooks/useFocusTrap.ts
import { useEffect, useRef } from 'react';

export function useFocusTrap<T extends HTMLElement>(isActive: boolean) {
  const containerRef = useRef<T>(null);

  useEffect(() => {
    if (!isActive || !containerRef.current) return;

    const container = containerRef.current;
    const focusableElements = container.querySelectorAll<HTMLElement>(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );
    const firstElement = focusableElements[0];
    const lastElement = focusableElements[focusableElements.length - 1];

    const handleTab = (e: KeyboardEvent) => {
      if (e.key !== 'Tab') return;

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

    container.addEventListener('keydown', handleTab);
    firstElement?.focus();

    return () => container.removeEventListener('keydown', handleTab);
  }, [isActive]);

  return containerRef;
}
焦点可见样式
/* Only show focus ring for keyboard users */
:focus {
  outline: none;
}

:focus-visible {
  outline: 2px solid #3b82f6;
  outline-offset: 2px;
}

/* Tailwind equivalent */
.focus-visible:focus-visible {
  @apply outline-none ring-2 ring-blue-500 ring-offset-2;
}

颜色对比度

WCAG 对比度要求
等级普通文本大号文本
AA4.5:13:1
AAA7:14.5:1

大号文本 = 18pt 及以上(24px),或 14pt 及以上的粗体(18.5px)

无障碍颜色组合
/* High contrast pairs */
:root {
  /* Text on white background */
  --text-primary: #1f2937;   /* gray-800, 12.6:1 contrast */
  --text-secondary: #4b5563; /* gray-600, 7.0:1 contrast */
  --text-tertiary: #6b7280;  /* gray-500, 4.6:1 contrast (AA only) */

  /* Links */
  --link-color: #1d4ed8;     /* blue-700, 7.3:1 contrast */

  /* Errors */
  --error-text: #dc2626;     /* red-600, 4.5:1 contrast */
}
测试对比度
// Utility to check contrast ratio
function getContrastRatio(color1: string, color2: string): number {
  const getLuminance = (hex: string): number => {
    const rgb = parseInt(hex.slice(1), 16);
    const r = (rgb >> 16) & 0xff;
    const g = (rgb >> 8) & 0xff;
    const b = (rgb >> 0) & 0xff;

    const [rs, gs, bs] = [r, g, b].map((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;
  };

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

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

// Usage
const ratio = getContrastRatio('#1f2937', '#ffffff'); // 12.6
const passesAA = ratio >= 4.5;
const passesAAA = ratio >= 7;

表单

无障碍表单字段
// components/FormField.tsx
interface FormFieldProps {
  id: string;
  label: string;
  error?: string;
  required?: boolean;
  description?: string;
  children: React.ReactNode;
}

export function FormField({
  id,
  label,
  error,
  required,
  description,
  children,
}: FormFieldProps) {
  const descriptionId = description ? `${id}-description` : undefined;
  const errorId = error ? `${id}-error` : undefined;

  return (
    <div className="space-y-1">
      <label htmlFor={id} className="block font-medium">
        {label}
        {required && (
          <span className="text-red-500 ml-1" aria-hidden="true">
            *
          </span>
        )}
        {required && <span className="sr-only">(required)</span>}
      </label>

      {description && (
        <p id={descriptionId} className="text-sm text-gray-500">
          {description}
        </p>
      )}

      {/* Clone child and add aria attributes */}
      {React.cloneElement(children as React.ReactElement, {
        id,
        'aria-required': required,
        'aria-invalid': !!error,
        'aria-describedby': [descriptionId, errorId].filter(Boolean).join(' ') || undefined,
      })}

      {error && (
        <p id={errorId} className="text-sm text-red-600" role="alert">
          {error}
        </p>
      )}
    </div>
  );
}
错误提示
// components/LiveRegion.tsx
export function LiveRegion({ message }: { message: string }) {
  return (
    <div
      role="alert"
      aria-live="polite"
      aria-atomic="true"
      className="sr-only"
    >
      {message}
    </div>
  );
}

// Usage: Announce form submission result
const [announcement, setAnnouncement] = useState('');

const handleSubmit = async () => {
  try {
    await submitForm();
    setAnnouncement('Form submitted successfully');
  } catch {
    setAnnouncement('Error submitting form. Please try again.');
  }
};

图像和媒体

图像替代文本

<img src="chart.png" alt="Sales increased 25% from Q1 to Q2 2024" />


<img src="decoration.svg" alt="" role="presentation" />


<figure>
  <img src="infographic.png" alt="Company growth infographic" aria-describedby="infographic-desc" />
  <figcaption id="infographic-desc">
    Detailed description of the infographic...
  </figcaption>
</figure>
视频无障碍
<video controls>
  <source src="video.mp4" type="video/mp4" />
  <track kind="captions" src="captions-en.vtt" srclang="en" label="English" default />
  <track kind="descriptions" src="descriptions.vtt" srclang="en" label="Audio descriptions" />
</video>

屏幕阅读器辅助工具

Tailwind 仅屏幕阅读器可见类
/* Already in Tailwind */
.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;
}

.not-sr-only {
  position: static;
  width: auto;
  height: auto;
  padding: 0;
  margin: 0;
  overflow: visible;
  clip: auto;
  white-space: normal;
}
仅供屏幕阅读器使用的文本
// components/VisuallyHidden.tsx
export function VisuallyHidden({ children }: { children: React.ReactNode }) {
  return <span className="sr-only">{children}</span>;
}

// Usage
<button>
  <TrashIcon aria-hidden="true" />
  <VisuallyHidden>Delete item</VisuallyHidden>
</button>

测试工具

自动化测试
// jest-axe for unit tests
import { axe, toHaveNoViolations } from 'jest-axe';
import { render } from '@testing-library/react';

expect.extend(toHaveNoViolations);

test('component has no accessibility violations', async () => {
  const { container } = render(<MyComponent />);
  const results = await axe(container);
  expect(results).toHaveNoViolations();
});
Playwright 无障碍测试
// tests/a11y.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

test('homepage has no accessibility violations', async ({ page }) => {
  await page.goto('/');

  const accessibilityScanResults = await new AxeBuilder({ page }).analyze();

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

test('keyboard navigation works', async ({ page }) => {
  await page.goto('/');

  // Tab through interactive elements
  await page.keyboard.press('Tab');
  const firstFocused = await page.evaluate(() => document.activeElement?.tagName);
  expect(['A', 'BUTTON', 'INPUT']).toContain(firstFocused);

  // Test skip link
  await page.keyboard.press('Enter');
  await expect(page.locator('#main-content')).toBeFocused();
});
手动测试检查清单
  • 仅使用键盘浏览整个页面
  • 使用屏幕阅读器测试(VoiceOver、NVDA)
  • 放大到 200% 后,布局仍可正常使用
  • 使用浏览器工具检查颜色对比度
  • 确认焦点指示器清晰可见
  • 使用减少动态效果偏好进行测试
  • 确认表单错误提示正常播报

最佳实践

  1. 优先使用语义化 HTML:先使用原生元素,再考虑 ARIA
  2. 管理焦点:移除焦点轮廓时,务必提供替代样式
  3. 播报变化:使用实时区域播报动态内容
  4. 与用户一起测试:让残障用户参与测试
  5. 渐进增强:确保没有 JavaScript 时核心功能仍可用
  6. 不依赖颜色传达含义:不要仅通过颜色表达含义
  7. 触控目标:移动设备上的最小尺寸为 44x44px
  8. 动画:遵循 prefers-reduced-motion

输出检查清单

每次无障碍审计都应确认:

  • 全面使用语义化 HTML
  • 标题层级正确(h1 → h2 → h3)
  • 所有交互元素均可通过键盘操作
  • 所有可聚焦元素均有可见焦点
  • 图像均有恰当的替代文本
  • 表单字段均有关联标签
  • 错误消息通过 aria-describedby 关联
  • 颜色对比度符合 WCAG AA(4.5:1)
  • 提供跳转到主要内容的链接
  • 正确使用 ARIA 属性
  • 已实现模态框焦点陷阱
  • 动态内容会播报给屏幕阅读器
  • 已使用 axe-core 或类似工具测试
  • 已完成屏幕阅读器手动测试

评论

支持 Markdown