@svadmin/ui 0.63.0 → 0.65.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 (104) hide show
  1. package/dist/components/ActivityFeed.svelte +135 -0
  2. package/dist/components/ActivityFeed.svelte.d.ts +21 -0
  3. package/dist/components/ApprovalActionCard.svelte +248 -0
  4. package/dist/components/ApprovalActionCard.svelte.d.ts +22 -0
  5. package/dist/components/AutoTable.svelte +8 -0
  6. package/dist/components/AutoTable.svelte.d.ts +7 -1
  7. package/dist/components/CanvasAnnotation.svelte +389 -0
  8. package/dist/components/CanvasAnnotation.svelte.d.ts +17 -0
  9. package/dist/components/Cascader.svelte +205 -0
  10. package/dist/components/Cascader.svelte.d.ts +21 -0
  11. package/dist/components/ColumnHeaderFilter.svelte +178 -0
  12. package/dist/components/ColumnHeaderFilter.svelte.d.ts +12 -0
  13. package/dist/components/ColumnSettings.svelte +198 -0
  14. package/dist/components/ColumnSettings.svelte.d.ts +19 -0
  15. package/dist/components/DecisionTable.svelte +240 -0
  16. package/dist/components/DecisionTable.svelte.d.ts +22 -0
  17. package/dist/components/DraggableRowTable.svelte +117 -0
  18. package/dist/components/DraggableRowTable.svelte.d.ts +15 -0
  19. package/dist/components/DrawerForm.svelte +110 -26
  20. package/dist/components/DrawerForm.svelte.d.ts +20 -8
  21. package/dist/components/DynamicFormList.svelte +214 -0
  22. package/dist/components/DynamicFormList.svelte.d.ts +62 -0
  23. package/dist/components/EditableTable.svelte +195 -0
  24. package/dist/components/EditableTable.svelte.d.ts +25 -0
  25. package/dist/components/FieldRenderer.svelte +32 -0
  26. package/dist/components/FilterBuilder.svelte +310 -0
  27. package/dist/components/FilterBuilder.svelte.d.ts +23 -0
  28. package/dist/components/GanttChart.svelte +144 -0
  29. package/dist/components/GanttChart.svelte.d.ts +20 -0
  30. package/dist/components/ImageCropper.svelte +122 -0
  31. package/dist/components/ImageCropper.svelte.d.ts +12 -0
  32. package/dist/components/ImportWizard.svelte +336 -0
  33. package/dist/components/ImportWizard.svelte.d.ts +12 -0
  34. package/dist/components/JsonSchemaForm.svelte +149 -0
  35. package/dist/components/JsonSchemaForm.svelte.d.ts +10 -0
  36. package/dist/components/KanbanBoard.svelte +212 -0
  37. package/dist/components/KanbanBoard.svelte.d.ts +30 -0
  38. package/dist/components/MasterDetailView.svelte +134 -0
  39. package/dist/components/MasterDetailView.svelte.d.ts +16 -0
  40. package/dist/components/MediaLibraryModal.svelte +195 -0
  41. package/dist/components/MediaLibraryModal.svelte.d.ts +22 -0
  42. package/dist/components/MentionsInput.svelte +174 -0
  43. package/dist/components/MentionsInput.svelte.d.ts +21 -0
  44. package/dist/components/ModalForm.svelte +113 -28
  45. package/dist/components/ModalForm.svelte.d.ts +20 -8
  46. package/dist/components/MultiTabKeepAlive.svelte +143 -0
  47. package/dist/components/MultiTabKeepAlive.svelte.d.ts +21 -0
  48. package/dist/components/OfflineSyncBanner.svelte +172 -0
  49. package/dist/components/OfflineSyncBanner.svelte.d.ts +19 -0
  50. package/dist/components/PdfDocumentViewer.svelte +255 -0
  51. package/dist/components/PdfDocumentViewer.svelte.d.ts +22 -0
  52. package/dist/components/PivotTable.svelte +140 -0
  53. package/dist/components/PivotTable.svelte.d.ts +16 -0
  54. package/dist/components/PresenceAvatarGroup.svelte +89 -0
  55. package/dist/components/PresenceAvatarGroup.svelte.d.ts +15 -0
  56. package/dist/components/PrintableBill.svelte +149 -0
  57. package/dist/components/PrintableBill.svelte.d.ts +24 -0
  58. package/dist/components/SensitiveDataMask.svelte +125 -0
  59. package/dist/components/SensitiveDataMask.svelte.d.ts +13 -0
  60. package/dist/components/SignaturePad.svelte +264 -0
  61. package/dist/components/SignaturePad.svelte.d.ts +17 -0
  62. package/dist/components/SplitPaneLayout.svelte +113 -0
  63. package/dist/components/SplitPaneLayout.svelte.d.ts +13 -0
  64. package/dist/components/SpreadsheetView.svelte +363 -0
  65. package/dist/components/SpreadsheetView.svelte.d.ts +18 -0
  66. package/dist/components/StepForm.svelte +180 -0
  67. package/dist/components/StepForm.svelte.d.ts +23 -0
  68. package/dist/components/TableSummary.svelte +110 -0
  69. package/dist/components/TableSummary.svelte.d.ts +20 -0
  70. package/dist/components/Transfer.svelte +352 -0
  71. package/dist/components/Transfer.svelte.d.ts +28 -0
  72. package/dist/components/TreeSelect.svelte +307 -0
  73. package/dist/components/TreeSelect.svelte.d.ts +22 -0
  74. package/dist/components/TreeTable.svelte +237 -0
  75. package/dist/components/TreeTable.svelte.d.ts +28 -0
  76. package/dist/components/UnsavedChangesPrompt.svelte +44 -0
  77. package/dist/components/UnsavedChangesPrompt.svelte.d.ts +7 -0
  78. package/dist/components/VersionDiffViewer.svelte +196 -0
  79. package/dist/components/VersionDiffViewer.svelte.d.ts +12 -0
  80. package/dist/components/VirtualTable.svelte +86 -54
  81. package/dist/components/VirtualTable.svelte.d.ts +14 -21
  82. package/dist/components/Watermark.svelte +166 -0
  83. package/dist/components/Watermark.svelte.d.ts +26 -0
  84. package/dist/components/ui/navigation-menu/index.d.ts +11 -0
  85. package/dist/components/ui/navigation-menu/index.js +13 -0
  86. package/dist/components/ui/navigation-menu/navigation-menu-content.svelte +21 -0
  87. package/dist/components/ui/navigation-menu/navigation-menu-content.svelte.d.ts +4 -0
  88. package/dist/components/ui/navigation-menu/navigation-menu-indicator.svelte +22 -0
  89. package/dist/components/ui/navigation-menu/navigation-menu-indicator.svelte.d.ts +4 -0
  90. package/dist/components/ui/navigation-menu/navigation-menu-item.svelte +17 -0
  91. package/dist/components/ui/navigation-menu/navigation-menu-item.svelte.d.ts +4 -0
  92. package/dist/components/ui/navigation-menu/navigation-menu-link.svelte +20 -0
  93. package/dist/components/ui/navigation-menu/navigation-menu-link.svelte.d.ts +4 -0
  94. package/dist/components/ui/navigation-menu/navigation-menu-list.svelte +17 -0
  95. package/dist/components/ui/navigation-menu/navigation-menu-list.svelte.d.ts +4 -0
  96. package/dist/components/ui/navigation-menu/navigation-menu-trigger.svelte +28 -0
  97. package/dist/components/ui/navigation-menu/navigation-menu-trigger.svelte.d.ts +4 -0
  98. package/dist/components/ui/navigation-menu/navigation-menu-viewport.svelte +22 -0
  99. package/dist/components/ui/navigation-menu/navigation-menu-viewport.svelte.d.ts +4 -0
  100. package/dist/components/ui/navigation-menu/navigation-menu.svelte +24 -0
  101. package/dist/components/ui/navigation-menu/navigation-menu.svelte.d.ts +4 -0
  102. package/dist/index.d.ts +68 -3
  103. package/dist/index.js +42 -3
  104. package/package.json +2 -2
