Initial commit
This commit is contained in:
99
src/components/ds/Timeline.jsx
Normal file
99
src/components/ds/Timeline.jsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { StatusBadge } from '@/components/ds/StatusBadge';
|
||||
|
||||
const TONE_DOT = {
|
||||
brand: 'bg-krow-blue ring-krow-blue/20',
|
||||
success: 'bg-success ring-success/20',
|
||||
warning: 'bg-warning ring-warning/20',
|
||||
destructive: 'bg-destructive ring-destructive/20',
|
||||
neutral: 'bg-ink-4 ring-ink-4/20',
|
||||
};
|
||||
|
||||
/**
|
||||
* Timeline — a vertical sequence of events: a candidate's journey, an audit
|
||||
* trail, a career history.
|
||||
*
|
||||
* Items:
|
||||
* { id, title, description, timestamp, icon, tone, status, meta, current }
|
||||
*
|
||||
* The connecting rail is drawn per item rather than as one absolute line, so a
|
||||
* timeline can be nested inside a scrolling card without the rail detaching.
|
||||
*/
|
||||
export function Timeline({ items = [], className, compact = false }) {
|
||||
return (
|
||||
<ol className={cn('relative', className)}>
|
||||
{items.map((item, i) => {
|
||||
const isLast = i === items.length - 1;
|
||||
const Icon = item.icon;
|
||||
const tone = item.tone || (item.current ? 'brand' : 'neutral');
|
||||
|
||||
return (
|
||||
<li key={item.id ?? i} className="relative flex gap-3.5 pb-5 last:pb-0">
|
||||
{/* Rail + node */}
|
||||
<div className="relative flex flex-col items-center shrink-0">
|
||||
{Icon ? (
|
||||
<span
|
||||
className={cn(
|
||||
'grid place-items-center rounded-full ring-4 shrink-0 z-10',
|
||||
compact ? 'w-6 h-6' : 'w-8 h-8',
|
||||
tone === 'neutral' ? 'bg-surface-sunken text-ink-3 ring-white' : 'text-white ring-white',
|
||||
tone !== 'neutral' && TONE_DOT[tone]
|
||||
)}
|
||||
>
|
||||
<Icon className={compact ? 'w-3 h-3' : 'w-4 h-4'} aria-hidden="true" />
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-full ring-4 ring-white shrink-0 z-10 mt-1',
|
||||
compact ? 'w-2 h-2' : 'w-2.5 h-2.5',
|
||||
TONE_DOT[tone],
|
||||
item.current && 'animate-pulse-ring'
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isLast && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="w-px flex-1 bg-border mt-1.5 min-h-4"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className={cn('flex-1 min-w-0', compact ? '-mt-0.5' : '-mt-1')}>
|
||||
<div className="flex items-start justify-between gap-2 flex-wrap">
|
||||
<div className="min-w-0">
|
||||
<p
|
||||
className={cn(
|
||||
'font-medium text-ink-1',
|
||||
compact ? 'text-body-sm' : 'text-body'
|
||||
)}
|
||||
>
|
||||
{item.title}
|
||||
</p>
|
||||
{item.description && (
|
||||
<p className="text-body-sm text-ink-3 mt-0.5 leading-relaxed">{item.description}</p>
|
||||
)}
|
||||
{item.meta && <p className="text-caption text-ink-4 mt-1">{item.meta}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{item.status && <StatusBadge status={item.status} size="sm" />}
|
||||
{item.timestamp && (
|
||||
<time className="text-caption text-ink-4 whitespace-nowrap">{item.timestamp}</time>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{item.children}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user