omgkit 2.1.0 → 2.2.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/SKILL_STANDARDS.md +743 -0
- package/plugin/skills/databases/mongodb/SKILL.md +797 -28
- package/plugin/skills/databases/postgresql/SKILL.md +494 -18
- package/plugin/skills/databases/prisma/SKILL.md +776 -30
- package/plugin/skills/databases/redis/SKILL.md +885 -25
- package/plugin/skills/devops/aws/SKILL.md +686 -28
- package/plugin/skills/devops/docker/SKILL.md +466 -18
- package/plugin/skills/devops/github-actions/SKILL.md +684 -29
- package/plugin/skills/devops/kubernetes/SKILL.md +621 -24
- package/plugin/skills/frameworks/django/SKILL.md +920 -20
- package/plugin/skills/frameworks/express/SKILL.md +1361 -35
- package/plugin/skills/frameworks/fastapi/SKILL.md +1260 -33
- package/plugin/skills/frameworks/laravel/SKILL.md +1244 -31
- package/plugin/skills/frameworks/nestjs/SKILL.md +1005 -26
- package/plugin/skills/frameworks/nextjs/SKILL.md +407 -44
- package/plugin/skills/frameworks/rails/SKILL.md +594 -28
- package/plugin/skills/frameworks/react/SKILL.md +1006 -32
- package/plugin/skills/frameworks/spring/SKILL.md +528 -35
- package/plugin/skills/frameworks/vue/SKILL.md +1296 -27
- package/plugin/skills/frontend/accessibility/SKILL.md +1108 -34
- package/plugin/skills/frontend/frontend-design/SKILL.md +1304 -26
- package/plugin/skills/frontend/responsive/SKILL.md +847 -21
- package/plugin/skills/frontend/shadcn-ui/SKILL.md +976 -38
- package/plugin/skills/frontend/tailwindcss/SKILL.md +831 -35
- package/plugin/skills/frontend/threejs/SKILL.md +1298 -29
- package/plugin/skills/languages/javascript/SKILL.md +935 -31
- package/plugin/skills/languages/python/SKILL.md +489 -25
- package/plugin/skills/languages/typescript/SKILL.md +379 -30
- package/plugin/skills/methodology/brainstorming/SKILL.md +597 -23
- package/plugin/skills/methodology/defense-in-depth/SKILL.md +832 -34
- package/plugin/skills/methodology/dispatching-parallel-agents/SKILL.md +665 -31
- package/plugin/skills/methodology/executing-plans/SKILL.md +556 -24
- package/plugin/skills/methodology/finishing-development-branch/SKILL.md +595 -25
- package/plugin/skills/methodology/problem-solving/SKILL.md +429 -61
- package/plugin/skills/methodology/receiving-code-review/SKILL.md +536 -24
- package/plugin/skills/methodology/requesting-code-review/SKILL.md +632 -21
- package/plugin/skills/methodology/root-cause-tracing/SKILL.md +641 -30
- package/plugin/skills/methodology/sequential-thinking/SKILL.md +262 -3
- package/plugin/skills/methodology/systematic-debugging/SKILL.md +571 -32
- package/plugin/skills/methodology/test-driven-development/SKILL.md +779 -24
- package/plugin/skills/methodology/testing-anti-patterns/SKILL.md +691 -29
- package/plugin/skills/methodology/token-optimization/SKILL.md +598 -29
- package/plugin/skills/methodology/verification-before-completion/SKILL.md +543 -22
- package/plugin/skills/methodology/writing-plans/SKILL.md +590 -18
- package/plugin/skills/omega/omega-architecture/SKILL.md +838 -39
- package/plugin/skills/omega/omega-coding/SKILL.md +636 -39
- package/plugin/skills/omega/omega-sprint/SKILL.md +855 -48
- package/plugin/skills/omega/omega-testing/SKILL.md +940 -41
- package/plugin/skills/omega/omega-thinking/SKILL.md +703 -50
- package/plugin/skills/security/better-auth/SKILL.md +1065 -28
- package/plugin/skills/security/oauth/SKILL.md +968 -31
- package/plugin/skills/security/owasp/SKILL.md +894 -33
- package/plugin/skills/testing/playwright/SKILL.md +764 -38
- package/plugin/skills/testing/pytest/SKILL.md +873 -36
- package/plugin/skills/testing/vitest/SKILL.md +980 -35
|
@@ -1,52 +1,1126 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: accessibility
|
|
3
|
-
description:
|
|
3
|
+
description: Web accessibility (a11y) with WCAG compliance, ARIA, keyboard navigation, screen readers, and testing
|
|
4
|
+
category: frontend
|
|
5
|
+
triggers:
|
|
6
|
+
- accessibility
|
|
7
|
+
- a11y
|
|
8
|
+
- wcag
|
|
9
|
+
- screen reader
|
|
10
|
+
- aria
|
|
11
|
+
- keyboard navigation
|
|
4
12
|
---
|
|
5
13
|
|
|
6
|
-
# Accessibility
|
|
14
|
+
# Accessibility
|
|
15
|
+
|
|
16
|
+
Enterprise-grade **web accessibility** following WCAG 2.1 AA guidelines and best practices. This skill covers semantic HTML, ARIA attributes, keyboard navigation, focus management, screen reader support, and accessibility testing patterns used by top engineering teams.
|
|
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
|
|
7
33
|
|
|
8
|
-
## Semantic HTML
|
|
9
34
|
```html
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
<
|
|
13
|
-
<
|
|
14
|
-
<
|
|
15
|
-
<
|
|
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>
|
|
16
84
|
```
|
|
17
85
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
145
|
+
|
|
146
|
+
```tsx
|
|
147
|
+
// components/accessible/Alert.tsx
|
|
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
|
+
|
|
196
|
+
export function Dialog({ isOpen, onClose, title, children }: DialogProps) {
|
|
197
|
+
const dialogRef = useRef<HTMLDivElement>(null);
|
|
198
|
+
const previousFocusRef = useRef<HTMLElement | null>(null);
|
|
199
|
+
const titleId = `dialog-title-${React.useId()}`;
|
|
200
|
+
const descId = `dialog-desc-${React.useId()}`;
|
|
201
|
+
|
|
202
|
+
useEffect(() => {
|
|
203
|
+
if (isOpen) {
|
|
204
|
+
// Store current focus
|
|
205
|
+
previousFocusRef.current = document.activeElement as HTMLElement;
|
|
206
|
+
|
|
207
|
+
// Focus the dialog
|
|
208
|
+
dialogRef.current?.focus();
|
|
209
|
+
|
|
210
|
+
// Prevent body scroll
|
|
211
|
+
document.body.style.overflow = "hidden";
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return () => {
|
|
215
|
+
document.body.style.overflow = "";
|
|
216
|
+
// Return focus on close
|
|
217
|
+
previousFocusRef.current?.focus();
|
|
218
|
+
};
|
|
219
|
+
}, [isOpen]);
|
|
220
|
+
|
|
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
|
+
if (!isOpen) return null;
|
|
249
|
+
|
|
250
|
+
return createPortal(
|
|
251
|
+
<>
|
|
252
|
+
<div
|
|
253
|
+
className="dialog-backdrop"
|
|
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
|
+
>
|
|
267
|
+
<h2 id={titleId}>{title}</h2>
|
|
268
|
+
<div id={descId}>{children}</div>
|
|
269
|
+
<button
|
|
270
|
+
type="button"
|
|
271
|
+
aria-label="Close dialog"
|
|
272
|
+
onClick={onClose}
|
|
273
|
+
className="dialog-close"
|
|
274
|
+
>
|
|
275
|
+
×
|
|
276
|
+
</button>
|
|
277
|
+
</div>
|
|
278
|
+
</>,
|
|
279
|
+
document.body
|
|
280
|
+
);
|
|
281
|
+
}
|
|
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
|
+
```
|
|
364
|
+
|
|
365
|
+
### 3. Keyboard Navigation
|
|
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
|
+
);
|
|
553
|
+
|
|
554
|
+
const getTabIndex = useCallback(
|
|
555
|
+
(index: number) => (index === activeIndex ? 0 : -1),
|
|
556
|
+
[activeIndex]
|
|
557
|
+
);
|
|
558
|
+
|
|
559
|
+
return { activeIndex, setActiveIndex, handleKeyDown, getTabIndex };
|
|
560
|
+
}
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
### 4. Form Accessibility
|
|
564
|
+
|
|
565
|
+
```tsx
|
|
566
|
+
// components/accessible/FormField.tsx
|
|
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) {
|
|
588
|
+
const hintId = hint ? `${id}-hint` : undefined;
|
|
589
|
+
const errorId = error ? `${id}-error` : undefined;
|
|
590
|
+
const describedBy = [hintId, errorId].filter(Boolean).join(" ") || undefined;
|
|
591
|
+
|
|
592
|
+
return (
|
|
593
|
+
<div className={`form-field ${error ? "has-error" : ""}`}>
|
|
594
|
+
<label htmlFor={id}>
|
|
595
|
+
{label}
|
|
596
|
+
{required && (
|
|
597
|
+
<span aria-hidden="true" className="required">*</span>
|
|
598
|
+
)}
|
|
599
|
+
{required && <span className="sr-only">(required)</span>}
|
|
600
|
+
</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">
|
|
649
|
+
<input
|
|
650
|
+
type="checkbox"
|
|
651
|
+
id={id}
|
|
652
|
+
checked={checked}
|
|
653
|
+
onChange={(e) => onChange(e.target.checked)}
|
|
654
|
+
aria-describedby={descId}
|
|
655
|
+
/>
|
|
656
|
+
<label htmlFor={id}>{label}</label>
|
|
657
|
+
{description && (
|
|
658
|
+
<p id={descId} className="checkbox-description">
|
|
659
|
+
{description}
|
|
660
|
+
</p>
|
|
661
|
+
)}
|
|
662
|
+
</div>
|
|
663
|
+
);
|
|
664
|
+
}
|
|
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
|
+
}
|
|
23
738
|
```
|
|
24
739
|
|
|
25
|
-
|
|
740
|
+
### 5. Live Regions
|
|
741
|
+
|
|
26
742
|
```tsx
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
743
|
+
// components/accessible/LiveRegion.tsx
|
|
744
|
+
import React from "react";
|
|
745
|
+
|
|
746
|
+
interface LiveRegionProps {
|
|
747
|
+
message: string;
|
|
748
|
+
type?: "polite" | "assertive";
|
|
749
|
+
atomic?: boolean;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
export function LiveRegion({
|
|
753
|
+
message,
|
|
754
|
+
type = "polite",
|
|
755
|
+
atomic = true,
|
|
756
|
+
}: LiveRegionProps) {
|
|
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);
|
|
788
|
+
}
|
|
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
|
+
};
|
|
821
|
+
|
|
822
|
+
return (
|
|
823
|
+
<div>
|
|
824
|
+
<LiveRegion message={announcement} />
|
|
825
|
+
{/* Search UI */}
|
|
826
|
+
</div>
|
|
827
|
+
);
|
|
34
828
|
}
|
|
35
829
|
```
|
|
36
830
|
|
|
37
|
-
|
|
831
|
+
### 6. Color and Contrast
|
|
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
|
|
902
|
+
|
|
903
|
+
```tsx
|
|
904
|
+
// tests/accessibility.test.tsx
|
|
905
|
+
import { render, screen } from "@testing-library/react";
|
|
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
|
|
1013
|
+
|
|
1014
|
+
### Accessible Data Table
|
|
1015
|
+
|
|
38
1016
|
```tsx
|
|
39
|
-
|
|
1017
|
+
// components/accessible/DataTable.tsx
|
|
1018
|
+
interface Column<T> {
|
|
1019
|
+
key: keyof T;
|
|
1020
|
+
header: string;
|
|
1021
|
+
render?: (value: T[keyof T], row: T) => React.ReactNode;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
interface DataTableProps<T> {
|
|
1025
|
+
data: T[];
|
|
1026
|
+
columns: Column<T>[];
|
|
1027
|
+
caption: string;
|
|
1028
|
+
sortable?: boolean;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
export function DataTable<T extends { id: string }>({
|
|
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");
|
|
40
1039
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
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
|
+
}
|
|
44
1090
|
```
|
|
45
1091
|
|
|
46
|
-
##
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
-
|
|
51
|
-
-
|
|
52
|
-
-
|
|
1092
|
+
## Best Practices
|
|
1093
|
+
|
|
1094
|
+
### Do's
|
|
1095
|
+
|
|
1096
|
+
- Use semantic HTML elements
|
|
1097
|
+
- Provide text alternatives for images
|
|
1098
|
+
- Ensure keyboard accessibility
|
|
1099
|
+
- Use sufficient color contrast (4.5:1)
|
|
1100
|
+
- Associate labels with form controls
|
|
1101
|
+
- Provide skip links for navigation
|
|
1102
|
+
- Test with screen readers
|
|
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/)
|