@groundbrick/svelte-ui 0.1.1

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 (60) hide show
  1. package/README.md +125 -0
  2. package/dist/components/Alert.svelte +335 -0
  3. package/dist/components/Alert.svelte.d.ts +24 -0
  4. package/dist/components/AutocompleteInput.svelte +356 -0
  5. package/dist/components/AutocompleteInput.svelte.d.ts +72 -0
  6. package/dist/components/Badge.svelte +185 -0
  7. package/dist/components/Badge.svelte.d.ts +20 -0
  8. package/dist/components/Button.svelte +415 -0
  9. package/dist/components/Button.svelte.d.ts +34 -0
  10. package/dist/components/Card.svelte +181 -0
  11. package/dist/components/Card.svelte.d.ts +24 -0
  12. package/dist/components/CardBody.svelte +78 -0
  13. package/dist/components/CardBody.svelte.d.ts +12 -0
  14. package/dist/components/CardFooter.svelte +81 -0
  15. package/dist/components/CardFooter.svelte.d.ts +14 -0
  16. package/dist/components/CardHeader.svelte +186 -0
  17. package/dist/components/CardHeader.svelte.d.ts +21 -0
  18. package/dist/components/Col.svelte +172 -0
  19. package/dist/components/Col.svelte.d.ts +26 -0
  20. package/dist/components/Container.svelte +118 -0
  21. package/dist/components/Container.svelte.d.ts +14 -0
  22. package/dist/components/Drawer.svelte +233 -0
  23. package/dist/components/Drawer.svelte.d.ts +13 -0
  24. package/dist/components/Dropdown.svelte +190 -0
  25. package/dist/components/Dropdown.svelte.d.ts +26 -0
  26. package/dist/components/DropdownItem.svelte +103 -0
  27. package/dist/components/DropdownItem.svelte.d.ts +22 -0
  28. package/dist/components/DurationInput.svelte +170 -0
  29. package/dist/components/DurationInput.svelte.d.ts +27 -0
  30. package/dist/components/EditableTable.svelte +647 -0
  31. package/dist/components/EditableTable.svelte.d.ts +74 -0
  32. package/dist/components/EmptyState.svelte +192 -0
  33. package/dist/components/EmptyState.svelte.d.ts +22 -0
  34. package/dist/components/FormField.svelte +260 -0
  35. package/dist/components/FormField.svelte.d.ts +68 -0
  36. package/dist/components/GridView.svelte +1022 -0
  37. package/dist/components/GridView.svelte.d.ts +38 -0
  38. package/dist/components/GridView.types.d.ts +28 -0
  39. package/dist/components/GridView.types.js +1 -0
  40. package/dist/components/LoadingSpinner.svelte +253 -0
  41. package/dist/components/LoadingSpinner.svelte.d.ts +17 -0
  42. package/dist/components/Modal.svelte +473 -0
  43. package/dist/components/Modal.svelte.d.ts +42 -0
  44. package/dist/components/PhoneInput.svelte +406 -0
  45. package/dist/components/PhoneInput.svelte.d.ts +31 -0
  46. package/dist/components/PhotoUpload.svelte +529 -0
  47. package/dist/components/PhotoUpload.svelte.d.ts +46 -0
  48. package/dist/components/Row.svelte +153 -0
  49. package/dist/components/Row.svelte.d.ts +18 -0
  50. package/dist/icons/PawPrintIcon.svelte +41 -0
  51. package/dist/icons/PawPrintIcon.svelte.d.ts +14 -0
  52. package/dist/index.d.ts +41 -0
  53. package/dist/index.js +49 -0
  54. package/dist/styles/forms.css +182 -0
  55. package/dist/styles/tokens.css +243 -0
  56. package/dist/utils/duration.d.ts +20 -0
  57. package/dist/utils/duration.js +40 -0
  58. package/dist/utils/scrollLock.d.ts +7 -0
  59. package/dist/utils/scrollLock.js +26 -0
  60. package/package.json +66 -0
