← 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,
};