@stampui/blocks 1.1.0 → 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.
- package/dist/manifests.js +917 -170
- package/package.json +15 -10
- package/src/components/blocks/animated-counter.tsx +70 -0
- package/src/components/blocks/changelog-feed.tsx +3 -3
- package/src/components/blocks/gradient-text.tsx +39 -0
- package/src/components/blocks/grid-wave.tsx +40 -0
- package/src/components/blocks/loading-card.tsx +48 -0
- package/src/components/blocks/loading-dots.tsx +68 -0
- package/src/components/blocks/orbit-trail.tsx +30 -0
- package/src/components/blocks/progress-ring.tsx +72 -0
- package/src/components/blocks/registry-card.tsx +9 -10
- package/src/components/blocks/signal-arc.tsx +32 -0
- package/src/components/blocks/typewriter-text.tsx +62 -0
- package/src/components/blocks/waitlist-section.tsx +1 -1
- package/src/components/core/alert-dialog.tsx +2 -2
- package/src/components/core/avatar.tsx +8 -4
- package/src/components/core/button.tsx +1 -1
- package/src/components/core/checkbox.tsx +1 -1
- package/src/components/core/combobox.tsx +1 -1
- package/src/components/core/command.tsx +7 -4
- package/src/components/core/date-picker.tsx +1 -1
- package/src/components/core/dialog.tsx +1 -1
- package/src/components/core/drawer.tsx +1 -1
- package/src/components/core/input.tsx +2 -0
- package/src/components/core/label.tsx +1 -1
- package/src/components/core/multi-select.tsx +1 -1
- package/src/components/core/native-select.tsx +1 -1
- package/src/components/core/password-input.tsx +3 -0
- package/src/components/core/radio-group.tsx +1 -1
- package/src/components/core/resizable.tsx +1 -1
- package/src/components/core/select.tsx +1 -1
- package/src/components/core/sheet.tsx +1 -1
- package/src/components/core/slider.tsx +1 -1
- package/src/components/core/status-pulse.tsx +6 -0
- package/src/components/core/switch.tsx +1 -1
- package/src/components/core/table.tsx +7 -2
- package/src/components/core/tabs.tsx +1 -1
- package/src/components/core/toggle.tsx +1 -1
- package/src/components/core/typing-indicator.tsx +41 -27
- package/src/manifests.ts +932 -183
- package/src/components/blocks/ai-chat-shell.tsx +0 -97
- package/src/components/blocks/auth-panel.tsx +0 -203
- package/src/components/blocks/dashboard-shell.tsx +0 -135
- package/src/components/blocks/notification-center.tsx +0 -185
- package/src/components/blocks/onboarding-flow.tsx +0 -230
- package/src/components/blocks/project-command-center.tsx +0 -188
- package/src/components/blocks/prompt-input.tsx +0 -81
- package/src/components/blocks/settings-layout.tsx +0 -178
- package/src/components/blocks/token-stream.tsx +0 -42
- package/src/components/core/carousel.tsx +0 -170
- package/src/components/core/chart.tsx +0 -377
- package/src/components/core/data-table.tsx +0 -173
- package/src/components/core/file-upload.tsx +0 -143
- package/src/components/core/input-otp.tsx +0 -108
- package/src/components/core/stepper.tsx +0 -111
- 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
|
-
}
|