@stampui/blocks 1.1.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/manifests.js +917 -170
  2. package/package.json +15 -10
  3. package/src/components/blocks/animated-counter.tsx +70 -0
  4. package/src/components/blocks/gradient-text.tsx +39 -0
  5. package/src/components/blocks/grid-wave.tsx +40 -0
  6. package/src/components/blocks/loading-card.tsx +48 -0
  7. package/src/components/blocks/loading-dots.tsx +68 -0
  8. package/src/components/blocks/orbit-trail.tsx +30 -0
  9. package/src/components/blocks/progress-ring.tsx +72 -0
  10. package/src/components/blocks/registry-card.tsx +6 -7
  11. package/src/components/blocks/signal-arc.tsx +32 -0
  12. package/src/components/blocks/typewriter-text.tsx +62 -0
  13. package/src/components/core/alert-dialog.tsx +2 -2
  14. package/src/components/core/avatar.tsx +8 -4
  15. package/src/components/core/button.tsx +1 -1
  16. package/src/components/core/checkbox.tsx +1 -1
  17. package/src/components/core/combobox.tsx +1 -1
  18. package/src/components/core/command.tsx +7 -4
  19. package/src/components/core/date-picker.tsx +1 -1
  20. package/src/components/core/dialog.tsx +1 -1
  21. package/src/components/core/drawer.tsx +1 -1
  22. package/src/components/core/input.tsx +2 -0
  23. package/src/components/core/label.tsx +1 -1
  24. package/src/components/core/multi-select.tsx +1 -1
  25. package/src/components/core/native-select.tsx +1 -1
  26. package/src/components/core/password-input.tsx +3 -0
  27. package/src/components/core/radio-group.tsx +1 -1
  28. package/src/components/core/resizable.tsx +1 -1
  29. package/src/components/core/select.tsx +1 -1
  30. package/src/components/core/sheet.tsx +1 -1
  31. package/src/components/core/slider.tsx +1 -1
  32. package/src/components/core/status-pulse.tsx +6 -0
  33. package/src/components/core/switch.tsx +1 -1
  34. package/src/components/core/table.tsx +7 -2
  35. package/src/components/core/tabs.tsx +1 -1
  36. package/src/components/core/toggle.tsx +1 -1
  37. package/src/components/core/typing-indicator.tsx +41 -27
  38. package/src/manifests.ts +932 -183
  39. package/src/components/blocks/ai-chat-shell.tsx +0 -97
  40. package/src/components/blocks/auth-panel.tsx +0 -203
  41. package/src/components/blocks/dashboard-shell.tsx +0 -135
  42. package/src/components/blocks/notification-center.tsx +0 -185
  43. package/src/components/blocks/onboarding-flow.tsx +0 -230
  44. package/src/components/blocks/project-command-center.tsx +0 -188
  45. package/src/components/blocks/prompt-input.tsx +0 -81
  46. package/src/components/blocks/settings-layout.tsx +0 -178
  47. package/src/components/blocks/token-stream.tsx +0 -42
  48. package/src/components/core/carousel.tsx +0 -170
  49. package/src/components/core/chart.tsx +0 -377
  50. package/src/components/core/data-table.tsx +0 -173
  51. package/src/components/core/file-upload.tsx +0 -143
  52. package/src/components/core/input-otp.tsx +0 -108
  53. package/src/components/core/stepper.tsx +0 -111
  54. package/src/components/core/timeline.tsx +0 -81