@@ -0,0 +1,363 @@
1
+ <script lang="ts">
2
+ import { Button } from './ui/button/index.js';
3
+ import { Plus, Download, FileSpreadsheet } from '@lucide/svelte';
4
+ import { cn } from '../utils.js';
5
+
6
+ export interface SheetData {
7
+ id: string;
8
+ name: string;
9
+ rows: number;
10
+ cols: number;
11
+ cells: Record<string, string>; // e.g. "A1": "100", "A2": "=SUM(A1:A1)"
12
+ }
13
+
14
+ interface Props {
15
+ sheets?: SheetData[];
16
+ activeSheetId?: string;
17
+ readonly?: boolean;
18
+ onchange?: (sheets: SheetData[]) => void;
19
+ onexport?: (csv: string) => void;
20
+ class?: string;
21
+ }
22
+
23
+ let {
24
+ sheets = $bindable([
25
+ {
26
+ id: 'sheet1',
27
+ name: 'Financial Summary',
28
+ rows: 8,
29
+ cols: 6,
30
+ cells: {
31
+ A1: 'Category',
32
+ B1: 'Q1',
33
+ C1: 'Q2',
34
+ D1: 'Q3',
35
+ E1: 'Q4',
36
+ F1: 'Total',
37
+ A2: 'Revenue',
38
+ B2: '12000',
39
+ C2: '15000',
40
+ D2: '18000',
41
+ E2: '21000',
42
+ F2: '=SUM(B2:E2)',
43
+ A3: 'Cost',
44
+ B3: '4000',
45
+ C3: '5000',
46
+ D3: '6000',
47
+ E3: '7000',
48
+ F3: '=SUM(B3:E3)',
49
+ },
50
+ },
51
+ ]),
52
+ activeSheetId = $bindable('sheet1'),
53
+ readonly = false,
54
+ onchange,
55
+ onexport,
56
+ class: className = '',
57
+ }: Props = $props();
58
+
59
+ let selectedCell = $state<string>('A1');
60
+ let formulaInput = $state('');
61
+
62
+ const currentSheet = $derived(
63
+ sheets.find((s) => s.id === activeSheetId) ?? sheets[0]
64
+ );
65
+
66
+ function getColName(index: number): string {
67
+ let result = '';
68
+ let i = index;
69
+ while (i >= 0) {
70
+ result = String.fromCharCode((i % 26) + 65) + result;
71
+ i = Math.floor(i / 26) - 1;
72
+ }
73
+ return result;
74
+ }
75
+
76
+ function colNameToIndex(col: string): number {
77
+ let idx = 0;
78
+ for (let i = 0; i < col.length; i++) {
79
+ idx = idx * 26 + (col.charCodeAt(i) - 64);
80
+ }
81
+ return idx - 1;
82
+ }
83
+
84
+ function parseCellKey(key: string): { col: string; row: number } | null {
85
+ const match = key.match(/^([A-Z]+)(\d+)$/);
86
+ if (!match || !match[1] || !match[2]) return null;
87
+ return { col: match[1], row: parseInt(match[2], 10) };
88
+ }
89
+
90
+ function evaluateFormula(formula: string, cells: Record<string, string>): number | string {
91
+ const sumMatch = formula.match(/^=SUM\(([A-Z]+\d+):([A-Z]+\d+)\)$/i);
92
+ const avgMatch = formula.match(/^=AVG\(([A-Z]+\d+):([A-Z]+\d+)\)$/i);
93
+ const countMatch = formula.match(/^=COUNT\(([A-Z]+\d+):([A-Z]+\d+)\)$/i);
94
+
95
+ const match = sumMatch || avgMatch || countMatch;
96
+ if (match && match[1] && match[2]) {
97
+ const start = parseCellKey(match[1].toUpperCase());
98
+ const end = parseCellKey(match[2].toUpperCase());
99
+ if (!start || !end) return '#ERROR!';
100
+
101
+ const startCol = colNameToIndex(start.col);
102
+ const endCol = colNameToIndex(end.col);
103
+ const minCol = Math.min(startCol, endCol);
104
+ const maxCol = Math.max(startCol, endCol);
105
+ const minRow = Math.min(start.row, end.row);
106
+ const maxRow = Math.max(start.row, end.row);
107
+
108
+ const numbers: number[] = [];
109
+ for (let c = minCol; c <= maxCol; c++) {
110
+ for (let r = minRow; r <= maxRow; r++) {
111
+ const k = `${getColName(c)}${r}`;
112
+ const raw = cells[k] ?? '';
113
+ const val = raw.startsWith('=') ? evaluateFormula(raw, cells) : Number(raw);
114
+ if (typeof val === 'number' && !isNaN(val)) {
115
+ numbers.push(val);
116
+ }
117
+ }
118
+ }
119
+
120
+ if (sumMatch) return numbers.reduce((a, b) => a + b, 0);
121
+ if (avgMatch) return numbers.length ? numbers.reduce((a, b) => a + b, 0) / numbers.length : 0;
122
+ if (countMatch) return numbers.length;
123
+ }
124
+
125
+ // Direct arithmetic
126
+ if (formula.startsWith('=')) {
127
+ try {
128
+ const expr = formula.slice(1).replace(/([A-Z]+\d+)/g, (k) => {
129
+ const val = cells[k.toUpperCase()] ?? '0';
130
+ return String(Number(val) || 0);
131
+ });
132
+ const sanitized = expr.replace(/[^0-9+\-*/(). ]/g, '');
133
+ const fn = new Function(`return (${sanitized || 0})`);
134
+ return Number(fn());
135
+ } catch {
136
+ return '#VALUE!';
137
+ }
138
+ }
139
+
140
+ return formula;
141
+ }
142
+
143
+ function getRenderedValue(key: string): string {
144
+ if (!currentSheet) return '';
145
+ const raw = currentSheet.cells[key] ?? '';
146
+ if (raw.startsWith('=')) {
147
+ const computed = evaluateFormula(raw, currentSheet.cells);
148
+ return typeof computed === 'number'
149
+ ? computed.toLocaleString(undefined, { maximumFractionDigits: 2 })
150
+ : String(computed);
151
+ }
152
+ return raw;
153
+ }
154
+
155
+ function selectCell(key: string) {
156
+ selectedCell = key;
157
+ if (currentSheet) {
158
+ formulaInput = currentSheet.cells[key] ?? '';
159
+ }
160
+ }
161
+
162
+ function updateCellValue(key: string, val: string) {
163
+ if (readonly || !currentSheet) return;
164
+ const nextSheets = sheets.map((s) => {
165
+ if (s.id === currentSheet.id) {
166
+ return {
167
+ ...s,
168
+ cells: {
169
+ ...s.cells,
170
+ [key]: val,
171
+ },
172
+ };
173
+ }
174
+ return s;
175
+ });
176
+ sheets = nextSheets;
177
+ onchange?.(sheets);
178
+ }
179
+
180
+ function handleFormulaCommit() {
181
+ if (!selectedCell) return;
182
+ updateCellValue(selectedCell, formulaInput);
183
+ }
184
+
185
+ function addRow() {
186
+ if (!currentSheet) return;
187
+ const next = sheets.map((s) => (s.id === currentSheet.id ? { ...s, rows: s.rows + 1 } : s));
188
+ sheets = next;
189
+ onchange?.(sheets);
190
+ }
191
+
192
+ function addCol() {
193
+ if (!currentSheet) return;
194
+ const next = sheets.map((s) => (s.id === currentSheet.id ? { ...s, cols: s.cols + 1 } : s));
195
+ sheets = next;
196
+ onchange?.(sheets);
197
+ }
198
+
199
+ function exportCsv() {
200
+ if (!currentSheet) return;
201
+ const lines: string[] = [];
202
+ for (let r = 1; r <= currentSheet.rows; r++) {
203
+ const rowVals: string[] = [];
204
+ for (let c = 0; c < currentSheet.cols; c++) {
205
+ const k = `${getColName(c)}${r}`;
206
+ const val = getRenderedValue(k).replace(/"/g, '""');
207
+ rowVals.push(`"${val}"`);
208
+ }
209
+ lines.push(rowVals.join(','));
210
+ }
211
+ const csvContent = lines.join('\n');
212
+ if (onexport) {
213
+ onexport(csvContent);
214
+ } else {
215
+ const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
216
+ const a = document.createElement('a');
217
+ a.href = URL.createObjectURL(blob);
218
+ a.download = `${currentSheet.name}_${Date.now()}.csv`;
219
+ a.click();
220
+ }
221
+ }
222
+
223
+ function addSheet() {
224
+ const id = `sheet_${Date.now()}`;
225
+ const newSheet: SheetData = {
226
+ id,
227
+ name: `Sheet ${sheets.length + 1}`,
228
+ rows: 6,
229
+ cols: 5,
230
+ cells: {},
231
+ };
232
+ sheets = [...sheets, newSheet];
233
+ activeSheetId = id;
234
+ onchange?.(sheets);
235
+ }
236
+ </script>
237
+
238
+ <div class={cn('rounded-xl border border-border bg-card shadow-xs text-xs space-y-2 p-3 overflow-hidden', className)}>
239
+ <!-- Header Toolbar -->
240
+ <div class="flex flex-wrap items-center justify-between gap-2 pb-2 border-b border-border/60">
241
+ <div class="flex items-center gap-2">
242
+ <FileSpreadsheet class="h-4 w-4 text-primary shrink-0" />
243
+ <span class="font-semibold text-foreground">{currentSheet?.name ?? 'Spreadsheet'}</span>
244
+ </div>
245
+
246
+ <div class="flex items-center gap-1.5">
247
+ {#if !readonly}
248
+ <Button variant="outline" size="sm" class="h-7 text-xs gap-1" onclick={addRow}>
249
+ <Plus class="h-3 w-3" />
250
+ Row
251
+ </Button>
252
+ <Button variant="outline" size="sm" class="h-7 text-xs gap-1" onclick={addCol}>
253
+ <Plus class="h-3 w-3" />
254
+ Column
255
+ </Button>
256
+ {/if}
257
+
258
+ <Button size="sm" class="h-7 text-xs gap-1" onclick={exportCsv}>
259
+ <Download class="h-3 w-3" />
260
+ Export CSV
261
+ </Button>
262
+ </div>
263
+ </div>
264
+
265
+ <!-- Formula Bar -->
266
+ <div class="flex items-center gap-2 px-2 py-1.5 rounded-lg bg-muted/40 border border-border/60 font-mono">
267
+ <div class="px-2 py-0.5 rounded bg-background border border-border text-foreground font-semibold text-[11px] min-w-10 text-center">
268
+ {selectedCell}
269
+ </div>
270
+ <span class="text-muted-foreground text-xs">fx</span>
271
+ <input
272
+ type="text"
273
+ bind:value={formulaInput}
274
+ {readonly}
275
+ placeholder="Value or formula, e.g. =SUM(B2:E2)"
276
+ class="flex-1 h-6 bg-transparent border-0 px-1 text-xs text-foreground focus-visible:outline-none placeholder:text-muted-foreground/60 font-mono"
277
+ oninput={handleFormulaCommit}
278
+ onkeydown={(e) => { if (e.key === 'Enter') handleFormulaCommit(); }}
279
+ />
280
+ </div>
281
+
282
+ <!-- Spreadsheet Grid -->
283
+ {#if currentSheet}
284
+ <div class="overflow-auto max-h-[500px] border border-border/60 rounded-lg">
285
+ <table class="w-full border-collapse text-left font-mono">
286
+ <thead class="bg-muted/60 sticky top-0 z-10 text-muted-foreground">
287
+ <tr>
288
+ <th class="w-10 p-1.5 text-center border-r border-b border-border/60 bg-muted/80 text-[10px]">#</th>
289
+ {#each Array.from({ length: currentSheet.cols }) as _, c (c)}
290
+ <th class="min-w-24 p-1.5 text-center border-r border-b border-border/60 font-semibold text-foreground text-xs">
291
+ {getColName(c)}
292
+ </th>
293
+ {/each}
294
+ </tr>
295
+ </thead>
296
+ <tbody class="divide-y divide-border/40">
297
+ {#each Array.from({ length: currentSheet.rows }) as _, r (r)}
298
+ {@const rowNum = r + 1}
299
+ <tr class="hover:bg-muted/10">
300
+ <td class="w-10 p-1 text-center border-r border-border/60 bg-muted/30 text-[10px] font-semibold text-muted-foreground select-none">
301
+ {rowNum}
302
+ </td>
303
+ {#each Array.from({ length: currentSheet.cols }) as _, c (c)}
304
+ {@const cellKey = `${getColName(c)}${rowNum}`}
305
+ {@const isSelected = selectedCell === cellKey}
306
+ <td
307
+ class={cn(
308
+ 'p-0 border-r border-border/40 relative cursor-pointer',
309
+ isSelected ? 'ring-2 ring-primary z-10 bg-primary/5' : ''
310
+ )}
311
+ onclick={() => selectCell(cellKey)}
312
+ >
313
+ <input
314
+ type="text"
315
+ {readonly}
316
+ value={isSelected ? (currentSheet.cells[cellKey] ?? '') : getRenderedValue(cellKey)}
317
+ class={cn(
318
+ 'w-full h-7 px-2 bg-transparent border-0 text-xs font-mono focus-visible:outline-none',
319
+ typeof evaluateFormula(currentSheet.cells[cellKey] ?? '', currentSheet.cells) === 'number' ? 'text-right' : 'text-left'
320
+ )}
321
+ onfocus={() => selectCell(cellKey)}
322
+ oninput={(e) => updateCellValue(cellKey, e.currentTarget.value)}
323
+ />
324
+ </td>
325
+ {/each}
326
+ </tr>
327
+ {/each}
328
+ </tbody>
329
+ </table>
330
+ </div>
331
+ {/if}
332
+
333
+ <!-- Sheet Tabs Footer -->
334
+ <div class="flex items-center justify-between gap-2 pt-1 border-t border-border/60">
335
+ <div class="flex items-center gap-1 overflow-x-auto">
336
+ {#each sheets as sheet (sheet.id)}
337
+ {@const isActive = sheet.id === activeSheetId}
338
+ <button
339
+ type="button"
340
+ class={cn(
341
+ 'px-3 py-1 rounded-md text-xs font-medium transition-colors cursor-pointer border',
342
+ isActive
343
+ ? 'bg-primary text-primary-foreground border-primary shadow-xs'
344
+ : 'bg-card text-muted-foreground border-border/60 hover:text-foreground hover:bg-muted/60'
345
+ )}
346
+ onclick={() => {
347
+ activeSheetId = sheet.id;
348
+ selectedCell = 'A1';
349
+ formulaInput = sheet.cells['A1'] ?? '';
350
+ }}
351
+ >
352
+ {sheet.name}
353
+ </button>
354
+ {/each}
355
+
356
+ {#if !readonly}
357
+ <Button variant="ghost" size="sm" class="h-6 w-6 p-0 text-muted-foreground" onclick={addSheet} title="New Sheet">
358
+ <Plus class="h-3.5 w-3.5" />
359
+ </Button>
360
+ {/if}
361
+ </div>
362
+ </div>
363
+ </div>
@@ -0,0 +1,18 @@
1
+ export interface SheetData {
2
+ id: string;
3
+ name: string;
4
+ rows: number;
5
+ cols: number;
6
+ cells: Record<string, string>;
7
+ }
8
+ interface Props {
9
+ sheets?: SheetData[];
10
+ activeSheetId?: string;
11
+ readonly?: boolean;
12
+ onchange?: (sheets: SheetData[]) => void;
13
+ onexport?: (csv: string) => void;
14
+ class?: string;
15
+ }
16
+ declare const SpreadsheetView: import("svelte").Component<Props, {}, "sheets" | "activeSheetId">;
17
+ type SpreadsheetView = ReturnType<typeof SpreadsheetView>;
18
+ export default SpreadsheetView;
@@ -0,0 +1,180 @@
1
+ <script lang="ts">
2
+ import type { Snippet, Component } from 'svelte';
3
+ import { Button } from './ui/button/index.js';
4
+ import { Check, ChevronRight, ChevronLeft, Loader2 } from '@lucide/svelte';
5
+ import { cn } from '../utils.js';
6
+
7
+ export interface FormStep {
8
+ title: string;
9
+ description?: string;
10
+ icon?: Component;
11
+ }
12
+
13
+ interface Props {
14
+ steps: FormStep[];
15
+ currentStep?: number;
16
+ onstepchange?: (newStep: number, prevStep: number) => boolean | Promise<boolean> | undefined;
17
+ onfinish?: () => void | Promise<void>;
18
+ oncancel?: () => void;
19
+ submitText?: string;
20
+ nextText?: string;
21
+ prevText?: string;
22
+ isSubmitting?: boolean;
23
+ class?: string;
24
+ children?: Snippet;
25
+ stepContent?: Snippet<[number]>;
26
+ }
27
+
28
+ let {
29
+ steps,
30
+ currentStep = $bindable(0),
31
+ onstepchange,
32
+ onfinish,
33
+ oncancel,
34
+ submitText = 'Submit',
35
+ nextText = 'Next Step',
36
+ prevText = 'Previous Step',
37
+ isSubmitting = false,
38
+ class: className = '',
39
+ children,
40
+ stepContent,
41
+ }: Props = $props();
42
+
43
+ let isMoving = $state(false);
44
+
45
+ async function goToStep(targetIndex: number) {
46
+ if (targetIndex < 0 || targetIndex >= steps.length) return;
47
+ if (targetIndex === currentStep) return;
48
+
49
+ isMoving = true;
50
+ try {
51
+ if (onstepchange) {
52
+ const canProceed = await onstepchange(targetIndex, currentStep);
53
+ if (canProceed === false) return;
54
+ }
55
+ currentStep = targetIndex;
56
+ } finally {
57
+ isMoving = false;
58
+ }
59
+ }
60
+
61
+ async function handleNext() {
62
+ if (currentStep < steps.length - 1) {
63
+ await goToStep(currentStep + 1);
64
+ } else {
65
+ await onfinish?.();
66
+ }
67
+ }
68
+
69
+ async function handlePrev() {
70
+ if (currentStep > 0) {
71
+ await goToStep(currentStep - 1);
72
+ }
73
+ }
74
+ </script>
75
+
76
+ <div class={cn('space-y-6 rounded-xl border border-border bg-card p-6 shadow-xs', className)}>
77
+ <!-- Step Navigation Header -->
78
+ <div class="flex items-center justify-between gap-2 overflow-x-auto pb-4 border-b border-border/60">
79
+ {#each steps as step, index (index)}
80
+ {@const isCompleted = index < currentStep}
81
+ {@const isCurrent = index === currentStep}
82
+ <button
83
+ type="button"
84
+ class={cn(
85
+ 'flex items-center gap-3 transition-colors text-left group cursor-pointer bg-transparent border-0 p-0',
86
+ index <= currentStep ? 'opacity-100' : 'opacity-50 hover:opacity-75'
87
+ )}
88
+ onclick={() => goToStep(index)}
89
+ disabled={isSubmitting || isMoving}
90
+ >
91
+ <div
92
+ class={cn(
93
+ 'flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-xs font-semibold transition-all shadow-xs',
94
+ isCompleted
95
+ ? 'bg-success text-success-foreground'
96
+ : isCurrent
97
+ ? 'bg-primary text-primary-foreground ring-4 ring-primary/20'
98
+ : 'bg-muted text-muted-foreground group-hover:bg-muted/80'
99
+ )}
100
+ >
101
+ {#if isCompleted}
102
+ <Check class="h-4 w-4" />
103
+ {:else if step.icon}
104
+ <step.icon class="h-4 w-4" />
105
+ {:else}
106
+ {index + 1}
107
+ {/if}
108
+ </div>
109
+
110
+ <div class="hidden sm:block">
111
+ <div class={cn('text-xs font-medium', isCurrent ? 'text-foreground font-semibold' : 'text-muted-foreground')}>
112
+ {step.title}
113
+ </div>
114
+ {#if step.description}
115
+ <div class="text-[11px] text-muted-foreground/70 truncate max-w-32">
116
+ {step.description}
117
+ </div>
118
+ {/if}
119
+ </div>
120
+ </button>
121
+
122
+ {#if index < steps.length - 1}
123
+ <div class="hidden flex-1 sm:flex items-center px-2">
124
+ <div class={cn('h-0.5 w-full rounded transition-colors', index < currentStep ? 'bg-success' : 'bg-border')}></div>
125
+ </div>
126
+ {/if}
127
+ {/each}
128
+ </div>
129
+
130
+ <!-- Step Content Body -->
131
+ <div class="py-2 min-h-48">
132
+ {#if stepContent}
133
+ {@render stepContent(currentStep)}
134
+ {:else if children}
135
+ {@render children()}
136
+ {/if}
137
+ </div>
138
+
139
+ <!-- Actions Footer -->
140
+ <div class="flex items-center justify-between pt-4 border-t border-border/60">
141
+ <div>
142
+ {#if oncancel}
143
+ <Button variant="ghost" size="sm" onclick={oncancel} disabled={isSubmitting || isMoving}>
144
+ Cancel
145
+ </Button>
146
+ {/if}
147
+ </div>
148
+
149
+ <div class="flex items-center gap-2">
150
+ {#if currentStep > 0}
151
+ <Button
152
+ variant="outline"
153
+ size="sm"
154
+ onclick={handlePrev}
155
+ disabled={isSubmitting || isMoving}
156
+ class="gap-1"
157
+ >
158
+ <ChevronLeft class="h-4 w-4" />
159
+ {prevText}
160
+ </Button>
161
+ {/if}
162
+
163
+ <Button
164
+ size="sm"
165
+ onclick={handleNext}
166
+ disabled={isSubmitting || isMoving}
167
+ class="gap-1 min-w-24"
168
+ >
169
+ {#if isSubmitting || isMoving}
170
+ <Loader2 class="h-4 w-4 animate-spin" />
171
+ {:else if currentStep === steps.length - 1}
172
+ {submitText}
173
+ {:else}
174
+ {nextText}
175
+ <ChevronRight class="h-4 w-4" />
176
+ {/if}
177
+ </Button>
178
+ </div>
179
+ </div>
180
+ </div>
@@ -0,0 +1,23 @@
1
+ import type { Snippet, Component } from 'svelte';
2
+ export interface FormStep {
3
+ title: string;
4
+ description?: string;
5
+ icon?: Component;
6
+ }
7
+ interface Props {
8
+ steps: FormStep[];
9
+ currentStep?: number;
10
+ onstepchange?: (newStep: number, prevStep: number) => boolean | Promise<boolean> | undefined;
11
+ onfinish?: () => void | Promise<void>;
12
+ oncancel?: () => void;
13
+ submitText?: string;
14
+ nextText?: string;
15
+ prevText?: string;
16
+ isSubmitting?: boolean;
17
+ class?: string;
18
+ children?: Snippet;
19
+ stepContent?: Snippet<[number]>;
20
+ }
21
+ declare const StepForm: Component<Props, {}, "currentStep">;
22
+ type StepForm = ReturnType<typeof StepForm>;
23
+ export default StepForm;
@@ -0,0 +1,110 @@
1
+ <script lang="ts">
2
+ import { cn } from '../utils.js';
3
+
4
+ export type AggregationType = 'sum' | 'avg' | 'count' | 'min' | 'max' | ((rows: Record<string, unknown>[]) => string | number);
5
+
6
+ export interface TableSummaryColumn {
7
+ key: string;
8
+ label?: string;
9
+ align?: 'left' | 'center' | 'right';
10
+ }
11
+
12
+ interface Props {
13
+ columns: TableSummaryColumn[];
14
+ data: Record<string, unknown>[];
15
+ aggregations: Record<string, AggregationType>;
16
+ title?: string;
17
+ titleColumnKey?: string;
18
+ prefix?: Record<string, string>;
19
+ suffix?: Record<string, string>;
20
+ precision?: Record<string, number>;
21
+ class?: string;
22
+ }
23
+
24
+ let {
25
+ columns,
26
+ data = [],
27
+ aggregations = {},
28
+ title = 'Total',
29
+ titleColumnKey,
30
+ prefix = {},
31
+ suffix = {},
32
+ precision = {},
33
+ class: className = '',
34
+ }: Props = $props();
35
+
36
+ const titleKey = $derived(titleColumnKey || (columns[0]?.key ?? ''));
37
+
38
+ function calculateAggregation(key: string, type: AggregationType): string | number {
39
+ if (!data.length) return '—';
40
+
41
+ if (typeof type === 'function') {
42
+ return type(data);
43
+ }
44
+
45
+ if (type === 'count') {
46
+ return data.length;
47
+ }
48
+
49
+ const numbers = data
50
+ .map((row) => Number(row[key]))
51
+ .filter((val) => !isNaN(val) && val !== null && val !== undefined);
52
+
53
+ if (numbers.length === 0) return '—';
54
+
55
+ const p = precision[key] !== undefined ? precision[key] : 2;
56
+
57
+ switch (type) {
58
+ case 'sum': {
59
+ const total = numbers.reduce((acc, curr) => acc + curr, 0);
60
+ return Number.isInteger(total) && p === 0 ? total : total.toFixed(p);
61
+ }
62
+ case 'avg': {
63
+ const avg = numbers.reduce((acc, curr) => acc + curr, 0) / numbers.length;
64
+ return avg.toFixed(p);
65
+ }
66
+ case 'min': {
67
+ const min = Math.min(...numbers);
68
+ return Number.isInteger(min) && p === 0 ? min : min.toFixed(p);
69
+ }
70
+ case 'max': {
71
+ const max = Math.max(...numbers);
72
+ return Number.isInteger(max) && p === 0 ? max : max.toFixed(p);
73
+ }
74
+ default:
75
+ return '—';
76
+ }
77
+ }
78
+ </script>
79
+
80
+ <div class={cn('w-full overflow-x-auto border-t-2 border-border/80 bg-muted/30 font-medium text-xs text-foreground', className)}>
81
+ <table class="w-full text-left border-collapse">
82
+ <tbody>
83
+ <tr class="h-10">
84
+ {#each columns as col (col.key)}
85
+ {@const isTitle = col.key === titleKey}
86
+ {@const aggType = aggregations[col.key]}
87
+ {@const align = col.align || 'left'}
88
+ <td
89
+ class={cn(
90
+ 'px-4 py-2 tabular-nums',
91
+ align === 'right' ? 'text-right' : align === 'center' ? 'text-center' : 'text-left',
92
+ isTitle ? 'font-semibold text-foreground' : 'text-muted-foreground'
93
+ )}
94
+ >
95
+ {#if isTitle && !aggType}
96
+ <span>{title}</span>
97
+ {:else if aggType}
98
+ {@const result = calculateAggregation(col.key, aggType)}
99
+ <span class="font-semibold text-foreground">
100
+ {#if prefix[col.key]}{prefix[col.key]}{/if}{result}{#if suffix[col.key]}{suffix[col.key]}{/if}
101
+ </span>
102
+ {:else}
103
+ <span class="opacity-40">—</span>
104
+ {/if}
105
+ </td>
106
+ {/each}
107
+ </tr>
108
+ </tbody>
109
+ </table>
110
+ </div>