305 lines
11 KiB
TypeScript
305 lines
11 KiB
TypeScript
// components/ui/Tabs.tsx
|
|
'use client';
|
|
|
|
import { ChevronDownIcon } from '@heroicons/react/16/solid';
|
|
import clsx from 'clsx';
|
|
import * as React from 'react';
|
|
import Badge from '@/components/ui/Badge';
|
|
|
|
export type TabsVariant =
|
|
| 'underline' // Standard: Unterstrich, wie bisher (mit optionalem Count/Badge)
|
|
| 'underlineFull' // Full-width Tabs mit Unterstrich
|
|
| 'bar' // "Bar with underline" Variante
|
|
| 'pills' // Pills (neutral)
|
|
| 'pillsGray' // Pills auf grauem Hintergrund
|
|
| 'pillsBrand'; // Pills mit Brand-Farbe
|
|
|
|
export type TabItem = {
|
|
id: string;
|
|
label: string;
|
|
/** optional: Anzahl / Badge, z.B. "52" */
|
|
count?: number | string;
|
|
/** optional: Icon (z.B. <UsersIcon className="size-5" />) */
|
|
icon?: React.ReactNode;
|
|
};
|
|
|
|
type TabsProps = {
|
|
tabs: TabItem[];
|
|
value: string;
|
|
onChange: (id: string) => void;
|
|
className?: string;
|
|
ariaLabel?: string;
|
|
variant?: TabsVariant;
|
|
};
|
|
|
|
export default function Tabs({
|
|
tabs,
|
|
value,
|
|
onChange,
|
|
className,
|
|
ariaLabel = 'Ansicht auswählen',
|
|
variant = 'underline',
|
|
}: TabsProps) {
|
|
if (!tabs || tabs.length === 0) return null;
|
|
|
|
const isValidValue = tabs.some((t) => t.id === value);
|
|
const currentId = isValidValue ? value : tabs[0].id;
|
|
const current = tabs.find((t) => t.id === currentId)!;
|
|
|
|
const renderDesktopTabs = () => {
|
|
switch (variant) {
|
|
case 'underline':
|
|
return (
|
|
<div className="border-b border-gray-200 dark:border-white/10">
|
|
<nav aria-label={ariaLabel} className="-mb-px flex space-x-8">
|
|
{tabs.map((tab) => {
|
|
const isCurrent = tab.id === currentId;
|
|
return (
|
|
<button
|
|
key={tab.id}
|
|
type="button"
|
|
onClick={() => onChange(tab.id)}
|
|
className={clsx(
|
|
isCurrent
|
|
? 'border-indigo-500 text-indigo-600 dark:border-indigo-400 dark:text-indigo-400'
|
|
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-white/20 dark:hover:text-gray-200',
|
|
'border-b-2 px-1 py-3 text-sm font-medium whitespace-nowrap flex items-center gap-2',
|
|
)}
|
|
>
|
|
{tab.icon && (
|
|
<span className="mr-1 -ml-0.5 flex items-center">
|
|
{tab.icon}
|
|
</span>
|
|
)}
|
|
<span>{tab.label}</span>
|
|
{tab.count != null && (
|
|
<Badge tone="gray" variant="flat" size="sm">
|
|
{tab.count}
|
|
</Badge>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</nav>
|
|
</div>
|
|
);
|
|
|
|
case 'underlineFull':
|
|
return (
|
|
<div className="border-b border-gray-200 dark:border-white/10">
|
|
<nav aria-label={ariaLabel} className="-mb-px flex">
|
|
{tabs.map((tab) => {
|
|
const isCurrent = tab.id === currentId;
|
|
return (
|
|
<button
|
|
key={tab.id}
|
|
type="button"
|
|
onClick={() => onChange(tab.id)}
|
|
className={clsx(
|
|
isCurrent
|
|
? 'border-indigo-500 text-indigo-600 dark:border-indigo-400 dark:text-indigo-400'
|
|
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-white/20 dark:hover:text-gray-300',
|
|
'flex-1 border-b-2 px-1 py-3 text-center text-sm font-medium flex items-center justify-center gap-2',
|
|
)}
|
|
>
|
|
{tab.icon && (
|
|
<span className="mr-1 -ml-0.5 flex items-center">
|
|
{tab.icon}
|
|
</span>
|
|
)}
|
|
<span>{tab.label}</span>
|
|
{tab.count != null && (
|
|
<Badge tone="gray" variant="flat" size="sm">
|
|
{tab.count}
|
|
</Badge>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</nav>
|
|
</div>
|
|
);
|
|
|
|
case 'bar':
|
|
return (
|
|
<nav
|
|
aria-label={ariaLabel}
|
|
className="isolate flex divide-x divide-gray-200 rounded-lg bg-white shadow-sm dark:divide-white/10 dark:bg-gray-800/50 dark:shadow-none dark:outline dark:-outline-offset-1 dark:outline-white/10"
|
|
>
|
|
{tabs.map((tab, tabIdx) => {
|
|
const isCurrent = tab.id === currentId;
|
|
return (
|
|
<button
|
|
key={tab.id}
|
|
type="button"
|
|
onClick={() => onChange(tab.id)}
|
|
className={clsx(
|
|
isCurrent
|
|
? 'text-gray-900 dark:text-white'
|
|
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-white',
|
|
tabIdx === 0 ? 'rounded-l-lg' : '',
|
|
tabIdx === tabs.length - 1 ? 'rounded-r-lg' : '',
|
|
'group relative min-w-0 flex-1 overflow-hidden px-4 py-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10 dark:hover:bg-white/5 flex items-center justify-center gap-2',
|
|
)}
|
|
>
|
|
{tab.icon && (
|
|
<span className="mr-1 -ml-0.5 flex items-center">
|
|
{tab.icon}
|
|
</span>
|
|
)}
|
|
<span>{tab.label}</span>
|
|
{tab.count != null && (
|
|
<Badge tone="gray" variant="flat" size="sm">
|
|
{tab.count}
|
|
</Badge>
|
|
)}
|
|
<span
|
|
aria-hidden="true"
|
|
className={clsx(
|
|
isCurrent ? 'bg-indigo-500 dark:bg-indigo-400' : 'bg-transparent',
|
|
'absolute inset-x-0 bottom-0 h-0.5',
|
|
)}
|
|
/>
|
|
</button>
|
|
);
|
|
})}
|
|
</nav>
|
|
);
|
|
|
|
case 'pills':
|
|
return (
|
|
<nav aria-label={ariaLabel} className="flex space-x-4">
|
|
{tabs.map((tab) => {
|
|
const isCurrent = tab.id === currentId;
|
|
return (
|
|
<button
|
|
key={tab.id}
|
|
type="button"
|
|
onClick={() => onChange(tab.id)}
|
|
className={clsx(
|
|
isCurrent
|
|
? 'bg-gray-100 text-gray-700 dark:bg-white/10 dark:text-gray-200'
|
|
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200',
|
|
'rounded-md px-3 py-2 text-sm font-medium flex items-center gap-2',
|
|
)}
|
|
>
|
|
{tab.icon && (
|
|
<span className="mr-1 -ml-0.5 flex items-center">
|
|
{tab.icon}
|
|
</span>
|
|
)}
|
|
<span>{tab.label}</span>
|
|
{tab.count != null && (
|
|
<Badge tone="gray" variant="flat" size="sm">
|
|
{tab.count}
|
|
</Badge>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</nav>
|
|
);
|
|
|
|
case 'pillsGray':
|
|
return (
|
|
<nav aria-label={ariaLabel} className="flex space-x-4">
|
|
{tabs.map((tab) => {
|
|
const isCurrent = tab.id === currentId;
|
|
return (
|
|
<button
|
|
key={tab.id}
|
|
type="button"
|
|
onClick={() => onChange(tab.id)}
|
|
className={clsx(
|
|
isCurrent
|
|
? 'bg-gray-200 text-gray-800 dark:bg-white/10 dark:text-white'
|
|
: 'text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-white',
|
|
'rounded-md px-3 py-2 text-sm font-medium flex items-center gap-2',
|
|
)}
|
|
>
|
|
{tab.icon && (
|
|
<span className="mr-1 -ml-0.5 flex items-center">
|
|
{tab.icon}
|
|
</span>
|
|
)}
|
|
<span>{tab.label}</span>
|
|
{tab.count != null && (
|
|
<Badge tone="gray" variant="flat" size="sm">
|
|
{tab.count}
|
|
</Badge>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</nav>
|
|
);
|
|
|
|
case 'pillsBrand':
|
|
return (
|
|
<nav aria-label={ariaLabel} className="flex space-x-4">
|
|
{tabs.map((tab) => {
|
|
const isCurrent = tab.id === currentId;
|
|
return (
|
|
<button
|
|
key={tab.id}
|
|
type="button"
|
|
onClick={() => onChange(tab.id)}
|
|
className={clsx(
|
|
isCurrent
|
|
? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300'
|
|
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200',
|
|
'rounded-md px-3 py-2 text-sm font-medium flex items-center gap-2',
|
|
)}
|
|
>
|
|
{tab.icon && (
|
|
<span className="mr-1 -ml-0.5 flex items-center">
|
|
{tab.icon}
|
|
</span>
|
|
)}
|
|
<span>{tab.label}</span>
|
|
{tab.count != null && (
|
|
<Badge tone="gray" variant="flat" size="sm">
|
|
{tab.count}
|
|
</Badge>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</nav>
|
|
);
|
|
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className={className}>
|
|
{/* Mobile: Select + Chevron (für alle Varianten gleich) */}
|
|
<div className="grid grid-cols-1 sm:hidden">
|
|
<select
|
|
value={currentId}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
aria-label={ariaLabel}
|
|
className="col-start-1 row-start-1 w-full appearance-none rounded-md bg-white py-2 pr-8 pl-3 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 dark:bg-white/5 dark:text-gray-100 dark:outline-white/10 dark:*:bg-gray-800 dark:focus:outline-indigo-500"
|
|
>
|
|
{tabs.map((tab) => (
|
|
<option key={tab.id} value={tab.id}>
|
|
{tab.count != null
|
|
? `${tab.label} (${tab.count})`
|
|
: tab.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<ChevronDownIcon
|
|
aria-hidden="true"
|
|
className="pointer-events-none col-start-1 row-start-1 mr-2 size-5 self-center justify-self-end fill-gray-500 dark:fill-gray-400"
|
|
/>
|
|
</div>
|
|
|
|
{/* Desktop: abhängig von variant */}
|
|
<div className="hidden sm:block">{renderDesktopTabs()}</div>
|
|
</div>
|
|
);
|
|
}
|