mcpField
A
a11y

a11y

github.com/dawitlabs测试与质量741 次安装24 周前更新
代码仓库

关于 a11y

用于检查和修复 Web 应用无障碍问题的技能,面向开发者。它结合 axe-core 扫描、键盘导航测试和代码检查,逐屏处理问题并在修改后复测。

主要功能

  1. 01桌面与移动视口 axe 扫描
  2. 02键盘导航与焦点可见性测试
  3. 03代码级结构问题检查
  4. 04按影响程度排列并修复问题
  5. 05复测并汇总未解决项

适用场景

  1. 01审查应用各页面的无障碍情况
  2. 02发现键盘操作和焦点问题
  3. 03检查移动端触控目标与对比度

安装

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

$npx skills add https://github.com/dawitlabs/ui-skills --skill a11y

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

附带文件WCAG.md

详细介绍

/a11y — 无障碍审计总览

查找并修复整个应用中的所有无障碍违规。逐屏通过程序化方式(axe-core)和视觉方式(键盘截图)进行检查,涵盖从严重到轻微的问题。

完整的 WCAG 速查表和修复模式:WCAG.md


阶段 0 — 环境设置

0a. 检查 / 启动开发服务器
curl -s --max-time 2 -o /dev/null -w "%{http_code}" http://localhost:3000

如果不是 200,查找并启动开发命令,然后等待:

until curl -s --max-time 2 http://localhost:3000 > /dev/null 2>&1; do sleep 1; done && echo "ready"
0b. 查找源代码根目录
find . -type d -name "src" \
  -not -path "*/node_modules/*" \
  -not -path "*/.next/*" \
  -not -path "*/dist/*" \
  -not -path "*/.turbo/*" \
  | head -1

如果没有 src/:find . -type d -name "app" -not -path "*/node_modules/*" -not -path "*/.next/*" | head -1

在全文中将结果用作 <SRC>。

0c. 验证 axe-core
find . -path "*/axe-core/package.json" -not -path "*/node_modules/.cache/*" 2>/dev/null | head -1

如果未找到:安装它(仅作为开发依赖):

pnpm add -D axe-core 2>/dev/null || npm install -D axe-core 2>/dev/null || yarn add -D axe-core

解析 axe 包的路径,以便稍后注入:

find . -path "*/axe-core/axe.min.js" -not -path "*/node_modules/.cache/*" 2>/dev/null | head -1

将其保存为 <AXE_PATH>。

0d. 编写扫描脚本

写入项目根目录(需要 node_modules)。在阶段 4 删除。

_a11y_scan.mjs:

import { chromium } from '@playwright/test';
import { mkdirSync, writeFileSync } from 'fs';
import { createRequire } from 'module';

const require = createRequire(import.meta.url);
const get = (f) => { const i = process.argv.indexOf(f); return i !== -1 ? process.argv[i+1] : null; };

const url        = get('--url');
const name       = get('--name') ?? 'screen';
const width      = parseInt(get('--width')  ?? '1280');
const height     = parseInt(get('--height') ?? '900');
const auth       = get('--auth');
const screenshot = get('--screenshot') ?? `/tmp/a11y-${name}.png`;
const report     = get('--report')     ?? `/tmp/a11y-${name}.json`;

if (!url) { console.error('--url required'); process.exit(1); }

mkdirSync('/tmp/a11y', { recursive: true });

const axePath = (() => {
  try { return require.resolve('axe-core'); }
  catch { console.error('axe-core not found — run: pnpm add -D axe-core'); process.exit(1); }
})();

const browser = await chromium.launch();
const context = await browser.newContext({
  viewport: { width, height },
  ...(auth ? { storageState: auth } : {}),
});
const page = await context.newPage();

try {
  await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
} catch {
  await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
}

// Inject axe-core from local node_modules
await page.addScriptTag({ path: axePath });