@@ -1,173 +0,0 @@
1
- "use client"
2
-
3
- import * as React from "react"
4
- import { ChevronUp, ChevronDown, ChevronsUpDown, ChevronLeft, ChevronRight } from "lucide-react"
5
- import { cx } from "@/lib/cx"
6
-
7
- // ── Types ─────────────────────────────────────────────────────────────────────
8
-
9
- export type SortDirection = "asc" | "desc" | null
10
-
11
- export interface Column<T> {
12
- key: string
13
- header: string
14
- accessor: (row: T) => React.ReactNode
15
- sortable?: boolean
16
- className?: string
17
- headerClassName?: string
18
- }
19
-
20
- export interface DataTableProps<T> {
21
- columns: Column<T>[]
22
- data: T[]
23
- rowKey?: (row: T, index: number) => string
24
- pageSize?: number
25
- searchable?: boolean
26
- searchPlaceholder?: string
27
- searchFn?: (row: T, query: string) => boolean
28
- className?: string
29
- emptyMessage?: string
30
- }
31
-
32
- // ── Component ─────────────────────────────────────────────────────────────────
33
-
34
- export function DataTable<T>({
35
- columns,
36
- data,
37
- rowKey,
38
- pageSize = 10,
39
- searchable = false,
40
- searchPlaceholder = "Search...",
41
- searchFn,
42
- className,
43
- emptyMessage = "No results.",
44
- }: DataTableProps<T>) {
45
- const [sortKey, setSortKey] = React.useState<string | null>(null)
46
- const [sortDir, setSortDir] = React.useState<SortDirection>(null)
47
- const [query, setQuery] = React.useState("")
48
- const [page, setPage] = React.useState(0)
49
-
50
- const filtered = React.useMemo(() => {
51
- if (!query || !searchable) return data
52
- return data.filter((row) =>
53
- searchFn
54
- ? searchFn(row, query)
55
- : columns.some((col) => String(col.accessor(row)).toLowerCase().includes(query.toLowerCase()))
56
- )
57
- }, [data, query, searchable, searchFn, columns])
58
-
59
- const sorted = React.useMemo(() => {
60
- if (!sortKey || !sortDir) return filtered
61
- const col = columns.find((c) => c.key === sortKey)
62
- if (!col) return filtered
63
- return [...filtered].sort((a, b) => {
64
- const av = String(col.accessor(a))
65
- const bv = String(col.accessor(b))
66
- const cmp = av.localeCompare(bv, undefined, { numeric: true })
67
- return sortDir === "asc" ? cmp : -cmp
68
- })
69
- }, [filtered, sortKey, sortDir, columns])
70
-
71
- const totalPages = Math.ceil(sorted.length / pageSize)
72
- const paged = sorted.slice(page * pageSize, (page + 1) * pageSize)
73
-
74
- React.useEffect(() => { setPage(0) }, [query, sortKey, sortDir])
75
-
76
- function toggleSort(key: string) {
77
- if (sortKey !== key) { setSortKey(key); setSortDir("asc") }
78
- else if (sortDir === "asc") setSortDir("desc")
79
- else { setSortKey(null); setSortDir(null) }
80
- }
81
-
82
- return (
83
- <div className={cx("space-y-3", className)}>
84
- {searchable && (
85
- <input
86
- value={query}
87
- onChange={(e) => setQuery(e.target.value)}
88
- placeholder={searchPlaceholder}
89
- className="h-9 w-full max-w-xs rounded-lg border border-border bg-surface-2 px-3 text-sm outline-none placeholder:text-muted-foreground focus-visible:border-border-strong focus-visible:ring-1 focus-visible:ring-border-strong focus-visible:ring-offset-1 focus-visible:ring-offset-background"
90
- />
91
- )}
92
- <div className="rounded-xl border border-border overflow-hidden">
93
- <div className="overflow-x-auto">
94
- <table className="w-full text-sm">
95
- <thead>
96
- <tr className="border-b border-border bg-surface-2">
97
- {columns.map((col) => (
98
- <th
99
- key={col.key}
100
- className={cx(
101
- "px-4 py-3 text-left text-xs font-medium text-muted-foreground",
102
- col.sortable && "cursor-pointer select-none hover:text-foreground transition-colors",
103
- col.headerClassName
104
- )}
105
- onClick={col.sortable ? () => toggleSort(col.key) : undefined}
106
- >
107
- <div className="flex items-center gap-1">
108
- {col.header}
109
- {col.sortable && (
110
- <span className="text-muted-foreground">
111
- {sortKey === col.key && sortDir === "asc" ? (
112
- <ChevronUp className="h-3.5 w-3.5" />
113
- ) : sortKey === col.key && sortDir === "desc" ? (
114
- <ChevronDown className="h-3.5 w-3.5" />
115
- ) : (
116
- <ChevronsUpDown className="h-3.5 w-3.5 opacity-40" />
117
- )}
118
- </span>
119
- )}
120
- </div>
121
- </th>
122
- ))}
123
- </tr>
124
- </thead>
125
- <tbody>
126
- {paged.length === 0 ? (
127
- <tr>
128
- <td colSpan={columns.length} className="px-4 py-10 text-center text-sm text-muted-foreground">
129
- {emptyMessage}
130
- </td>
131
- </tr>
132
- ) : (
133
- paged.map((row, i) => (
134
- <tr key={rowKey ? rowKey(row, i) : i} className="border-b border-border last:border-0 hover:bg-surface-2 transition-colors">
135
- {columns.map((col) => (
136
- <td key={col.key} className={cx("px-4 py-3", col.className)}>
137
- {col.accessor(row)}
138
- </td>
139
- ))}
140
- </tr>
141
- ))
142
- )}
143
- </tbody>
144
- </table>
145
- </div>
146
- </div>
147
- {totalPages > 1 && (
148
- <div className="flex items-center justify-between text-xs text-muted-foreground">
149
- <span>{sorted.length} row{sorted.length !== 1 ? "s" : ""}</span>
150
- <div className="flex items-center gap-1">
151
- <button
152
- onClick={() => setPage((p) => Math.max(0, p - 1))}
153
- disabled={page === 0}
154
- className="flex h-7 w-7 items-center justify-center rounded-md border border-border hover:bg-surface-2 disabled:opacity-40 disabled:pointer-events-none transition-colors"
155
- >
156
- <ChevronLeft className="h-3.5 w-3.5" />
157
- </button>
158
- <span className="px-2 font-medium text-foreground">
159
- {page + 1} / {totalPages}
160
- </span>
161
- <button
162
- onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
163
- disabled={page >= totalPages - 1}
164
- className="flex h-7 w-7 items-center justify-center rounded-md border border-border hover:bg-surface-2 disabled:opacity-40 disabled:pointer-events-none transition-colors"
165
- >
166
- <ChevronRight className="h-3.5 w-3.5" />
167
- </button>
168
- </div>
169
- </div>
170
- )}
171
- </div>
172
- )
173
- }
@@ -1,143 +0,0 @@
1
- "use client"
2
-
3
- import * as React from "react"
4
- import { Upload, X, FileText, Image, Film, Music } from "lucide-react"
5
- import { cx } from "@/lib/cx"
6
-
7
- export interface FileUploadFile {
8
- file: File
9
- id: string
10
- preview?: string
11
- }
12
-
13
- export interface FileUploadProps {
14
- accept?: string
15
- multiple?: boolean
16
- maxSize?: number
17
- disabled?: boolean
18
- files?: FileUploadFile[]
19
- onFilesChange?: (files: FileUploadFile[]) => void
20
- className?: string
21
- label?: string
22
- hint?: string
23
- }
24
-
25
- function getFileIcon(type: string) {
26
- if (type.startsWith("image/")) return <Image className="h-4 w-4" />
27
- if (type.startsWith("video/")) return <Film className="h-4 w-4" />
28
- if (type.startsWith("audio/")) return <Music className="h-4 w-4" />
29
- return <FileText className="h-4 w-4" />
30
- }
31
-
32
- function formatBytes(bytes: number) {
33
- if (bytes < 1024) return `${bytes} B`
34
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
35
- return `${(bytes / 1024 / 1024).toFixed(1)} MB`
36
- }
37
-
38
- export function FileUpload({
39
- accept,
40
- multiple = false,
41
- maxSize,
42
- disabled,
43
- files = [],
44
- onFilesChange,
45
- className,
46
- label = "Click to upload or drag and drop",
47
- hint,
48
- }: FileUploadProps) {
49
- const inputRef = React.useRef<HTMLInputElement>(null)
50
- const [dragging, setDragging] = React.useState(false)
51
-
52
- function addFiles(incoming: FileList | File[]) {
53
- const arr = Array.from(incoming)
54
- const valid = arr.filter((f) => !maxSize || f.size <= maxSize)
55
- const newFiles: FileUploadFile[] = valid.map((f) => ({
56
- file: f,
57
- id: `${f.name}-${f.size}-${Date.now()}`,
58
- preview: f.type.startsWith("image/") ? URL.createObjectURL(f) : undefined,
59
- }))
60
- onFilesChange?.(multiple ? [...files, ...newFiles] : newFiles)
61
- }
62
-
63
- function remove(id: string) {
64
- onFilesChange?.(files.filter((f) => f.id !== id))
65
- }
66
-
67
- function onDragOver(e: React.DragEvent) {
68
- e.preventDefault()
69
- if (!disabled) setDragging(true)
70
- }
71
-
72
- function onDrop(e: React.DragEvent) {
73
- e.preventDefault()
74
- setDragging(false)
75
- if (!disabled && e.dataTransfer.files.length) addFiles(e.dataTransfer.files)
76
- }
77
-
78
- return (
79
- <div className={cx("space-y-3", className)}>
80
- <div
81
- onClick={() => !disabled && inputRef.current?.click()}
82
- onDragOver={onDragOver}
83
- onDragLeave={() => setDragging(false)}
84
- onDrop={onDrop}
85
- className={cx(
86
- "relative flex cursor-pointer flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed px-6 py-8 text-center transition-colors",
87
- dragging
88
- ? "border-ring bg-surface-2"
89
- : "border-border hover:border-border-strong hover:bg-surface-2",
90
- disabled && "cursor-not-allowed opacity-50"
91
- )}
92
- >
93
- <div className="flex h-10 w-10 items-center justify-center rounded-xl border border-border bg-surface">
94
- <Upload className="h-5 w-5 text-muted-foreground" />
95
- </div>
96
- <div className="space-y-1">
97
- <p className="text-sm font-medium text-foreground">{label}</p>
98
- {hint && <p className="text-xs text-muted-foreground">{hint}</p>}
99
- {maxSize && (
100
- <p className="text-xs text-muted-foreground">Max {formatBytes(maxSize)}</p>
101
- )}
102
- </div>
103
- <input
104
- ref={inputRef}
105
- type="file"
106
- accept={accept}
107
- multiple={multiple}
108
- disabled={disabled}
109
- className="sr-only"
110
- onChange={(e) => e.target.files && addFiles(e.target.files)}
111
- />
112
- </div>
113
-
114
- {files.length > 0 && (
115
- <ul className="space-y-2">
116
- {files.map(({ file, id, preview }) => (
117
- <li key={id} className="flex items-center gap-3 rounded-lg border border-border bg-surface-2 px-3 py-2.5">
118
- {preview ? (
119
- // eslint-disable-next-line @next/next/no-img-element
120
- <img src={preview} alt={file.name} className="h-8 w-8 rounded-md object-cover shrink-0 border border-border" />
121
- ) : (
122
- <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-border bg-surface text-muted-foreground">
123
- {getFileIcon(file.type)}
124
- </div>
125
- )}
126
- <div className="min-w-0 flex-1">
127
- <p className="truncate text-sm font-medium text-foreground">{file.name}</p>
128
- <p className="text-xs text-muted-foreground">{formatBytes(file.size)}</p>
129
- </div>
130
- <button
131
- type="button"
132
- onClick={() => remove(id)}
133
- className="shrink-0 rounded-md p-1 text-muted-foreground hover:bg-surface-3 hover:text-foreground transition-colors"
134
- >
135
- <X className="h-3.5 w-3.5" />
136
- </button>
137
- </li>
138
- ))}
139
- </ul>
140
- )}
141
- </div>
142
- )
143
- }
@@ -1,108 +0,0 @@
1
- "use client"
2
-
3
- import * as React from "react"
4
- import { cx } from "@/lib/cx"
5
-
6
- export interface InputOTPProps {
7
- length?: number
8
- value?: string
9
- onChange?: (value: string) => void
10
- disabled?: boolean
11
- className?: string
12
- inputClassName?: string
13
- type?: "text" | "password" | "number"
14
- pattern?: "numeric" | "alphanumeric" | "any"
15
- }
16
-
17
- export function InputOTP({
18
- length = 6,
19
- value = "",
20
- onChange,
21
- disabled,
22
- className,
23
- inputClassName,
24
- pattern = "numeric",
25
- }: InputOTPProps) {
26
- const inputs = React.useRef<(HTMLInputElement | null)[]>([])
27
-
28
- const chars = Array.from({ length }, (_, i) => value[i] ?? "")
29
-
30
- function isAllowed(char: string) {
31
- if (pattern === "numeric") return /^\d$/.test(char)
32
- if (pattern === "alphanumeric") return /^[a-zA-Z0-9]$/.test(char)
33
- return char.length === 1
34
- }
35
-
36
- function handleKeyDown(i: number, e: React.KeyboardEvent<HTMLInputElement>) {
37
- if (e.key === "Backspace") {
38
- e.preventDefault()
39
- if (chars[i]) {
40
- const next = [...chars]
41
- next[i] = ""
42
- onChange?.(next.join(""))
43
- } else if (i > 0) {
44
- inputs.current[i - 1]?.focus()
45
- const next = [...chars]
46
- next[i - 1] = ""
47
- onChange?.(next.join(""))
48
- }
49
- } else if (e.key === "ArrowLeft" && i > 0) {
50
- e.preventDefault()
51
- inputs.current[i - 1]?.focus()
52
- } else if (e.key === "ArrowRight" && i < length - 1) {
53
- e.preventDefault()
54
- inputs.current[i + 1]?.focus()
55
- }
56
- }
57
-
58
- function handleInput(i: number, e: React.FormEvent<HTMLInputElement>) {
59
- const val = e.currentTarget.value.slice(-1)
60
- if (!val) return
61
- if (!isAllowed(val)) return
62
- const next = [...chars]
63
- next[i] = val
64
- onChange?.(next.join(""))
65
- if (i < length - 1) inputs.current[i + 1]?.focus()
66
- }
67
-
68
- function handlePaste(e: React.ClipboardEvent) {
69
- e.preventDefault()
70
- const pasted = e.clipboardData.getData("text").slice(0, length)
71
- const allowed = [...pasted].filter(isAllowed)
72
- const next = Array.from({ length }, (_, i) => allowed[i] ?? "")
73
- onChange?.(next.join(""))
74
- const lastFilled = Math.min(allowed.length, length - 1)
75
- inputs.current[lastFilled]?.focus()
76
- }
77
-
78
- return (
79
- <div className={cx("flex items-center gap-2", className)} onPaste={handlePaste}>
80
- {chars.map((char, i) => (
81
- <React.Fragment key={i}>
82
- {i === length / 2 && length % 2 === 0 && length > 4 && (
83
- <span className="text-muted-foreground">–</span>
84
- )}
85
- <input
86
- ref={(el) => { inputs.current[i] = el }}
87
- type="text"
88
- inputMode={pattern === "numeric" ? "numeric" : "text"}
89
- maxLength={1}
90
- value={char}
91
- disabled={disabled}
92
- onKeyDown={(e) => handleKeyDown(i, e)}
93
- onInput={(e) => handleInput(i, e)}
94
- onChange={() => {}}
95
- className={cx(
96
- "h-10 w-10 rounded-lg border border-border bg-surface-2 text-center text-sm font-medium outline-none transition-colors caret-transparent",
97
- "hover:border-border-strong",
98
- "focus-visible:ring-1 focus-visible:ring-border-strong focus-visible:ring-offset-1 focus-visible:ring-offset-background",
99
- "disabled:cursor-not-allowed disabled:opacity-50",
100
- char && "border-border-strong",
101
- inputClassName
102
- )}
103
- />
104
- </React.Fragment>
105
- ))}
106
- </div>
107
- )
108
- }
@@ -1,111 +0,0 @@
1
- import * as React from "react"
2
- import { Check, X } from "lucide-react"
3
- import { cx } from "@/lib/cx"
4
-
5
- export type StepStatus = "pending" | "active" | "completed" | "error"
6
- export type StepperOrientation = "horizontal" | "vertical"
7
-
8
- export interface Step {
9
- title: string
10
- description?: string
11
- icon?: React.ReactNode
12
- }
13
-
14
- export interface StepperProps {
15
- steps: Step[]
16
- currentStep: number
17
- orientation?: StepperOrientation
18
- className?: string
19
- }
20
-
21
- function StepIcon({ status, index, icon }: { status: StepStatus; index: number; icon?: React.ReactNode }) {
22
- if (status === "completed") return <Check className="h-3.5 w-3.5" />
23
- if (status === "error") return <X className="h-3.5 w-3.5" />
24
- if (icon && status === "active") return <span className="text-xs">{icon}</span>
25
- return <span className="text-xs font-medium">{index + 1}</span>
26
- }
27
-
28
- export function Stepper({ steps, currentStep, orientation = "horizontal", className }: StepperProps) {
29
- const isHorizontal = orientation === "horizontal"
30
-
31
- function getStatus(index: number): StepStatus {
32
- if (index < currentStep) return "completed"
33
- if (index === currentStep) return "active"
34
- return "pending"
35
- }
36
-
37
- if (isHorizontal) {
38
- return (
39
- <div className={cx("flex items-start w-full", className)}>
40
- {steps.map((step, i) => {
41
- const status = getStatus(i)
42
- const isLast = i === steps.length - 1
43
- return (
44
- <React.Fragment key={i}>
45
- <div className="flex flex-col items-center flex-1 min-w-0">
46
- <div className={cx(
47
- "flex h-8 w-8 shrink-0 items-center justify-center rounded-full border-2 transition-colors",
48
- status === "completed" && "border-foreground bg-foreground text-background",
49
- status === "active" && "border-foreground bg-transparent text-foreground",
50
- status === "pending" && "border-border bg-transparent text-muted-foreground",
51
- status === "error" && "border-danger bg-danger/10 text-danger",
52
- )}>
53
- <StepIcon status={status} index={i} icon={step.icon} />
54
- </div>
55
- <div className="mt-2 text-center px-1">
56
- <p className={cx("text-xs font-medium leading-tight", status === "pending" ? "text-muted-foreground" : "text-foreground")}>
57
- {step.title}
58
- </p>
59
- {step.description && (
60
- <p className="text-xs text-muted-foreground mt-0.5 leading-snug">{step.description}</p>
61
- )}
62
- </div>
63
- </div>
64
- {!isLast && (
65
- <div className={cx(
66
- "h-0.5 flex-1 mt-4 mx-2 rounded-full transition-colors",
67
- i < currentStep ? "bg-foreground" : "bg-border"
68
- )} />
69
- )}
70
- </React.Fragment>
71
- )
72
- })}
73
- </div>
74
- )
75
- }
76
-
77
- return (
78
- <div className={cx("flex flex-col", className)}>
79
- {steps.map((step, i) => {
80
- const status = getStatus(i)
81
- const isLast = i === steps.length - 1
82
- return (
83
- <div key={i} className="flex gap-4">
84
- <div className="flex flex-col items-center">
85
- <div className={cx(
86
- "flex h-8 w-8 shrink-0 items-center justify-center rounded-full border-2 transition-colors",
87
- status === "completed" && "border-foreground bg-foreground text-background",
88
- status === "active" && "border-foreground bg-transparent text-foreground",
89
- status === "pending" && "border-border bg-transparent text-muted-foreground",
90
- status === "error" && "border-danger bg-danger/10 text-danger",
91
- )}>
92
- <StepIcon status={status} index={i} icon={step.icon} />
93
- </div>
94
- {!isLast && (
95
- <div className={cx("w-0.5 flex-1 my-1 rounded-full transition-colors min-h-[24px]", i < currentStep ? "bg-foreground" : "bg-border")} />
96
- )}
97
- </div>
98
- <div className={cx("pb-6 min-w-0", isLast && "pb-0")}>
99
- <p className={cx("text-sm font-medium leading-tight mt-1.5", status === "pending" ? "text-muted-foreground" : "text-foreground")}>
100
- {step.title}
101
- </p>
102
- {step.description && (
103
- <p className="text-xs text-muted-foreground mt-0.5 leading-relaxed">{step.description}</p>
104
- )}
105
- </div>
106
- </div>
107
- )
108
- })}
109
- </div>
110
- )
111
- }
@@ -1,81 +0,0 @@
1
- import * as React from "react"
2
- import { cx } from "@/lib/cx"
3
-
4
- // ── Types ─────────────────────────────────────────────────────────────────────
5
-
6
- export interface TimelineItem {
7
- title: string
8
- description?: string
9
- date?: string
10
- icon?: React.ReactNode
11
- dot?: "default" | "filled" | "outline" | "ring"
12
- children?: React.ReactNode
13
- }
14
-
15
- export interface TimelineProps {
16
- items: TimelineItem[]
17
- className?: string
18
- }
19
-
20
- // ── Timeline ──────────────────────────────────────────────────────────────────
21
-
22
- export function Timeline({ items, className }: TimelineProps) {
23
- return (
24
- <div className={cx("relative", className)}>
25
- {items.map((item, i) => {
26
- const isLast = i === items.length - 1
27
- return (
28
- <div key={i} className="flex gap-4">
29
- {/* Left column: dot + connector */}
30
- <div className="flex flex-col items-center">
31
- <TimelineDot dot={item.dot} icon={item.icon} />
32
- {!isLast && <div className="w-px flex-1 bg-border my-1" />}
33
- </div>
34
- {/* Content */}
35
- <div className={cx("pb-7 min-w-0 flex-1", isLast && "pb-0")}>
36
- <div className="flex items-baseline justify-between gap-3 mb-0.5">
37
- <p className="text-sm font-medium text-foreground leading-snug">{item.title}</p>
38
- {item.date && <span className="shrink-0 text-xs text-muted-foreground">{item.date}</span>}
39
- </div>
40
- {item.description && (
41
- <p className="text-sm text-muted-foreground leading-relaxed">{item.description}</p>
42
- )}
43
- {item.children && <div className="mt-2">{item.children}</div>}
44
- </div>
45
- </div>
46
- )
47
- })}
48
- </div>
49
- )
50
- }
51
-
52
- // ── Dot ───────────────────────────────────────────────────────────────────────
53
-
54
- function TimelineDot({ dot = "default", icon }: { dot?: TimelineItem["dot"]; icon?: React.ReactNode }) {
55
- if (icon) {
56
- return (
57
- <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-border bg-surface-2 text-muted-foreground">
58
- {icon}
59
- </div>
60
- )
61
- }
62
- return (
63
- <div className={cx(
64
- "mt-1.5 h-3 w-3 shrink-0 rounded-full transition-colors",
65
- dot === "default" && "bg-border-strong",
66
- dot === "filled" && "bg-foreground",
67
- dot === "outline" && "border-2 border-foreground bg-transparent",
68
- dot === "ring" && "border-2 border-foreground bg-card ring-4 ring-surface-2",
69
- )} />
70
- )
71
- }
72
-
73
- // ── Sub-exports for composing custom content ──────────────────────────────────
74
-
75
- export function TimelineCard({ children, className }: { children: React.ReactNode; className?: string }) {
76
- return (
77
- <div className={cx("rounded-xl border border-border bg-surface-2 px-4 py-3 text-sm", className)}>
78
- {children}
79
- </div>
80
- )
81
- }