omgkit 2.2.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/plugin/skills/databases/mongodb/SKILL.md +60 -776
- package/plugin/skills/databases/prisma/SKILL.md +53 -744
- package/plugin/skills/databases/redis/SKILL.md +53 -860
- package/plugin/skills/devops/aws/SKILL.md +68 -672
- package/plugin/skills/devops/github-actions/SKILL.md +54 -657
- package/plugin/skills/devops/kubernetes/SKILL.md +67 -602
- package/plugin/skills/devops/performance-profiling/SKILL.md +59 -863
- package/plugin/skills/frameworks/django/SKILL.md +87 -853
- package/plugin/skills/frameworks/express/SKILL.md +95 -1301
- package/plugin/skills/frameworks/fastapi/SKILL.md +90 -1198
- package/plugin/skills/frameworks/laravel/SKILL.md +87 -1187
- package/plugin/skills/frameworks/nestjs/SKILL.md +106 -973
- package/plugin/skills/frameworks/react/SKILL.md +94 -962
- package/plugin/skills/frameworks/vue/SKILL.md +95 -1242
- package/plugin/skills/frontend/accessibility/SKILL.md +91 -1056
- package/plugin/skills/frontend/frontend-design/SKILL.md +69 -1262
- package/plugin/skills/frontend/responsive/SKILL.md +76 -799
- package/plugin/skills/frontend/shadcn-ui/SKILL.md +73 -921
- package/plugin/skills/frontend/tailwindcss/SKILL.md +60 -788
- package/plugin/skills/frontend/threejs/SKILL.md +72 -1266
- package/plugin/skills/languages/javascript/SKILL.md +106 -849
- package/plugin/skills/methodology/brainstorming/SKILL.md +70 -576
- package/plugin/skills/methodology/defense-in-depth/SKILL.md +79 -831
- package/plugin/skills/methodology/dispatching-parallel-agents/SKILL.md +81 -654
- package/plugin/skills/methodology/executing-plans/SKILL.md +86 -529
- package/plugin/skills/methodology/finishing-development-branch/SKILL.md +95 -586
- package/plugin/skills/methodology/problem-solving/SKILL.md +67 -681
- package/plugin/skills/methodology/receiving-code-review/SKILL.md +70 -533
- package/plugin/skills/methodology/requesting-code-review/SKILL.md +70 -610
- package/plugin/skills/methodology/root-cause-tracing/SKILL.md +70 -646
- package/plugin/skills/methodology/sequential-thinking/SKILL.md +70 -478
- package/plugin/skills/methodology/systematic-debugging/SKILL.md +66 -559
- package/plugin/skills/methodology/test-driven-development/SKILL.md +91 -752
- package/plugin/skills/methodology/testing-anti-patterns/SKILL.md +78 -687
- package/plugin/skills/methodology/token-optimization/SKILL.md +72 -602
- package/plugin/skills/methodology/verification-before-completion/SKILL.md +108 -529
- package/plugin/skills/methodology/writing-plans/SKILL.md +79 -566
- package/plugin/skills/omega/omega-architecture/SKILL.md +91 -752
- package/plugin/skills/omega/omega-coding/SKILL.md +161 -552
- package/plugin/skills/omega/omega-sprint/SKILL.md +132 -777
- package/plugin/skills/omega/omega-testing/SKILL.md +157 -845
- package/plugin/skills/omega/omega-thinking/SKILL.md +165 -606
- package/plugin/skills/security/better-auth/SKILL.md +46 -1034
- package/plugin/skills/security/oauth/SKILL.md +80 -934
- package/plugin/skills/security/owasp/SKILL.md +78 -862
- package/plugin/skills/testing/playwright/SKILL.md +77 -700
- package/plugin/skills/testing/pytest/SKILL.md +73 -811
- package/plugin/skills/testing/vitest/SKILL.md +60 -920
- package/plugin/skills/tools/document-processing/SKILL.md +111 -838
- package/plugin/skills/tools/image-processing/SKILL.md +126 -659
- package/plugin/skills/tools/mcp-development/SKILL.md +85 -758
- package/plugin/skills/tools/media-processing/SKILL.md +118 -735
- package/plugin/stdrules/SKILL_STANDARDS.md +490 -0
- package/plugin/skills/SKILL_STANDARDS.md +0 -743
|
@@ -1,1126 +1,161 @@
|
|
|
1
1
|
---
|
|
2
|
-
name: accessibility
|
|
3
|
-
description:
|
|
4
|
-
category: frontend
|
|
5
|
-
triggers:
|
|
6
|
-
- accessibility
|
|
7
|
-
- a11y
|
|
8
|
-
- wcag
|
|
9
|
-
- screen reader
|
|
10
|
-
- aria
|
|
11
|
-
- keyboard navigation
|
|
2
|
+
name: implementing-accessibility
|
|
3
|
+
description: Claude implements WCAG 2.1 AA compliant web interfaces with proper ARIA, keyboard navigation, and screen reader support. Use when building accessible components or auditing existing UIs.
|
|
12
4
|
---
|
|
13
5
|
|
|
14
|
-
# Accessibility
|
|
6
|
+
# Implementing Accessibility
|
|
15
7
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
## Purpose
|
|
19
|
-
|
|
20
|
-
Build inclusive web applications:
|
|
21
|
-
|
|
22
|
-
- Implement WCAG 2.1 AA compliance
|
|
23
|
-
- Write semantic, accessible HTML
|
|
24
|
-
- Use ARIA attributes correctly
|
|
25
|
-
- Support keyboard navigation
|
|
26
|
-
- Manage focus appropriately
|
|
27
|
-
- Test with screen readers
|
|
28
|
-
- Build accessible components
|
|
29
|
-
|
|
30
|
-
## Features
|
|
31
|
-
|
|
32
|
-
### 1. Semantic HTML Structure
|
|
33
|
-
|
|
34
|
-
```html
|
|
35
|
-
<!-- Accessible page structure -->
|
|
36
|
-
<!DOCTYPE html>
|
|
37
|
-
<html lang="en">
|
|
38
|
-
<head>
|
|
39
|
-
<meta charset="UTF-8">
|
|
40
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
41
|
-
<title>Page Title - Site Name</title>
|
|
42
|
-
</head>
|
|
43
|
-
<body>
|
|
44
|
-
<!-- Skip link for keyboard users -->
|
|
45
|
-
<a href="#main-content" class="skip-link">
|
|
46
|
-
Skip to main content
|
|
47
|
-
</a>
|
|
48
|
-
|
|
49
|
-
<header role="banner">
|
|
50
|
-
<nav aria-label="Main navigation">
|
|
51
|
-
<ul>
|
|
52
|
-
<li><a href="/" aria-current="page">Home</a></li>
|
|
53
|
-
<li><a href="/about">About</a></li>
|
|
54
|
-
<li><a href="/contact">Contact</a></li>
|
|
55
|
-
</ul>
|
|
56
|
-
</nav>
|
|
57
|
-
</header>
|
|
58
|
-
|
|
59
|
-
<main id="main-content" role="main">
|
|
60
|
-
<article>
|
|
61
|
-
<h1>Main Heading</h1>
|
|
62
|
-
<p>Content...</p>
|
|
63
|
-
|
|
64
|
-
<section aria-labelledby="section-heading">
|
|
65
|
-
<h2 id="section-heading">Section Title</h2>
|
|
66
|
-
<p>Section content...</p>
|
|
67
|
-
</section>
|
|
68
|
-
</article>
|
|
69
|
-
|
|
70
|
-
<aside aria-label="Related content">
|
|
71
|
-
<h2>Related Articles</h2>
|
|
72
|
-
<!-- Sidebar content -->
|
|
73
|
-
</aside>
|
|
74
|
-
</main>
|
|
75
|
-
|
|
76
|
-
<footer role="contentinfo">
|
|
77
|
-
<nav aria-label="Footer navigation">
|
|
78
|
-
<!-- Footer links -->
|
|
79
|
-
</nav>
|
|
80
|
-
<p>© 2024 Company Name</p>
|
|
81
|
-
</footer>
|
|
82
|
-
</body>
|
|
83
|
-
</html>
|
|
84
|
-
```
|
|
85
|
-
|
|
86
|
-
```css
|
|
87
|
-
/* Skip link styles */
|
|
88
|
-
.skip-link {
|
|
89
|
-
position: absolute;
|
|
90
|
-
top: -40px;
|
|
91
|
-
left: 0;
|
|
92
|
-
background: #000;
|
|
93
|
-
color: #fff;
|
|
94
|
-
padding: 8px 16px;
|
|
95
|
-
z-index: 100;
|
|
96
|
-
transition: top 0.3s;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
.skip-link:focus {
|
|
100
|
-
top: 0;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/* Focus styles - never remove, customize instead */
|
|
104
|
-
:focus {
|
|
105
|
-
outline: 2px solid #005fcc;
|
|
106
|
-
outline-offset: 2px;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
:focus:not(:focus-visible) {
|
|
110
|
-
outline: none;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
:focus-visible {
|
|
114
|
-
outline: 2px solid #005fcc;
|
|
115
|
-
outline-offset: 2px;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/* Visually hidden but accessible to screen readers */
|
|
119
|
-
.sr-only {
|
|
120
|
-
position: absolute;
|
|
121
|
-
width: 1px;
|
|
122
|
-
height: 1px;
|
|
123
|
-
padding: 0;
|
|
124
|
-
margin: -1px;
|
|
125
|
-
overflow: hidden;
|
|
126
|
-
clip: rect(0, 0, 0, 0);
|
|
127
|
-
white-space: nowrap;
|
|
128
|
-
border: 0;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/* Show when focused (for skip links) */
|
|
132
|
-
.sr-only-focusable:focus {
|
|
133
|
-
position: static;
|
|
134
|
-
width: auto;
|
|
135
|
-
height: auto;
|
|
136
|
-
padding: inherit;
|
|
137
|
-
margin: inherit;
|
|
138
|
-
overflow: visible;
|
|
139
|
-
clip: auto;
|
|
140
|
-
white-space: normal;
|
|
141
|
-
}
|
|
142
|
-
```
|
|
143
|
-
|
|
144
|
-
### 2. ARIA Attributes
|
|
8
|
+
## Quick Start
|
|
145
9
|
|
|
146
10
|
```tsx
|
|
147
|
-
//
|
|
148
|
-
import React from "react";
|
|
149
|
-
|
|
150
|
-
interface AlertProps {
|
|
151
|
-
type: "info" | "success" | "warning" | "error";
|
|
152
|
-
children: React.ReactNode;
|
|
153
|
-
dismissible?: boolean;
|
|
154
|
-
onDismiss?: () => void;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
export function Alert({ type, children, dismissible, onDismiss }: AlertProps) {
|
|
158
|
-
// Map type to appropriate ARIA role
|
|
159
|
-
const role = type === "error" ? "alert" : "status";
|
|
160
|
-
const ariaLive = type === "error" ? "assertive" : "polite";
|
|
161
|
-
|
|
162
|
-
return (
|
|
163
|
-
<div
|
|
164
|
-
role={role}
|
|
165
|
-
aria-live={ariaLive}
|
|
166
|
-
aria-atomic="true"
|
|
167
|
-
className={`alert alert-${type}`}
|
|
168
|
-
>
|
|
169
|
-
<span className="sr-only">{type}:</span>
|
|
170
|
-
<div className="alert-content">{children}</div>
|
|
171
|
-
{dismissible && (
|
|
172
|
-
<button
|
|
173
|
-
type="button"
|
|
174
|
-
aria-label={`Dismiss ${type} message`}
|
|
175
|
-
onClick={onDismiss}
|
|
176
|
-
className="alert-dismiss"
|
|
177
|
-
>
|
|
178
|
-
<span aria-hidden="true">×</span>
|
|
179
|
-
</button>
|
|
180
|
-
)}
|
|
181
|
-
</div>
|
|
182
|
-
);
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// components/accessible/Dialog.tsx
|
|
186
|
-
import React, { useRef, useEffect } from "react";
|
|
187
|
-
import { createPortal } from "react-dom";
|
|
188
|
-
|
|
189
|
-
interface DialogProps {
|
|
190
|
-
isOpen: boolean;
|
|
191
|
-
onClose: () => void;
|
|
192
|
-
title: string;
|
|
193
|
-
children: React.ReactNode;
|
|
194
|
-
}
|
|
195
|
-
|
|
11
|
+
// Accessible dialog with focus trap and ARIA
|
|
196
12
|
export function Dialog({ isOpen, onClose, title, children }: DialogProps) {
|
|
197
13
|
const dialogRef = useRef<HTMLDivElement>(null);
|
|
198
|
-
const
|
|
199
|
-
const titleId = `dialog-title-${React.useId()}`;
|
|
200
|
-
const descId = `dialog-desc-${React.useId()}`;
|
|
14
|
+
const titleId = useId();
|
|
201
15
|
|
|
202
16
|
useEffect(() => {
|
|
203
17
|
if (isOpen) {
|
|
204
|
-
// Store current focus
|
|
205
|
-
previousFocusRef.current = document.activeElement as HTMLElement;
|
|
206
|
-
|
|
207
|
-
// Focus the dialog
|
|
208
18
|
dialogRef.current?.focus();
|
|
209
|
-
|
|
210
|
-
// Prevent body scroll
|
|
211
|
-
document.body.style.overflow = "hidden";
|
|
19
|
+
document.body.style.overflow = 'hidden';
|
|
212
20
|
}
|
|
213
|
-
|
|
214
|
-
return () => {
|
|
215
|
-
document.body.style.overflow = "";
|
|
216
|
-
// Return focus on close
|
|
217
|
-
previousFocusRef.current?.focus();
|
|
218
|
-
};
|
|
21
|
+
return () => { document.body.style.overflow = ''; };
|
|
219
22
|
}, [isOpen]);
|
|
220
23
|
|
|
221
|
-
// Trap focus within dialog
|
|
222
|
-
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
223
|
-
if (e.key === "Escape") {
|
|
224
|
-
onClose();
|
|
225
|
-
return;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
if (e.key !== "Tab") return;
|
|
229
|
-
|
|
230
|
-
const focusableElements = dialogRef.current?.querySelectorAll(
|
|
231
|
-
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
|
232
|
-
);
|
|
233
|
-
|
|
234
|
-
if (!focusableElements?.length) return;
|
|
235
|
-
|
|
236
|
-
const firstElement = focusableElements[0] as HTMLElement;
|
|
237
|
-
const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
|
|
238
|
-
|
|
239
|
-
if (e.shiftKey && document.activeElement === firstElement) {
|
|
240
|
-
e.preventDefault();
|
|
241
|
-
lastElement.focus();
|
|
242
|
-
} else if (!e.shiftKey && document.activeElement === lastElement) {
|
|
243
|
-
e.preventDefault();
|
|
244
|
-
firstElement.focus();
|
|
245
|
-
}
|
|
246
|
-
};
|
|
247
|
-
|
|
248
24
|
if (!isOpen) return null;
|
|
249
|
-
|
|
250
25
|
return createPortal(
|
|
251
26
|
<>
|
|
252
|
-
<div
|
|
253
|
-
|
|
254
|
-
aria-hidden="true"
|
|
255
|
-
onClick={onClose}
|
|
256
|
-
/>
|
|
257
|
-
<div
|
|
258
|
-
ref={dialogRef}
|
|
259
|
-
role="dialog"
|
|
260
|
-
aria-modal="true"
|
|
261
|
-
aria-labelledby={titleId}
|
|
262
|
-
aria-describedby={descId}
|
|
263
|
-
tabIndex={-1}
|
|
264
|
-
onKeyDown={handleKeyDown}
|
|
265
|
-
className="dialog"
|
|
266
|
-
>
|
|
27
|
+
<div className="dialog-backdrop" onClick={onClose} />
|
|
28
|
+
<div ref={dialogRef} role="dialog" aria-modal="true" aria-labelledby={titleId} tabIndex={-1}>
|
|
267
29
|
<h2 id={titleId}>{title}</h2>
|
|
268
|
-
|
|
269
|
-
<button
|
|
270
|
-
type="button"
|
|
271
|
-
aria-label="Close dialog"
|
|
272
|
-
onClick={onClose}
|
|
273
|
-
className="dialog-close"
|
|
274
|
-
>
|
|
275
|
-
×
|
|
276
|
-
</button>
|
|
30
|
+
{children}
|
|
31
|
+
<button onClick={onClose} aria-label="Close dialog">×</button>
|
|
277
32
|
</div>
|
|
278
33
|
</>,
|
|
279
34
|
document.body
|
|
280
35
|
);
|
|
281
36
|
}
|
|
282
|
-
|
|
283
|
-
// components/accessible/Tabs.tsx
|
|
284
|
-
import React, { useState, useRef } from "react";
|
|
285
|
-
|
|
286
|
-
interface Tab {
|
|
287
|
-
id: string;
|
|
288
|
-
label: string;
|
|
289
|
-
content: React.ReactNode;
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
interface TabsProps {
|
|
293
|
-
tabs: Tab[];
|
|
294
|
-
defaultTab?: string;
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
export function Tabs({ tabs, defaultTab }: TabsProps) {
|
|
298
|
-
const [activeTab, setActiveTab] = useState(defaultTab || tabs[0]?.id);
|
|
299
|
-
const tabRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
|
|
300
|
-
|
|
301
|
-
const handleKeyDown = (e: React.KeyboardEvent, index: number) => {
|
|
302
|
-
let newIndex: number | null = null;
|
|
303
|
-
|
|
304
|
-
switch (e.key) {
|
|
305
|
-
case "ArrowLeft":
|
|
306
|
-
newIndex = index === 0 ? tabs.length - 1 : index - 1;
|
|
307
|
-
break;
|
|
308
|
-
case "ArrowRight":
|
|
309
|
-
newIndex = index === tabs.length - 1 ? 0 : index + 1;
|
|
310
|
-
break;
|
|
311
|
-
case "Home":
|
|
312
|
-
newIndex = 0;
|
|
313
|
-
break;
|
|
314
|
-
case "End":
|
|
315
|
-
newIndex = tabs.length - 1;
|
|
316
|
-
break;
|
|
317
|
-
default:
|
|
318
|
-
return;
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
e.preventDefault();
|
|
322
|
-
const newTab = tabs[newIndex];
|
|
323
|
-
setActiveTab(newTab.id);
|
|
324
|
-
tabRefs.current.get(newTab.id)?.focus();
|
|
325
|
-
};
|
|
326
|
-
|
|
327
|
-
return (
|
|
328
|
-
<div className="tabs">
|
|
329
|
-
<div role="tablist" aria-label="Content tabs">
|
|
330
|
-
{tabs.map((tab, index) => (
|
|
331
|
-
<button
|
|
332
|
-
key={tab.id}
|
|
333
|
-
ref={(el) => el && tabRefs.current.set(tab.id, el)}
|
|
334
|
-
role="tab"
|
|
335
|
-
id={`tab-${tab.id}`}
|
|
336
|
-
aria-selected={activeTab === tab.id}
|
|
337
|
-
aria-controls={`panel-${tab.id}`}
|
|
338
|
-
tabIndex={activeTab === tab.id ? 0 : -1}
|
|
339
|
-
onClick={() => setActiveTab(tab.id)}
|
|
340
|
-
onKeyDown={(e) => handleKeyDown(e, index)}
|
|
341
|
-
className={`tab ${activeTab === tab.id ? "active" : ""}`}
|
|
342
|
-
>
|
|
343
|
-
{tab.label}
|
|
344
|
-
</button>
|
|
345
|
-
))}
|
|
346
|
-
</div>
|
|
347
|
-
{tabs.map((tab) => (
|
|
348
|
-
<div
|
|
349
|
-
key={tab.id}
|
|
350
|
-
role="tabpanel"
|
|
351
|
-
id={`panel-${tab.id}`}
|
|
352
|
-
aria-labelledby={`tab-${tab.id}`}
|
|
353
|
-
hidden={activeTab !== tab.id}
|
|
354
|
-
tabIndex={0}
|
|
355
|
-
className="tab-panel"
|
|
356
|
-
>
|
|
357
|
-
{tab.content}
|
|
358
|
-
</div>
|
|
359
|
-
))}
|
|
360
|
-
</div>
|
|
361
|
-
);
|
|
362
|
-
}
|
|
363
37
|
```
|
|
364
38
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
```tsx
|
|
368
|
-
// components/accessible/Menu.tsx
|
|
369
|
-
import React, { useState, useRef, useEffect } from "react";
|
|
370
|
-
|
|
371
|
-
interface MenuItem {
|
|
372
|
-
id: string;
|
|
373
|
-
label: string;
|
|
374
|
-
onClick: () => void;
|
|
375
|
-
disabled?: boolean;
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
interface MenuProps {
|
|
379
|
-
trigger: React.ReactNode;
|
|
380
|
-
items: MenuItem[];
|
|
381
|
-
label: string;
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
export function Menu({ trigger, items, label }: MenuProps) {
|
|
385
|
-
const [isOpen, setIsOpen] = useState(false);
|
|
386
|
-
const [activeIndex, setActiveIndex] = useState(-1);
|
|
387
|
-
const menuRef = useRef<HTMLUListElement>(null);
|
|
388
|
-
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
389
|
-
const menuId = `menu-${React.useId()}`;
|
|
390
|
-
|
|
391
|
-
useEffect(() => {
|
|
392
|
-
if (isOpen && activeIndex >= 0) {
|
|
393
|
-
const item = menuRef.current?.children[activeIndex] as HTMLElement;
|
|
394
|
-
item?.focus();
|
|
395
|
-
}
|
|
396
|
-
}, [isOpen, activeIndex]);
|
|
397
|
-
|
|
398
|
-
// Close on outside click
|
|
399
|
-
useEffect(() => {
|
|
400
|
-
const handleClickOutside = (e: MouseEvent) => {
|
|
401
|
-
if (
|
|
402
|
-
menuRef.current &&
|
|
403
|
-
!menuRef.current.contains(e.target as Node) &&
|
|
404
|
-
!triggerRef.current?.contains(e.target as Node)
|
|
405
|
-
) {
|
|
406
|
-
setIsOpen(false);
|
|
407
|
-
}
|
|
408
|
-
};
|
|
409
|
-
|
|
410
|
-
document.addEventListener("mousedown", handleClickOutside);
|
|
411
|
-
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
412
|
-
}, []);
|
|
413
|
-
|
|
414
|
-
const handleTriggerKeyDown = (e: React.KeyboardEvent) => {
|
|
415
|
-
switch (e.key) {
|
|
416
|
-
case "Enter":
|
|
417
|
-
case " ":
|
|
418
|
-
case "ArrowDown":
|
|
419
|
-
e.preventDefault();
|
|
420
|
-
setIsOpen(true);
|
|
421
|
-
setActiveIndex(0);
|
|
422
|
-
break;
|
|
423
|
-
case "ArrowUp":
|
|
424
|
-
e.preventDefault();
|
|
425
|
-
setIsOpen(true);
|
|
426
|
-
setActiveIndex(items.length - 1);
|
|
427
|
-
break;
|
|
428
|
-
}
|
|
429
|
-
};
|
|
430
|
-
|
|
431
|
-
const handleMenuKeyDown = (e: React.KeyboardEvent) => {
|
|
432
|
-
switch (e.key) {
|
|
433
|
-
case "Escape":
|
|
434
|
-
setIsOpen(false);
|
|
435
|
-
triggerRef.current?.focus();
|
|
436
|
-
break;
|
|
437
|
-
case "ArrowDown":
|
|
438
|
-
e.preventDefault();
|
|
439
|
-
setActiveIndex((prev) =>
|
|
440
|
-
prev === items.length - 1 ? 0 : prev + 1
|
|
441
|
-
);
|
|
442
|
-
break;
|
|
443
|
-
case "ArrowUp":
|
|
444
|
-
e.preventDefault();
|
|
445
|
-
setActiveIndex((prev) =>
|
|
446
|
-
prev === 0 ? items.length - 1 : prev - 1
|
|
447
|
-
);
|
|
448
|
-
break;
|
|
449
|
-
case "Home":
|
|
450
|
-
e.preventDefault();
|
|
451
|
-
setActiveIndex(0);
|
|
452
|
-
break;
|
|
453
|
-
case "End":
|
|
454
|
-
e.preventDefault();
|
|
455
|
-
setActiveIndex(items.length - 1);
|
|
456
|
-
break;
|
|
457
|
-
case "Tab":
|
|
458
|
-
setIsOpen(false);
|
|
459
|
-
break;
|
|
460
|
-
}
|
|
461
|
-
};
|
|
462
|
-
|
|
463
|
-
const handleItemClick = (item: MenuItem) => {
|
|
464
|
-
if (!item.disabled) {
|
|
465
|
-
item.onClick();
|
|
466
|
-
setIsOpen(false);
|
|
467
|
-
triggerRef.current?.focus();
|
|
468
|
-
}
|
|
469
|
-
};
|
|
470
|
-
|
|
471
|
-
return (
|
|
472
|
-
<div className="menu-container">
|
|
473
|
-
<button
|
|
474
|
-
ref={triggerRef}
|
|
475
|
-
aria-haspopup="menu"
|
|
476
|
-
aria-expanded={isOpen}
|
|
477
|
-
aria-controls={menuId}
|
|
478
|
-
onClick={() => setIsOpen(!isOpen)}
|
|
479
|
-
onKeyDown={handleTriggerKeyDown}
|
|
480
|
-
className="menu-trigger"
|
|
481
|
-
>
|
|
482
|
-
{trigger}
|
|
483
|
-
<span className="sr-only">{label}</span>
|
|
484
|
-
</button>
|
|
485
|
-
|
|
486
|
-
{isOpen && (
|
|
487
|
-
<ul
|
|
488
|
-
ref={menuRef}
|
|
489
|
-
id={menuId}
|
|
490
|
-
role="menu"
|
|
491
|
-
aria-label={label}
|
|
492
|
-
onKeyDown={handleMenuKeyDown}
|
|
493
|
-
className="menu-list"
|
|
494
|
-
>
|
|
495
|
-
{items.map((item, index) => (
|
|
496
|
-
<li
|
|
497
|
-
key={item.id}
|
|
498
|
-
role="menuitem"
|
|
499
|
-
tabIndex={activeIndex === index ? 0 : -1}
|
|
500
|
-
aria-disabled={item.disabled}
|
|
501
|
-
onClick={() => handleItemClick(item)}
|
|
502
|
-
onKeyDown={(e) => {
|
|
503
|
-
if (e.key === "Enter" || e.key === " ") {
|
|
504
|
-
e.preventDefault();
|
|
505
|
-
handleItemClick(item);
|
|
506
|
-
}
|
|
507
|
-
}}
|
|
508
|
-
className={`menu-item ${item.disabled ? "disabled" : ""}`}
|
|
509
|
-
>
|
|
510
|
-
{item.label}
|
|
511
|
-
</li>
|
|
512
|
-
))}
|
|
513
|
-
</ul>
|
|
514
|
-
)}
|
|
515
|
-
</div>
|
|
516
|
-
);
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
// hooks/useRovingTabIndex.ts
|
|
520
|
-
import { useState, useCallback } from "react";
|
|
521
|
-
|
|
522
|
-
export function useRovingTabIndex<T>(items: T[]) {
|
|
523
|
-
const [activeIndex, setActiveIndex] = useState(0);
|
|
524
|
-
|
|
525
|
-
const handleKeyDown = useCallback(
|
|
526
|
-
(e: React.KeyboardEvent, index: number) => {
|
|
527
|
-
let newIndex: number | null = null;
|
|
528
|
-
|
|
529
|
-
switch (e.key) {
|
|
530
|
-
case "ArrowDown":
|
|
531
|
-
case "ArrowRight":
|
|
532
|
-
newIndex = index === items.length - 1 ? 0 : index + 1;
|
|
533
|
-
break;
|
|
534
|
-
case "ArrowUp":
|
|
535
|
-
case "ArrowLeft":
|
|
536
|
-
newIndex = index === 0 ? items.length - 1 : index - 1;
|
|
537
|
-
break;
|
|
538
|
-
case "Home":
|
|
539
|
-
newIndex = 0;
|
|
540
|
-
break;
|
|
541
|
-
case "End":
|
|
542
|
-
newIndex = items.length - 1;
|
|
543
|
-
break;
|
|
544
|
-
default:
|
|
545
|
-
return;
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
e.preventDefault();
|
|
549
|
-
setActiveIndex(newIndex);
|
|
550
|
-
},
|
|
551
|
-
[items.length]
|
|
552
|
-
);
|
|
39
|
+
## Features
|
|
553
40
|
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
41
|
+
| Feature | Description | Guide |
|
|
42
|
+
|---------|-------------|-------|
|
|
43
|
+
| Semantic HTML | Proper landmarks, headings, and native elements | `ref/semantic-structure.md` |
|
|
44
|
+
| ARIA Attributes | Roles, states, and properties for custom widgets | `ref/aria-patterns.md` |
|
|
45
|
+
| Keyboard Navigation | Tab order, roving tabindex, keyboard shortcuts | `ref/keyboard-nav.md` |
|
|
46
|
+
| Focus Management | Focus trapping, restoration, visible indicators | `ref/focus-management.md` |
|
|
47
|
+
| Live Regions | Dynamic content announcements for screen readers | `ref/live-regions.md` |
|
|
48
|
+
| Testing | jest-axe, Cypress accessibility, manual testing | `ref/a11y-testing.md` |
|
|
558
49
|
|
|
559
|
-
|
|
560
|
-
}
|
|
561
|
-
```
|
|
50
|
+
## Common Patterns
|
|
562
51
|
|
|
563
|
-
###
|
|
52
|
+
### Accessible Form Field
|
|
564
53
|
|
|
565
54
|
```tsx
|
|
566
|
-
|
|
567
|
-
import React from "react";
|
|
568
|
-
|
|
569
|
-
interface FormFieldProps {
|
|
570
|
-
id: string;
|
|
571
|
-
label: string;
|
|
572
|
-
type?: string;
|
|
573
|
-
required?: boolean;
|
|
574
|
-
error?: string;
|
|
575
|
-
hint?: string;
|
|
576
|
-
children?: React.ReactNode;
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
export function FormField({
|
|
580
|
-
id,
|
|
581
|
-
label,
|
|
582
|
-
type = "text",
|
|
583
|
-
required,
|
|
584
|
-
error,
|
|
585
|
-
hint,
|
|
586
|
-
children,
|
|
587
|
-
}: FormFieldProps) {
|
|
55
|
+
export function FormField({ id, label, error, hint, required, ...props }: FormFieldProps) {
|
|
588
56
|
const hintId = hint ? `${id}-hint` : undefined;
|
|
589
57
|
const errorId = error ? `${id}-error` : undefined;
|
|
590
|
-
const describedBy = [hintId, errorId].filter(Boolean).join(
|
|
58
|
+
const describedBy = [hintId, errorId].filter(Boolean).join(' ') || undefined;
|
|
591
59
|
|
|
592
60
|
return (
|
|
593
|
-
<div className={`form-field ${error ?
|
|
61
|
+
<div className={`form-field ${error ? 'has-error' : ''}`}>
|
|
594
62
|
<label htmlFor={id}>
|
|
595
63
|
{label}
|
|
596
|
-
{required &&
|
|
597
|
-
<span aria-hidden="true" className="required">*</span>
|
|
598
|
-
)}
|
|
64
|
+
{required && <span aria-hidden="true">*</span>}
|
|
599
65
|
{required && <span className="sr-only">(required)</span>}
|
|
600
66
|
</label>
|
|
601
|
-
|
|
602
|
-
{hint && (
|
|
603
|
-
<p id={hintId} className="form-hint">
|
|
604
|
-
{hint}
|
|
605
|
-
</p>
|
|
606
|
-
)}
|
|
607
|
-
|
|
608
|
-
{children || (
|
|
609
|
-
<input
|
|
610
|
-
id={id}
|
|
611
|
-
type={type}
|
|
612
|
-
required={required}
|
|
613
|
-
aria-required={required}
|
|
614
|
-
aria-invalid={!!error}
|
|
615
|
-
aria-describedby={describedBy}
|
|
616
|
-
/>
|
|
617
|
-
)}
|
|
618
|
-
|
|
619
|
-
{error && (
|
|
620
|
-
<p id={errorId} role="alert" className="form-error">
|
|
621
|
-
<span className="sr-only">Error: </span>
|
|
622
|
-
{error}
|
|
623
|
-
</p>
|
|
624
|
-
)}
|
|
625
|
-
</div>
|
|
626
|
-
);
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
// components/accessible/Checkbox.tsx
|
|
630
|
-
interface CheckboxProps {
|
|
631
|
-
id: string;
|
|
632
|
-
label: string;
|
|
633
|
-
checked: boolean;
|
|
634
|
-
onChange: (checked: boolean) => void;
|
|
635
|
-
description?: string;
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
export function Checkbox({
|
|
639
|
-
id,
|
|
640
|
-
label,
|
|
641
|
-
checked,
|
|
642
|
-
onChange,
|
|
643
|
-
description,
|
|
644
|
-
}: CheckboxProps) {
|
|
645
|
-
const descId = description ? `${id}-desc` : undefined;
|
|
646
|
-
|
|
647
|
-
return (
|
|
648
|
-
<div className="checkbox-field">
|
|
67
|
+
{hint && <p id={hintId} className="form-hint">{hint}</p>}
|
|
649
68
|
<input
|
|
650
|
-
type="checkbox"
|
|
651
69
|
id={id}
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
aria-describedby={
|
|
70
|
+
aria-required={required}
|
|
71
|
+
aria-invalid={!!error}
|
|
72
|
+
aria-describedby={describedBy}
|
|
73
|
+
{...props}
|
|
655
74
|
/>
|
|
656
|
-
<
|
|
657
|
-
{description && (
|
|
658
|
-
<p id={descId} className="checkbox-description">
|
|
659
|
-
{description}
|
|
660
|
-
</p>
|
|
661
|
-
)}
|
|
75
|
+
{error && <p id={errorId} role="alert">{error}</p>}
|
|
662
76
|
</div>
|
|
663
77
|
);
|
|
664
78
|
}
|
|
665
|
-
|
|
666
|
-
// components/accessible/RadioGroup.tsx
|
|
667
|
-
interface RadioOption {
|
|
668
|
-
value: string;
|
|
669
|
-
label: string;
|
|
670
|
-
description?: string;
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
interface RadioGroupProps {
|
|
674
|
-
name: string;
|
|
675
|
-
legend: string;
|
|
676
|
-
options: RadioOption[];
|
|
677
|
-
value: string;
|
|
678
|
-
onChange: (value: string) => void;
|
|
679
|
-
required?: boolean;
|
|
680
|
-
error?: string;
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
export function RadioGroup({
|
|
684
|
-
name,
|
|
685
|
-
legend,
|
|
686
|
-
options,
|
|
687
|
-
value,
|
|
688
|
-
onChange,
|
|
689
|
-
required,
|
|
690
|
-
error,
|
|
691
|
-
}: RadioGroupProps) {
|
|
692
|
-
const errorId = error ? `${name}-error` : undefined;
|
|
693
|
-
|
|
694
|
-
return (
|
|
695
|
-
<fieldset
|
|
696
|
-
aria-required={required}
|
|
697
|
-
aria-invalid={!!error}
|
|
698
|
-
aria-describedby={errorId}
|
|
699
|
-
>
|
|
700
|
-
<legend>
|
|
701
|
-
{legend}
|
|
702
|
-
{required && <span className="sr-only">(required)</span>}
|
|
703
|
-
</legend>
|
|
704
|
-
|
|
705
|
-
{options.map((option) => {
|
|
706
|
-
const optionId = `${name}-${option.value}`;
|
|
707
|
-
const descId = option.description ? `${optionId}-desc` : undefined;
|
|
708
|
-
|
|
709
|
-
return (
|
|
710
|
-
<div key={option.value} className="radio-option">
|
|
711
|
-
<input
|
|
712
|
-
type="radio"
|
|
713
|
-
id={optionId}
|
|
714
|
-
name={name}
|
|
715
|
-
value={option.value}
|
|
716
|
-
checked={value === option.value}
|
|
717
|
-
onChange={(e) => onChange(e.target.value)}
|
|
718
|
-
aria-describedby={descId}
|
|
719
|
-
/>
|
|
720
|
-
<label htmlFor={optionId}>{option.label}</label>
|
|
721
|
-
{option.description && (
|
|
722
|
-
<p id={descId} className="radio-description">
|
|
723
|
-
{option.description}
|
|
724
|
-
</p>
|
|
725
|
-
)}
|
|
726
|
-
</div>
|
|
727
|
-
);
|
|
728
|
-
})}
|
|
729
|
-
|
|
730
|
-
{error && (
|
|
731
|
-
<p id={errorId} role="alert" className="form-error">
|
|
732
|
-
{error}
|
|
733
|
-
</p>
|
|
734
|
-
)}
|
|
735
|
-
</fieldset>
|
|
736
|
-
);
|
|
737
|
-
}
|
|
738
79
|
```
|
|
739
80
|
|
|
740
|
-
###
|
|
81
|
+
### Roving Tabindex for Tab Component
|
|
741
82
|
|
|
742
83
|
```tsx
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
return (
|
|
758
|
-
<div
|
|
759
|
-
role={type === "assertive" ? "alert" : "status"}
|
|
760
|
-
aria-live={type}
|
|
761
|
-
aria-atomic={atomic}
|
|
762
|
-
className="sr-only"
|
|
763
|
-
>
|
|
764
|
-
{message}
|
|
765
|
-
</div>
|
|
766
|
-
);
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
// hooks/useAnnounce.ts
|
|
770
|
-
import { useState, useCallback, useRef, useEffect } from "react";
|
|
771
|
-
|
|
772
|
-
export function useAnnounce() {
|
|
773
|
-
const [announcement, setAnnouncement] = useState("");
|
|
774
|
-
const timeoutRef = useRef<NodeJS.Timeout>();
|
|
775
|
-
|
|
776
|
-
const announce = useCallback((message: string, duration = 1000) => {
|
|
777
|
-
// Clear previous announcement
|
|
778
|
-
setAnnouncement("");
|
|
779
|
-
|
|
780
|
-
// Small delay to ensure screen reader picks up change
|
|
781
|
-
requestAnimationFrame(() => {
|
|
782
|
-
setAnnouncement(message);
|
|
783
|
-
});
|
|
784
|
-
|
|
785
|
-
// Clear after duration
|
|
786
|
-
if (timeoutRef.current) {
|
|
787
|
-
clearTimeout(timeoutRef.current);
|
|
84
|
+
export function Tabs({ tabs }: { tabs: Tab[] }) {
|
|
85
|
+
const [activeTab, setActiveTab] = useState(0);
|
|
86
|
+
const tabRefs = useRef<HTMLButtonElement[]>([]);
|
|
87
|
+
|
|
88
|
+
const handleKeyDown = (e: KeyboardEvent, index: number) => {
|
|
89
|
+
let newIndex = index;
|
|
90
|
+
if (e.key === 'ArrowRight') newIndex = (index + 1) % tabs.length;
|
|
91
|
+
if (e.key === 'ArrowLeft') newIndex = (index - 1 + tabs.length) % tabs.length;
|
|
92
|
+
if (e.key === 'Home') newIndex = 0;
|
|
93
|
+
if (e.key === 'End') newIndex = tabs.length - 1;
|
|
94
|
+
if (newIndex !== index) {
|
|
95
|
+
e.preventDefault();
|
|
96
|
+
setActiveTab(newIndex);
|
|
97
|
+
tabRefs.current[newIndex]?.focus();
|
|
788
98
|
}
|
|
789
|
-
timeoutRef.current = setTimeout(() => {
|
|
790
|
-
setAnnouncement("");
|
|
791
|
-
}, duration);
|
|
792
|
-
}, []);
|
|
793
|
-
|
|
794
|
-
useEffect(() => {
|
|
795
|
-
return () => {
|
|
796
|
-
if (timeoutRef.current) {
|
|
797
|
-
clearTimeout(timeoutRef.current);
|
|
798
|
-
}
|
|
799
|
-
};
|
|
800
|
-
}, []);
|
|
801
|
-
|
|
802
|
-
return { announcement, announce };
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
// Usage with loading states
|
|
806
|
-
function SearchResults() {
|
|
807
|
-
const { announcement, announce } = useAnnounce();
|
|
808
|
-
const [results, setResults] = useState([]);
|
|
809
|
-
const [loading, setLoading] = useState(false);
|
|
810
|
-
|
|
811
|
-
const handleSearch = async (query: string) => {
|
|
812
|
-
setLoading(true);
|
|
813
|
-
announce("Searching...");
|
|
814
|
-
|
|
815
|
-
const data = await fetchResults(query);
|
|
816
|
-
setResults(data);
|
|
817
|
-
setLoading(false);
|
|
818
|
-
|
|
819
|
-
announce(`Found ${data.length} results`);
|
|
820
99
|
};
|
|
821
100
|
|
|
822
101
|
return (
|
|
823
102
|
<div>
|
|
824
|
-
<
|
|
825
|
-
|
|
103
|
+
<div role="tablist">
|
|
104
|
+
{tabs.map((tab, i) => (
|
|
105
|
+
<button
|
|
106
|
+
key={tab.id}
|
|
107
|
+
ref={el => tabRefs.current[i] = el!}
|
|
108
|
+
role="tab"
|
|
109
|
+
aria-selected={activeTab === i}
|
|
110
|
+
aria-controls={`panel-${tab.id}`}
|
|
111
|
+
tabIndex={activeTab === i ? 0 : -1}
|
|
112
|
+
onClick={() => setActiveTab(i)}
|
|
113
|
+
onKeyDown={e => handleKeyDown(e, i)}
|
|
114
|
+
>{tab.label}</button>
|
|
115
|
+
))}
|
|
116
|
+
</div>
|
|
117
|
+
{tabs.map((tab, i) => (
|
|
118
|
+
<div key={tab.id} role="tabpanel" id={`panel-${tab.id}`} hidden={activeTab !== i} tabIndex={0}>
|
|
119
|
+
{tab.content}
|
|
120
|
+
</div>
|
|
121
|
+
))}
|
|
826
122
|
</div>
|
|
827
123
|
);
|
|
828
124
|
}
|
|
829
125
|
```
|
|
830
126
|
|
|
831
|
-
###
|
|
832
|
-
|
|
833
|
-
```css
|
|
834
|
-
/* Color contrast guidelines */
|
|
835
|
-
|
|
836
|
-
/* Text colors - minimum 4.5:1 for normal text, 3:1 for large text */
|
|
837
|
-
:root {
|
|
838
|
-
--color-text-primary: #1a1a1a; /* High contrast */
|
|
839
|
-
--color-text-secondary: #4a4a4a; /* 4.5:1 minimum */
|
|
840
|
-
--color-text-disabled: #767676; /* 4.5:1 on white */
|
|
841
|
-
--color-background: #ffffff;
|
|
842
|
-
|
|
843
|
-
/* Focus indicators - minimum 3:1 against adjacent colors */
|
|
844
|
-
--color-focus: #005fcc;
|
|
845
|
-
--color-focus-offset: #ffffff;
|
|
846
|
-
|
|
847
|
-
/* Error states - don't rely on color alone */
|
|
848
|
-
--color-error: #d32f2f;
|
|
849
|
-
--color-error-bg: #ffebee;
|
|
850
|
-
|
|
851
|
-
/* Success states */
|
|
852
|
-
--color-success: #2e7d32;
|
|
853
|
-
--color-success-bg: #e8f5e9;
|
|
854
|
-
}
|
|
855
|
-
|
|
856
|
-
/* Don't rely on color alone - use icons and text */
|
|
857
|
-
.status-indicator {
|
|
858
|
-
display: flex;
|
|
859
|
-
align-items: center;
|
|
860
|
-
gap: 0.5rem;
|
|
861
|
-
}
|
|
862
|
-
|
|
863
|
-
.status-indicator::before {
|
|
864
|
-
content: "";
|
|
865
|
-
width: 8px;
|
|
866
|
-
height: 8px;
|
|
867
|
-
border-radius: 50%;
|
|
868
|
-
}
|
|
869
|
-
|
|
870
|
-
.status-indicator.success::before {
|
|
871
|
-
background-color: var(--color-success);
|
|
872
|
-
}
|
|
873
|
-
|
|
874
|
-
.status-indicator.error::before {
|
|
875
|
-
background-color: var(--color-error);
|
|
876
|
-
}
|
|
877
|
-
|
|
878
|
-
/* Link styling - don't rely on color alone */
|
|
879
|
-
a {
|
|
880
|
-
color: var(--color-focus);
|
|
881
|
-
text-decoration: underline;
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
a:hover,
|
|
885
|
-
a:focus {
|
|
886
|
-
text-decoration-thickness: 2px;
|
|
887
|
-
}
|
|
888
|
-
|
|
889
|
-
/* Form validation - use icons alongside color */
|
|
890
|
-
.input-error {
|
|
891
|
-
border-color: var(--color-error);
|
|
892
|
-
border-width: 2px;
|
|
893
|
-
}
|
|
894
|
-
|
|
895
|
-
.input-error + .error-icon {
|
|
896
|
-
display: block;
|
|
897
|
-
color: var(--color-error);
|
|
898
|
-
}
|
|
899
|
-
```
|
|
900
|
-
|
|
901
|
-
### 7. Testing Accessibility
|
|
127
|
+
### Live Region Announcer
|
|
902
128
|
|
|
903
129
|
```tsx
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
import userEvent from "@testing-library/user-event";
|
|
907
|
-
import { axe, toHaveNoViolations } from "jest-axe";
|
|
908
|
-
|
|
909
|
-
expect.extend(toHaveNoViolations);
|
|
910
|
-
|
|
911
|
-
describe("Accessibility Tests", () => {
|
|
912
|
-
describe("Button", () => {
|
|
913
|
-
it("should have no accessibility violations", async () => {
|
|
914
|
-
const { container } = render(
|
|
915
|
-
<button onClick={() => {}}>Click me</button>
|
|
916
|
-
);
|
|
917
|
-
const results = await axe(container);
|
|
918
|
-
expect(results).toHaveNoViolations();
|
|
919
|
-
});
|
|
920
|
-
|
|
921
|
-
it("should be keyboard accessible", async () => {
|
|
922
|
-
const user = userEvent.setup();
|
|
923
|
-
const handleClick = jest.fn();
|
|
924
|
-
|
|
925
|
-
render(<button onClick={handleClick}>Click me</button>);
|
|
926
|
-
|
|
927
|
-
const button = screen.getByRole("button");
|
|
928
|
-
button.focus();
|
|
929
|
-
expect(button).toHaveFocus();
|
|
930
|
-
|
|
931
|
-
await user.keyboard("{Enter}");
|
|
932
|
-
expect(handleClick).toHaveBeenCalledTimes(1);
|
|
933
|
-
|
|
934
|
-
await user.keyboard(" ");
|
|
935
|
-
expect(handleClick).toHaveBeenCalledTimes(2);
|
|
936
|
-
});
|
|
937
|
-
});
|
|
938
|
-
|
|
939
|
-
describe("Form", () => {
|
|
940
|
-
it("should have proper labels", () => {
|
|
941
|
-
render(
|
|
942
|
-
<form>
|
|
943
|
-
<label htmlFor="email">Email</label>
|
|
944
|
-
<input id="email" type="email" />
|
|
945
|
-
</form>
|
|
946
|
-
);
|
|
947
|
-
|
|
948
|
-
expect(screen.getByLabelText("Email")).toBeInTheDocument();
|
|
949
|
-
});
|
|
950
|
-
|
|
951
|
-
it("should announce errors to screen readers", async () => {
|
|
952
|
-
render(
|
|
953
|
-
<div>
|
|
954
|
-
<input aria-invalid="true" aria-describedby="error" />
|
|
955
|
-
<p id="error" role="alert">Invalid email</p>
|
|
956
|
-
</div>
|
|
957
|
-
);
|
|
958
|
-
|
|
959
|
-
expect(screen.getByRole("alert")).toHaveTextContent("Invalid email");
|
|
960
|
-
});
|
|
961
|
-
});
|
|
962
|
-
|
|
963
|
-
describe("Dialog", () => {
|
|
964
|
-
it("should trap focus", async () => {
|
|
965
|
-
const user = userEvent.setup();
|
|
966
|
-
|
|
967
|
-
render(
|
|
968
|
-
<div role="dialog" aria-modal="true">
|
|
969
|
-
<button>First</button>
|
|
970
|
-
<button>Second</button>
|
|
971
|
-
<button>Third</button>
|
|
972
|
-
</div>
|
|
973
|
-
);
|
|
974
|
-
|
|
975
|
-
const buttons = screen.getAllByRole("button");
|
|
976
|
-
buttons[0].focus();
|
|
977
|
-
|
|
978
|
-
// Tab through all buttons
|
|
979
|
-
await user.tab();
|
|
980
|
-
expect(buttons[1]).toHaveFocus();
|
|
981
|
-
|
|
982
|
-
await user.tab();
|
|
983
|
-
expect(buttons[2]).toHaveFocus();
|
|
984
|
-
});
|
|
985
|
-
});
|
|
986
|
-
});
|
|
987
|
-
|
|
988
|
-
// Cypress accessibility tests
|
|
989
|
-
// cypress/e2e/accessibility.cy.ts
|
|
990
|
-
describe("Accessibility", () => {
|
|
991
|
-
beforeEach(() => {
|
|
992
|
-
cy.visit("/");
|
|
993
|
-
cy.injectAxe();
|
|
994
|
-
});
|
|
995
|
-
|
|
996
|
-
it("should have no accessibility violations on homepage", () => {
|
|
997
|
-
cy.checkA11y();
|
|
998
|
-
});
|
|
999
|
-
|
|
1000
|
-
it("should have no violations after interaction", () => {
|
|
1001
|
-
cy.get("button").first().click();
|
|
1002
|
-
cy.checkA11y();
|
|
1003
|
-
});
|
|
1004
|
-
|
|
1005
|
-
it("should be navigable by keyboard", () => {
|
|
1006
|
-
cy.get("body").tab();
|
|
1007
|
-
cy.focused().should("have.attr", "href");
|
|
1008
|
-
});
|
|
1009
|
-
});
|
|
1010
|
-
```
|
|
1011
|
-
|
|
1012
|
-
## Use Cases
|
|
130
|
+
export function useAnnounce() {
|
|
131
|
+
const [message, setMessage] = useState('');
|
|
1013
132
|
|
|
1014
|
-
|
|
133
|
+
const announce = useCallback((text: string) => {
|
|
134
|
+
setMessage('');
|
|
135
|
+
requestAnimationFrame(() => setMessage(text));
|
|
136
|
+
setTimeout(() => setMessage(''), 1000);
|
|
137
|
+
}, []);
|
|
1015
138
|
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
render?: (value: T[keyof T], row: T) => React.ReactNode;
|
|
1022
|
-
}
|
|
139
|
+
const Announcer = () => (
|
|
140
|
+
<div role="status" aria-live="polite" aria-atomic="true" className="sr-only">
|
|
141
|
+
{message}
|
|
142
|
+
</div>
|
|
143
|
+
);
|
|
1023
144
|
|
|
1024
|
-
|
|
1025
|
-
data: T[];
|
|
1026
|
-
columns: Column<T>[];
|
|
1027
|
-
caption: string;
|
|
1028
|
-
sortable?: boolean;
|
|
145
|
+
return { announce, Announcer };
|
|
1029
146
|
}
|
|
1030
147
|
|
|
1031
|
-
|
|
1032
|
-
data,
|
|
1033
|
-
columns,
|
|
1034
|
-
caption,
|
|
1035
|
-
sortable,
|
|
1036
|
-
}: DataTableProps<T>) {
|
|
1037
|
-
const [sortColumn, setSortColumn] = useState<keyof T | null>(null);
|
|
1038
|
-
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
|
|
1039
|
-
|
|
1040
|
-
return (
|
|
1041
|
-
<table>
|
|
1042
|
-
<caption>{caption}</caption>
|
|
1043
|
-
<thead>
|
|
1044
|
-
<tr>
|
|
1045
|
-
{columns.map((col) => (
|
|
1046
|
-
<th
|
|
1047
|
-
key={String(col.key)}
|
|
1048
|
-
scope="col"
|
|
1049
|
-
aria-sort={
|
|
1050
|
-
sortable && sortColumn === col.key
|
|
1051
|
-
? sortDirection === "asc"
|
|
1052
|
-
? "ascending"
|
|
1053
|
-
: "descending"
|
|
1054
|
-
: undefined
|
|
1055
|
-
}
|
|
1056
|
-
>
|
|
1057
|
-
{sortable ? (
|
|
1058
|
-
<button
|
|
1059
|
-
onClick={() => handleSort(col.key)}
|
|
1060
|
-
aria-label={`Sort by ${col.header}`}
|
|
1061
|
-
>
|
|
1062
|
-
{col.header}
|
|
1063
|
-
</button>
|
|
1064
|
-
) : (
|
|
1065
|
-
col.header
|
|
1066
|
-
)}
|
|
1067
|
-
</th>
|
|
1068
|
-
))}
|
|
1069
|
-
</tr>
|
|
1070
|
-
</thead>
|
|
1071
|
-
<tbody>
|
|
1072
|
-
{data.map((row) => (
|
|
1073
|
-
<tr key={row.id}>
|
|
1074
|
-
{columns.map((col, index) => (
|
|
1075
|
-
<td
|
|
1076
|
-
key={String(col.key)}
|
|
1077
|
-
headers={`header-${String(col.key)}`}
|
|
1078
|
-
>
|
|
1079
|
-
{col.render
|
|
1080
|
-
? col.render(row[col.key], row)
|
|
1081
|
-
: String(row[col.key])}
|
|
1082
|
-
</td>
|
|
1083
|
-
))}
|
|
1084
|
-
</tr>
|
|
1085
|
-
))}
|
|
1086
|
-
</tbody>
|
|
1087
|
-
</table>
|
|
1088
|
-
);
|
|
1089
|
-
}
|
|
148
|
+
// Usage: announce('3 results found');
|
|
1090
149
|
```
|
|
1091
150
|
|
|
1092
151
|
## Best Practices
|
|
1093
152
|
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
- Use ARIA only when needed
|
|
1104
|
-
- Manage focus appropriately
|
|
1105
|
-
- Announce dynamic content changes
|
|
1106
|
-
|
|
1107
|
-
### Don'ts
|
|
1108
|
-
|
|
1109
|
-
- Don't remove focus outlines without replacement
|
|
1110
|
-
- Don't rely on color alone to convey information
|
|
1111
|
-
- Don't use placeholder as label
|
|
1112
|
-
- Don't trap keyboard focus unintentionally
|
|
1113
|
-
- Don't auto-play media with sound
|
|
1114
|
-
- Don't use ARIA when HTML suffices
|
|
1115
|
-
- Don't hide content from screen readers unnecessarily
|
|
1116
|
-
- Don't create keyboard traps
|
|
1117
|
-
- Don't use very small touch targets
|
|
1118
|
-
- Don't ignore accessibility in testing
|
|
1119
|
-
|
|
1120
|
-
## References
|
|
1121
|
-
|
|
1122
|
-
- [WCAG 2.1 Guidelines](https://www.w3.org/WAI/WCAG21/quickref/)
|
|
1123
|
-
- [WAI-ARIA Practices](https://www.w3.org/WAI/ARIA/apg/)
|
|
1124
|
-
- [MDN Accessibility](https://developer.mozilla.org/en-US/docs/Web/Accessibility)
|
|
1125
|
-
- [A11y Project](https://www.a11yproject.com/)
|
|
1126
|
-
- [Inclusive Components](https://inclusive-components.design/)
|
|
153
|
+
| Do | Avoid |
|
|
154
|
+
|----|-------|
|
|
155
|
+
| Use semantic HTML elements (`<button>`, `<nav>`, `<main>`) | Removing focus outlines without replacement |
|
|
156
|
+
| Provide text alternatives for images | Relying on color alone to convey information |
|
|
157
|
+
| Ensure 4.5:1 color contrast for text | Using placeholder as the only label |
|
|
158
|
+
| Associate labels with form controls | Trapping keyboard focus unintentionally |
|
|
159
|
+
| Provide skip links for keyboard users | Auto-playing media with sound |
|
|
160
|
+
| Test with actual screen readers (NVDA, VoiceOver) | Using ARIA when native HTML suffices |
|
|
161
|
+
| Announce dynamic content changes with live regions | Very small touch targets (min 44x44px) |
|