azamat-ui-kit-cli 0.2.2

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 (213) hide show
  1. package/README.md +8 -0
  2. package/dist/index.js +432 -0
  3. package/package.json +34 -0
  4. package/vendor/package.json +4 -0
  5. package/vendor/src/components/actions/action-bar.tsx +35 -0
  6. package/vendor/src/components/actions/action-menu.tsx +120 -0
  7. package/vendor/src/components/actions/button-group.tsx +47 -0
  8. package/vendor/src/components/actions/copy-button.tsx +91 -0
  9. package/vendor/src/components/actions/copy-field.tsx +31 -0
  10. package/vendor/src/components/actions/floating-action-button.tsx +33 -0
  11. package/vendor/src/components/actions/index.ts +7 -0
  12. package/vendor/src/components/actions/public.ts +5 -0
  13. package/vendor/src/components/actions/quick-action-grid.tsx +162 -0
  14. package/vendor/src/components/calendar/calendar.tsx +328 -0
  15. package/vendor/src/components/calendar/date-picker.tsx +78 -0
  16. package/vendor/src/components/calendar/date-range-picker.tsx +96 -0
  17. package/vendor/src/components/calendar/date-utils.ts +89 -0
  18. package/vendor/src/components/calendar/index.ts +4 -0
  19. package/vendor/src/components/charts/charts.tsx +275 -0
  20. package/vendor/src/components/charts/horizontal-bar-chart.tsx +46 -0
  21. package/vendor/src/components/charts/index.ts +4 -0
  22. package/vendor/src/components/charts/kpi.tsx +68 -0
  23. package/vendor/src/components/charts/progress-ring.tsx +45 -0
  24. package/vendor/src/components/charts/public.ts +1 -0
  25. package/vendor/src/components/command/command-palette.tsx +375 -0
  26. package/vendor/src/components/command/index.ts +1 -0
  27. package/vendor/src/components/data-table/data-table-actions-column.tsx +58 -0
  28. package/vendor/src/components/data-table/data-table-bulk-actions.tsx +84 -0
  29. package/vendor/src/components/data-table/data-table-column-visibility-menu.tsx +79 -0
  30. package/vendor/src/components/data-table/data-table-pagination.tsx +91 -0
  31. package/vendor/src/components/data-table/data-table-row-actions.tsx +48 -0
  32. package/vendor/src/components/data-table/data-table-select-column.tsx +59 -0
  33. package/vendor/src/components/data-table/data-table-sortable-header.tsx +45 -0
  34. package/vendor/src/components/data-table/data-table-toolbar.tsx +76 -0
  35. package/vendor/src/components/data-table/data-table-view-presets.tsx +128 -0
  36. package/vendor/src/components/data-table/data-table.tsx +507 -0
  37. package/vendor/src/components/data-table/index.ts +12 -0
  38. package/vendor/src/components/data-table/public.ts +10 -0
  39. package/vendor/src/components/data-table/table-export-menu.tsx +56 -0
  40. package/vendor/src/components/data-table/table-import-button.tsx +43 -0
  41. package/vendor/src/components/display/activity-feed.tsx +97 -0
  42. package/vendor/src/components/display/avatar.tsx +131 -0
  43. package/vendor/src/components/display/code-block.tsx +33 -0
  44. package/vendor/src/components/display/data-state.tsx +63 -0
  45. package/vendor/src/components/display/description-list.tsx +119 -0
  46. package/vendor/src/components/display/descriptions.tsx +83 -0
  47. package/vendor/src/components/display/entity-card.tsx +53 -0
  48. package/vendor/src/components/display/file-card.tsx +54 -0
  49. package/vendor/src/components/display/index.ts +30 -0
  50. package/vendor/src/components/display/kanban.tsx +104 -0
  51. package/vendor/src/components/display/keyboard-shortcut.tsx +31 -0
  52. package/vendor/src/components/display/list.tsx +100 -0
  53. package/vendor/src/components/display/metric-grid.tsx +86 -0
  54. package/vendor/src/components/display/progress.tsx +162 -0
  55. package/vendor/src/components/display/property-grid.tsx +54 -0
  56. package/vendor/src/components/display/result.tsx +90 -0
  57. package/vendor/src/components/display/smart-card.tsx +168 -0
  58. package/vendor/src/components/display/statistic.tsx +107 -0
  59. package/vendor/src/components/display/status-legend.tsx +108 -0
  60. package/vendor/src/components/display/tag-list.tsx +52 -0
  61. package/vendor/src/components/display/timeline.tsx +132 -0
  62. package/vendor/src/components/display/tree-view.tsx +116 -0
  63. package/vendor/src/components/feedback/alert.tsx +69 -0
  64. package/vendor/src/components/feedback/empty-state.tsx +56 -0
  65. package/vendor/src/components/feedback/index.ts +5 -0
  66. package/vendor/src/components/feedback/loading-state.tsx +39 -0
  67. package/vendor/src/components/feedback/page-state.tsx +69 -0
  68. package/vendor/src/components/feedback/status-badge.tsx +62 -0
  69. package/vendor/src/components/filters/filter-bar.tsx +89 -0
  70. package/vendor/src/components/filters/filter-chips.tsx +69 -0
  71. package/vendor/src/components/filters/index.ts +2 -0
  72. package/vendor/src/components/form/form-actions.tsx +53 -0
  73. package/vendor/src/components/form/form-async-select.tsx +26 -0
  74. package/vendor/src/components/form/form-date-input.tsx +19 -0
  75. package/vendor/src/components/form/form-date-picker.tsx +54 -0
  76. package/vendor/src/components/form/form-date-range-input.tsx +79 -0
  77. package/vendor/src/components/form/form-date-range-picker.tsx +57 -0
  78. package/vendor/src/components/form/form-field-shell.tsx +191 -0
  79. package/vendor/src/components/form/form-input.tsx +480 -0
  80. package/vendor/src/components/form/form-number-input.tsx +19 -0
  81. package/vendor/src/components/form/form-password-input.tsx +19 -0
  82. package/vendor/src/components/form/form-phone-input.tsx +22 -0
  83. package/vendor/src/components/form/form-search-input.tsx +19 -0
  84. package/vendor/src/components/form/form-section.tsx +29 -0
  85. package/vendor/src/components/form/form-select.tsx +194 -0
  86. package/vendor/src/components/form/form-switch.tsx +145 -0
  87. package/vendor/src/components/form/form-textarea.tsx +103 -0
  88. package/vendor/src/components/form/index.ts +17 -0
  89. package/vendor/src/components/form/public.ts +14 -0
  90. package/vendor/src/components/form/smart-form-shell.tsx +59 -0
  91. package/vendor/src/components/inputs/async-select.tsx +1143 -0
  92. package/vendor/src/components/inputs/clearable-input.tsx +78 -0
  93. package/vendor/src/components/inputs/color-input.tsx +47 -0
  94. package/vendor/src/components/inputs/combobox.tsx +89 -0
  95. package/vendor/src/components/inputs/date-input.tsx +32 -0
  96. package/vendor/src/components/inputs/date-range-input.tsx +67 -0
  97. package/vendor/src/components/inputs/index.ts +19 -0
  98. package/vendor/src/components/inputs/input-chrome.tsx +37 -0
  99. package/vendor/src/components/inputs/input-decorator.tsx +64 -0
  100. package/vendor/src/components/inputs/input-value.ts +42 -0
  101. package/vendor/src/components/inputs/masked-input.tsx +51 -0
  102. package/vendor/src/components/inputs/money-input.tsx +73 -0
  103. package/vendor/src/components/inputs/number-input.tsx +87 -0
  104. package/vendor/src/components/inputs/numeric-value.ts +39 -0
  105. package/vendor/src/components/inputs/otp-input.tsx +102 -0
  106. package/vendor/src/components/inputs/password-input.tsx +85 -0
  107. package/vendor/src/components/inputs/phone-input.tsx +46 -0
  108. package/vendor/src/components/inputs/quantity-input.tsx +116 -0
  109. package/vendor/src/components/inputs/quantity-stepper.tsx +49 -0
  110. package/vendor/src/components/inputs/rating.tsx +98 -0
  111. package/vendor/src/components/inputs/search-input.tsx +26 -0
  112. package/vendor/src/components/inputs/simple-select.tsx +72 -0
  113. package/vendor/src/components/inputs/slider.tsx +149 -0
  114. package/vendor/src/components/inputs/tag-input.tsx +104 -0
  115. package/vendor/src/components/layout/app-header.tsx +46 -0
  116. package/vendor/src/components/layout/app-shell.tsx +243 -0
  117. package/vendor/src/components/layout/app-sidebar.tsx +179 -0
  118. package/vendor/src/components/layout/breadcrumbs.tsx +72 -0
  119. package/vendor/src/components/layout/index.ts +11 -0
  120. package/vendor/src/components/layout/page-container.tsx +30 -0
  121. package/vendor/src/components/layout/page-header.tsx +60 -0
  122. package/vendor/src/components/layout/public.ts +10 -0
  123. package/vendor/src/components/layout/section.tsx +76 -0
  124. package/vendor/src/components/layout/sidebar-nav.tsx +147 -0
  125. package/vendor/src/components/layout/stat-card.tsx +88 -0
  126. package/vendor/src/components/layout/sticky-footer-bar.tsx +23 -0
  127. package/vendor/src/components/layout/workspace-shell.tsx +50 -0
  128. package/vendor/src/components/navigation/anchor-nav.tsx +44 -0
  129. package/vendor/src/components/navigation/index.ts +4 -0
  130. package/vendor/src/components/navigation/page-tabs.tsx +67 -0
  131. package/vendor/src/components/navigation/pagination.tsx +179 -0
  132. package/vendor/src/components/navigation/stepper-tabs.tsx +67 -0
  133. package/vendor/src/components/notifications/index.ts +1 -0
  134. package/vendor/src/components/notifications/toast.tsx +259 -0
  135. package/vendor/src/components/overlay/confirm-dialog.tsx +66 -0
  136. package/vendor/src/components/overlay/dialog-actions.tsx +68 -0
  137. package/vendor/src/components/overlay/index.ts +4 -0
  138. package/vendor/src/components/overlay/modal-shell.tsx +93 -0
  139. package/vendor/src/components/overlay/sheet-shell.tsx +212 -0
  140. package/vendor/src/components/patterns/action-system.tsx +116 -0
  141. package/vendor/src/components/patterns/crud-system.tsx +53 -0
  142. package/vendor/src/components/patterns/data-view.tsx +84 -0
  143. package/vendor/src/components/patterns/entity-details.tsx +66 -0
  144. package/vendor/src/components/patterns/filter-builder.tsx +113 -0
  145. package/vendor/src/components/patterns/form-builder-presets.ts +131 -0
  146. package/vendor/src/components/patterns/form-builder.tsx +334 -0
  147. package/vendor/src/components/patterns/index.ts +12 -0
  148. package/vendor/src/components/patterns/public.ts +4 -0
  149. package/vendor/src/components/patterns/resource-detail-page.tsx +160 -0
  150. package/vendor/src/components/patterns/resource-page.tsx +159 -0
  151. package/vendor/src/components/patterns/resource-system.tsx +61 -0
  152. package/vendor/src/components/patterns/settings-section.tsx +46 -0
  153. package/vendor/src/components/patterns/status-system.tsx +89 -0
  154. package/vendor/src/components/theme-provider.tsx +51 -0
  155. package/vendor/src/components/ui/badge.tsx +52 -0
  156. package/vendor/src/components/ui/button.tsx +61 -0
  157. package/vendor/src/components/ui/card.tsx +103 -0
  158. package/vendor/src/components/ui/checkbox.tsx +82 -0
  159. package/vendor/src/components/ui/collapse.tsx +126 -0
  160. package/vendor/src/components/ui/command.tsx +194 -0
  161. package/vendor/src/components/ui/dialog.tsx +160 -0
  162. package/vendor/src/components/ui/divider.tsx +46 -0
  163. package/vendor/src/components/ui/dropdown-menu.tsx +266 -0
  164. package/vendor/src/components/ui/input-group.tsx +158 -0
  165. package/vendor/src/components/ui/input.tsx +20 -0
  166. package/vendor/src/components/ui/popover.tsx +90 -0
  167. package/vendor/src/components/ui/segmented-control.tsx +78 -0
  168. package/vendor/src/components/ui/select.tsx +201 -0
  169. package/vendor/src/components/ui/skeleton.tsx +75 -0
  170. package/vendor/src/components/ui/spinner.tsx +50 -0
  171. package/vendor/src/components/ui/switch.tsx +71 -0
  172. package/vendor/src/components/ui/table.tsx +114 -0
  173. package/vendor/src/components/ui/tabs.tsx +55 -0
  174. package/vendor/src/components/ui/textarea.tsx +18 -0
  175. package/vendor/src/components/ui/tooltip.tsx +38 -0
  176. package/vendor/src/components/upload/file-upload.tsx +483 -0
  177. package/vendor/src/components/upload/image-upload.tsx +118 -0
  178. package/vendor/src/components/upload/index.ts +2 -0
  179. package/vendor/src/components/wizard/index.ts +2 -0
  180. package/vendor/src/components/wizard/stepper.tsx +53 -0
  181. package/vendor/src/components/wizard/wizard.tsx +60 -0
  182. package/vendor/src/families/card-family.ts +28 -0
  183. package/vendor/src/families/catalog.ts +96 -0
  184. package/vendor/src/families/data-table-family.ts +31 -0
  185. package/vendor/src/families/docs-adoption.ts +103 -0
  186. package/vendor/src/families/docs-groups.ts +209 -0
  187. package/vendor/src/families/docs-queries.ts +84 -0
  188. package/vendor/src/families/docs-routing.ts +89 -0
  189. package/vendor/src/families/form-family.ts +45 -0
  190. package/vendor/src/families/index.ts +17 -0
  191. package/vendor/src/families/input-family.ts +61 -0
  192. package/vendor/src/families/member-metadata.ts +466 -0
  193. package/vendor/src/families/member-queries.ts +28 -0
  194. package/vendor/src/families/member-snippet-queries.ts +54 -0
  195. package/vendor/src/families/member-snippets.ts +673 -0
  196. package/vendor/src/families/migration-map.ts +79 -0
  197. package/vendor/src/families/queries.ts +63 -0
  198. package/vendor/src/families/select-family.ts +33 -0
  199. package/vendor/src/families/views.ts +81 -0
  200. package/vendor/src/hooks/index.ts +6 -0
  201. package/vendor/src/hooks/use-before-unload-when-dirty.ts +21 -0
  202. package/vendor/src/hooks/use-data-table-view-state.ts +122 -0
  203. package/vendor/src/hooks/use-debounce.ts +52 -0
  204. package/vendor/src/hooks/use-disclosure.ts +38 -0
  205. package/vendor/src/hooks/use-is-mobile.ts +28 -0
  206. package/vendor/src/hooks/use-session-storage-state.ts +85 -0
  207. package/vendor/src/index.ts +38 -0
  208. package/vendor/src/lib/utils.ts +6 -0
  209. package/vendor/templates/components/button.tsx +0 -0
  210. package/vendor/templates/components/data-table.tsx +0 -0
  211. package/vendor/templates/components/input.tsx +0 -0
  212. package/vendor/templates/lib/utils.ts +0 -0
  213. package/vendor/templates/styles/globals.css +0 -0
