@makolabs/ripple 1.14.0 → 2.1.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.
@@ -1,274 +0,0 @@
1
- <script lang="ts">
2
- import { cn } from '../../helper/cls.js';
3
- import { buildTestId } from '../../helper/testid.js';
4
- import FileUpload from './FileUpload.svelte';
5
- import Button from '../../button/Button.svelte';
6
- import Spinner from '../spinner/Spinner.svelte';
7
- import { Size, Color } from '../../variants.js';
8
- import type { MultiFileUploadProps, StagedFile } from '../../index.js';
9
-
10
- let {
11
- files = $bindable<StagedFile[]>([]),
12
- allowedMimeTypes = [],
13
- maxFiles = 10,
14
- maxSize,
15
- size = 'md',
16
- id = 'multi-file-upload',
17
- class: className = '',
18
- dropzoneClass = '',
19
- onfiles,
20
- clearAfterUpload = true,
21
- uploadButtonLabel = 'Upload All',
22
- clearButtonLabel = 'Clear All',
23
- filesListLabel = 'Selected files',
24
- listMaxHeight = 'max-h-64',
25
- header,
26
- testId
27
- }: MultiFileUploadProps = $props();
28
-
29
- function makeId(): string {
30
- if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
31
- return crypto.randomUUID();
32
- }
33
- return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
34
- }
35
-
36
- function handleAdd(newFiles: FileList | File[]) {
37
- const arr = Array.from(newFiles);
38
- const room = Math.max(0, maxFiles - files.length);
39
- const accepted = arr.slice(0, room);
40
- const toAdd: StagedFile[] = accepted.map((file) => ({
41
- id: makeId(),
42
- file,
43
- status: 'ready' as const
44
- }));
45
- files = [...files, ...toAdd];
46
- }
47
-
48
- function handleRemove(fileId: string) {
49
- files = files.filter((f) => f.id !== fileId);
50
- }
51
-
52
- function handleClearAll() {
53
- files = [];
54
- }
55
-
56
- const readyCount = $derived(files.filter((f) => !f.status || f.status === 'ready').length);
57
- const hasAnyFile = $derived(files.length > 0);
58
- const anyUploading = $derived(files.some((f) => f.status === 'uploading'));
59
- const canUpload = $derived(readyCount > 0 && !anyUploading && !!onfiles);
60
-
61
- function handleUploadAll() {
62
- if (!onfiles) return;
63
- const rawFiles = files.filter((f) => !f.status || f.status === 'ready').map((f) => f.file);
64
- if (rawFiles.length === 0) return;
65
- onfiles(rawFiles);
66
- if (clearAfterUpload) {
67
- files = [];
68
- }
69
- }
70
-
71
- function handleRetry(fileId: string) {
72
- const target = files.find((f) => f.id === fileId);
73
- if (!target || !onfiles) return;
74
- // Reset the row to 'ready' so the caller's upload handler picks it up.
75
- files = files.map((f) =>
76
- f.id === fileId
77
- ? { ...f, status: 'ready' as const, error: undefined, progress: undefined }
78
- : f
79
- );
80
- onfiles([target.file]);
81
- }
82
-
83
- function formatFileSize(bytes: number): string {
84
- if (bytes === 0) return '0 B';
85
- const k = 1024;
86
- const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
87
- const i = Math.floor(Math.log(bytes) / Math.log(k));
88
- return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))}${sizes[i]}`;
89
- }
90
-
91
- // Internal FileUpload should only accept files up to the remaining room.
92
- // This caps input but the dropzone still renders.
93
- const remainingFiles = $derived(Math.max(0, maxFiles - files.length));
94
- </script>
95
-
96
- <div
97
- class={cn('flex flex-col gap-4', className)}
98
- data-testid={buildTestId('multi-file-upload', undefined, testId)}
99
- >
100
- {#if header}
101
- {@render header()}
102
- {/if}
103
-
104
- <FileUpload
105
- {allowedMimeTypes}
106
- maxFiles={remainingFiles}
107
- {maxSize}
108
- {size}
109
- {dropzoneClass}
110
- {id}
111
- onfiles={handleAdd}
112
- />
113
-
114
- {#if hasAnyFile}
115
- <div class="flex flex-col gap-2">
116
- <div class="flex items-center justify-between">
117
- <span class="text-default-700 text-sm font-medium">
118
- {filesListLabel} ({files.length})
119
- </span>
120
- <div class="flex gap-2">
121
- <Button
122
- size={Size.XS}
123
- variant="outline"
124
- color={Color.DEFAULT}
125
- onclick={handleClearAll}
126
- disabled={anyUploading}
127
- >
128
- {clearButtonLabel}
129
- </Button>
130
- <Button
131
- size={Size.XS}
132
- color={Color.PRIMARY}
133
- onclick={handleUploadAll}
134
- disabled={!canUpload}
135
- >
136
- {uploadButtonLabel}{readyCount > 0 ? ` (${readyCount})` : ''}
137
- </Button>
138
- </div>
139
- </div>
140
-
141
- <ul
142
- class={cn(
143
- 'border-default-200 divide-default-100 divide-y overflow-y-auto rounded-lg border',
144
- listMaxHeight
145
- )}
146
- >
147
- {#each files as stagedFile (stagedFile.id)}
148
- {@const status = stagedFile.status ?? 'ready'}
149
- <li
150
- class={cn('flex items-center gap-3 px-3 py-2', status === 'success' && 'opacity-60')}
151
- data-multi-file-row=""
152
- data-status={status}
153
- >
154
- <!-- Status icon -->
155
- {#if status === 'ready'}
156
- <span
157
- class="bg-default-100 text-default-500 flex size-7 shrink-0 items-center justify-center rounded-full"
158
- aria-hidden="true"
159
- >
160
- <svg class="size-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
161
- <path
162
- stroke-linecap="round"
163
- stroke-linejoin="round"
164
- stroke-width="2"
165
- d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
166
- />
167
- </svg>
168
- </span>
169
- {:else if status === 'uploading'}
170
- <div class="flex size-7 shrink-0 items-center justify-center">
171
- <Spinner size={Size.SM} color={Color.PRIMARY} />
172
- </div>
173
- {:else if status === 'success'}
174
- <span
175
- class="bg-success-100 text-success-600 flex size-7 shrink-0 items-center justify-center rounded-full"
176
- aria-hidden="true"
177
- >
178
- <svg class="size-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
179
- <path
180
- stroke-linecap="round"
181
- stroke-linejoin="round"
182
- stroke-width="3"
183
- d="M5 13l4 4L19 7"
184
- />
185
- </svg>
186
- </span>
187
- {:else if status === 'error'}
188
- <span
189
- class="bg-danger-100 text-danger-600 flex size-7 shrink-0 items-center justify-center rounded-full"
190
- aria-hidden="true"
191
- >
192
- <svg class="size-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
193
- <path
194
- stroke-linecap="round"
195
- stroke-linejoin="round"
196
- stroke-width="3"
197
- d="M6 18L18 6M6 6l12 12"
198
- />
199
- </svg>
200
- </span>
201
- {/if}
202
-
203
- <!-- File info -->
204
- <div class="flex min-w-0 flex-1 flex-col gap-0.5">
205
- <span class="text-default-900 truncate text-sm font-medium">
206
- {stagedFile.file.name}
207
- </span>
208
- <span class="text-default-500 text-xs">
209
- {formatFileSize(stagedFile.file.size)}
210
- {#if stagedFile.error}
211
- <span class="text-danger-600"> · {stagedFile.error}</span>
212
- {/if}
213
- </span>
214
- {#if status === 'uploading' && stagedFile.progress !== undefined}
215
- <div class="bg-default-100 mt-1 h-1 w-full overflow-hidden rounded-full">
216
- <div
217
- data-multi-file-progress=""
218
- class="bg-primary-500 h-full transition-all duration-200"
219
- style="width: {Math.max(0, Math.min(100, stagedFile.progress))}%"
220
- ></div>
221
- </div>
222
- {/if}
223
- </div>
224
-
225
- <!-- Retry button — only for error rows (re-runs upload for this one file) -->
226
- {#if status === 'error'}
227
- <button
228
- type="button"
229
- onclick={() => handleRetry(stagedFile.id)}
230
- class="text-default-500 hover:bg-primary-50 hover:text-primary-600 shrink-0 cursor-pointer rounded p-1 transition-colors"
231
- aria-label="Retry upload for {stagedFile.file.name}"
232
- title="Retry"
233
- >
234
- <svg
235
- class="size-4"
236
- fill="none"
237
- viewBox="0 0 24 24"
238
- stroke="currentColor"
239
- stroke-width="2"
240
- stroke-linecap="round"
241
- stroke-linejoin="round"
242
- >
243
- <polyline points="23 4 23 10 17 10" />
244
- <polyline points="1 20 1 14 7 14" />
245
- <path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
246
- </svg>
247
- </button>
248
- {/if}
249
-
250
- <!-- Remove button — hidden for in-flight or successful rows -->
251
- {#if status !== 'uploading' && status !== 'success'}
252
- <button
253
- type="button"
254
- onclick={() => handleRemove(stagedFile.id)}
255
- class="text-default-400 hover:bg-default-100 hover:text-danger-500 shrink-0 cursor-pointer rounded p-1 transition-colors"
256
- aria-label="Remove {stagedFile.file.name}"
257
- title="Remove"
258
- >
259
- <svg class="size-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
260
- <path
261
- stroke-linecap="round"
262
- stroke-linejoin="round"
263
- stroke-width="2"
264
- d="M6 18L18 6M6 6l12 12"
265
- />
266
- </svg>
267
- </button>
268
- {/if}
269
- </li>
270
- {/each}
271
- </ul>
272
- </div>
273
- {/if}
274
- </div>
@@ -1,4 +0,0 @@
1
- import type { MultiFileUploadProps } from '../../index.js';
2
- declare const MultiFileUpload: import("svelte").Component<MultiFileUploadProps, {}, "files">;
3
- type MultiFileUpload = ReturnType<typeof MultiFileUpload>;
4
- export default MultiFileUpload;