import { useRef, useEffect, RefObject } from 'react'; interface UseTapNavigationProps { onNext: () => void; onPrevious: () => void; containerRef: RefObject; /** * Whether tap-zones are divided horizontally or vertically. */ axis?: 'horizontal' | 'vertical'; disabled?: boolean; } /** * A hook that enables tap-based navigation on a container element. * * Horizontal mode: tap left side → previous, tap right side → next. * Vertical mode: tap top half → previous, tap bottom half → next. * Taps on interactive elements (buttons, links, [role="button"], [data-no-nav]) are always ignored. * * On touch devices we capture the position at touchstart (before any finger * drift that can corrupt the synthetic click coordinates) and confirm the * intent on touchend (only if movement is below the tap threshold). * Mouse clicks fall through via a normal click listener. */ const useTapNavigation = ({ onNext, onPrevious, containerRef, axis = 'horizontal', disabled = false, }: UseTapNavigationProps) => { const onNextRef = useRef(onNext); const onPreviousRef = useRef(onPrevious); useEffect(() => { onNextRef.current = onNext; onPreviousRef.current = onPrevious; }, [onNext, onPrevious]); useEffect(() => { const container = containerRef.current; if (!container || disabled) return; // Max finger displacement (px) that still counts as a tap (not a swipe). const TAP_THRESHOLD = 10; let touchStartX = 0; let touchStartY = 0; // Store position captured at touchstart so we use the initial press point, // not the finger position at release (which may have drifted slightly). let touchTapX = 0; let touchTapY = 0; let isTouchActive = false; const navigate = (clientX: number, clientY: number) => { // If the tap landed on a button, link, or any explicitly-excluded element, // don't navigate — let the element handle its own action. const target = document.elementFromPoint(clientX, clientY) as HTMLElement | null; if (target?.closest('button, a, [role="button"], [data-no-nav]')) return; const rect = container.getBoundingClientRect(); const relX = clientX - rect.left; const relY = clientY - rect.top; const width = rect.width; const height = rect.height; if (axis === 'vertical') { if (relY < height / 2) { onPreviousRef.current(); } else { onNextRef.current(); } } else { // Left 40% → previous, right 60% → next (asymmetric: next is the more frequent action). const leftBoundary = width * 0.4; if (relX < leftBoundary) { onPreviousRef.current(); } else { onNextRef.current(); } } }; // ---- Touch: capture position at press time, confirm on release ---- const handleTouchStart = (e: TouchEvent) => { if (e.touches.length !== 1) return; touchStartX = e.touches[0].clientX; touchStartY = e.touches[0].clientY; touchTapX = touchStartX; touchTapY = touchStartY; isTouchActive = true; }; const handleTouchEnd = (e: TouchEvent) => { if (!isTouchActive) return; isTouchActive = false; const endX = e.changedTouches[0].clientX; const endY = e.changedTouches[0].clientY; const moved = Math.max(Math.abs(endX - touchStartX), Math.abs(endY - touchStartY)); if (moved < TAP_THRESHOLD) { // It was a tap — use the touchstart position (most reliable) navigate(touchTapX, touchTapY); } }; // ---- Mouse: use click (pointer-type guard prevents double-fire on touch) ---- const handleClick = (e: MouseEvent) => { // PointerEvent carries pointerType; skip if already handled via touch path if ((e as PointerEvent).pointerType === 'touch') return; // Interactive elements handle themselves if ((e.target as HTMLElement)?.closest('button, a, [role="button"], [data-no-nav]')) return; navigate(e.clientX, e.clientY); }; container.addEventListener('touchstart', handleTouchStart, { passive: true }); container.addEventListener('touchend', handleTouchEnd, { passive: true }); container.addEventListener('click', handleClick); return () => { container.removeEventListener('touchstart', handleTouchStart); container.removeEventListener('touchend', handleTouchEnd); container.removeEventListener('click', handleClick); }; }, [ containerRef, axis, disabled, ]); }; export default useTapNavigation;