omgkit 2.2.0 → 2.3.1

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.
Files changed (60) hide show
  1. package/README.md +3 -3
  2. package/package.json +1 -1
  3. package/plugin/skills/databases/database-management/SKILL.md +288 -0
  4. package/plugin/skills/databases/database-migration/SKILL.md +285 -0
  5. package/plugin/skills/databases/database-schema-design/SKILL.md +195 -0
  6. package/plugin/skills/databases/mongodb/SKILL.md +60 -776
  7. package/plugin/skills/databases/prisma/SKILL.md +53 -744
  8. package/plugin/skills/databases/redis/SKILL.md +53 -860
  9. package/plugin/skills/databases/supabase/SKILL.md +283 -0
  10. package/plugin/skills/devops/aws/SKILL.md +68 -672
  11. package/plugin/skills/devops/github-actions/SKILL.md +54 -657
  12. package/plugin/skills/devops/kubernetes/SKILL.md +67 -602
  13. package/plugin/skills/devops/performance-profiling/SKILL.md +59 -863
  14. package/plugin/skills/frameworks/django/SKILL.md +87 -853
  15. package/plugin/skills/frameworks/express/SKILL.md +95 -1301
  16. package/plugin/skills/frameworks/fastapi/SKILL.md +90 -1198
  17. package/plugin/skills/frameworks/laravel/SKILL.md +87 -1187
  18. package/plugin/skills/frameworks/nestjs/SKILL.md +106 -973
  19. package/plugin/skills/frameworks/react/SKILL.md +94 -962
  20. package/plugin/skills/frameworks/vue/SKILL.md +95 -1242
  21. package/plugin/skills/frontend/accessibility/SKILL.md +91 -1056
  22. package/plugin/skills/frontend/frontend-design/SKILL.md +69 -1262
  23. package/plugin/skills/frontend/responsive/SKILL.md +76 -799
  24. package/plugin/skills/frontend/shadcn-ui/SKILL.md +73 -921
  25. package/plugin/skills/frontend/tailwindcss/SKILL.md +60 -788
  26. package/plugin/skills/frontend/threejs/SKILL.md +72 -1266
  27. package/plugin/skills/languages/javascript/SKILL.md +106 -849
  28. package/plugin/skills/methodology/brainstorming/SKILL.md +70 -576
  29. package/plugin/skills/methodology/defense-in-depth/SKILL.md +79 -831
  30. package/plugin/skills/methodology/dispatching-parallel-agents/SKILL.md +81 -654
  31. package/plugin/skills/methodology/executing-plans/SKILL.md +86 -529
  32. package/plugin/skills/methodology/finishing-development-branch/SKILL.md +95 -586
  33. package/plugin/skills/methodology/problem-solving/SKILL.md +67 -681
  34. package/plugin/skills/methodology/receiving-code-review/SKILL.md +70 -533
  35. package/plugin/skills/methodology/requesting-code-review/SKILL.md +70 -610
  36. package/plugin/skills/methodology/root-cause-tracing/SKILL.md +70 -646
  37. package/plugin/skills/methodology/sequential-thinking/SKILL.md +70 -478
  38. package/plugin/skills/methodology/systematic-debugging/SKILL.md +66 -559
  39. package/plugin/skills/methodology/test-driven-development/SKILL.md +91 -752
  40. package/plugin/skills/methodology/testing-anti-patterns/SKILL.md +78 -687
  41. package/plugin/skills/methodology/token-optimization/SKILL.md +72 -602
  42. package/plugin/skills/methodology/verification-before-completion/SKILL.md +108 -529
  43. package/plugin/skills/methodology/writing-plans/SKILL.md +79 -566
  44. package/plugin/skills/omega/omega-architecture/SKILL.md +91 -752
  45. package/plugin/skills/omega/omega-coding/SKILL.md +161 -552
  46. package/plugin/skills/omega/omega-sprint/SKILL.md +132 -777
  47. package/plugin/skills/omega/omega-testing/SKILL.md +157 -845
  48. package/plugin/skills/omega/omega-thinking/SKILL.md +165 -606
  49. package/plugin/skills/security/better-auth/SKILL.md +46 -1034
  50. package/plugin/skills/security/oauth/SKILL.md +80 -934
  51. package/plugin/skills/security/owasp/SKILL.md +78 -862
  52. package/plugin/skills/testing/playwright/SKILL.md +77 -700
  53. package/plugin/skills/testing/pytest/SKILL.md +73 -811
  54. package/plugin/skills/testing/vitest/SKILL.md +60 -920
  55. package/plugin/skills/tools/document-processing/SKILL.md +111 -838
  56. package/plugin/skills/tools/image-processing/SKILL.md +126 -659
  57. package/plugin/skills/tools/mcp-development/SKILL.md +85 -758
  58. package/plugin/skills/tools/media-processing/SKILL.md +118 -735
  59. package/plugin/stdrules/SKILL_STANDARDS.md +490 -0
  60. package/plugin/skills/SKILL_STANDARDS.md +0 -743
