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:
173
maternal-web/hooks/useFocusManagement.ts
Normal file
173
maternal-web/hooks/useFocusManagement.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
/**
|
||||
* Focus Management Hook
|
||||
*
|
||||
* Manages focus behavior for accessibility:
|
||||
* - Moves focus to main heading on route changes
|
||||
* - Announces page changes to screen readers
|
||||
* - Restores focus after modals close
|
||||
*/
|
||||
|
||||
/**
|
||||
* Focus the main heading (h1) on route change
|
||||
*
|
||||
* WCAG 2.4.3 Focus Order - ensures logical focus progression
|
||||
* Helps screen reader users understand page context after navigation
|
||||
*/
|
||||
export function useFocusOnRouteChange() {
|
||||
const pathname = usePathname();
|
||||
const previousPathname = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Skip on initial mount
|
||||
if (previousPathname.current === null) {
|
||||
previousPathname.current = pathname;
|
||||
return;
|
||||
}
|
||||
|
||||
// Only trigger if pathname actually changed
|
||||
if (previousPathname.current === pathname) {
|
||||
return;
|
||||
}
|
||||
|
||||
previousPathname.current = pathname;
|
||||
|
||||
// Small delay to ensure DOM is updated
|
||||
const timeoutId = setTimeout(() => {
|
||||
// Try to find the main heading (h1)
|
||||
const mainHeading = document.querySelector('h1');
|
||||
|
||||
if (mainHeading) {
|
||||
// Make the heading focusable temporarily
|
||||
const tabindex = mainHeading.getAttribute('tabindex');
|
||||
if (tabindex === null) {
|
||||
mainHeading.setAttribute('tabindex', '-1');
|
||||
}
|
||||
|
||||
// Focus the heading with smooth scroll
|
||||
(mainHeading as HTMLElement).focus({ preventScroll: false });
|
||||
|
||||
// Remove tabindex if we added it
|
||||
if (tabindex === null) {
|
||||
// Keep tabindex=-1 for programmatic focus
|
||||
// This doesn't affect keyboard navigation but allows .focus()
|
||||
}
|
||||
} else {
|
||||
// Fallback: focus the main content area
|
||||
const main = document.getElementById('main-content');
|
||||
if (main) {
|
||||
main.focus({ preventScroll: false });
|
||||
}
|
||||
}
|
||||
|
||||
// Announce page change to screen readers
|
||||
announcePageChange(pathname);
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [pathname]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Announce route changes to screen readers
|
||||
*/
|
||||
function announcePageChange(pathname: string) {
|
||||
// Create a live region if it doesn't exist
|
||||
let liveRegion = document.getElementById('route-change-announcer');
|
||||
|
||||
if (!liveRegion) {
|
||||
liveRegion = document.createElement('div');
|
||||
liveRegion.id = 'route-change-announcer';
|
||||
liveRegion.setAttribute('role', 'status');
|
||||
liveRegion.setAttribute('aria-live', 'polite');
|
||||
liveRegion.setAttribute('aria-atomic', 'true');
|
||||
liveRegion.className = 'sr-only'; // Screen reader only
|
||||
document.body.appendChild(liveRegion);
|
||||
}
|
||||
|
||||
// Get page title from pathname
|
||||
const pageTitle = getPageTitle(pathname);
|
||||
|
||||
// Update the announcement
|
||||
liveRegion.textContent = `Navigated to ${pageTitle}`;
|
||||
|
||||
// Clear after announcement
|
||||
setTimeout(() => {
|
||||
if (liveRegion) {
|
||||
liveRegion.textContent = '';
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get friendly page title from pathname
|
||||
*/
|
||||
function getPageTitle(pathname: string): string {
|
||||
const pathSegments = pathname.split('/').filter(Boolean);
|
||||
|
||||
if (pathSegments.length === 0) return 'Home';
|
||||
|
||||
const pageMap: Record<string, string> = {
|
||||
'track': 'Track Activity',
|
||||
'ai-assistant': 'AI Assistant',
|
||||
'insights': 'Insights',
|
||||
'analytics': 'Analytics',
|
||||
'activities': 'Activities',
|
||||
'children': 'Children',
|
||||
'family': 'Family',
|
||||
'settings': 'Settings',
|
||||
'login': 'Login',
|
||||
'register': 'Register',
|
||||
'forgot-password': 'Forgot Password',
|
||||
'reset-password': 'Reset Password',
|
||||
'onboarding': 'Welcome',
|
||||
};
|
||||
|
||||
const lastSegment = pathSegments[pathSegments.length - 1];
|
||||
return pageMap[lastSegment] || lastSegment.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus trap for modals/dialogs
|
||||
* Returns to previously focused element when modal closes
|
||||
*/
|
||||
export function useFocusTrap(isOpen: boolean) {
|
||||
const previousFocus = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// Store currently focused element
|
||||
previousFocus.current = document.activeElement as HTMLElement;
|
||||
} else {
|
||||
// Restore focus when modal closes
|
||||
if (previousFocus.current && typeof previousFocus.current.focus === 'function') {
|
||||
setTimeout(() => {
|
||||
previousFocus.current?.focus();
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return previousFocus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus notification/toast when it appears
|
||||
* Useful for important messages that need immediate attention
|
||||
*/
|
||||
export function useFocusOnNotification(isVisible: boolean, notificationRef: React.RefObject<HTMLElement>) {
|
||||
useEffect(() => {
|
||||
if (isVisible && notificationRef.current) {
|
||||
// Small delay to ensure notification is rendered
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (notificationRef.current) {
|
||||
notificationRef.current.focus();
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
}, [isVisible, notificationRef]);
|
||||
}
|
||||
Reference in New Issue
Block a user