@@ -0,0 +1,529 @@
1
+ <script lang="ts">
2
+ // Headless photo uploader: owns the UI (preview, dropzone/tiles, drag & drop,
3
+ // file selection and basic validation) but delegates all I/O to callbacks so
4
+ // it stays app-agnostic. The consuming app performs the actual upload/remove
5
+ // (HTTP, storage) and optional image compression.
6
+
7
+ interface PhotoUploadProps {
8
+ /** Folder/bucket hint passed back to the upload callback. */
9
+ folder?: string;
10
+ /** Current photo URL to preview (alias: currentPhoto). */
11
+ currentPhotoUrl?: string | null;
12
+ /** Current photo URL to preview (alias of currentPhotoUrl). */
13
+ currentPhoto?: string | null;
14
+ /** Max accepted size in MB (validated after optional compression). */
15
+ maxSizeMB?: number;
16
+ /** Accepted MIME types. */
17
+ acceptedTypes?: string[];
18
+ /** Disable the whole control. */
19
+ disabled?: boolean;
20
+ /** Wrap the content in a Bootstrap card with a header. */
21
+ uiCardWrapped?: boolean;
22
+ /** Upload-in-progress flag (bindable). */
23
+ uploading?: boolean;
24
+ /** Longest edge passed to the compress callback. */
25
+ maxDimension?: number;
26
+ /** Mobile camera capture mode; false disables the camera tile. */
27
+ capture?: "environment" | "user" | false;
28
+ /** Presentation: wide dropzone or square tiles. */
29
+ layout?: "dropzone" | "tiles";
30
+
31
+ /** Perform the actual upload and return the resulting URL (or null). */
32
+ onUpload?: (file: File, opts: { folder: string }) => Promise<string | null>;
33
+ /** Perform the actual removal of the current photo. */
34
+ onRemove?: () => Promise<void>;
35
+ /** Optional image compression hook applied before preview/upload. */
36
+ compress?: (file: File, opts: { maxSizeMB: number; maxDimension: number }) => Promise<File>;
37
+ /** Called with a validation/processing/upload error message. */
38
+ onError?: (message: string) => void;
39
+ /** Called when a (valid, processed) file is selected. */
40
+ onFileSelected?: (file: File) => void;
41
+ /** Called after a successful upload with the resulting URL. */
42
+ onUploaded?: (url: string | null) => void;
43
+ /** Called after the current photo is removed. */
44
+ onRemoved?: () => void;
45
+ }
46
+
47
+ let {
48
+ folder = "pets",
49
+ currentPhotoUrl = null,
50
+ currentPhoto = null,
51
+ maxSizeMB = 5,
52
+ acceptedTypes = ["image/jpeg", "image/jpg", "image/png"],
53
+ disabled = false,
54
+ uiCardWrapped = true,
55
+ uploading = $bindable(false),
56
+ maxDimension = 1200,
57
+ capture = false,
58
+ layout = "dropzone",
59
+ onUpload,
60
+ onRemove,
61
+ compress,
62
+ onError,
63
+ onFileSelected,
64
+ onUploaded,
65
+ onRemoved
66
+ }: PhotoUploadProps = $props();
67
+
68
+ // Support both currentPhoto and currentPhotoUrl for backward compatibility
69
+ const effectiveCurrentPhotoUrl = $derived(currentPhoto || currentPhotoUrl);
70
+
71
+ let selectedFile = $state<File | null>(null);
72
+ let previewUrl = $state<string | null>(null);
73
+ let hasChanges = $state(false);
74
+ let processing = $state(false);
75
+ let dragOver = $state(false);
76
+ let galleryInput: HTMLInputElement | null = null;
77
+ let cameraInput: HTMLInputElement | null = null;
78
+
79
+ const dropzoneDisabled = $derived(disabled || uploading || processing);
80
+
81
+ $effect(() => {
82
+ if (!hasChanges) {
83
+ previewUrl = effectiveCurrentPhotoUrl;
84
+ }
85
+ });
86
+
87
+ function reportError(message: string) {
88
+ onError?.(message);
89
+ }
90
+
91
+ async function onFileSelect(event: Event) {
92
+ const input = event.target as HTMLInputElement;
93
+ const file = input?.files?.[0];
94
+
95
+ if (!file) {
96
+ clearSelection();
97
+ return;
98
+ }
99
+
100
+ await processFile(file);
101
+ }
102
+
103
+ function openGallery() {
104
+ if (dropzoneDisabled) return;
105
+ galleryInput?.click();
106
+ }
107
+
108
+ function openCamera() {
109
+ if (dropzoneDisabled) return;
110
+ cameraInput?.click();
111
+ }
112
+
113
+ function onDropzoneKeydown(event: KeyboardEvent) {
114
+ if (event.key === "Enter" || event.key === " ") {
115
+ event.preventDefault();
116
+ openGallery();
117
+ }
118
+ }
119
+
120
+ function onDragOver(event: DragEvent) {
121
+ event.preventDefault();
122
+ if (dropzoneDisabled) return;
123
+ dragOver = true;
124
+ }
125
+
126
+ function onDragLeave(event: DragEvent) {
127
+ event.preventDefault();
128
+ dragOver = false;
129
+ }
130
+
131
+ async function onDrop(event: DragEvent) {
132
+ event.preventDefault();
133
+ dragOver = false;
134
+ if (dropzoneDisabled) return;
135
+ const file = event.dataTransfer?.files?.[0];
136
+ if (file) {
137
+ await processFile(file);
138
+ }
139
+ }
140
+
141
+ function exceedsMaxSize(file: File, limitMB: number): boolean {
142
+ return file.size > limitMB * 1024 * 1024;
143
+ }
144
+
145
+ async function processFile(file: File) {
146
+ if (!acceptedTypes.includes(file.type)) {
147
+ reportError(`Tipo de ficheiro inválido. Use: ${acceptedTypes.join(", ")}`);
148
+ clearSelection();
149
+ return;
150
+ }
151
+
152
+ try {
153
+ processing = true;
154
+ let processedFile = file;
155
+
156
+ // Compress image if a compressor was provided
157
+ if (compress) {
158
+ processedFile = await compress(file, { maxSizeMB, maxDimension });
159
+ }
160
+
161
+ // Validate size after (optional) compression
162
+ if (exceedsMaxSize(processedFile, maxSizeMB)) {
163
+ reportError(`Imagem demasiado grande. Selecione uma foto menor.`);
164
+ clearSelection();
165
+ return;
166
+ }
167
+
168
+ selectedFile = processedFile;
169
+ hasChanges = true;
170
+
171
+ const reader = new FileReader();
172
+ reader.onload = () => {
173
+ previewUrl = reader.result as string;
174
+ };
175
+ reader.readAsDataURL(processedFile);
176
+
177
+ onFileSelected?.(processedFile);
178
+ } catch (error: any) {
179
+ reportError(error?.message || "Erro ao processar imagem");
180
+ clearSelection();
181
+ } finally {
182
+ processing = false;
183
+ }
184
+ }
185
+
186
+ async function confirmUpload() {
187
+ if (!selectedFile) {
188
+ reportError("Nenhum ficheiro selecionado.");
189
+ return;
190
+ }
191
+ if (!onUpload) return;
192
+
193
+ uploading = true;
194
+ try {
195
+ const url = await onUpload(selectedFile, { folder });
196
+ hasChanges = false;
197
+ if (url) {
198
+ previewUrl = url;
199
+ }
200
+ onUploaded?.(url ?? null);
201
+ } catch (error: any) {
202
+ reportError(error?.message || "Erro ao enviar imagem");
203
+ } finally {
204
+ uploading = false;
205
+ }
206
+ }
207
+
208
+ async function removePhoto() {
209
+ uploading = true;
210
+ try {
211
+ if (onRemove) {
212
+ await onRemove();
213
+ }
214
+ clearSelection();
215
+ previewUrl = null;
216
+ onRemoved?.();
217
+ } catch (error: any) {
218
+ reportError(error?.message || "Erro ao remover imagem");
219
+ } finally {
220
+ uploading = false;
221
+ }
222
+ }
223
+
224
+ function clearSelection() {
225
+ selectedFile = null;
226
+ hasChanges = false;
227
+ previewUrl = effectiveCurrentPhotoUrl;
228
+
229
+ if (galleryInput) {
230
+ galleryInput.value = "";
231
+ }
232
+ if (cameraInput) {
233
+ cameraInput.value = "";
234
+ }
235
+ }
236
+ </script>
237
+
238
+ {#snippet photoUploaderContent() }
239
+ <div class="photo-upload-content">
240
+ <!-- Photo Preview -->
241
+ {#if previewUrl}
242
+ <div class="mb-3 text-center">
243
+ <img
244
+ src={previewUrl}
245
+ alt="Preview"
246
+ class="img-thumbnail"
247
+ style="max-height: 250px;"
248
+ />
249
+ {#if hasChanges}
250
+ <div class="mt-2">
251
+ <small class="text-muted">Nova foto selecionada - confirme para enviar</small>
252
+ </div>
253
+ {/if}
254
+ </div>
255
+ {/if}
256
+
257
+ <!-- Photo source tiles (camera + gallery) -->
258
+ <div class="mb-3">
259
+ <input
260
+ id="photo-file-input"
261
+ type="file"
262
+ class="visually-hidden"
263
+ accept={acceptedTypes.join(",")}
264
+ onchange={onFileSelect}
265
+ disabled={dropzoneDisabled}
266
+ bind:this={galleryInput}
267
+ />
268
+ {#if capture}
269
+ <input
270
+ type="file"
271
+ class="visually-hidden"
272
+ accept={acceptedTypes.join(",")}
273
+ capture={capture}
274
+ onchange={onFileSelect}
275
+ disabled={dropzoneDisabled}
276
+ bind:this={cameraInput}
277
+ />
278
+ {/if}
279
+
280
+ {#if layout === "dropzone"}
281
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
282
+ <div
283
+ class="photo-dropzone"
284
+ class:dragover={dragOver}
285
+ class:disabled={dropzoneDisabled}
286
+ role="button"
287
+ tabindex={dropzoneDisabled ? -1 : 0}
288
+ aria-label="Selecionar ou arrastar uma foto"
289
+ onclick={openGallery}
290
+ onkeydown={onDropzoneKeydown}
291
+ ondragover={onDragOver}
292
+ ondragleave={onDragLeave}
293
+ ondrop={onDrop}
294
+ >
295
+ <i class="bi bi-cloud-arrow-up dz-icon" aria-hidden="true"></i>
296
+ <div class="dz-text">
297
+ {#if processing}
298
+ A processar...
299
+ {:else}
300
+ <strong>Arraste uma foto para aqui</strong>
301
+ <span>ou carregue para escolher</span>
302
+ {/if}
303
+ </div>
304
+ <small class="dz-hint">
305
+ {acceptedTypes.map((t:any) => t.split("/")[1].toUpperCase()).join("/")} · máx {maxSizeMB}MB
306
+ </small>
307
+ </div>
308
+ {:else}
309
+ <div class="photo-tiles">
310
+ {#if capture}
311
+ <!-- Camera tile: shown only on touch devices (see CSS) -->
312
+ <button
313
+ type="button"
314
+ class="photo-tile photo-tile--camera"
315
+ disabled={dropzoneDisabled}
316
+ onclick={openCamera}
317
+ >
318
+ <i class="bi bi-camera dz-icon" aria-hidden="true"></i>
319
+ <span class="tile-label">Tirar foto</span>
320
+ </button>
321
+ {/if}
322
+
323
+ <!-- Gallery / import tile (also the drag & drop target) -->
324
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
325
+ <div
326
+ class="photo-tile"
327
+ class:dragover={dragOver}
328
+ class:disabled={dropzoneDisabled}
329
+ role="button"
330
+ tabindex={dropzoneDisabled ? -1 : 0}
331
+ aria-label={capture ? "Importar foto da galeria" : "Adicionar foto"}
332
+ onclick={openGallery}
333
+ onkeydown={onDropzoneKeydown}
334
+ ondragover={onDragOver}
335
+ ondragleave={onDragLeave}
336
+ ondrop={onDrop}
337
+ >
338
+ {#if processing}
339
+ <span class="spinner-border spinner-border-sm dz-icon" aria-hidden="true"></span>
340
+ <span class="tile-label">A processar...</span>
341
+ {:else}
342
+ <i class="bi bi-plus-lg dz-icon" aria-hidden="true"></i>
343
+ <span class="tile-label">{capture ? "Importar" : "Adicionar foto"}</span>
344
+ {/if}
345
+ </div>
346
+ </div>
347
+
348
+ <small class="dz-hint d-block mt-2">
349
+ {acceptedTypes.map((t:any) => t.split("/")[1].toUpperCase()).join("/")} · máx {maxSizeMB}MB
350
+ </small>
351
+ {/if}
352
+ </div>
353
+
354
+ <!-- Action Buttons -->
355
+ <div class="d-flex gap-2 flex-wrap">
356
+ {#if hasChanges && selectedFile}
357
+ <button
358
+ type="button"
359
+ class="btn btn-success"
360
+ onclick={confirmUpload}
361
+ disabled={uploading || disabled}
362
+ >
363
+ {uploading ? "A enviar..." : "Confirmar Upload"}
364
+ </button>
365
+
366
+ <button
367
+ type="button"
368
+ class="btn btn-outline-secondary"
369
+ onclick={clearSelection}
370
+ disabled={uploading || disabled}
371
+ >
372
+ Voltar
373
+ </button>
374
+ {/if}
375
+
376
+ {#if previewUrl && !hasChanges}
377
+ <button
378
+ type="button"
379
+ class="btn btn-outline-danger"
380
+ onclick={removePhoto}
381
+ disabled={uploading || disabled}
382
+ >
383
+ {uploading ? "A remover..." : "Remover Foto"}
384
+ </button>
385
+ {/if}
386
+ </div>
387
+ </div>
388
+ {/snippet}
389
+
390
+ {#if uiCardWrapped}
391
+ <div class="card">
392
+ <div class="card-header">
393
+ <h6 class="mb-0">
394
+ <i class="bi bi-camera-fill" aria-hidden="true"></i> Foto
395
+ </h6>
396
+ </div>
397
+ <div class="card-body">
398
+ {@render photoUploaderContent() }
399
+ </div>
400
+ </div>
401
+ {:else}
402
+ {@render photoUploaderContent() }
403
+ {/if}
404
+
405
+ <style>
406
+ .photo-dropzone {
407
+ display: flex;
408
+ flex-direction: column;
409
+ align-items: center;
410
+ justify-content: center;
411
+ gap: 0.35rem;
412
+ width: 100%;
413
+ padding: 1.5rem 1rem;
414
+ border: 2px dashed var(--border-color, #cfcfe1);
415
+ border-radius: 12px;
416
+ background: var(--bs-tertiary-bg, #fafafa);
417
+ color: #555;
418
+ text-align: center;
419
+ cursor: pointer;
420
+ transition: border-color 0.15s ease, background-color 0.15s ease, transform 0.05s ease;
421
+ }
422
+
423
+ .photo-dropzone:hover:not(.disabled),
424
+ .photo-dropzone:focus-visible {
425
+ border-color: #7b3ff2;
426
+ background: #f6f1ff;
427
+ outline: none;
428
+ }
429
+
430
+ .photo-dropzone.dragover {
431
+ border-color: #7b3ff2;
432
+ background: #efe6ff;
433
+ transform: scale(1.01);
434
+ }
435
+
436
+ .photo-dropzone.disabled {
437
+ cursor: not-allowed;
438
+ opacity: 0.6;
439
+ }
440
+
441
+ .dz-text {
442
+ display: flex;
443
+ flex-direction: column;
444
+ line-height: 1.25;
445
+ }
446
+
447
+ .dz-text strong {
448
+ font-weight: 600;
449
+ color: #333;
450
+ }
451
+
452
+ .dz-text span {
453
+ font-size: 0.85rem;
454
+ color: #777;
455
+ }
456
+
457
+ .photo-tiles {
458
+ display: flex;
459
+ flex-wrap: wrap;
460
+ gap: 0.75rem;
461
+ }
462
+
463
+ .photo-tile {
464
+ display: flex;
465
+ flex-direction: column;
466
+ align-items: center;
467
+ justify-content: center;
468
+ gap: 0.4rem;
469
+ width: 120px;
470
+ aspect-ratio: 1 / 1;
471
+ padding: 0.75rem;
472
+ margin: 0;
473
+ font: inherit;
474
+ border: 2px dashed var(--border-color, #cfcfe1);
475
+ border-radius: 14px;
476
+ background: var(--bs-tertiary-bg, #fafafa);
477
+ color: #555;
478
+ text-align: center;
479
+ cursor: pointer;
480
+ transition: border-color 0.15s ease, background-color 0.15s ease, transform 0.05s ease;
481
+ }
482
+
483
+ .photo-tile:hover:not(.disabled):not(:disabled),
484
+ .photo-tile:focus-visible {
485
+ border-color: #7b3ff2;
486
+ background: #f6f1ff;
487
+ outline: none;
488
+ }
489
+
490
+ .photo-tile.dragover {
491
+ border-color: #7b3ff2;
492
+ background: #efe6ff;
493
+ transform: scale(1.02);
494
+ }
495
+
496
+ .photo-tile.disabled,
497
+ .photo-tile:disabled {
498
+ cursor: not-allowed;
499
+ opacity: 0.6;
500
+ }
501
+
502
+ /* Camera tile only makes sense on touch devices; capture is ignored on desktop. */
503
+ .photo-tile--camera {
504
+ display: none;
505
+ }
506
+
507
+ @media (pointer: coarse) {
508
+ .photo-tile--camera {
509
+ display: flex;
510
+ }
511
+ }
512
+
513
+ .dz-icon {
514
+ font-size: 2rem;
515
+ color: #7b3ff2;
516
+ line-height: 1;
517
+ }
518
+
519
+ .tile-label {
520
+ font-size: 0.8rem;
521
+ font-weight: 600;
522
+ color: #444;
523
+ }
524
+
525
+ .dz-hint {
526
+ color: #999;
527
+ font-size: 0.75rem;
528
+ }
529
+ </style>
@@ -0,0 +1,46 @@
1
+ interface PhotoUploadProps {
2
+ /** Folder/bucket hint passed back to the upload callback. */
3
+ folder?: string;
4
+ /** Current photo URL to preview (alias: currentPhoto). */
5
+ currentPhotoUrl?: string | null;
6
+ /** Current photo URL to preview (alias of currentPhotoUrl). */
7
+ currentPhoto?: string | null;
8
+ /** Max accepted size in MB (validated after optional compression). */
9
+ maxSizeMB?: number;
10
+ /** Accepted MIME types. */
11
+ acceptedTypes?: string[];
12
+ /** Disable the whole control. */
13
+ disabled?: boolean;
14
+ /** Wrap the content in a Bootstrap card with a header. */
15
+ uiCardWrapped?: boolean;
16
+ /** Upload-in-progress flag (bindable). */
17
+ uploading?: boolean;
18
+ /** Longest edge passed to the compress callback. */
19
+ maxDimension?: number;
20
+ /** Mobile camera capture mode; false disables the camera tile. */
21
+ capture?: "environment" | "user" | false;
22
+ /** Presentation: wide dropzone or square tiles. */
23
+ layout?: "dropzone" | "tiles";
24
+ /** Perform the actual upload and return the resulting URL (or null). */
25
+ onUpload?: (file: File, opts: {
26
+ folder: string;
27
+ }) => Promise<string | null>;
28
+ /** Perform the actual removal of the current photo. */
29
+ onRemove?: () => Promise<void>;
30
+ /** Optional image compression hook applied before preview/upload. */
31
+ compress?: (file: File, opts: {
32
+ maxSizeMB: number;
33
+ maxDimension: number;
34
+ }) => Promise<File>;
35
+ /** Called with a validation/processing/upload error message. */
36
+ onError?: (message: string) => void;
37
+ /** Called when a (valid, processed) file is selected. */
38
+ onFileSelected?: (file: File) => void;
39
+ /** Called after a successful upload with the resulting URL. */
40
+ onUploaded?: (url: string | null) => void;
41
+ /** Called after the current photo is removed. */
42
+ onRemoved?: () => void;
43
+ }
44
+ declare const PhotoUpload: import("svelte").Component<PhotoUploadProps, {}, "uploading">;
45
+ type PhotoUpload = ReturnType<typeof PhotoUpload>;
46
+ export default PhotoUpload;