2026-04-27 14:40:16 +02:00

339 lines
13 KiB
TypeScript

// frontend\src\components\ui\Tabs.tsx
'use client'
import { type ComponentType, type SVGProps } from 'react'
import { ChevronDownIcon } from '@heroicons/react/16/solid'
import clsx from 'clsx'
export type TabIcon = ComponentType<SVGProps<SVGSVGElement>>
export type TabItem = {
id: string
label: string
count?: number | string
icon?: TabIcon
disabled?: boolean
spinIcon?: boolean
}
export type TabsVariant =
| 'underline'
| 'underlineIcons'
| 'pills'
| 'pillsGray'
| 'pillsBrand'
| 'fullWidthUnderline'
| 'barUnderline'
| 'simple'
type TabsProps = {
tabs: TabItem[]
value: string
onChange: (id: string) => void
className?: string
ariaLabel?: string
/** Siehe Variants aus pasted.txt */
variant?: TabsVariant
/**
* Optional: In der pasted.txt sind Badges teils erst ab md sichtbar.
* Default: false (wie deine bisherige Komponente: immer sichtbar auf Desktop).
*/
hideCountUntilMd?: boolean
}
export default function Tabs({
tabs,
value,
onChange,
className,
ariaLabel = 'Ansicht auswählen',
variant = 'underline',
hideCountUntilMd = false,
}: TabsProps) {
if (!tabs?.length) return null
const current = tabs.find((t) => t.id === value) ?? tabs[0]
const mobileSelectClass = clsx(
// entspricht den Beispielen (outline-1 + -outline-offset-1)
'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:text-gray-100 dark:outline-white/10 dark:*:bg-gray-800 dark:focus:outline-indigo-500',
variant === 'pillsBrand' ? 'dark:bg-gray-800/50' : 'dark:bg-white/5'
)
const countPillClass = (selected: boolean) =>
clsx(
selected
? 'bg-indigo-100 text-indigo-600 dark:bg-indigo-500/20 dark:text-indigo-400'
: 'bg-gray-100 text-gray-900 dark:bg-white/10 dark:text-gray-300',
hideCountUntilMd ? 'ml-3 hidden rounded-full px-2.5 py-0.5 text-xs font-medium md:inline-block' : 'ml-3 rounded-full px-2.5 py-0.5 text-xs font-medium'
)
const renderCount = (selected: boolean, tab: TabItem) => {
if (tab.count === undefined) return null
return <span className={countPillClass(selected)}>{tab.count}</span>
}
const renderDesktop = () => {
switch (variant) {
case 'underline':
case 'underlineIcons': {
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 selected = tab.id === current.id
const disabled = !!tab.disabled
return (
<button
key={tab.id}
type="button"
onClick={() => !disabled && onChange(tab.id)}
disabled={disabled}
aria-current={selected ? 'page' : undefined}
className={clsx(
selected
? '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',
variant === 'underlineIcons'
? 'group inline-flex items-center border-b-2 px-1 py-4 text-sm font-medium'
: 'flex items-center border-b-2 px-1 py-4 text-sm font-medium whitespace-nowrap',
disabled && 'cursor-not-allowed opacity-50 hover:border-transparent hover:text-gray-500 dark:hover:text-gray-400'
)}
>
{variant === 'underlineIcons' && tab.icon ? (
<tab.icon
aria-hidden="true"
className={clsx(
selected
? 'text-indigo-500 dark:text-indigo-400'
: 'text-gray-400 group-hover:text-gray-500 dark:text-gray-500 dark:group-hover:text-gray-400',
'mr-2 -ml-0.5 size-5'
)}
/>
) : null}
<span>{tab.label}</span>
{renderCount(selected, tab)}
</button>
)
})}
</nav>
</div>
)
}
case 'pills':
case 'pillsGray':
case 'pillsBrand': {
const active =
variant === 'pills'
? 'bg-gray-100 text-gray-700 dark:bg-white/10 dark:text-gray-200'
: variant === 'pillsGray'
? 'bg-gray-200 text-gray-800 dark:bg-white/10 dark:text-white'
: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300'
const inactive =
variant === 'pills'
? 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
: variant === 'pillsGray'
? 'text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-white'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
return (
<nav aria-label={ariaLabel} className="flex space-x-4">
{tabs.map((tab) => {
const selected = tab.id === current.id
const disabled = !!tab.disabled
return (
<button
key={tab.id}
type="button"
onClick={() => !disabled && onChange(tab.id)}
disabled={disabled}
aria-current={selected ? 'page' : undefined}
className={clsx(
selected ? active : inactive,
'inline-flex items-center rounded-md px-3 py-2 text-sm font-medium',
disabled && 'cursor-not-allowed opacity-50 hover:text-inherit'
)}
>
<span>{tab.label}</span>
{tab.count !== undefined ? (
<span
className={clsx(
selected
? 'ml-2 bg-white/70 text-gray-900 dark:bg-white/10 dark:text-white'
: 'ml-2 bg-gray-100 text-gray-900 dark:bg-white/10 dark:text-gray-300',
'rounded-full px-2 py-0.5 text-xs font-medium'
)}
>
{tab.count}
</span>
) : null}
</button>
)
})}
</nav>
)
}
case 'fullWidthUnderline': {
return (
<div className="border-b border-gray-200 dark:border-white/10">
<nav aria-label={ariaLabel} className="-mb-px flex">
{tabs.map((tab) => {
const selected = tab.id === current.id
const disabled = !!tab.disabled
return (
<button
key={tab.id}
type="button"
onClick={() => !disabled && onChange(tab.id)}
disabled={disabled}
aria-current={selected ? 'page' : undefined}
className={clsx(
selected
? '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-4 text-center text-sm font-medium',
disabled && 'cursor-not-allowed opacity-50 hover:border-transparent hover:text-gray-500 dark:hover:text-gray-400'
)}
>
{tab.label}
</button>
)
})}
</nav>
</div>
)
}
case 'barUnderline': {
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, idx) => {
const selected = tab.id === current.id
const disabled = !!tab.disabled
const Icon = tab.icon
return (
<button
key={tab.id}
type="button"
onClick={() => !disabled && onChange(tab.id)}
disabled={disabled}
aria-current={selected ? 'page' : undefined}
className={clsx(
selected
? 'text-gray-900 dark:text-white'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-white',
idx === 0 ? 'rounded-l-lg' : '',
idx === 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',
disabled && 'cursor-not-allowed opacity-50 hover:bg-transparent hover:text-gray-500 dark:hover:text-gray-400'
)}
>
<span className="inline-flex max-w-full min-w-0 items-center justify-center gap-2">
{Icon ? (
<Icon
aria-hidden="true"
className={clsx(
'size-4 shrink-0 transition-transform',
tab.spinIcon && 'animate-spin [animation-duration:2s]',
selected
? 'text-indigo-600 dark:text-indigo-400'
: 'text-gray-400 group-hover:text-gray-500 dark:text-gray-500 dark:group-hover:text-gray-300'
)}
/>
) : null}
<span className="min-w-0 truncate whitespace-nowrap" title={tab.label}>
{tab.label}
</span>
{tab.count !== undefined ? (
<span className="ml-1 shrink-0 tabular-nums min-w-[2.25rem] rounded-full bg-white/70 px-2 py-0.5 text-center text-xs font-medium text-gray-900 dark:bg-white/10 dark:text-white">
{tab.count}
</span>
) : null}
</span>
<span
aria-hidden="true"
className={clsx(
selected ? 'bg-indigo-500 dark:bg-indigo-400' : 'bg-transparent',
'absolute inset-x-0 bottom-0 h-0.5'
)}
/>
</button>
)
})}
</nav>
)
}
case 'simple': {
return (
<nav className="flex border-b border-gray-200 py-4 dark:border-white/10" aria-label={ariaLabel}>
<ul role="list" className="flex min-w-full flex-none gap-x-8 px-2 text-sm/6 font-semibold text-gray-500 dark:text-gray-400">
{tabs.map((tab) => {
const selected = tab.id === current.id
const disabled = !!tab.disabled
return (
<li key={tab.id}>
<button
type="button"
onClick={() => !disabled && onChange(tab.id)}
disabled={disabled}
aria-current={selected ? 'page' : undefined}
className={clsx(
selected ? 'text-indigo-600 dark:text-indigo-400' : 'hover:text-gray-700 dark:hover:text-white',
disabled && 'cursor-not-allowed opacity-50 hover:text-inherit'
)}
>
{tab.label}
</button>
</li>
)
})}
</ul>
</nav>
)
}
default:
return null
}
}
return (
<div className={className}>
{/* Mobile: Select + Chevron (wie Beispiele) */}
<div className="grid grid-cols-1 sm:hidden">
<select value={current.id} onChange={(e) => onChange(e.target.value)} aria-label={ariaLabel} className={mobileSelectClass}>
{tabs.map((tab) => (
<option key={tab.id} value={tab.id}>
{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 */}
<div className="hidden sm:block">{renderDesktop()}</div>
</div>
)
}