@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.
- package/README.md +125 -0
- package/dist/components/Alert.svelte +335 -0
- package/dist/components/Alert.svelte.d.ts +24 -0
- package/dist/components/AutocompleteInput.svelte +356 -0
- package/dist/components/AutocompleteInput.svelte.d.ts +72 -0
- package/dist/components/Badge.svelte +185 -0
- package/dist/components/Badge.svelte.d.ts +20 -0
- package/dist/components/Button.svelte +415 -0
- package/dist/components/Button.svelte.d.ts +34 -0
- package/dist/components/Card.svelte +181 -0
- package/dist/components/Card.svelte.d.ts +24 -0
- package/dist/components/CardBody.svelte +78 -0
- package/dist/components/CardBody.svelte.d.ts +12 -0
- package/dist/components/CardFooter.svelte +81 -0
- package/dist/components/CardFooter.svelte.d.ts +14 -0
- package/dist/components/CardHeader.svelte +186 -0
- package/dist/components/CardHeader.svelte.d.ts +21 -0
- package/dist/components/Col.svelte +172 -0
- package/dist/components/Col.svelte.d.ts +26 -0
- package/dist/components/Container.svelte +118 -0
- package/dist/components/Container.svelte.d.ts +14 -0
- package/dist/components/Drawer.svelte +233 -0
- package/dist/components/Drawer.svelte.d.ts +13 -0
- package/dist/components/Dropdown.svelte +190 -0
- package/dist/components/Dropdown.svelte.d.ts +26 -0
- package/dist/components/DropdownItem.svelte +103 -0
- package/dist/components/DropdownItem.svelte.d.ts +22 -0
- package/dist/components/DurationInput.svelte +170 -0
- package/dist/components/DurationInput.svelte.d.ts +27 -0
- package/dist/components/EditableTable.svelte +647 -0
- package/dist/components/EditableTable.svelte.d.ts +74 -0
- package/dist/components/EmptyState.svelte +192 -0
- package/dist/components/EmptyState.svelte.d.ts +22 -0
- package/dist/components/FormField.svelte +260 -0
- package/dist/components/FormField.svelte.d.ts +68 -0
- package/dist/components/GridView.svelte +1022 -0
- package/dist/components/GridView.svelte.d.ts +38 -0
- package/dist/components/GridView.types.d.ts +28 -0
- package/dist/components/GridView.types.js +1 -0
- package/dist/components/LoadingSpinner.svelte +253 -0
- package/dist/components/LoadingSpinner.svelte.d.ts +17 -0
- package/dist/components/Modal.svelte +473 -0
- package/dist/components/Modal.svelte.d.ts +42 -0
- package/dist/components/PhoneInput.svelte +406 -0
- package/dist/components/PhoneInput.svelte.d.ts +31 -0
- package/dist/components/PhotoUpload.svelte +529 -0
- package/dist/components/PhotoUpload.svelte.d.ts +46 -0
- package/dist/components/Row.svelte +153 -0
- package/dist/components/Row.svelte.d.ts +18 -0
- package/dist/icons/PawPrintIcon.svelte +41 -0
- package/dist/icons/PawPrintIcon.svelte.d.ts +14 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.js +49 -0
- package/dist/styles/forms.css +182 -0
- package/dist/styles/tokens.css +243 -0
- package/dist/utils/duration.d.ts +20 -0
- package/dist/utils/duration.js +40 -0
- package/dist/utils/scrollLock.d.ts +7 -0
- package/dist/utils/scrollLock.js +26 -0
- package/package.json +66 -0
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import Button from './Button.svelte';
|
|
4
|
+
import Badge from './Badge.svelte';
|
|
5
|
+
import DurationInput from './DurationInput.svelte';
|
|
6
|
+
import { formatDuration } from '../utils/duration.js';
|
|
7
|
+
|
|
8
|
+
/** Argumentos passados ao editor de célula custom (col.editCell). */
|
|
9
|
+
export interface EditCellArgs {
|
|
10
|
+
/** Linha em edição (editingData ou newRowData) */
|
|
11
|
+
data: Record<string, any>;
|
|
12
|
+
/** Atualiza um campo da linha em edição */
|
|
13
|
+
setField: (key: string, value: any) => void;
|
|
14
|
+
/** Edição em curso (desabilitar inputs) */
|
|
15
|
+
disabled: boolean;
|
|
16
|
+
/** Erros de validação por chave */
|
|
17
|
+
errors: Record<string, string>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface EditableColumn {
|
|
21
|
+
/** Chave do campo no objeto */
|
|
22
|
+
key: string;
|
|
23
|
+
/** Título da coluna */
|
|
24
|
+
title: string;
|
|
25
|
+
/** Tipo de campo */
|
|
26
|
+
type: 'text' | 'number' | 'select' | 'badge' | 'duration' | 'custom';
|
|
27
|
+
/** Largura da coluna */
|
|
28
|
+
width?: string;
|
|
29
|
+
/** Campo editável */
|
|
30
|
+
editable?: boolean;
|
|
31
|
+
/** Campo obrigatório */
|
|
32
|
+
required?: boolean;
|
|
33
|
+
/** Opções para select */
|
|
34
|
+
options?: Array<{ value: any; label: string }>;
|
|
35
|
+
/** Valor mínimo para number */
|
|
36
|
+
min?: number;
|
|
37
|
+
/** Valor máximo para number */
|
|
38
|
+
max?: number;
|
|
39
|
+
/** Step para number */
|
|
40
|
+
step?: number;
|
|
41
|
+
/** Função para formatar exibição */
|
|
42
|
+
format?: (value: any) => string;
|
|
43
|
+
/** Exibição custom usando a linha inteira (type: 'custom') */
|
|
44
|
+
render?: (row: any) => string;
|
|
45
|
+
/** Editor custom usando a linha inteira (type: 'custom') */
|
|
46
|
+
editCell?: Snippet<[EditCellArgs]>;
|
|
47
|
+
/** Validação custom; devolve mensagem de erro ou null */
|
|
48
|
+
validate?: (data: Record<string, any>) => string | null;
|
|
49
|
+
/** Função para obter variante do badge */
|
|
50
|
+
badgeVariant?: (value: any) => 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info' | 'light' | 'dark';
|
|
51
|
+
/** Placeholder */
|
|
52
|
+
placeholder?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface EditableTableProps<T> {
|
|
56
|
+
/** Colunas da tabela */
|
|
57
|
+
columns: EditableColumn[];
|
|
58
|
+
/** Dados da tabela */
|
|
59
|
+
data: T[];
|
|
60
|
+
/** Callback ao guardar linha */
|
|
61
|
+
onSave: (row: T, isNew: boolean) => Promise<void>;
|
|
62
|
+
/** Callback ao apagar linha */
|
|
63
|
+
onDelete: (row: T) => Promise<void>;
|
|
64
|
+
/** Factory para nova linha */
|
|
65
|
+
createNewRow: () => Partial<T>;
|
|
66
|
+
/** Mensagem quando vazio */
|
|
67
|
+
emptyMessage?: string;
|
|
68
|
+
/** Chave única para identificar linhas */
|
|
69
|
+
rowKey?: string;
|
|
70
|
+
/** Mostrar loading global */
|
|
71
|
+
loading?: boolean;
|
|
72
|
+
/** Snippet para ações extras */
|
|
73
|
+
rowActions?: Snippet<[T]>;
|
|
74
|
+
/** Callback quando linha é marcada para exclusão */
|
|
75
|
+
onMarkDelete?: (row: T) => void;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let {
|
|
79
|
+
columns,
|
|
80
|
+
data = [],
|
|
81
|
+
onSave,
|
|
82
|
+
onDelete,
|
|
83
|
+
createNewRow,
|
|
84
|
+
emptyMessage = 'Nenhum registo encontrado',
|
|
85
|
+
rowKey = 'id',
|
|
86
|
+
loading = false,
|
|
87
|
+
rowActions,
|
|
88
|
+
onMarkDelete
|
|
89
|
+
}: EditableTableProps<any> = $props();
|
|
90
|
+
|
|
91
|
+
// Estado interno
|
|
92
|
+
let editingRowId: any = $state(null);
|
|
93
|
+
let editingData: Record<string, any> = $state({});
|
|
94
|
+
let isAddingNew: boolean = $state(false);
|
|
95
|
+
let newRowData: Record<string, any> = $state({});
|
|
96
|
+
let savingRowId: any = $state(null);
|
|
97
|
+
let deletingRowId: any = $state(null);
|
|
98
|
+
let errors: Record<string, string> = $state({});
|
|
99
|
+
|
|
100
|
+
// Iniciar edição de linha
|
|
101
|
+
function startEdit(row: any) {
|
|
102
|
+
editingRowId = row[rowKey];
|
|
103
|
+
editingData = { ...row };
|
|
104
|
+
errors = {};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Cancelar edição
|
|
108
|
+
function cancelEdit() {
|
|
109
|
+
editingRowId = null;
|
|
110
|
+
editingData = {};
|
|
111
|
+
errors = {};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Validar dados
|
|
115
|
+
function validateRow(rowData: Record<string, any>): boolean {
|
|
116
|
+
errors = {};
|
|
117
|
+
let isValid = true;
|
|
118
|
+
|
|
119
|
+
for (const col of columns) {
|
|
120
|
+
if (col.editable !== false && col.required) {
|
|
121
|
+
const value = rowData[col.key];
|
|
122
|
+
if (value === null || value === undefined || value === '') {
|
|
123
|
+
errors[col.key] = 'Campo obrigatório';
|
|
124
|
+
isValid = false;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if ((col.type === 'number' || col.type === 'duration') && rowData[col.key] !== null && rowData[col.key] !== undefined) {
|
|
129
|
+
const numValue = Number(rowData[col.key]);
|
|
130
|
+
if (col.min !== undefined && numValue < col.min) {
|
|
131
|
+
errors[col.key] = `Valor mínimo: ${col.min}`;
|
|
132
|
+
isValid = false;
|
|
133
|
+
}
|
|
134
|
+
if (col.max !== undefined && numValue > col.max) {
|
|
135
|
+
errors[col.key] = `Valor máximo: ${col.max}`;
|
|
136
|
+
isValid = false;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (col.validate) {
|
|
141
|
+
const message = col.validate(rowData);
|
|
142
|
+
if (message) {
|
|
143
|
+
errors[col.key] = message;
|
|
144
|
+
isValid = false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return isValid;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Guardar edição
|
|
153
|
+
async function saveEdit() {
|
|
154
|
+
if (!validateRow(editingData)) return;
|
|
155
|
+
|
|
156
|
+
savingRowId = editingRowId;
|
|
157
|
+
try {
|
|
158
|
+
await onSave(editingData, false);
|
|
159
|
+
cancelEdit();
|
|
160
|
+
} catch (error) {
|
|
161
|
+
console.error('Erro ao guardar:', error);
|
|
162
|
+
} finally {
|
|
163
|
+
savingRowId = null;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Iniciar adição de nova linha
|
|
168
|
+
function startAddNew() {
|
|
169
|
+
isAddingNew = true;
|
|
170
|
+
newRowData = createNewRow();
|
|
171
|
+
errors = {};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Cancelar adição
|
|
175
|
+
function cancelAddNew() {
|
|
176
|
+
isAddingNew = false;
|
|
177
|
+
newRowData = {};
|
|
178
|
+
errors = {};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Guardar nova linha
|
|
182
|
+
async function saveNewRow() {
|
|
183
|
+
if (!validateRow(newRowData)) return;
|
|
184
|
+
|
|
185
|
+
savingRowId = 'new';
|
|
186
|
+
try {
|
|
187
|
+
await onSave(newRowData, true);
|
|
188
|
+
cancelAddNew();
|
|
189
|
+
} catch (error) {
|
|
190
|
+
console.error('Erro ao criar:', error);
|
|
191
|
+
} finally {
|
|
192
|
+
savingRowId = null;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Apagar linha
|
|
197
|
+
async function deleteRow(row: any) {
|
|
198
|
+
if (onMarkDelete) {
|
|
199
|
+
onMarkDelete(row);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
deletingRowId = row[rowKey];
|
|
204
|
+
try {
|
|
205
|
+
await onDelete(row);
|
|
206
|
+
} catch (error) {
|
|
207
|
+
console.error('Erro ao apagar:', error);
|
|
208
|
+
} finally {
|
|
209
|
+
deletingRowId = null;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Resolve select value: match the string from DOM back to the original option value type
|
|
214
|
+
function resolveSelectValue(col: EditableColumn, rawValue: string): any {
|
|
215
|
+
if (!rawValue) return null;
|
|
216
|
+
const option = col.options?.find(o => String(o.value) === rawValue);
|
|
217
|
+
return option ? option.value : rawValue;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Atualizar valor na edição
|
|
221
|
+
function updateEditValue(key: string, value: any) {
|
|
222
|
+
editingData[key] = value;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Atualizar valor na nova linha
|
|
226
|
+
function updateNewValue(key: string, value: any) {
|
|
227
|
+
newRowData[key] = value;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Formatar valor para exibição
|
|
231
|
+
function formatValue(col: EditableColumn, value: any): string {
|
|
232
|
+
if (value === null || value === undefined) return '-';
|
|
233
|
+
|
|
234
|
+
if (col.format) return col.format(value);
|
|
235
|
+
|
|
236
|
+
if (col.type === 'duration') return formatDuration(Number(value));
|
|
237
|
+
|
|
238
|
+
if (col.type === 'select' && col.options) {
|
|
239
|
+
const option = col.options.find(o => o.value === value);
|
|
240
|
+
return option?.label || String(value);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return String(value);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Obter variante do badge
|
|
247
|
+
function getBadgeVariant(col: EditableColumn, value: any): 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info' | 'light' | 'dark' {
|
|
248
|
+
if (col.badgeVariant) return col.badgeVariant(value);
|
|
249
|
+
return 'secondary';
|
|
250
|
+
}
|
|
251
|
+
</script>
|
|
252
|
+
|
|
253
|
+
<!-- Editor de uma célula, partilhado entre a linha de edição e a de adição -->
|
|
254
|
+
{#snippet cellEditor(col: EditableColumn, rowData: Record<string, any>, setField: (key: string, value: any) => void, disabled: boolean, idScope: string)}
|
|
255
|
+
{#if col.type === 'custom' && col.editCell}
|
|
256
|
+
{@render col.editCell({ data: rowData, setField, disabled, errors })}
|
|
257
|
+
{:else if col.type === 'select'}
|
|
258
|
+
<select
|
|
259
|
+
class="form-select form-select-sm"
|
|
260
|
+
value={rowData[col.key] ?? ''}
|
|
261
|
+
onchange={(e) => setField(col.key, resolveSelectValue(col, e.currentTarget.value))}
|
|
262
|
+
{disabled}
|
|
263
|
+
>
|
|
264
|
+
<option value="">{col.placeholder || 'Selecione...'}</option>
|
|
265
|
+
{#each col.options || [] as option}
|
|
266
|
+
<option value={option.value}>{option.label}</option>
|
|
267
|
+
{/each}
|
|
268
|
+
</select>
|
|
269
|
+
{:else if col.type === 'number'}
|
|
270
|
+
<input
|
|
271
|
+
type="number"
|
|
272
|
+
class="form-control form-control-sm"
|
|
273
|
+
value={rowData[col.key] ?? ''}
|
|
274
|
+
oninput={(e) => setField(col.key, e.currentTarget.value ? Number(e.currentTarget.value) : null)}
|
|
275
|
+
min={col.min}
|
|
276
|
+
max={col.max}
|
|
277
|
+
step={col.step || 1}
|
|
278
|
+
placeholder={col.placeholder}
|
|
279
|
+
{disabled}
|
|
280
|
+
/>
|
|
281
|
+
{:else if col.type === 'duration'}
|
|
282
|
+
<DurationInput
|
|
283
|
+
id={`dur-${idScope}-${col.key}`}
|
|
284
|
+
size="sm"
|
|
285
|
+
value={rowData[col.key] ?? null}
|
|
286
|
+
onchange={(m) => setField(col.key, m)}
|
|
287
|
+
{disabled}
|
|
288
|
+
/>
|
|
289
|
+
{:else}
|
|
290
|
+
<input
|
|
291
|
+
type="text"
|
|
292
|
+
class="form-control form-control-sm"
|
|
293
|
+
value={rowData[col.key] ?? ''}
|
|
294
|
+
oninput={(e) => setField(col.key, e.currentTarget.value)}
|
|
295
|
+
placeholder={col.placeholder}
|
|
296
|
+
{disabled}
|
|
297
|
+
/>
|
|
298
|
+
{/if}
|
|
299
|
+
{/snippet}
|
|
300
|
+
|
|
301
|
+
<div class="editable-table-wrapper">
|
|
302
|
+
{#if loading}
|
|
303
|
+
<div class="loading-overlay">
|
|
304
|
+
<div class="spinner-border text-primary" role="status">
|
|
305
|
+
<span class="visually-hidden">A carregar...</span>
|
|
306
|
+
</div>
|
|
307
|
+
</div>
|
|
308
|
+
{/if}
|
|
309
|
+
|
|
310
|
+
<div class="table-responsive">
|
|
311
|
+
<table class="table editable-table">
|
|
312
|
+
<thead>
|
|
313
|
+
<tr>
|
|
314
|
+
{#each columns as col}
|
|
315
|
+
<th style={col.width ? `width: ${col.width}` : ''}>
|
|
316
|
+
{col.title}
|
|
317
|
+
{#if col.required}
|
|
318
|
+
<span class="text-danger">*</span>
|
|
319
|
+
{/if}
|
|
320
|
+
</th>
|
|
321
|
+
{/each}
|
|
322
|
+
<th class="actions-column">Ações</th>
|
|
323
|
+
</tr>
|
|
324
|
+
</thead>
|
|
325
|
+
<tbody>
|
|
326
|
+
{#if data.length === 0 && !isAddingNew}
|
|
327
|
+
<tr class="empty-row">
|
|
328
|
+
<td colspan={columns.length + 1}>
|
|
329
|
+
<div class="empty-message">
|
|
330
|
+
<i class="bi bi-inbox"></i>
|
|
331
|
+
<span>{emptyMessage}</span>
|
|
332
|
+
</div>
|
|
333
|
+
</td>
|
|
334
|
+
</tr>
|
|
335
|
+
{/if}
|
|
336
|
+
|
|
337
|
+
{#each data as row (row[rowKey])}
|
|
338
|
+
{@const isEditing = editingRowId === row[rowKey]}
|
|
339
|
+
{@const isSaving = savingRowId === row[rowKey]}
|
|
340
|
+
{@const isDeleting = deletingRowId === row[rowKey]}
|
|
341
|
+
|
|
342
|
+
<tr class:editing={isEditing} class:saving={isSaving} class:deleting={isDeleting}>
|
|
343
|
+
{#each columns as col}
|
|
344
|
+
<td data-label={col.title} class:has-error={isEditing && errors[col.key]}>
|
|
345
|
+
{#if isEditing && col.editable !== false}
|
|
346
|
+
{@render cellEditor(col, editingData, updateEditValue, isSaving, String(editingRowId))}
|
|
347
|
+
{#if errors[col.key]}
|
|
348
|
+
<div class="field-error">{errors[col.key]}</div>
|
|
349
|
+
{/if}
|
|
350
|
+
{:else if col.type === 'custom'}
|
|
351
|
+
<span class="cell-value">{col.render ? col.render(row) : formatValue(col, row[col.key])}</span>
|
|
352
|
+
{:else if col.type === 'badge'}
|
|
353
|
+
<Badge variant={getBadgeVariant(col, row[col.key])} size="sm">
|
|
354
|
+
{formatValue(col, row[col.key])}
|
|
355
|
+
</Badge>
|
|
356
|
+
{:else}
|
|
357
|
+
<span class="cell-value">{formatValue(col, row[col.key])}</span>
|
|
358
|
+
{/if}
|
|
359
|
+
</td>
|
|
360
|
+
{/each}
|
|
361
|
+
<td class="actions-cell" data-label="Ações">
|
|
362
|
+
{#if isEditing}
|
|
363
|
+
<div class="action-buttons">
|
|
364
|
+
<Button
|
|
365
|
+
variant="success"
|
|
366
|
+
size="sm"
|
|
367
|
+
onclick={saveEdit}
|
|
368
|
+
loading={isSaving}
|
|
369
|
+
disabled={isSaving}
|
|
370
|
+
>
|
|
371
|
+
<i class="bi bi-check-lg"></i>
|
|
372
|
+
</Button>
|
|
373
|
+
<Button
|
|
374
|
+
variant="outline-secondary"
|
|
375
|
+
size="sm"
|
|
376
|
+
onclick={cancelEdit}
|
|
377
|
+
disabled={isSaving}
|
|
378
|
+
>
|
|
379
|
+
<i class="bi bi-x-lg"></i>
|
|
380
|
+
</Button>
|
|
381
|
+
</div>
|
|
382
|
+
{:else}
|
|
383
|
+
<div class="action-buttons">
|
|
384
|
+
<Button
|
|
385
|
+
variant="outline-primary"
|
|
386
|
+
size="sm"
|
|
387
|
+
onclick={() => startEdit(row)}
|
|
388
|
+
disabled={editingRowId !== null || isAddingNew || isDeleting}
|
|
389
|
+
>
|
|
390
|
+
<i class="bi bi-pencil"></i>
|
|
391
|
+
</Button>
|
|
392
|
+
{#if rowActions}
|
|
393
|
+
{@render rowActions(row)}
|
|
394
|
+
{/if}
|
|
395
|
+
<Button
|
|
396
|
+
variant="outline-danger"
|
|
397
|
+
size="sm"
|
|
398
|
+
onclick={() => deleteRow(row)}
|
|
399
|
+
loading={isDeleting}
|
|
400
|
+
disabled={editingRowId !== null || isAddingNew || isDeleting}
|
|
401
|
+
>
|
|
402
|
+
<i class="bi bi-trash"></i>
|
|
403
|
+
</Button>
|
|
404
|
+
</div>
|
|
405
|
+
{/if}
|
|
406
|
+
</td>
|
|
407
|
+
</tr>
|
|
408
|
+
{/each}
|
|
409
|
+
|
|
410
|
+
<!-- Linha para adicionar novo -->
|
|
411
|
+
{#if isAddingNew}
|
|
412
|
+
{@const isSaving = savingRowId === 'new'}
|
|
413
|
+
<tr class="new-row" class:saving={isSaving}>
|
|
414
|
+
{#each columns as col}
|
|
415
|
+
<td data-label={col.title} class:has-error={errors[col.key]}>
|
|
416
|
+
{#if col.editable !== false}
|
|
417
|
+
{@render cellEditor(col, newRowData, updateNewValue, isSaving, 'new')}
|
|
418
|
+
{#if errors[col.key]}
|
|
419
|
+
<div class="field-error">{errors[col.key]}</div>
|
|
420
|
+
{/if}
|
|
421
|
+
{:else}
|
|
422
|
+
<span class="cell-readonly">-</span>
|
|
423
|
+
{/if}
|
|
424
|
+
</td>
|
|
425
|
+
{/each}
|
|
426
|
+
<td class="actions-cell" data-label="Ações">
|
|
427
|
+
<div class="action-buttons">
|
|
428
|
+
<Button
|
|
429
|
+
variant="success"
|
|
430
|
+
size="sm"
|
|
431
|
+
onclick={saveNewRow}
|
|
432
|
+
loading={isSaving}
|
|
433
|
+
disabled={isSaving}
|
|
434
|
+
>
|
|
435
|
+
<i class="bi bi-check-lg"></i>
|
|
436
|
+
</Button>
|
|
437
|
+
<Button
|
|
438
|
+
variant="outline-secondary"
|
|
439
|
+
size="sm"
|
|
440
|
+
onclick={cancelAddNew}
|
|
441
|
+
disabled={isSaving}
|
|
442
|
+
>
|
|
443
|
+
<i class="bi bi-x-lg"></i>
|
|
444
|
+
</Button>
|
|
445
|
+
</div>
|
|
446
|
+
</td>
|
|
447
|
+
</tr>
|
|
448
|
+
{/if}
|
|
449
|
+
</tbody>
|
|
450
|
+
</table>
|
|
451
|
+
</div>
|
|
452
|
+
|
|
453
|
+
<!-- Botão para adicionar nova linha -->
|
|
454
|
+
{#if !isAddingNew && editingRowId === null}
|
|
455
|
+
<div class="add-row-container">
|
|
456
|
+
<Button
|
|
457
|
+
variant="outline-primary"
|
|
458
|
+
size="sm"
|
|
459
|
+
onclick={startAddNew}
|
|
460
|
+
disabled={loading}
|
|
461
|
+
>
|
|
462
|
+
<i class="bi bi-plus-lg me-1"></i>
|
|
463
|
+
Adicionar
|
|
464
|
+
</Button>
|
|
465
|
+
</div>
|
|
466
|
+
{/if}
|
|
467
|
+
</div>
|
|
468
|
+
|
|
469
|
+
<style>
|
|
470
|
+
.editable-table-wrapper {
|
|
471
|
+
position: relative;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
.loading-overlay {
|
|
475
|
+
position: absolute;
|
|
476
|
+
inset: 0;
|
|
477
|
+
background: rgba(255, 255, 255, 0.8);
|
|
478
|
+
display: flex;
|
|
479
|
+
align-items: center;
|
|
480
|
+
justify-content: center;
|
|
481
|
+
z-index: 10;
|
|
482
|
+
border-radius: var(--radius-md);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
.table-responsive {
|
|
486
|
+
overflow-x: auto;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
.editable-table {
|
|
490
|
+
width: 100%;
|
|
491
|
+
margin-bottom: 0;
|
|
492
|
+
font-size: var(--font-size-sm);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
.editable-table thead th {
|
|
496
|
+
background-color: var(--color-gray-50);
|
|
497
|
+
border-bottom: 2px solid var(--color-gray-200);
|
|
498
|
+
padding: var(--spacing-sm) var(--spacing-md);
|
|
499
|
+
font-weight: var(--font-weight-semibold);
|
|
500
|
+
color: var(--color-gray-700);
|
|
501
|
+
white-space: nowrap;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
.editable-table tbody td {
|
|
505
|
+
padding: var(--spacing-sm) var(--spacing-md);
|
|
506
|
+
vertical-align: middle;
|
|
507
|
+
border-bottom: 1px solid var(--color-gray-100);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
.editable-table tbody tr:hover {
|
|
511
|
+
background-color: var(--color-gray-50);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
.editable-table tbody tr.editing {
|
|
515
|
+
background-color: var(--color-primary-light, #e7f3ff);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
.editable-table tbody tr.new-row {
|
|
519
|
+
background-color: var(--color-success-light, #e8f5e9);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
.editable-table tbody tr.saving {
|
|
523
|
+
opacity: 0.7;
|
|
524
|
+
pointer-events: none;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
.editable-table tbody tr.deleting {
|
|
528
|
+
opacity: 0.5;
|
|
529
|
+
background-color: var(--color-danger-light, #ffebee);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
.actions-column {
|
|
533
|
+
width: 120px;
|
|
534
|
+
text-align: center;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
.actions-cell {
|
|
538
|
+
text-align: center;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
.action-buttons {
|
|
542
|
+
display: flex;
|
|
543
|
+
gap: var(--spacing-xs);
|
|
544
|
+
justify-content: center;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
.cell-value {
|
|
548
|
+
color: var(--color-gray-800);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
.cell-readonly {
|
|
552
|
+
color: var(--color-gray-400);
|
|
553
|
+
font-style: italic;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
.empty-row td {
|
|
557
|
+
padding: var(--spacing-xl) !important;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
.empty-message {
|
|
561
|
+
display: flex;
|
|
562
|
+
flex-direction: column;
|
|
563
|
+
align-items: center;
|
|
564
|
+
gap: var(--spacing-sm);
|
|
565
|
+
color: var(--color-gray-500);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
.empty-message i {
|
|
569
|
+
font-size: 2rem;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
.add-row-container {
|
|
573
|
+
padding: var(--spacing-md);
|
|
574
|
+
border-top: 1px dashed var(--color-gray-200);
|
|
575
|
+
text-align: center;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
.has-error {
|
|
579
|
+
position: relative;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
.has-error .form-control,
|
|
583
|
+
.has-error .form-select {
|
|
584
|
+
border-color: var(--color-danger);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
.field-error {
|
|
588
|
+
position: absolute;
|
|
589
|
+
bottom: -18px;
|
|
590
|
+
left: 0;
|
|
591
|
+
font-size: var(--font-size-xs);
|
|
592
|
+
color: var(--color-danger);
|
|
593
|
+
white-space: nowrap;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/* Inputs dentro da tabela */
|
|
597
|
+
.editable-table .form-control,
|
|
598
|
+
.editable-table .form-select {
|
|
599
|
+
min-width: 80px;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
.editable-table .form-control:focus,
|
|
603
|
+
.editable-table .form-select:focus {
|
|
604
|
+
box-shadow: none;
|
|
605
|
+
border-color: var(--color-primary);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/* Responsivo */
|
|
609
|
+
@media (max-width: 768px) {
|
|
610
|
+
.editable-table thead {
|
|
611
|
+
display: none;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
.editable-table tbody tr {
|
|
615
|
+
display: block;
|
|
616
|
+
margin-bottom: var(--spacing-md);
|
|
617
|
+
border: 1px solid var(--color-gray-200);
|
|
618
|
+
border-radius: var(--radius-md);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
.editable-table tbody td {
|
|
622
|
+
display: flex;
|
|
623
|
+
justify-content: flex-start;
|
|
624
|
+
align-items: center;
|
|
625
|
+
padding: var(--spacing-sm);
|
|
626
|
+
border-bottom: 1px solid var(--color-gray-100);
|
|
627
|
+
gap: var(--spacing-sm);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
.editable-table tbody td::before {
|
|
631
|
+
content: attr(data-label);
|
|
632
|
+
font-weight: var(--font-weight-semibold);
|
|
633
|
+
color: var(--color-gray-600);
|
|
634
|
+
flex-shrink: 0;
|
|
635
|
+
min-width: 80px;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
.actions-cell {
|
|
639
|
+
justify-content: flex-end !important;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
.add-row-container {
|
|
643
|
+
border-top: none;
|
|
644
|
+
margin-top: var(--spacing-md);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
</style>
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
/** Argumentos passados ao editor de célula custom (col.editCell). */
|
|
3
|
+
export interface EditCellArgs {
|
|
4
|
+
/** Linha em edição (editingData ou newRowData) */
|
|
5
|
+
data: Record<string, any>;
|
|
6
|
+
/** Atualiza um campo da linha em edição */
|
|
7
|
+
setField: (key: string, value: any) => void;
|
|
8
|
+
/** Edição em curso (desabilitar inputs) */
|
|
9
|
+
disabled: boolean;
|
|
10
|
+
/** Erros de validação por chave */
|
|
11
|
+
errors: Record<string, string>;
|
|
12
|
+
}
|
|
13
|
+
export interface EditableColumn {
|
|
14
|
+
/** Chave do campo no objeto */
|
|
15
|
+
key: string;
|
|
16
|
+
/** Título da coluna */
|
|
17
|
+
title: string;
|
|
18
|
+
/** Tipo de campo */
|
|
19
|
+
type: 'text' | 'number' | 'select' | 'badge' | 'duration' | 'custom';
|
|
20
|
+
/** Largura da coluna */
|
|
21
|
+
width?: string;
|
|
22
|
+
/** Campo editável */
|
|
23
|
+
editable?: boolean;
|
|
24
|
+
/** Campo obrigatório */
|
|
25
|
+
required?: boolean;
|
|
26
|
+
/** Opções para select */
|
|
27
|
+
options?: Array<{
|
|
28
|
+
value: any;
|
|
29
|
+
label: string;
|
|
30
|
+
}>;
|
|
31
|
+
/** Valor mínimo para number */
|
|
32
|
+
min?: number;
|
|
33
|
+
/** Valor máximo para number */
|
|
34
|
+
max?: number;
|
|
35
|
+
/** Step para number */
|
|
36
|
+
step?: number;
|
|
37
|
+
/** Função para formatar exibição */
|
|
38
|
+
format?: (value: any) => string;
|
|
39
|
+
/** Exibição custom usando a linha inteira (type: 'custom') */
|
|
40
|
+
render?: (row: any) => string;
|
|
41
|
+
/** Editor custom usando a linha inteira (type: 'custom') */
|
|
42
|
+
editCell?: Snippet<[EditCellArgs]>;
|
|
43
|
+
/** Validação custom; devolve mensagem de erro ou null */
|
|
44
|
+
validate?: (data: Record<string, any>) => string | null;
|
|
45
|
+
/** Função para obter variante do badge */
|
|
46
|
+
badgeVariant?: (value: any) => 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info' | 'light' | 'dark';
|
|
47
|
+
/** Placeholder */
|
|
48
|
+
placeholder?: string;
|
|
49
|
+
}
|
|
50
|
+
interface EditableTableProps<T> {
|
|
51
|
+
/** Colunas da tabela */
|
|
52
|
+
columns: EditableColumn[];
|
|
53
|
+
/** Dados da tabela */
|
|
54
|
+
data: T[];
|
|
55
|
+
/** Callback ao guardar linha */
|
|
56
|
+
onSave: (row: T, isNew: boolean) => Promise<void>;
|
|
57
|
+
/** Callback ao apagar linha */
|
|
58
|
+
onDelete: (row: T) => Promise<void>;
|
|
59
|
+
/** Factory para nova linha */
|
|
60
|
+
createNewRow: () => Partial<T>;
|
|
61
|
+
/** Mensagem quando vazio */
|
|
62
|
+
emptyMessage?: string;
|
|
63
|
+
/** Chave única para identificar linhas */
|
|
64
|
+
rowKey?: string;
|
|
65
|
+
/** Mostrar loading global */
|
|
66
|
+
loading?: boolean;
|
|
67
|
+
/** Snippet para ações extras */
|
|
68
|
+
rowActions?: Snippet<[T]>;
|
|
69
|
+
/** Callback quando linha é marcada para exclusão */
|
|
70
|
+
onMarkDelete?: (row: T) => void;
|
|
71
|
+
}
|
|
72
|
+
declare const EditableTable: import("svelte").Component<EditableTableProps<any>, {}, "">;
|
|
73
|
+
type EditableTable = ReturnType<typeof EditableTable>;
|
|
74
|
+
export default EditableTable;
|