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>
261 lines
7.4 KiB
TypeScript
261 lines
7.4 KiB
TypeScript
/**
|
|
* 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',
|
|
});
|
|
}
|
|
}
|