// Run axe scan
const results = await page.evaluate(async () => {
  const r = await window.axe.run(document, {
    runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'best-practice'] },
  });
  return {
    violations:  r.violations.map(v => ({
      id:          v.id,
      impact:      v.impact,
      description: v.description,
      help:        v.help,
      helpUrl:     v.helpUrl,
      nodes:       v.nodes.map(n => ({ html: n.html, target: n.target, failureSummary: n.failureSummary })),
    })),
    incomplete: r.incomplete.length,
    passes:     r.passes.length,
  };
});

await page.screenshot({ path: screenshot, fullPage: true });
await browser.close();

writeFileSync(report, JSON.stringify(results, null, 2));

const counts = { critical: 0, serious: 0, moderate: 0, minor: 0 };
for (const v of results.violations) counts[v.impact] = (counts[v.impact] ?? 0) + 1;

console.log(`saved: ${screenshot}`);
console.log(`report: ${report}`);
console.log(`violations: critical=${counts.critical} serious=${counts.serious} moderate=${counts.moderate} minor=${counts.minor}`);
console.log(`passes: ${results.passes} | incomplete: ${results.incomplete}`);

用法:

node _a11y_scan.mjs --url http://localhost:3000 --name home
node _a11y_scan.mjs --url http://localhost:3000/dashboard --name dashboard --auth /tmp/uiux-auth-state.json
node _a11y_scan.mjs --url http://localhost:3000/settings --name settings --width 375 --height 812

每次扫描后:阅读 JSON 报告并查看截图。

0e. 编写键盘导航脚本

写入项目根目录。在阶段 4 删除。

_a11y_keyboard.mjs:

import { chromium } from '@playwright/test';
import { mkdirSync } from 'fs';

const get = (f) => { const i = process.argv.indexOf(f); return i !== -1 ? process.argv[i+1] : null; };

const url    = get('--url');
const name   = get('--name') ?? 'screen';
const width  = parseInt(get('--width')  ?? '1280');
const height = parseInt(get('--height') ?? '900');
const auth   = get('--auth');
const maxTabs = parseInt(get('--max-tabs') ?? '30');

if (!url) { console.error('--url required'); process.exit(1); }

mkdirSync('/tmp/a11y', { recursive: true });

const browser = await chromium.launch();
const context = await browser.newContext({
  viewport: { width, height },
  ...(auth ? { storageState: auth } : {}),
});
const page = await context.newPage();

try {
  await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
} catch {
  await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
}

const issues = [];

// Check for skip link as first tab stop
await page.keyboard.press('Tab');
const skipLink = await page.evaluate(() => {
  const el = document.activeElement;
  if (!el || el === document.body) return null;
  return { tag: el.tagName, text: el.textContent?.trim(), href: el.getAttribute('href') };
});

const hasSkipLink = skipLink?.text?.toLowerCase().includes('skip') && skipLink?.href?.startsWith('#');
if (!hasSkipLink) issues.push({ type: 'missing-skip-link', detail: 'First Tab stop is not a "Skip to main content" link' });

