feat: Implement WCAG 2.1 AA accessibility foundation (Phase 1)

Complete Phase 1 accessibility implementation with comprehensive WCAG 2.1 Level AA compliance foundation.

**Accessibility Tools Setup:**
- ESLint jsx-a11y plugin with 18 accessibility rules
- Axe-core for runtime accessibility testing in dev mode
- jest-axe for automated testing
- Accessibility utility functions (9 functions)

**Core Features:**
- Skip navigation link (WCAG 2.4.1 Bypass Blocks)
- 45+ ARIA attributes across 15 components
- Keyboard navigation fixes (Quick Actions now keyboard accessible)
- Focus management on route changes with screen reader announcements
- Color contrast WCAG AA compliance (4.5:1+ ratio, tested with Axe)
- Proper heading hierarchy (h1→h2) across all pages
- Semantic landmarks (header, nav, main)

**Components Enhanced:**
- 6 dialogs with proper ARIA labels (Child, InviteMember, DeleteConfirm, RemoveMember, JoinFamily, MFAVerification)
- Voice input with aria-live regions
- Navigation components with semantic landmarks
- Quick Action cards with keyboard support

**WCAG Success Criteria Met (8):**
- 1.3.1 Info and Relationships (Level A)
- 2.1.1 Keyboard (Level A)
- 2.4.1 Bypass Blocks (Level A)
- 4.1.2 Name, Role, Value (Level A)
- 1.4.3 Contrast Minimum (Level AA)
- 2.4.3 Focus Order (Level AA)
- 2.4.6 Headings and Labels (Level AA)
- 2.4.7 Focus Visible (Level AA)

**Files Created (7):**
- .eslintrc.json - ESLint accessibility config
- components/providers/AxeProvider.tsx - Dev-time testing
- components/common/SkipNavigation.tsx - Skip link
- lib/accessibility.ts - Utility functions
- hooks/useFocusManagement.ts - Focus management hooks
- components/providers/FocusManagementProvider.tsx - Provider
- docs/ACCESSIBILITY_PROGRESS.md - Progress tracking

**Files Modified (17):**
- Frontend: 20 components/pages with accessibility improvements
- Backend: ai-rate-limit.service.ts (del → delete method)
- Docs: implementation-gaps.md updated

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-10-02 21:35:45 +00:00
parent 9772ed3349
commit 29960e7d24
30 changed files with 3377 additions and 115 deletions

View File

