import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type PointerEvent, type ReactNode, type TouchEvent, type TransitionEvent } from 'react'; type SwipeTabsProps = { tabs: T[]; activeTab: T; onTabChange: (tab: T) => void; renderPanel: (tab: T) => ReactNode; className?: string; panelClassName?: string; swipeDisabled?: boolean; threshold?: number; renderWindow?: number; scrollToTopOnChange?: boolean; onSwipePastStart?: () => void; // For 1:1 sidebar gesture tracking onSwipePastStartProgress?: (progress: number) => void; onSwipePastStartDragStart?: () => void; onSwipePastStartDragEnd?: (shouldOpen: boolean) => void; sidebarWidth?: number; // Width of sidebar for progress calculation }; const DRAG_START_DISTANCE = 3; const EDGE_RESISTANCE = 0.35; const DEFAULT_THRESHOLD = 0.2; const DEFAULT_RENDER_WINDOW = 1; const findTouchById = (touches: React.TouchList, id: number): React.Touch | null => { for (let i = 0; i < touches.length; i += 1) { const touch = touches.item(i); if (touch && touch.identifier === id) { return touch; } } return null; }; const resolveSwipeTarget = (target: EventTarget | null): Element | null => { if (!target) return null; const element = target as Element; if (typeof element.closest === 'function') return element; const node = target as Node & { parentElement?: Element | null }; return node.parentElement ?? null; }; export function SwipeTabs({ tabs, activeTab, onTabChange, renderPanel, className, panelClassName, swipeDisabled = false, threshold = DEFAULT_THRESHOLD, renderWindow = DEFAULT_RENDER_WINDOW, scrollToTopOnChange = true, onSwipePastStart, onSwipePastStartProgress, onSwipePastStartDragStart, onSwipePastStartDragEnd, sidebarWidth = 280 }: SwipeTabsProps) { const containerRef = useRef(null); const trackRef = useRef(null); const activeIndex = useMemo(() => { const index = tabs.indexOf(activeTab); return index >= 0 ? index : 0; }, [tabs, activeTab]); const [displayIndex, setDisplayIndex] = useState(activeIndex); const displayIndexRef = useRef(displayIndex); const widthRef = useRef(0); const baseOffsetRef = useRef(0); const dragOffsetRef = useRef(0); const rafRef = useRef(0); const isDraggingRef = useRef(false); const hasMountedRef = useRef(false); const transitionRef = useRef(null); const transformRef = useRef(null); const pendingIndexRef = useRef(null); const isAnimatingRef = useRef(false); const needsResetRef = useRef(false); const hasScrolledRef = useRef(false); const dragRef = useRef({ pointerId: null as number | null, touchId: null as number | null, startX: 0, startY: 0, startIndex: 0, isActive: false, isDragging: false }); const shouldIgnoreSwipe = useCallback((target: EventTarget | null) => { const element = resolveSwipeTarget(target); if (!element) return false; // Don't ignore role="button" - we handle tap vs swipe via delayed pointer capture if (element.closest( '[data-swipe-ignore="true"], button, a, input, select, textarea, [role="link"], [role="switch"], [contenteditable="true"]' )) { return true; } const label = element.closest('label'); if (label && label.querySelector('input, select, textarea, button')) { return true; } return false; }, []); const applyTransform = useCallback((offset: number, animate: boolean) => { const track = trackRef.current; if (!track) return; const translateX = baseOffsetRef.current + offset; const transform = `translate3d(${translateX}px, 0, 0)`; if (transformRef.current !== transform) { track.style.transform = transform; transformRef.current = transform; } const transition = animate ? 'transform 280ms cubic-bezier(0.2, 0.7, 0.2, 1)' : 'none'; if (transitionRef.current !== transition) { track.style.transition = transition; transitionRef.current = transition; } }, []); const measureWidth = useCallback(() => { const width = containerRef.current?.getBoundingClientRect().width || 0; if (width && width !== widthRef.current) { widthRef.current = width; baseOffsetRef.current = -width; applyTransform(dragOffsetRef.current, false); } }, [applyTransform]); useLayoutEffect(() => { measureWidth(); const node = containerRef.current; if (!node) return undefined; let observer: ResizeObserver | null = null; if (typeof ResizeObserver !== 'undefined') { observer = new ResizeObserver(() => measureWidth()); observer.observe(node); } else if (typeof window !== 'undefined') { window.addEventListener('resize', measureWidth); } return () => { observer?.disconnect(); if (typeof window !== 'undefined') { window.removeEventListener('resize', measureWidth); } }; }, [measureWidth]); useLayoutEffect(() => { displayIndexRef.current = displayIndex; if (!needsResetRef.current) return; needsResetRef.current = false; dragOffsetRef.current = 0; applyTransform(0, false); }, [applyTransform, displayIndex]); useEffect(() => { return () => { if (rafRef.current) { cancelAnimationFrame(rafRef.current); } }; }, []); useEffect(() => { if (!scrollToTopOnChange) return; if (!hasScrolledRef.current) { hasScrolledRef.current = true; return; } if (typeof window === 'undefined') return; window.scrollTo({ top: 0, left: 0, behavior: 'auto' }); }, [activeTab, scrollToTopOnChange]); const resetToIndex = useCallback( (index: number) => { displayIndexRef.current = index; setDisplayIndex(index); dragOffsetRef.current = 0; applyTransform(0, false); }, [applyTransform] ); const animateToIndex = useCallback( (index: number) => { if (index === displayIndexRef.current) { dragOffsetRef.current = 0; applyTransform(0, true); return; } const delta = index - displayIndexRef.current; const width = widthRef.current || 0; if (Math.abs(delta) !== 1 || width === 0) { resetToIndex(index); return; } pendingIndexRef.current = index; isAnimatingRef.current = true; dragOffsetRef.current = 0; applyTransform(delta > 0 ? -width : width, true); }, [applyTransform, resetToIndex] ); useLayoutEffect(() => { if (isDraggingRef.current || isAnimatingRef.current) return; if (!hasMountedRef.current) { hasMountedRef.current = true; resetToIndex(activeIndex); return; } if (activeIndex !== displayIndexRef.current) { const distance = Math.abs(activeIndex - displayIndexRef.current); if (distance === 1) { animateToIndex(activeIndex); } else { resetToIndex(activeIndex); } } }, [activeIndex, animateToIndex, resetToIndex]); const scheduleDragUpdate = useCallback(() => { if (rafRef.current) return; rafRef.current = requestAnimationFrame(() => { rafRef.current = 0; applyTransform(dragOffsetRef.current, false); }); }, [applyTransform]); const setDraggingClass = useCallback((value: boolean) => { const node = containerRef.current; if (!node) return; node.classList.toggle('swipe-tabs--dragging', value); }, []); const cancelAnimationForDrag = useCallback(() => { if (!isAnimatingRef.current) return; const pendingIndex = pendingIndexRef.current; pendingIndexRef.current = null; isAnimatingRef.current = false; needsResetRef.current = false; const targetIndex = pendingIndex ?? activeIndex; resetToIndex(targetIndex); }, [activeIndex, resetToIndex]); const startDrag = useCallback( (x: number, y: number, pointerId: number | null, touchId: number | null) => { if (swipeDisabled || tabs.length <= 1) return; if (isAnimatingRef.current) { cancelAnimationForDrag(); } measureWidth(); dragRef.current.pointerId = pointerId; dragRef.current.touchId = touchId; dragRef.current.startX = x; dragRef.current.startY = y; dragRef.current.startIndex = displayIndexRef.current; dragRef.current.isActive = true; dragRef.current.isDragging = false; dragOffsetRef.current = 0; }, [cancelAnimationForDrag, measureWidth, swipeDisabled, tabs.length] ); const sidebarDragStartedRef = useRef(false); const sidebarDragProgressRef = useRef(0); const updateDrag = useCallback( (x: number, y: number, preventDefault: () => void, pointerType?: string) => { const state = dragRef.current; if (!state.isActive) return; const dx = x - state.startX; const dy = y - state.startY; if (!state.isDragging) { if (Math.abs(dx) < DRAG_START_DISTANCE && Math.abs(dy) < DRAG_START_DISTANCE) { return; } if (Math.abs(dx) <= Math.abs(dy)) { return; } state.isDragging = true; isDraggingRef.current = true; setDraggingClass(true); // Capture pointer now that drag has started if (state.pointerId !== null && containerRef.current?.setPointerCapture) { try { containerRef.current.setPointerCapture(state.pointerId); } catch { // Ignore capture failures } } } if (!state.isDragging) return; const width = widthRef.current || 1; let offset = dx; const atStart = state.startIndex <= 0; const atEnd = state.startIndex >= tabs.length - 1; // Don't apply resistance at start if onSwipePastStart is defined (sidebar can open) const hasVirtualTabAtStart = !!onSwipePastStart || !!onSwipePastStartProgress; // Calculate sidebar drag progress when swiping right on first tab const isSidebarSwipe = atStart && dx > 0 && hasVirtualTabAtStart; if (isSidebarSwipe) { // Notify drag start if (!sidebarDragStartedRef.current) { sidebarDragStartedRef.current = true; onSwipePastStartDragStart?.(); } // Calculate progress (0 to 1) based on sidebar width const progress = Math.min(1, dx / sidebarWidth); sidebarDragProgressRef.current = progress; onSwipePastStartProgress?.(progress); // Don't move the page - only the sidebar moves offset = 0; } else if ((atStart && dx > 0) || (atEnd && dx < 0)) { // Apply resistance at edges when not doing sidebar swipe offset = dx * EDGE_RESISTANCE; } offset = Math.max(Math.min(offset, width), -width); dragOffsetRef.current = offset; scheduleDragUpdate(); if (pointerType !== 'mouse') { preventDefault(); } }, [onSwipePastStart, onSwipePastStartProgress, onSwipePastStartDragStart, scheduleDragUpdate, sidebarWidth, tabs.length] ); const finishDrag = useCallback( (cancelled: boolean) => { const state = dragRef.current; if (!state.isActive) return; state.isActive = false; state.pointerId = null; state.touchId = null; const wasDragging = state.isDragging; state.isDragging = false; isDraggingRef.current = false; setDraggingClass(false); // Handle sidebar drag end const wasSidebarDragging = sidebarDragStartedRef.current; const sidebarProgress = sidebarDragProgressRef.current; sidebarDragStartedRef.current = false; sidebarDragProgressRef.current = 0; if (!wasDragging) return; if (rafRef.current) { cancelAnimationFrame(rafRef.current); rafRef.current = 0; } const width = widthRef.current || 1; const dx = dragOffsetRef.current; const thresholdPx = width * threshold; let targetIndex = state.startIndex; // Check if this was a sidebar gesture (using the flag, not dx which is 0 for sidebar swipes) const shouldOpenSidebar = !cancelled && sidebarProgress > 0.3; // 30% threshold if (wasSidebarDragging) { // Call the new drag end callback with whether to open onSwipePastStartDragEnd?.(shouldOpenSidebar); // Also call legacy callback if sidebar should open if (shouldOpenSidebar && onSwipePastStart) { onSwipePastStart(); } } else if (!cancelled && Math.abs(dx) > thresholdPx) { const direction = dx < 0 ? 1 : -1; targetIndex = Math.min(Math.max(state.startIndex + direction, 0), tabs.length - 1); // If swiping right on first tab without new callbacks, call onSwipePastStart if (state.startIndex === 0 && dx > 0 && onSwipePastStart && !onSwipePastStartDragEnd) { onSwipePastStart(); } } dragOffsetRef.current = 0; if (targetIndex === state.startIndex) { applyTransform(0, true); } else { pendingIndexRef.current = targetIndex; isAnimatingRef.current = true; applyTransform(targetIndex > state.startIndex ? -width : width, true); } if (targetIndex !== state.startIndex) { const nextTab = tabs[targetIndex]; if (nextTab !== activeTab) { onTabChange(nextTab); } } }, [activeTab, applyTransform, onTabChange, onSwipePastStart, onSwipePastStartDragEnd, setDraggingClass, sidebarWidth, tabs, threshold] ); const handlePointerDown = (event: PointerEvent) => { if (swipeDisabled || shouldIgnoreSwipe(event.target)) return; if (event.pointerType === 'mouse' && event.button !== 0) return; if (dragRef.current.isActive) return; startDrag(event.clientX, event.clientY, event.pointerId, null); // Don't capture pointer immediately - let taps work normally // Capture will happen in updateDrag when drag actually starts }; const handlePointerMove = (event: PointerEvent) => { const state = dragRef.current; if (!state.isActive || state.pointerId !== event.pointerId) return; const nativeEvent = event.nativeEvent as globalThis.PointerEvent; const coalesced = typeof nativeEvent.getCoalescedEvents === 'function' ? nativeEvent.getCoalescedEvents() : []; const latest = coalesced.length > 0 ? coalesced[coalesced.length - 1] : nativeEvent; updateDrag( latest.clientX, latest.clientY, () => { if (event.nativeEvent.cancelable) { event.preventDefault(); } }, event.pointerType ); }; const handlePointerUp = (event: PointerEvent) => { if (dragRef.current.pointerId !== event.pointerId) return; finishDrag(false); if (containerRef.current?.hasPointerCapture?.(event.pointerId)) { containerRef.current.releasePointerCapture(event.pointerId); } }; const handlePointerCancel = (event: PointerEvent) => { if (dragRef.current.pointerId !== event.pointerId) return; finishDrag(true); if (containerRef.current?.hasPointerCapture?.(event.pointerId)) { containerRef.current.releasePointerCapture(event.pointerId); } }; const handleTouchStart = (event: TouchEvent) => { if (swipeDisabled || shouldIgnoreSwipe(event.target)) return; if (dragRef.current.isActive) return; const touch = event.changedTouches[0]; if (!touch) return; startDrag(touch.clientX, touch.clientY, null, touch.identifier); }; const handleTouchMove = (event: TouchEvent) => { const state = dragRef.current; if (!state.isActive || state.touchId === null) return; const touch = findTouchById(event.changedTouches, state.touchId); if (!touch) return; updateDrag(touch.clientX, touch.clientY, () => { if (event.nativeEvent.cancelable) { event.preventDefault(); } }); }; const handleTouchEnd = (event: TouchEvent) => { const state = dragRef.current; if (!state.isActive || state.touchId === null) return; const touch = findTouchById(event.changedTouches, state.touchId); if (!touch) return; finishDrag(false); }; const handleTouchCancel = (event: TouchEvent) => { const state = dragRef.current; if (!state.isActive || state.touchId === null) return; const touch = findTouchById(event.changedTouches, state.touchId); if (!touch) return; finishDrag(true); }; const handleTransitionEnd = (event: TransitionEvent) => { if (event.propertyName !== 'transform') return; if (!isAnimatingRef.current) return; const nextIndex = pendingIndexRef.current; pendingIndexRef.current = null; isAnimatingRef.current = false; if (nextIndex === null) return; dragOffsetRef.current = 0; displayIndexRef.current = nextIndex; needsResetRef.current = true; setDisplayIndex(nextIndex); }; const swipeEnabled = !swipeDisabled && tabs.length > 1; const rootClassName = [ 'swipe-tabs', swipeEnabled ? 'swipe-tabs--snap' : 'swipe-tabs--static', className ] .filter(Boolean) .join(' '); const panelInnerClasses = ['swipe-tabs-panel-inner', panelClassName].filter(Boolean).join(' '); const usePointerEvents = typeof window !== 'undefined' && 'PointerEvent' in window; const renderRadius = Math.max(1, renderWindow); const prevIndex = renderRadius >= 1 && displayIndex > 0 ? displayIndex - 1 : null; const nextIndex = renderRadius >= 1 && displayIndex < tabs.length - 1 ? displayIndex + 1 : null; const slotTabs = [ prevIndex !== null ? { tab: tabs[prevIndex], index: prevIndex } : null, { tab: tabs[displayIndex], index: displayIndex }, nextIndex !== null ? { tab: tabs[nextIndex], index: nextIndex } : null ]; return (
{slotTabs.map((slot, slotIndex) => { const tab = slot?.tab ?? null; const key = tab !== null ? String(tab) : `empty-${slotIndex}`; return (
{tab !== null ? renderPanel(tab) : null}
); })}
); }