Audit and fix web accessibility issues. Enforce WCAG 2.1 AA compliance, semantic HTML, ARIA patterns, keyboard navigation, screen reader compatibility.
Audit web applications for WCAG 2.1/2.2 compliance, fix accessibility barriers, implement proper semantic HTML and ARIA patterns.
// BAD: Div button without keyboard support
<div className="btn" onClick={handleClick}>
Submit
</div>
// BAD: Color-only error indication
<input className="border-red-500" />
<span style={{color: 'red'}}>Error</span>
// BAD: Image without alt text
<img src="/chart.png" />
// BAD: Aria-label overuse
<button aria-label="Click me">Click me</button>
// BAD: Auto-focus abuse
<input autoFocus />
<Modal open={isOpen}>
<input autoFocus /> {/* Steals focus */}
</Modal>
// BAD: No heading hierarchy
<h1>Page Title</h1>
<h4>Section</h4> {/* Skips h2, h3 */}
// BAD: Form without labels
<input type="text" placeholder="Email" />
<select>
<option>Choose...</option>
</select>
// BAD: Custom dropdown without keyboard support
<div onClick={toggle}>
{options.map(opt => <div onClick={() => select(opt)}>{opt}</div>)}
</div>
// GOOD: Semantic button with proper keyboard support
<button type="button" onClick={handleClick}>
Submit
</button>
// GOOD: Multi-sensory error indication
<input
className="border-red-500"
aria-invalid="true"
aria-describedby="email-error"
/>
<span id="email-error" className="text-red-600" role="alert">
<span className="sr-only">Error:</span>
Invalid email format
</span>
// GOOD: Descriptive alt text (or empty for decorative)
<img src="/sales-chart.png" alt="Sales increased 40% in Q4" />
<img src="/decorative-line.svg" alt="" role="presentation" />
// GOOD: Redundant aria-label removed
<button>Click me</button>
// GOOD: Managed focus with trap
function Modal({ open, onClose, children }) {
const modalRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const previousFocus = document.activeElement as HTMLElement;
modalRef.current?.focus();
return () => previousFocus?.focus();
}, [open]);
return (
<div
ref={modalRef}
role="dialog"
aria-modal="true"
tabIndex={-1}
onKeyDown={(e) => {
if (e.key === 'Escape') onClose();
}}
>
{children}
</div>
);
}
// GOOD: Proper heading hierarchy
<h1>Page Title</h1>
<h2>Main Section</h2>
<h3>Subsection</h3>
<h2>Another Section</h2>
// GOOD: Labeled form controls
<label htmlFor="email">Email address</label>
<input
id="email"
type="email"
aria-required="true"
aria-describedby="email-hint"
/>
<span id="email-hint" className="text-gray-600">
We'll never share your email
</span>
<label htmlFor="country">Country</label>
<select id="country" aria-required="true">
<option value="">Select a country</option>
<option value="us">United States</option>
</select>
// GOOD: Accessible custom dropdown
function Dropdown({ options, value, onChange, label }) {
const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(0);
const buttonRef = useRef<HTMLButtonElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setActiveIndex(prev => Math.min(prev + 1, options.length - 1));
break;
case 'ArrowUp':
e.preventDefault();
setActiveIndex(prev => Math.max(prev - 1, 0));
break;
case 'Enter':
case ' ':
e.preventDefault();
onChange(options[activeIndex]);
setOpen(false);
buttonRef.current?.focus();
break;
case 'Escape':
setOpen(false);
buttonRef.current?.focus();
break;
}
};
return (
<div>
<button
ref={buttonRef}
type="button"
aria-haspopup="listbox"
aria-expanded={open}
aria-labelledby="dropdown-label"
onClick={() => setOpen(!open)}
>
<span id="dropdown-label">{label}</span>: {value}
</button>
{open && (
<ul
ref={listRef}
role="listbox"
aria-labelledby="dropdown-label"
onKeyDown={handleKeyDown}
tabIndex={-1}
>
{options.map((opt, i) => (
<li
key={opt}
role="option"
aria-selected={i === activeIndex}
onClick={() => {
onChange(opt);
setOpen(false);
buttonRef.current?.focus();
}}
>
{opt}
</li>
))}
</ul>
)}
</div>
);
}
// Roving tabindex for toolbar
function Toolbar({ items }) {
const [focusedIndex, setFocusedIndex] = useState(0);
const handleKeyDown = (e: KeyboardEvent, index: number) => {
let newIndex = index;
if (e.key === 'ArrowRight') {
newIndex = (index + 1) % items.length;
} else if (e.key === 'ArrowLeft') {
newIndex = (index - 1 + items.length) % items.length;
} else if (e.key === 'Home') {
newIndex = 0;
} else if (e.key === 'End') {
newIndex = items.length - 1;
} else {
return;
}
e.preventDefault();
setFocusedIndex(newIndex);
};
return (
<div role="toolbar" aria-label="Formatting tools">
{items.map((item, i) => (
<button
key={item.id}
type="button"
tabIndex={i === focusedIndex ? 0 : -1}
onKeyDown={(e) => handleKeyDown(e, i)}
onFocus={() => setFocusedIndex(i)}
>
{item.label}
</button>
))}
</div>
);
}
// Skip navigation link
function Layout({ children }) {
return (
<>
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-0 focus:left-0 focus:z-50 focus:p-4 focus:bg-blue-600 focus:text-white"
>
Skip to main content
</a>
<nav aria-label="Main navigation">
{/* Nav items */}
</nav>
<main id="main-content" tabIndex={-1}>
{children}
</main>
</>
);
}
// Focus-visible styling (CSS)
/* Show focus only for keyboard users */
button:focus {
outline: none;
}
button:focus-visible {
outline: 2px solid #0066cc;
outline-offset: 2px;
}
// Status announcements
function Toast({ message, type }) {
return (
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="toast"
>
{message}
</div>
);
}
// Urgent alerts
function ErrorAlert({ message }) {
return (
<div
role="alert"
aria-live="assertive"
className="bg-red-100 border border-red-600 p-4"
>
<strong>Error:</strong> {message}
</div>
);
}
// Loading state announcement
function DataTable({ loading, data }) {
return (
<div>
{loading && (
<div role="status" aria-live="polite">
<span className="sr-only">Loading data...</span>
<div className="spinner" aria-hidden="true" />
</div>
)}
<table>
<caption className="sr-only">Sales data for 2025</caption>
<thead>
<tr>
<th scope="col">Month</th>
<th scope="col">Revenue</th>
</tr>
</thead>
<tbody>
{data.map(row => (
<tr key={row.id}>
<th scope="row">{row.month}</th>
<td>{row.revenue}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
/* GOOD: WCAG AA contrast ratios */
.text-normal {
color: #1a1a1a; /* 4.5:1 on white */
background: #ffffff;
}
.text-large {
font-size: 18px;
font-weight: 700;
color: #4a4a4a; /* 3:1 on white (large text) */
}
.button-primary {
background: #0066cc; /* 4.5:1 with white text */
color: #ffffff;
}
/* Respect reduced motion preference */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* GOOD: Graceful animation degradation */
@media (prefers-reduced-motion: no-preference) {
.fade-in {
animation: fadeIn 0.3s ease-in;
}
}
@media (prefers-reduced-motion: reduce) {
.fade-in {
opacity: 1; /* Skip animation, show immediately */
}
}
// axe-core integration (Jest + React Testing Library)
import { axe, toHaveNoViolations } from 'jest-axe';
import { render } from '@testing-library/react';
expect.extend(toHaveNoViolations);
test('LoginForm has no accessibility violations', async () => {
const { container } = render(<LoginForm />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
// Playwright accessibility audit
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test('Homepage should not have accessibility violations', async ({ page }) => {
await page.goto('http://localhost:3000');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
.analyze();
expect(results.violations).toEqual([]);
});
// Cypress accessibility testing
describe('Accessibility', () => {
beforeEach(() => {
cy.visit('/');
cy.injectAxe();
});
it('Has no detectable accessibility violations', () => {
cy.checkA11y();
});
it('Has no violations on specific component', () => {
cy.checkA11y('.navigation-menu');
});
it('Respects wcag2aa standard', () => {
cy.checkA11y(null, {
runOnly: {
type: 'tag',
values: ['wcag2aa']
}
});
});
});
When auditing accessibility:
Common WCAG 2.1 Level AA failures:
Fix priority: Critical (blocks screen readers) > High (keyboard issues) > Medium (contrast) > Low (enhancements).
npx skills add medy-gribkov/accessibility-wcag下载完整 Skill 目录,包含 SKILL.md 及所有相关文件
Search for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Start voice calls via the OpenClaw voice-call plugin.
Notion API for creating and managing pages, databases, and blocks.
Gemini CLI for one-shot Q&A, summaries, and generation.
Category:developer