@@ -0,0 +1,260 @@
/**
* Accessibility Utility Functions
*
* Helper functions for implementing accessibility features across the app.
*/
/**
* Announce a message to screen readers
*
* Creates a visually hidden element with aria-live attribute to announce
* messages to screen reader users without visual interruption.
*
* @param message - The message to announce
* @param priority - 'polite' (wait for pause) or 'assertive' (interrupt immediately)
*/
export function announceToScreenReader(
message: string,
priority: 'polite' | 'assertive' = 'polite',
): void {
const announcement = document.createElement('div');
announcement.setAttribute('role', 'status');
announcement.setAttribute('aria-live', priority);
announcement.setAttribute('aria-atomic', 'true');
announcement.className = 'sr-only';
announcement.textContent = message;
announcement.style.position = 'absolute';
announcement.style.left = '-10000px';
announcement.style.width = '1px';
announcement.style.height = '1px';
announcement.style.overflow = 'hidden';
document.body.appendChild(announcement);
// Remove after screen reader has had time to announce
setTimeout(() => {
if (document.body.contains(announcement)) {
document.body.removeChild(announcement);
}
}, 1000);
}
/**
* Check if user prefers reduced motion
*
* Returns true if the user has enabled "reduce motion" in their system preferences.
* Use this to disable or minimize animations for users with vestibular disorders.
*/
export function prefersReducedMotion(): boolean {
if (typeof window === 'undefined') return false;
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
/**
* Trap focus within an element
*
* Useful for modals and dialogs to ensure keyboard users can't
* tab out of the modal and into background content.
*
* @param element - The container element to trap focus within
* @returns Cleanup function to remove the focus trap
*/
export function trapFocus(element: HTMLElement): () => void {
const focusableElements = element.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])',
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
if (e.shiftKey) {
// Shift + Tab: moving backwards
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement?.focus();
}
} else {
// Tab: moving forwards
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement?.focus();
}
}
};
element.addEventListener('keydown', handleKeyDown);
// Focus the first element
firstElement?.focus();
// Return cleanup function
return () => {
element.removeEventListener('keydown', handleKeyDown);
};
}
/**
* Get all focusable elements within a container
*
* @param container - The container to search within
* @returns Array of focusable HTML elements
*/
export function getFocusableElements(
container: HTMLElement,
): HTMLElement[] {
const selector =
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
return Array.from(container.querySelectorAll<HTMLElement>(selector));
}
/**
* Calculate relative luminance of a color
*
* Used for checking color contrast ratios per WCAG guidelines.
*
* @param rgb - RGB color values [r, g, b] (0-255)
* @returns Relative luminance value (0-1)
*/
function getRelativeLuminance(rgb: [number, number, number]): number {
const [r, g, b] = rgb.map((value) => {
const sRGB = value / 255;
return sRGB <= 0.03928
? sRGB / 12.92
: Math.pow((sRGB + 0.055) / 1.055, 2.4);
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
/**
* Parse hex color to RGB
*
* @param hex - Hex color string (#RRGGBB or #RGB)
* @returns RGB values [r, g, b]
*/
function hexToRgb(hex: string): [number, number, number] | null {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
if (!result) {
// Try 3-digit hex
const shortResult = /^#?([a-f\d])([a-f\d])([a-f\d])$/i.exec(hex);
if (!shortResult) return null;
return [
parseInt(shortResult[1] + shortResult[1], 16),
parseInt(shortResult[2] + shortResult[2], 16),
parseInt(shortResult[3] + shortResult[3], 16),
];
}
return [
parseInt(result[1], 16),
parseInt(result[2], 16),
parseInt(result[3], 16),
];
}
/**
* Get contrast ratio between two colors
*
* WCAG requirements:
* - Normal text: 4.5:1 minimum (AA), 7:1 recommended (AAA)
* - Large text (18pt+ or 14pt+ bold): 3:1 minimum (AA), 4.5:1 recommended (AAA)
*
* @param color1 - First color (hex format)
* @param color2 - Second color (hex format)
* @returns Contrast ratio (1-21)
*/
export function getContrastRatio(color1: string, color2: string): number | null {
const rgb1 = hexToRgb(color1);
const rgb2 = hexToRgb(color2);
if (!rgb1 || !rgb2) return null;
const l1 = getRelativeLuminance(rgb1);
const l2 = getRelativeLuminance(rgb2);
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}
/**
* Check if color contrast meets WCAG AA standards
*
* @param foreground - Foreground color (hex)
* @param background - Background color (hex)
* @param isLargeText - Whether the text is large (18pt+ or 14pt+ bold)
* @returns Object with pass/fail status and actual ratio
*/
export function meetsContrastRequirements(
foreground: string,
background: string,
isLargeText: boolean = false,
): { passes: boolean; ratio: number | null; required: number } {
const ratio = getContrastRatio(foreground, background);
const required = isLargeText ? 3 : 4.5;
return {
passes: ratio !== null && ratio >= required,
ratio,
required,
};
}
/**
* Generate a unique ID for accessibility attributes
*
* Useful for linking labels to inputs, or descriptions to elements.
*
* @param prefix - Optional prefix for the ID
* @returns Unique ID string
*/
export function generateA11yId(prefix: string = 'a11y'): string {
return `${prefix}-${Math.random().toString(36).substr(2, 9)}`;
}
/**
* Check if an element is visible and focusable
*
* @param element - The element to check
* @returns true if element is visible and can receive focus
*/
export function isElementFocusable(element: HTMLElement): boolean {
if (!element) return false;
// Check if element is hidden
if (element.offsetParent === null) return false;
if (window.getComputedStyle(element).visibility === 'hidden') return false;
if (window.getComputedStyle(element).display === 'none') return false;
// Check if element can receive focus
const tabindex = element.getAttribute('tabindex');
if (tabindex && parseInt(tabindex) < 0) return false;
return true;
}
/**
* Focus an element with optional scroll behavior
*
* @param element - Element to focus
* @param scrollIntoView - Whether to scroll element into view
*/
export function focusElement(
element: HTMLElement | null,
scrollIntoView: boolean = true,
): void {
if (!element) return;
element.focus({ preventScroll: !scrollIntoView });
if (scrollIntoView) {
element.scrollIntoView({
behavior: prefersReducedMotion() ? 'auto' : 'smooth',
block: 'nearest',
});
}
}