- Create SwipeableContent component for sidebar swipe on non-tab pages - Add swipe-to-close sidebar from overlay - Make swipe work from entire page (ignoring interactive elements) - Show title and divider on desktop when tab bar is at bottom - Hide title/divider only on mobile for bottom position 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
568 lines
21 KiB
TypeScript
568 lines
21 KiB
TypeScript
import {
|
|
useCallback,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type PointerEvent,
|
|
type ReactNode,
|
|
type TouchEvent,
|
|
type TransitionEvent
|
|
} from 'react';
|
|
|
|
type SwipeTabsProps<T extends string | number> = {
|
|
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<T extends string | number>({
|
|
tabs,
|
|
activeTab,
|
|
onTabChange,
|
|
renderPanel,
|
|
className,
|
|
panelClassName,
|
|
swipeDisabled = false,
|
|
threshold = DEFAULT_THRESHOLD,
|
|
renderWindow = DEFAULT_RENDER_WINDOW,
|
|
scrollToTopOnChange = true,
|
|
onSwipePastStart,
|
|
onSwipePastStartProgress,
|
|
onSwipePastStartDragStart,
|
|
onSwipePastStartDragEnd,
|
|
sidebarWidth = 280
|
|
}: SwipeTabsProps<T>) {
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const trackRef = useRef<HTMLDivElement>(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<string | null>(null);
|
|
const transformRef = useRef<string | null>(null);
|
|
const pendingIndexRef = useRef<number | null>(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<HTMLDivElement>) => {
|
|
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<HTMLDivElement>) => {
|
|
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<HTMLDivElement>) => {
|
|
if (dragRef.current.pointerId !== event.pointerId) return;
|
|
finishDrag(false);
|
|
if (containerRef.current?.hasPointerCapture?.(event.pointerId)) {
|
|
containerRef.current.releasePointerCapture(event.pointerId);
|
|
}
|
|
};
|
|
|
|
const handlePointerCancel = (event: PointerEvent<HTMLDivElement>) => {
|
|
if (dragRef.current.pointerId !== event.pointerId) return;
|
|
finishDrag(true);
|
|
if (containerRef.current?.hasPointerCapture?.(event.pointerId)) {
|
|
containerRef.current.releasePointerCapture(event.pointerId);
|
|
}
|
|
};
|
|
|
|
const handleTouchStart = (event: TouchEvent<HTMLDivElement>) => {
|
|
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<HTMLDivElement>) => {
|
|
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<HTMLDivElement>) => {
|
|
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<HTMLDivElement>) => {
|
|
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<HTMLDivElement>) => {
|
|
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 (
|
|
<div
|
|
ref={containerRef}
|
|
className={rootClassName}
|
|
onPointerDown={usePointerEvents ? handlePointerDown : undefined}
|
|
onPointerMove={usePointerEvents ? handlePointerMove : undefined}
|
|
onPointerUp={usePointerEvents ? handlePointerUp : undefined}
|
|
onPointerCancel={usePointerEvents ? handlePointerCancel : undefined}
|
|
onTouchStart={!usePointerEvents ? handleTouchStart : undefined}
|
|
onTouchMove={!usePointerEvents ? handleTouchMove : undefined}
|
|
onTouchEnd={!usePointerEvents ? handleTouchEnd : undefined}
|
|
onTouchCancel={!usePointerEvents ? handleTouchCancel : undefined}
|
|
>
|
|
<div className="swipe-tabs-track" ref={trackRef} onTransitionEnd={handleTransitionEnd}>
|
|
{slotTabs.map((slot, slotIndex) => {
|
|
const tab = slot?.tab ?? null;
|
|
const key = tab !== null ? String(tab) : `empty-${slotIndex}`;
|
|
return (
|
|
<section key={key} className="swipe-tabs-panel">
|
|
<div className={panelInnerClasses}>
|
|
{tab !== null ? renderPanel(tab) : null}
|
|
</div>
|
|
</section>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|