swipeable tabs use url

This commit is contained in:
yohlo
2025-08-25 22:22:33 -05:00
parent 44417d063b
commit c9df4947bd
4 changed files with 62 additions and 3 deletions

View File

@@ -2,8 +2,14 @@ import Page from "@/components/page";
import Profile from "@/features/players/components/profile"; import Profile from "@/features/players/components/profile";
import { playerQueries } from "@/features/players/queries"; import { playerQueries } from "@/features/players/queries";
import { redirect, createFileRoute } from "@tanstack/react-router"; 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")({ export const Route = createFileRoute("/_authed/profile/$playerId")({
validateSearch: searchSchema,
beforeLoad: async ({ params, context }) => { beforeLoad: async ({ params, context }) => {
const { queryClient } = context; const { queryClient } = context;
const player = await queryClient.ensureQueryData(playerQueries.details(params.playerId)) const player = await queryClient.ensureQueryData(playerQueries.details(params.playerId))

View File

@@ -2,8 +2,14 @@ import Page from "@/components/page";
import TeamProfile from "@/features/teams/components/team-profile"; import TeamProfile from "@/features/teams/components/team-profile";
import { teamQueries } from "@/features/teams/queries"; import { teamQueries } from "@/features/teams/queries";
import { redirect, createFileRoute } from "@tanstack/react-router"; 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")({ export const Route = createFileRoute("/_authed/teams/$teamId")({
validateSearch: searchSchema,
beforeLoad: async ({ params, context }) => { beforeLoad: async ({ params, context }) => {
const { queryClient } = context; const { queryClient } = context;
const team = await queryClient.ensureQueryData(teamQueries.details(params.teamId)) const team = await queryClient.ensureQueryData(teamQueries.details(params.teamId))

View File

@@ -10,8 +10,14 @@ import TeamList from '@/features/teams/components/team-list';
import Button from '@/components/button'; import Button from '@/components/button';
import Avatar from '@/components/avatar'; import Avatar from '@/components/avatar';
import Profile from '@/features/tournaments/components/profile'; 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')({ export const Route = createFileRoute('/_authed/tournaments/$tournamentId')({
validateSearch: searchSchema,
beforeLoad: async ({ context, params }) => { beforeLoad: async ({ context, params }) => {
const { queryClient } = context; const { queryClient } = context;
await queryClient.ensureQueryData(tournamentQueries.details(params.tournamentId)) await queryClient.ensureQueryData(tournamentQueries.details(params.tournamentId))

View File

@@ -1,6 +1,7 @@
import { FloatingIndicator, UnstyledButton, Box, Text, ScrollArea } from "@mantine/core"; import { FloatingIndicator, UnstyledButton, Box, Text, ScrollArea } from "@mantine/core";
import { Carousel } from "@mantine/carousel"; import { Carousel } from "@mantine/carousel";
import { useState, useEffect, ReactNode, useRef } from "react"; import { useState, useEffect, ReactNode, useRef } from "react";
import { useRouter } from "@tanstack/react-router";
interface TabItem { interface TabItem {
label: string; label: string;
@@ -15,7 +16,28 @@ interface SwipeableTabsProps {
} }
function SwipeableTabs({ tabs, defaultTab = 0, onTabChange, scrollPosition }: 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<any>(null); const [embla, setEmbla] = useState<any>(null);
const [rootRef, setRootRef] = useState<HTMLDivElement | null>(null); const [rootRef, setRootRef] = useState<HTMLDivElement | null>(null);
const [controlsRefs, setControlsRefs] = useState<Record<number, HTMLSpanElement | null>>({}); const [controlsRefs, setControlsRefs] = useState<Record<number, HTMLSpanElement | null>>({});
@@ -33,12 +55,18 @@ function SwipeableTabs({ tabs, defaultTab = 0, onTabChange, scrollPosition }: Sw
const newIndex = embla.selectedScrollSnap(); const newIndex = embla.selectedScrollSnap();
setActiveTab(newIndex); setActiveTab(newIndex);
// Update height based on active slide content
const activeSlideRef = slideRefs.current[newIndex]; const activeSlideRef = slideRefs.current[newIndex];
if (activeSlideRef) { if (activeSlideRef) {
const height = activeSlideRef.scrollHeight; const height = activeSlideRef.scrollHeight;
setActiveSlideHeight(height); setActiveSlideHeight(height);
} }
const tabLabel = tabs[newIndex]?.label.toLowerCase();
if (tabLabel) {
updateUrlTab(tabLabel);
}
onTabChange?.(newIndex, tabs[newIndex]);
}; };
embla.on("select", onSelect); embla.on("select", onSelect);
@@ -48,7 +76,6 @@ function SwipeableTabs({ tabs, defaultTab = 0, onTabChange, scrollPosition }: Sw
}; };
}, [embla]); }, [embla]);
// Update height when activeTab changes
useEffect(() => { useEffect(() => {
const activeSlideRef = slideRefs.current[activeTab]; const activeSlideRef = slideRefs.current[activeTab];
if (activeSlideRef) { if (activeSlideRef) {
@@ -57,6 +84,17 @@ function SwipeableTabs({ tabs, defaultTab = 0, onTabChange, scrollPosition }: Sw
} }
}, [activeTab]); }, [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(() => { useEffect(() => {
if (scrollPosition) { if (scrollPosition) {
setIsSticky(scrollPosition.y > stickyThreshold); setIsSticky(scrollPosition.y > stickyThreshold);
@@ -96,6 +134,9 @@ function SwipeableTabs({ tabs, defaultTab = 0, onTabChange, scrollPosition }: Sw
setActiveTab(index); setActiveTab(index);
embla?.scrollTo(index); embla?.scrollTo(index);
onTabChange?.(index, tabs[index]); onTabChange?.(index, tabs[index]);
const tabLabel = tabs[index].label.toLowerCase();
updateUrlTab(tabLabel);
} }
}; };