← back to Watches
src/components/AccessibilityEnhancements.jsx
488 lines
/**
* ACCESSIBILITY ENHANCEMENT SYSTEM
* WCAG AAA compliant components and utilities
*
* Features:
* - Keyboard navigation
* - Screen reader optimizations
* - Focus management
* - Skip links
* - Keyboard shortcuts
* - High contrast mode
* - Reduced motion support
*/
import React, { useEffect, useState, useRef, createContext, useContext } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { FiHelpCircle, FiEye, FiEyeOff, FiZap } from 'react-icons/fi';
// ============================================================================
// ACCESSIBILITY CONTEXT
// ============================================================================
const AccessibilityContext = createContext({
reducedMotion: false,
highContrast: false,
keyboardOnly: false,
screenReaderMode: false,
});
export const AccessibilityProvider = ({ children }) => {
const [reducedMotion, setReducedMotion] = useState(false);
const [highContrast, setHighContrast] = useState(false);
const [keyboardOnly, setKeyboardOnly] = useState(false);
const [screenReaderMode, setScreenReaderMode] = useState(false);
useEffect(() => {
// Check for prefers-reduced-motion
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
setReducedMotion(mediaQuery.matches);
const handleChange = (e) => setReducedMotion(e.matches);
mediaQuery.addEventListener('change', handleChange);
// Detect keyboard-only navigation
const handleMouseDown = () => setKeyboardOnly(false);
const handleKeyDown = (e) => {
if (e.key === 'Tab') setKeyboardOnly(true);
};
document.addEventListener('mousedown', handleMouseDown);
document.addEventListener('keydown', handleKeyDown);
return () => {
mediaQuery.removeEventListener('change', handleChange);
document.removeEventListener('mousedown', handleMouseDown);
document.removeEventListener('keydown', handleKeyDown);
};
}, []);
// Apply accessibility classes to body
useEffect(() => {
const classList = document.body.classList;
if (reducedMotion) classList.add('reduce-motion');
else classList.remove('reduce-motion');
if (highContrast) classList.add('high-contrast');
else classList.remove('high-contrast');
if (keyboardOnly) classList.add('keyboard-navigation');
else classList.remove('keyboard-navigation');
}, [reducedMotion, highContrast, keyboardOnly]);
const value = {
reducedMotion,
setReducedMotion,
highContrast,
setHighContrast,
keyboardOnly,
screenReaderMode,
setScreenReaderMode,
};
return (
<AccessibilityContext.Provider value={value}>
{children}
</AccessibilityContext.Provider>
);
};
export const useAccessibility = () => useContext(AccessibilityContext);
// ============================================================================
// SKIP TO CONTENT LINK
// ============================================================================
export const SkipToContent = ({ targetId = 'main-content' }) => {
return (
<a
href={`#${targetId}`}
className="
sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4
focus:z-max focus:px-6 focus:py-3
bg-omega-red text-white font-bold rounded-lg
shadow-xl outline-none ring-4 ring-omega-red/50
transition-all duration-200
"
>
Skip to main content
</a>
);
};
// ============================================================================
// KEYBOARD SHORTCUTS MODAL
// ============================================================================
export const KeyboardShortcuts = () => {
const [isOpen, setIsOpen] = useState(false);
const shortcuts = [
{ key: '?', description: 'Show keyboard shortcuts' },
{ key: '/', description: 'Focus search' },
{ key: 'h', description: 'Go to home/dashboard' },
{ key: 'l', description: 'Go to watch list' },
{ key: 'c', description: 'Go to compare view' },
{ key: 'd', description: 'Toggle dark mode' },
{ key: 'Esc', description: 'Close modals/dialogs' },
{ key: '←/→', description: 'Navigate between watches' },
{ key: 'Enter', description: 'Select/activate item' },
{ key: 'Tab', description: 'Navigate between elements' },
];
useEffect(() => {
const handleKeyPress = (e) => {
// Show shortcuts on '?'
if (e.key === '?' && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
setIsOpen(true);
}
// Close on Escape
if (e.key === 'Escape' && isOpen) {
setIsOpen(false);
}
};
document.addEventListener('keydown', handleKeyPress);
return () => document.removeEventListener('keydown', handleKeyPress);
}, [isOpen]);
return (
<AnimatePresence>
{isOpen && (
<>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setIsOpen(false)}
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[1040]"
/>
{/* Modal */}
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
className="
fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2
bg-white dark:bg-gray-800 rounded-2xl shadow-2xl
max-w-2xl w-full mx-4 p-8 z-[1050]
"
role="dialog"
aria-labelledby="shortcuts-title"
aria-modal="true"
>
<div className="flex items-center justify-between mb-6">
<h2 id="shortcuts-title" className="text-2xl font-bold text-gray-800 dark:text-white flex items-center">
<FiZap className="mr-3 text-omega-red" />
Keyboard Shortcuts
</h2>
<button
onClick={() => setIsOpen(false)}
className="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
aria-label="Close keyboard shortcuts"
>
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="space-y-3 max-h-96 overflow-y-auto">
{shortcuts.map((shortcut, index) => (
<div
key={index}
className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
>
<span className="text-gray-700 dark:text-gray-300">{shortcut.description}</span>
<kbd className="px-3 py-1 bg-white dark:bg-gray-800 border-2 border-gray-300 dark:border-gray-600 rounded text-sm font-mono font-semibold text-gray-800 dark:text-white shadow-sm">
{shortcut.key}
</kbd>
</div>
))}
</div>
<div className="mt-6 pt-6 border-t dark:border-gray-700">
<p className="text-sm text-gray-600 dark:text-gray-400 text-center">
Press <kbd className="px-2 py-1 bg-gray-200 dark:bg-gray-700 rounded text-xs font-mono">?</kbd> anytime to see this guide
</p>
</div>
</motion.div>
</>
)}
</AnimatePresence>
);
};
// ============================================================================
// FOCUS TRAP (for modals and dialogs)
// ============================================================================
export const useFocusTrap = (ref, isActive) => {
useEffect(() => {
if (!isActive || !ref.current) return;
const focusableElements = ref.current.querySelectorAll(
'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 handleTabKey = (e) => {
if (e.key !== 'Tab') return;
if (e.shiftKey) {
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
}
} else {
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
};
// Focus first element
firstElement?.focus();
document.addEventListener('keydown', handleTabKey);
return () => document.removeEventListener('keydown', handleTabKey);
}, [ref, isActive]);
};
// ============================================================================
// SCREEN READER ANNOUNCER
// ============================================================================
export const LiveRegion = ({ message, politeness = 'polite' }) => {
return (
<div
role="status"
aria-live={politeness}
aria-atomic="true"
className="sr-only"
>
{message}
</div>
);
};
export const useLiveAnnounce = () => {
const [message, setMessage] = useState('');
const [politeness, setPoliteness] = useState('polite');
const announce = (text, level = 'polite') => {
setMessage('');
setTimeout(() => {
setPoliteness(level);
setMessage(text);
}, 100);
};
return {
announce,
LiveRegionComponent: () => <LiveRegion message={message} politeness={politeness} />,
};
};
// ============================================================================
// ACCESSIBLE MODAL
// ============================================================================
export const AccessibleModal = ({ isOpen, onClose, title, children, className = '' }) => {
const modalRef = useRef(null);
const previousFocus = useRef(null);
useFocusTrap(modalRef, isOpen);
useEffect(() => {
if (isOpen) {
// Store current focus
previousFocus.current = document.activeElement;
// Prevent body scroll
document.body.style.overflow = 'hidden';
} else {
// Restore body scroll
document.body.style.overflow = '';
// Restore focus
if (previousFocus.current) {
previousFocus.current.focus();
}
}
return () => {
document.body.style.overflow = '';
};
}, [isOpen]);
// Close on Escape
useEffect(() => {
const handleEscape = (e) => {
if (e.key === 'Escape' && isOpen) {
onClose();
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<AnimatePresence>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[1040]"
aria-hidden="true"
/>
{/* Modal */}
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
className={`
fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2
bg-white dark:bg-gray-800 rounded-2xl shadow-2xl
max-w-4xl w-full mx-4 max-h-[90vh] overflow-y-auto
z-[1050] ${className}
`}
>
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
>
<div className="sticky top-0 bg-white dark:bg-gray-800 z-10 px-8 pt-8 pb-4 border-b dark:border-gray-700">
<div className="flex items-center justify-between">
<h2 id="modal-title" className="text-2xl font-bold text-gray-800 dark:text-white">
{title}
</h2>
<button
onClick={onClose}
className="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
aria-label="Close modal"
>
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<div className="p-8">
{children}
</div>
</motion.div>
</div>
</AnimatePresence>
);
};
// ============================================================================
// HIGH CONTRAST TOGGLE
// ============================================================================
export const HighContrastToggle = () => {
const { highContrast, setHighContrast } = useAccessibility();
return (
<button
onClick={() => setHighContrast(!highContrast)}
className="flex items-center space-x-2 px-4 py-2 rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
aria-label={highContrast ? 'Disable high contrast' : 'Enable high contrast'}
aria-pressed={highContrast}
>
{highContrast ? <FiEyeOff /> : <FiEye />}
<span className="text-sm font-medium">High Contrast</span>
</button>
);
};
// ============================================================================
// ACCESSIBLE TOOLTIP with ARIA
// ============================================================================
export const AccessibleTooltip = ({ children, content, id }) => {
const [isVisible, setIsVisible] = useState(false);
const tooltipId = id || `tooltip-${Math.random().toString(36).substr(2, 9)}`;
return (
<div className="relative inline-block">
<div
onMouseEnter={() => setIsVisible(true)}
onMouseLeave={() => setIsVisible(false)}
onFocus={() => setIsVisible(true)}
onBlur={() => setIsVisible(false)}
aria-describedby={isVisible ? tooltipId : undefined}
>
{children}
</div>
{isVisible && (
<div
id={tooltipId}
role="tooltip"
className="
absolute bottom-full left-1/2 -translate-x-1/2 mb-2
px-3 py-2 bg-omega-navy text-white text-sm rounded-lg
shadow-xl whitespace-nowrap z-50
"
>
{content}
<div className="absolute w-2 h-2 bg-omega-navy transform rotate-45 bottom-[-4px] left-1/2 -ml-1" />
</div>
)}
</div>
);
};
// ============================================================================
// HELP BUTTON with contextual help
// ============================================================================
export const HelpButton = ({ content, title = 'Help' }) => {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<button
onClick={() => setIsOpen(true)}
className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-gray-200 dark:bg-gray-700 hover:bg-omega-red hover:text-white transition-colors"
aria-label={title}
>
<FiHelpCircle className="w-4 h-4" />
</button>
<AccessibleModal
isOpen={isOpen}
onClose={() => setIsOpen(false)}
title={title}
>
<div className="prose dark:prose-invert max-w-none">
{content}
</div>
</AccessibleModal>
</>
);
};
export default {
AccessibilityProvider,
useAccessibility,
SkipToContent,
KeyboardShortcuts,
useFocusTrap,
useLiveAnnounce,
LiveRegion,
AccessibleModal,
HighContrastToggle,
AccessibleTooltip,
HelpButton,
};