// Tab through interactive elements
const focusLog = [];
for (let i = 0; i < maxTabs; i++) {
  await page.keyboard.press('Tab');
  const focusInfo = await page.evaluate(() => {
    const el = document.activeElement;
    if (!el || el === document.body) return null;
    const style   = getComputedStyle(el);
    const rect    = el.getBoundingClientRect();

    // Capture focused styles
    const focusedBorder     = style.borderColor;
    const focusedBackground = style.backgroundColor;

    // Briefly blur to capture unfocused styles, then refocus
    el.blur();
    const blurStyle       = getComputedStyle(el);
    const blurBorder      = blurStyle.borderColor;
    const blurBackground  = blurStyle.backgroundColor;
    el.focus();

    return {
      tag:                el.tagName,
      role:               el.getAttribute('role'),
      ariaLabel:          el.getAttribute('aria-label'),
      text:               el.textContent?.trim().slice(0, 60),
      outlineWidth:       style.outlineWidth,
      outlineStyle:       style.outlineStyle,
      boxShadow:          style.boxShadow,
      hasBorderChange:    focusedBorder     !== blurBorder,
      hasBackgroundChange:focusedBackground !== blurBackground,
      width:              Math.round(rect.width),
      height:             Math.round(rect.height),
    };
  });
  if (!focusInfo) break;

  // Detect focus visibility via outline, box-shadow, or meaningful border/background change.
  // Compare against a snapshot of the element's non-focused style by briefly blurring and refocusing.
  const hasVisibleFocus = (
    (focusInfo.outlineStyle !== 'none' && focusInfo.outlineWidth !== '0px')
    || (focusInfo.boxShadow && focusInfo.boxShadow !== 'none')
    || focusInfo.hasBorderChange
    || focusInfo.hasBackgroundChange
  );
  const hasName = !!(focusInfo.ariaLabel || focusInfo.text);
  const isTiny  = focusInfo.width < 44 || focusInfo.height < 44;

  if (!hasVisibleFocus) issues.push({ type: 'no-focus-visible', element: focusInfo.tag, text: focusInfo.text });
  if (!hasName)         issues.push({ type: 'missing-accessible-name', element: focusInfo.tag });
  if (isTiny)           issues.push({ type: 'small-tap-target', element: focusInfo.tag, text: focusInfo.text, size: `${focusInfo.width}×${focusInfo.height}` });

  focusLog.push({ ...focusInfo, hasVisibleFocus, hasName });
}

// Screenshot after keyboard traversal (last focused state)
await page.screenshot({ path: `/tmp/a11y/${name}-keyboard.png`, fullPage: false });
await browser.close();

console.log(`keyboard screenshot: /tmp/a11y/${name}-keyboard.png`);
console.log(`tab stops visited: ${focusLog.length}`);
if (issues.length) {
  console.log('issues found:');
  issues.forEach(i => console.log(' -', JSON.stringify(i)));
} else {
  console.log('no keyboard issues found');
}

用法:

node _a11y_keyboard.mjs --url http://localhost:3000 --name home
node _a11y_keyboard.mjs --url http://localhost:3000/dashboard --name dashboard --auth /tmp/uiux-auth-state.json --max-tabs 50
0f. 处理受身份验证保护的屏幕
ls /tmp/uiux-auth-state.json /tmp/animate-auth-state.json /tmp/copy-auth-state.json 2>/dev/null | head -1

如果找到,则对受保护路由的所有脚本调用传入 --auth <path>。

如果未找到且存在受保护的屏幕,则在项目根目录编写 _a11y_auth.mjs:

import { chromium } from '@playwright/test';
const { TEST_EMAIL, TEST_PASSWORD, LOGIN_URL, SUCCESS_URL } = process.env;
if (!TEST_EMAIL || !TEST_PASSWORD) { console.error('Set TEST_EMAIL and TEST_PASSWORD'); process.exit(1); }
const browser = await chromium.launch();
const page    = await browser.newPage();
await page.goto(LOGIN_URL ?? 'http://localhost:3000/sign-in');
await page.fill('input[type="email"]',    TEST_EMAIL);
await page.fill('input[type="password"]', TEST_PASSWORD);
await page.click('button[type="submit"]');
await page.waitForURL(SUCCESS_URL ?? '**/dashboard**', { timeout: 15000 });
await page.context().storageState({ path: '/tmp/a11y-auth-state.json' });
await browser.close();
console.log('saved → /tmp/a11y-auth-state.json');

阶段 1 — 无障碍审计

1a. 查找所有屏幕

列出 App Router 中的每个 page.tsx、Pages Router 中的每个页面、模态框、抽屉、身份验证流程、设置和入门引导,并注明各自的路由。

1b. 对每个屏幕运行 axe 扫描

