@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/LICENSE +21 -0
- package/README.md +318 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/styles.d.ts +16 -0
- package/dist/styles.d.ts.map +1 -0
- package/dist/styles.js +345 -0
- package/dist/styles.js.map +1 -0
- package/dist/table.d.ts +9 -0
- package/dist/table.d.ts.map +1 -0
- package/dist/table.js +737 -0
- package/dist/table.js.map +1 -0
- package/dist/types.d.ts +165 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -0
- package/package.json +61 -0
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
|