mcpField
A
accessibility-a11y

accessibility-a11y

github.com/travisjneuman设计与 UI106157 次安装今天更新
代码仓库

关于 accessibility-a11y

面向 Web 开发者的无障碍设计指南,涵盖使用屏幕阅读器、键盘和其他辅助技术的用户需求。内容包括 WCAG 2.2、ARIA、焦点管理、色彩对比及自动化测试实践。

主要功能

  1. 01语义化 HTML 与 ARIA 使用
  2. 02键盘导航和焦点管理
  3. 03无障碍表单与错误提示
  4. 04动态内容实时区域模式
  5. 05WCAG 色彩对比参考
  6. 06自动化与手动无障碍测试

适用场景

  1. 01构建无障碍 UI 组件
  2. 02检查现有界面的合规问题
  3. 03修复无障碍审计发现
  4. 04在 CI/CD 中集成自动化测试

安装

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

$npx skills add https://github.com/travisjneuman/.claude --skill accessibility-a11y

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

详细介绍

无障碍访问(a11y)

概述

这项技能涵盖构建人人都能使用的无障碍 Web 应用,包括使用屏幕阅读器、仅通过键盘导航、开关设备及其他辅助技术的人士。内容涉及符合 WCAG 2.2 AA 和 AAA 级别要求、正确使用 ARIA、焦点管理、色彩对比度、减少动态效果支持,以及自动化测试集成。

在构建新的 UI 组件、检查现有界面的无障碍合规性、修复 a11y 审计问题,或将自动化无障碍测试集成到 CI/CD 工作流时,请使用这项技能。


核心原则

  1. 优先使用语义化 HTML - 原生 HTML 元素(<button>、<nav>、<dialog>)天然具备无障碍支持。语义不足时,ARIA 是一种修复手段,而不是正确 HTML 的替代品。
  2. 以键盘操作为基准 - 如果无法仅通过键盘操作,就说明功能不可用。每个交互元素都必须可聚焦、可操作,并具有清晰可见的焦点指示。
  3. 使用真实的辅助技术进行测试 - 自动化工具只能发现约 30% 的无障碍问题。其余问题需要使用屏幕阅读器(VoiceOver、NVDA)和仅键盘导航进行手动测试。
  4. 渐进增强 - 先构建无障碍版本,再逐步添加视觉增强。切勿向辅助技术隐藏视力正常的用户能够看到的内容。
  5. 不能只靠颜色传达信息 - 颜色可以强化含义,但绝不能作为唯一指示方式。应配合使用图标、文本标签和图案。

关键模式

模式 1:无障碍模态对话框

适用场景: 任何要求用户先进行交互才能返回主要内容的覆盖层。

实现:

import { useRef, useEffect, useCallback } from "react";

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

export function Dialog({ isOpen, onClose, title, children }: DialogProps) {
  const dialogRef = useRef<HTMLDialogElement>(null);
  const previousFocusRef = useRef<HTMLElement | null>(null);

  useEffect(() => {
    const dialog = dialogRef.current;
    if (!dialog) return;

    if (isOpen) {
      // Store the element that had focus before opening
      previousFocusRef.current = document.activeElement as HTMLElement;
      dialog.showModal();
    } else {
      dialog.close();
      // Restore focus to the triggering element
      previousFocusRef.current?.focus();
    }
  }, [isOpen]);

  // Handle Escape key (native dialog handles this, but we need cleanup)
  const handleClose = useCallback(() => {
    onClose();
  }, [onClose]);

  // Handle backdrop click
  const handleBackdropClick = useCallback(
    (e: React.MouseEvent<HTMLDialogElement>) => {
      if (e.target === dialogRef.current) {
        onClose();
      }
    },
    [onClose]
  );

  if (!isOpen) return null;

  return (
    <dialog
      ref={dialogRef}
      onClose={handleClose}
      onClick={handleBackdropClick}
      aria-labelledby="dialog-title"
      aria-describedby="dialog-description"
      className="dialog"
    >
      <div className="dialog-content" role="document">
        <header className="dialog-header">
          <h2 id="dialog-title">{title}</h2>
          <button
            onClick={onClose}
            aria-label="Close dialog"
            className="dialog-close"
          >
            <span aria-hidden="true">&times;</span>
          </button>
        </header>
        <div id="dialog-description">{children}</div>
      </div>
    </dialog>
  );
}
/* Focus trap is handled natively by <dialog> showModal() */
dialog::backdrop {
  background: rgba(0, 0, 0, 0.5);
}

dialog .dialog-close:focus-visible {
  outline: 2px solid var(--color-focus);
  outline-offset: 2px;
}

原因: 原生 <dialog> 元素配合 showModal(),可自动提供焦点陷阱、Escape 键处理和正确的 role="dialog" 语义。自定义模态框几乎总会有焦点陷阱问题。使用原生元素即可免费获得正确的行为。


模式 2:带错误处理的无障碍表单

适用场景: 任何收集用户输入并进行验证的表单。

实现:

interface FormFieldProps {
  id: string;
  label: string;
  type?: string;
  required?: boolean;
  error?: string;
  description?: string;
  value: string;
  onChange: (value: string) => void;
}

function FormField({
  id,
  label,
  type = "text",
  required = false,
  error,
  description,
  value,
  onChange,
}: FormFieldProps) {
  const descriptionId = description ? `${id}-description` : undefined;
  const errorId = error ? `${id}-error` : undefined;

  // Build aria-describedby from available descriptions
  const describedBy = [descriptionId, errorId].filter(Boolean).join(" ") || undefined;

  return (
    <div className="form-field">
      <label htmlFor={id}>
        {label}
        {required && <span aria-hidden="true"> *</span>}
        {required && <span className="sr-only"> (required)</span>}
      </label>

      {description && (
        <p id={descriptionId} className="field-description">
          {description}
        </p>
      )}

      <input
        id={id}
        type={type}
        value={value}
        onChange={(e) => onChange(e.target.value)}
        required={required}
        aria-invalid={error ? "true" : undefined}
        aria-describedby={describedBy}
        aria-required={required}
      />

      {error && (
        <p id={errorId} className="field-error" role="alert">
          <span aria-hidden="true">!</span> {error}
        </p>
      )}
    </div>
  );
}

// Form-level error summary for screen readers
function ErrorSummary({ errors }: { errors: Record<string, string> }) {
  const errorEntries = Object.entries(errors);
  if (errorEntries.length === 0) return null;

  return (
    <div role="alert" aria-labelledby="error-summary-title" className="error-summary">
      <h3 id="error-summary-title">
        {errorEntries.length} error{errorEntries.length > 1 ? "s" : ""} found
      </h3>
      <ul>
        {errorEntries.map(([field, message]) => (
          <li key={field}>
            <a href={`#${field}`}>{message}</a>
          </li>
        ))}
      </ul>
    </div>
  );
}
/* Screen-reader 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;
}

.field-error {
  color: var(--color-error);
  font-size: 0.875rem;
  margin-top: 0.25rem;
}

/* Never rely on color alone for errors - include icon */
.field-error::before {
  content: "";
  /* Error icon via background-image */
}

input[aria-invalid="true"] {
  border-color: var(--color-error);
  /* Also use a thicker border or icon, not just color */
  border-width: 2px;
}

原因: 表单是无障碍问题最常见的来源。此模式可确保每个字段都有程序化标签,通过 role="alert" 播报错误,通过 aria-describedby 将错误消息与输入项关联,并提供错误摘要,让键盘用户可以直接跳转到有问题的字段。


模式 3:自定义控件的键盘导航

适用场景: 构建无法映射到原生 HTML 元素的自定义交互组件(标签页、菜单、列表框、组合框)。

实现:

// Accessible tabs following WAI-ARIA Authoring Practices
interface Tab {
  id: string;
  label: string;
  content: React.ReactNode;
}

function Tabs({ tabs }: { tabs: Tab[] }) {
  const [activeIndex, setActiveIndex] = useState(0);

  const handleKeyDown = (e: React.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; // Don't prevent default for other keys
    }

    e.preventDefault();
    setActiveIndex(newIndex);

    // Move focus to the newly active tab
    const tabElement = document.getElementById(`tab-${tabs[newIndex].id}`);
    tabElement?.focus();
  };

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

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

原因: 自定义控件必须实现 WAI-ARIA Authoring Practices 规定的预期键盘交互模式。标签页使用方向键在标签页之间移动(而不是 Tab 键),并为非活动标签页设置 tabIndex={-1},这样 Tab 顺序中就只有当前活动标签页。这符合屏幕阅读器用户的心智模型。


模式 4:动态内容的实时区域

适用场景: 内容更新但页面未重新加载,且需要告知屏幕阅读器用户的情况(通知、搜索结果、加载状态)。

实现:

// Toast notification system with live regions
function ToastContainer({ toasts }: { toasts: Toast[] }) {
  return (
    <div
      aria-live="polite"
      aria-atomic="false"
      aria-relevant="additions"
      className="toast-container"
    >
      {toasts.map((toast) => (
        <div
          key={toast.id}
          role="status"
          className={`toast toast-${toast.type}`}
        >
          <span className="toast-icon" aria-hidden="true">
            {toast.type === "success" ? "check" : "warning"}
          </span>
          <span>{toast.message}</span>
          <button
            onClick={() => dismissToast(toast.id)}
            aria-label={`Dismiss: ${toast.message}`}
          >
            <span aria-hidden="true">&times;</span>
          </button>
        </div>
      ))}
    </div>
  );
}

// For urgent errors, use role="alert" (assertive)
function CriticalError({ message }: { message: string }) {
  return (
    <div role="alert" className="critical-error">
      {message}
    </div>
  );
}

// Search results count announcement
function SearchResults({ query, count }: { query: string; count: number }) {
  return (
    <>
      <div aria-live="polite" className="sr-only">
        {count} results found for "{query}"
      </div>
      {/* Visual results list */}
    </>
  );
}

