@beeblock/svelar-datatable 0.1.5 → 0.1.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@beeblock/svelar-datatable",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Full-featured DataTable plugin for Svelar — sorting, searching, pagination, inline editing, export, and server-side processing",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -45,6 +45,10 @@
45
45
  "files": [
46
46
  "dist",
47
47
  "src/ui",
48
+ "src/state",
49
+ "src/export",
50
+ "src/types.ts",
51
+ "src/index.ts",
48
52
  "src/publishable",
49
53
  "LICENSE"
50
54
  ],
@@ -0,0 +1,30 @@
1
+ import type { ColumnDef, ExportFormat } from '../types.js';
2
+ import { generateCSV, downloadCSV } from './csv.js';
3
+ import { copyToClipboard } from './clipboard.js';
4
+ import { printTable } from './print.js';
5
+ import { exportExcel } from './excel.js';
6
+ import { exportPdf } from './pdf.js';
7
+
8
+ export class ExportManager<T = any> {
9
+ async export(format: ExportFormat, data: T[], columns: ColumnDef<T>[], filename?: string): Promise<void> {
10
+ switch (format) {
11
+ case 'csv': {
12
+ const csv = generateCSV(data, columns);
13
+ downloadCSV(csv, filename ?? 'export.csv');
14
+ break;
15
+ }
16
+ case 'excel':
17
+ await exportExcel(data, columns, filename ?? 'export.xlsx');
18
+ break;
19
+ case 'pdf':
20
+ exportPdf(data, columns, filename ?? 'export.pdf');
21
+ break;
22
+ case 'clipboard':
23
+ await copyToClipboard(data, columns);
24
+ break;
25
+ case 'print':
26
+ printTable(data, columns);
27
+ break;
28
+ }
29
+ }
30
+ }
@@ -0,0 +1,14 @@
1
+ import type { ColumnDef } from '../types.js';
2
+
3
+ export async function copyToClipboard<T>(data: T[], columns: ColumnDef<T>[]): Promise<void> {
4
+ const visibleCols = columns.filter((c) => c.visible !== false);
5
+ const header = visibleCols.map((c) => c.header).join('\t');
6
+ const rows = data.map((row: any) =>
7
+ visibleCols.map((col) => {
8
+ const val = row[col.key];
9
+ return val === null || val === undefined ? '' : String(val);
10
+ }).join('\t')
11
+ );
12
+ const text = [header, ...rows].join('\n');
13
+ await navigator.clipboard.writeText(text);
14
+ }
@@ -0,0 +1,36 @@
1
+ import type { ColumnDef } from '../types.js';
2
+
3
+ function escapeCSV(value: any): string {
4
+ if (value === null || value === undefined) return '';
5
+ const str = String(value);
6
+ if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
7
+ return '"' + str.replace(/"/g, '""') + '"';
8
+ }
9
+ return str;
10
+ }
11
+
12
+ export function generateCSV<T>(data: T[], columns: ColumnDef<T>[]): string {
13
+ const visibleCols = columns.filter((c) => c.visible !== false);
14
+ const header = visibleCols.map((c) => escapeCSV(c.header)).join(',');
15
+ const rows = data.map((row: any) =>
16
+ visibleCols.map((col) => escapeCSV(row[col.key])).join(',')
17
+ );
18
+ // BOM for Excel UTF-8 compatibility
19
+ return '\uFEFF' + [header, ...rows].join('\r\n');
20
+ }
21
+
22
+ export function downloadCSV(content: string, filename: string = 'export.csv') {
23
+ const blob = new Blob([content], { type: 'text/csv;charset=utf-8;' });
24
+ downloadBlob(blob, filename);
25
+ }
26
+
27
+ export function downloadBlob(blob: Blob, filename: string) {
28
+ const url = URL.createObjectURL(blob);
29
+ const a = document.createElement('a');
30
+ a.href = url;
31
+ a.download = filename;
32
+ document.body.appendChild(a);
33
+ a.click();
34
+ document.body.removeChild(a);
35
+ URL.revokeObjectURL(url);
36
+ }
@@ -0,0 +1,44 @@
1
+ import type { ColumnDef } from '../types.js';
2
+ import { downloadBlob } from './csv.js';
3
+
4
+ export async function exportExcel<T>(data: T[], columns: ColumnDef<T>[], filename: string = 'export.xlsx') {
5
+ let ExcelJS: any;
6
+ try {
7
+ ExcelJS = await import('exceljs');
8
+ } catch {
9
+ throw new Error('exceljs is required for Excel export. Install it: npm install exceljs');
10
+ }
11
+
12
+ const workbook = new ExcelJS.Workbook();
13
+ const worksheet = workbook.addWorksheet('Data');
14
+ const visibleCols = columns.filter((c) => c.visible !== false);
15
+
16
+ // Header row
17
+ worksheet.columns = visibleCols.map((col) => ({
18
+ header: col.header,
19
+ key: col.key,
20
+ width: 20,
21
+ }));
22
+
23
+ // Style header
24
+ const headerRow = worksheet.getRow(1);
25
+ headerRow.font = { bold: true };
26
+ headerRow.fill = {
27
+ type: 'pattern',
28
+ pattern: 'solid',
29
+ fgColor: { argb: 'FFF0F0F0' },
30
+ };
31
+
32
+ // Data rows
33
+ for (const row of data) {
34
+ const rowData: Record<string, any> = {};
35
+ for (const col of visibleCols) {
36
+ rowData[col.key] = (row as any)[col.key] ?? '';
37
+ }
38
+ worksheet.addRow(rowData);
39
+ }
40
+
41
+ const buffer = await workbook.xlsx.writeBuffer();
42
+ const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
43
+ downloadBlob(blob, filename);
44
+ }
@@ -0,0 +1,6 @@
1
+ export { generateCSV, downloadCSV, downloadBlob } from './csv.js';
2
+ export { copyToClipboard } from './clipboard.js';
3
+ export { printTable } from './print.js';
4
+ export { exportExcel } from './excel.js';
5
+ export { exportPdf } from './pdf.js';
6
+ export { ExportManager } from './ExportManager.js';
@@ -0,0 +1,9 @@
1
+ import type { ColumnDef } from '../types.js';
2
+ import { generateCSV } from './csv.js';
3
+ import { printTable } from './print.js';
4
+
5
+ export function exportPdf<T>(data: T[], columns: ColumnDef<T>[], filename: string = 'export.pdf') {
6
+ // Use browser print as fallback (generates PDF via print dialog)
7
+ // For server-side PDF, users should use @beeblock/svelar/pdf directly
8
+ printTable(data, columns, filename.replace('.pdf', ''));
9
+ }
@@ -0,0 +1,42 @@
1
+ import type { ColumnDef } from '../types.js';
2
+
3
+ export function printTable<T>(data: T[], columns: ColumnDef<T>[], title?: string) {
4
+ const visibleCols = columns.filter((c) => c.visible !== false);
5
+
6
+ const headerCells = visibleCols.map((c) => `<th style="border:1px solid #ddd;padding:8px 12px;text-align:left;background:#f5f5f5;font-weight:600;">${escapeHtml(c.header)}</th>`).join('');
7
+ const bodyRows = data.map((row: any) => {
8
+ const cells = visibleCols.map((col) => {
9
+ const val = row[col.key];
10
+ return `<td style="border:1px solid #ddd;padding:8px 12px;">${escapeHtml(val === null || val === undefined ? '' : String(val))}</td>`;
11
+ }).join('');
12
+ return `<tr>${cells}</tr>`;
13
+ }).join('');
14
+
15
+ const html = `<!DOCTYPE html>
16
+ <html><head>
17
+ <title>${escapeHtml(title ?? 'Data Export')}</title>
18
+ <style>
19
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; margin: 20px; }
20
+ table { border-collapse: collapse; width: 100%; font-size: 13px; }
21
+ h1 { font-size: 18px; margin-bottom: 16px; }
22
+ @media print { h1 { margin-top: 0; } }
23
+ </style>
24
+ </head><body>
25
+ ${title ? `<h1>${escapeHtml(title)}</h1>` : ''}
26
+ <table>
27
+ <thead><tr>${headerCells}</tr></thead>
28
+ <tbody>${bodyRows}</tbody>
29
+ </table>
30
+ </body></html>`;
31
+
32
+ const win = window.open('', '_blank');
33
+ if (!win) return;
34
+ win.document.write(html);
35
+ win.document.close();
36
+ win.focus();
37
+ setTimeout(() => win.print(), 250);
38
+ }
39
+
40
+ function escapeHtml(str: string): string {
41
+ return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
42
+ }
package/src/index.ts ADDED
@@ -0,0 +1,22 @@
1
+ export { DataTableStore } from './state/DataTableStore.js';
2
+ export { ServerDataTableStore } from './state/ServerDataTableStore.js';
3
+ export { ExportManager } from './export/ExportManager.js';
4
+ export type {
5
+ ColumnDef,
6
+ ColumnType,
7
+ FilterOperator,
8
+ SelectionMode,
9
+ EditorMode,
10
+ FieldType,
11
+ ExportFormat,
12
+ SortState,
13
+ FilterState,
14
+ PaginationState,
15
+ DataTableRequest,
16
+ DataTableResponse,
17
+ EditorFieldDef,
18
+ ButtonDef,
19
+ DataTableConfig,
20
+ DataTableClassNames,
21
+ DataTableState,
22
+ } from './types.js';
@@ -0,0 +1,599 @@
1
+ import type { ColumnDef, DataTableConfig, DataTableState, FilterState, SortState, PaginationState } from '../types.js';
2
+
3
+ type Listener = () => void;
4
+
5
+ export class DataTableStore<T = any> {
6
+ private _state: DataTableState<T>;
7
+ private _listeners = new Set<Listener>();
8
+ private _columns: ColumnDef<T>[] = [];
9
+ private _rowIdFn: (row: T) => string | number;
10
+ private _stateSaveKey: string | null = null;
11
+ private _lastSelectedId: string | number | null = null;
12
+ private _paginate: boolean = true;
13
+
14
+ constructor(config: DataTableConfig<T>) {
15
+ this._columns = config.columns;
16
+ this._stateSaveKey = config.stateSaveKey ?? null;
17
+ this._paginate = config.paginate !== false;
18
+ this._rowIdFn = typeof config.rowId === 'function'
19
+ ? config.rowId
20
+ : (row: any) => row[config.rowId as string ?? 'id'];
21
+
22
+ const savedState = this._loadState();
23
+
24
+ this._state = {
25
+ allRows: config.data ?? [],
26
+ filteredRows: [],
27
+ sortedRows: [],
28
+ paginatedRows: [],
29
+ sort: savedState?.sort ?? [],
30
+ filters: savedState?.filters ?? [],
31
+ globalSearch: savedState?.globalSearch ?? '',
32
+ pagination: {
33
+ page: savedState?.page ?? 1,
34
+ perPage: config.perPage ?? 15,
35
+ total: 0,
36
+ lastPage: 1,
37
+ },
38
+ selectedIds: new Set(),
39
+ columnVisibility: savedState?.columnVisibility ?? Object.fromEntries(
40
+ config.columns.map((c) => [c.key, c.visible !== false])
41
+ ),
42
+ columnOrder: savedState?.columnOrder ?? config.columns.map((c) => c.key),
43
+ loading: false,
44
+ error: null,
45
+ editingRowId: null,
46
+ editingColumn: null,
47
+ editorMode: null,
48
+ formData: {},
49
+ validationErrors: {},
50
+ draw: 0,
51
+ excelFocusedCell: null,
52
+ excelEditingCell: null,
53
+ excelEditValue: '',
54
+ };
55
+
56
+ this._recompute();
57
+ }
58
+
59
+ subscribe(listener: Listener): () => void {
60
+ this._listeners.add(listener);
61
+ return () => this._listeners.delete(listener);
62
+ }
63
+
64
+ getState(): DataTableState<T> {
65
+ return this._state;
66
+ }
67
+
68
+ private _notify() {
69
+ for (const listener of this._listeners) listener();
70
+ }
71
+
72
+ private _saveState() {
73
+ if (!this._stateSaveKey) return;
74
+ try {
75
+ const toSave = {
76
+ sort: this._state.sort,
77
+ filters: this._state.filters,
78
+ globalSearch: this._state.globalSearch,
79
+ page: this._state.pagination.page,
80
+ columnVisibility: this._state.columnVisibility,
81
+ columnOrder: this._state.columnOrder,
82
+ };
83
+ localStorage.setItem(this._stateSaveKey, JSON.stringify(toSave));
84
+ } catch {}
85
+ }
86
+
87
+ private _loadState(): any {
88
+ if (!this._stateSaveKey) return null;
89
+ try {
90
+ const raw = localStorage.getItem(this._stateSaveKey);
91
+ return raw ? JSON.parse(raw) : null;
92
+ } catch {
93
+ return null;
94
+ }
95
+ }
96
+
97
+ // Set data (replaces all rows)
98
+ setData(rows: T[]) {
99
+ this._state = { ...this._state, allRows: rows };
100
+ this._recompute();
101
+ }
102
+
103
+ // Sorting
104
+ setSort(sort: SortState[]) {
105
+ this._state = { ...this._state, sort, pagination: { ...this._state.pagination, page: 1 } };
106
+ this._recompute();
107
+ this._saveState();
108
+ }
109
+
110
+ toggleSort(column: string, multiSort: boolean = false) {
111
+ const existing = this._state.sort.find((s) => s.column === column);
112
+ let newSort: SortState[];
113
+
114
+ if (existing) {
115
+ if (existing.direction === 'asc') {
116
+ newSort = multiSort
117
+ ? this._state.sort.map((s) => s.column === column ? { ...s, direction: 'desc' as const } : s)
118
+ : [{ column, direction: 'desc' }];
119
+ } else {
120
+ // Remove sort
121
+ newSort = multiSort
122
+ ? this._state.sort.filter((s) => s.column !== column)
123
+ : [];
124
+ }
125
+ } else {
126
+ newSort = multiSort
127
+ ? [...this._state.sort, { column, direction: 'asc' }]
128
+ : [{ column, direction: 'asc' }];
129
+ }
130
+
131
+ this.setSort(newSort);
132
+ }
133
+
134
+ // Searching
135
+ setGlobalSearch(search: string) {
136
+ this._state = { ...this._state, globalSearch: search, pagination: { ...this._state.pagination, page: 1 } };
137
+ this._recompute();
138
+ this._saveState();
139
+ }
140
+
141
+ // Filtering
142
+ setFilters(filters: FilterState[]) {
143
+ this._state = { ...this._state, filters, pagination: { ...this._state.pagination, page: 1 } };
144
+ this._recompute();
145
+ this._saveState();
146
+ }
147
+
148
+ setColumnFilter(column: string, value: any, operator: FilterState['operator'] = 'like') {
149
+ const filters = this._state.filters.filter((f) => f.column !== column);
150
+ if (value !== '' && value !== null && value !== undefined) {
151
+ filters.push({ column, value, operator });
152
+ }
153
+ this.setFilters(filters);
154
+ }
155
+
156
+ // Pagination
157
+ setPage(page: number) {
158
+ const lastPage = this._state.pagination.lastPage;
159
+ const safePage = Math.max(1, Math.min(page, lastPage));
160
+ this._state = { ...this._state, pagination: { ...this._state.pagination, page: safePage } };
161
+ this._recompute();
162
+ this._saveState();
163
+ }
164
+
165
+ setPerPage(perPage: number) {
166
+ this._state = { ...this._state, pagination: { ...this._state.pagination, perPage, page: 1 } };
167
+ this._recompute();
168
+ this._saveState();
169
+ }
170
+
171
+ // Column visibility
172
+ toggleColumnVisibility(column: string) {
173
+ const vis = { ...this._state.columnVisibility };
174
+ vis[column] = !vis[column];
175
+ this._state = { ...this._state, columnVisibility: vis };
176
+ this._notify();
177
+ this._saveState();
178
+ }
179
+
180
+ setColumnVisibility(visibility: Record<string, boolean>) {
181
+ this._state = { ...this._state, columnVisibility: visibility };
182
+ this._notify();
183
+ this._saveState();
184
+ }
185
+
186
+ // Column reorder
187
+ reorderColumns(columnOrder: string[]) {
188
+ this._state = { ...this._state, columnOrder };
189
+ this._notify();
190
+ this._saveState();
191
+ }
192
+
193
+ // Selection
194
+ getRowId(row: T): string | number {
195
+ return this._rowIdFn(row);
196
+ }
197
+
198
+ toggleSelect(rowId: string | number) {
199
+ const selected = new Set(this._state.selectedIds);
200
+ if (selected.has(rowId)) {
201
+ selected.delete(rowId);
202
+ } else {
203
+ selected.add(rowId);
204
+ }
205
+ this._lastSelectedId = rowId;
206
+ this._state = { ...this._state, selectedIds: selected };
207
+ this._notify();
208
+ }
209
+
210
+ getLastSelectedId(): string | number | null {
211
+ return this._lastSelectedId;
212
+ }
213
+
214
+ selectSingle(rowId: string | number) {
215
+ this._state = { ...this._state, selectedIds: new Set([rowId]) };
216
+ this._notify();
217
+ }
218
+
219
+ selectAll() {
220
+ const ids = this._state.filteredRows.map((row) => this.getRowId(row));
221
+ this._state = { ...this._state, selectedIds: new Set(ids) };
222
+ this._notify();
223
+ }
224
+
225
+ deselectAll() {
226
+ this._state = { ...this._state, selectedIds: new Set() };
227
+ this._notify();
228
+ }
229
+
230
+ selectRange(fromId: string | number, toId: string | number) {
231
+ const rows = this._state.sortedRows;
232
+ const fromIdx = rows.findIndex((r) => this.getRowId(r) === fromId);
233
+ const toIdx = rows.findIndex((r) => this.getRowId(r) === toId);
234
+ if (fromIdx === -1 || toIdx === -1) return;
235
+
236
+ const start = Math.min(fromIdx, toIdx);
237
+ const end = Math.max(fromIdx, toIdx);
238
+ const selected = new Set(this._state.selectedIds);
239
+ for (let i = start; i <= end; i++) {
240
+ selected.add(this.getRowId(rows[i]));
241
+ }
242
+ this._state = { ...this._state, selectedIds: selected };
243
+ this._notify();
244
+ }
245
+
246
+ getSelectedRows(): T[] {
247
+ return this._state.allRows.filter((row) => this._state.selectedIds.has(this.getRowId(row)));
248
+ }
249
+
250
+ // Editor
251
+ openEditor(rowId: string | number | null, column: string | null, mode: 'inline' | 'bubble' | 'modal') {
252
+ const row = rowId !== null
253
+ ? this._state.allRows.find((r) => this.getRowId(r) === rowId)
254
+ : null;
255
+ const formData = row ? { ...(row as any) } : {};
256
+ this._state = {
257
+ ...this._state,
258
+ editingRowId: rowId,
259
+ editingColumn: column,
260
+ editorMode: mode,
261
+ formData,
262
+ validationErrors: {},
263
+ };
264
+ this._notify();
265
+ }
266
+
267
+ closeEditor() {
268
+ this._state = {
269
+ ...this._state,
270
+ editingRowId: null,
271
+ editingColumn: null,
272
+ editorMode: null,
273
+ formData: {},
274
+ validationErrors: {},
275
+ };
276
+ this._notify();
277
+ }
278
+
279
+ setFormField(name: string, value: any) {
280
+ this._state = {
281
+ ...this._state,
282
+ formData: { ...this._state.formData, [name]: value },
283
+ };
284
+ this._notify();
285
+ }
286
+
287
+ setValidationErrors(errors: Record<string, string>) {
288
+ this._state = { ...this._state, validationErrors: errors };
289
+ this._notify();
290
+ }
291
+
292
+ // Loading
293
+ setLoading(loading: boolean) {
294
+ this._state = { ...this._state, loading };
295
+ this._notify();
296
+ }
297
+
298
+ setError(error: string | null) {
299
+ this._state = { ...this._state, error };
300
+ this._notify();
301
+ }
302
+
303
+ // Server-side response
304
+ setServerResponse(response: { data: T[]; recordsTotal: number; recordsFiltered: number; draw: number }) {
305
+ this._state = {
306
+ ...this._state,
307
+ allRows: response.data,
308
+ filteredRows: response.data,
309
+ sortedRows: response.data,
310
+ paginatedRows: response.data,
311
+ loading: false,
312
+ draw: response.draw,
313
+ pagination: {
314
+ ...this._state.pagination,
315
+ total: response.recordsFiltered,
316
+ lastPage: Math.ceil(response.recordsFiltered / this._state.pagination.perPage) || 1,
317
+ },
318
+ };
319
+ this._notify();
320
+ }
321
+
322
+ // Clear state
323
+ resetState() {
324
+ this._state = {
325
+ ...this._state,
326
+ sort: [],
327
+ filters: [],
328
+ globalSearch: '',
329
+ pagination: { ...this._state.pagination, page: 1 },
330
+ selectedIds: new Set(),
331
+ };
332
+ this._recompute();
333
+ if (this._stateSaveKey) {
334
+ try { localStorage.removeItem(this._stateSaveKey); } catch {}
335
+ }
336
+ }
337
+
338
+ // Excel mode
339
+ focusCell(rowIndex: number, columnKey: string) {
340
+ this._state = {
341
+ ...this._state,
342
+ excelFocusedCell: { rowIndex, columnKey },
343
+ };
344
+ this._notify();
345
+ }
346
+
347
+ startCellEdit() {
348
+ const focused = this._state.excelFocusedCell;
349
+ if (!focused) return;
350
+ const row = this._state.paginatedRows[focused.rowIndex] as any;
351
+ if (!row) return;
352
+ const col = this._columns.find((c) => c.key === focused.columnKey);
353
+ if (!col || col.editable === false) return;
354
+ this._state = {
355
+ ...this._state,
356
+ excelEditingCell: { ...focused },
357
+ excelEditValue: row[focused.columnKey] != null ? String(row[focused.columnKey]) : '',
358
+ };
359
+ this._notify();
360
+ }
361
+
362
+ setExcelEditValue(value: string) {
363
+ this._state = { ...this._state, excelEditValue: value };
364
+ // No notify needed — the input component manages its own value
365
+ }
366
+
367
+ // Returns { row, columnKey, oldValue, newValue } if there was a change, null otherwise
368
+ commitCellEdit(): { row: any; columnKey: string; oldValue: any; newValue: any } | null {
369
+ const editing = this._state.excelEditingCell;
370
+ if (!editing) return null;
371
+ const row = this._state.paginatedRows[editing.rowIndex] as any;
372
+ if (!row) { this.cancelCellEdit(); return null; }
373
+
374
+ const col = this._columns.find((c) => c.key === editing.columnKey);
375
+ const oldValue = row[editing.columnKey];
376
+ let newValue: any = this._state.excelEditValue;
377
+
378
+ // Cast value based on column type
379
+ if (col?.type === 'number') newValue = newValue === '' ? null : Number(newValue);
380
+ else if (col?.type === 'boolean') newValue = newValue === 'true' || newValue === '1';
381
+ else if (newValue === '') newValue = null;
382
+
383
+ this._state = {
384
+ ...this._state,
385
+ excelEditingCell: null,
386
+ excelEditValue: '',
387
+ };
388
+
389
+ if (newValue === oldValue || (newValue === null && (oldValue === null || oldValue === undefined))) {
390
+ this._notify();
391
+ return null;
392
+ }
393
+
394
+ // Update local data for client-side
395
+ row[editing.columnKey] = newValue;
396
+ this._notify();
397
+
398
+ return { row, columnKey: editing.columnKey, oldValue, newValue };
399
+ }
400
+
401
+ cancelCellEdit() {
402
+ this._state = {
403
+ ...this._state,
404
+ excelEditingCell: null,
405
+ excelEditValue: '',
406
+ };
407
+ this._notify();
408
+ }
409
+
410
+ clearExcelFocus() {
411
+ this._state = {
412
+ ...this._state,
413
+ excelFocusedCell: null,
414
+ excelEditingCell: null,
415
+ excelEditValue: '',
416
+ };
417
+ this._notify();
418
+ }
419
+
420
+ getVisibleColumns(): ColumnDef[] {
421
+ return this._state.columnOrder
422
+ .filter((key) => this._state.columnVisibility[key] !== false)
423
+ .map((key) => this._columns.find((c) => c.key === key))
424
+ .filter(Boolean) as ColumnDef[];
425
+ }
426
+
427
+ // Navigate to adjacent cell. Returns 'page-next' | 'page-prev' | null
428
+ navigateCell(direction: 'up' | 'down' | 'left' | 'right'): 'page-next' | 'page-prev' | null {
429
+ const focused = this._state.excelFocusedCell;
430
+ const rows = this._state.paginatedRows;
431
+ const visibleCols = this.getVisibleColumns();
432
+ if (visibleCols.length === 0) return null;
433
+
434
+ if (!focused) {
435
+ // Focus first editable cell
436
+ const firstEditable = visibleCols.find((c) => c.editable !== false);
437
+ if (firstEditable && rows.length > 0) {
438
+ this.focusCell(0, firstEditable.key);
439
+ }
440
+ return null;
441
+ }
442
+
443
+ let { rowIndex, columnKey } = focused;
444
+ const colIdx = visibleCols.findIndex((c) => c.key === columnKey);
445
+ if (colIdx === -1) return null;
446
+
447
+ switch (direction) {
448
+ case 'left': {
449
+ // Find previous editable column
450
+ for (let i = colIdx - 1; i >= 0; i--) {
451
+ if (visibleCols[i].editable !== false) {
452
+ this.focusCell(rowIndex, visibleCols[i].key);
453
+ return null;
454
+ }
455
+ }
456
+ // Wrap to last editable col of previous row
457
+ for (let i = visibleCols.length - 1; i >= 0; i--) {
458
+ if (visibleCols[i].editable !== false) {
459
+ if (rowIndex > 0) {
460
+ this.focusCell(rowIndex - 1, visibleCols[i].key);
461
+ return null;
462
+ } else {
463
+ return 'page-prev';
464
+ }
465
+ }
466
+ }
467
+ return null;
468
+ }
469
+ case 'right': {
470
+ for (let i = colIdx + 1; i < visibleCols.length; i++) {
471
+ if (visibleCols[i].editable !== false) {
472
+ this.focusCell(rowIndex, visibleCols[i].key);
473
+ return null;
474
+ }
475
+ }
476
+ // Wrap to first editable col of next row
477
+ for (let i = 0; i < visibleCols.length; i++) {
478
+ if (visibleCols[i].editable !== false) {
479
+ if (rowIndex < rows.length - 1) {
480
+ this.focusCell(rowIndex + 1, visibleCols[i].key);
481
+ return null;
482
+ } else {
483
+ return 'page-next';
484
+ }
485
+ }
486
+ }
487
+ return null;
488
+ }
489
+ case 'up': {
490
+ if (rowIndex > 0) {
491
+ this.focusCell(rowIndex - 1, columnKey);
492
+ return null;
493
+ }
494
+ return 'page-prev';
495
+ }
496
+ case 'down': {
497
+ if (rowIndex < rows.length - 1) {
498
+ this.focusCell(rowIndex + 1, columnKey);
499
+ return null;
500
+ }
501
+ return 'page-next';
502
+ }
503
+ }
504
+ }
505
+
506
+ // Core recompute pipeline: filter -> sort -> paginate
507
+ private _recompute() {
508
+ let rows = [...this._state.allRows];
509
+
510
+ // 1. Global search
511
+ if (this._state.globalSearch) {
512
+ const searchLower = this._state.globalSearch.toLowerCase();
513
+ const searchableCols = this._columns.filter((c) => c.searchable !== false);
514
+ rows = rows.filter((row: any) =>
515
+ searchableCols.some((col) => {
516
+ const val = row[col.key];
517
+ return val !== null && val !== undefined && String(val).toLowerCase().includes(searchLower);
518
+ })
519
+ );
520
+ }
521
+
522
+ // 2. Column filters
523
+ for (const filter of this._state.filters) {
524
+ rows = rows.filter((row: any) => {
525
+ const val = row[filter.column];
526
+ switch (filter.operator) {
527
+ case '=': return val == filter.value;
528
+ case '!=': return val != filter.value;
529
+ case '>': return val > filter.value;
530
+ case '<': return val < filter.value;
531
+ case '>=': return val >= filter.value;
532
+ case '<=': return val <= filter.value;
533
+ case 'like': return val !== null && val !== undefined && String(val).toLowerCase().includes(String(filter.value).toLowerCase());
534
+ case 'not_like': return val === null || val === undefined || !String(val).toLowerCase().includes(String(filter.value).toLowerCase());
535
+ case 'in': return Array.isArray(filter.value) && filter.value.includes(val);
536
+ case 'between': return Array.isArray(filter.value) && val >= filter.value[0] && val <= filter.value[1];
537
+ case 'null': return val === null || val === undefined;
538
+ case 'not_null': return val !== null && val !== undefined;
539
+ default: return true;
540
+ }
541
+ });
542
+ }
543
+
544
+ const filteredRows = rows;
545
+
546
+ // 3. Sort
547
+ if (this._state.sort.length > 0) {
548
+ rows = [...rows].sort((a: any, b: any) => {
549
+ for (const s of this._state.sort) {
550
+ const colDef = this._columns.find((c) => c.key === s.column);
551
+ const aVal = a[s.column];
552
+ const bVal = b[s.column];
553
+
554
+ let cmp = 0;
555
+ if (aVal === null || aVal === undefined) cmp = -1;
556
+ else if (bVal === null || bVal === undefined) cmp = 1;
557
+ else if (colDef?.type === 'number') cmp = Number(aVal) - Number(bVal);
558
+ else if (colDef?.type === 'date') cmp = new Date(aVal).getTime() - new Date(bVal).getTime();
559
+ else if (colDef?.type === 'boolean') cmp = (aVal ? 1 : 0) - (bVal ? 1 : 0);
560
+ else cmp = String(aVal).localeCompare(String(bVal));
561
+
562
+ if (cmp !== 0) return s.direction === 'desc' ? -cmp : cmp;
563
+ }
564
+ return 0;
565
+ });
566
+ }
567
+
568
+ const sortedRows = rows;
569
+
570
+ // 4. Paginate
571
+ const total = filteredRows.length;
572
+ const perPage = this._state.pagination.perPage;
573
+ let paginatedRows: T[];
574
+ let page: number;
575
+ let lastPage: number;
576
+
577
+ if (this._paginate) {
578
+ lastPage = Math.ceil(total / perPage) || 1;
579
+ page = Math.min(this._state.pagination.page, lastPage);
580
+ const start = (page - 1) * perPage;
581
+ paginatedRows = sortedRows.slice(start, start + perPage);
582
+ } else {
583
+ // No pagination — all rows visible (used with virtual scroll)
584
+ paginatedRows = sortedRows;
585
+ page = 1;
586
+ lastPage = 1;
587
+ }
588
+
589
+ this._state = {
590
+ ...this._state,
591
+ filteredRows,
592
+ sortedRows,
593
+ paginatedRows,
594
+ pagination: { page, perPage, total, lastPage },
595
+ };
596
+
597
+ this._notify();
598
+ }
599
+ }
@@ -0,0 +1,168 @@
1
+ import type { DataTableConfig, DataTableRequest, DataTableResponse, DataTableState, FilterState, SortState } from '../types.js';
2
+ import { DataTableStore } from './DataTableStore.js';
3
+
4
+ function getCsrfToken(cookieName = 'XSRF-TOKEN'): string | null {
5
+ if (typeof document === 'undefined') return null;
6
+ const escaped = cookieName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
7
+ const match = document.cookie.match(new RegExp(`(?:^|;\\s*)${escaped}=([^;]*)`));
8
+ return match ? decodeURIComponent(match[1]) : null;
9
+ }
10
+
11
+ export class ServerDataTableStore<T = any> extends DataTableStore<T> {
12
+ private _serverUrl: string;
13
+ private _serverMethod: 'GET' | 'POST';
14
+ private _drawCounter = 0;
15
+ private _abortController: AbortController | null = null;
16
+ private _fetchDebounceTimer: ReturnType<typeof setTimeout> | null = null;
17
+ private _serverColumns: { data: string; name: string; searchable: boolean; orderable: boolean }[];
18
+ private _csrfCookieName: string;
19
+ private _csrfHeaderName: string;
20
+
21
+ constructor(config: DataTableConfig<T>) {
22
+ super(config);
23
+ this._serverUrl = config.serverUrl!;
24
+ this._serverMethod = config.serverMethod ?? 'GET';
25
+ this._csrfCookieName = config.csrfCookieName ?? 'XSRF-TOKEN';
26
+ this._csrfHeaderName = config.csrfHeaderName ?? 'X-CSRF-Token';
27
+ this._serverColumns = config.columns.map((c) => ({
28
+ data: c.key,
29
+ name: c.key,
30
+ searchable: c.searchable !== false,
31
+ orderable: c.sortable !== false,
32
+ }));
33
+ }
34
+
35
+ private _buildHeaders(extra: Record<string, string> = {}): Record<string, string> {
36
+ const headers: Record<string, string> = { ...extra };
37
+ const token = getCsrfToken(this._csrfCookieName);
38
+ if (token) {
39
+ headers[this._csrfHeaderName] = token;
40
+ }
41
+ return headers;
42
+ }
43
+
44
+ override setSort(sort: SortState[]) {
45
+ const state = this.getState();
46
+ // Update sort in state without recomputing locally
47
+ (this as any)._state = { ...state, sort, pagination: { ...state.pagination, page: 1 } };
48
+ this._debouncedFetch();
49
+ }
50
+
51
+ override setGlobalSearch(search: string) {
52
+ const state = this.getState();
53
+ (this as any)._state = { ...state, globalSearch: search, pagination: { ...state.pagination, page: 1 } };
54
+ this._debouncedFetch();
55
+ }
56
+
57
+ override setFilters(filters: FilterState[]) {
58
+ const state = this.getState();
59
+ (this as any)._state = { ...state, filters, pagination: { ...state.pagination, page: 1 } };
60
+ this._debouncedFetch();
61
+ }
62
+
63
+ override setPage(page: number) {
64
+ const state = this.getState();
65
+ (this as any)._state = { ...state, pagination: { ...state.pagination, page } };
66
+ this._fetchFromServer();
67
+ }
68
+
69
+ override setPerPage(perPage: number) {
70
+ const state = this.getState();
71
+ (this as any)._state = { ...state, pagination: { ...state.pagination, perPage, page: 1 } };
72
+ this._fetchFromServer();
73
+ }
74
+
75
+ private _debouncedFetch() {
76
+ if (this._fetchDebounceTimer) clearTimeout(this._fetchDebounceTimer);
77
+ this._fetchDebounceTimer = setTimeout(() => this._fetchFromServer(), 300);
78
+ }
79
+
80
+ async _fetchFromServer() {
81
+ if (this._abortController) this._abortController.abort();
82
+ this._abortController = new AbortController();
83
+
84
+ const state = this.getState();
85
+ this.setLoading(true);
86
+ this.setError(null);
87
+
88
+ const draw = ++this._drawCounter;
89
+ const request: DataTableRequest = {
90
+ draw,
91
+ start: (state.pagination.page - 1) * state.pagination.perPage,
92
+ length: state.pagination.perPage,
93
+ search: { value: state.globalSearch, regex: false },
94
+ order: state.sort.map((s) => ({
95
+ column: this._serverColumns.findIndex((c) => c.data === s.column),
96
+ dir: s.direction,
97
+ })),
98
+ columns: this._serverColumns.map((col) => ({
99
+ ...col,
100
+ search: {
101
+ value: state.filters.find((f) => f.column === col.data)?.value ?? '',
102
+ regex: false,
103
+ },
104
+ })),
105
+ };
106
+
107
+ try {
108
+ let response: Response;
109
+
110
+ if (this._serverMethod === 'POST') {
111
+ response = await fetch(this._serverUrl, {
112
+ method: 'POST',
113
+ headers: this._buildHeaders({ 'Content-Type': 'application/json' }),
114
+ body: JSON.stringify(request),
115
+ signal: this._abortController.signal,
116
+ });
117
+ } else {
118
+ const params = new URLSearchParams();
119
+ params.set('draw', String(request.draw));
120
+ params.set('start', String(request.start));
121
+ params.set('length', String(request.length));
122
+ params.set('search[value]', request.search.value);
123
+ params.set('search[regex]', String(request.search.regex));
124
+
125
+ request.order.forEach((o, i) => {
126
+ params.set(`order[${i}][column]`, String(o.column));
127
+ params.set(`order[${i}][dir]`, o.dir);
128
+ });
129
+
130
+ request.columns.forEach((c, i) => {
131
+ params.set(`columns[${i}][data]`, c.data);
132
+ params.set(`columns[${i}][name]`, c.name);
133
+ params.set(`columns[${i}][searchable]`, String(c.searchable));
134
+ params.set(`columns[${i}][orderable]`, String(c.orderable));
135
+ params.set(`columns[${i}][search][value]`, c.search.value);
136
+ params.set(`columns[${i}][search][regex]`, String(c.search.regex));
137
+ });
138
+
139
+ const url = `${this._serverUrl}?${params.toString()}`;
140
+ response = await fetch(url, { signal: this._abortController.signal });
141
+ }
142
+
143
+ if (!response.ok) {
144
+ throw new Error(`Server responded with ${response.status}`);
145
+ }
146
+
147
+ const json: DataTableResponse<T> = await response.json();
148
+
149
+ // Only accept if this is the latest draw
150
+ if (json.draw === draw) {
151
+ this.setServerResponse(json);
152
+ }
153
+ } catch (err: any) {
154
+ if (err.name === 'AbortError') return;
155
+ this.setLoading(false);
156
+ this.setError(err.message ?? 'Failed to fetch data');
157
+ }
158
+ }
159
+
160
+ async initialFetch() {
161
+ await this._fetchFromServer();
162
+ }
163
+
164
+ destroy() {
165
+ if (this._abortController) this._abortController.abort();
166
+ if (this._fetchDebounceTimer) clearTimeout(this._fetchDebounceTimer);
167
+ }
168
+ }
@@ -0,0 +1,2 @@
1
+ export { DataTableStore } from './DataTableStore.js';
2
+ export { ServerDataTableStore } from './ServerDataTableStore.js';
package/src/types.ts ADDED
@@ -0,0 +1,241 @@
1
+ import type { Component } from 'svelte';
2
+
3
+ // Column definition
4
+ export type ColumnType = 'string' | 'number' | 'date' | 'boolean' | 'html' | 'custom';
5
+ export type FilterOperator = '=' | '!=' | '>' | '<' | '>=' | '<=' | 'like' | 'not_like' | 'in' | 'between' | 'null' | 'not_null';
6
+ export type SelectionMode = 'none' | 'single' | 'multi';
7
+ export type EditorMode = 'inline' | 'bubble' | 'modal' | 'excel';
8
+ export type FieldType = 'text' | 'textarea' | 'number' | 'select' | 'multi-select' | 'checkbox' | 'radio' | 'date' | 'datetime' | 'upload' | 'hidden' | 'readonly';
9
+ export type ExportFormat = 'csv' | 'excel' | 'pdf' | 'clipboard' | 'print';
10
+
11
+ export interface ColumnDef<T = any> {
12
+ key: string;
13
+ header: string;
14
+ type?: ColumnType;
15
+ sortable?: boolean;
16
+ searchable?: boolean;
17
+ filterable?: boolean;
18
+ visible?: boolean;
19
+ width?: string;
20
+ minWidth?: string;
21
+ maxWidth?: string;
22
+ className?: string;
23
+ headerClassName?: string;
24
+ orderable?: boolean;
25
+ defaultSort?: 'asc' | 'desc';
26
+ // Footer
27
+ footer?: string | ((rows: T[]) => string | number);
28
+ // Editor
29
+ editable?: boolean;
30
+ editorField?: EditorFieldDef;
31
+ }
32
+
33
+ export interface SortState {
34
+ column: string;
35
+ direction: 'asc' | 'desc';
36
+ }
37
+
38
+ export interface FilterState {
39
+ column: string;
40
+ value: any;
41
+ operator: FilterOperator;
42
+ }
43
+
44
+ export interface PaginationState {
45
+ page: number;
46
+ perPage: number;
47
+ total: number;
48
+ lastPage: number;
49
+ }
50
+
51
+ // Server-side protocol (compatible with jQuery DataTables wire protocol)
52
+ export interface DataTableRequest {
53
+ draw: number;
54
+ start: number;
55
+ length: number;
56
+ search: { value: string; regex: boolean };
57
+ order: { column: number; dir: 'asc' | 'desc' }[];
58
+ columns: {
59
+ data: string;
60
+ name: string;
61
+ searchable: boolean;
62
+ orderable: boolean;
63
+ search: { value: string; regex: boolean };
64
+ }[];
65
+ }
66
+
67
+ export interface DataTableResponse<T = any> {
68
+ draw: number;
69
+ recordsTotal: number;
70
+ recordsFiltered: number;
71
+ data: T[];
72
+ error?: string;
73
+ }
74
+
75
+ // Editor
76
+ export interface EditorFieldDef {
77
+ name: string;
78
+ type: FieldType;
79
+ label: string;
80
+ placeholder?: string;
81
+ options?: { label: string; value: any }[];
82
+ multiple?: boolean;
83
+ required?: boolean;
84
+ disabled?: boolean;
85
+ className?: string;
86
+ dependsOn?: string;
87
+ dependsOnValue?: any;
88
+ showWhen?: (formData: Record<string, any>) => boolean;
89
+ defaultValue?: any;
90
+ }
91
+
92
+ // Buttons
93
+ export interface ButtonDef {
94
+ key: string;
95
+ label: string;
96
+ icon?: Component<any>;
97
+ action?: string | ((selectedRows: any[], allData: any[]) => void | Promise<void>);
98
+ variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost';
99
+ disabled?: boolean | ((selectedRows: any[]) => boolean);
100
+ collection?: ButtonDef[];
101
+ }
102
+
103
+ // CSS class overrides for full Tailwind customization
104
+ export interface DataTableClassNames {
105
+ container?: string;
106
+ toolbar?: string;
107
+ toolbarLeft?: string;
108
+ toolbarRight?: string;
109
+ searchInput?: string;
110
+ table?: string;
111
+ thead?: string;
112
+ th?: string;
113
+ thSortable?: string;
114
+ tbody?: string;
115
+ tr?: string;
116
+ trSelected?: string;
117
+ trEven?: string;
118
+ td?: string;
119
+ tfoot?: string;
120
+ tf?: string;
121
+ pagination?: string;
122
+ paginationInfo?: string;
123
+ paginationControls?: string;
124
+ pageButton?: string;
125
+ pageButtonActive?: string;
126
+ perPageSelect?: string;
127
+ btn?: string;
128
+ btnCreate?: string;
129
+ btnEdit?: string;
130
+ btnDelete?: string;
131
+ editorModal?: string;
132
+ editorBackdrop?: string;
133
+ editorField?: string;
134
+ editorInput?: string;
135
+ editorLabel?: string;
136
+ loading?: string;
137
+ empty?: string;
138
+ error?: string;
139
+ }
140
+
141
+ // Full config
142
+ export interface DataTableConfig<T = any> {
143
+ // Data source
144
+ data?: T[];
145
+ serverUrl?: string;
146
+ serverMethod?: 'GET' | 'POST';
147
+ // CSRF token config (defaults to Svelar conventions)
148
+ csrfCookieName?: string;
149
+ csrfHeaderName?: string;
150
+ // Columns
151
+ columns: ColumnDef<T>[];
152
+ // Features
153
+ sortable?: boolean;
154
+ searchable?: boolean;
155
+ paginate?: boolean;
156
+ selectable?: SelectionMode;
157
+ // Pagination
158
+ perPage?: number;
159
+ perPageOptions?: number[];
160
+ // Search
161
+ searchDebounceMs?: number;
162
+ // State
163
+ stateSaveKey?: string;
164
+ // Row identity
165
+ rowId?: string | ((row: T) => string | number);
166
+ // Row classes
167
+ rowClass?: string | ((row: T, index: number) => string);
168
+ // Buttons
169
+ buttons?: (ButtonDef | ExportFormat)[];
170
+ // Editor
171
+ editorMode?: EditorMode;
172
+ editorFields?: EditorFieldDef[];
173
+ // Callbacks
174
+ onSort?: (sort: SortState[]) => void;
175
+ onFilter?: (filters: FilterState[]) => void;
176
+ onPageChange?: (page: number, perPage: number) => void;
177
+ onSelect?: (selectedRows: T[]) => void;
178
+ onRowClick?: (row: T, event: MouseEvent) => void;
179
+ onEdit?: (row: T, data: Record<string, any>) => void | Promise<void>;
180
+ onCellEdit?: (row: T, columnKey: string, newValue: any, oldValue: any) => void | Promise<void>;
181
+ onCreate?: (data: Record<string, any>) => void | Promise<void>;
182
+ onDelete?: (rows: T[]) => void | Promise<void>;
183
+ // Virtual scroll
184
+ virtualScroll?: boolean;
185
+ virtualRowHeight?: number;
186
+ // Responsive
187
+ responsive?: boolean;
188
+ // Row grouping
189
+ groupBy?: string;
190
+ // Detail/child rows
191
+ expandable?: boolean;
192
+ // Empty state
193
+ emptyText?: string;
194
+ // Loading
195
+ loadingText?: string;
196
+ // CSS
197
+ className?: string;
198
+ compact?: boolean;
199
+ striped?: boolean;
200
+ hover?: boolean;
201
+ bordered?: boolean;
202
+ // Tailwind / custom class overrides
203
+ classNames?: DataTableClassNames;
204
+ // Unstyled mode — disables all built-in CSS
205
+ unstyled?: boolean;
206
+ }
207
+
208
+ // Store state shape
209
+ export interface DataTableState<T = any> {
210
+ // Source data
211
+ allRows: T[];
212
+ // Derived
213
+ filteredRows: T[];
214
+ sortedRows: T[];
215
+ paginatedRows: T[];
216
+ // State
217
+ sort: SortState[];
218
+ filters: FilterState[];
219
+ globalSearch: string;
220
+ pagination: PaginationState;
221
+ // Selection
222
+ selectedIds: Set<string | number>;
223
+ // Column state
224
+ columnVisibility: Record<string, boolean>;
225
+ columnOrder: string[];
226
+ // Loading
227
+ loading: boolean;
228
+ error: string | null;
229
+ // Editor
230
+ editingRowId: string | number | null;
231
+ editingColumn: string | null;
232
+ editorMode: EditorMode | null;
233
+ formData: Record<string, any>;
234
+ validationErrors: Record<string, string>;
235
+ // Server draw
236
+ draw: number;
237
+ // Excel mode
238
+ excelFocusedCell: { rowIndex: number; columnKey: string } | null;
239
+ excelEditingCell: { rowIndex: number; columnKey: string } | null;
240
+ excelEditValue: string;
241
+ }
@@ -149,6 +149,10 @@
149
149
  if (row) await onEdit(row, formData);
150
150
  }
151
151
  store.closeEditor();
152
+ // Auto-refetch for server-side stores after successful edit/create
153
+ if ('initialFetch' in store) {
154
+ await (store as any).initialFetch();
155
+ }
152
156
  } catch (err: any) {
153
157
  if (err.errors) {
154
158
  store.setValidationErrors(err.errors);