@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,1022 @@
1
+ <script lang="ts">
2
+ import type { ActionItem, Column } from "./GridView.types.js";
3
+
4
+ // Browser guard without SvelteKit's $app/environment.
5
+ const browser = typeof window !== "undefined";
6
+
7
+ /** Default date formatter; override via the `formatDate` prop. */
8
+ function defaultFormatDate(value: any): string {
9
+ if (!value) return "-";
10
+ const d = new Date(value);
11
+ return isNaN(d.getTime()) ? String(value) : d.toLocaleDateString("pt-PT");
12
+ }
13
+
14
+ // Props
15
+ export let data: any[] = [];
16
+ export let columns: Column[] = [];
17
+ export let loading: boolean = false;
18
+ export let emptyMessage: string = "Nenhum item encontrado";
19
+ export let searchable: boolean = false;
20
+ export let searchPlaceholder: string = "Pesquisar...";
21
+ export let selectable: boolean = false;
22
+ export let draggable: boolean = false;
23
+ export let onRowClick: ((row: any) => void) | undefined = undefined;
24
+ export let onSelectionChange: ((selectedRows: any[]) => void) | undefined = undefined;
25
+ export let onReorder: ((orderedIds: number[]) => Promise<void>) | undefined = undefined;
26
+ export let actions: ActionItem[] = [];
27
+ // Opt-in client-side pagination
28
+ export let paginated: boolean = false;
29
+ export let pageSize: number = 25;
30
+ // Opt-in persistence of search/sort/page to sessionStorage (survives back navigation)
31
+ export let persistKey: string | undefined = undefined;
32
+ // Dependency-injection props (replace app-specific utilities):
33
+ /** Resolve an image cell value to a URL. Defaults to identity (use value as-is). */
34
+ export let resolveImageUrl: (value: string, folder?: string) => string = (value) => value;
35
+ /** Format a date cell value. Defaults to pt-PT locale date. */
36
+ export let formatDate: (value: any) => string = defaultFormatDate;
37
+ /** Permission check for row actions. Defaults to allow-all. */
38
+ export let hasPermission: (permission: string) => boolean = () => true;
39
+
40
+ // Drag and drop state
41
+ let draggedIndex: number | null = null;
42
+ let dropTargetIndex: number | null = null;
43
+ let isDragging = false;
44
+ let localData: any[] = [];
45
+ let dataSignature = '';
46
+
47
+ // Long press state for touch devices
48
+ let longPressTimer: ReturnType<typeof setTimeout> | null = null;
49
+ let longPressingIndex: number | null = null;
50
+ const LONG_PRESS_DURATION = 500; // ms
51
+
52
+ // Create a signature from data IDs to detect actual data changes vs re-renders
53
+ function getDataSignature(arr: any[]): string {
54
+ return JSON.stringify(arr);
55
+ }
56
+
57
+ // Only sync localData with data prop when actual data changes (not during drag)
58
+ $: {
59
+ const newSignature = getDataSignature(data);
60
+ if (newSignature !== dataSignature && !isDragging) {
61
+ localData = [...data];
62
+ dataSignature = newSignature;
63
+ }
64
+ }
65
+
66
+ // State
67
+ let searchQuery = "";
68
+ let sortColumn: string | null = null;
69
+ let sortDirection: "asc" | "desc" = "asc";
70
+ let currentPage = 1;
71
+ let selectedRows = new Set<any>();
72
+
73
+ // Restore persisted view state (search / sort / page) before reactivity kicks in
74
+ const STORAGE_PREFIX = "gridview:";
75
+ if (browser && persistKey) {
76
+ try {
77
+ const saved = sessionStorage.getItem(STORAGE_PREFIX + persistKey);
78
+ if (saved) {
79
+ const s = JSON.parse(saved);
80
+ searchQuery = s.searchQuery ?? "";
81
+ sortColumn = s.sortColumn ?? null;
82
+ sortDirection = s.sortDirection ?? "asc";
83
+ currentPage = s.currentPage ?? 1;
84
+ }
85
+ } catch {
86
+ // ignore corrupted storage
87
+ }
88
+ }
89
+
90
+ // Reset to first page whenever the search term changes (init aligned to restored value)
91
+ let lastSearchQuery = searchQuery;
92
+ $: if (searchQuery !== lastSearchQuery) {
93
+ lastSearchQuery = searchQuery;
94
+ currentPage = 1;
95
+ }
96
+
97
+ // Persist view state
98
+ $: if (browser && persistKey) {
99
+ sessionStorage.setItem(
100
+ STORAGE_PREFIX + persistKey,
101
+ JSON.stringify({ searchQuery, sortColumn, sortDirection, currentPage }),
102
+ );
103
+ }
104
+
105
+ // Reactive statements
106
+ $: filteredData = (() => {
107
+ let result = [...localData];
108
+
109
+ // Search filter
110
+ if (searchable && searchQuery.trim()) {
111
+ const query = searchQuery.toLowerCase();
112
+ result = result.filter((row) =>
113
+ columns.some((col) => {
114
+ const value = row[col.key];
115
+ return value && value.toString().toLowerCase().includes(query);
116
+ }),
117
+ );
118
+ }
119
+
120
+ // Sort
121
+ if (sortColumn) {
122
+ result.sort((a, b) => {
123
+ const aVal = a[sortColumn!];
124
+ const bVal = b[sortColumn!];
125
+
126
+ let comparison = 0;
127
+ if (aVal < bVal) comparison = -1;
128
+ else if (aVal > bVal) comparison = 1;
129
+
130
+ return sortDirection === "asc" ? comparison : -comparison;
131
+ });
132
+ }
133
+
134
+ return result;
135
+ })();
136
+
137
+ // Pagination (client-side, opt-in)
138
+ $: totalPages = paginated
139
+ ? Math.max(1, Math.ceil(filteredData.length / pageSize))
140
+ : 1;
141
+ $: if (currentPage > totalPages) currentPage = totalPages;
142
+ $: displayData = paginated
143
+ ? filteredData.slice((currentPage - 1) * pageSize, currentPage * pageSize)
144
+ : filteredData;
145
+
146
+ // Windowed page buttons with ellipsis markers (-1)
147
+ $: pageNumbers = (() => {
148
+ if (!paginated || totalPages <= 1) return [] as number[];
149
+ const pages = new Set<number>([1, totalPages, currentPage]);
150
+ if (currentPage - 1 > 1) pages.add(currentPage - 1);
151
+ if (currentPage + 1 < totalPages) pages.add(currentPage + 1);
152
+ const sorted = Array.from(pages).sort((a, b) => a - b);
153
+ const result: number[] = [];
154
+ let prev = 0;
155
+ for (const p of sorted) {
156
+ if (prev && p - prev > 1) result.push(-1); // ellipsis
157
+ result.push(p);
158
+ prev = p;
159
+ }
160
+ return result;
161
+ })();
162
+
163
+ $: rangeStart = filteredData.length === 0 ? 0 : (currentPage - 1) * pageSize + 1;
164
+ $: rangeEnd = paginated
165
+ ? Math.min(currentPage * pageSize, filteredData.length)
166
+ : filteredData.length;
167
+
168
+ function goToPage(p: number) {
169
+ if (p < 1 || p > totalPages) return;
170
+ currentPage = p;
171
+ }
172
+
173
+ function clearSearch() {
174
+ searchQuery = "";
175
+ }
176
+
177
+ function handleSearchKeydown(e: KeyboardEvent) {
178
+ if (e.key === "Escape") {
179
+ e.preventDefault();
180
+ clearSearch();
181
+ }
182
+ }
183
+
184
+ // Build grid template columns
185
+ $: gridTemplateColumns = (() => {
186
+ const cols: string[] = [];
187
+ if (draggable) cols.push('40px');
188
+ if (selectable) cols.push('50px');
189
+ columns.forEach(col => {
190
+ if (!col.hidden) cols.push(col.width || '1fr');
191
+ });
192
+ if (actionsAvailable) cols.push('120px');
193
+ return cols.join(' ');
194
+ })();
195
+
196
+ // Methods
197
+ function handleSort(column: Column) {
198
+ if (!column.sortable) return;
199
+
200
+ if (sortColumn === column.key) {
201
+ sortDirection = sortDirection === "asc" ? "desc" : "asc";
202
+ } else {
203
+ sortColumn = column.key;
204
+ sortDirection = "asc";
205
+ }
206
+ }
207
+
208
+ function handleRowClick(row: any) {
209
+ if (onRowClick) {
210
+ onRowClick(row);
211
+ }
212
+ }
213
+
214
+ function toggleRowSelection(row: any) {
215
+ if (selectedRows.has(row)) {
216
+ selectedRows.delete(row);
217
+ } else {
218
+ selectedRows.add(row);
219
+ }
220
+ selectedRows = new Set(selectedRows);
221
+ if (onSelectionChange) {
222
+ onSelectionChange(Array.from(selectedRows));
223
+ }
224
+ }
225
+
226
+ function toggleSelectAll() {
227
+ if (selectedRows.size === filteredData.length) {
228
+ selectedRows.clear();
229
+ } else {
230
+ selectedRows = new Set(filteredData);
231
+ }
232
+ selectedRows = new Set(selectedRows);
233
+ if (onSelectionChange) {
234
+ onSelectionChange(Array.from(selectedRows));
235
+ }
236
+ }
237
+
238
+ function formatCellValue(value: any, column: Column, row: any): string {
239
+ if (column.customRender) {
240
+ return column.customRender(value, row);
241
+ }
242
+
243
+ if (!value) return "-";
244
+
245
+ switch (column.type) {
246
+ case "date":
247
+ return formatDate(value);
248
+ case "number":
249
+ return Number(value).toLocaleString("pt-PT");
250
+ default:
251
+ return value.toString();
252
+ }
253
+ }
254
+
255
+ function resolveRowActions(row: any): ActionItem[] {
256
+ return actions.filter((action) => {
257
+ if (action.permission && !hasPermission(action.permission)) {
258
+ return false;
259
+ }
260
+
261
+ if (action.condition && !action.condition(row)) {
262
+ return false;
263
+ }
264
+
265
+ return true;
266
+ });
267
+ }
268
+
269
+ let actionsAvailable = false;
270
+ $: actionsAvailable = filteredData.some((row) => resolveRowActions(row).length > 0);
271
+
272
+ // Drag and drop handlers - only triggered from drag handle
273
+ function handleDragStart(e: DragEvent, index: number) {
274
+ if (!draggable) return;
275
+ draggedIndex = index;
276
+ isDragging = true;
277
+ if (e.dataTransfer) {
278
+ e.dataTransfer.effectAllowed = 'move';
279
+ e.dataTransfer.setData('text/plain', String(index));
280
+ }
281
+ }
282
+
283
+ function handleDragOver(e: DragEvent, index: number) {
284
+ if (!draggable || draggedIndex === null) return;
285
+ e.preventDefault();
286
+ if (e.dataTransfer) {
287
+ e.dataTransfer.dropEffect = 'move';
288
+ }
289
+ dropTargetIndex = index;
290
+ }
291
+
292
+ function handleDragLeave() {
293
+ dropTargetIndex = null;
294
+ }
295
+
296
+ function handleDrop(e: DragEvent, index: number) {
297
+ if (!draggable || draggedIndex === null) return;
298
+ e.preventDefault();
299
+
300
+ if (draggedIndex !== index) {
301
+ // Reorder the local data array
302
+ const newData = [...localData];
303
+ const [removed] = newData.splice(draggedIndex, 1);
304
+ newData.splice(index, 0, removed);
305
+
306
+ // Update local data immediately for visual feedback
307
+ localData = newData;
308
+
309
+ // Update signature to reflect new order
310
+ dataSignature = getDataSignature(newData);
311
+
312
+ // Get ordered IDs and call the callback
313
+ const orderedIds = newData.map(item => item.id);
314
+ if (onReorder) {
315
+ onReorder(orderedIds);
316
+ }
317
+ }
318
+
319
+ draggedIndex = null;
320
+ dropTargetIndex = null;
321
+ isDragging = false;
322
+ longPressingIndex = null;
323
+ }
324
+
325
+ function handleDragEnd() {
326
+ draggedIndex = null;
327
+ dropTargetIndex = null;
328
+ isDragging = false;
329
+ longPressingIndex = null;
330
+ }
331
+
332
+ // Long press handlers for touch devices
333
+ function startLongPress(e: TouchEvent, index: number) {
334
+ if (!draggable) return;
335
+ e.preventDefault();
336
+
337
+ if (longPressTimer) clearTimeout(longPressTimer);
338
+ longPressingIndex = index;
339
+
340
+ longPressTimer = setTimeout(() => {
341
+ // After long press, initiate drag mode
342
+ draggedIndex = index;
343
+ isDragging = true;
344
+ // Vibrate if available for haptic feedback
345
+ if (navigator.vibrate) {
346
+ navigator.vibrate(50);
347
+ }
348
+ }, LONG_PRESS_DURATION);
349
+ }
350
+
351
+ function cancelLongPress() {
352
+ if (longPressTimer) {
353
+ clearTimeout(longPressTimer);
354
+ longPressTimer = null;
355
+ }
356
+ if (!isDragging) {
357
+ longPressingIndex = null;
358
+ }
359
+ }
360
+
361
+ function handleTouchMove(e: TouchEvent, index: number) {
362
+ if (!isDragging || draggedIndex === null) {
363
+ cancelLongPress();
364
+ return;
365
+ }
366
+
367
+ // Find which row we're over
368
+ const touch = e.touches[0];
369
+ const element = document.elementFromPoint(touch.clientX, touch.clientY);
370
+ const row = element?.closest('.grid-row');
371
+ if (row) {
372
+ const rowIndex = parseInt(row.getAttribute('data-index') || '-1');
373
+ if (rowIndex >= 0) {
374
+ dropTargetIndex = rowIndex;
375
+ }
376
+ }
377
+ }
378
+
379
+ function handleTouchEnd(e: TouchEvent, index: number) {
380
+ cancelLongPress();
381
+
382
+ if (isDragging && draggedIndex !== null && dropTargetIndex !== null) {
383
+ // Perform the reorder
384
+ if (draggedIndex !== dropTargetIndex) {
385
+ const newData = [...localData];
386
+ const [removed] = newData.splice(draggedIndex, 1);
387
+ newData.splice(dropTargetIndex, 0, removed);
388
+ localData = newData;
389
+ dataSignature = getDataSignature(newData);
390
+ const orderedIds = newData.map(item => item.id);
391
+ if (onReorder) {
392
+ onReorder(orderedIds);
393
+ }
394
+ }
395
+ }
396
+
397
+ draggedIndex = null;
398
+ dropTargetIndex = null;
399
+ isDragging = false;
400
+ longPressingIndex = null;
401
+ }
402
+ </script>
403
+
404
+ <div class="grid-view">
405
+ {#if searchable}
406
+ <div class="card mb-3">
407
+ <div class="card-body">
408
+ <div class="row align-items-center">
409
+ <div class="col-md-6">
410
+ <label for="grid-search" class="visually-hidden">Pesquisar na tabela</label>
411
+ <div class="search-group">
412
+ <input
413
+ id="grid-search"
414
+ bind:value={searchQuery}
415
+ on:keydown={handleSearchKeydown}
416
+ type="search"
417
+ class="form-control"
418
+ placeholder={searchPlaceholder}
419
+ aria-label="Pesquisar na tabela"
420
+ />
421
+ {#if searchQuery}
422
+ <button
423
+ type="button"
424
+ class="btn btn-clear-search"
425
+ on:click={clearSearch}
426
+ aria-label="Limpar pesquisa"
427
+ >
428
+ <i class="bi bi-x-lg"></i>
429
+ Limpar
430
+ </button>
431
+ {/if}
432
+ </div>
433
+ </div>
434
+ <div class="col-md-6 text-end">
435
+ <small class="text-muted">
436
+ {#if paginated && filteredData.length > 0}
437
+ {rangeStart}–{rangeEnd} de {filteredData.length} registos
438
+ {:else}
439
+ {filteredData.length} de {data.length} registos
440
+ {/if}
441
+ {#if selectedRows.size > 0}
442
+ • {selectedRows.size} selecionados
443
+ {/if}
444
+ </small>
445
+ </div>
446
+ </div>
447
+ </div>
448
+ </div>
449
+ {/if}
450
+
451
+ <div class="card">
452
+ {#if loading}
453
+ <div class="card-body text-center py-5">
454
+ <div class="spinner-border" role="status">
455
+ <span class="visually-hidden">A carregar...</span>
456
+ </div>
457
+ <p class="mt-2 text-muted">A carregar dados...</p>
458
+ </div>
459
+ {:else if filteredData.length === 0}
460
+ <div class="card-body text-center py-5">
461
+ <div class="text-muted">
462
+ <i class="bi bi-inbox" style="font-size: 3rem;"></i>
463
+ <h5 class="mt-3">{emptyMessage}</h5>
464
+ {#if searchQuery}
465
+ <p>Nenhum resultado encontrado para "{searchQuery}"</p>
466
+ {/if}
467
+ </div>
468
+ </div>
469
+ {:else}
470
+ <div class="grid-table-wrapper">
471
+ <div class="grid-table">
472
+ <!-- Header -->
473
+ <div class="grid-header" style="grid-template-columns: {gridTemplateColumns};">
474
+ {#if draggable}
475
+ <div class="grid-cell header-cell"></div>
476
+ {/if}
477
+ {#if selectable}
478
+ <div class="grid-cell header-cell">
479
+ <input
480
+ type="checkbox"
481
+ class="form-check-input"
482
+ checked={selectedRows.size === filteredData.length && filteredData.length > 0}
483
+ indeterminate={selectedRows.size > 0 && selectedRows.size < filteredData.length}
484
+ on:change={toggleSelectAll}
485
+ />
486
+ </div>
487
+ {/if}
488
+ {#each columns as column}
489
+ {#if !column.hidden}
490
+ <div
491
+ class="grid-cell header-cell"
492
+ class:sortable={column.sortable}
493
+ on:click={() => handleSort(column)}
494
+ on:keydown={(e) => e.key === 'Enter' && handleSort(column)}
495
+ role={column.sortable ? "button" : undefined}
496
+ tabindex={column.sortable ? 0 : undefined}
497
+ >
498
+ {column.title}
499
+ {#if column.sortable}
500
+ {#if sortColumn === column.key}
501
+ {#if sortDirection === "asc"}
502
+ <i class="bi bi-arrow-up"></i>
503
+ {:else}
504
+ <i class="bi bi-arrow-down"></i>
505
+ {/if}
506
+ {:else}
507
+ <i class="bi bi-arrow-down-up opacity-50"></i>
508
+ {/if}
509
+ {/if}
510
+ </div>
511
+ {/if}
512
+ {/each}
513
+ {#if actionsAvailable}
514
+ <div class="grid-cell header-cell">Ações</div>
515
+ {/if}
516
+ </div>
517
+
518
+ <!-- Body -->
519
+ <div class="grid-body">
520
+ {#each displayData as row, index (row.id ?? index)}
521
+ <div
522
+ class="grid-row"
523
+ class:clickable-row={onRowClick && !isDragging}
524
+ class:dragging={draggedIndex === index}
525
+ class:drop-above={dropTargetIndex === index && draggedIndex !== null && draggedIndex > index}
526
+ class:drop-below={dropTargetIndex === index && draggedIndex !== null && draggedIndex < index}
527
+ class:long-pressing={longPressingIndex === index && !isDragging}
528
+ style="grid-template-columns: {gridTemplateColumns};"
529
+ data-index={index}
530
+ on:click={() => !isDragging && handleRowClick(row)}
531
+ on:dragover={(e) => handleDragOver(e, index)}
532
+ on:dragleave={handleDragLeave}
533
+ on:drop={(e) => handleDrop(e, index)}
534
+ on:touchmove={(e) => handleTouchMove(e, index)}
535
+ on:touchend={(e) => handleTouchEnd(e, index)}
536
+ role="row"
537
+ >
538
+ {#if draggable}
539
+ <div
540
+ class="grid-cell drag-handle"
541
+ draggable="true"
542
+ on:dragstart={(e) => handleDragStart(e, index)}
543
+ on:dragend={handleDragEnd}
544
+ on:touchstart={(e) => startLongPress(e, index)}
545
+ on:touchend={cancelLongPress}
546
+ on:click={(e) => e.stopPropagation()}
547
+ >
548
+ <i class="bi bi-grip-vertical"></i>
549
+ </div>
550
+ {/if}
551
+ {#if selectable}
552
+ <div class="grid-cell" on:click={(e) => e.stopPropagation()}>
553
+ <input
554
+ type="checkbox"
555
+ class="form-check-input"
556
+ checked={selectedRows.has(row)}
557
+ on:change={() => toggleRowSelection(row)}
558
+ />
559
+ </div>
560
+ {/if}
561
+ {#each columns as column}
562
+ {#if !column.hidden}
563
+ <div class="grid-cell">
564
+ {#if column.type === "image"}
565
+ {#if row[column.key]}
566
+ <img src={resolveImageUrl(row[column.key], column.imageFolder || 'pets')} alt="Imagem" class="table-image" />
567
+ {:else}
568
+ <div class="table-image-placeholder">
569
+ <i class="bi bi-image"></i>
570
+ </div>
571
+ {/if}
572
+ {:else if column.type === "custom" && column.component}
573
+ {@const props = column.componentProps ? column.componentProps(row[column.key], row) : { value: row[column.key] }}
574
+ <svelte:component this={column.component} {...props} />
575
+ {:else if column.type === "badge"}
576
+ {@const variant = column.badgeVariant ? column.badgeVariant(row[column.key]) : 'secondary'}
577
+ <span class="badge bg-{variant}">
578
+ {formatCellValue(row[column.key], column, row)}
579
+ </span>
580
+ {:else if column.customRender}
581
+ {@html formatCellValue(row[column.key], column, row)}
582
+ {:else}
583
+ {formatCellValue(row[column.key], column, row)}
584
+ {/if}
585
+ </div>
586
+ {/if}
587
+ {/each}
588
+ {#if actionsAvailable}
589
+ {@const rowActions = resolveRowActions(row)}
590
+ <div class="grid-cell" on:click={(e) => e.stopPropagation()}>
591
+ {#if rowActions.length > 0}
592
+ <div class="dropdown">
593
+ <button
594
+ class="btn btn-sm btn-outline-secondary dropdown-toggle"
595
+ type="button"
596
+ data-bs-toggle="dropdown"
597
+ aria-label="Ações"
598
+ >
599
+ <i class="bi bi-three-dots"></i>
600
+ </button>
601
+ <ul class="dropdown-menu">
602
+ {#each rowActions as action}
603
+ <li>
604
+ <button
605
+ class="dropdown-item"
606
+ class:dropdown-item-success={action.variant === 'success'}
607
+ class:dropdown-item-danger={action.variant === 'danger'}
608
+ class:dropdown-item-warning={action.variant === 'warning'}
609
+ class:dropdown-item-info={action.variant === 'info'}
610
+ on:click={() => action.onClick(row)}
611
+ >
612
+ {#if action.icon}
613
+ <i class="bi {action.icon} me-2"></i>
614
+ {/if}
615
+ {action.label}
616
+ </button>
617
+ </li>
618
+ {/each}
619
+ </ul>
620
+ </div>
621
+ {/if}
622
+ </div>
623
+ {/if}
624
+ </div>
625
+ {/each}
626
+ </div>
627
+ </div>
628
+ </div>
629
+ {#if paginated && totalPages > 1}
630
+ <div class="grid-pagination">
631
+ <button
632
+ type="button"
633
+ class="page-btn page-nav"
634
+ disabled={currentPage === 1}
635
+ on:click={() => goToPage(currentPage - 1)}
636
+ aria-label="Página anterior"
637
+ >
638
+ <i class="bi bi-chevron-left"></i>
639
+ </button>
640
+ {#each pageNumbers as p}
641
+ {#if p === -1}
642
+ <span class="page-ellipsis">…</span>
643
+ {:else}
644
+ <button
645
+ type="button"
646
+ class="page-btn"
647
+ class:active={p === currentPage}
648
+ aria-current={p === currentPage ? "page" : undefined}
649
+ on:click={() => goToPage(p)}
650
+ >
651
+ {p}
652
+ </button>
653
+ {/if}
654
+ {/each}
655
+ <button
656
+ type="button"
657
+ class="page-btn page-nav"
658
+ disabled={currentPage === totalPages}
659
+ on:click={() => goToPage(currentPage + 1)}
660
+ aria-label="Página seguinte"
661
+ >
662
+ <i class="bi bi-chevron-right"></i>
663
+ </button>
664
+ </div>
665
+ {/if}
666
+ {/if}
667
+ </div>
668
+ </div>
669
+
670
+ <style>
671
+ /* Search input + clear button */
672
+ .search-group {
673
+ display: flex;
674
+ align-items: center;
675
+ gap: var(--spacing-sm);
676
+ }
677
+
678
+ .search-group .form-control {
679
+ flex: 1 1 auto;
680
+ }
681
+
682
+ .btn-clear-search {
683
+ flex: 0 0 auto;
684
+ display: inline-flex;
685
+ align-items: center;
686
+ gap: var(--spacing-xs);
687
+ white-space: nowrap;
688
+ border: 1px solid var(--color-border-subtle);
689
+ color: var(--color-text-muted);
690
+ background: var(--color-bg-surface);
691
+ font-size: var(--font-size-sm);
692
+ transition: all var(--transition-fast);
693
+ }
694
+
695
+ .btn-clear-search:hover {
696
+ border-color: var(--color-primary);
697
+ color: var(--color-primary);
698
+ background: var(--color-primary-light);
699
+ }
700
+
701
+ /* Grid Table Layout */
702
+ .grid-table-wrapper {
703
+ overflow-x: auto;
704
+ -webkit-overflow-scrolling: touch;
705
+ }
706
+
707
+ .grid-table {
708
+ width: 100%;
709
+ min-width: 600px; /* Minimum width before scroll kicks in */
710
+ }
711
+
712
+ .grid-header {
713
+ display: grid;
714
+ background: var(--gradient-card-header);
715
+ border-bottom: 2px solid var(--color-primary-light);
716
+ /* Match the card's inner rounded corners so the gradient doesn't bleed past them */
717
+ border-top-left-radius: calc(var(--radius-lg) - 1px);
718
+ border-top-right-radius: calc(var(--radius-lg) - 1px);
719
+ }
720
+
721
+ .grid-body {
722
+ display: flex;
723
+ flex-direction: column;
724
+ }
725
+
726
+ .grid-row {
727
+ display: grid;
728
+ border-bottom: 1px solid var(--border-color-light);
729
+ transition: background-color 0.15s ease;
730
+ }
731
+
732
+ .grid-row:last-child {
733
+ border-bottom: none;
734
+ }
735
+
736
+ .grid-cell {
737
+ padding: var(--spacing-md) var(--spacing-lg);
738
+ display: flex;
739
+ align-items: center;
740
+ overflow: hidden;
741
+ text-overflow: ellipsis;
742
+ white-space: nowrap;
743
+ min-width: 0;
744
+ }
745
+
746
+ /* Header styling */
747
+ .header-cell {
748
+ font-weight: var(--font-weight-semibold);
749
+ font-size: var(--font-size-sm);
750
+ color: var(--color-primary);
751
+ text-transform: uppercase;
752
+ letter-spacing: 0.03em;
753
+ }
754
+
755
+ .header-cell.sortable {
756
+ cursor: pointer;
757
+ user-select: none;
758
+ transition: all var(--transition-fast);
759
+ }
760
+
761
+ .header-cell.sortable:hover {
762
+ background-color: var(--color-primary-light);
763
+ color: var(--color-primary-active);
764
+ }
765
+
766
+ .header-cell i {
767
+ font-size: var(--font-size-sm);
768
+ margin-left: var(--spacing-xs);
769
+ color: var(--color-gray-500);
770
+ }
771
+
772
+ .header-cell i.bi-arrow-up,
773
+ .header-cell i.bi-arrow-down {
774
+ color: var(--color-primary);
775
+ }
776
+
777
+ /* Row styling */
778
+ .clickable-row {
779
+ cursor: pointer;
780
+ }
781
+
782
+ .clickable-row:hover {
783
+ background-color: var(--color-primary-light);
784
+ }
785
+
786
+ /* Drag and drop styling */
787
+ .drag-handle {
788
+ justify-content: center;
789
+ color: var(--color-gray-400);
790
+ background: var(--color-gray-50);
791
+ border-right: 1px solid var(--border-color-light);
792
+ transition: all 0.15s ease;
793
+ cursor: grab;
794
+ user-select: none;
795
+ touch-action: none; /* Prevents scroll on touch */
796
+ }
797
+
798
+ .drag-handle:active {
799
+ cursor: grabbing;
800
+ }
801
+
802
+ .grid-row:hover .drag-handle {
803
+ color: var(--color-gray-600);
804
+ background: var(--color-gray-100);
805
+ }
806
+
807
+ .drag-handle i {
808
+ font-size: 1.1rem;
809
+ pointer-events: none;
810
+ }
811
+
812
+ .grid-row.dragging {
813
+ opacity: 0.5;
814
+ background-color: var(--color-gray-200) !important;
815
+ }
816
+
817
+ /* Long press animation for touch devices */
818
+ .grid-row.long-pressing {
819
+ animation: long-press-glow 0.5s ease-in-out forwards;
820
+ }
821
+
822
+ @keyframes long-press-glow {
823
+ 0% {
824
+ box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.6);
825
+ }
826
+ 100% {
827
+ box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.4);
828
+ }
829
+ }
830
+
831
+ .grid-row.drop-above {
832
+ position: relative;
833
+ }
834
+
835
+ .grid-row.drop-above::before {
836
+ content: '';
837
+ position: absolute;
838
+ top: -2px;
839
+ left: 0;
840
+ right: 0;
841
+ height: 3px;
842
+ background-color: var(--color-primary);
843
+ border-radius: 2px;
844
+ z-index: 10;
845
+ }
846
+
847
+ .grid-row.drop-below {
848
+ position: relative;
849
+ }
850
+
851
+ .grid-row.drop-below::after {
852
+ content: '';
853
+ position: absolute;
854
+ bottom: -2px;
855
+ left: 0;
856
+ right: 0;
857
+ height: 3px;
858
+ background-color: var(--color-primary);
859
+ border-radius: 2px;
860
+ z-index: 10;
861
+ }
862
+
863
+ /* Image styling */
864
+ .table-image {
865
+ width: 40px;
866
+ height: 40px;
867
+ object-fit: cover;
868
+ border-radius: var(--radius-md);
869
+ box-shadow: var(--shadow-xs);
870
+ }
871
+
872
+ .table-image-placeholder {
873
+ width: 40px;
874
+ height: 40px;
875
+ background-color: var(--color-gray-100);
876
+ border-radius: var(--radius-md);
877
+ display: flex;
878
+ align-items: center;
879
+ justify-content: center;
880
+ color: var(--color-gray-400);
881
+ font-size: var(--font-size-lg);
882
+ }
883
+
884
+ /* Empty state styling */
885
+ :global(.grid-view .card-body .text-muted) {
886
+ color: var(--color-gray-500);
887
+ }
888
+
889
+ :global(.grid-view .card-body h5) {
890
+ font-weight: var(--font-weight-semibold);
891
+ color: var(--color-gray-700);
892
+ font-size: var(--font-size-lg);
893
+ }
894
+
895
+ /* Search results counter */
896
+ :global(.grid-view small.text-muted) {
897
+ color: var(--color-gray-600);
898
+ font-size: var(--font-size-sm);
899
+ font-weight: var(--font-weight-medium);
900
+ }
901
+
902
+ /* Loading spinner */
903
+ :global(.grid-view .spinner-border) {
904
+ color: var(--color-primary);
905
+ width: var(--spacing-xl);
906
+ height: var(--spacing-xl);
907
+ }
908
+
909
+ /* Checkbox indeterminate state */
910
+ input[type="checkbox"]:indeterminate {
911
+ background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10h8'/%3e%3c/svg%3e");
912
+ }
913
+
914
+ /* Dropdown styling */
915
+ .grid-cell .dropdown {
916
+ position: static;
917
+ }
918
+
919
+ .grid-cell .dropdown-menu {
920
+ z-index: 1050;
921
+ position: fixed;
922
+ }
923
+
924
+ /* Action button styling */
925
+ :global(.grid-view .btn-outline-secondary) {
926
+ border-color: var(--border-color-medium);
927
+ color: var(--color-gray-600);
928
+ transition: all var(--transition-fast);
929
+ }
930
+
931
+ :global(.grid-view .btn-outline-secondary:hover) {
932
+ background-color: var(--color-gray-50);
933
+ border-color: var(--border-color-dark);
934
+ color: var(--color-gray-800);
935
+ }
936
+
937
+ /* Dropdown menu items with variants */
938
+ .dropdown-item-success {
939
+ color: var(--color-success);
940
+ }
941
+
942
+ .dropdown-item-success:hover {
943
+ background-color: var(--color-success-light);
944
+ }
945
+
946
+ .dropdown-item-danger {
947
+ color: var(--color-danger);
948
+ }
949
+
950
+ .dropdown-item-danger:hover {
951
+ background-color: var(--color-danger-light);
952
+ }
953
+
954
+ .dropdown-item-warning {
955
+ color: var(--color-warning);
956
+ }
957
+
958
+ .dropdown-item-warning:hover {
959
+ background-color: var(--color-warning-light);
960
+ }
961
+
962
+ .dropdown-item-info {
963
+ color: var(--color-info);
964
+ }
965
+
966
+ .dropdown-item-info:hover {
967
+ background-color: var(--color-info-light);
968
+ }
969
+
970
+ /* Pagination */
971
+ .grid-pagination {
972
+ display: flex;
973
+ flex-wrap: wrap;
974
+ align-items: center;
975
+ justify-content: center;
976
+ gap: var(--spacing-xs);
977
+ padding: var(--spacing-md) var(--spacing-lg);
978
+ border-top: 1px solid var(--color-border-subtle);
979
+ }
980
+
981
+ .page-btn {
982
+ min-width: 2.25rem;
983
+ height: 2.25rem;
984
+ padding: 0 var(--spacing-sm);
985
+ border: 1px solid var(--color-border-subtle);
986
+ border-radius: var(--radius-md);
987
+ background: var(--color-bg-surface);
988
+ color: var(--color-text-muted);
989
+ font-size: var(--font-size-sm);
990
+ font-weight: var(--font-weight-medium);
991
+ cursor: pointer;
992
+ transition: all var(--transition-fast);
993
+ }
994
+
995
+ .page-btn:hover:not(:disabled):not(.active) {
996
+ border-color: var(--color-primary);
997
+ color: var(--color-primary);
998
+ background: var(--color-primary-light);
999
+ }
1000
+
1001
+ .page-btn.active {
1002
+ background: var(--gradient-brand);
1003
+ border-color: transparent;
1004
+ color: #fff;
1005
+ box-shadow: var(--shadow-xs);
1006
+ }
1007
+
1008
+ .page-btn:disabled {
1009
+ opacity: 0.45;
1010
+ cursor: not-allowed;
1011
+ }
1012
+
1013
+ .page-ellipsis {
1014
+ padding: 0 var(--spacing-xs);
1015
+ color: var(--color-text-muted);
1016
+ }
1017
+
1018
+ /* Stacked avatars cell should not clip overflow */
1019
+ .grid-cell :global(.pet-avatars-stack) {
1020
+ overflow: visible;
1021
+ }
1022
+ </style>