first commit
This commit is contained in:
120
src/components/ds/Tabs.jsx
Normal file
120
src/components/ds/Tabs.jsx
Normal file
@@ -0,0 +1,120 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user