ca-grow-ops-manager/frontend/src/lib/accessibility.tsx
fullsizemalt 4663b0ac86
Some checks failed
Deploy to Production / deploy (push) Failing after 0s
Test / backend-test (push) Failing after 0s
Test / frontend-test (push) Failing after 0s
feat: Navigation refactor with RBAC, DevTools for quick user switching, enhanced seed data
- Refactored navigation with grouped sections (Operations, Cultivation, Analytics, etc.)
- Added RBAC-based navigation filtering by user role
- Created DevTools panel for quick user switching during testing
- Added collapsible sidebar sections on desktop
- Mobile: bottom nav bar (4 items + More) with slide-up sheet
- Enhanced seed data with [DEMO] prefix markers
- Added multiple demo users: Owner, Manager, Cultivator, Worker
- Fixed domain to runfoo.run
- Added Audit Log and SOP Library pages to navigation
- Created usePermissions hook and RoleBadge component
2025-12-11 11:07:22 -08:00

262 lines
7.4 KiB
TypeScript

import { useEffect, useState, useRef, useCallback } from 'react';
/**
* Accessibility utilities and hooks for WCAG 2.1 AA compliance
*/
// ==================== HOOKS ====================
/**
* Detects if user prefers reduced motion
*/
export function usePrefersReducedMotion(): boolean {
const [prefersReduced, setPrefersReduced] = useState(() => {
if (typeof window === 'undefined') return false;
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
});
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const handler = (e: MediaQueryListEvent) => setPrefersReduced(e.matches);
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
}, []);
return prefersReduced;
}
/**
* Detects if user prefers high contrast
*/
export function usePrefersHighContrast(): boolean {
const [prefersHighContrast, setPrefersHighContrast] = useState(() => {
if (typeof window === 'undefined') return false;
return window.matchMedia('(prefers-contrast: more)').matches;
});
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-contrast: more)');
const handler = (e: MediaQueryListEvent) => setPrefersHighContrast(e.matches);
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
}, []);
return prefersHighContrast;
}
/**
* Manage focus trap for modals/dialogs
*/
export function useFocusTrap(isActive: boolean) {
const containerRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!isActive) return;
// Store current focus
previousFocusRef.current = document.activeElement as HTMLElement;
const container = containerRef.current;
if (!container) return;
// Get focusable elements
const focusableSelector = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
const focusableElements = container.querySelectorAll<HTMLElement>(focusableSelector);
const firstFocusable = focusableElements[0];
const lastFocusable = focusableElements[focusableElements.length - 1];
// Focus first element
firstFocusable?.focus();
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
if (e.shiftKey) {
if (document.activeElement === firstFocusable) {
e.preventDefault();
lastFocusable?.focus();
}
} else {
if (document.activeElement === lastFocusable) {
e.preventDefault();
firstFocusable?.focus();
}
}
};
container.addEventListener('keydown', handleKeyDown);
return () => {
container.removeEventListener('keydown', handleKeyDown);
// Restore focus on unmount
previousFocusRef.current?.focus();
};
}, [isActive]);
return containerRef;
}
/**
* Announce message to screen readers
*/
export function useAnnounce() {
const announce = useCallback((message: string, priority: 'polite' | 'assertive' = 'polite') => {
const el = document.createElement('div');
el.setAttribute('role', 'status');
el.setAttribute('aria-live', priority);
el.setAttribute('aria-atomic', 'true');
el.className = 'sr-only';
el.textContent = message;
document.body.appendChild(el);
// Remove after announcement
setTimeout(() => document.body.removeChild(el), 1000);
}, []);
return announce;
}
/**
* Handle roving tabindex for arrow key navigation
*/
export function useRovingTabIndex<T extends HTMLElement>(itemCount: number) {
const [activeIndex, setActiveIndex] = useState(0);
const itemsRef = useRef<(T | null)[]>([]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
let newIndex = activeIndex;
switch (e.key) {
case 'ArrowDown':
case 'ArrowRight':
e.preventDefault();
newIndex = (activeIndex + 1) % itemCount;
break;
case 'ArrowUp':
case 'ArrowLeft':
e.preventDefault();
newIndex = (activeIndex - 1 + itemCount) % itemCount;
break;
case 'Home':
e.preventDefault();
newIndex = 0;
break;
case 'End':
e.preventDefault();
newIndex = itemCount - 1;
break;
default:
return;
}
setActiveIndex(newIndex);
itemsRef.current[newIndex]?.focus();
}, [activeIndex, itemCount]);
const getItemProps = (index: number) => ({
ref: (el: T | null) => { itemsRef.current[index] = el; },
tabIndex: index === activeIndex ? 0 : -1,
onKeyDown: handleKeyDown,
});
return { activeIndex, setActiveIndex, getItemProps };
}
// ==================== UTILITIES ====================
/**
* Generate unique ID for ARIA relationships
*/
let idCounter = 0;
export function generateAriaId(prefix: string = 'aria'): string {
return `${prefix}-${++idCounter}`;
}
/**
* Check if color contrast meets WCAG standards
* Returns ratio (4.5:1 for AA normal text, 3:1 for large text)
*/
export function getContrastRatio(foreground: string, background: string): number {
const getLuminance = (hex: string): number => {
const rgb = parseInt(hex.slice(1), 16);
const r = ((rgb >> 16) & 0xff) / 255;
const g = ((rgb >> 8) & 0xff) / 255;
const b = (rgb & 0xff) / 255;
const toLinear = (c: number) =>
c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
};
const l1 = getLuminance(foreground);
const l2 = getLuminance(background);
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}
/**
* Format number for screen readers
*/
export function formatNumberForSR(num: number, type: 'currency' | 'percent' | 'decimal' = 'decimal'): string {
switch (type) {
case 'currency':
return `${num.toFixed(2)} dollars`;
case 'percent':
return `${num} percent`;
default:
return num.toLocaleString();
}
}
// ==================== COMPONENTS ====================
/**
* Screen reader only text
*/
export function VisuallyHidden({ children }: { children: React.ReactNode }) {
return (
<span className="sr-only">
{children}
</span>
);
}
/**
* Skip link for keyboard navigation
*/
export function SkipLink({ href, children }: { href: string; children: React.ReactNode }) {
return (
<a href={href} className="skip-to-main">
{children}
</a>
);
}
/**
* Live region for dynamic announcements
*/
export function LiveRegion({
message,
priority = 'polite'
}: {
message: string;
priority?: 'polite' | 'assertive'
}) {
return (
<div
role="status"
aria-live={priority}
aria-atomic="true"
className="sr-only"
>
{message}
</div>
);
}