@@ -0,0 +1,483 @@
1
+ import * as React from "react"
2
+ import { FileIcon, UploadCloudIcon, XIcon } from "lucide-react"
3
+
4
+ import { Button } from "@/components/ui/button"
5
+ import { cn } from "@/lib/utils"
6
+
7
+ export type FileUploadRejectReason = "max-files" | "max-size" | "type"
8
+
9
+ export type FileUploadRejectedFile = {
10
+ file: File
11
+ reason: FileUploadRejectReason
12
+ message: string
13
+ }
14
+
15
+ export type FileUploadRejectionMessageContext = {
16
+ file: File
17
+ reason: FileUploadRejectReason
18
+ maxFiles?: number
19
+ maxSize?: number
20
+ accept?: string
21
+ }
22
+
23
+ export type FileUploadRejectionMessages = Partial<
24
+ Record<FileUploadRejectReason, string | ((context: FileUploadRejectionMessageContext) => string)>
25
+ >
26
+
27
+ export type FileUploadRenderFileState = {
28
+ file: File
29
+ index: number
30
+ progress?: number
31
+ remove: () => void
32
+ removeLabel?: string
33
+ }
34
+
35
+ export type FileUploadRenderRejectedFileState = {
36
+ rejectedFile: FileUploadRejectedFile
37
+ index: number
38
+ }
39
+
40
+ type NativeFileInputProps = Omit<
41
+ React.InputHTMLAttributes<HTMLInputElement>,
42
+ | "type"
43
+ | "value"
44
+ | "onChange"
45
+ | "className"
46
+ | "children"
47
+ | "onDragEnter"
48
+ | "onDragLeave"
49
+ | "onDragOver"
50
+ | "onDrop"
51
+ >
52
+
53
+ export type FileUploadProps = NativeFileInputProps & {
54
+ files?: File[]
55
+ onFilesChange?: (files: File[]) => void
56
+ rejectedFiles?: FileUploadRejectedFile[]
57
+ onRejectedFilesChange?: (files: FileUploadRejectedFile[]) => void
58
+ onDragEnter?: React.DragEventHandler<HTMLDivElement>
59
+ onDragLeave?: React.DragEventHandler<HTMLDivElement>
60
+ onDragOver?: React.DragEventHandler<HTMLDivElement>
61
+ onDrop?: React.DragEventHandler<HTMLDivElement>
62
+ buttonLabel?: React.ReactNode
63
+ dropzoneLabel?: React.ReactNode
64
+ dropzoneDescription?: React.ReactNode
65
+ dragActiveLabel?: React.ReactNode
66
+ helperText?: React.ReactNode
67
+ clearLabel?: React.ReactNode
68
+ removeLabel?: string
69
+ dropzoneAriaLabel?: string
70
+ rejectionMessages?: FileUploadRejectionMessages
71
+ maxFiles?: number
72
+ maxSize?: number
73
+ appendFiles?: boolean
74
+ showFileList?: boolean
75
+ showClearButton?: boolean
76
+ loading?: boolean
77
+ progress?: number | Record<string, number>
78
+ renderFile?: (state: FileUploadRenderFileState) => React.ReactNode
79
+ renderRejectedFile?: (state: FileUploadRenderRejectedFileState) => React.ReactNode
80
+ renderActions?: (state: { openFileDialog: () => void; clearFiles: () => void; files: File[] }) => React.ReactNode
81
+ className?: string
82
+ inputClassName?: string
83
+ dropzoneClassName?: string
84
+ fileListClassName?: string
85
+ fileItemClassName?: string
86
+ rejectedListClassName?: string
87
+ }
88
+
89
+ function formatBytes(bytes: number) {
90
+ if (bytes === 0) return "0 B"
91
+
92
+ const units = ["B", "KB", "MB", "GB"]
93
+ const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
94
+ const value = bytes / 1024 ** index
95
+
96
+ return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[index]}`
97
+ }
98
+
99
+ function normalizeAccept(accept?: string) {
100
+ return accept
101
+ ?.split(",")
102
+ .map((item) => item.trim().toLowerCase())
103
+ .filter(Boolean) ?? []
104
+ }
105
+
106
+ function fileMatchesAccept(file: File, accept?: string) {
107
+ const rules = normalizeAccept(accept)
108
+
109
+ if (rules.length === 0) return true
110
+
111
+ const fileName = file.name.toLowerCase()
112
+ const fileType = file.type.toLowerCase()
113
+
114
+ return rules.some((rule) => {
115
+ if (rule.startsWith(".")) return fileName.endsWith(rule)
116
+ if (rule.endsWith("/*")) return fileType.startsWith(rule.slice(0, -1))
117
+ return fileType === rule
118
+ })
119
+ }
120
+
121
+ function getFileKey(file: File) {
122
+ return `${file.name}-${file.size}-${file.lastModified}`
123
+ }
124
+
125
+ function getProgressForFile(progress: FileUploadProps["progress"], file: File) {
126
+ if (typeof progress === "number") return progress
127
+ return progress?.[getFileKey(file)] ?? progress?.[file.name]
128
+ }
129
+
130
+ function resolveRejectionMessage(
131
+ reason: FileUploadRejectReason,
132
+ context: Omit<FileUploadRejectionMessageContext, "reason">,
133
+ messages?: FileUploadRejectionMessages
134
+ ) {
135
+ const customMessage = messages?.[reason]
136
+ const fullContext = { ...context, reason }
137
+
138
+ if (typeof customMessage === "function") return customMessage(fullContext)
139
+ if (typeof customMessage === "string") return customMessage
140
+
141
+ switch (reason) {
142
+ case "max-files":
143
+ return `Maximum ${context.maxFiles} file${context.maxFiles === 1 ? "" : "s"} allowed.`
144
+ case "max-size":
145
+ return `File is larger than ${formatBytes(context.maxSize ?? 0)}.`
146
+ case "type":
147
+ return context.accept ? `File type is not allowed. Expected: ${context.accept}.` : "File type is not allowed."
148
+ }
149
+ }
150
+
151
+ function validateIncomingFiles({
152
+ currentFiles,
153
+ incomingFiles,
154
+ accept,
155
+ maxFiles,
156
+ maxSize,
157
+ appendFiles,
158
+ rejectionMessages,
159
+ }: {
160
+ currentFiles: File[]
161
+ incomingFiles: File[]
162
+ accept?: string
163
+ maxFiles?: number
164
+ maxSize?: number
165
+ appendFiles: boolean
166
+ rejectionMessages?: FileUploadRejectionMessages
167
+ }) {
168
+ const accepted: File[] = []
169
+ const rejected: FileUploadRejectedFile[] = []
170
+ const nextBase = appendFiles ? currentFiles : []
171
+ const effectiveMaxFiles = maxFiles ?? (appendFiles ? undefined : incomingFiles.length)
172
+
173
+ for (const file of incomingFiles) {
174
+ if (effectiveMaxFiles !== undefined && nextBase.length + accepted.length >= effectiveMaxFiles) {
175
+ rejected.push({
176
+ file,
177
+ reason: "max-files",
178
+ message: resolveRejectionMessage("max-files", { file, maxFiles: effectiveMaxFiles, maxSize, accept }, rejectionMessages),
179
+ })
180
+ continue
181
+ }
182
+
183
+ if (maxSize !== undefined && file.size > maxSize) {
184
+ rejected.push({
185
+ file,
186
+ reason: "max-size",
187
+ message: resolveRejectionMessage("max-size", { file, maxFiles: effectiveMaxFiles, maxSize, accept }, rejectionMessages),
188
+ })
189
+ continue
190
+ }
191
+
192
+ if (!fileMatchesAccept(file, accept)) {
193
+ rejected.push({
194
+ file,
195
+ reason: "type",
196
+ message: resolveRejectionMessage("type", { file, maxFiles: effectiveMaxFiles, maxSize, accept }, rejectionMessages),
197
+ })
198
+ continue
199
+ }
200
+
201
+ accepted.push(file)
202
+ }
203
+
204
+ return {
205
+ accepted,
206
+ rejected,
207
+ nextFiles: appendFiles ? [...currentFiles, ...accepted] : accepted,
208
+ }
209
+ }
210
+
211
+ function defaultRenderFile({ file, progress, remove, removeLabel = "Remove file" }: FileUploadRenderFileState) {
212
+ return (
213
+ <div className="flex min-w-0 items-center gap-3">
214
+ <span className="flex size-9 shrink-0 items-center justify-center rounded-full border border-border/70 bg-background/90 shadow-sm">
215
+ <FileIcon className="size-4 text-muted-foreground" />
216
+ </span>
217
+ <div className="min-w-0 flex-1">
218
+ <div className="truncate text-sm font-medium text-foreground">{file.name}</div>
219
+ <div className="text-xs text-muted-foreground">{formatBytes(file.size)}</div>
220
+ {typeof progress === "number" && (
221
+ <div className="mt-2 h-1.5 overflow-hidden rounded-full bg-muted/80">
222
+ <div
223
+ className="h-full rounded-full bg-primary shadow-[0_8px_18px_color-mix(in_oklch,var(--primary),transparent_82%)]"
224
+ style={{ width: `${Math.min(Math.max(progress, 0), 100)}%` }}
225
+ />
226
+ </div>
227
+ )}
228
+ </div>
229
+ <Button type="button" variant="ghost" size="icon-xs" className="rounded-full" onClick={remove}>
230
+ <XIcon />
231
+ <span className="sr-only">{removeLabel}</span>
232
+ </Button>
233
+ </div>
234
+ )
235
+ }
236
+
237
+ function FileUpload({
238
+ files = [],
239
+ onFilesChange,
240
+ rejectedFiles,
241
+ onRejectedFilesChange,
242
+ buttonLabel = "Choose files",
243
+ dropzoneLabel = "Drop files here or click to upload",
244
+ dropzoneDescription,
245
+ dragActiveLabel = "Drop files to upload",
246
+ helperText,
247
+ clearLabel = "Clear all",
248
+ removeLabel = "Remove file",
249
+ dropzoneAriaLabel,
250
+ rejectionMessages,
251
+ maxFiles,
252
+ maxSize,
253
+ appendFiles = true,
254
+ showFileList = true,
255
+ showClearButton = true,
256
+ loading = false,
257
+ progress,
258
+ renderFile,
259
+ renderRejectedFile,
260
+ renderActions,
261
+ className,
262
+ inputClassName,
263
+ dropzoneClassName,
264
+ fileListClassName,
265
+ fileItemClassName,
266
+ rejectedListClassName,
267
+ accept,
268
+ multiple,
269
+ disabled,
270
+ onDragEnter,
271
+ onDragLeave,
272
+ onDragOver,
273
+ onDrop,
274
+ ...props
275
+ }: FileUploadProps) {
276
+ const inputRef = React.useRef<HTMLInputElement>(null)
277
+ const dragDepthRef = React.useRef(0)
278
+ const [isDragging, setIsDragging] = React.useState(false)
279
+ const [internalRejectedFiles, setInternalRejectedFiles] = React.useState<FileUploadRejectedFile[]>([])
280
+ const resolvedRejectedFiles = rejectedFiles ?? internalRejectedFiles
281
+ const isDisabled = disabled || loading
282
+ const inputMultiple = multiple ?? maxFiles !== 1
283
+
284
+ React.useEffect(() => {
285
+ if (!isDisabled) return
286
+ dragDepthRef.current = 0
287
+ setIsDragging(false)
288
+ }, [isDisabled])
289
+
290
+ const setRejectedFiles = React.useCallback(
291
+ (nextRejectedFiles: FileUploadRejectedFile[]) => {
292
+ if (rejectedFiles === undefined) setInternalRejectedFiles(nextRejectedFiles)
293
+ onRejectedFilesChange?.(nextRejectedFiles)
294
+ },
295
+ [onRejectedFilesChange, rejectedFiles]
296
+ )
297
+
298
+ const openFileDialog = React.useCallback(() => {
299
+ if (!isDisabled) inputRef.current?.click()
300
+ }, [isDisabled])
301
+
302
+ const clearFiles = React.useCallback(() => {
303
+ onFilesChange?.([])
304
+ setRejectedFiles([])
305
+ }, [onFilesChange, setRejectedFiles])
306
+
307
+ const processFiles = React.useCallback(
308
+ (incomingFiles: File[]) => {
309
+ const result = validateIncomingFiles({ currentFiles: files, incomingFiles, accept, maxFiles, maxSize, appendFiles, rejectionMessages })
310
+ onFilesChange?.(result.nextFiles)
311
+ setRejectedFiles(result.rejected)
312
+ },
313
+ [accept, appendFiles, files, maxFiles, maxSize, onFilesChange, rejectionMessages, setRejectedFiles]
314
+ )
315
+
316
+ const removeFile = React.useCallback(
317
+ (fileIndex: number) => {
318
+ onFilesChange?.(files.filter((_, index) => index !== fileIndex))
319
+ },
320
+ [files, onFilesChange]
321
+ )
322
+
323
+ const handleChange: React.ChangeEventHandler<HTMLInputElement> = (event) => {
324
+ if (isDisabled) return
325
+ processFiles(Array.from(event.target.files ?? []))
326
+ event.target.value = ""
327
+ }
328
+
329
+ const preventDisabledDragDefault = (event: React.DragEvent<HTMLDivElement>) => {
330
+ if (!isDisabled) return false
331
+ event.preventDefault()
332
+ event.stopPropagation()
333
+ return true
334
+ }
335
+
336
+ const handleDragEnter: React.DragEventHandler<HTMLDivElement> = (event) => {
337
+ onDragEnter?.(event)
338
+ if (event.defaultPrevented || preventDisabledDragDefault(event)) return
339
+ event.preventDefault()
340
+ dragDepthRef.current += 1
341
+ setIsDragging(true)
342
+ }
343
+
344
+ const handleDragLeave: React.DragEventHandler<HTMLDivElement> = (event) => {
345
+ onDragLeave?.(event)
346
+ if (event.defaultPrevented || preventDisabledDragDefault(event)) return
347
+ dragDepthRef.current = Math.max(dragDepthRef.current - 1, 0)
348
+ if (dragDepthRef.current === 0) setIsDragging(false)
349
+ }
350
+
351
+ const handleDragOver: React.DragEventHandler<HTMLDivElement> = (event) => {
352
+ onDragOver?.(event)
353
+ if (event.defaultPrevented || preventDisabledDragDefault(event)) return
354
+ event.preventDefault()
355
+ }
356
+
357
+ const handleDrop: React.DragEventHandler<HTMLDivElement> = (event) => {
358
+ onDrop?.(event)
359
+ if (event.defaultPrevented || preventDisabledDragDefault(event)) return
360
+ event.preventDefault()
361
+ dragDepthRef.current = 0
362
+ setIsDragging(false)
363
+ processFiles(Array.from(event.dataTransfer.files ?? []))
364
+ }
365
+
366
+ return (
367
+ <div data-slot="file-upload" className={cn("grid gap-3", className)}>
368
+ <input
369
+ ref={inputRef}
370
+ type="file"
371
+ className={cn("sr-only", inputClassName)}
372
+ disabled={isDisabled}
373
+ accept={accept}
374
+ multiple={inputMultiple}
375
+ onChange={handleChange}
376
+ {...props}
377
+ />
378
+
379
+ <div
380
+ data-slot="file-upload-dropzone"
381
+ data-dragging={isDragging || undefined}
382
+ data-disabled={isDisabled || undefined}
383
+ role="button"
384
+ aria-disabled={isDisabled || undefined}
385
+ aria-label={dropzoneAriaLabel}
386
+ tabIndex={isDisabled ? -1 : 0}
387
+ className={cn(
388
+ "grid cursor-pointer gap-4 rounded-[var(--radius-2xl)] border border-dashed border-border/75 bg-card/96 p-5 text-center shadow-sm outline-none transition-[background-color,border-color,box-shadow,transform] hover:border-primary/35 hover:bg-muted/25 focus-visible:ring-2 focus-visible:ring-ring data-[dragging=true]:border-primary data-[dragging=true]:bg-primary/6 data-[dragging=true]:shadow-[0_18px_50px_color-mix(in_oklch,var(--primary),transparent_84%)] data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-60",
389
+ dropzoneClassName
390
+ )}
391
+ onClick={openFileDialog}
392
+ onKeyDown={(event) => {
393
+ if (isDisabled) return
394
+ if (event.key === "Enter" || event.key === " ") {
395
+ event.preventDefault()
396
+ openFileDialog()
397
+ }
398
+ }}
399
+ onDragEnter={handleDragEnter}
400
+ onDragLeave={handleDragLeave}
401
+ onDragOver={handleDragOver}
402
+ onDrop={handleDrop}
403
+ >
404
+ <div className="mx-auto flex size-12 items-center justify-center rounded-full border border-border/70 bg-background/92 text-muted-foreground shadow-sm">
405
+ <UploadCloudIcon className="size-5" />
406
+ </div>
407
+ <div className="grid gap-1">
408
+ <div className="text-sm font-semibold text-foreground">{isDragging ? dragActiveLabel : dropzoneLabel}</div>
409
+ {dropzoneDescription && <div className="text-sm leading-6 text-muted-foreground">{dropzoneDescription}</div>}
410
+ {(accept || maxSize || maxFiles || helperText) && (
411
+ <div className="text-xs leading-5 text-muted-foreground">
412
+ {helperText ?? [accept ? `Types: ${accept}` : null, maxSize ? `Max size: ${formatBytes(maxSize)}` : null, maxFiles ? `Max files: ${maxFiles}` : null].filter(Boolean).join(" • ")}
413
+ </div>
414
+ )}
415
+ </div>
416
+
417
+ {renderActions?.({ openFileDialog, clearFiles, files }) ?? (
418
+ <div className="flex justify-center gap-2">
419
+ <Button
420
+ type="button"
421
+ variant="outline"
422
+ size="sm"
423
+ disabled={isDisabled}
424
+ onClick={(event) => {
425
+ event.stopPropagation()
426
+ openFileDialog()
427
+ }}
428
+ >
429
+ {buttonLabel}
430
+ </Button>
431
+ {showClearButton && files.length > 0 && (
432
+ <Button
433
+ type="button"
434
+ variant="ghost"
435
+ size="sm"
436
+ disabled={isDisabled}
437
+ onClick={(event) => {
438
+ event.stopPropagation()
439
+ clearFiles()
440
+ }}
441
+ >
442
+ {clearLabel}
443
+ </Button>
444
+ )}
445
+ </div>
446
+ )}
447
+ </div>
448
+
449
+ {showFileList && files.length > 0 && (
450
+ <div data-slot="file-upload-list" className={cn("grid gap-2", fileListClassName)}>
451
+ {files.map((file, index) => {
452
+ const state = { file, index, progress: getProgressForFile(progress, file), remove: () => removeFile(index), removeLabel }
453
+
454
+ return (
455
+ <div
456
+ key={`${file.name}-${file.lastModified}-${index}`}
457
+ data-slot="file-upload-item"
458
+ className={cn("rounded-[min(var(--radius-xl),18px)] border border-border/75 bg-card/96 p-3 shadow-sm", fileItemClassName)}
459
+ >
460
+ {renderFile?.(state) ?? defaultRenderFile(state)}
461
+ </div>
462
+ )
463
+ })}
464
+ </div>
465
+ )}
466
+
467
+ {resolvedRejectedFiles.length > 0 && (
468
+ <div data-slot="file-upload-rejected-list" className={cn("grid gap-2", rejectedListClassName)}>
469
+ {resolvedRejectedFiles.map((rejectedFile, index) => (
470
+ <div
471
+ key={`${rejectedFile.file.name}-${index}`}
472
+ className="rounded-[min(var(--radius-lg),12px)] border border-destructive/20 bg-destructive/8 px-3 py-2 text-xs leading-5 text-destructive"
473
+ >
474
+ {renderRejectedFile?.({ rejectedFile, index }) ?? <span>{rejectedFile.file.name}: {rejectedFile.message}</span>}
475
+ </div>
476
+ ))}
477
+ </div>
478
+ )}
479
+ </div>
480
+ )
481
+ }
482
+
483
+ export { FileUpload, fileMatchesAccept, formatBytes, getFileKey }
@@ -0,0 +1,118 @@
1
+ import * as React from "react"
2
+ import { ImageIcon, XIcon } from "lucide-react"
3
+
4
+ import {
5
+ FileUpload,
6
+ formatBytes,
7
+ getFileKey,
8
+ type FileUploadProps,
9
+ type FileUploadRenderFileState,
10
+ } from "@/components/upload/file-upload"
11
+ import { Button } from "@/components/ui/button"
12
+ import { cn } from "@/lib/utils"
13
+
14
+ export type ImageUploadProps = Omit<FileUploadProps, "accept" | "renderFile"> & {
15
+ accept?: string
16
+ preview?: boolean
17
+ previewClassName?: string
18
+ imageClassName?: string
19
+ renderImageFile?: (state: FileUploadRenderFileState & { previewUrl?: string }) => React.ReactNode
20
+ }
21
+
22
+ function useImagePreviewUrls(files: File[], enabled = true) {
23
+ const [urls, setUrls] = React.useState<Record<string, string>>({})
24
+
25
+ React.useEffect(() => {
26
+ if (!enabled) {
27
+ setUrls({})
28
+ return
29
+ }
30
+
31
+ const entries = files
32
+ .filter((file) => file.type.startsWith("image/"))
33
+ .map((file) => [getFileKey(file), URL.createObjectURL(file)] as const)
34
+
35
+ setUrls(Object.fromEntries(entries))
36
+
37
+ return () => {
38
+ entries.forEach(([, url]) => URL.revokeObjectURL(url))
39
+ }
40
+ }, [enabled, files])
41
+
42
+ return urls
43
+ }
44
+
45
+ function defaultRenderImageFile({
46
+ file,
47
+ previewUrl,
48
+ progress,
49
+ remove,
50
+ previewClassName,
51
+ imageClassName,
52
+ }: FileUploadRenderFileState & {
53
+ previewUrl?: string
54
+ previewClassName?: string
55
+ imageClassName?: string
56
+ }) {
57
+ return (
58
+ <div className="flex min-w-0 items-center gap-3">
59
+ <div
60
+ className={cn(
61
+ "flex size-14 shrink-0 items-center justify-center overflow-hidden rounded-md border bg-muted text-muted-foreground",
62
+ previewClassName
63
+ )}
64
+ >
65
+ {previewUrl ? (
66
+ <img src={previewUrl} alt={file.name} className={cn("size-full object-cover", imageClassName)} />
67
+ ) : (
68
+ <ImageIcon className="size-5" />
69
+ )}
70
+ </div>
71
+ <div className="min-w-0 flex-1">
72
+ <div className="truncate text-sm font-medium text-foreground">{file.name}</div>
73
+ <div className="text-xs text-muted-foreground">{formatBytes(file.size)}</div>
74
+ {typeof progress === "number" && (
75
+ <div className="mt-1 h-1.5 overflow-hidden rounded-full bg-muted">
76
+ <div className="h-full rounded-full bg-primary" style={{ width: `${Math.min(Math.max(progress, 0), 100)}%` }} />
77
+ </div>
78
+ )}
79
+ </div>
80
+ <Button type="button" variant="ghost" size="icon-xs" onClick={remove}>
81
+ <XIcon />
82
+ <span className="sr-only">Remove image</span>
83
+ </Button>
84
+ </div>
85
+ )
86
+ }
87
+
88
+ function ImageUpload({
89
+ accept = "image/*",
90
+ buttonLabel = "Choose image",
91
+ dropzoneLabel = "Drop images here or click to upload",
92
+ preview = true,
93
+ previewClassName,
94
+ imageClassName,
95
+ files = [],
96
+ renderImageFile,
97
+ ...props
98
+ }: ImageUploadProps) {
99
+ const previewUrls = useImagePreviewUrls(files, preview)
100
+
101
+ return (
102
+ <FileUpload
103
+ accept={accept}
104
+ buttonLabel={buttonLabel}
105
+ dropzoneLabel={dropzoneLabel}
106
+ files={files}
107
+ renderFile={(state) => {
108
+ const previewUrl = previewUrls[getFileKey(state.file)]
109
+
110
+ return renderImageFile?.({ ...state, previewUrl }) ??
111
+ defaultRenderImageFile({ ...state, previewUrl, previewClassName, imageClassName })
112
+ }}
113
+ {...props}
114
+ />
115
+ )
116
+ }
117
+
118
+ export { ImageUpload, useImagePreviewUrls }
@@ -0,0 +1,2 @@
1
+ export { FileUpload } from "./file-upload"
2
+ export { ImageUpload } from "./image-upload"
@@ -0,0 +1,2 @@
1
+ export * from "./stepper"
2
+ export * from "./wizard"
@@ -0,0 +1,53 @@
1
+ import * as React from "react"
2
+ import { CheckIcon } from "lucide-react"
3
+
4
+ import { cn } from "@/lib/utils"
5
+
6
+ export type StepperStep = {
7
+ id: string
8
+ title: React.ReactNode
9
+ description?: React.ReactNode
10
+ disabled?: boolean
11
+ completed?: boolean
12
+ }
13
+
14
+ export type StepperProps = React.ComponentProps<"div"> & {
15
+ steps: StepperStep[]
16
+ currentStep: string
17
+ onStepChange?: (stepId: string) => void
18
+ orientation?: "horizontal" | "vertical"
19
+ }
20
+
21
+ function Stepper({ className, steps, currentStep, onStepChange, orientation = "horizontal", ...props }: StepperProps) {
22
+ const currentIndex = Math.max(steps.findIndex((step) => step.id === currentStep), 0)
23
+
24
+ return (
25
+ <div data-slot="stepper" data-orientation={orientation} className={cn(orientation === "vertical" ? "grid gap-3" : "flex flex-wrap items-start gap-3", className)} {...props}>
26
+ {steps.map((step, index) => {
27
+ const isActive = step.id === currentStep
28
+ const isComplete = step.completed ?? index < currentIndex
29
+ const isClickable = Boolean(onStepChange) && !step.disabled
30
+
31
+ return (
32
+ <button
33
+ key={step.id}
34
+ type="button"
35
+ disabled={!isClickable}
36
+ className={cn("flex min-w-0 items-start gap-2 text-left disabled:cursor-default", orientation === "horizontal" && "flex-1")}
37
+ onClick={() => onStepChange?.(step.id)}
38
+ >
39
+ <span className={cn("flex size-7 shrink-0 items-center justify-center rounded-full border text-xs font-medium", isActive && "border-primary bg-primary text-primary-foreground", isComplete && !isActive && "border-primary bg-primary/10 text-primary")}>
40
+ {isComplete && !isActive ? <CheckIcon className="size-4" /> : index + 1}
41
+ </span>
42
+ <span className="min-w-0 grid gap-0.5">
43
+ <span className={cn("text-sm font-medium", isActive ? "text-foreground" : "text-muted-foreground")}>{step.title}</span>
44
+ {step.description && <span className="text-xs text-muted-foreground">{step.description}</span>}
45
+ </span>
46
+ </button>
47
+ )
48
+ })}
49
+ </div>
50
+ )
51
+ }
52
+
53
+ export { Stepper }