@torch-ui/solid 0.1.3

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 (118) hide show
  1. package/README.md +166 -0
  2. package/package.json +67 -0
  3. package/src/components/actions/Button.tsx +612 -0
  4. package/src/components/actions/ButtonGroup.tsx +728 -0
  5. package/src/components/actions/Copy.tsx +98 -0
  6. package/src/components/actions/DarkModeToggle.tsx +80 -0
  7. package/src/components/actions/Link.tsx +37 -0
  8. package/src/components/actions/index.ts +19 -0
  9. package/src/components/actions/useCopyToClipboard.ts +90 -0
  10. package/src/components/charts/Chart.tsx +331 -0
  11. package/src/components/charts/Sparkline.tsx +156 -0
  12. package/src/components/charts/index.ts +13 -0
  13. package/src/components/data-display/Avatar.tsx +208 -0
  14. package/src/components/data-display/AvatarGroup.tsx +228 -0
  15. package/src/components/data-display/Badge.tsx +70 -0
  16. package/src/components/data-display/Carousel.tsx +214 -0
  17. package/src/components/data-display/ColorSwatch.tsx +56 -0
  18. package/src/components/data-display/DataTable.tsx +886 -0
  19. package/src/components/data-display/EmptyState.tsx +61 -0
  20. package/src/components/data-display/Image.tsx +277 -0
  21. package/src/components/data-display/Kbd.tsx +114 -0
  22. package/src/components/data-display/Persona.tsx +78 -0
  23. package/src/components/data-display/StatCard.tsx +338 -0
  24. package/src/components/data-display/Table.tsx +147 -0
  25. package/src/components/data-display/Tag.tsx +91 -0
  26. package/src/components/data-display/Timeline.tsx +200 -0
  27. package/src/components/data-display/TreeView.tsx +172 -0
  28. package/src/components/data-display/Video.tsx +95 -0
  29. package/src/components/data-display/avatar-utils.ts +32 -0
  30. package/src/components/data-display/index.ts +81 -0
  31. package/src/components/feedback/Loading.tsx +159 -0
  32. package/src/components/feedback/Progress.tsx +321 -0
  33. package/src/components/feedback/Skeleton.tsx +62 -0
  34. package/src/components/feedback/SkeletonBlocks.tsx +222 -0
  35. package/src/components/feedback/Toast.tsx +648 -0
  36. package/src/components/feedback/index.ts +44 -0
  37. package/src/components/feedback/password/PasswordStrengthIndicator.tsx +232 -0
  38. package/src/components/feedback/password/password-strength.ts +115 -0
  39. package/src/components/feedback/password/password-validation-data.ts +66 -0
  40. package/src/components/feedback/password/password-validation.ts +93 -0
  41. package/src/components/forms/Autocomplete.tsx +268 -0
  42. package/src/components/forms/Checkbox.tsx +155 -0
  43. package/src/components/forms/CodeInput.tsx +237 -0
  44. package/src/components/forms/ColorPicker/ColorPicker.tsx +469 -0
  45. package/src/components/forms/ColorPicker/color-utils.ts +75 -0
  46. package/src/components/forms/ColorPicker/index.ts +2 -0
  47. package/src/components/forms/DatePicker.tsx +516 -0
  48. package/src/components/forms/DateRangePicker.tsx +464 -0
  49. package/src/components/forms/FieldPicker.tsx +64 -0
  50. package/src/components/forms/FileUpload.tsx +614 -0
  51. package/src/components/forms/FilterBuilder/FilterGroupBlock.ts +6 -0
  52. package/src/components/forms/FilterBuilder.tsx +16 -0
  53. package/src/components/forms/FilterRuleRow.tsx +68 -0
  54. package/src/components/forms/Input.tsx +200 -0
  55. package/src/components/forms/MultiSelect.tsx +361 -0
  56. package/src/components/forms/NumberField.tsx +145 -0
  57. package/src/components/forms/RadioGroup.tsx +135 -0
  58. package/src/components/forms/RelativeDateDefaultInput.tsx +62 -0
  59. package/src/components/forms/ReorderableList.tsx +163 -0
  60. package/src/components/forms/Select.tsx +268 -0
  61. package/src/components/forms/Slider.tsx +260 -0
  62. package/src/components/forms/Switch.tsx +135 -0
  63. package/src/components/forms/TextArea.tsx +202 -0
  64. package/src/components/forms/ViewCustomizer.tsx +44 -0
  65. package/src/components/forms/index.ts +43 -0
  66. package/src/components/layout/Accordion.tsx +110 -0
  67. package/src/components/layout/Alert.tsx +156 -0
  68. package/src/components/layout/BlockQuote.tsx +70 -0
  69. package/src/components/layout/Card.tsx +166 -0
  70. package/src/components/layout/CodeBlock/CodeBlock.tsx +477 -0
  71. package/src/components/layout/CodeBlock/code-block-tokens.css +104 -0
  72. package/src/components/layout/CodeBlock/prism.ts +81 -0
  73. package/src/components/layout/Collapsible.tsx +84 -0
  74. package/src/components/layout/Container.tsx +55 -0
  75. package/src/components/layout/Divider.tsx +64 -0
  76. package/src/components/layout/Form.tsx +39 -0
  77. package/src/components/layout/FormActions.tsx +50 -0
  78. package/src/components/layout/Grid.tsx +53 -0
  79. package/src/components/layout/PageHeading.tsx +46 -0
  80. package/src/components/layout/PromptWithAction.tsx +49 -0
  81. package/src/components/layout/Section.tsx +60 -0
  82. package/src/components/layout/TablePanel.tsx +24 -0
  83. package/src/components/layout/TableView/TableView.tsx +1018 -0
  84. package/src/components/layout/TableView/index.ts +3 -0
  85. package/src/components/layout/TableView/types.ts +51 -0
  86. package/src/components/layout/WizardStep.tsx +40 -0
  87. package/src/components/layout/WizardStepper.tsx +173 -0
  88. package/src/components/layout/index.ts +96 -0
  89. package/src/components/navigation/Breadcrumbs.tsx +66 -0
  90. package/src/components/navigation/DropdownMenu.tsx +86 -0
  91. package/src/components/navigation/MegaMenu.tsx +480 -0
  92. package/src/components/navigation/NavigationMenu.tsx +305 -0
  93. package/src/components/navigation/Pagination.tsx +298 -0
  94. package/src/components/navigation/Sidebar.tsx +280 -0
  95. package/src/components/navigation/Tabs.tsx +122 -0
  96. package/src/components/navigation/ViewSwitcher.tsx +314 -0
  97. package/src/components/navigation/index.ts +66 -0
  98. package/src/components/overlays/AlertDialog.tsx +174 -0
  99. package/src/components/overlays/ContextMenu.tsx +65 -0
  100. package/src/components/overlays/Dialog.tsx +279 -0
  101. package/src/components/overlays/Drawer.tsx +370 -0
  102. package/src/components/overlays/HoverCard.tsx +107 -0
  103. package/src/components/overlays/Popover.tsx +73 -0
  104. package/src/components/overlays/Tooltip.tsx +31 -0
  105. package/src/components/overlays/index.ts +71 -0
  106. package/src/components/typography/Code.tsx +72 -0
  107. package/src/components/typography/Icon.tsx +36 -0
  108. package/src/components/typography/index.ts +10 -0
  109. package/src/env.d.ts +9 -0
  110. package/src/index.ts +13 -0
  111. package/src/styles/theme.css +226 -0
  112. package/src/types/avatar-types.ts +11 -0
  113. package/src/types/filter-types.ts +35 -0
  114. package/src/utilities/classNames.ts +6 -0
  115. package/src/utilities/componentSize.ts +46 -0
  116. package/src/utilities/i18n.tsx +60 -0
  117. package/src/utilities/mergeRefs.ts +12 -0
  118. package/src/utilities/relativeDateDefault.ts +14 -0
