2026-04-28 12:10:21 +02:00
|
|
|
'use client';
|
|
|
|
|
|
2026-05-03 16:03:56 +02:00
|
|
|
import { useEffect, useRef, type ReactNode } from 'react';
|
2026-04-28 12:10:21 +02:00
|
|
|
|
|
|
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
|
|
|
import { Badge } from '@/components/ui/badge';
|
|
|
|
|
|
|
|
|
|
export interface ResponsiveTab {
|
|
|
|
|
id: string;
|
|
|
|
|
label: string;
|
|
|
|
|
content: ReactNode;
|
|
|
|
|
badge?: string | number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface ResponsiveTabsProps {
|
|
|
|
|
tabs: ResponsiveTab[];
|
|
|
|
|
value: string;
|
|
|
|
|
onValueChange: (value: string) => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-05-03 16:03:56 +02:00
|
|
|
* Tab strip that scrolls horizontally on narrow viewports. The active tab is
|
|
|
|
|
* automatically scrolled into view so users can tell at a glance that more
|
|
|
|
|
* tabs exist beyond the visible edge.
|
|
|
|
|
*
|
|
|
|
|
* Previously this collapsed to a <Select> on phone widths, but that read as
|
|
|
|
|
* a generic dropdown and obscured the fact that multiple peer tabs exist.
|
2026-04-28 12:10:21 +02:00
|
|
|
*/
|
|
|
|
|
export function ResponsiveTabs({ tabs, value, onValueChange }: ResponsiveTabsProps) {
|
2026-05-03 16:03:56 +02:00
|
|
|
const listRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
|
|
|
|
// Keep the active trigger in view when the value changes externally
|
|
|
|
|
// (e.g. ?tab= in the URL or a back/forward navigation).
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const root = listRef.current;
|
|
|
|
|
if (!root) return;
|
|
|
|
|
const active = root.querySelector<HTMLButtonElement>(`[data-tab-id="${CSS.escape(value)}"]`);
|
|
|
|
|
if (active) {
|
|
|
|
|
active.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' });
|
|
|
|
|
}
|
|
|
|
|
}, [value]);
|
|
|
|
|
|
2026-04-28 12:10:21 +02:00
|
|
|
return (
|
|
|
|
|
<Tabs value={value} onValueChange={onValueChange}>
|
2026-05-03 16:03:56 +02:00
|
|
|
{/* Single scrollable strip for all viewport widths.
|
|
|
|
|
The wrapper handles horizontal overflow with momentum scroll on
|
|
|
|
|
touch devices; the inner TabsList stays its natural width and
|
|
|
|
|
slides under the wrapper. */}
|
|
|
|
|
<div
|
|
|
|
|
ref={listRef}
|
|
|
|
|
className="overflow-x-auto -mx-2 px-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
|
|
|
|
>
|
|
|
|
|
<TabsList className="inline-flex w-max">
|
|
|
|
|
{tabs.map((tab) => (
|
|
|
|
|
<TabsTrigger
|
|
|
|
|
key={tab.id}
|
|
|
|
|
value={tab.id}
|
|
|
|
|
className="gap-1.5 whitespace-nowrap"
|
|
|
|
|
data-tab-id={tab.id}
|
|
|
|
|
>
|
|
|
|
|
{tab.label}
|
|
|
|
|
{tab.badge !== undefined && tab.badge !== null && (
|
|
|
|
|
<Badge variant="secondary" className="px-1.5 py-0 text-xs">
|
|
|
|
|
{tab.badge}
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
|
|
|
|
</TabsTrigger>
|
|
|
|
|
))}
|
|
|
|
|
</TabsList>
|
2026-04-28 12:10:21 +02:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{tabs.map((tab) => (
|
|
|
|
|
<TabsContent key={tab.id} value={tab.id} className="mt-4">
|
|
|
|
|
{tab.content}
|
|
|
|
|
</TabsContent>
|
|
|
|
|
))}
|
|
|
|
|
</Tabs>
|
|
|
|
|
);
|
|
|
|
|
}
|