对每个屏幕分别在桌面和移动视口下运行。只有在移动视口下才会出现点击目标失败问题。

node _a11y_scan.mjs --url http://localhost:3000<ROUTE> --name <name>-desktop
node _a11y_scan.mjs --url http://localhost:3000<ROUTE> --name <name>-mobile --width 375 --height 812

阅读两份 JSON 报告和两张截图。按影响程度归类违规:

  • 严重 — impact: "critical" — 辅助技术无法访问内容
  • 重大 — impact: "serious" — 造成重大障碍,通常违反 WCAG AA
  • 中等 — impact: "moderate" — 降低使用体验
  • 轻微 — impact: "minor" — 违反最佳实践
1c. 对每个屏幕运行键盘导航测试

对每个屏幕运行 _a11y_keyboard.mjs。阅读标准输出,查找问题。查看键盘截图。

node _a11y_keyboard.mjs --url http://localhost:3000<ROUTE> --name <name>

根据输出手动检查:

  • 第一个 Tab 停靠点是否为跳过链接?
  • 所有可交互元素是否都有可见的焦点环?
  • 所有按钮和链接是否都有无障碍名称?
  • 所有点击目标是否至少为 44×44px?
1d. 对结构性问题进行代码级扫描
SRC=<your source root>

# Images without alt
grep -rn "<img" "$SRC" --include="*.tsx" --include="*.jsx" | grep -v "alt="

# Buttons with no accessible name
grep -rn "<button" "$SRC" --include="*.tsx" | grep -v "aria-label\|aria-labelledby"

# onClick on non-interactive elements
grep -rn "onClick" "$SRC" --include="*.tsx" | grep -v "button\|a\|input\|select\|textarea\|role="

# Missing form labels
grep -rn "<input" "$SRC" --include="*.tsx" | grep -v "aria-label\|aria-labelledby\|id=" | grep -v "type=\"hidden\""

# Heading hierarchy — look for h3 without an h2 parent context
grep -rn "<h[1-6]" "$SRC" --include="*.tsx"

# lang attribute on html element
grep -rn "<html" "$SRC" --include="*.tsx" --include="*.html"

# color-only state signals (errors shown only with a red class, no icon/text)
grep -rn "text-red\|text-error\|border-red" "$SRC" --include="*.tsx" | head -20

# Dynamic content missing aria-live
# Find containers that update dynamically but have no live region announcement
grep -rn "useState\|useEffect\|isLoading\|isError\|toast\|notification\|alert" "$SRC" --include="*.tsx" -l | \
  xargs grep -L "aria-live\|aria-atomic\|role=\"alert\"\|role=\"status\""

需要使用 aria-live 的动态内容:

  • Toast / 通知消息
  • 表单验证错误(提交后)
  • 搜索结果数量(“找到 3 条结果”)
  • 加载完成消息
  • 实时数据更新

如果组件在不重新加载页面的情况下更新内容,却没有 aria-live,屏幕阅读器将无法获取这些变化——标记出来以便人工审核。

1e. 检查 prefers-reduced-motion
grep -rn "prefers-reduced-motion" "$SRC" --include="*.css" --include="*.tsx" --include="*.ts"

如果完全缺失:标记为严重问题——必须能够禁用所有动画。

继续之前,按屏幕分组展示完整审计结果。


阶段 2 — 优先级排序

为每个屏幕评分:

因素分值
存在严重违规(内容无法访问)+4
存在重大违规(违反 WCAG AA)+3
入口 / 高流量页面(落地页、身份验证、仪表板)+3
对收入至关重要(结账、定价、注册)+3
键盘导航失效或被困住+3
存在中等违规+2
存在轻微违规+1
仅供管理员 / 高级用户使用−1

按分数从高到低排序。开始阶段 3 前请用户确认。


阶段 3 — 递归修复循环

按排名顺序逐个处理屏幕。每个屏幕最多进行 3 次迭代。

