refactor swipeable tabs

This commit is contained in:
yohlo
2025-08-25 23:50:36 -05:00
parent f5e4e5b214
commit 7226fb33f4

View File

@@ -1,4 +1,4 @@
import { FloatingIndicator, UnstyledButton, Box, Text, ScrollArea } from "@mantine/core"; import { FloatingIndicator, UnstyledButton, Box, Text } from "@mantine/core";
import { Carousel } from "@mantine/carousel"; import { Carousel } from "@mantine/carousel";
import { useState, useEffect, ReactNode, useRef } from "react"; import { useState, useEffect, ReactNode, useRef } from "react";
import { useRouter } from "@tanstack/react-router"; import { useRouter } from "@tanstack/react-router";
@@ -12,23 +12,14 @@ interface SwipeableTabsProps {
tabs: TabItem[]; tabs: TabItem[];
defaultTab?: number; defaultTab?: number;
onTabChange?: (index: number, tab: TabItem) => void; onTabChange?: (index: number, tab: TabItem) => void;
scrollPosition?: { x: number; y: number };
} }
function SwipeableTabs({ tabs, defaultTab = 0, onTabChange, scrollPosition }: SwipeableTabsProps) { function SwipeableTabs({ tabs, defaultTab = 0, onTabChange }: SwipeableTabsProps) {
const router = useRouter(); const router = useRouter();
const search = router.state.location.search as any; const search = router.state.location.search as any;
const [embla, setEmbla] = useState<any>(null);
// manually update url tab w/o useNavigate
const updateUrlTab = (tabLabel: string) => {
if (typeof window === 'undefined') return;
const url = new URL(window.location.href);
url.searchParams.set('tab', tabLabel);
window.history.replaceState(null, '', url.toString());
};
const getInitialTab = () => { const getActiveTabFromUrl = () => {
const urlTab = search?.tab; const urlTab = search?.tab;
if (typeof urlTab === 'string') { if (typeof urlTab === 'string') {
const tabIndex = tabs.findIndex(tab => tab.label.toLowerCase() === urlTab.toLowerCase()); const tabIndex = tabs.findIndex(tab => tab.label.toLowerCase() === urlTab.toLowerCase());
@@ -37,109 +28,55 @@ function SwipeableTabs({ tabs, defaultTab = 0, onTabChange, scrollPosition }: Sw
return defaultTab; return defaultTab;
}; };
const [activeTab, setActiveTab] = useState(getInitialTab); const [activeTab, setActiveTab] = useState(getActiveTabFromUrl);
const [embla, setEmbla] = useState<any>(null);
const [rootRef, setRootRef] = useState<HTMLDivElement | null>(null); const [rootRef, setRootRef] = useState<HTMLDivElement | null>(null);
const [controlsRefs, setControlsRefs] = useState<Record<number, HTMLSpanElement | null>>({}); const [controlsRefs, setControlsRefs] = useState<Record<number, HTMLSpanElement | null>>({});
const [isSticky, setIsSticky] = useState(false);
const tabsRef = useRef<HTMLDivElement | null>(null);
const originalPositionRef = useRef<number | null>(null);
const slideRefs = useRef<Record<number, HTMLDivElement | null>>({}); const slideRefs = useRef<Record<number, HTMLDivElement | null>>({});
const [activeSlideHeight, setActiveSlideHeight] = useState<number | 'auto'>('auto'); const [carouselHeight, setCarouselHeight] = useState<number | 'auto'>('auto');
const stickyThreshold = 0;
const changeTab = (index: number) => {
if (index === activeTab || index < 0 || index >= tabs.length) return;
setActiveTab(index);
embla?.scrollTo(index);
onTabChange?.(index, tabs[index]);
const tabLabel = tabs[index].label.toLowerCase();
if (typeof window !== 'undefined') {
const url = new URL(window.location.href);
url.searchParams.set('tab', tabLabel);
window.history.replaceState(null, '', url.toString());
}
};
useEffect(() => { useEffect(() => {
if (!embla) return; if (!embla) return;
const onSelect = () => { const onSelect = () => {
const newIndex = embla.selectedScrollSnap(); const newIndex = embla.selectedScrollSnap();
setActiveTab(newIndex); changeTab(newIndex);
const activeSlideRef = slideRefs.current[newIndex];
if (activeSlideRef) {
const height = activeSlideRef.scrollHeight;
setActiveSlideHeight(height);
}
const tabLabel = tabs[newIndex]?.label.toLowerCase();
if (tabLabel) {
updateUrlTab(tabLabel);
}
onTabChange?.(newIndex, tabs[newIndex]);
}; };
embla.on("select", onSelect); embla.on("select", onSelect);
return () => embla.off("select", onSelect);
return () => { }, [embla, activeTab, tabs]);
embla.off("select", onSelect);
}; useEffect(() => {
}, [embla]); const newActiveTab = getActiveTabFromUrl();
if (newActiveTab !== activeTab) {
setActiveTab(newActiveTab);
embla?.scrollTo(newActiveTab);
}
}, [search?.tab]);
useEffect(() => { useEffect(() => {
const activeSlideRef = slideRefs.current[activeTab]; const activeSlideRef = slideRefs.current[activeTab];
if (activeSlideRef) { if (activeSlideRef) {
const height = activeSlideRef.scrollHeight; const height = activeSlideRef.scrollHeight;
setActiveSlideHeight(height); setCarouselHeight(height);
} }
}, [activeTab]); }, [activeTab]);
useEffect(() => {
const urlTab = search?.tab;
if (typeof urlTab === 'string') {
const tabIndex = tabs.findIndex(tab => tab.label.toLowerCase() === urlTab.toLowerCase());
if (tabIndex !== -1 && tabIndex !== activeTab) {
setActiveTab(tabIndex);
embla?.scrollTo(tabIndex);
}
}
}, [search?.tab, tabs, activeTab, embla]);
useEffect(() => {
if (scrollPosition) {
setIsSticky(scrollPosition.y > stickyThreshold);
return;
}
const scrollWrapper = document.getElementById('scroll-wrapper');
let viewport = scrollWrapper!.querySelector('.mantine-ScrollArea-viewport') as HTMLElement;
const handleScroll = () => {
if (!tabsRef.current) return;
const scrollTop = viewport.scrollTop;
const viewportRect = viewport.getBoundingClientRect();
if (originalPositionRef.current === null && !isSticky) {
const tabsRect = tabsRef.current.getBoundingClientRect();
originalPositionRef.current = tabsRect.top - viewportRect.top + scrollTop;
}
setIsSticky(
originalPositionRef.current !== null
&& scrollTop >= (originalPositionRef.current - viewportRect.top - stickyThreshold)
);
};
handleScroll();
viewport.addEventListener('scroll', handleScroll, { passive: true });
return () => {
viewport.removeEventListener('scroll', handleScroll);
};
}, [stickyThreshold, isSticky, scrollPosition]);
const handleTabChange = (index: number) => {
if (index !== activeTab && index >= 0 && index < tabs.length) {
setActiveTab(index);
embla?.scrollTo(index);
onTabChange?.(index, tabs[index]);
const tabLabel = tabs[index].label.toLowerCase();
updateUrlTab(tabLabel);
}
};
const setControlRef = (index: number) => (node: HTMLSpanElement | null) => { const setControlRef = (index: number) => (node: HTMLSpanElement | null) => {
controlsRefs[index] = node; controlsRefs[index] = node;
setControlsRefs(controlsRefs); setControlsRefs(controlsRefs);
@@ -151,24 +88,15 @@ function SwipeableTabs({ tabs, defaultTab = 0, onTabChange, scrollPosition }: Sw
return ( return (
<Box> <Box>
{isSticky && (
<Box style={{ height: '60px' }} />
)}
<Box <Box
ref={(node) => { ref={setRootRef}
setRootRef(node); pos="sticky"
tabsRef.current = node; top={0}
}}
pos={isSticky ? "fixed" : "relative"}
style={{ style={{
display: 'flex', display: 'flex',
marginBottom: 'var(--mantine-spacing-md)', marginBottom: 'var(--mantine-spacing-md)',
top: isSticky ? 0 : 'auto',
left: isSticky ? 0 : 'auto',
right: isSticky ? 0 : 'auto',
zIndex: 100, zIndex: 100,
backgroundColor: 'var(--mantine-color-body)', backgroundColor: 'var(--mantine-color-body)'
transition: 'all 200ms ease',
}} }}
> >
<FloatingIndicator <FloatingIndicator
@@ -184,7 +112,7 @@ function SwipeableTabs({ tabs, defaultTab = 0, onTabChange, scrollPosition }: Sw
{tabs.map((tab, index) => ( {tabs.map((tab, index) => (
<UnstyledButton <UnstyledButton
key={`${tab.label}-${index}`} key={`${tab.label}-${index}`}
onClick={() => handleTabChange(index)} onClick={() => changeTab(index)}
style={{ style={{
flex: 1, flex: 1,
padding: 'var(--mantine-spacing-sm) var(--mantine-spacing-md)', padding: 'var(--mantine-spacing-sm) var(--mantine-spacing-md)',
@@ -193,11 +121,10 @@ function SwipeableTabs({ tabs, defaultTab = 0, onTabChange, scrollPosition }: Sw
? 'var(--mantine-color-blue-6)' ? 'var(--mantine-color-blue-6)'
: 'var(--mantine-color-text)', : 'var(--mantine-color-text)',
fontWeight: activeTab === index ? 600 : 400, fontWeight: activeTab === index ? 600 : 400,
transition: 'color 200ms ease', transition: 'color 200ms ease, font-weight 200ms ease',
backgroundColor: 'transparent', backgroundColor: 'transparent',
border: 'none', border: 'none',
borderRadius: 0, borderRadius: 0,
}} }}
> >
<Text <Text
@@ -224,19 +151,15 @@ function SwipeableTabs({ tabs, defaultTab = 0, onTabChange, scrollPosition }: Sw
initialSlide={activeTab} initialSlide={activeTab}
style={{ style={{
overflow: 'hidden', overflow: 'hidden',
height: activeSlideHeight === 'auto' ? 'auto' : `${activeSlideHeight}px`, height: carouselHeight === 'auto' ? 'auto' : `${carouselHeight}px`,
transition: 'height 300ms ease' transition: 'height 300ms ease'
}} }}
> >
{tabs.map((tab, index) => ( {tabs.map((tab, index) => (
<Carousel.Slide key={`${tab.label}-content-${index}`} style={{ height: 'auto' }}> <Carousel.Slide key={`${tab.label}-content-${index}`}>
<Box <Box
ref={setSlideRef(index)} ref={setSlideRef(index)}
style={{ style={{ height: 'auto' }}
minHeight: 'fit-content',
height: 'auto',
visibility: index === activeTab ? 'visible' : 'hidden'
}}
> >
{tab.content} {tab.content}
</Box> </Box>