swipeable tabs using mantine carousel

This commit is contained in:
yohlo
2025-08-24 10:41:31 -05:00
parent fb1e4c3ee7
commit 1015f63f7e
6 changed files with 153 additions and 5 deletions

View File

@@ -13,6 +13,7 @@
},
"dependencies": {
"@hello-pangea/dnd": "^18.0.1",
"@mantine/carousel": "^8.2.4",
"@mantine/charts": "^8.2.4",
"@mantine/core": "^8.2.4",
"@mantine/dates": "^8.2.4",
@@ -28,6 +29,7 @@
"@types/ioredis": "^4.28.10",
"drizzle-orm": "^0.44.4",
"drizzle-zod": "^0.8.3",
"embla-carousel-react": "^8.6.0",
"framer-motion": "^12.23.12",
"ioredis": "^5.7.0",
"pg": "^8.16.3",

View File

@@ -1,5 +1,6 @@
import '@mantine/core/styles.css';
import '@mantine/dates/styles.css';
import '@mantine/carousel/styles.css';
import {
HeadContent,
Navigate,

View File

@@ -17,7 +17,7 @@ export const Route = createFileRoute('/_authed/tournaments/$tournamentId')({
header: {
collapsed: true,
withBackButton: true,
settingsLink: context.auth.roles.includes("Admin") ? `/admin/tournaments/${params.tournamentId}` : undefined
settingsLink: context.auth.roles?.includes("Admin") ? `/admin/tournaments/${params.tournamentId}` : undefined
},
refresh: {
toRefresh: tournamentQueries.details(params.tournamentId).queryKey,

View 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;

View File

@@ -3,18 +3,38 @@ import Header from "./header";
import { testEvent } from "@/utils/test-event";
import { Player } from "@/features/players/types";
import TeamList from "@/features/teams/components/team-list";
import SwipeableTabs from "@/components/swipeable-tabs";
interface ProfileProps {
player: Player;
}
const Profile = ({ player }: ProfileProps) => {
const tabs = [
{
label: "Overview",
content: <Text p="md">Panel 1 content</Text>
},
{
label: "Teams",
content: <Text p="md">Panel 2 content</Text>
},
{
label: "Stats",
content: <Text p="md">Panel 3 content</Text>
}
];
return <>
<Header player={player} />
<Box m='sm' mt='lg'>
<Text size='xl' fw={600}>Teams</Text>
<TeamList teams={player.teams ?? []} />
<SwipeableTabs
tabs={tabs}
defaultTab={0}
onTabChange={(index, tab) => {
console.log(`Switched to ${tab.label} tab`);
}}
/>
</Box>
</>;
};

View File

@@ -47,7 +47,6 @@ export const superTokensRequestMiddleware = createMiddleware({ type: 'request' }
if (!session.context.userAuthId) {
logger.error('Unauthenticated user in API call.', session.context)
throw new Error('Unauthenticated')
}
const context = {
@@ -66,7 +65,6 @@ export const superTokensFunctionMiddleware = createMiddleware({ type: 'function'
if (!session.context.userAuthId) {
logger.error('Unauthenticated user in server function.', session.context)
throw new Error('Unauthenticated')
}
const context = {