关于 accessibility-auditor
面向 Web 开发者的无障碍审查技能,协助检查 WCAG 2.1、语义 HTML、键盘操作和屏幕阅读器支持。涵盖自动化与人工测试,并提供常见界面模式的实现指导。
主要功能
- 01按 WCAG 2.1 检查无障碍问题
- 02改进语义 HTML 与 ARIA 属性
- 03检查键盘导航和焦点管理
- 04检查色彩对比度与表单标注
- 05结合自动化及人工测试
适用场景
- 01审查现有 Web 界面的无障碍问题
- 02为组件添加键盘操作与焦点管理
- 03检查表单错误提示和屏幕阅读器播报
- 04测试页面对比度与 WCAG AA 要求
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ patricio0312rev/ skills --skill accessibility-auditor安装后在 Claude Code 中输入/accessibility-auditor确认已加载。
详细介绍
无障碍审计
构建符合 WCAG 2.1 标准并采用全面无障碍模式的包容性 Web 体验。
核心工作流
- 审计现有代码:识别无障碍问题
- 检查 WCAG 合规性:依据成功标准进行验证
- 修正语义化 HTML:使用恰当的元素和地标
- 添加 ARIA 属性:增强对辅助技术的支持
- 实现键盘导航:确保全面支持键盘操作
- 使用工具测试:进行自动化和手动测试
- 使用屏幕阅读器验证:进行真实场景测试
WCAG 2.1 快速参考
合规等级
| 等级 | 说明 | 要求 |
|---|---|---|
| A | 基本无障碍要求 | 必须达到 |
| AA | 标准合规 | 行业标准 |
| AAA | 增强无障碍体验 | 最好达到 |
四项原则(POUR)
- 可感知:内容必须能通过所有感官呈现
- 可操作:所有用户都必须能够导航界面
- 可理解:内容必须清晰且可预测
- 稳健:内容必须能与辅助技术协同工作
语义化 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 对比度要求
| 等级 | 普通文本 | 大号文本 |
|---|---|---|
| AA | 4.5:1 | 3:1 |
| AAA | 7:1 | 4.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% 后,布局仍可正常使用
- 使用浏览器工具检查颜色对比度
- 确认焦点指示器清晰可见
- 使用减少动态效果偏好进行测试
- 确认表单错误提示正常播报
最佳实践
- 优先使用语义化 HTML:先使用原生元素,再考虑 ARIA
- 管理焦点:移除焦点轮廓时,务必提供替代样式
- 播报变化:使用实时区域播报动态内容
- 与用户一起测试:让残障用户参与测试
- 渐进增强:确保没有 JavaScript 时核心功能仍可用
- 不依赖颜色传达含义:不要仅通过颜色表达含义
- 触控目标:移动设备上的最小尺寸为 44x44px
- 动画:遵循
prefers-reduced-motion
输出检查清单
每次无障碍审计都应确认:
- 全面使用语义化 HTML
- 标题层级正确(h1 → h2 → h3)
- 所有交互元素均可通过键盘操作
- 所有可聚焦元素均有可见焦点
- 图像均有恰当的替代文本
- 表单字段均有关联标签
- 错误消息通过 aria-describedby 关联
- 颜色对比度符合 WCAG AA(4.5:1)
- 提供跳转到主要内容的链接
- 正确使用 ARIA 属性
- 已实现模态框焦点陷阱
- 动态内容会播报给屏幕阅读器
- 已使用 axe-core 或类似工具测试
- 已完成屏幕阅读器手动测试
评论
支持 Markdown