Built from scratch (replaces reverted PR #375 contribution). Skill package: - SKILL.md: 1132 lines, 3-phase workflow (scan → fix → verify), per-framework fix patterns (React, Next.js, Vue, Angular, Svelte, HTML), CI/CD integration guide, 20+ issue type coverage - scripts/a11y_scanner.py: static scanner detecting 20+ violation types across HTML/JSX/TSX/Vue/Svelte/CSS — severity-ranked, CI-friendly exit codes - scripts/contrast_checker.py: WCAG contrast calculator with AA/AAA checks, --suggest mode, --batch CSS scanning, named color support - references/wcag-quick-ref.md: WCAG 2.2 Level A/AA criteria table - references/aria-patterns.md: ARIA roles, live regions, keyboard interaction - references/framework-a11y-patterns.md: React, Vue, Angular, Svelte fix patterns - assets/sample-component.tsx: sample file with intentional violations - expected_outputs/: scan report, contrast output, JSON output samples - /a11y-audit slash command, settings.json, plugin.json, README.md Validation: 97.6/100 (EXCELLENT), quality 73.9/100 (B-), scripts 2/2 PASS Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
5.9 KiB
5.9 KiB
ARIA Patterns & Keyboard Interaction Reference
Landmark Roles
Every page should have these landmarks:
<header role="banner"> <!-- Site header — once per page -->
<nav role="navigation"> <!-- Navigation — can have multiple with aria-label -->
<main role="main"> <!-- Main content — once per page -->
<aside role="complementary"> <!-- Sidebar — related but not essential -->
<footer role="contentinfo"> <!-- Site footer — once per page -->
<form role="search"> <!-- Search form -->
Semantic HTML equivalents: <header>, <nav>, <main>, <aside>, <footer> provide implicit roles — no need to double up with explicit role attributes.
Live Regions
When to Use
| Pattern | Attribute | Use Case |
|---|---|---|
| Polite | aria-live="polite" |
Toast notifications, status updates, search result counts |
| Assertive | aria-live="assertive" |
Error messages, urgent alerts, form validation errors |
| Status | role="status" |
Loading indicators, progress updates |
| Alert | role="alert" |
Error dialogs, time-sensitive warnings |
| Log | role="log" |
Chat messages, activity feeds |
| Timer | role="timer" |
Countdown timers |
Implementation
<!-- Toast notifications -->
<div aria-live="polite" aria-atomic="true">
<!-- Inject toast content here dynamically -->
</div>
<!-- Form validation errors -->
<div aria-live="assertive" role="alert">
<p>Please enter a valid email address.</p>
</div>
<!-- Loading state -->
<div role="status" aria-live="polite">
Loading results...
</div>
Key rule: The live region container must exist in the DOM before content is injected. Adding aria-live to a newly created element won't announce it.
Focus Management
Focus Trap (Modals)
// Trap focus inside modal
const modal = document.querySelector('[role="dialog"]');
const focusable = modal.querySelectorAll(
'a[href], button, textarea, input, select, [tabindex]:not([tabindex="-1"])'
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
modal.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
if (e.key === 'Escape') closeModal();
});
Focus Restoration
// Save focus before opening modal
const trigger = document.activeElement;
openModal();
// Restore focus on close
function closeModal() {
modal.hidden = true;
trigger.focus();
}
Skip Link
<a href="#main-content" class="skip-link">Skip to main content</a>
<!-- ... navigation ... -->
<main id="main-content" tabindex="-1">
.skip-link {
position: absolute;
left: -9999px;
z-index: 999;
}
.skip-link:focus {
left: 10px;
top: 10px;
background: #000;
color: #fff;
padding: 8px 16px;
}
Keyboard Interaction Patterns
Tabs
Tab → Move to tab list, then to tab panel
Arrow Left/Right → Switch between tabs
Home → First tab
End → Last tab
<div role="tablist" aria-label="Settings">
<button role="tab" aria-selected="true" aria-controls="panel-1" id="tab-1">General</button>
<button role="tab" aria-selected="false" aria-controls="panel-2" id="tab-2" tabindex="-1">Security</button>
</div>
<div role="tabpanel" id="panel-1" aria-labelledby="tab-1">...</div>
<div role="tabpanel" id="panel-2" aria-labelledby="tab-2" hidden>...</div>
Combobox / Autocomplete
Arrow Down → Open list / next option
Arrow Up → Previous option
Enter → Select option
Escape → Close list
Type → Filter options
Menu
Enter/Space → Activate item
Arrow Down → Next item
Arrow Up → Previous item
Arrow Right → Open submenu
Arrow Left → Close submenu
Escape → Close menu
Accordion
Enter/Space → Toggle section
Arrow Down → Next header
Arrow Up → Previous header
Home → First header
End → Last header
Framework-Specific ARIA
React
// Announce route changes (SPA)
<div aria-live="polite" className="sr-only">
{`Navigated to ${pageTitle}`}
</div>
// Error boundary with accessible error
<div role="alert">
<h2>Something went wrong</h2>
<p>{error.message}</p>
</div>
Vue
<!-- Announce dynamic content -->
<div aria-live="polite">
<p v-if="results.length">{{ results.length }} results found</p>
</div>
<!-- Accessible toggle -->
<button
:aria-expanded="isOpen"
:aria-controls="panelId"
@click="toggle"
>
{{ isOpen ? 'Collapse' : 'Expand' }}
</button>
Angular
<!-- cdkTrapFocus for modals -->
<div cdkTrapFocus cdkTrapFocusAutoCapture role="dialog" aria-labelledby="dialog-title">
<h2 id="dialog-title">Confirm Action</h2>
</div>
<!-- LiveAnnouncer service -->
<!-- In component: this.liveAnnouncer.announce('Item added to cart'); -->
Common ARIA Mistakes
| Mistake | Why It's Wrong | Fix |
|---|---|---|
<div role="button"> without keyboard |
Div doesn't get keyboard events | Use <button> or add tabindex="0" + onkeydown |
aria-hidden="true" on focusable element |
Screen reader skips it but keyboard reaches it | Remove from tab order too: tabindex="-1" |
aria-label overriding visible text |
Confusing for sighted screen reader users | Use aria-labelledby pointing to visible text |
| Redundant ARIA on semantic HTML | <nav role="navigation"> is redundant |
Drop the role — <nav> implies it |
aria-live on container that already has content |
Initial content gets announced on load | Add aria-live to empty container, inject content after |
Missing aria-expanded on toggles |
Screen reader can't tell if section is open | Add aria-expanded="true/false" |