@@ -1,1126 +1,161 @@
1
1
  ---
2
- name: accessibility
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
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
- 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
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>&copy; 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
- // 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">&times;</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 previousFocusRef = useRef<HTMLElement | null>(null);
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
- 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
- >
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
- <div id={descId}>{children}</div>
269
- <button
270
- type="button"
271
- aria-label="Close dialog"
272
- onClick={onClose}
273
- className="dialog-close"
274
- >
275
- &times;
276
- </button>
30
+ {children}
31
+ <button onClick={onClose} aria-label="Close dialog">&times;</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
- ### 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
- );
39
+ ## Features
553
40
 
554
- const getTabIndex = useCallback(
555
- (index: number) => (index === activeIndex ? 0 : -1),
556
- [activeIndex]
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
- return { activeIndex, setActiveIndex, handleKeyDown, getTabIndex };
560
- }
561
- ```
50
+ ## Common Patterns
562
51
 
563
- ### 4. Form Accessibility
52
+ ### Accessible Form Field
564
53
 
565
54
  ```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) {
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(" ") || undefined;
58
+ const describedBy = [hintId, errorId].filter(Boolean).join(' ') || undefined;
591
59
 
592
60
  return (
593
- <div className={`form-field ${error ? "has-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
- checked={checked}
653
- onChange={(e) => onChange(e.target.checked)}
654
- aria-describedby={descId}
70
+ aria-required={required}
71
+ aria-invalid={!!error}
72
+ aria-describedby={describedBy}
73
+ {...props}
655
74
  />
656
- <label htmlFor={id}>{label}</label>
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
- ### 5. Live Regions
81
+ ### Roving Tabindex for Tab Component
741
82
 
742
83
  ```tsx
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);
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
- <LiveRegion message={announcement} />
825
- {/* Search UI */}
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
- ### 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
127
+ ### Live Region Announcer
902
128
 
903
129
  ```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
130
+ export function useAnnounce() {
131
+ const [message, setMessage] = useState('');
1013
132
 
1014
- ### Accessible Data Table
133
+ const announce = useCallback((text: string) => {
134
+ setMessage('');
135
+ requestAnimationFrame(() => setMessage(text));
136
+ setTimeout(() => setMessage(''), 1000);
137
+ }, []);
1015
138
 
1016
- ```tsx
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
- }
139
+ const Announcer = () => (
140
+ <div role="status" aria-live="polite" aria-atomic="true" className="sr-only">
141
+ {message}
142
+ </div>
143
+ );
1023
144
 
1024
- interface DataTableProps<T> {
1025
- data: T[];
1026
- columns: Column<T>[];
1027
- caption: string;
1028
- sortable?: boolean;
145
+ return { announce, Announcer };
1029
146
  }
1030
147
 
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");
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
- ### 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/)
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) |