@beeblock/svelar-datatable 0.1.6 → 0.1.8

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.
@@ -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
+ }