@liteforge/table 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/table.js ADDED
@@ -0,0 +1,737 @@
1
+ /**
2
+ * @liteforge/table - createTable Implementation
3
+ *
4
+ * Signals-based data table with sorting, filtering, pagination, and selection.
5
+ * Uses computed() pipeline for efficient fine-grained updates.
6
+ */
7
+ import { signal, computed, effect } from '@liteforge/core';
8
+ import { injectDefaultStyles } from './styles.js';
9
+ // ─── Utility Functions ─────────────────────────────────────
10
+ /**
11
+ * Get nested property value using dot notation
12
+ * e.g., getNestedValue(obj, 'company.name') → obj.company.name
13
+ */
14
+ function getNestedValue(obj, path) {
15
+ const keys = path.split('.');
16
+ let current = obj;
17
+ for (const key of keys) {
18
+ if (current === null || current === undefined)
19
+ return undefined;
20
+ current = current[key];
21
+ }
22
+ return current;
23
+ }
24
+ /**
25
+ * Compare two values for sorting
26
+ */
27
+ function compareValues(a, b, direction) {
28
+ const mult = direction === 'asc' ? 1 : -1;
29
+ // Handle null/undefined
30
+ if (a === null || a === undefined)
31
+ return mult;
32
+ if (b === null || b === undefined)
33
+ return -mult;
34
+ // String comparison
35
+ if (typeof a === 'string' && typeof b === 'string') {
36
+ return mult * a.localeCompare(b);
37
+ }
38
+ // Number comparison
39
+ if (typeof a === 'number' && typeof b === 'number') {
40
+ return mult * (a - b);
41
+ }
42
+ // Boolean comparison
43
+ if (typeof a === 'boolean' && typeof b === 'boolean') {
44
+ return mult * (a === b ? 0 : a ? -1 : 1);
45
+ }
46
+ // Fallback: convert to string
47
+ return mult * String(a).localeCompare(String(b));
48
+ }
49
+ /**
50
+ * Check if a value matches a search query (case-insensitive)
51
+ */
52
+ function matchesSearch(value, query) {
53
+ if (value === null || value === undefined)
54
+ return false;
55
+ return String(value).toLowerCase().includes(query.toLowerCase());
56
+ }
57
+ /**
58
+ * Check if a row matches a column filter
59
+ */
60
+ function matchesFilter(value, filterValue, filterDef) {
61
+ if (filterValue === null || filterValue === undefined || filterValue === '') {
62
+ return true; // No filter applied
63
+ }
64
+ switch (filterDef.type) {
65
+ case 'text':
66
+ return matchesSearch(value, String(filterValue));
67
+ case 'select':
68
+ return value === filterValue;
69
+ case 'boolean':
70
+ return value === filterValue;
71
+ case 'number-range': {
72
+ const numValue = typeof value === 'number' ? value : Number(value);
73
+ const range = filterValue;
74
+ if (isNaN(numValue))
75
+ return false;
76
+ if (range.min !== undefined && numValue < range.min)
77
+ return false;
78
+ if (range.max !== undefined && numValue > range.max)
79
+ return false;
80
+ return true;
81
+ }
82
+ default:
83
+ return true;
84
+ }
85
+ }
86
+ /**
87
+ * Debounce a function
88
+ */
89
+ function debounce(fn, delay) {
90
+ let timeoutId = null;
91
+ return (...args) => {
92
+ if (timeoutId)
93
+ clearTimeout(timeoutId);
94
+ timeoutId = setTimeout(() => fn(...args), delay);
95
+ };
96
+ }
97
+ // ─── createTable ───────────────────────────────────────────
98
+ export function createTable(options) {
99
+ const { data, columns: columnsInput, search: searchOptions, filters: filterDefs = {}, pagination: paginationOptions, selection: selectionOptions, columnToggle = false, onRowClick, onRowDoubleClick, rowClass, unstyled = false, classes = {}, } = options;
100
+ // Inject default styles if not unstyled
101
+ if (!unstyled) {
102
+ injectDefaultStyles();
103
+ }
104
+ // ─── Internal State (Signals) ────────────────────────────
105
+ // Columns can be static or reactive
106
+ const getColumns = typeof columnsInput === 'function'
107
+ ? columnsInput
108
+ : () => columnsInput;
109
+ // Column visibility map
110
+ const columnVisibility = signal({});
111
+ // Initialize column visibility from column definitions
112
+ const initVisibility = () => {
113
+ const cols = getColumns();
114
+ const visibility = {};
115
+ for (const col of cols) {
116
+ visibility[col.key] = col.visible !== false;
117
+ }
118
+ columnVisibility.set(visibility);
119
+ };
120
+ initVisibility();
121
+ // Sorting state
122
+ const sortingState = signal(null);
123
+ // Search query
124
+ const searchQueryState = signal('');
125
+ // Column filters state
126
+ const filtersState = signal({});
127
+ // Pagination state
128
+ const currentPage = signal(1);
129
+ const currentPageSize = signal(paginationOptions?.pageSize ?? 10);
130
+ // Selection state (Set of row references)
131
+ const selectedRows = signal(new Set());
132
+ // ─── Computed Data Pipeline ──────────────────────────────
133
+ // Step 1: Apply search and column filters
134
+ const filteredData = computed(() => {
135
+ let rows = data();
136
+ const query = searchQueryState();
137
+ const activeFilters = filtersState();
138
+ const cols = getColumns();
139
+ // Apply global search
140
+ if (searchOptions?.enabled && query.trim()) {
141
+ const searchCols = searchOptions.columns ?? cols.map(c => c.key);
142
+ rows = rows.filter(row => searchCols.some(colKey => matchesSearch(getNestedValue(row, colKey), query)));
143
+ }
144
+ // Apply column filters
145
+ for (const [key, filterValue] of Object.entries(activeFilters)) {
146
+ const filterDef = filterDefs[key];
147
+ if (!filterDef)
148
+ continue;
149
+ rows = rows.filter(row => {
150
+ const value = getNestedValue(row, key);
151
+ return matchesFilter(value, filterValue, filterDef);
152
+ });
153
+ }
154
+ return rows;
155
+ });
156
+ // Step 2: Apply sorting
157
+ const sortedData = computed(() => {
158
+ const rows = filteredData();
159
+ const sort = sortingState();
160
+ if (!sort)
161
+ return rows;
162
+ return [...rows].sort((a, b) => {
163
+ const aVal = getNestedValue(a, sort.key);
164
+ const bVal = getNestedValue(b, sort.key);
165
+ return compareValues(aVal, bVal, sort.direction);
166
+ });
167
+ });
168
+ // Step 3: Apply pagination
169
+ const paginatedData = computed(() => {
170
+ const rows = sortedData();
171
+ if (!paginationOptions)
172
+ return rows;
173
+ const page = currentPage();
174
+ const size = currentPageSize();
175
+ const start = (page - 1) * size;
176
+ return rows.slice(start, start + size);
177
+ });
178
+ // ─── Computed Metadata ───────────────────────────────────
179
+ const totalRowsComputed = computed(() => data().length);
180
+ const filteredRowsComputed = computed(() => filteredData().length);
181
+ const pageCountComputed = computed(() => {
182
+ if (!paginationOptions)
183
+ return 1;
184
+ const filtered = filteredRowsComputed();
185
+ const size = currentPageSize();
186
+ return Math.max(1, Math.ceil(filtered / size));
187
+ });
188
+ const visibleColumnsComputed = computed(() => {
189
+ const cols = getColumns();
190
+ const visibility = columnVisibility();
191
+ return cols.filter(c => visibility[c.key] !== false).map(c => c.key);
192
+ });
193
+ const selectedComputed = computed(() => Array.from(selectedRows()));
194
+ const selectedCountComputed = computed(() => selectedRows().size);
195
+ // ─── Reset page when filters/search change ───────────────
196
+ effect(() => {
197
+ // Subscribe to filter changes
198
+ searchQueryState();
199
+ filtersState();
200
+ // Reset to page 1
201
+ currentPage.set(1);
202
+ });
203
+ // ─── API Methods ─────────────────────────────────────────
204
+ // Sorting
205
+ const sort = (key, direction) => {
206
+ const current = sortingState();
207
+ if (direction) {
208
+ sortingState.set({ key, direction });
209
+ }
210
+ else if (!current || current.key !== key) {
211
+ sortingState.set({ key, direction: 'asc' });
212
+ }
213
+ else if (current.direction === 'asc') {
214
+ sortingState.set({ key, direction: 'desc' });
215
+ }
216
+ else {
217
+ sortingState.set(null);
218
+ }
219
+ };
220
+ const clearSort = () => sortingState.set(null);
221
+ // Search
222
+ const setSearch = (query) => searchQueryState.set(query);
223
+ // Filters
224
+ const setFilter = (key, value) => {
225
+ filtersState.update((f) => ({ ...f, [key]: value }));
226
+ };
227
+ const clearFilter = (key) => {
228
+ filtersState.update((f) => {
229
+ const next = { ...f };
230
+ delete next[key];
231
+ return next;
232
+ });
233
+ };
234
+ const clearAllFilters = () => {
235
+ filtersState.set({});
236
+ searchQueryState.set('');
237
+ };
238
+ // Pagination
239
+ const setPage = (page) => {
240
+ const max = pageCountComputed();
241
+ currentPage.set(Math.max(1, Math.min(page, max)));
242
+ };
243
+ const nextPage = () => setPage(currentPage() + 1);
244
+ const prevPage = () => setPage(currentPage() - 1);
245
+ const setPageSize = (size) => {
246
+ currentPageSize.set(size);
247
+ currentPage.set(1); // Reset to first page
248
+ };
249
+ // Selection
250
+ const isSelected = (row) => selectedRows().has(row);
251
+ const toggleRow = (row) => {
252
+ selectedRows.update((set) => {
253
+ const next = new Set(set);
254
+ if (selectionOptions?.mode === 'single') {
255
+ // Single mode: clear others, toggle this one
256
+ if (next.has(row)) {
257
+ next.clear();
258
+ }
259
+ else {
260
+ next.clear();
261
+ next.add(row);
262
+ }
263
+ }
264
+ else {
265
+ // Multi mode: toggle
266
+ if (next.has(row)) {
267
+ next.delete(row);
268
+ }
269
+ else {
270
+ next.add(row);
271
+ }
272
+ }
273
+ return next;
274
+ });
275
+ };
276
+ const selectAll = () => {
277
+ if (selectionOptions?.mode === 'single')
278
+ return;
279
+ selectedRows.set(new Set(paginatedData()));
280
+ };
281
+ const deselectAll = () => {
282
+ selectedRows.set(new Set());
283
+ };
284
+ // Column visibility
285
+ const showColumn = (key) => {
286
+ columnVisibility.update((v) => ({ ...v, [key]: true }));
287
+ };
288
+ const hideColumn = (key) => {
289
+ columnVisibility.update((v) => ({ ...v, [key]: false }));
290
+ };
291
+ const toggleColumn = (key) => {
292
+ columnVisibility.update((v) => ({ ...v, [key]: !v[key] }));
293
+ };
294
+ // ─── Root Component ──────────────────────────────────────
295
+ const Root = () => {
296
+ const container = document.createElement('div');
297
+ container.className = classes.root ?? 'lf-table';
298
+ // Search input
299
+ if (searchOptions?.enabled) {
300
+ const searchDiv = document.createElement('div');
301
+ searchDiv.className = classes.search ?? 'lf-table-search';
302
+ const searchInput = document.createElement('input');
303
+ searchInput.type = 'text';
304
+ searchInput.placeholder = searchOptions.placeholder ?? 'Search...';
305
+ searchInput.className = classes.searchInput ?? 'lf-table-search-input';
306
+ // Debounced search handler
307
+ const handleSearch = debounce((value) => {
308
+ setSearch(value);
309
+ }, 300);
310
+ searchInput.addEventListener('input', () => {
311
+ handleSearch(searchInput.value);
312
+ });
313
+ // Sync initial value
314
+ effect(() => {
315
+ const query = searchQueryState();
316
+ if (searchInput.value !== query) {
317
+ searchInput.value = query;
318
+ }
319
+ });
320
+ searchDiv.appendChild(searchInput);
321
+ container.appendChild(searchDiv);
322
+ }
323
+ // Column toggle dropdown
324
+ if (columnToggle) {
325
+ const toggleDiv = document.createElement('div');
326
+ toggleDiv.className = classes.columnToggle ?? 'lf-table-column-toggle';
327
+ const toggleBtn = document.createElement('button');
328
+ toggleBtn.textContent = 'Columns';
329
+ toggleBtn.className = 'lf-table-column-toggle-btn';
330
+ const dropdown = document.createElement('div');
331
+ dropdown.className = 'lf-table-column-toggle-dropdown';
332
+ dropdown.style.display = 'none';
333
+ toggleBtn.addEventListener('click', () => {
334
+ dropdown.style.display = dropdown.style.display === 'none' ? 'block' : 'none';
335
+ });
336
+ // Close on outside click
337
+ document.addEventListener('click', (e) => {
338
+ if (!toggleDiv.contains(e.target)) {
339
+ dropdown.style.display = 'none';
340
+ }
341
+ });
342
+ // Render column checkboxes
343
+ effect(() => {
344
+ const cols = getColumns();
345
+ const visibility = columnVisibility();
346
+ dropdown.innerHTML = '';
347
+ for (const col of cols) {
348
+ if (col.key.startsWith('_'))
349
+ continue; // Skip virtual columns
350
+ const label = document.createElement('label');
351
+ label.className = 'lf-table-column-toggle-item';
352
+ const checkbox = document.createElement('input');
353
+ checkbox.type = 'checkbox';
354
+ checkbox.checked = visibility[col.key] !== false;
355
+ checkbox.addEventListener('change', () => {
356
+ toggleColumn(col.key);
357
+ });
358
+ const text = document.createTextNode(col.header);
359
+ label.appendChild(checkbox);
360
+ label.appendChild(text);
361
+ dropdown.appendChild(label);
362
+ }
363
+ });
364
+ toggleDiv.appendChild(toggleBtn);
365
+ toggleDiv.appendChild(dropdown);
366
+ container.appendChild(toggleDiv);
367
+ }
368
+ // Column filters row (if any columns are filterable)
369
+ const filterableCols = getColumns().filter(c => c.filterable && filterDefs[c.key]);
370
+ if (filterableCols.length > 0) {
371
+ const filtersDiv = document.createElement('div');
372
+ filtersDiv.className = classes.filters ?? 'lf-table-filters';
373
+ for (const col of filterableCols) {
374
+ const filterDef = filterDefs[col.key];
375
+ if (!filterDef)
376
+ continue;
377
+ const filterWrapper = document.createElement('div');
378
+ filterWrapper.className = 'lf-table-filter-item';
379
+ const filterLabel = document.createElement('label');
380
+ filterLabel.textContent = col.header;
381
+ if (filterDef.type === 'text') {
382
+ const input = document.createElement('input');
383
+ input.type = 'text';
384
+ input.placeholder = `Filter ${col.header}...`;
385
+ const handleInput = debounce((value) => {
386
+ setFilter(col.key, value || undefined);
387
+ }, filterDef.debounce ?? 300);
388
+ input.addEventListener('input', () => handleInput(input.value));
389
+ filterWrapper.appendChild(filterLabel);
390
+ filterWrapper.appendChild(input);
391
+ }
392
+ else if (filterDef.type === 'select') {
393
+ const select = document.createElement('select');
394
+ // Generate options from data if not provided
395
+ effect(() => {
396
+ const opts = filterDef.options ?? [...new Set(data().map(row => String(getNestedValue(row, col.key) ?? '')))].filter(Boolean).sort();
397
+ select.innerHTML = '<option value="">All</option>';
398
+ for (const opt of opts) {
399
+ const option = document.createElement('option');
400
+ option.value = opt;
401
+ option.textContent = opt;
402
+ select.appendChild(option);
403
+ }
404
+ });
405
+ select.addEventListener('change', () => {
406
+ setFilter(col.key, select.value || undefined);
407
+ });
408
+ filterWrapper.appendChild(filterLabel);
409
+ filterWrapper.appendChild(select);
410
+ }
411
+ else if (filterDef.type === 'boolean') {
412
+ const select = document.createElement('select');
413
+ select.innerHTML = `
414
+ <option value="">All</option>
415
+ <option value="true">Yes</option>
416
+ <option value="false">No</option>
417
+ `;
418
+ select.addEventListener('change', () => {
419
+ if (select.value === '') {
420
+ clearFilter(col.key);
421
+ }
422
+ else {
423
+ setFilter(col.key, select.value === 'true');
424
+ }
425
+ });
426
+ filterWrapper.appendChild(filterLabel);
427
+ filterWrapper.appendChild(select);
428
+ }
429
+ filtersDiv.appendChild(filterWrapper);
430
+ }
431
+ container.appendChild(filtersDiv);
432
+ }
433
+ // Table container (for horizontal scroll)
434
+ const tableContainer = document.createElement('div');
435
+ tableContainer.className = 'lf-table-container';
436
+ // Table element
437
+ const table = document.createElement('table');
438
+ table.className = classes.table ?? 'lf-table-element';
439
+ // Thead
440
+ const thead = document.createElement('thead');
441
+ thead.className = classes.header ?? 'lf-table-header';
442
+ const headerRow = document.createElement('tr');
443
+ headerRow.className = classes.headerRow ?? 'lf-table-header-row';
444
+ // Selection header cell (checkbox for multi-select)
445
+ if (selectionOptions?.enabled && selectionOptions.mode === 'multi') {
446
+ const selectTh = document.createElement('th');
447
+ selectTh.className = 'lf-table-header-cell lf-table-header-cell--select';
448
+ const selectAll_checkbox = document.createElement('input');
449
+ selectAll_checkbox.type = 'checkbox';
450
+ selectAll_checkbox.title = 'Select all';
451
+ // Update checkbox state reactively
452
+ effect(() => {
453
+ const rows = paginatedData();
454
+ const selected = selectedRows();
455
+ const allSelected = rows.length > 0 && rows.every((r) => selected.has(r));
456
+ const someSelected = rows.some((r) => selected.has(r));
457
+ selectAll_checkbox.checked = allSelected;
458
+ selectAll_checkbox.indeterminate = someSelected && !allSelected;
459
+ });
460
+ selectAll_checkbox.addEventListener('change', () => {
461
+ if (selectAll_checkbox.checked) {
462
+ selectAll();
463
+ }
464
+ else {
465
+ deselectAll();
466
+ }
467
+ });
468
+ selectTh.appendChild(selectAll_checkbox);
469
+ headerRow.appendChild(selectTh);
470
+ }
471
+ else if (selectionOptions?.enabled && selectionOptions.mode === 'single') {
472
+ // Empty cell for single selection radio column
473
+ const selectTh = document.createElement('th');
474
+ selectTh.className = 'lf-table-header-cell lf-table-header-cell--select';
475
+ headerRow.appendChild(selectTh);
476
+ }
477
+ // Render header cells reactively
478
+ effect(() => {
479
+ // Remove old header cells (keep selection cell if present)
480
+ const selectionCellCount = selectionOptions?.enabled ? 1 : 0;
481
+ while (headerRow.children.length > selectionCellCount) {
482
+ headerRow.removeChild(headerRow.lastChild);
483
+ }
484
+ const cols = getColumns();
485
+ const visibility = columnVisibility();
486
+ const currentSort = sortingState();
487
+ for (const col of cols) {
488
+ if (visibility[col.key] === false)
489
+ continue;
490
+ const th = document.createElement('th');
491
+ let thClass = classes.headerCell ?? 'lf-table-header-cell';
492
+ if (col.sortable) {
493
+ thClass += ' lf-table-header-cell--sortable';
494
+ if (currentSort?.key === col.key) {
495
+ thClass += ` lf-table-header-cell--sorted-${currentSort.direction}`;
496
+ }
497
+ }
498
+ th.className = thClass;
499
+ if (col.width) {
500
+ th.style.width = typeof col.width === 'number' ? `${col.width}px` : col.width;
501
+ }
502
+ // Header content
503
+ if (col.headerCell) {
504
+ th.appendChild(col.headerCell());
505
+ }
506
+ else {
507
+ const headerText = document.createElement('span');
508
+ headerText.textContent = col.header;
509
+ th.appendChild(headerText);
510
+ // Sort icon
511
+ if (col.sortable) {
512
+ const sortIcon = document.createElement('span');
513
+ sortIcon.className = 'lf-table-sort-icon';
514
+ if (currentSort?.key === col.key) {
515
+ sortIcon.textContent = currentSort.direction === 'asc' ? ' ▲' : ' ▼';
516
+ }
517
+ else {
518
+ sortIcon.textContent = ' ⇅';
519
+ }
520
+ th.appendChild(sortIcon);
521
+ }
522
+ }
523
+ // Click to sort
524
+ if (col.sortable) {
525
+ th.style.cursor = 'pointer';
526
+ th.addEventListener('click', () => sort(col.key));
527
+ }
528
+ headerRow.appendChild(th);
529
+ }
530
+ });
531
+ thead.appendChild(headerRow);
532
+ table.appendChild(thead);
533
+ // Tbody
534
+ const tbody = document.createElement('tbody');
535
+ tbody.className = classes.body ?? 'lf-table-body';
536
+ // Render body rows reactively
537
+ effect(() => {
538
+ tbody.innerHTML = '';
539
+ const rows = paginatedData();
540
+ const cols = getColumns();
541
+ const visibility = columnVisibility();
542
+ const selected = selectedRows();
543
+ if (rows.length === 0) {
544
+ // Empty state
545
+ const emptyRow = document.createElement('tr');
546
+ const emptyCell = document.createElement('td');
547
+ emptyCell.className = classes.empty ?? 'lf-table-empty';
548
+ emptyCell.colSpan = cols.filter(c => visibility[c.key] !== false).length +
549
+ (selectionOptions?.enabled ? 1 : 0);
550
+ emptyCell.textContent = 'No data available';
551
+ emptyRow.appendChild(emptyCell);
552
+ tbody.appendChild(emptyRow);
553
+ return;
554
+ }
555
+ rows.forEach((row, index) => {
556
+ const tr = document.createElement('tr');
557
+ let rowClasses = classes.row ?? 'lf-table-row';
558
+ rowClasses += index % 2 === 0 ? ' lf-table-row--even' : ' lf-table-row--odd';
559
+ if (selected.has(row)) {
560
+ rowClasses += ` ${classes.rowSelected ?? 'lf-table-row--selected'}`;
561
+ }
562
+ if (rowClass) {
563
+ const customClass = rowClass(row);
564
+ if (customClass)
565
+ rowClasses += ` ${customClass}`;
566
+ }
567
+ tr.className = rowClasses;
568
+ // Row click handlers
569
+ if (onRowClick) {
570
+ tr.style.cursor = 'pointer';
571
+ tr.addEventListener('click', (e) => {
572
+ // Don't trigger on selection checkbox click
573
+ if (e.target.tagName === 'INPUT')
574
+ return;
575
+ onRowClick(row);
576
+ });
577
+ }
578
+ if (onRowDoubleClick) {
579
+ tr.addEventListener('dblclick', () => onRowDoubleClick(row));
580
+ }
581
+ // Selection cell
582
+ if (selectionOptions?.enabled) {
583
+ const selectTd = document.createElement('td');
584
+ selectTd.className = 'lf-table-cell lf-table-cell--select';
585
+ if (selectionOptions.mode === 'multi') {
586
+ const checkbox = document.createElement('input');
587
+ checkbox.type = 'checkbox';
588
+ checkbox.checked = selected.has(row);
589
+ checkbox.addEventListener('change', () => toggleRow(row));
590
+ selectTd.appendChild(checkbox);
591
+ }
592
+ else {
593
+ const radio = document.createElement('input');
594
+ radio.type = 'radio';
595
+ radio.name = 'lf-table-select';
596
+ radio.checked = selected.has(row);
597
+ radio.addEventListener('change', () => toggleRow(row));
598
+ selectTd.appendChild(radio);
599
+ }
600
+ tr.appendChild(selectTd);
601
+ }
602
+ // Data cells
603
+ for (const col of cols) {
604
+ if (visibility[col.key] === false)
605
+ continue;
606
+ const td = document.createElement('td');
607
+ td.className = classes.cell ?? 'lf-table-cell';
608
+ // Get value (undefined for virtual columns)
609
+ const value = col.key.startsWith('_')
610
+ ? undefined
611
+ : getNestedValue(row, col.key);
612
+ if (col.cell) {
613
+ // Custom cell renderer
614
+ const rendered = col.cell(value, row);
615
+ td.appendChild(rendered);
616
+ }
617
+ else {
618
+ // Default: text content
619
+ td.textContent = value === null || value === undefined
620
+ ? ''
621
+ : String(value);
622
+ }
623
+ tr.appendChild(td);
624
+ }
625
+ tbody.appendChild(tr);
626
+ });
627
+ });
628
+ table.appendChild(tbody);
629
+ tableContainer.appendChild(table);
630
+ container.appendChild(tableContainer);
631
+ // Pagination
632
+ if (paginationOptions) {
633
+ const paginationDiv = document.createElement('div');
634
+ paginationDiv.className = classes.pagination ?? 'lf-table-pagination';
635
+ // Info: "Showing 1-10 of 100"
636
+ const infoSpan = document.createElement('span');
637
+ infoSpan.className = classes.paginationInfo ?? 'lf-table-pagination-info';
638
+ effect(() => {
639
+ const page = currentPage();
640
+ const size = currentPageSize();
641
+ const total = filteredRowsComputed();
642
+ const start = (page - 1) * size + 1;
643
+ const end = Math.min(page * size, total);
644
+ if (total === 0) {
645
+ infoSpan.textContent = 'No results';
646
+ }
647
+ else {
648
+ infoSpan.textContent = `Showing ${start}-${end} of ${total}`;
649
+ }
650
+ });
651
+ paginationDiv.appendChild(infoSpan);
652
+ // Controls
653
+ const controlsDiv = document.createElement('div');
654
+ controlsDiv.className = classes.paginationControls ?? 'lf-table-pagination-controls';
655
+ const prevBtn = document.createElement('button');
656
+ prevBtn.textContent = '← Prev';
657
+ prevBtn.addEventListener('click', prevPage);
658
+ const pageInfo = document.createElement('span');
659
+ effect(() => {
660
+ pageInfo.textContent = `Page ${currentPage()} of ${pageCountComputed()}`;
661
+ });
662
+ const nextBtn = document.createElement('button');
663
+ nextBtn.textContent = 'Next →';
664
+ nextBtn.addEventListener('click', nextPage);
665
+ // Disable buttons at boundaries
666
+ effect(() => {
667
+ prevBtn.disabled = currentPage() <= 1;
668
+ nextBtn.disabled = currentPage() >= pageCountComputed();
669
+ });
670
+ controlsDiv.appendChild(prevBtn);
671
+ controlsDiv.appendChild(pageInfo);
672
+ controlsDiv.appendChild(nextBtn);
673
+ paginationDiv.appendChild(controlsDiv);
674
+ // Page size selector
675
+ if (paginationOptions.pageSizes && paginationOptions.pageSizes.length > 1) {
676
+ const sizeSelect = document.createElement('select');
677
+ sizeSelect.className = 'lf-table-pagination-sizes';
678
+ for (const size of paginationOptions.pageSizes) {
679
+ const option = document.createElement('option');
680
+ option.value = String(size);
681
+ option.textContent = `${size} / page`;
682
+ sizeSelect.appendChild(option);
683
+ }
684
+ effect(() => {
685
+ sizeSelect.value = String(currentPageSize());
686
+ });
687
+ sizeSelect.addEventListener('change', () => {
688
+ setPageSize(Number(sizeSelect.value));
689
+ });
690
+ paginationDiv.appendChild(sizeSelect);
691
+ }
692
+ container.appendChild(paginationDiv);
693
+ }
694
+ return container;
695
+ };
696
+ // ─── Return Table API ────────────────────────────────────
697
+ return {
698
+ Root,
699
+ // Sorting
700
+ sorting: () => sortingState(),
701
+ sort,
702
+ clearSort,
703
+ // Search
704
+ searchQuery: () => searchQueryState(),
705
+ setSearch,
706
+ // Filters
707
+ filters: () => filtersState(),
708
+ setFilter,
709
+ clearFilter,
710
+ clearAllFilters,
711
+ // Pagination
712
+ page: () => currentPage(),
713
+ pageSize: () => currentPageSize(),
714
+ pageCount: () => pageCountComputed(),
715
+ totalRows: () => totalRowsComputed(),
716
+ filteredRows: () => filteredRowsComputed(),
717
+ setPage,
718
+ nextPage,
719
+ prevPage,
720
+ setPageSize,
721
+ // Selection
722
+ selected: () => selectedComputed(),
723
+ selectedCount: () => selectedCountComputed(),
724
+ isSelected,
725
+ toggleRow,
726
+ selectAll,
727
+ deselectAll,
728
+ // Column visibility
729
+ visibleColumns: () => visibleColumnsComputed(),
730
+ showColumn,
731
+ hideColumn,
732
+ toggleColumn,
733
+ // Data access
734
+ rows: () => paginatedData(),
735
+ };
736
+ }
737
+ //# sourceMappingURL=table.js.map