mirror of
https://github.com/hanzoai/shadcn.git
synced 2026-10-30 18:22:59 +00:00
412 lines
14 KiB
TypeScript
412 lines
14 KiB
TypeScript
"use client"
|
|
|
|
import * as React from "react"
|
|
import { Check, Circle, Clock } from "lucide-react"
|
|
|
|
import { cn } from "@/lib/utils"
|
|
|
|
interface TimelineItem {
|
|
id: string
|
|
title: string
|
|
description?: string
|
|
date?: string
|
|
time?: string
|
|
status?: "completed" | "active" | "pending"
|
|
icon?: React.ReactNode
|
|
content?: React.ReactNode
|
|
}
|
|
|
|
interface TimelineProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
items: TimelineItem[]
|
|
orientation?: "vertical" | "horizontal"
|
|
variant?: "default" | "alternate" | "compact" | "simple"
|
|
animated?: boolean
|
|
}
|
|
|
|
const Timeline = React.forwardRef<HTMLDivElement, TimelineProps>(
|
|
(
|
|
{
|
|
className,
|
|
items,
|
|
orientation = "vertical",
|
|
variant = "default",
|
|
animated = true,
|
|
...props
|
|
},
|
|
ref
|
|
) => {
|
|
const [visibleItems, setVisibleItems] = React.useState<Set<string>>(
|
|
new Set()
|
|
)
|
|
|
|
React.useEffect(() => {
|
|
if (!animated) {
|
|
setVisibleItems(new Set(items.map((item) => item.id)))
|
|
return
|
|
}
|
|
|
|
const observer = new IntersectionObserver(
|
|
(entries) => {
|
|
entries.forEach((entry) => {
|
|
if (entry.isIntersecting) {
|
|
const id = entry.target.getAttribute("data-timeline-id")
|
|
if (id) {
|
|
setVisibleItems((prev) => new Set([...prev, id]))
|
|
}
|
|
}
|
|
})
|
|
},
|
|
{
|
|
threshold: 0.1,
|
|
rootMargin: "50px",
|
|
}
|
|
)
|
|
|
|
const elements = document.querySelectorAll("[data-timeline-id]")
|
|
elements.forEach((el) => observer.observe(el))
|
|
|
|
return () => observer.disconnect()
|
|
}, [items, animated])
|
|
|
|
const getStatusIcon = (status?: TimelineItem["status"]) => {
|
|
switch (status) {
|
|
case "completed":
|
|
return <Check className="h-3 w-3" />
|
|
case "active":
|
|
return <Clock className="h-3 w-3 animate-pulse" />
|
|
default:
|
|
return null
|
|
}
|
|
}
|
|
|
|
const getStatusColor = (status?: TimelineItem["status"]) => {
|
|
switch (status) {
|
|
case "completed":
|
|
return "bg-green-500 text-white"
|
|
case "active":
|
|
return "bg-blue-500 text-white"
|
|
case "pending":
|
|
return "bg-gray-300 dark:bg-gray-600"
|
|
default:
|
|
return "bg-primary text-primary-foreground"
|
|
}
|
|
}
|
|
|
|
if (orientation === "horizontal") {
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
className={cn("relative overflow-x-auto", className)}
|
|
{...props}
|
|
>
|
|
<div className="flex items-start space-x-8 pb-4">
|
|
{items.map((item, index) => (
|
|
<div
|
|
key={item.id}
|
|
data-timeline-id={item.id}
|
|
className={cn(
|
|
"relative flex flex-col items-center min-w-[200px]",
|
|
animated &&
|
|
!visibleItems.has(item.id) &&
|
|
"opacity-0 translate-y-4",
|
|
animated &&
|
|
visibleItems.has(item.id) &&
|
|
"opacity-100 translate-y-0",
|
|
"transition-all duration-500 ease-out"
|
|
)}
|
|
>
|
|
{/* Connector Line */}
|
|
{index < items.length - 1 && (
|
|
<div className="absolute top-5 left-[50%] w-[calc(100%+2rem)] h-[2px] bg-border" />
|
|
)}
|
|
|
|
{/* Milestone */}
|
|
<div
|
|
className={cn(
|
|
"relative z-10 flex h-10 w-10 items-center justify-center rounded-full border-2 border-background shadow-sm",
|
|
getStatusColor(item.status)
|
|
)}
|
|
>
|
|
{item.icon || getStatusIcon(item.status) || (
|
|
<Circle className="h-3 w-3" />
|
|
)}
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="mt-4 text-center">
|
|
{(item.date || item.time) && (
|
|
<div className="mb-1 text-xs text-muted-foreground">
|
|
{item.date}
|
|
{item.date && item.time && " • "}
|
|
{item.time}
|
|
</div>
|
|
)}
|
|
<h3 className="font-semibold">{item.title}</h3>
|
|
{item.description && (
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
|
{item.description}
|
|
</p>
|
|
)}
|
|
{item.content && <div className="mt-3">{item.content}</div>}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Vertical orientation
|
|
if (variant === "alternate") {
|
|
return (
|
|
<div ref={ref} className={cn("relative", className)} {...props}>
|
|
{items.map((item, index) => (
|
|
<div
|
|
key={item.id}
|
|
data-timeline-id={item.id}
|
|
className={cn(
|
|
"relative flex items-start mb-8",
|
|
index % 2 === 0 ? "justify-start" : "justify-end",
|
|
animated && !visibleItems.has(item.id) && "opacity-0",
|
|
animated && visibleItems.has(item.id) && "opacity-100",
|
|
animated &&
|
|
!visibleItems.has(item.id) &&
|
|
(index % 2 === 0 ? "-translate-x-8" : "translate-x-8"),
|
|
animated && visibleItems.has(item.id) && "translate-x-0",
|
|
"transition-all duration-500 ease-out"
|
|
)}
|
|
>
|
|
{/* Center line */}
|
|
<div className="absolute left-1/2 -translate-x-1/2 top-5 bottom-0 w-[2px] bg-border" />
|
|
|
|
{/* Content */}
|
|
<div
|
|
className={cn(
|
|
"relative w-[calc(50%-2rem)]",
|
|
index % 2 === 0 ? "pr-8 text-right" : "pl-8 text-left"
|
|
)}
|
|
>
|
|
{/* Milestone */}
|
|
<div
|
|
className={cn(
|
|
"absolute top-0",
|
|
index % 2 === 0 ? "right-0" : "left-0",
|
|
"flex h-10 w-10 items-center justify-center rounded-full border-2 border-background shadow-sm",
|
|
getStatusColor(item.status)
|
|
)}
|
|
>
|
|
{item.icon || getStatusIcon(item.status) || (
|
|
<Circle className="h-3 w-3" />
|
|
)}
|
|
</div>
|
|
|
|
{/* Card */}
|
|
<div
|
|
className={cn(
|
|
"rounded-lg border bg-card p-4 shadow-sm",
|
|
index % 2 === 0 ? "mr-12" : "ml-12"
|
|
)}
|
|
>
|
|
{(item.date || item.time) && (
|
|
<div className="mb-2 text-xs text-muted-foreground">
|
|
{item.date}
|
|
{item.date && item.time && " • "}
|
|
{item.time}
|
|
</div>
|
|
)}
|
|
<h3 className="font-semibold">{item.title}</h3>
|
|
{item.description && (
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
|
{item.description}
|
|
</p>
|
|
)}
|
|
{item.content && <div className="mt-3">{item.content}</div>}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (variant === "compact") {
|
|
return (
|
|
<div ref={ref} className={cn("relative", className)} {...props}>
|
|
<div className="space-y-2">
|
|
{items.map((item, index) => (
|
|
<div
|
|
key={item.id}
|
|
data-timeline-id={item.id}
|
|
className={cn(
|
|
"relative flex items-center gap-4 p-2 rounded-lg hover:bg-accent transition-colors",
|
|
animated &&
|
|
!visibleItems.has(item.id) &&
|
|
"opacity-0 translate-x-4",
|
|
animated &&
|
|
visibleItems.has(item.id) &&
|
|
"opacity-100 translate-x-0",
|
|
"transition-all duration-500 ease-out",
|
|
`transition-delay-[${index * 50}ms]`
|
|
)}
|
|
>
|
|
{/* Connector */}
|
|
{index < items.length - 1 && (
|
|
<div className="absolute left-5 top-10 bottom-0 w-[2px] bg-border" />
|
|
)}
|
|
|
|
{/* Milestone */}
|
|
<div
|
|
className={cn(
|
|
"relative z-10 flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full",
|
|
getStatusColor(item.status)
|
|
)}
|
|
>
|
|
{getStatusIcon(item.status) || <Circle className="h-2 w-2" />}
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="flex-1 flex items-center justify-between">
|
|
<div>
|
|
<span className="font-medium text-sm">{item.title}</span>
|
|
{item.description && (
|
|
<span className="ml-2 text-sm text-muted-foreground">
|
|
{item.description}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{(item.date || item.time) && (
|
|
<div className="text-xs text-muted-foreground">
|
|
{item.date} {item.time}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (variant === "simple") {
|
|
return (
|
|
<div ref={ref} className={cn("relative", className)} {...props}>
|
|
<div className="border-l-2 border-border pl-6 space-y-6">
|
|
{items.map((item, index) => (
|
|
<div
|
|
key={item.id}
|
|
data-timeline-id={item.id}
|
|
className={cn(
|
|
"relative",
|
|
animated &&
|
|
!visibleItems.has(item.id) &&
|
|
"opacity-0 translate-x-4",
|
|
animated &&
|
|
visibleItems.has(item.id) &&
|
|
"opacity-100 translate-x-0",
|
|
"transition-all duration-500 ease-out"
|
|
)}
|
|
>
|
|
{/* Milestone */}
|
|
<div
|
|
className={cn(
|
|
"absolute -left-[1.75rem] flex h-4 w-4 items-center justify-center rounded-full border-2 border-background",
|
|
getStatusColor(item.status)
|
|
)}
|
|
>
|
|
{item.status === "completed" && <Check className="h-2 w-2" />}
|
|
{item.status === "active" && (
|
|
<div className="h-2 w-2 rounded-full bg-white animate-pulse" />
|
|
)}
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div>
|
|
{(item.date || item.time) && (
|
|
<div className="mb-1 text-xs text-muted-foreground">
|
|
{item.date}
|
|
{item.date && item.time && " • "}
|
|
{item.time}
|
|
</div>
|
|
)}
|
|
<h3 className="font-semibold">{item.title}</h3>
|
|
{item.description && (
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
|
{item.description}
|
|
</p>
|
|
)}
|
|
{item.content && <div className="mt-3">{item.content}</div>}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Default variant
|
|
return (
|
|
<div ref={ref} className={cn("relative", className)} {...props}>
|
|
<div className="space-y-8">
|
|
{items.map((item, index) => (
|
|
<div
|
|
key={item.id}
|
|
data-timeline-id={item.id}
|
|
className={cn(
|
|
"relative flex gap-6",
|
|
animated &&
|
|
!visibleItems.has(item.id) &&
|
|
"opacity-0 translate-x-8",
|
|
animated &&
|
|
visibleItems.has(item.id) &&
|
|
"opacity-100 translate-x-0",
|
|
"transition-all duration-500 ease-out"
|
|
)}
|
|
>
|
|
{/* Connector Line */}
|
|
{index < items.length - 1 && (
|
|
<div className="absolute left-5 top-10 bottom-0 w-[2px] bg-border" />
|
|
)}
|
|
|
|
{/* Milestone */}
|
|
<div
|
|
className={cn(
|
|
"relative z-10 flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full border-2 border-background shadow-sm",
|
|
getStatusColor(item.status)
|
|
)}
|
|
>
|
|
{item.icon || getStatusIcon(item.status) || (
|
|
<Circle className="h-3 w-3" />
|
|
)}
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="flex-1 pb-8">
|
|
<div className="rounded-lg border bg-card p-4 shadow-sm">
|
|
{(item.date || item.time) && (
|
|
<div className="mb-2 text-xs text-muted-foreground">
|
|
{item.date}
|
|
{item.date && item.time && " • "}
|
|
{item.time}
|
|
</div>
|
|
)}
|
|
<h3 className="font-semibold">{item.title}</h3>
|
|
{item.description && (
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
|
{item.description}
|
|
</p>
|
|
)}
|
|
{item.content && <div className="mt-3">{item.content}</div>}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
)
|
|
|
|
Timeline.displayName = "Timeline"
|
|
|
|
export { Timeline, type TimelineItem, type TimelineProps }
|