3a. 阅读违规报告

阅读阶段 1 中该屏幕的 JSON。按影响程度归类:严重 → 重大 → 中等 → 轻微。再次查看 axe 截图和键盘截图。

3b. 分析

对于每项违规,阅读源文件以了解上下文。参照 WCAG.md,找出正确的修复模式。分类如下:

  • Critical — 屏幕阅读器无法访问内容、键盘陷阱、缺少 lang 属性
  • Major — 颜色对比度不达标、缺少表单标签、图片缺少 alt、按钮没有名称、非语义化的交互元素
  • Minor — ARIA 使用不当、标题层级跳跃、缺少跳过链接、点击目标过小、缺少 prefers-reduced-motion
3c. 修复——先修复结构问题,再修复视觉问题

优先级顺序:

  1. 语义结构(lang、标题、地标、列表)
  2. 交互元素(按钮角色、键盘访问、不要在 div 上使用 onClick)
  3. 名称和标签(aria-label、alt 文本、表单标签)
  4. ARIA 正确性(角色、状态、属性)
  5. 对比度(使用 ui-design-principles 中的 HSB 调整方法)
  6. 焦点可见性(添加 focus-visible:outline 或 focus-visible:ring)
  7. 动效(如果缺少 prefers-reduced-motion,则添加)
  8. 点击目标(增加内边距,而不是直接增加宽度/高度)

自动应用修复——无需许可:

Semantic HTML:
  <div onClick> → <button>
  <div role="button"> → <button> (unless ARIA role is truly needed)
  <img> missing alt → alt="" for decorative, descriptive alt for informative
  heading skip (h1→h3) → add missing h2

Labels:
  <input> no label → add aria-label or associate <label htmlFor>
  <button> no text → add aria-label describing the action
  icon-only button → add aria-label, add <span className="sr-only">

ARIA:
  aria-label on non-interactive div → move to the interactive child
  role="button" on <a> → remove role, keep <a> with href
  aria-hidden on focusable element → remove aria-hidden or tabIndex={-1}

Contrast:
  Use HSB adjustments: darker = increase S + decrease B
  Prefer token changes over component-level overrides

Focus:
  Add to global CSS or Tailwind config:
    focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand
  Never suppress with: outline: none (unless replacing with custom focus style)

Skip link:
  Add as first child of <body>:
    <a href="#main-content" className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2 focus:bg-white focus:text-black">
      Skip to main content
    </a>
  Add id="main-content" to the <main> element.

aria-live regions:
  // Non-urgent announcements (search results, save confirmations)
  <div aria-live="polite" aria-atomic="true" className="sr-only">{statusMessage ?? ''}</div>

  // Urgent interruptions (form errors, session expiry)
  <p role="alert">{errorMessage}</p>

  // CRITICAL: container must be in the DOM BEFORE content changes.
  // Wrong:  {message && <div aria-live="polite">{message}</div>}
  // Right:  <div aria-live="polite" className="sr-only">{message ?? ''}</div>

prefers-reduced-motion:
  In globals.css — verify this rule exists, add if missing:
    @media (prefers-reduced-motion: reduce) {
      *, *::before, *::after {
        animation-duration: 0.01ms !important;
        animation-iteration-count: 1 !important;
        transition-duration: 0.01ms !important;
      }
    }
3d. 修复后重新扫描
sleep 3  # hot reload
node _a11y_scan.mjs --url http://localhost:3000<ROUTE> --name <name>-after-desktop
node _a11y_scan.mjs --url http://localhost:3000<ROUTE> --name <name>-after-mobile --width 375 --height 812
node _a11y_keyboard.mjs --url http://localhost:3000<ROUTE> --name <name>-after

阅读新的 JSON 报告。比较修复前后的违规数量。阅读截图。

