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.
Files changed (37) hide show
  1. package/dist/CheckboxGroup/CheckboxGroup.svelte +215 -0
  2. package/dist/CheckboxGroup/CheckboxGroup.svelte.d.ts +5 -0
  3. package/dist/CheckboxGroup/checkbox-group.types.d.ts +130 -0
  4. package/dist/CheckboxGroup/checkbox-group.types.js +1 -0
  5. package/dist/CheckboxGroup/checkbox-group.variants.d.ts +553 -0
  6. package/dist/CheckboxGroup/checkbox-group.variants.js +231 -0
  7. package/dist/CheckboxGroup/index.d.ts +2 -0
  8. package/dist/CheckboxGroup/index.js +1 -0
  9. package/dist/FileUpload/FileUpload.svelte +561 -0
  10. package/dist/FileUpload/FileUpload.svelte.d.ts +8 -0
  11. package/dist/FileUpload/file-upload.types.d.ts +164 -0
  12. package/dist/FileUpload/file-upload.types.js +1 -0
  13. package/dist/FileUpload/file-upload.variants.d.ts +397 -0
  14. package/dist/FileUpload/file-upload.variants.js +224 -0
  15. package/dist/FileUpload/index.d.ts +2 -0
  16. package/dist/FileUpload/index.js +1 -0
  17. package/dist/PinInput/PinInput.svelte +150 -0
  18. package/dist/PinInput/PinInput.svelte.d.ts +6 -0
  19. package/dist/PinInput/index.d.ts +2 -0
  20. package/dist/PinInput/index.js +1 -0
  21. package/dist/PinInput/pin-input.types.d.ts +99 -0
  22. package/dist/PinInput/pin-input.types.js +1 -0
  23. package/dist/PinInput/pin-input.variants.d.ts +303 -0
  24. package/dist/PinInput/pin-input.variants.js +196 -0
  25. package/dist/Slider/Slider.svelte +135 -0
  26. package/dist/Slider/Slider.svelte.d.ts +6 -0
  27. package/dist/Slider/index.d.ts +2 -0
  28. package/dist/Slider/index.js +1 -0
  29. package/dist/Slider/slider.types.d.ts +55 -0
  30. package/dist/Slider/slider.types.js +1 -0
  31. package/dist/Slider/slider.variants.d.ts +383 -0
  32. package/dist/Slider/slider.variants.js +102 -0
  33. package/dist/config.d.ts +4 -0
  34. package/dist/config.js +5 -1
  35. package/dist/index.d.ts +4 -0
  36. package/dist/index.js +4 -0
  37. 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;