From c9df4947bdd21b33abb7ccf536aa2bfc86e86e7e Mon Sep 17 00:00:00 2001 From: yohlo Date: Mon, 25 Aug 2025 22:22:33 -0500 Subject: [PATCH] swipeable tabs use url --- src/app/routes/_authed/profile.$playerId.tsx | 6 +++ src/app/routes/_authed/teams.$teamId.tsx | 6 +++ .../_authed/tournaments/$tournamentId.tsx | 6 +++ src/components/swipeable-tabs.tsx | 47 +++++++++++++++++-- 4 files changed, 62 insertions(+), 3 deletions(-) diff --git a/src/app/routes/_authed/profile.$playerId.tsx b/src/app/routes/_authed/profile.$playerId.tsx index 51d7dcc..af422d2 100644 --- a/src/app/routes/_authed/profile.$playerId.tsx +++ b/src/app/routes/_authed/profile.$playerId.tsx @@ -2,8 +2,14 @@ import Page from "@/components/page"; import Profile from "@/features/players/components/profile"; import { playerQueries } from "@/features/players/queries"; import { redirect, createFileRoute } from "@tanstack/react-router"; +import { z } from "zod"; + +const searchSchema = z.object({ + tab: z.string().optional(), +}); export const Route = createFileRoute("/_authed/profile/$playerId")({ + validateSearch: searchSchema, beforeLoad: async ({ params, context }) => { const { queryClient } = context; const player = await queryClient.ensureQueryData(playerQueries.details(params.playerId)) diff --git a/src/app/routes/_authed/teams.$teamId.tsx b/src/app/routes/_authed/teams.$teamId.tsx index 4394379..0ab150b 100644 --- a/src/app/routes/_authed/teams.$teamId.tsx +++ b/src/app/routes/_authed/teams.$teamId.tsx @@ -2,8 +2,14 @@ import Page from "@/components/page"; import TeamProfile from "@/features/teams/components/team-profile"; import { teamQueries } from "@/features/teams/queries"; import { redirect, createFileRoute } from "@tanstack/react-router"; +import { z } from "zod"; + +const searchSchema = z.object({ + tab: z.string().optional(), +}); export const Route = createFileRoute("/_authed/teams/$teamId")({ + validateSearch: searchSchema, beforeLoad: async ({ params, context }) => { const { queryClient } = context; const team = await queryClient.ensureQueryData(teamQueries.details(params.teamId)) diff --git a/src/app/routes/_authed/tournaments/$tournamentId.tsx b/src/app/routes/_authed/tournaments/$tournamentId.tsx index 78696e9..c36f592 100644 --- a/src/app/routes/_authed/tournaments/$tournamentId.tsx +++ b/src/app/routes/_authed/tournaments/$tournamentId.tsx @@ -10,8 +10,14 @@ import TeamList from '@/features/teams/components/team-list'; import Button from '@/components/button'; import Avatar from '@/components/avatar'; import Profile from '@/features/tournaments/components/profile'; +import { z } from "zod"; + +const searchSchema = z.object({ + tab: z.string().optional(), +}); export const Route = createFileRoute('/_authed/tournaments/$tournamentId')({ + validateSearch: searchSchema, beforeLoad: async ({ context, params }) => { const { queryClient } = context; await queryClient.ensureQueryData(tournamentQueries.details(params.tournamentId)) diff --git a/src/components/swipeable-tabs.tsx b/src/components/swipeable-tabs.tsx index 6c792ac..d947305 100644 --- a/src/components/swipeable-tabs.tsx +++ b/src/components/swipeable-tabs.tsx @@ -1,6 +1,7 @@ import { FloatingIndicator, UnstyledButton, Box, Text, ScrollArea } from "@mantine/core"; import { Carousel } from "@mantine/carousel"; import { useState, useEffect, ReactNode, useRef } from "react"; +import { useRouter } from "@tanstack/react-router"; interface TabItem { label: string; @@ -15,7 +16,28 @@ interface SwipeableTabsProps { } function SwipeableTabs({ tabs, defaultTab = 0, onTabChange, scrollPosition }: SwipeableTabsProps) { - const [activeTab, setActiveTab] = useState(defaultTab); + const router = useRouter(); + const search = router.state.location.search as any; + + // manually update url tab w/o useNavigate + const updateUrlTab = (tabLabel: string) => { + if (typeof window === 'undefined') return; + + const url = new URL(window.location.href); + url.searchParams.set('tab', tabLabel); + window.history.replaceState(null, '', url.toString()); + }; + + const getInitialTab = () => { + 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; + }; + + const [activeTab, setActiveTab] = useState(getInitialTab); const [embla, setEmbla] = useState(null); const [rootRef, setRootRef] = useState(null); const [controlsRefs, setControlsRefs] = useState>({}); @@ -33,12 +55,18 @@ function SwipeableTabs({ tabs, defaultTab = 0, onTabChange, scrollPosition }: Sw const newIndex = embla.selectedScrollSnap(); setActiveTab(newIndex); - // Update height based on active slide content const activeSlideRef = slideRefs.current[newIndex]; if (activeSlideRef) { const height = activeSlideRef.scrollHeight; setActiveSlideHeight(height); } + + const tabLabel = tabs[newIndex]?.label.toLowerCase(); + if (tabLabel) { + updateUrlTab(tabLabel); + } + + onTabChange?.(newIndex, tabs[newIndex]); }; embla.on("select", onSelect); @@ -48,7 +76,6 @@ function SwipeableTabs({ tabs, defaultTab = 0, onTabChange, scrollPosition }: Sw }; }, [embla]); - // Update height when activeTab changes useEffect(() => { const activeSlideRef = slideRefs.current[activeTab]; if (activeSlideRef) { @@ -57,6 +84,17 @@ function SwipeableTabs({ tabs, defaultTab = 0, onTabChange, scrollPosition }: Sw } }, [activeTab]); + useEffect(() => { + const urlTab = search?.tab; + if (typeof urlTab === 'string') { + const tabIndex = tabs.findIndex(tab => tab.label.toLowerCase() === urlTab.toLowerCase()); + if (tabIndex !== -1 && tabIndex !== activeTab) { + setActiveTab(tabIndex); + embla?.scrollTo(tabIndex); + } + } + }, [search?.tab, tabs, activeTab, embla]); + useEffect(() => { if (scrollPosition) { setIsSticky(scrollPosition.y > stickyThreshold); @@ -96,6 +134,9 @@ function SwipeableTabs({ tabs, defaultTab = 0, onTabChange, scrollPosition }: Sw setActiveTab(index); embla?.scrollTo(index); onTabChange?.(index, tabs[index]); + + const tabLabel = tabs[index].label.toLowerCase(); + updateUrlTab(tabLabel); } };