验证:

  1. Critical 和 serious 违规是否已清除?
  2. 所有交互元素的键盘焦点是否可见?
  3. 跳过链接是否存在且可用?
  4. 所有按钮和输入框是否都有无障碍名称?
  5. 对比度是否达到 4.5:1(正文)和 3:1(大号文本、UI 组件)?

如果仍有任何 critical 或 serious 违规:返回 3c(增加迭代次数,最多 3 次)。

3e. 无障碍检查清单

进入下一个屏幕前:

  • axe 报告中没有 critical 违规
  • axe 报告中没有 serious 违规
  • <html> 元素上有 lang 属性
  • 每页有一个 <main> 地标、一个 <h1>
  • 标题层级连续(不会从 h1 跳到 h3)
  • 跳过链接是第一个可聚焦元素,目标为 #main-content
  • 按 Tab 键可按逻辑顺序访问每个交互元素
  • 每个获得焦点的元素都有可见焦点环(未移除浏览器默认样式)
  • 没有键盘陷阱(可按 Tab 离开每个元素,包括通过 Escape 关闭模态框)
  • 每个 <img> 都有 alt——装饰性图片使用空值 "",信息性图片使用描述性文本
  • 每个 <input> 都有关联的 <label> 或 aria-label
  • 每个仅含图标的按钮都有 aria-label
  • 非交互元素(<div>、<span>、<p>)上没有 onClick
  • 颜色不是唯一的提示方式(错误信息同时使用图标和文本,而不只是颜色)
  • 正文文本对比度 ≥4.5:1,大号文本 ≥3:1,UI 组件 ≥3:1
  • 全局 CSS 中存在 prefers-reduced-motion 规则
  • 触控目标 ≥44×44px(已在键盘脚本输出中验证)
  • 模态框/抽屉打开时会锁定焦点,关闭时焦点返回触发元素
  • 动态内容(toast、错误信息、搜索结果数量、加载完成提示)使用 aria-live="polite" 包裹;紧急消息使用 role="alert"
  • aria-live 容器在内容变化前已存在于 DOM 中(不会随内容一同挂载和卸载)

任何 critical/serious 项未通过 → 返回 3c。第 3 次迭代后:标记并继续。


第 4 阶段 — 清理与报告

删除临时脚本:

rm -f _a11y_scan.mjs _a11y_keyboard.mjs _a11y_auth.mjs

每个屏幕完成后:

✓ Screen name (rank N, iteration K) — A critical + B serious + C moderate + D minor fixed

最终汇总表:

Rank屏幕路由CriticalSeriousModerateMinor迭代次数状态
1......00.........✓ / ⚠

在底部列出所有已标记的违规,以及无法解决的具体问题。


规则

  1. Axe-core 是底线,不是上限。 它能发现约 30–40% 的实际无障碍问题。键盘测试和代码扫描能发现其余问题。
  2. 阅读 JSON,而不只是看数量。 修复前,先了解每项违规的含义。
  3. 语义化 HTML 优于 ARIA。 真正的 <button> 永远优于 <div role="button">。
  4. 移除焦点样式时,必须用其他样式替代。 除非用自定义焦点样式替代,否则 outline: none 就是违规。
  5. 颜色绝不能是唯一的提示方式。 仅通过颜色呈现的每种状态变化,还必须使用图标、文本或图案。
  6. 先修复结构问题,再处理视觉问题。 优先处理地标、标题和键盘访问,最后处理对比度。
  7. 每个屏幕最多迭代 3 次。 标记剩余问题,然后继续处理。
  8. 绝不更改逻辑或功能。 只修改无障碍属性、语义元素和样式。
  9. 也要在 375px 下测试。 在桌面端合格的点击目标,在移动端可能不合格。
  10. 模态框焦点陷阱不可妥协。 打开的模态框若允许焦点移至背景,是严重故障。

请参阅 WCAG.md,了解完整的 WCAG 2.1 AA 快速参考和修复模式。

评论

支持 Markdown