← back to Watches

src/components/GestureControls.jsx

200 lines

import React, { useState, useEffect, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';

/**
 * Gesture Control System
 * Features: Swipe navigation, pinch zoom simulation, haptic feedback
 */
const GestureControls = ({ items = [], currentIndex = 0, onIndexChange }) => {
  const [touchStart, setTouchStart] = useState(null);
  const [touchEnd, setTouchEnd] = useState(null);
  const [showIndicator, setShowIndicator] = useState(true);
  const containerRef = useRef(null);

  const minSwipeDistance = 50;

  useEffect(() => {
    const timer = setTimeout(() => setShowIndicator(false), 5000);
    return () => clearTimeout(timer);
  }, []);

  const onTouchStart = (e) => {
    setTouchEnd(null);
    setTouchStart(e.targetTouches[0].clientX);
    setShowIndicator(true);
  };

  const onTouchMove = (e) => {
    setTouchEnd(e.targetTouches[0].clientX);
  };

  const onTouchEnd = () => {
    if (!touchStart || !touchEnd) return;

    const distance = touchStart - touchEnd;
    const isLeftSwipe = distance > minSwipeDistance;
    const isRightSwipe = distance < -minSwipeDistance;

    if (isLeftSwipe && currentIndex < items.length - 1) {
      onIndexChange(currentIndex + 1);
      triggerHaptic();
    }

    if (isRightSwipe && currentIndex > 0) {
      onIndexChange(currentIndex - 1);
      triggerHaptic();
    }
  };

  const triggerHaptic = () => {
    // Simulate haptic feedback with visual/audio cues
    if (navigator.vibrate) {
      navigator.vibrate(10);
    }
  };

  const handleKeyDown = (e) => {
    if (e.key === 'ArrowLeft' && currentIndex > 0) {
      onIndexChange(currentIndex - 1);
      triggerHaptic();
    } else if (e.key === 'ArrowRight' && currentIndex < items.length - 1) {
      onIndexChange(currentIndex + 1);
      triggerHaptic();
    }
  };

  useEffect(() => {
    window.addEventListener('keydown', handleKeyDown);
    return () => window.removeEventListener('keydown', handleKeyDown);
  }, [currentIndex, items.length]);

  return (
    <div
      ref={containerRef}
      onTouchStart={onTouchStart}
      onTouchMove={onTouchMove}
      onTouchEnd={onTouchEnd}
      className="relative w-full"
    >
      {/* Gesture indicator */}
      <AnimatePresence>
        {showIndicator && items.length > 1 && (
          <motion.div
            className="swipe-indicator no-print"
            initial={{ opacity: 0, y: 20 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0, y: 20 }}
          >
            {items.map((_, index) => (
              <div
                key={index}
                className={`swipe-dot ${index === currentIndex ? 'active' : ''}`}
              />
            ))}
          </motion.div>
        )}
      </AnimatePresence>

      {/* Navigation arrows for desktop */}
      {items.length > 1 && (
        <>
          {currentIndex > 0 && (
            <motion.button
              className="fixed left-4 top-1/2 -translate-y-1/2 z-50 no-print hidden md:block"
              onClick={() => {
                onIndexChange(currentIndex - 1);
                triggerHaptic();
              }}
              whileHover={{ scale: 1.1, x: -5 }}
              whileTap={{ scale: 0.95 }}
              initial={{ opacity: 0, x: -20 }}
              animate={{ opacity: 1, x: 0 }}
              aria-label="Previous item"
            >
              <div className="glass-luxury p-4 rounded-full shadow-gold haptic-click">
                <svg
                  className="w-6 h-6 text-gold"
                  fill="none"
                  stroke="currentColor"
                  viewBox="0 0 24 24"
                >
                  <path
                    strokeLinecap="round"
                    strokeLinejoin="round"
                    strokeWidth={2}
                    d="M15 19l-7-7 7-7"
                  />
                </svg>
              </div>
            </motion.button>
          )}

          {currentIndex < items.length - 1 && (
            <motion.button
              className="fixed right-4 top-1/2 -translate-y-1/2 z-50 no-print hidden md:block"
              onClick={() => {
                onIndexChange(currentIndex + 1);
                triggerHaptic();
              }}
              whileHover={{ scale: 1.1, x: 5 }}
              whileTap={{ scale: 0.95 }}
              initial={{ opacity: 0, x: 20 }}
              animate={{ opacity: 1, x: 0 }}
              aria-label="Next item"
            >
              <div className="glass-luxury p-4 rounded-full shadow-gold haptic-click">
                <svg
                  className="w-6 h-6 text-gold"
                  fill="none"
                  stroke="currentColor"
                  viewBox="0 0 24 24"
                >
                  <path
                    strokeLinecap="round"
                    strokeLinejoin="round"
                    strokeWidth={2}
                    d="M9 5l7 7-7 7"
                  />
                </svg>
              </div>
            </motion.button>
          )}
        </>
      )}

      {/* Counter display */}
      {items.length > 1 && (
        <motion.div
          className="fixed top-24 right-4 z-50 no-print"
          initial={{ opacity: 0, scale: 0.8 }}
          animate={{ opacity: 1, scale: 1 }}
        >
          <div className="glass-luxury px-4 py-2 rounded-full text-gold font-bold text-sm">
            {currentIndex + 1} / {items.length}
          </div>
        </motion.div>
      )}

      {/* Keyboard navigation hint */}
      <AnimatePresence>
        {showIndicator && (
          <motion.div
            className="fixed bottom-24 left-1/2 -translate-x-1/2 z-50 no-print hidden md:block"
            initial={{ opacity: 0, y: 20 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0, y: 20 }}
          >
            <div className="glass-luxury px-6 py-3 rounded-full text-gray-300 text-xs tracking-wider flex items-center gap-3">
              <kbd className="px-2 py-1 bg-gray-800 rounded text-gold">←</kbd>
              <span>Navigate</span>
              <kbd className="px-2 py-1 bg-gray-800 rounded text-gold">→</kbd>
            </div>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
};

export default GestureControls;