Improve mobile swipe tabs with native scroll-snap
- Rewrite SwipeTabs to use CSS scroll-snap for smoother transitions - Add GPU acceleration hints for fluid animations - Use native scrolling behavior instead of manual touch handling - Add swipe-tabs--snap and swipe-tabs--static variants - Improve height transitions and layout containment - Update dimension variables for better spacing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
326
frontend/src/components/SwipeTabs.tsx
Normal file
326
frontend/src/components/SwipeTabs.tsx
Normal file
@@ -0,0 +1,326 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
|
||||
type SwipeTabsProps<T extends string> = {
|
||||
tabs: readonly T[];
|
||||
activeTab: T;
|
||||
onTabChange: (tab: T) => void;
|
||||
renderPanel: (tab: T) => ReactNode;
|
||||
className?: string;
|
||||
panelClassName?: string;
|
||||
renderWindow?: number;
|
||||
threshold?: number;
|
||||
directionRatio?: number;
|
||||
respectScrollableTargets?: boolean;
|
||||
};
|
||||
|
||||
const SCROLL_END_DELAY = 120;
|
||||
const COARSE_POINTER_QUERY = '(pointer: coarse)';
|
||||
const NO_HOVER_QUERY = '(hover: none)';
|
||||
const FALLBACK_WIDTH_QUERY = '(max-width: 1024px)';
|
||||
|
||||
const getSwipeEnabled = () => {
|
||||
if (typeof window === 'undefined' || !window.matchMedia) return false;
|
||||
const coarsePointer = window.matchMedia(COARSE_POINTER_QUERY).matches;
|
||||
const noHover = window.matchMedia(NO_HOVER_QUERY).matches;
|
||||
if (coarsePointer || noHover) return true;
|
||||
const supportsTouch = ('ontouchstart' in window)
|
||||
|| (typeof navigator !== 'undefined' && navigator.maxTouchPoints > 0);
|
||||
if (!supportsTouch) return false;
|
||||
return window.matchMedia(FALLBACK_WIDTH_QUERY).matches;
|
||||
};
|
||||
|
||||
export function SwipeTabs<T extends string>({
|
||||
tabs,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
renderPanel,
|
||||
className,
|
||||
panelClassName,
|
||||
renderWindow = 0,
|
||||
}: SwipeTabsProps<T>) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const panelRefs = useRef(new Map<T, HTMLDivElement>());
|
||||
const activeIndex = tabs.indexOf(activeTab);
|
||||
const safeActiveIndex = activeIndex >= 0 ? activeIndex : 0;
|
||||
const maxIndex = Math.max(0, tabs.length - 1);
|
||||
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const [containerHeight, setContainerHeight] = useState<number | null>(null);
|
||||
const [isScrolling, setIsScrolling] = useState(false);
|
||||
const [scrollPosition, setScrollPosition] = useState(0);
|
||||
const [swipeEnabled, setSwipeEnabled] = useState(() => getSwipeEnabled());
|
||||
|
||||
const scrollEndTimer = useRef<number | null>(null);
|
||||
const scrollRaf = useRef<number | null>(null);
|
||||
const scrollPositionRef = useRef(0);
|
||||
const isUserScrolling = useRef(false);
|
||||
const hasMounted = useRef(false);
|
||||
const lastReportedIndex = useRef(safeActiveIndex);
|
||||
|
||||
const prefersReducedMotion = useMemo(() => {
|
||||
if (typeof window === 'undefined' || !window.matchMedia) return true;
|
||||
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || !window.matchMedia) return;
|
||||
const pointerMql = window.matchMedia(COARSE_POINTER_QUERY);
|
||||
const hoverMql = window.matchMedia(NO_HOVER_QUERY);
|
||||
const widthMql = window.matchMedia(FALLBACK_WIDTH_QUERY);
|
||||
const update = () => setSwipeEnabled(getSwipeEnabled());
|
||||
update();
|
||||
|
||||
const addListener = (mql: MediaQueryList) => {
|
||||
if (mql.addEventListener) {
|
||||
mql.addEventListener('change', update);
|
||||
return;
|
||||
}
|
||||
mql.addListener(update);
|
||||
};
|
||||
|
||||
const removeListener = (mql: MediaQueryList) => {
|
||||
if (mql.removeEventListener) {
|
||||
mql.removeEventListener('change', update);
|
||||
return;
|
||||
}
|
||||
mql.removeListener(update);
|
||||
};
|
||||
|
||||
addListener(pointerMql);
|
||||
addListener(hoverMql);
|
||||
addListener(widthMql);
|
||||
|
||||
return () => {
|
||||
removeListener(pointerMql);
|
||||
removeListener(hoverMql);
|
||||
removeListener(widthMql);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const element = containerRef.current;
|
||||
const updateSize = () => {
|
||||
const styles = window.getComputedStyle(element);
|
||||
const paddingX = parseFloat(styles.paddingLeft) + parseFloat(styles.paddingRight);
|
||||
const width = element.clientWidth - (Number.isFinite(paddingX) ? paddingX : 0);
|
||||
setContainerWidth(width > 0 ? width : element.clientWidth);
|
||||
};
|
||||
updateSize();
|
||||
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
window.addEventListener('resize', updateSize);
|
||||
return () => window.removeEventListener('resize', updateSize);
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(updateSize);
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const panel = panelRefs.current.get(activeTab);
|
||||
if (!panel) {
|
||||
setContainerHeight(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const updateHeight = () => setContainerHeight(panel.offsetHeight);
|
||||
updateHeight();
|
||||
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
window.addEventListener('resize', updateHeight);
|
||||
return () => window.removeEventListener('resize', updateHeight);
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(updateHeight);
|
||||
observer.observe(panel);
|
||||
return () => observer.disconnect();
|
||||
}, [activeTab]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (scrollEndTimer.current !== null) {
|
||||
window.clearTimeout(scrollEndTimer.current);
|
||||
}
|
||||
if (scrollRaf.current !== null) {
|
||||
cancelAnimationFrame(scrollRaf.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
lastReportedIndex.current = safeActiveIndex;
|
||||
}, [safeActiveIndex]);
|
||||
|
||||
const scrollToIndex = useCallback((index: number, behavior: ScrollBehavior) => {
|
||||
const container = containerRef.current;
|
||||
if (!container || containerWidth <= 0) return;
|
||||
const targetLeft = index * containerWidth;
|
||||
if (Math.abs(container.scrollLeft - targetLeft) <= 1) return;
|
||||
container.scrollTo({ left: targetLeft, behavior });
|
||||
}, [containerWidth]);
|
||||
|
||||
const scheduleScrollPositionUpdate = useCallback(() => {
|
||||
if (scrollRaf.current !== null) return;
|
||||
scrollRaf.current = window.requestAnimationFrame(() => {
|
||||
scrollRaf.current = null;
|
||||
setScrollPosition(scrollPositionRef.current);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
if (!swipeEnabled) return;
|
||||
const container = containerRef.current;
|
||||
if (!container || containerWidth <= 0) return;
|
||||
|
||||
if (!isUserScrolling.current) {
|
||||
isUserScrolling.current = true;
|
||||
setIsScrolling(true);
|
||||
}
|
||||
|
||||
const rawIndex = container.scrollLeft / containerWidth;
|
||||
scrollPositionRef.current = rawIndex;
|
||||
scheduleScrollPositionUpdate();
|
||||
|
||||
const roundedIndex = Math.round(rawIndex);
|
||||
const clampedIndex = Math.max(0, Math.min(roundedIndex, maxIndex));
|
||||
if (tabs[clampedIndex] && clampedIndex !== lastReportedIndex.current) {
|
||||
lastReportedIndex.current = clampedIndex;
|
||||
onTabChange(tabs[clampedIndex]);
|
||||
}
|
||||
|
||||
if (scrollEndTimer.current !== null) {
|
||||
window.clearTimeout(scrollEndTimer.current);
|
||||
}
|
||||
|
||||
scrollEndTimer.current = window.setTimeout(() => {
|
||||
const rawIndex = container.scrollLeft / containerWidth;
|
||||
const nextIndex = Math.round(rawIndex);
|
||||
const clampedIndex = Math.max(0, Math.min(nextIndex, maxIndex));
|
||||
const targetLeft = clampedIndex * containerWidth;
|
||||
|
||||
if (Math.abs(container.scrollLeft - targetLeft) > 1) {
|
||||
container.scrollTo({ left: targetLeft, behavior: 'auto' });
|
||||
}
|
||||
|
||||
if (tabs[clampedIndex] && clampedIndex !== lastReportedIndex.current) {
|
||||
lastReportedIndex.current = clampedIndex;
|
||||
onTabChange(tabs[clampedIndex]);
|
||||
}
|
||||
|
||||
scrollPositionRef.current = targetLeft / containerWidth;
|
||||
scheduleScrollPositionUpdate();
|
||||
|
||||
isUserScrolling.current = false;
|
||||
setIsScrolling(false);
|
||||
}, SCROLL_END_DELAY);
|
||||
}, [activeTab, containerWidth, maxIndex, onTabChange, scheduleScrollPositionUpdate, swipeEnabled, tabs]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!swipeEnabled) return;
|
||||
const container = containerRef.current;
|
||||
if (!container || containerWidth <= 0) return;
|
||||
if (isUserScrolling.current) return;
|
||||
const behavior: ScrollBehavior = hasMounted.current && !prefersReducedMotion ? 'smooth' : 'auto';
|
||||
scrollToIndex(safeActiveIndex, behavior);
|
||||
hasMounted.current = true;
|
||||
const currentPosition = container.scrollLeft / containerWidth;
|
||||
scrollPositionRef.current = currentPosition;
|
||||
setScrollPosition(currentPosition);
|
||||
}, [containerWidth, prefersReducedMotion, safeActiveIndex, scrollToIndex, swipeEnabled]);
|
||||
|
||||
const visibleIndexes = useMemo(() => {
|
||||
const indexes = new Set<number>();
|
||||
if (!swipeEnabled) {
|
||||
if (safeActiveIndex >= 0) {
|
||||
indexes.add(safeActiveIndex);
|
||||
}
|
||||
return indexes;
|
||||
}
|
||||
const centers = new Set<number>();
|
||||
centers.add(safeActiveIndex);
|
||||
if (containerWidth <= 0) {
|
||||
centers.add(0);
|
||||
}
|
||||
if (containerWidth > 0 && Number.isFinite(scrollPosition)) {
|
||||
const base = Math.floor(scrollPosition);
|
||||
const next = Math.ceil(scrollPosition);
|
||||
centers.add(base);
|
||||
centers.add(next);
|
||||
}
|
||||
centers.forEach((center) => {
|
||||
for (let offset = -renderWindow; offset <= renderWindow; offset += 1) {
|
||||
const index = center + offset;
|
||||
if (index >= 0 && index < tabs.length) {
|
||||
indexes.add(index);
|
||||
}
|
||||
}
|
||||
});
|
||||
return indexes;
|
||||
}, [containerWidth, renderWindow, safeActiveIndex, scrollPosition, swipeEnabled, tabs.length]);
|
||||
|
||||
const containerStyle: CSSProperties = {
|
||||
height: !isScrolling && containerHeight ? `${containerHeight}px` : 'auto',
|
||||
transition: !isScrolling && containerHeight ? 'height 160ms ease-out' : 'none',
|
||||
};
|
||||
|
||||
if (!swipeEnabled) {
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`swipe-tabs swipe-tabs--static${className ? ` ${className}` : ''}`}
|
||||
style={containerStyle}
|
||||
>
|
||||
<div className="swipe-tabs-track">
|
||||
<div className="swipe-tabs-panel" aria-hidden={false}>
|
||||
<div
|
||||
className={`swipe-tabs-panel-inner${panelClassName ? ` ${panelClassName}` : ''}`}
|
||||
ref={(node) => {
|
||||
if (node) {
|
||||
panelRefs.current.set(activeTab, node);
|
||||
} else {
|
||||
panelRefs.current.delete(activeTab);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{renderPanel(activeTab)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`swipe-tabs swipe-tabs--snap${className ? ` ${className}` : ''}${isScrolling ? ' is-scrolling' : ''}`}
|
||||
style={containerStyle}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<div className="swipe-tabs-track">
|
||||
{tabs.map((tab, index) => {
|
||||
const shouldRender = visibleIndexes.has(index);
|
||||
return (
|
||||
<div key={tab} className="swipe-tabs-panel" aria-hidden={tab !== activeTab}>
|
||||
<div
|
||||
className={`swipe-tabs-panel-inner${panelClassName ? ` ${panelClassName}` : ''}`}
|
||||
ref={(node) => {
|
||||
if (node) {
|
||||
panelRefs.current.set(tab, node);
|
||||
} else {
|
||||
panelRefs.current.delete(tab);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{shouldRender ? renderPanel(tab) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user