swipeable tabs using mantine carousel
This commit is contained in:
127
src/components/swipeable-tabs.tsx
Normal file
127
src/components/swipeable-tabs.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { FloatingIndicator, UnstyledButton, Box, Text } 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;
|
||||
Reference in New Issue
Block a user