@svadmin/ui 0.64.0 → 0.65.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.
- package/dist/components/ActivityFeed.svelte +135 -0
- package/dist/components/ActivityFeed.svelte.d.ts +21 -0
- package/dist/components/ApprovalActionCard.svelte +248 -0
- package/dist/components/ApprovalActionCard.svelte.d.ts +22 -0
- package/dist/components/CanvasAnnotation.svelte +389 -0
- package/dist/components/CanvasAnnotation.svelte.d.ts +17 -0
- package/dist/components/ColumnHeaderFilter.svelte +178 -0
- package/dist/components/ColumnHeaderFilter.svelte.d.ts +12 -0
- package/dist/components/ColumnSettings.svelte +198 -0
- package/dist/components/ColumnSettings.svelte.d.ts +19 -0
- package/dist/components/DecisionTable.svelte +240 -0
- package/dist/components/DecisionTable.svelte.d.ts +22 -0
- package/dist/components/DraggableRowTable.svelte +117 -0
- package/dist/components/DraggableRowTable.svelte.d.ts +15 -0
- package/dist/components/DrawerForm.svelte +110 -26
- package/dist/components/DrawerForm.svelte.d.ts +20 -8
- package/dist/components/EditableTable.svelte +195 -0
- package/dist/components/EditableTable.svelte.d.ts +25 -0
- package/dist/components/GanttChart.svelte +144 -0
- package/dist/components/GanttChart.svelte.d.ts +20 -0
- package/dist/components/ImageCropper.svelte +122 -0
- package/dist/components/ImageCropper.svelte.d.ts +12 -0
- package/dist/components/ImportWizard.svelte +336 -0
- package/dist/components/ImportWizard.svelte.d.ts +12 -0
- package/dist/components/JsonSchemaForm.svelte +149 -0
- package/dist/components/JsonSchemaForm.svelte.d.ts +10 -0
- package/dist/components/KanbanBoard.svelte +212 -0
- package/dist/components/KanbanBoard.svelte.d.ts +30 -0
- package/dist/components/MasterDetailView.svelte +134 -0
- package/dist/components/MasterDetailView.svelte.d.ts +16 -0
- package/dist/components/MediaLibraryModal.svelte +195 -0
- package/dist/components/MediaLibraryModal.svelte.d.ts +22 -0
- package/dist/components/MentionsInput.svelte +174 -0
- package/dist/components/MentionsInput.svelte.d.ts +21 -0
- package/dist/components/ModalForm.svelte +113 -28
- package/dist/components/ModalForm.svelte.d.ts +20 -8
- package/dist/components/MultiTabKeepAlive.svelte +143 -0
- package/dist/components/MultiTabKeepAlive.svelte.d.ts +21 -0
- package/dist/components/OfflineSyncBanner.svelte +172 -0
- package/dist/components/OfflineSyncBanner.svelte.d.ts +19 -0
- package/dist/components/PdfDocumentViewer.svelte +255 -0
- package/dist/components/PdfDocumentViewer.svelte.d.ts +22 -0
- package/dist/components/PivotTable.svelte +140 -0
- package/dist/components/PivotTable.svelte.d.ts +16 -0
- package/dist/components/PresenceAvatarGroup.svelte +89 -0
- package/dist/components/PresenceAvatarGroup.svelte.d.ts +15 -0
- package/dist/components/PrintableBill.svelte +149 -0
- package/dist/components/PrintableBill.svelte.d.ts +24 -0
- package/dist/components/SensitiveDataMask.svelte +125 -0
- package/dist/components/SensitiveDataMask.svelte.d.ts +13 -0
- package/dist/components/SignaturePad.svelte +264 -0
- package/dist/components/SignaturePad.svelte.d.ts +17 -0
- package/dist/components/SplitPaneLayout.svelte +113 -0
- package/dist/components/SplitPaneLayout.svelte.d.ts +13 -0
- package/dist/components/SpreadsheetView.svelte +363 -0
- package/dist/components/SpreadsheetView.svelte.d.ts +18 -0
- package/dist/components/StepForm.svelte +180 -0
- package/dist/components/StepForm.svelte.d.ts +23 -0
- package/dist/components/TableSummary.svelte +110 -0
- package/dist/components/TableSummary.svelte.d.ts +20 -0
- package/dist/components/TreeTable.svelte +237 -0
- package/dist/components/TreeTable.svelte.d.ts +28 -0
- package/dist/components/UnsavedChangesPrompt.svelte +44 -0
- package/dist/components/UnsavedChangesPrompt.svelte.d.ts +7 -0
- package/dist/components/VersionDiffViewer.svelte +196 -0
- package/dist/components/VersionDiffViewer.svelte.d.ts +12 -0
- package/dist/components/VirtualTable.svelte +86 -54
- package/dist/components/VirtualTable.svelte.d.ts +14 -21
- package/dist/components/Watermark.svelte +166 -0
- package/dist/components/Watermark.svelte.d.ts +26 -0
- package/dist/index.d.ts +58 -3
- package/dist/index.js +35 -3
- package/package.json +2 -2
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { untrack } from 'svelte';
|
|
3
|
+
import type { Snippet } from 'svelte';
|
|
4
|
+
import { cn } from '../utils.js';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
direction?: 'horizontal' | 'vertical';
|
|
8
|
+
initialRatio?: number;
|
|
9
|
+
minRatio?: number;
|
|
10
|
+
maxRatio?: number;
|
|
11
|
+
pane1?: Snippet;
|
|
12
|
+
pane2?: Snippet;
|
|
13
|
+
class?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let {
|
|
17
|
+
direction = 'horizontal',
|
|
18
|
+
initialRatio = 0.3,
|
|
19
|
+
minRatio = 0.15,
|
|
20
|
+
maxRatio = 0.85,
|
|
21
|
+
pane1,
|
|
22
|
+
pane2,
|
|
23
|
+
class: className = '',
|
|
24
|
+
}: Props = $props();
|
|
25
|
+
|
|
26
|
+
let containerEl = $state<HTMLDivElement | null>(null);
|
|
27
|
+
let ratio = $state(untrack(() => initialRatio));
|
|
28
|
+
let isDragging = $state(false);
|
|
29
|
+
|
|
30
|
+
function startDrag(e: MouseEvent | TouchEvent) {
|
|
31
|
+
e.preventDefault();
|
|
32
|
+
isDragging = true;
|
|
33
|
+
|
|
34
|
+
function onMove(ev: MouseEvent | TouchEvent) {
|
|
35
|
+
if (!containerEl) return;
|
|
36
|
+
const rect = containerEl.getBoundingClientRect();
|
|
37
|
+
|
|
38
|
+
const clientPos =
|
|
39
|
+
direction === 'horizontal'
|
|
40
|
+
? ('touches' in ev ? ev.touches[0].clientX - rect.left : ev.clientX - rect.left)
|
|
41
|
+
: ('touches' in ev ? ev.touches[0].clientY - rect.top : ev.clientY - rect.top);
|
|
42
|
+
const totalSize = direction === 'horizontal' ? rect.width : rect.height;
|
|
43
|
+
|
|
44
|
+
if (totalSize > 0) {
|
|
45
|
+
const calculatedRatio = Math.max(minRatio, Math.min(maxRatio, clientPos / totalSize));
|
|
46
|
+
ratio = calculatedRatio;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function onEnd() {
|
|
51
|
+
isDragging = false;
|
|
52
|
+
window.removeEventListener('mousemove', onMove);
|
|
53
|
+
window.removeEventListener('mouseup', onEnd);
|
|
54
|
+
window.removeEventListener('touchmove', onMove);
|
|
55
|
+
window.removeEventListener('touchend', onEnd);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
window.addEventListener('mousemove', onMove);
|
|
59
|
+
window.addEventListener('mouseup', onEnd);
|
|
60
|
+
window.addEventListener('touchmove', onMove);
|
|
61
|
+
window.addEventListener('touchend', onEnd);
|
|
62
|
+
}
|
|
63
|
+
</script>
|
|
64
|
+
|
|
65
|
+
<div
|
|
66
|
+
bind:this={containerEl}
|
|
67
|
+
class={cn(
|
|
68
|
+
'relative flex w-full h-full min-h-72 rounded-xl border border-border bg-card shadow-xs overflow-hidden select-none',
|
|
69
|
+
direction === 'horizontal' ? 'flex-row' : 'flex-col',
|
|
70
|
+
className
|
|
71
|
+
)}
|
|
72
|
+
>
|
|
73
|
+
<!-- Pane 1 -->
|
|
74
|
+
<div
|
|
75
|
+
style={direction === 'horizontal' ? `width: ${ratio * 100}%;` : `height: ${ratio * 100}%;`}
|
|
76
|
+
class="overflow-auto shrink-0 select-text p-4"
|
|
77
|
+
>
|
|
78
|
+
{#if pane1}
|
|
79
|
+
{@render pane1()}
|
|
80
|
+
{/if}
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
<!-- Resizer Splitter -->
|
|
84
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
85
|
+
<div
|
|
86
|
+
onmousedown={startDrag}
|
|
87
|
+
ontouchstart={startDrag}
|
|
88
|
+
class={cn(
|
|
89
|
+
'shrink-0 bg-border hover:bg-primary/60 transition-colors z-10 flex items-center justify-center cursor-pointer',
|
|
90
|
+
direction === 'horizontal'
|
|
91
|
+
? 'w-1.5 cursor-col-resize hover:w-2'
|
|
92
|
+
: 'h-1.5 cursor-row-resize hover:h-2',
|
|
93
|
+
isDragging ? 'bg-primary w-2' : ''
|
|
94
|
+
)}
|
|
95
|
+
>
|
|
96
|
+
<div
|
|
97
|
+
class={cn(
|
|
98
|
+
'rounded-full bg-muted-foreground/40',
|
|
99
|
+
direction === 'horizontal' ? 'h-6 w-0.5' : 'w-6 h-0.5'
|
|
100
|
+
)}
|
|
101
|
+
></div>
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
<!-- Pane 2 -->
|
|
105
|
+
<div
|
|
106
|
+
style={direction === 'horizontal' ? `width: ${(1 - ratio) * 100}%;` : `height: ${(1 - ratio) * 100}%;`}
|
|
107
|
+
class="overflow-auto flex-1 select-text p-4"
|
|
108
|
+
>
|
|
109
|
+
{#if pane2}
|
|
110
|
+
{@render pane2()}
|
|
111
|
+
{/if}
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
interface Props {
|
|
3
|
+
direction?: 'horizontal' | 'vertical';
|
|
4
|
+
initialRatio?: number;
|
|
5
|
+
minRatio?: number;
|
|
6
|
+
maxRatio?: number;
|
|
7
|
+
pane1?: Snippet;
|
|
8
|
+
pane2?: Snippet;
|
|
9
|
+
class?: string;
|
|
10
|
+
}
|
|
11
|
+
declare const SplitPaneLayout: import("svelte").Component<Props, {}, "">;
|
|
12
|
+
type SplitPaneLayout = ReturnType<typeof SplitPaneLayout>;
|
|
13
|
+
export default SplitPaneLayout;
|
|
@@ -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>
|