Files
flxn-app/src/components/swipeable-tabs.tsx
2025-09-11 13:35:33 -05:00

195 lines
5.4 KiB
TypeScript

import { FloatingIndicator, UnstyledButton, Box, Text } from "@mantine/core";
import { Carousel } from "@mantine/carousel";
import {
useState,
useEffect,
ReactNode,
useRef,
useCallback,
useMemo,
} from "react";
import { useRouter } from "@tanstack/react-router";
interface TabItem {
label: string;
content: ReactNode;
}
interface SwipeableTabsProps {
tabs: TabItem[];
defaultTab?: number;
onTabChange?: (index: number, tab: TabItem) => void;
}
function SwipeableTabs({
tabs,
defaultTab = 0,
onTabChange,
}: SwipeableTabsProps) {
const router = useRouter();
const search = router.state.location.search as any;
const [embla, setEmbla] = useState<any>(null);
const getActiveTabFromUrl = useCallback(() => {
const urlTab = search?.tab;
if (typeof urlTab === "string") {
const tabIndex = tabs.findIndex(
(tab) => tab.label.toLowerCase() === urlTab.toLowerCase()
);
return tabIndex !== -1 ? tabIndex : defaultTab;
}
return defaultTab;
}, [search?.tab, tabs, defaultTab]);
const [activeTab, setActiveTab] = useState(getActiveTabFromUrl);
const [rootRef, setRootRef] = useState<HTMLDivElement | null>(null);
const controlsRefs = useRef<Record<number, HTMLSpanElement | null>>({});
const slideRefs = useRef<Record<number, HTMLDivElement | null>>({});
const [carouselHeight, setCarouselHeight] = useState<number | "auto">("auto");
const changeTab = useCallback(
(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());
}
},
[activeTab, tabs, embla, onTabChange]
);
const handleEmblaSelect = useCallback(() => {
if (!embla) return;
const newIndex = embla.selectedScrollSnap();
changeTab(newIndex);
}, [embla, changeTab]);
useEffect(() => {
if (!embla) return;
embla.on("select", handleEmblaSelect);
return () => embla.off("select", handleEmblaSelect);
}, [embla, handleEmblaSelect]);
useEffect(() => {
const newActiveTab = getActiveTabFromUrl();
if (newActiveTab !== activeTab) {
setActiveTab(newActiveTab);
embla?.scrollTo(newActiveTab);
}
}, [search?.tab]);
useEffect(() => {
const activeSlideRef = slideRefs.current[activeTab];
if (activeSlideRef) {
const height = activeSlideRef.scrollHeight;
setCarouselHeight(height);
}
}, [activeTab]);
const setControlRef = useCallback(
(index: number) => (node: HTMLSpanElement | null) => {
controlsRefs.current[index] = node;
},
[]
);
const setSlideRef = useCallback(
(index: number) => (node: HTMLDivElement | null) => {
slideRefs.current[index] = node;
},
[]
);
return (
<Box style={{ touchAction: "pan-y" }}>
<Box
ref={setRootRef}
pos="sticky"
top={0}
style={{
display: "flex",
marginBottom: "var(--mantine-spacing-md)",
zIndex: 100,
backgroundColor: "var(--mantine-color-body)",
}}
>
<FloatingIndicator
target={controlsRefs.current[activeTab]}
parent={rootRef}
styles={{
root: {
borderBottom: "2px solid var(--mantine-primary-color-filled)",
paddingInline: "0.5rem",
},
}}
/>
{tabs.map((tab, index) => (
<UnstyledButton
key={`${tab.label}-${index}`}
onClick={() => changeTab(index)}
style={{
flex: 1,
padding: "var(--mantine-spacing-sm) var(--mantine-spacing-xs)",
textAlign: "center",
color:
activeTab === index
? "var(--mantine-primary-color-filled)"
: "var(--mantine-color-text)",
fontWeight: activeTab === index ? 600 : 400,
transition: "color 200ms ease, font-weight 200ms ease",
backgroundColor: "transparent",
border: "none",
borderRadius: 0,
}}
>
<Text
size="sm"
component="span"
style={{
display: "inline-block",
paddingInline: "0.5rem",
paddingBottom: "0.25rem",
}}
ref={setControlRef(index)}
>
{tab.label}
</Text>
</UnstyledButton>
))}
</Box>
<Carousel
getEmblaApi={setEmbla}
withControls={false}
withIndicators={false}
slideSize="100%"
initialSlide={activeTab}
style={{
overflow: "hidden",
height: carouselHeight === "auto" ? "auto" : `${carouselHeight}px`,
transition: "height 300ms ease",
touchAction: "pan-y",
}}
>
{tabs.map((tab, index) => (
<Carousel.Slide key={`${tab.label}-content-${index}`}>
<Box ref={setSlideRef(index)} style={{ height: "auto" }}>
{tab.content}
</Box>
</Carousel.Slide>
))}
</Carousel>
</Box>
);
}
export default SwipeableTabs;