@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.
- package/README.md +166 -0
- package/package.json +67 -0
- package/src/components/actions/Button.tsx +612 -0
- package/src/components/actions/ButtonGroup.tsx +728 -0
- package/src/components/actions/Copy.tsx +98 -0
- package/src/components/actions/DarkModeToggle.tsx +80 -0
- package/src/components/actions/Link.tsx +37 -0
- package/src/components/actions/index.ts +19 -0
- package/src/components/actions/useCopyToClipboard.ts +90 -0
- package/src/components/charts/Chart.tsx +331 -0
- package/src/components/charts/Sparkline.tsx +156 -0
- package/src/components/charts/index.ts +13 -0
- package/src/components/data-display/Avatar.tsx +208 -0
- package/src/components/data-display/AvatarGroup.tsx +228 -0
- package/src/components/data-display/Badge.tsx +70 -0
- package/src/components/data-display/Carousel.tsx +214 -0
- package/src/components/data-display/ColorSwatch.tsx +56 -0
- package/src/components/data-display/DataTable.tsx +886 -0
- package/src/components/data-display/EmptyState.tsx +61 -0
- package/src/components/data-display/Image.tsx +277 -0
- package/src/components/data-display/Kbd.tsx +114 -0
- package/src/components/data-display/Persona.tsx +78 -0
- package/src/components/data-display/StatCard.tsx +338 -0
- package/src/components/data-display/Table.tsx +147 -0
- package/src/components/data-display/Tag.tsx +91 -0
- package/src/components/data-display/Timeline.tsx +200 -0
- package/src/components/data-display/TreeView.tsx +172 -0
- package/src/components/data-display/Video.tsx +95 -0
- package/src/components/data-display/avatar-utils.ts +32 -0
- package/src/components/data-display/index.ts +81 -0
- package/src/components/feedback/Loading.tsx +159 -0
- package/src/components/feedback/Progress.tsx +321 -0
- package/src/components/feedback/Skeleton.tsx +62 -0
- package/src/components/feedback/SkeletonBlocks.tsx +222 -0
- package/src/components/feedback/Toast.tsx +648 -0
- package/src/components/feedback/index.ts +44 -0
- package/src/components/feedback/password/PasswordStrengthIndicator.tsx +232 -0
- package/src/components/feedback/password/password-strength.ts +115 -0
- package/src/components/feedback/password/password-validation-data.ts +66 -0
- package/src/components/feedback/password/password-validation.ts +93 -0
- package/src/components/forms/Autocomplete.tsx +268 -0
- package/src/components/forms/Checkbox.tsx +155 -0
- package/src/components/forms/CodeInput.tsx +237 -0
- package/src/components/forms/ColorPicker/ColorPicker.tsx +469 -0
- package/src/components/forms/ColorPicker/color-utils.ts +75 -0
- package/src/components/forms/ColorPicker/index.ts +2 -0
- package/src/components/forms/DatePicker.tsx +516 -0
- package/src/components/forms/DateRangePicker.tsx +464 -0
- package/src/components/forms/FieldPicker.tsx +64 -0
- package/src/components/forms/FileUpload.tsx +614 -0
- package/src/components/forms/FilterBuilder/FilterGroupBlock.ts +6 -0
- package/src/components/forms/FilterBuilder.tsx +16 -0
- package/src/components/forms/FilterRuleRow.tsx +68 -0
- package/src/components/forms/Input.tsx +200 -0
- package/src/components/forms/MultiSelect.tsx +361 -0
- package/src/components/forms/NumberField.tsx +145 -0
- package/src/components/forms/RadioGroup.tsx +135 -0
- package/src/components/forms/RelativeDateDefaultInput.tsx +62 -0
- package/src/components/forms/ReorderableList.tsx +163 -0
- package/src/components/forms/Select.tsx +268 -0
- package/src/components/forms/Slider.tsx +260 -0
- package/src/components/forms/Switch.tsx +135 -0
- package/src/components/forms/TextArea.tsx +202 -0
- package/src/components/forms/ViewCustomizer.tsx +44 -0
- package/src/components/forms/index.ts +43 -0
- package/src/components/layout/Accordion.tsx +110 -0
- package/src/components/layout/Alert.tsx +156 -0
- package/src/components/layout/BlockQuote.tsx +70 -0
- package/src/components/layout/Card.tsx +166 -0
- package/src/components/layout/CodeBlock/CodeBlock.tsx +477 -0
- package/src/components/layout/CodeBlock/code-block-tokens.css +104 -0
- package/src/components/layout/CodeBlock/prism.ts +81 -0
- package/src/components/layout/Collapsible.tsx +84 -0
- package/src/components/layout/Container.tsx +55 -0
- package/src/components/layout/Divider.tsx +64 -0
- package/src/components/layout/Form.tsx +39 -0
- package/src/components/layout/FormActions.tsx +50 -0
- package/src/components/layout/Grid.tsx +53 -0
- package/src/components/layout/PageHeading.tsx +46 -0
- package/src/components/layout/PromptWithAction.tsx +49 -0
- package/src/components/layout/Section.tsx +60 -0
- package/src/components/layout/TablePanel.tsx +24 -0
- package/src/components/layout/TableView/TableView.tsx +1018 -0
- package/src/components/layout/TableView/index.ts +3 -0
- package/src/components/layout/TableView/types.ts +51 -0
- package/src/components/layout/WizardStep.tsx +40 -0
- package/src/components/layout/WizardStepper.tsx +173 -0
- package/src/components/layout/index.ts +96 -0
- package/src/components/navigation/Breadcrumbs.tsx +66 -0
- package/src/components/navigation/DropdownMenu.tsx +86 -0
- package/src/components/navigation/MegaMenu.tsx +480 -0
- package/src/components/navigation/NavigationMenu.tsx +305 -0
- package/src/components/navigation/Pagination.tsx +298 -0
- package/src/components/navigation/Sidebar.tsx +280 -0
- package/src/components/navigation/Tabs.tsx +122 -0
- package/src/components/navigation/ViewSwitcher.tsx +314 -0
- package/src/components/navigation/index.ts +66 -0
- package/src/components/overlays/AlertDialog.tsx +174 -0
- package/src/components/overlays/ContextMenu.tsx +65 -0
- package/src/components/overlays/Dialog.tsx +279 -0
- package/src/components/overlays/Drawer.tsx +370 -0
- package/src/components/overlays/HoverCard.tsx +107 -0
- package/src/components/overlays/Popover.tsx +73 -0
- package/src/components/overlays/Tooltip.tsx +31 -0
- package/src/components/overlays/index.ts +71 -0
- package/src/components/typography/Code.tsx +72 -0
- package/src/components/typography/Icon.tsx +36 -0
- package/src/components/typography/index.ts +10 -0
- package/src/env.d.ts +9 -0
- package/src/index.ts +13 -0
- package/src/styles/theme.css +226 -0
- package/src/types/avatar-types.ts +11 -0
- package/src/types/filter-types.ts +35 -0
- package/src/utilities/classNames.ts +6 -0
- package/src/utilities/componentSize.ts +46 -0
- package/src/utilities/i18n.tsx +60 -0
- package/src/utilities/mergeRefs.ts +12 -0
- 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,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
|
+
}
|