127 lines
3.4 KiB
TypeScript
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; |