121 lines
4.1 KiB
JavaScript
121 lines
4.1 KiB
JavaScript
import * as React from 'react';
|
|
import { motion } from 'framer-motion';
|
|
import { cn } from '@/lib/utils';
|
|
import { Badge } from '@/components/ui/badge';
|
|
|
|
/**
|
|
* Tabs — switches between views of the same page.
|
|
*
|
|
* Two visual styles:
|
|
* - `pill` — matches the app's header navigation, with a sliding indicator
|
|
* - `underline` — quieter, for tabs inside a card
|
|
*
|
|
* The sliding indicator uses a shared `layoutId`, so the highlight animates
|
|
* between tabs the same way the main nav pill does. `layoutGroupId` must be
|
|
* unique when two tab sets are on screen at once, otherwise the indicator
|
|
* animates between them.
|
|
*/
|
|
/** @param {any} props */
|
|
export function Tabs({
|
|
tabs = [],
|
|
value,
|
|
onChange,
|
|
variant = 'pill',
|
|
size = 'default',
|
|
layoutGroupId,
|
|
className,
|
|
ariaLabel = 'Views',
|
|
}) {
|
|
const normalized = tabs.map((t) => (typeof t === 'string' ? { value: t, label: t } : t));
|
|
const fallbackId = React.useId();
|
|
const groupId = layoutGroupId || fallbackId;
|
|
|
|
if (variant === 'underline') {
|
|
return (
|
|
<div role="tablist" aria-label={ariaLabel} className={cn('flex items-center gap-1 border-b border-border', className)}>
|
|
{normalized.map((tab) => {
|
|
const active = tab.value === value;
|
|
return (
|
|
<button
|
|
key={tab.value}
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={active}
|
|
onClick={() => onChange?.(tab.value)}
|
|
className={cn(
|
|
'relative inline-flex items-center gap-2 px-3 pb-2.5 pt-1.5 font-medium transition-colors duration-base focus-ring-inset rounded-t-md',
|
|
size === 'sm' ? 'text-body-sm' : 'text-body',
|
|
active ? 'text-krow-blue' : 'text-ink-3 hover:text-ink-1'
|
|
)}
|
|
>
|
|
{tab.icon && <tab.icon className="w-4 h-4" aria-hidden="true" />}
|
|
{tab.label}
|
|
{tab.count != null && (
|
|
<Badge variant={active ? 'soft' : 'neutral'} size="sm">{tab.count}</Badge>
|
|
)}
|
|
{active && (
|
|
<motion.span
|
|
layoutId={`tab-underline-${groupId}`}
|
|
className="absolute left-0 right-0 -bottom-px h-0.5 rounded-full bg-krow-blue"
|
|
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
|
|
/>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
role="tablist"
|
|
aria-label={ariaLabel}
|
|
className={cn(
|
|
'inline-flex items-center gap-1 rounded-full bg-white/60 backdrop-blur-xl p-1 border border-white/50 shadow-sm',
|
|
className
|
|
)}
|
|
>
|
|
{normalized.map((tab) => {
|
|
const active = tab.value === value;
|
|
return (
|
|
<button
|
|
key={tab.value}
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={active}
|
|
onClick={() => onChange?.(tab.value)}
|
|
className={cn(
|
|
'relative inline-flex items-center gap-1.5 rounded-full font-medium transition-colors duration-base focus-ring',
|
|
size === 'sm' ? 'px-3 py-1 text-caption' : 'px-4 py-1.5 text-body-sm',
|
|
active ? 'text-white' : 'text-ink-3 hover:text-ink-1'
|
|
)}
|
|
>
|
|
{active && (
|
|
<motion.span
|
|
layoutId={`tab-pill-${groupId}`}
|
|
className="absolute inset-0 rounded-full bg-krow-blue"
|
|
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
|
|
/>
|
|
)}
|
|
<span className="relative z-10 inline-flex items-center gap-1.5">
|
|
{tab.icon && <tab.icon className="w-3.5 h-3.5" aria-hidden="true" />}
|
|
{tab.label}
|
|
{tab.count != null && (
|
|
<span
|
|
className={cn(
|
|
'rounded-full px-1.5 text-[10px] font-bold tabular-nums',
|
|
active ? 'bg-white/25 text-white' : 'bg-surface-sunken text-ink-3'
|
|
)}
|
|
>
|
|
{tab.count}
|
|
</span>
|
|
)}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|