Loading...
Loading...
WCAG 2.2 compliance, ARIA patterns, keyboard navigation, screen readers, automated testing
npx skill4agent add travisjneuman/.claude accessibility-a11y<button><nav><dialog>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">×</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()role="dialog"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// 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>
);
}tabIndex={-1}// 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">×</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 */}
</>
);
}aria-live="polite"role="alert"// 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| Text Size | WCAG AA | WCAG AAA |
|---|---|---|
| Normal text (< 18px / 14px bold) | 4.5:1 | 7:1 |
| Large text (>= 18px / 14px bold) | 3:1 | 4.5:1 |
| UI components & graphical objects | 3:1 | 3:1 |
| Decorative / disabled elements | No requirement | No requirement |
| Need | Use This | Not This |
|---|---|---|
| Label for input | | |
| Describe input requirements | | Title attribute |
| Hide decorative content | | |
| Mark required field | | Only visual asterisk |
| Announce error | | Just changing text color |
| Expandable section | | Custom |
| Anti-Pattern | Why It's Bad | Better Approach |
|---|---|---|
| No keyboard access, no role, no focus | Use |
| Using ARIA where native HTML works | More complex, more bugs | |
| Breaks natural tab order | Use |
| Screen reader hears it twice | Use |
Hiding focus outlines ( | Keyboard users lose their place | Style |
| Disorients screen reader users | Let users navigate naturally |
| Images of text | Cannot be resized, not translatable | Use real text with CSS styling |
:focus-visiblealtalt=""<label>aria-describedbyprefers-reduced-motion<html lang="en">performance-engineeringgrowth-engineeringdocs/reference/checklists/ui-visual-changes.mddocs/reference/stacks/react-typescript.md