Timeline

Ecsight

Activity feed / order history with relative Thai time.

Motion: Items stagger in.

  1. จัดส่งแล้ว

    Kerry Express · TH0123456789

  2. แพ็กสินค้า

    คลังบางนา

  3. ชำระเงินแล้ว

    PromptPay ฿3,920

  4. สร้างคำสั่งซื้อ

    ผ่าน LINE OA

ติดตั้ง

npx shadcn@latest add @ecsight/timeline

Source

components/ui/timeline.tsx
"use client"

import { motion } from "motion/react"
import { cn } from "cn"

import { formatDate, formatRelative, formatTime } from "@/lib/format"
import { fadeUp, listStagger } from "@/lib/motion"
import type { StatusTone } from "@/components/ui/status-badge"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"

export type TimelineItem = {
  id: string
  title: React.ReactNode
  description?: React.ReactNode
  time: Date
  /** A Solar icon element, e.g. `<DeliveryIcon />`; defaults to a dot */
  icon?: React.ReactNode
  tone?: StatusTone
}

const toneClass: Record<StatusTone, string> = {
  success: "bg-success/10 text-success",
  warning: "bg-warning/15 text-foreground",
  info: "bg-primary/10 text-primary",
  danger: "bg-destructive/10 text-destructive",
  neutral: "bg-muted text-muted-foreground",
}

/**
 * Activity feed / order history, newest first. Relative time ("3 นาทีที่ผ่านมา")
 * with the exact date-time in the tooltip and `<time dateTime>`. Items stagger in.
 */
function Timeline({ items, now, className }: { items: TimelineItem[]; now?: Date; className?: string }) {
  return (
    <motion.ol
      data-slot="timeline"
      variants={listStagger}
      initial="hidden"
      animate="visible"
      className={cn("grid", className)}
    >
      {items.map((item, i) => (
        <motion.li key={item.id} variants={fadeUp} className="relative flex gap-3 pb-5 last:pb-0">
          {/* connector to the next item */}
          {i < items.length - 1 && (
            <span aria-hidden className="absolute top-8 bottom-1 left-[15px] w-px bg-border" />
          )}
          <span
            className={cn(
              "relative grid size-8 shrink-0 place-items-center rounded-full [&_svg]:size-4",
              toneClass[item.tone ?? "neutral"]
            )}
          >
            {item.icon ?? <span className="size-2 rounded-full bg-current" />}
          </span>
          <div className="grid min-w-0 flex-1 gap-0.5 pt-1.5 text-sm">
            <div className="flex items-baseline justify-between gap-3">
              <span className="min-w-0 font-medium">{item.title}</span>
              {/* exact date-time in the system Tooltip (not the browser's title tooltip); a button so keyboards reach it */}
              <Tooltip>
                <TooltipTrigger asChild>
                  <button
                    type="button"
                    className="shrink-0 rounded-sm text-xs text-muted-foreground tabular-nums outline-none hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50"
                  >
                    <time dateTime={item.time.toISOString()}>{formatRelative(item.time, now)}</time>
                  </button>
                </TooltipTrigger>
                <TooltipContent>
                  {formatDate(item.time, { style: "long" })} {formatTime(item.time)} น.
                </TooltipContent>
              </Tooltip>
            </div>
            {item.description && <p className="text-muted-foreground">{item.description}</p>}
          </div>
        </motion.li>
      ))}
    </motion.ol>
  )
}

export { Timeline }