sv5ui 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/CheckboxGroup/CheckboxGroup.svelte +215 -0
- package/dist/CheckboxGroup/CheckboxGroup.svelte.d.ts +5 -0
- package/dist/CheckboxGroup/checkbox-group.types.d.ts +130 -0
- package/dist/CheckboxGroup/checkbox-group.types.js +1 -0
- package/dist/CheckboxGroup/checkbox-group.variants.d.ts +553 -0
- package/dist/CheckboxGroup/checkbox-group.variants.js +231 -0
- package/dist/CheckboxGroup/index.d.ts +2 -0
- package/dist/CheckboxGroup/index.js +1 -0
- package/dist/FileUpload/FileUpload.svelte +561 -0
- package/dist/FileUpload/FileUpload.svelte.d.ts +8 -0
- package/dist/FileUpload/file-upload.types.d.ts +164 -0
- package/dist/FileUpload/file-upload.types.js +1 -0
- package/dist/FileUpload/file-upload.variants.d.ts +397 -0
- package/dist/FileUpload/file-upload.variants.js +224 -0
- package/dist/FileUpload/index.d.ts +2 -0
- package/dist/FileUpload/index.js +1 -0
- package/dist/PinInput/PinInput.svelte +150 -0
- package/dist/PinInput/PinInput.svelte.d.ts +6 -0
- package/dist/PinInput/index.d.ts +2 -0
- package/dist/PinInput/index.js +1 -0
- package/dist/PinInput/pin-input.types.d.ts +99 -0
- package/dist/PinInput/pin-input.types.js +1 -0
- package/dist/PinInput/pin-input.variants.d.ts +303 -0
- package/dist/PinInput/pin-input.variants.js +196 -0
- package/dist/Slider/Slider.svelte +135 -0
- package/dist/Slider/Slider.svelte.d.ts +6 -0
- package/dist/Slider/index.d.ts +2 -0
- package/dist/Slider/index.js +1 -0
- package/dist/Slider/slider.types.d.ts +55 -0
- package/dist/Slider/slider.types.js +1 -0
- package/dist/Slider/slider.variants.d.ts +383 -0
- package/dist/Slider/slider.variants.js +102 -0
- package/dist/config.d.ts +4 -0
- package/dist/config.js +5 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/package.json +1 -1
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { FileUploadProps } from './file-upload.types.js'
|
|
3
|
+
|
|
4
|
+
export type Props = FileUploadProps
|
|
5
|
+
</script>
|
|
6
|
+
|
|
7
|
+
<script lang="ts">
|
|
8
|
+
import { useId } from 'bits-ui'
|
|
9
|
+
import { untrack } from 'svelte'
|
|
10
|
+
import { fileUploadVariants, fileUploadDefaults } from './file-upload.variants.js'
|
|
11
|
+
import { getComponentConfig, iconsDefaults } from '../config.js'
|
|
12
|
+
import Icon from '../Icon/Icon.svelte'
|
|
13
|
+
import Button from '../Button/Button.svelte'
|
|
14
|
+
import Modal from '../Modal/Modal.svelte'
|
|
15
|
+
|
|
16
|
+
const config = getComponentConfig('fileUpload', fileUploadDefaults)
|
|
17
|
+
const icons = getComponentConfig('icons', iconsDefaults)
|
|
18
|
+
|
|
19
|
+
let {
|
|
20
|
+
ref = $bindable(null),
|
|
21
|
+
value = $bindable([]),
|
|
22
|
+
onValueChange,
|
|
23
|
+
multiple = false,
|
|
24
|
+
accept,
|
|
25
|
+
dropzone = true,
|
|
26
|
+
interactive = true,
|
|
27
|
+
label = 'Drop files here or click to upload',
|
|
28
|
+
description,
|
|
29
|
+
icon = icons.upload,
|
|
30
|
+
color = config.defaultVariants.color,
|
|
31
|
+
size = config.defaultVariants.size,
|
|
32
|
+
variant = config.defaultVariants.variant,
|
|
33
|
+
layout = config.defaultVariants.layout,
|
|
34
|
+
disabled = false,
|
|
35
|
+
loading = false,
|
|
36
|
+
loadingIcon = icons.loading,
|
|
37
|
+
preview = true,
|
|
38
|
+
highlight = false,
|
|
39
|
+
required = false,
|
|
40
|
+
fileIcon = icons.file,
|
|
41
|
+
imagePreview = true,
|
|
42
|
+
name,
|
|
43
|
+
leadingSlot,
|
|
44
|
+
labelSlot,
|
|
45
|
+
descriptionSlot,
|
|
46
|
+
actionsSlot,
|
|
47
|
+
filesSlot,
|
|
48
|
+
fileSlot,
|
|
49
|
+
children,
|
|
50
|
+
class: className,
|
|
51
|
+
ui,
|
|
52
|
+
...restProps
|
|
53
|
+
}: Props = $props()
|
|
54
|
+
|
|
55
|
+
const autoId = useId()
|
|
56
|
+
|
|
57
|
+
let inputRef = $state<HTMLInputElement | null>(null)
|
|
58
|
+
let dragCounter = $state(0)
|
|
59
|
+
let previewOpen = $state(false)
|
|
60
|
+
let previewFile = $state<File | null>(null)
|
|
61
|
+
|
|
62
|
+
// Stable file identity key with separator to avoid collisions
|
|
63
|
+
const fileKey = (f: File) => `${f.name}:${f.size}:${f.lastModified}`
|
|
64
|
+
|
|
65
|
+
// Plain Map intentionally — urlCache is an internal optimization cache, not reactive UI
|
|
66
|
+
// state. SvelteMap mutations would cascade re-renders every time a URL is cached/evicted.
|
|
67
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
68
|
+
const urlCache = new Map<string, string>()
|
|
69
|
+
|
|
70
|
+
const isDisabled = $derived(disabled || loading)
|
|
71
|
+
const isDragging = $derived(dragCounter > 0)
|
|
72
|
+
const showFilesInside = $derived(
|
|
73
|
+
layout === 'grid' && !multiple && value.length > 0 && preview && variant === 'area'
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
// Pass booleans directly so compound variants with `false` values match correctly
|
|
77
|
+
const slots = $derived(
|
|
78
|
+
fileUploadVariants({
|
|
79
|
+
color,
|
|
80
|
+
size,
|
|
81
|
+
variant,
|
|
82
|
+
layout,
|
|
83
|
+
dropzone,
|
|
84
|
+
interactive: interactive && !isDisabled,
|
|
85
|
+
highlight,
|
|
86
|
+
multiple,
|
|
87
|
+
disabled: isDisabled
|
|
88
|
+
})
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
const classes = $derived.by(() => {
|
|
92
|
+
const u = ui ?? {}
|
|
93
|
+
return {
|
|
94
|
+
root: slots.root({ class: [config.slots.root, className, u.root] }),
|
|
95
|
+
base: slots.base({ class: [config.slots.base, u.base] }),
|
|
96
|
+
wrapper: slots.wrapper({ class: [config.slots.wrapper, u.wrapper] }),
|
|
97
|
+
icon: slots.icon({ class: [config.slots.icon, u.icon] }),
|
|
98
|
+
label: slots.label({ class: [config.slots.label, u.label] }),
|
|
99
|
+
description: slots.description({ class: [config.slots.description, u.description] }),
|
|
100
|
+
actions: slots.actions({ class: [config.slots.actions, u.actions] }),
|
|
101
|
+
files: slots.files({ class: [config.slots.files, u.files] }),
|
|
102
|
+
file: slots.file({ class: [config.slots.file, u.file] }),
|
|
103
|
+
fileLeading: slots.fileLeading({ class: [config.slots.fileLeading, u.fileLeading] }),
|
|
104
|
+
fileWrapper: slots.fileWrapper({ class: [config.slots.fileWrapper, u.fileWrapper] }),
|
|
105
|
+
fileName: slots.fileName({ class: [config.slots.fileName, u.fileName] }),
|
|
106
|
+
fileSize: slots.fileSize({ class: [config.slots.fileSize, u.fileSize] }),
|
|
107
|
+
fileTrailing: slots.fileTrailing({
|
|
108
|
+
class: [config.slots.fileTrailing, u.fileTrailing]
|
|
109
|
+
}),
|
|
110
|
+
previewContent: slots.previewContent({
|
|
111
|
+
class: [config.slots.previewContent, u.previewContent]
|
|
112
|
+
}),
|
|
113
|
+
previewBody: slots.previewBody({ class: [config.slots.previewBody, u.previewBody] })
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
// Revoke blob URLs for files no longer in value (handles external bind:value resets)
|
|
118
|
+
$effect(() => {
|
|
119
|
+
const current = new Set(value.map(fileKey))
|
|
120
|
+
// Close preview if its file was removed externally — untrack to avoid subscribing to previewFile
|
|
121
|
+
const pf = untrack(() => previewFile)
|
|
122
|
+
if (pf && !current.has(fileKey(pf))) {
|
|
123
|
+
previewOpen = false
|
|
124
|
+
previewFile = null
|
|
125
|
+
}
|
|
126
|
+
for (const [key, url] of urlCache) {
|
|
127
|
+
if (!current.has(key)) {
|
|
128
|
+
URL.revokeObjectURL(url)
|
|
129
|
+
urlCache.delete(key)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
export function open() {
|
|
135
|
+
if (isDisabled) return
|
|
136
|
+
inputRef?.click()
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function isFileAccepted(file: File): boolean {
|
|
140
|
+
if (!accept) return true
|
|
141
|
+
return accept
|
|
142
|
+
.split(',')
|
|
143
|
+
.map((s) => s.trim())
|
|
144
|
+
.some((token) => {
|
|
145
|
+
if (!token || token === '*') return true
|
|
146
|
+
if (token.startsWith('.'))
|
|
147
|
+
return file.name.toLowerCase().endsWith(token.toLowerCase())
|
|
148
|
+
if (token.endsWith('/*')) return file.type.startsWith(token.slice(0, -1))
|
|
149
|
+
return file.type === token
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function addFiles(newFiles: File[]) {
|
|
154
|
+
if (isDisabled) return
|
|
155
|
+
const filtered = accept ? newFiles.filter(isFileAccepted) : newFiles
|
|
156
|
+
if (!filtered.length) return
|
|
157
|
+
if (!multiple) {
|
|
158
|
+
value = [filtered[0]]
|
|
159
|
+
} else {
|
|
160
|
+
const existing = new Set(value.map(fileKey))
|
|
161
|
+
value = [...value, ...filtered.filter((f) => !existing.has(fileKey(f)))]
|
|
162
|
+
}
|
|
163
|
+
onValueChange?.(value)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function removeFile(index: number) {
|
|
167
|
+
const removed = value[index]
|
|
168
|
+
const key = fileKey(removed)
|
|
169
|
+
if (previewFile && fileKey(previewFile) === key) {
|
|
170
|
+
previewOpen = false
|
|
171
|
+
previewFile = null
|
|
172
|
+
}
|
|
173
|
+
if (urlCache.has(key)) {
|
|
174
|
+
URL.revokeObjectURL(urlCache.get(key)!)
|
|
175
|
+
urlCache.delete(key)
|
|
176
|
+
}
|
|
177
|
+
value = value.filter((_, i) => i !== index)
|
|
178
|
+
onValueChange?.(value)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function clearAll() {
|
|
182
|
+
previewOpen = false
|
|
183
|
+
previewFile = null
|
|
184
|
+
for (const [, url] of urlCache) URL.revokeObjectURL(url)
|
|
185
|
+
urlCache.clear()
|
|
186
|
+
value = []
|
|
187
|
+
onValueChange?.(value)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function handleInputChange(e: Event) {
|
|
191
|
+
const input = e.target as HTMLInputElement
|
|
192
|
+
if (input.files?.length) {
|
|
193
|
+
addFiles(Array.from(input.files))
|
|
194
|
+
input.value = ''
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function handleDrop(e: DragEvent) {
|
|
199
|
+
e.preventDefault()
|
|
200
|
+
dragCounter = 0
|
|
201
|
+
if (!dropzone || isDisabled) return
|
|
202
|
+
const files = e.dataTransfer?.files
|
|
203
|
+
if (files?.length) addFiles(Array.from(files))
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function handleDragEnter(e: DragEvent) {
|
|
207
|
+
e.preventDefault()
|
|
208
|
+
if (dropzone && !isDisabled) dragCounter++
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function handleDragOver(e: DragEvent) {
|
|
212
|
+
e.preventDefault()
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function handleDragLeave() {
|
|
216
|
+
dragCounter = Math.max(0, dragCounter - 1)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function handleAreaClick() {
|
|
220
|
+
if (interactive && !isDisabled) open()
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function handleKeydown(e: KeyboardEvent) {
|
|
224
|
+
if (!interactive) return
|
|
225
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
226
|
+
e.preventDefault()
|
|
227
|
+
open()
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function formatFileSize(bytes: number): string {
|
|
232
|
+
if (bytes === 0) return '0 B'
|
|
233
|
+
const k = 1024
|
|
234
|
+
const sizes = ['B', 'KB', 'MB', 'GB']
|
|
235
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
|
236
|
+
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function isImageFile(file: File): boolean {
|
|
240
|
+
return file.type.startsWith('image/')
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function openPreview(file: File) {
|
|
244
|
+
previewFile = file
|
|
245
|
+
previewOpen = true
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function getObjectUrl(file: File): string {
|
|
249
|
+
const key = fileKey(file)
|
|
250
|
+
if (!urlCache.has(key)) {
|
|
251
|
+
urlCache.set(key, URL.createObjectURL(file))
|
|
252
|
+
}
|
|
253
|
+
return urlCache.get(key)!
|
|
254
|
+
}
|
|
255
|
+
</script>
|
|
256
|
+
|
|
257
|
+
<div {...restProps} bind:this={ref} class={classes.root}>
|
|
258
|
+
<!-- Hidden file input — uses auto-generated id internally -->
|
|
259
|
+
<input
|
|
260
|
+
bind:this={inputRef}
|
|
261
|
+
type="file"
|
|
262
|
+
id={autoId}
|
|
263
|
+
{name}
|
|
264
|
+
{accept}
|
|
265
|
+
{multiple}
|
|
266
|
+
{required}
|
|
267
|
+
disabled={isDisabled}
|
|
268
|
+
onchange={handleInputChange}
|
|
269
|
+
class="sr-only"
|
|
270
|
+
tabindex="-1"
|
|
271
|
+
aria-hidden="true"
|
|
272
|
+
/>
|
|
273
|
+
|
|
274
|
+
{#if variant === 'area'}
|
|
275
|
+
<!-- Area / Dropzone -->
|
|
276
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
277
|
+
<div
|
|
278
|
+
class={classes.base}
|
|
279
|
+
data-dragging={isDragging ? '' : undefined}
|
|
280
|
+
role={interactive ? 'button' : undefined}
|
|
281
|
+
tabindex={interactive && !isDisabled ? 0 : undefined}
|
|
282
|
+
aria-disabled={isDisabled || undefined}
|
|
283
|
+
aria-label={interactive ? label : undefined}
|
|
284
|
+
onclick={handleAreaClick}
|
|
285
|
+
ondragenter={handleDragEnter}
|
|
286
|
+
ondragover={handleDragOver}
|
|
287
|
+
ondragleave={handleDragLeave}
|
|
288
|
+
ondrop={handleDrop}
|
|
289
|
+
onkeydown={handleKeydown}
|
|
290
|
+
>
|
|
291
|
+
{#if showFilesInside}
|
|
292
|
+
<!-- Grid single: file fills the area as overlay -->
|
|
293
|
+
{#each value as file, i (fileKey(file))}
|
|
294
|
+
<div class={classes.file} role="none" onclick={(e) => e.stopPropagation()}>
|
|
295
|
+
{#if isImageFile(file)}
|
|
296
|
+
{#if imagePreview}
|
|
297
|
+
<button
|
|
298
|
+
class="group relative size-full cursor-zoom-in overflow-hidden rounded-[7px]"
|
|
299
|
+
onclick={() => openPreview(file)}
|
|
300
|
+
aria-label="Preview {file.name}"
|
|
301
|
+
>
|
|
302
|
+
<img
|
|
303
|
+
src={getObjectUrl(file)}
|
|
304
|
+
alt={file.name}
|
|
305
|
+
class="size-full object-cover transition-[filter] duration-200 group-hover:brightness-75"
|
|
306
|
+
/>
|
|
307
|
+
<div
|
|
308
|
+
class="absolute inset-0 flex items-center justify-center opacity-0 transition-opacity duration-200 group-hover:opacity-100"
|
|
309
|
+
>
|
|
310
|
+
<Icon
|
|
311
|
+
name={icons.zoomIn}
|
|
312
|
+
class="size-6 text-white drop-shadow-md"
|
|
313
|
+
/>
|
|
314
|
+
</div>
|
|
315
|
+
</button>
|
|
316
|
+
{:else}
|
|
317
|
+
<div class="size-full overflow-hidden rounded-[7px]">
|
|
318
|
+
<img
|
|
319
|
+
src={getObjectUrl(file)}
|
|
320
|
+
alt={file.name}
|
|
321
|
+
class="size-full object-cover"
|
|
322
|
+
/>
|
|
323
|
+
</div>
|
|
324
|
+
{/if}
|
|
325
|
+
{:else}
|
|
326
|
+
<div
|
|
327
|
+
class="flex size-full flex-col items-center justify-center gap-1 p-4 text-on-surface-variant"
|
|
328
|
+
>
|
|
329
|
+
<Icon name={fileIcon} class="size-10 shrink-0" />
|
|
330
|
+
<span class="w-full truncate text-center text-xs">{file.name}</span>
|
|
331
|
+
</div>
|
|
332
|
+
{/if}
|
|
333
|
+
<div
|
|
334
|
+
class={classes.fileTrailing}
|
|
335
|
+
role="none"
|
|
336
|
+
onclick={(e) => e.stopPropagation()}
|
|
337
|
+
>
|
|
338
|
+
<Button
|
|
339
|
+
variant="solid"
|
|
340
|
+
color="error"
|
|
341
|
+
size="xs"
|
|
342
|
+
icon={icons.close}
|
|
343
|
+
square
|
|
344
|
+
ui={{
|
|
345
|
+
base: 'size-5 rounded-full border-2 border-surface p-0 shadow-sm'
|
|
346
|
+
}}
|
|
347
|
+
onclick={() => removeFile(i)}
|
|
348
|
+
aria-label="Remove {file.name}"
|
|
349
|
+
/>
|
|
350
|
+
</div>
|
|
351
|
+
</div>
|
|
352
|
+
{/each}
|
|
353
|
+
{:else}
|
|
354
|
+
<!-- Normal area content -->
|
|
355
|
+
<div class={classes.wrapper}>
|
|
356
|
+
{#if leadingSlot}
|
|
357
|
+
{@render leadingSlot()}
|
|
358
|
+
{:else}
|
|
359
|
+
<Icon name={loading ? loadingIcon : icon} class={classes.icon} />
|
|
360
|
+
{/if}
|
|
361
|
+
|
|
362
|
+
{#if labelSlot}
|
|
363
|
+
{@render labelSlot()}
|
|
364
|
+
{:else if label}
|
|
365
|
+
<p class={classes.label}>{label}</p>
|
|
366
|
+
{/if}
|
|
367
|
+
|
|
368
|
+
{#if descriptionSlot}
|
|
369
|
+
{@render descriptionSlot()}
|
|
370
|
+
{:else if description}
|
|
371
|
+
<p class={classes.description}>{description}</p>
|
|
372
|
+
{/if}
|
|
373
|
+
|
|
374
|
+
{#if actionsSlot}
|
|
375
|
+
<div
|
|
376
|
+
class={classes.actions}
|
|
377
|
+
role="none"
|
|
378
|
+
onclick={(e) => e.stopPropagation()}
|
|
379
|
+
>
|
|
380
|
+
{@render actionsSlot({ open })}
|
|
381
|
+
</div>
|
|
382
|
+
{/if}
|
|
383
|
+
|
|
384
|
+
{#if children}
|
|
385
|
+
{@render children()}
|
|
386
|
+
{/if}
|
|
387
|
+
</div>
|
|
388
|
+
{/if}
|
|
389
|
+
</div>
|
|
390
|
+
{:else}
|
|
391
|
+
<!-- Button variant -->
|
|
392
|
+
<Button
|
|
393
|
+
{color}
|
|
394
|
+
{size}
|
|
395
|
+
disabled={isDisabled}
|
|
396
|
+
{loading}
|
|
397
|
+
{loadingIcon}
|
|
398
|
+
leadingIcon={loading ? undefined : icon}
|
|
399
|
+
{label}
|
|
400
|
+
onclick={handleAreaClick}
|
|
401
|
+
/>
|
|
402
|
+
{/if}
|
|
403
|
+
|
|
404
|
+
<!-- File list (outside the zone) -->
|
|
405
|
+
{#if preview && value.length > 0 && !showFilesInside}
|
|
406
|
+
{#if filesSlot}
|
|
407
|
+
{@render filesSlot({ files: value })}
|
|
408
|
+
{:else}
|
|
409
|
+
<div class="flex items-center justify-between gap-2">
|
|
410
|
+
<span class="text-sm text-on-surface-variant">
|
|
411
|
+
{value.length}
|
|
412
|
+
{value.length === 1 ? 'file' : 'files'}
|
|
413
|
+
</span>
|
|
414
|
+
<Button
|
|
415
|
+
variant="ghost"
|
|
416
|
+
color="error"
|
|
417
|
+
size="xs"
|
|
418
|
+
label="Clear all"
|
|
419
|
+
leadingIcon={icons.trash}
|
|
420
|
+
onclick={clearAll}
|
|
421
|
+
/>
|
|
422
|
+
</div>
|
|
423
|
+
<div class={classes.files}>
|
|
424
|
+
{#each value as file, i (fileKey(file))}
|
|
425
|
+
{#if fileSlot}
|
|
426
|
+
{@render fileSlot({ file, index: i, remove: () => removeFile(i) })}
|
|
427
|
+
{:else if layout === 'grid'}
|
|
428
|
+
<!-- Grid item: no overflow-hidden on outer so close button can escape -->
|
|
429
|
+
<div class={classes.file}>
|
|
430
|
+
{#if isImageFile(file)}
|
|
431
|
+
{#if imagePreview}
|
|
432
|
+
<button
|
|
433
|
+
class="group relative size-full cursor-zoom-in overflow-hidden rounded-[7px]"
|
|
434
|
+
onclick={() => openPreview(file)}
|
|
435
|
+
aria-label="Preview {file.name}"
|
|
436
|
+
>
|
|
437
|
+
<img
|
|
438
|
+
src={getObjectUrl(file)}
|
|
439
|
+
alt={file.name}
|
|
440
|
+
class="size-full object-cover transition-[filter] duration-200 group-hover:brightness-75"
|
|
441
|
+
/>
|
|
442
|
+
<div
|
|
443
|
+
class="absolute inset-0 flex items-center justify-center opacity-0 transition-opacity duration-200 group-hover:opacity-100"
|
|
444
|
+
>
|
|
445
|
+
<Icon
|
|
446
|
+
name={icons.zoomIn}
|
|
447
|
+
class="size-6 text-white drop-shadow-md"
|
|
448
|
+
/>
|
|
449
|
+
</div>
|
|
450
|
+
</button>
|
|
451
|
+
{:else}
|
|
452
|
+
<div class="size-full overflow-hidden rounded-[7px]">
|
|
453
|
+
<img
|
|
454
|
+
src={getObjectUrl(file)}
|
|
455
|
+
alt={file.name}
|
|
456
|
+
class="size-full object-cover"
|
|
457
|
+
/>
|
|
458
|
+
</div>
|
|
459
|
+
{/if}
|
|
460
|
+
{:else}
|
|
461
|
+
<div
|
|
462
|
+
class="flex size-full flex-col items-center justify-center gap-1.5 overflow-hidden rounded-[7px] bg-surface-container-low p-3 text-on-surface-variant"
|
|
463
|
+
>
|
|
464
|
+
<Icon name={fileIcon} class="size-8 shrink-0" />
|
|
465
|
+
<span class="w-full truncate text-center text-xs"
|
|
466
|
+
>{file.name}</span
|
|
467
|
+
>
|
|
468
|
+
</div>
|
|
469
|
+
{/if}
|
|
470
|
+
<div class={classes.fileTrailing}>
|
|
471
|
+
<Button
|
|
472
|
+
variant="solid"
|
|
473
|
+
color="error"
|
|
474
|
+
size="xs"
|
|
475
|
+
icon={icons.close}
|
|
476
|
+
square
|
|
477
|
+
ui={{
|
|
478
|
+
base: 'size-5 rounded-full border-2 border-surface p-0 shadow-sm'
|
|
479
|
+
}}
|
|
480
|
+
onclick={() => removeFile(i)}
|
|
481
|
+
aria-label="Remove {file.name}"
|
|
482
|
+
/>
|
|
483
|
+
</div>
|
|
484
|
+
</div>
|
|
485
|
+
{:else}
|
|
486
|
+
<!-- List item -->
|
|
487
|
+
<div class={classes.file}>
|
|
488
|
+
<div class={classes.fileLeading}>
|
|
489
|
+
{#if isImageFile(file)}
|
|
490
|
+
<img
|
|
491
|
+
src={getObjectUrl(file)}
|
|
492
|
+
alt={file.name}
|
|
493
|
+
class="size-8 shrink-0 rounded object-cover"
|
|
494
|
+
/>
|
|
495
|
+
{:else}
|
|
496
|
+
<Icon
|
|
497
|
+
name={fileIcon}
|
|
498
|
+
class="size-4 shrink-0 text-on-surface-variant"
|
|
499
|
+
/>
|
|
500
|
+
{/if}
|
|
501
|
+
</div>
|
|
502
|
+
<div class={classes.fileWrapper}>
|
|
503
|
+
<span class={classes.fileName}>{file.name}</span>
|
|
504
|
+
<span class={classes.fileSize}>{formatFileSize(file.size)}</span>
|
|
505
|
+
</div>
|
|
506
|
+
<div class={classes.fileTrailing}>
|
|
507
|
+
<div class="flex items-center gap-0.5">
|
|
508
|
+
{#if isImageFile(file) && imagePreview}
|
|
509
|
+
<Button
|
|
510
|
+
variant="ghost"
|
|
511
|
+
{size}
|
|
512
|
+
icon={icons.zoomIn}
|
|
513
|
+
square
|
|
514
|
+
onclick={() => openPreview(file)}
|
|
515
|
+
aria-label="Preview {file.name}"
|
|
516
|
+
/>
|
|
517
|
+
{/if}
|
|
518
|
+
<Button
|
|
519
|
+
variant="ghost"
|
|
520
|
+
{size}
|
|
521
|
+
icon={icons.close}
|
|
522
|
+
square
|
|
523
|
+
onclick={() => removeFile(i)}
|
|
524
|
+
aria-label="Remove {file.name}"
|
|
525
|
+
/>
|
|
526
|
+
</div>
|
|
527
|
+
</div>
|
|
528
|
+
</div>
|
|
529
|
+
{/if}
|
|
530
|
+
{/each}
|
|
531
|
+
</div>
|
|
532
|
+
{/if}
|
|
533
|
+
{/if}
|
|
534
|
+
</div>
|
|
535
|
+
|
|
536
|
+
{#if imagePreview}
|
|
537
|
+
<Modal
|
|
538
|
+
bind:open={previewOpen}
|
|
539
|
+
onOpenChange={(v) => {
|
|
540
|
+
if (!v) previewFile = null
|
|
541
|
+
}}
|
|
542
|
+
title={previewFile?.name ?? ''}
|
|
543
|
+
description={previewFile ? formatFileSize(previewFile.size) : ''}
|
|
544
|
+
ui={{
|
|
545
|
+
content: ['max-w-3xl overflow-hidden', classes.previewContent],
|
|
546
|
+
body: ['p-0', classes.previewBody]
|
|
547
|
+
}}
|
|
548
|
+
>
|
|
549
|
+
{#snippet body()}
|
|
550
|
+
{#if previewFile}
|
|
551
|
+
<div class="flex items-center justify-center bg-surface-container-low">
|
|
552
|
+
<img
|
|
553
|
+
src={getObjectUrl(previewFile)}
|
|
554
|
+
alt={previewFile.name}
|
|
555
|
+
class="max-h-[75vh] max-w-full"
|
|
556
|
+
/>
|
|
557
|
+
</div>
|
|
558
|
+
{/if}
|
|
559
|
+
{/snippet}
|
|
560
|
+
</Modal>
|
|
561
|
+
{/if}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { FileUploadProps } from './file-upload.types.js';
|
|
2
|
+
export type Props = FileUploadProps;
|
|
3
|
+
declare const FileUpload: import("svelte").Component<FileUploadProps, {
|
|
4
|
+
open: () => void;
|
|
5
|
+
clearAll: () => void;
|
|
6
|
+
}, "ref" | "value">;
|
|
7
|
+
type FileUpload = ReturnType<typeof FileUpload>;
|
|
8
|
+
export default FileUpload;
|