@@ -0,0 +1,614 @@
1
+ import type { JSX } from 'solid-js'
2
+ import { createUniqueId, createSignal, createEffect, onCleanup, Show, For, splitProps } from 'solid-js'
3
+ import { File as FileIcon, FileCode, FileImage, FilePlay, FileSpreadsheet, FileText, FileUp, FolderArchive, LoaderCircle, RefreshCw, Trash2, Eye, Loader2 } from 'lucide-solid'
4
+ import { cn } from '../../utilities/classNames'
5
+ import { mergeRefs } from '../../utilities/mergeRefs'
6
+ import { Progress } from '../feedback/Progress'
7
+ import { Dialog } from '../overlays/Dialog'
8
+
9
+ /** Single file entry with status and optional progress/error. Parent manages upload and updates these. */
10
+ export interface FileUploadItem {
11
+ id: string
12
+ file: File
13
+ status: 'pending' | 'uploading' | 'done' | 'error'
14
+ progress?: number
15
+ error?: string
16
+ }
17
+
18
+ export type FileUploadVariant = 'button' | 'dropzone'
19
+
20
+ export interface FileUploadProps {
21
+ /** Controlled list of files. Parent adds/removes and updates status/progress. */
22
+ files: FileUploadItem[]
23
+ /** Called when user selects or drops files. Parent should append with id and status (e.g. 'pending' or 'uploading'). */
24
+ onAddFiles: (files: File[]) => void
25
+ /** Called when user removes a file. */
26
+ onRemove: (id: string) => void
27
+ /** Called when user retries a failed file. */
28
+ onRetry?: (id: string) => void
29
+ /** Label above the control. */
30
+ label?: string
31
+ /** Helper text and/or limits (e.g. "PNG, JPG. Max 10 MB"). Shown below label or in dropzone. */
32
+ description?: string
33
+ /** Accept attribute (e.g. "image/png,image/jpeg"). */
34
+ accept?: string
35
+ /** When false, single file only (input multiple=false, maxFiles 1). Default true. */
36
+ multiple?: boolean
37
+ /** Max number of files. When multiple is false, defaults to 1. */
38
+ maxFiles?: number
39
+ /** Max file size in bytes. Validated before onAddFiles. */
40
+ maxFileSize?: number
41
+ /** 'button' = trigger only; 'dropzone' = large dashed area with drag & drop. */
42
+ variant?: FileUploadVariant
43
+ /** Form-level error (e.g. "No files attached"). */
44
+ error?: string
45
+ /** Disabled state. */
46
+ disabled?: boolean
47
+ /** Optional id for the hidden input. */
48
+ id?: string
49
+ /** Ref forwarded to the hidden file input. */
50
+ ref?: (el: HTMLInputElement) => void
51
+ /** Root class. */
52
+ class?: string
53
+ /** When true (dropzone only), show "Drag and drop..." + a prominent Browse button inside the zone. Default false = single clickable area. */
54
+ browseButton?: boolean
55
+ /** Footer actions (e.g. Done left, Upload right). Dropzone: footer bar; button variant: same row after the trigger. */
56
+ actions?: JSX.Element
57
+ /** Optional icon per file in the list. Receives the File; return a JSX element. When not set, a default icon is chosen by type (PDF→FileText, image→FileImage, video→FilePlay, spreadsheet/csv→FileSpreadsheet, archive→FolderArchive, code/txt→FileCode, else File). */
58
+ fileIcon?: (file: File) => JSX.Element
59
+ /** When true (button variant only), show the selected file(s) to the right of the button in the same row instead of below. */
60
+ fileInline?: boolean
61
+ }
62
+
63
+ const DEFAULT_MAX_FILES = 10
64
+
65
+ const CODE_EXT = new Set(['txt', 'js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs', 'json', 'html', 'htm', 'css', 'scss', 'sass', 'less', 'md', 'xml', 'yml', 'yaml', 'sh', 'bash', 'py', 'rb', 'php', 'java', 'c', 'cpp', 'h', 'hpp', 'cs', 'go', 'rs', 'vue', 'svelte'])
66
+ const SPREADSHEET_EXT = new Set(['csv', 'xls', 'xlsx', 'xlsm', 'ods'])
67
+ const ARCHIVE_EXT = new Set(['zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz', 'zst'])
68
+
69
+ /** Default icon by file type: PDF, image, video, spreadsheet, archive, code/txt, else file. */
70
+ function defaultFileIcon(file: File): JSX.Element {
71
+ const t = (file.type || '').toLowerCase()
72
+ const ext = (file.name.split('.').pop() ?? '').toLowerCase()
73
+ const cls = 'h-4 w-4 shrink-0 text-ink-400'
74
+ if (t === 'application/pdf') return <FileText class={cls} aria-hidden="true" />
75
+ if (t.startsWith('image/')) return <FileImage class={cls} aria-hidden="true" />
76
+ if (t.startsWith('video/')) return <FilePlay class={cls} aria-hidden="true" />
77
+ if (t === 'text/csv' || t.includes('spreadsheet') || t.includes('excel') || SPREADSHEET_EXT.has(ext))
78
+ return <FileSpreadsheet class={cls} aria-hidden="true" />
79
+ if (t.includes('zip') || t.includes('rar') || t.includes('gzip') || t.includes('compress') || ARCHIVE_EXT.has(ext))
80
+ return <FolderArchive class={cls} aria-hidden="true" />
81
+ if (t.startsWith('text/') || CODE_EXT.has(ext)) return <FileCode class={cls} aria-hidden="true" />
82
+ return <FileIcon class={cls} aria-hidden="true" />
83
+ }
84
+
85
+ function formatFileSize(bytes: number): string {
86
+ if (bytes < 1024) return `${bytes} B`
87
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
88
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
89
+ }
90
+
91
+ function validateFiles(
92
+ files: File[],
93
+ accept: string | undefined,
94
+ maxFileSize: number | undefined,
95
+ maxFiles: number,
96
+ currentCount: number
97
+ ): { valid: File[]; errors: string[] } {
98
+ const errors: string[] = []
99
+ const valid: File[] = []
100
+ const remaining = Math.max(0, maxFiles - currentCount)
101
+ if (remaining === 0) {
102
+ errors.push(maxFiles === 1 ? 'Maximum 1 file allowed.' : `Maximum ${maxFiles} files allowed.`)
103
+ return { valid, errors }
104
+ }
105
+ const types = (accept?.split(',').map((t) => t.trim().toLowerCase()).filter(Boolean) ?? [])
106
+ for (let i = 0; i < files.length; i++) {
107
+ const file = files[i]!
108
+ if (valid.length >= remaining) {
109
+ const skipped = files.length - i
110
+ const limitMsg = maxFiles === 1 ? 'maximum 1 file' : `maximum of ${maxFiles}`
111
+ errors.push(`${skipped} file${skipped > 1 ? 's' : ''} not added: ${limitMsg} reached.`)
112
+ break
113
+ }
114
+ if (maxFileSize != null && file.size > maxFileSize) {
115
+ errors.push(`${file.name}: exceeds ${formatFileSize(maxFileSize)}.`)
116
+ continue
117
+ }
118
+ if (types.length > 0) {
119
+ const mime = (file.type || '').toLowerCase()
120
+ const ext = '.' + (file.name.split('.').pop() ?? '').toLowerCase()
121
+ const allowed =
122
+ types.some((t) => t === '*/*') ||
123
+ types.some((t) => t.startsWith('.') && ext === t) ||
124
+ types.some((t) => mime === t || (t.endsWith('/*') && mime && mime.startsWith(t.slice(0, -1))))
125
+ if (!allowed) {
126
+ errors.push(`${file.name}: type not accepted.`)
127
+ continue
128
+ }
129
+ }
130
+ valid.push(file)
131
+ }
132
+ return { valid, errors }
133
+ }
134
+
135
+ export function FileUpload(props: FileUploadProps) {
136
+ const [local, rest] = splitProps(props, [
137
+ 'files',
138
+ 'onAddFiles',
139
+ 'onRemove',
140
+ 'onRetry',
141
+ 'label',
142
+ 'description',
143
+ 'accept',
144
+ 'maxFiles',
145
+ 'maxFileSize',
146
+ 'variant',
147
+ 'error',
148
+ 'disabled',
149
+ 'id',
150
+ 'ref',
151
+ 'class',
152
+ 'browseButton',
153
+ 'actions',
154
+ 'multiple',
155
+ 'fileIcon',
156
+ 'fileInline',
157
+ ])
158
+
159
+ const uid = createUniqueId()
160
+ const id = () => local.id ?? `file-upload-${uid}`
161
+ const labelId = () => `${id()}-label`
162
+ const descId = () => `${id()}-desc`
163
+ const validationId = () => `${id()}-validation`
164
+ const errorId = () => `${id()}-error`
165
+ const variant = () => local.variant ?? 'dropzone'
166
+ const isMultiple = () => local.multiple !== false
167
+ const maxFiles = () =>
168
+ local.maxFiles ?? (isMultiple() ? DEFAULT_MAX_FILES : 1)
169
+ const fileIcon = (file: File) =>
170
+ local.fileIcon ? local.fileIcon(file) : defaultFileIcon(file)
171
+ const canAddMore = () => local.files.length < maxFiles()
172
+ const atLimit = () => !canAddMore()
173
+ const [dragDepth, setDragDepth] = createSignal(0)
174
+ const dragOver = () => dragDepth() > 0
175
+ const [validationErrors, setValidationErrors] = createSignal<string[]>([])
176
+ const [viewModalOpen, setViewModalOpen] = createSignal(false)
177
+ const zoneDisabled = () => local.disabled || atLimit()
178
+
179
+ const isFileDrag = (e: DragEvent) =>
180
+ Array.from(e.dataTransfer?.types ?? []).includes('Files')
181
+
182
+ // Reset drag depth if the cursor leaves the window or the drag ends elsewhere
183
+ createEffect(() => {
184
+ if (typeof window === 'undefined') return
185
+ const reset = () => setDragDepth(0)
186
+ const onWinDragLeave = (e: DragEvent) => {
187
+ if ((e as any).relatedTarget == null) reset()
188
+ }
189
+ window.addEventListener('drop', reset)
190
+ window.addEventListener('dragend', reset)
191
+ window.addEventListener('dragleave', onWinDragLeave as EventListener)
192
+ document.addEventListener('drop', reset)
193
+ document.addEventListener('dragend', reset)
194
+ onCleanup(() => {
195
+ window.removeEventListener('drop', reset)
196
+ window.removeEventListener('dragend', reset)
197
+ window.removeEventListener('dragleave', onWinDragLeave as EventListener)
198
+ document.removeEventListener('drop', reset)
199
+ document.removeEventListener('dragend', reset)
200
+ })
201
+ })
202
+
203
+ const describedBy = () => {
204
+ const parts: string[] = []
205
+ if (local.description || limitsText()) parts.push(descId())
206
+ if (validationErrors().length > 0) parts.push(validationId())
207
+ if (local.error) parts.push(errorId())
208
+ return parts.length > 0 ? parts.join(' ') : undefined
209
+ }
210
+ const hasAnyError = () => validationErrors().length > 0 || !!local.error
211
+ const ariaErrorMessage = () =>
212
+ validationErrors().length > 0 ? validationId()
213
+ : local.error ? errorId()
214
+ : undefined
215
+
216
+ let inputEl: HTMLInputElement | undefined
217
+
218
+ const handleInputChange = (e: Event) => {
219
+ setValidationErrors([])
220
+ const input = e.currentTarget as HTMLInputElement
221
+ const list = input.files
222
+ if (!list?.length) return
223
+ const newFiles = Array.from(list)
224
+ const { valid, errors } = validateFiles(
225
+ newFiles,
226
+ local.accept,
227
+ local.maxFileSize,
228
+ maxFiles(),
229
+ local.files.length
230
+ )
231
+ setValidationErrors(errors)
232
+ if (valid.length) local.onAddFiles(valid)
233
+ input.value = ''
234
+ }
235
+
236
+ const handleDrop = (e: DragEvent) => {
237
+ e.preventDefault()
238
+ setDragDepth(0)
239
+ setValidationErrors([])
240
+ if (local.disabled || atLimit()) return
241
+ const list = e.dataTransfer?.files
242
+ if (!list?.length) return
243
+ const newFiles = Array.from(list)
244
+ const { valid, errors } = validateFiles(
245
+ newFiles,
246
+ local.accept,
247
+ local.maxFileSize,
248
+ maxFiles(),
249
+ local.files.length
250
+ )
251
+ setValidationErrors(errors)
252
+ if (valid.length) local.onAddFiles(valid)
253
+ }
254
+
255
+ const handleDragOver = (e: DragEvent) => {
256
+ e.preventDefault()
257
+ }
258
+
259
+ const handleDragEnter = (e: DragEvent) => {
260
+ e.preventDefault()
261
+ if (!isFileDrag(e) || local.disabled || atLimit()) return
262
+ setDragDepth((d) => Math.min(1, d + 1))
263
+ }
264
+
265
+ const handleDragLeave = (e: DragEvent) => {
266
+ e.preventDefault()
267
+ const next = e.relatedTarget as Node | null
268
+ if (next && (e.currentTarget as Node).contains(next)) return
269
+ setDragDepth((d) => Math.max(0, d - 1))
270
+ }
271
+
272
+ const isSingleFileMode = () => local.multiple === false || maxFiles() === 1
273
+ /** When single file and a file is selected, hide the dropzone/button so only the file row shows. */
274
+ const hideTrigger = () => isSingleFileMode() && local.files.length > 0
275
+ /** When button + fileInline and trigger visible, files show inline; otherwise show list below. When hideTrigger (single file + has file), list below is the only UI. */
276
+ const showFileListBelow = () =>
277
+ local.files.length > 0 &&
278
+ (hideTrigger() || !(variant() === 'button' && local.fileInline))
279
+ const limitsText = () => {
280
+ const parts: string[] = []
281
+ if (local.maxFileSize != null) parts.push(`Max ${formatFileSize(local.maxFileSize)}`)
282
+ if (maxFiles() === 1) parts.push('1 file')
283
+ else if (maxFiles() !== DEFAULT_MAX_FILES) parts.push(`Up to ${maxFiles()} files`)
284
+ return parts.length ? parts.join('. ') : undefined
285
+ }
286
+
287
+ const summary = () => {
288
+ const list = local.files
289
+ const done = list.filter((f) => f.status === 'done').length
290
+ const failed = list.filter((f) => f.status === 'error').length
291
+ const uploading = list.filter((f) => f.status === 'uploading').length
292
+ if (failed > 0) return `${done} uploaded, ${failed} failed`
293
+ if (uploading > 0) return `Uploading ${uploading}…`
294
+ if (done > 0) return done === 1 ? '1 file uploaded' : `${done} files uploaded`
295
+ return undefined
296
+ }
297
+
298
+ return (
299
+ <div class={cn('w-full', local.class)} {...rest}>
300
+ <Show when={local.label}>
301
+ <div
302
+ id={labelId()}
303
+ class="mb-1.5 block text-sm font-medium text-ink-700"
304
+ >
305
+ {local.label}
306
+ </div>
307
+ </Show>
308
+
309
+ {variant() === 'dropzone' && (
310
+ <Show when={!hideTrigger()}>
311
+ <div
312
+ role="group"
313
+ aria-labelledby={local.label ? labelId() : undefined}
314
+ aria-label={local.label ? undefined : 'File upload'}
315
+ class={cn(
316
+ 'rounded-lg border-2 border-dashed transition-colors',
317
+ dragOver()
318
+ ? 'border-primary-500 bg-primary-50 dark:bg-primary-500/10'
319
+ : 'border-surface-border bg-surface-base/50',
320
+ (local.disabled || atLimit()) && 'pointer-events-none opacity-50'
321
+ )}
322
+ onDrop={handleDrop}
323
+ onDragOver={handleDragOver}
324
+ onDragEnter={handleDragEnter}
325
+ onDragLeave={handleDragLeave}
326
+ >
327
+ <input
328
+ ref={mergeRefs((el: HTMLInputElement) => (inputEl = el), local.ref)}
329
+ id={id()}
330
+ type="file"
331
+ accept={local.accept}
332
+ multiple={isMultiple()}
333
+ onChange={handleInputChange}
334
+ disabled={local.disabled || atLimit()}
335
+ class="sr-only"
336
+ aria-labelledby={local.label ? labelId() : undefined}
337
+ aria-label={local.label ? undefined : (isMultiple() ? 'Choose files' : 'Choose file')}
338
+ aria-describedby={describedBy()}
339
+ aria-invalid={hasAnyError() ? 'true' : undefined}
340
+ aria-errormessage={ariaErrorMessage()}
341
+ />
342
+ <Show
343
+ when={local.browseButton}
344
+ fallback={
345
+ <div
346
+ role="button"
347
+ tabIndex={zoneDisabled() ? -1 : 0}
348
+ aria-disabled={zoneDisabled() ? 'true' : undefined}
349
+ onClick={() => !zoneDisabled() && inputEl?.click()}
350
+ onKeyDown={(e: KeyboardEvent) => {
351
+ if (zoneDisabled()) return
352
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); inputEl?.click() }
353
+ }}
354
+ class="flex min-h-[120px] cursor-pointer flex-col items-center justify-center gap-1 px-4 py-6 text-center focus:outline-none focus:ring-2 focus:ring-inset focus:ring-primary-500 rounded-lg"
355
+ >
356
+ <FileUp class="h-8 w-8 text-ink-400" />
357
+ <span class="text-sm font-medium text-ink-700">
358
+ Choose a file or drag & drop here
359
+ </span>
360
+ <Show when={limitsText()}>
361
+ <span class="text-xs text-ink-500">{limitsText()}</span>
362
+ </Show>
363
+ <Show when={local.description && !limitsText()}>
364
+ <span class="text-xs text-ink-500">{local.description}</span>
365
+ </Show>
366
+ </div>
367
+ }
368
+ >
369
+ <div class="flex min-h-[140px] flex-col items-center justify-center gap-3 px-4 py-6">
370
+ <div class="flex flex-col items-center justify-center gap-1 text-center">
371
+ <FileUp class="h-10 w-10 text-ink-400" />
372
+ <span class="text-sm font-medium text-ink-700">
373
+ Drag and drop your files here
374
+ </span>
375
+ <span class="text-sm text-ink-500">
376
+ or click Browse below
377
+ </span>
378
+ <Show when={limitsText()}>
379
+ <span class="text-xs text-ink-500">{limitsText()}</span>
380
+ </Show>
381
+ <Show when={local.description && !limitsText()}>
382
+ <span class="text-xs text-ink-500">{local.description}</span>
383
+ </Show>
384
+ </div>
385
+ <button
386
+ type="button"
387
+ onClick={() => inputEl?.click()}
388
+ disabled={local.disabled || atLimit()}
389
+ class={cn(
390
+ 'inline-flex items-center gap-2 rounded-lg bg-primary-500 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-ink-900',
391
+ (local.disabled || atLimit()) && 'opacity-50 cursor-not-allowed'
392
+ )}
393
+ >
394
+ <FileUp class="h-4 w-4" />
395
+ Browse
396
+ </button>
397
+ </div>
398
+ </Show>
399
+ <Show when={local.actions}>
400
+ <div class="flex w-full items-center justify-between gap-2 border-t border-surface-border px-4 py-3">
401
+ {local.actions}
402
+ </div>
403
+ </Show>
404
+ </div>
405
+ </Show>
406
+ )}
407
+
408
+ {variant() === 'button' && (
409
+ <>
410
+ <Show when={!hideTrigger()}>
411
+ <div class="flex flex-wrap items-center gap-2">
412
+ <input
413
+ ref={mergeRefs((el: HTMLInputElement) => (inputEl = el), local.ref)}
414
+ id={id()}
415
+ type="file"
416
+ accept={local.accept}
417
+ multiple={isMultiple()}
418
+ onChange={handleInputChange}
419
+ disabled={local.disabled || atLimit()}
420
+ class="sr-only"
421
+ aria-labelledby={local.label ? labelId() : undefined}
422
+ aria-label={local.label ? undefined : 'Browse files'}
423
+ aria-describedby={describedBy()}
424
+ aria-invalid={hasAnyError() ? 'true' : undefined}
425
+ aria-errormessage={ariaErrorMessage()}
426
+ />
427
+ <button
428
+ type="button"
429
+ onClick={() => inputEl?.click()}
430
+ disabled={local.disabled || atLimit()}
431
+ class={cn(
432
+ 'inline-flex items-center gap-2 rounded-lg border border-surface-border bg-surface-raised px-3 py-2 text-sm font-medium text-ink-700 hover:bg-surface-overlay transition-colors',
433
+ (local.disabled || atLimit()) && 'opacity-50 cursor-not-allowed'
434
+ )}
435
+ >
436
+ <FileUp class="h-4 w-4" />
437
+ Browse Files
438
+ </button>
439
+ <Show when={local.description}>
440
+ <span class="text-sm text-ink-500">{local.description}</span>
441
+ </Show>
442
+ <Show when={local.fileInline && local.files.length > 0}>
443
+ <div class="flex min-w-0 flex-1 items-center gap-2 rounded-lg border border-surface-border bg-surface-raised px-3 py-2">
444
+ <Show
445
+ when={local.files.length === 1 && local.files[0]}
446
+ fallback={
447
+ <span class="min-w-0 flex-1 text-sm text-ink-900">
448
+ {local.files.length === 1 ? '1 file' : `${local.files.length} files`}
449
+ </span>
450
+ }
451
+ >
452
+ <>
453
+ {local.files[0] && fileIcon(local.files[0].file)}
454
+ <span class="min-w-0 flex-1 truncate text-sm font-medium text-ink-900">
455
+ {local.files[0]?.file.name}
456
+ </span>
457
+ <span class="shrink-0 text-xs text-ink-500">
458
+ {local.files[0] && formatFileSize(local.files[0].file.size)}
459
+ </span>
460
+ </>
461
+ </Show>
462
+ <button
463
+ type="button"
464
+ onClick={() => setViewModalOpen(true)}
465
+ class="shrink-0 rounded p-1.5 text-ink-500 hover:bg-ink-100 hover:text-ink-700 dark:hover:bg-ink-800 dark:hover:text-ink-200"
466
+ aria-label="View files"
467
+ >
468
+ <Eye class="h-4 w-4" />
469
+ </button>
470
+ </div>
471
+ </Show>
472
+ <Show when={local.actions}>{local.actions}</Show>
473
+ </div>
474
+ </Show>
475
+ <Dialog
476
+ open={viewModalOpen()}
477
+ onClose={() => setViewModalOpen(false)}
478
+ size="md"
479
+ showCloseButton
480
+ >
481
+ <h2 class="text-lg font-semibold text-ink-900">
482
+ {local.files.length === 1 ? '1 file' : `${local.files.length} files`}
483
+ </h2>
484
+ <ul class="mt-3 space-y-2" aria-label="Uploaded files">
485
+ <For each={local.files}>
486
+ {(item) => (
487
+ <li class="flex items-center gap-2 rounded-lg border border-surface-border bg-surface-base px-3 py-2">
488
+ {fileIcon(item.file)}
489
+ <span class="min-w-0 flex-1 truncate text-sm font-medium text-ink-900">
490
+ {item.file.name}
491
+ </span>
492
+ <span class="shrink-0 text-xs text-ink-500">
493
+ {formatFileSize(item.file.size)}
494
+ </span>
495
+ <span class="shrink-0 text-xs text-ink-500">
496
+ {item.status === 'done' && 'Uploaded'}
497
+ {item.status === 'uploading' && (item.progress != null ? `${item.progress}%` : '…')}
498
+ {item.status === 'error' && (item.error ?? 'Failed')}
499
+ {item.status === 'pending' && 'Pending'}
500
+ </span>
501
+ <div class="flex shrink-0 items-center gap-1">
502
+ {item.status === 'error' && local.onRetry && (
503
+ <button
504
+ type="button"
505
+ onClick={() => local.onRetry?.(item.id)}
506
+ class="rounded p-1 text-ink-500 hover:bg-ink-100 hover:text-ink-700 dark:hover:bg-ink-800 dark:hover:text-ink-200"
507
+ aria-label={`Retry ${item.file.name}`}
508
+ >
509
+ <RefreshCw class="h-4 w-4" />
510
+ </button>
511
+ )}
512
+ <button
513
+ type="button"
514
+ onClick={() => local.onRemove(item.id)}
515
+ class="rounded p-1 text-ink-500 hover:bg-ink-100 hover:text-ink-700 dark:hover:bg-ink-800 dark:hover:text-ink-200"
516
+ aria-label={`Remove ${item.file.name}`}
517
+ >
518
+ <Trash2 class="h-4 w-4" />
519
+ </button>
520
+ </div>
521
+ </li>
522
+ )}
523
+ </For>
524
+ </ul>
525
+ </Dialog>
526
+ </>
527
+ )}
528
+
529
+ {/* File list below (when not button+fileInline, or when dropzone / single file with trigger hidden) */}
530
+ <Show when={showFileListBelow()}>
531
+ <ul class="mt-3 space-y-2" aria-label="Uploaded files">
532
+ <For each={local.files}>
533
+ {(item) => (
534
+ <li class="flex flex-col gap-1.5 rounded-lg border border-surface-border bg-surface-raised px-3 py-2">
535
+ <div class="flex items-center gap-2">
536
+ {fileIcon(item.file)}
537
+ <span class="min-w-0 flex-1 truncate text-sm font-medium text-ink-900">
538
+ {item.file.name}
539
+ </span>
540
+ <span class="shrink-0 text-xs text-ink-500">
541
+ {formatFileSize(item.file.size)}
542
+ </span>
543
+ <span class="shrink-0 text-xs text-ink-500">
544
+ {item.status === 'uploading' && (item.progress != null ? `${item.progress}%` : '…')}
545
+ {item.status === 'done' && 'Uploaded'}
546
+ {item.status === 'error' && (item.error ?? 'Failed')}
547
+ {item.status === 'pending' && 'Pending'}
548
+ </span>
549
+ <div class="flex shrink-0 items-center gap-1">
550
+ {item.status === 'uploading' && (
551
+ <LoaderCircle class="h-4 w-4 animate-spin text-ink-400" aria-hidden="true" />
552
+ )}
553
+ {item.status === 'error' && local.onRetry && (
554
+ <button
555
+ type="button"
556
+ onClick={() => local.onRetry?.(item.id)}
557
+ class="rounded p-1 text-ink-500 hover:bg-ink-100 hover:text-ink-700 dark:hover:bg-ink-800 dark:hover:text-ink-200"
558
+ aria-label={`Retry ${item.file.name}`}
559
+ >
560
+ <RefreshCw class="h-4 w-4" />
561
+ </button>
562
+ )}
563
+ <button
564
+ type="button"
565
+ onClick={() => local.onRemove(item.id)}
566
+ class="rounded p-1 text-ink-500 hover:bg-ink-100 hover:text-ink-700 dark:hover:bg-ink-800 dark:hover:text-ink-200"
567
+ aria-label={`Remove ${item.file.name}`}
568
+ >
569
+ <Trash2 class="h-4 w-4" />
570
+ </button>
571
+ </div>
572
+ </div>
573
+ <Show when={item.status === 'uploading' && item.progress != null}>
574
+ <Progress
575
+ value={item.progress}
576
+ size="sm"
577
+ showValueLabel={false}
578
+ class="h-1.5"
579
+ aria-label={`Upload progress for ${item.file.name}`}
580
+ />
581
+ </Show>
582
+ </li>
583
+ )}
584
+ </For>
585
+ </ul>
586
+ </Show>
587
+
588
+ <Show when={summary() && !(variant() === 'button' && local.fileInline)}>
589
+ <p class="mt-2 text-xs text-ink-500">{summary()}</p>
590
+ </Show>
591
+
592
+ <Show when={local.description || limitsText()}>
593
+ <span id={descId()} class="sr-only">
594
+ {/* In button variant, description is visible — only include limits here to avoid double-announce */}
595
+ {variant() === 'button' ? limitsText() : <>{local.description}{local.description && limitsText() ? ' ' : ''}{limitsText()}</>}
596
+ </span>
597
+ </Show>
598
+
599
+ <Show when={validationErrors().length > 0}>
600
+ <ul id={validationId()} role="alert" class="mt-2 flex flex-col gap-0.5 text-sm text-danger-600 dark:text-danger-400">
601
+ <For each={validationErrors()}>
602
+ {(msg) => <li>{msg}</li>}
603
+ </For>
604
+ </ul>
605
+ </Show>
606
+
607
+ <Show when={local.error}>
608
+ <p id={errorId()} class="mt-2 flex items-center gap-1.5 text-sm text-danger-600 dark:text-danger-400">
609
+ {local.error}
610
+ </p>
611
+ </Show>
612
+ </div>
613
+ )
614
+ }
@@ -0,0 +1,6 @@
1
+ export interface FilterFieldConfig {
2
+ id: string
3
+ label: string
4
+ type: string
5
+ options?: any[]
6
+ }
@@ -0,0 +1,16 @@
1
+ import type { JSX } from 'solid-js'
2
+
3
+ export interface FilterBuilderProps {
4
+ value: () => any
5
+ onChange: (group: any) => void
6
+ fields: any[]
7
+ getOperators: (type: string) => any[]
8
+ }
9
+
10
+ export function FilterBuilder(props: FilterBuilderProps): JSX.Element {
11
+ return (
12
+ <div class="p-4 border rounded-md">
13
+ <div class="text-sm text-gray-600">FilterBuilder placeholder</div>
14
+ </div>
15
+ )
16
+ }