原因: 屏幕阅读器不会监控 DOM 中的视觉变化。实时区域会明确告知辅助技术播报新内容。对非紧急更新(搜索结果、toast 通知)使用 aria-live="polite",对紧急消息(错误、会话过期)使用 role="alert"。


模式 5:自动化无障碍测试

适用场景: 所有项目,集成到 CI/CD 和开发工作流中。

实现:

// Jest + axe-core for component testing
import { render } from "@testing-library/react";
import { axe, toHaveNoViolations } from "jest-axe";

expect.extend(toHaveNoViolations);

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

  it("should have no violations in error state", async () => {
    const { container } = render(<LoginForm />);

    // Trigger validation errors
    fireEvent.click(screen.getByRole("button", { name: /submit/i }));

    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });
});
// Playwright + axe for E2E accessibility testing
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";

test.describe("accessibility", () => {
  test("home page passes axe audit", async ({ page }) => {
    await page.goto("/");

    const results = await new AxeBuilder({ page })
      .withTags(["wcag2a", "wcag2aa", "wcag22aa"])
      .analyze();

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

  test("modal dialog passes axe audit when open", async ({ page }) => {
    await page.goto("/dashboard");
    await page.getByRole("button", { name: "Create project" }).click();

    const results = await new AxeBuilder({ page })
      .include(".dialog")
      .analyze();

    expect(results.violations).toEqual([]);
  });
});
# GitHub Actions CI integration
name: Accessibility Audit
on: [pull_request]
jobs:
  a11y:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
      - run: npm ci
      - run: npm run build
      - name: Run axe accessibility tests
        run: npx playwright test --grep "accessibility"
      - name: Run pa11y on built pages
        run: |
          npx serve -s build -l 3000 &
          sleep 3
          npx pa11y-ci --config .pa11yci.json

原因: 自动化测试可以持续、及早地发现结构问题(缺少标签、无效的 ARIA、对比度不达标)。在 CI 中自动发现 30% 的问题,也好过完全不自动发现问题,等到发布后才发现。结合手动测试,才能实现全面覆盖。


色彩对比度速查表

文本大小WCAG AAWCAG AAA
普通文本(< 18px / 14px 粗体)4.5:17:1
大号文本(>= 18px / 14px 粗体)3:14.5:1
UI 组件和图形对象3:13:1
装饰性元素 / 禁用元素无要求无要求

ARIA 速查表

需求使用此项不要使用此项
输入框的标签<label for="x"> 或 aria-labelledby仅用 placeholder 作为标签
描述输入要求aria-describedby 指向提示文本Title 属性
隐藏装饰性内容aria-hidden="true"display: none(会对所有人隐藏)
标记必填字段aria-required="true" + required仅用视觉上的星号
播报错误role="alert" 或 aria-live="assertive"仅更改文本颜色
可展开区域aria-expanded="true/false"自定义 data-open 属性

反模式

反模式问题更好的做法
使用 <div onclick> 代替 <button>无法通过键盘访问、没有角色、无法获得焦点可点击元素使用 <button>
原生 HTML 可实现时仍使用 ARIA更复杂,也更容易出错使用 <nav>,而非 <div role="navigation">
tabindex="5"(正数值)会破坏自然的 Tab 顺序仅使用 tabindex="0" 或 tabindex="-1"
aria-label 重复可见文本屏幕阅读器会重复播报仅对不可见标签使用 aria-label
隐藏焦点轮廓(outline: none)键盘用户会失去当前位置设置 :focus-visible 样式,而不是移除轮廓
页面加载时使用 autofocus会使屏幕阅读器用户迷失方向让用户自然地浏览页面
使用文字图片无法调整大小,也无法翻译使用带 CSS 样式的真实文本

检查清单

  • 所有交互元素都可通过键盘访问(Tab、Enter、Space、Escape、方向键)
  • 所有可聚焦元素都有可见的焦点指示器(:focus-visible)
  • 所有图片都有描述性 alt 文本(装饰性图片使用 alt="")
  • 表单输入框都有对应的 <label> 元素
  • 错误消息通过程序与输入框关联(aria-describedby)
  • 色彩对比度符合 WCAG AA(普通文本为 4.5:1,大号文本为 3:1)
  • 不单独依靠颜色传达信息
  • 动画遵循 prefers-reduced-motion 媒体查询
  • 页面标题层级正确(h1 > h2 > h3,不跳级)
  • 已声明语言(<html lang="en">)
  • 在 CI 中检查 axe-core(仅在用户要求自动化无障碍测试时)
  • 已使用 VoiceOver(macOS)或 NVDA(Windows)进行手动测试

相关资源

  • 技能: performance-engineering(性能与无障碍的交集)、growth-engineering(无障碍引导流程)
  • 规则: docs/reference/checklists/ui-visual-changes.md(视觉变更的无障碍检查)
  • 规则: docs/reference/stacks/react-typescript.md(React 组件模式)

评论

支持 Markdown