Files
app-service/frontend/src/components/SwipeTabs.tsx

509 lines
18 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;
};
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
}: 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;
if (element.closest(
'[data-swipe-ignore="true"], button, a, input, select, textarea, [role="button"], [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 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);
}
if (!state.isDragging) return;
const width = widthRef.current || 1;
let offset = dx;
const atStart = state.startIndex <= 0;
const atEnd = state.startIndex >= tabs.length - 1;
if ((atStart && dx > 0) || (atEnd && dx < 0)) {
offset = dx * EDGE_RESISTANCE;
}
offset = Math.max(Math.min(offset, width), -width);
dragOffsetRef.current = offset;
scheduleDragUpdate();
if (pointerType !== 'mouse') {
preventDefault();
}
},
[scheduleDragUpdate, 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);
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;
if (!cancelled && Math.abs(dx) > thresholdPx) {
const direction = dx < 0 ? 1 : -1;
targetIndex = Math.min(Math.max(state.startIndex + direction, 0), tabs.length - 1);
}
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, setDraggingClass, 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);
if (containerRef.current?.setPointerCapture) {
try {
containerRef.current.setPointerCapture(event.pointerId);
} catch {
// Ignore pointer capture failures.
}
}
};
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>
);
}