Files
flxn-app/src/components/swipeable-tabs.tsx
2025-08-24 22:56:48 -05:00

127 lines
3.4 KiB
TypeScript

import { FloatingIndicator, UnstyledButton, Box, Text, ScrollArea } from "@mantine/core";
import { Carousel } from "@mantine/carousel";
import { useState, useEffect, ReactNode } from "react";
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 [activeTab, setActiveTab] = useState(defaultTab);
const [embla, setEmbla] = useState<any>(null);
const [rootRef, setRootRef] = useState<HTMLDivElement | null>(null);
const [controlsRefs, setControlsRefs] = useState<Record<number, HTMLSpanElement | null>>({});
useEffect(() => {
if (!embla) return;
const onSelect = () => {
const newIndex = embla.selectedScrollSnap();
setActiveTab(newIndex);
};
embla.on("select", onSelect);
return () => {
embla.off("select", onSelect);
};
}, [embla]);
const handleTabChange = (index: number) => {
if (index !== activeTab && index >= 0 && index < tabs.length) {
setActiveTab(index);
embla?.scrollTo(index);
onTabChange?.(index, tabs[index]);
}
};
const setControlRef = (index: number) => (node: HTMLSpanElement | null) => {
controlsRefs[index] = node;
setControlsRefs(controlsRefs);
};
return (
<Box>
<Box
ref={setRootRef}
pos="relative"
style={{
display: 'flex',
marginBottom: 'var(--mantine-spacing-md)',
}}
>
<FloatingIndicator
target={controlsRefs[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={() => handleTabChange(index)}
style={{
flex: 1,
padding: 'var(--mantine-spacing-sm) var(--mantine-spacing-md)',
textAlign: 'center',
color: activeTab === index
? 'var(--mantine-color-blue-6)'
: '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: '1rem',
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'
}}
>
{tabs.map((tab, index) => (
<Carousel.Slide key={`${tab.label}-content-${index}`}>
<Box>
{tab.content}
</Box>
</Carousel.Slide>
))}
</Carousel>
</Box>
);
}
export default SwipeableTabs;