@zakkster/lite-table 1.0.0 → 1.2.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/CHANGELOG.md +344 -0
- package/README.md +564 -19
- package/Table.d.ts +258 -1
- package/Table.js +1644 -116
- package/llms.txt +291 -6
- package/package.json +15 -9
package/README.md
CHANGED
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
[](https://bundlephobia.com/result?p=@zakkster/lite-table)
|
|
9
9
|
[](https://www.npmjs.com/package/@zakkster/lite-table)
|
|
10
10
|
[](https://www.npmjs.com/package/@zakkster/lite-table)
|
|
11
|
+
[](https://github.com/PeshoVurtoleta/lite-signal)
|
|
11
12
|

|
|
12
|
-

|
|
13
14
|
[](./LICENSE.txt)
|
|
14
15
|
|
|
15
16
|
```bash
|
|
@@ -48,6 +49,10 @@ Synchronous, virtual, allocation-free in the steady state. A 100,000-row scroll
|
|
|
48
49
|
- [Architecture in one diagram](#architecture-in-one-diagram)
|
|
49
50
|
- [How a scroll propagates](#how-a-scroll-propagates)
|
|
50
51
|
- [API reference](#api-reference)
|
|
52
|
+
- [Export](#export)
|
|
53
|
+
- [Cell editing](#cell-editing)
|
|
54
|
+
- [Per-column filtering](#per-column-filtering)
|
|
55
|
+
- [Grouping and aggregation](#grouping-and-aggregation)
|
|
51
56
|
- [Pinning and sticky offsets](#pinning-and-sticky-offsets)
|
|
52
57
|
- [Flex columns](#flex-columns)
|
|
53
58
|
- [Sorting](#sorting)
|
|
@@ -96,9 +101,9 @@ No microtask between `A` and `H`. No reconciliation. No diffing. Just version-st
|
|
|
96
101
|
|
|
97
102
|
- **`createTable(config)`** -- headless `TableCore`. Reactive columns, visible rows, sort, selection, focused cell. Pure data, no DOM.
|
|
98
103
|
- **`mountTable(host, table, options?)`** -- DOM mount. Builds a fixed slot pool, header bar, viewport, attaches all reactive bindings, returns `{ root, dispose }`.
|
|
99
|
-
- **`createTable` accepts** rows (array or getter), columns, `getRowId`, `rowHeight`, `overscan`, `initialFocus`, `initialSort`.
|
|
100
|
-
- **Reactive surface:** `visibleRows`, `rowCount`, `visibleColumns`, `displayIndexByKey`, `colTemplate`, `colPlacement`, `contentWidth`, `leftOffsets`, `rightOffsets`, `sortChain`, `focusedCell`, `selection`, `selectionAnchor`.
|
|
101
|
-
- **Methods:** `setSort`, `addSort`, `toggleSort`, `clearSort`, `setColumnWidth`, `setColumnHidden`, `setColumnPin`, `setColumnFlex`, `setColumnOrder`, `moveColumn`, `selectRow`, `selectRowRange`, `selectAll`, `clearSelection`, `isSelected`, `moveFocus`, `cellId`, `dispose`.
|
|
104
|
+
- **`createTable` accepts** rows (array or getter), columns, `getRowId`, `rowHeight`, `overscan`, `initialFocus`, `initialSort`, `onCellEdit`, `groupBy`, `initialCollapsedGroups`, `showGrandTotal`.
|
|
105
|
+
- **Reactive surface:** `visibleRows`, `rowCount`, `filteredRows`, `visibleColumns`, `displayIndexByKey`, `colTemplate`, `colPlacement`, `contentWidth`, `leftOffsets`, `rightOffsets`, `sortChain`, `columnFilters`, `focusedCell`, `selection`, `selectionAnchor`, `editingCell`, `editingDraft`, `groupBy`, `collapsedGroups`, `groupedRows`, `visibleEntries`, `entryCount`.
|
|
106
|
+
- **Methods:** `setSort`, `addSort`, `toggleSort`, `clearSort`, `setColumnWidth`, `setColumnHidden`, `setColumnPin`, `setColumnFlex`, `setColumnOrder`, `moveColumn`, `setColumnFilter`, `clearColumnFilters`, `startEdit`, `commitEdit`, `cancelEdit`, `isEditing`, `exportCsv`, `exportJson`, `setGroupBy`, `toggleGroup`, `expandGroup`, `collapseGroup`, `expandAllGroups`, `collapseAllGroups`, `isGroupCollapsed`, `groupAncestryAt`, `selectRow`, `selectRowRange`, `selectAll`, `clearSelection`, `isSelected`, `moveFocus`, `cellId`, `dispose`.
|
|
102
107
|
- **Per-column state:** every `ColumnState` exposes `width`, `hidden`, `pin`, `flex` as signals -- wire them directly to your own UI controls.
|
|
103
108
|
|
|
104
109
|
Full type definitions ship in [`Table.d.ts`](./Table.d.ts) and are referenced from `package.json`. Every public symbol has JSDoc.
|
|
@@ -242,10 +247,18 @@ const table = createTable({
|
|
|
242
247
|
rows: Row[] | (() => Row[]),
|
|
243
248
|
columns: ColumnDef[],
|
|
244
249
|
getRowId: (row: Row) => string | number,
|
|
245
|
-
rowHeight?: number,
|
|
246
|
-
overscan?: number,
|
|
250
|
+
rowHeight?: number, // default 32
|
|
251
|
+
overscan?: number, // default 4
|
|
247
252
|
initialFocus?: CellId | null,
|
|
248
|
-
initialSort?: SortEntry[]
|
|
253
|
+
initialSort?: SortEntry[],
|
|
254
|
+
|
|
255
|
+
// M2 -- editing
|
|
256
|
+
onCellEdit?: ({ row, columnKey, oldValue, newValue }) => void | Promise<any>,
|
|
257
|
+
|
|
258
|
+
// M3 -- grouping + aggregation
|
|
259
|
+
groupBy?: string | string[] | null, // default null (ungrouped fast path)
|
|
260
|
+
initialCollapsedGroups?: string[][], // e.g. [["Europe"], ["Asia","Books"]]
|
|
261
|
+
showGrandTotal?: boolean // default false
|
|
249
262
|
});
|
|
250
263
|
```
|
|
251
264
|
|
|
@@ -285,6 +298,17 @@ interface ColumnDef {
|
|
|
285
298
|
reorderable?: boolean; // default true
|
|
286
299
|
accessor?: (row) => any; // default row[key]
|
|
287
300
|
compare?: (a, b) => number; // default null-safe numeric/string
|
|
301
|
+
|
|
302
|
+
// M2
|
|
303
|
+
editable?: boolean; // opt-in for cell editing
|
|
304
|
+
filterable?: boolean; // opt-in for per-column filter
|
|
305
|
+
filter?: (value, query, row) => boolean; // custom filter predicate
|
|
306
|
+
filterPlaceholder?: string; // default "Filter…"
|
|
307
|
+
|
|
308
|
+
// M3
|
|
309
|
+
aggregate?: "sum" | "avg" | "min" | "max" | "count" // reducer for group + total cells
|
|
310
|
+
| ((rows, col) => any) | null;
|
|
311
|
+
aggregateFormat?: (value, col, count) => string; // display formatter (raw stays available)
|
|
288
312
|
}
|
|
289
313
|
```
|
|
290
314
|
|
|
@@ -320,8 +344,9 @@ These are live signals. `column.width.set(160)` updates the CSS template, the st
|
|
|
320
344
|
```ts
|
|
321
345
|
table.columns // ColumnState[]
|
|
322
346
|
table.rowsGetter() // current row source
|
|
323
|
-
table.visibleRows() // Computed<Row[]> -- sort applied
|
|
324
|
-
table.rowCount() // Computed<number>
|
|
347
|
+
table.visibleRows() // Computed<Row[]> -- sort applied, data-only
|
|
348
|
+
table.rowCount() // Computed<number> -- data-only
|
|
349
|
+
table.filteredRows() // Computed<Row[]> -- post-filter, pre-sort (M2)
|
|
325
350
|
table.columnOrder() // Signal<string[]>
|
|
326
351
|
table.visibleColumns() // Computed<ColumnState[]>
|
|
327
352
|
table.displayIndexByKey() // Computed<Map<key, 0-indexed display pos>>
|
|
@@ -331,9 +356,20 @@ table.contentWidth() // Computed<number> -- sum of widths
|
|
|
331
356
|
table.leftOffsets() // Computed<Map<key, cumulative left px>>
|
|
332
357
|
table.rightOffsets() // Computed<Map<key, cumulative right px>>
|
|
333
358
|
table.sortChain() // Signal<SortEntry[]>
|
|
359
|
+
table.columnFilters() // Signal<ReadonlyMap<key, query>> (M2)
|
|
334
360
|
table.focusedCell() // Signal<CellId | null>
|
|
335
|
-
table.selection() // Signal<
|
|
361
|
+
table.selection() // Signal<{mode, set}> -- predicate, not a list
|
|
336
362
|
table.selectionAnchor() // Signal<RowId | null>
|
|
363
|
+
table.selectedCount() // Computed<number> -- reactive O(1)
|
|
364
|
+
table.editingCell() // Signal<{rowId, columnKey} | null> (M2)
|
|
365
|
+
table.editingDraft() // Signal<string> (M2)
|
|
366
|
+
|
|
367
|
+
// M3: grouping + aggregation
|
|
368
|
+
table.groupBy() // Signal<string[]> -- current group keys ([] = ungrouped)
|
|
369
|
+
table.collapsedGroups() // Signal<Set<string>> -- path-strings (U+001F separator)
|
|
370
|
+
table.groupedRows() // Computed<GroupNode[] | null> -- null when ungrouped
|
|
371
|
+
table.visibleEntries() // Computed<Entry[]> -- data + group-headers + grand-total interleaved
|
|
372
|
+
table.entryCount() // Computed<number> -- length of visibleEntries; drives the axis
|
|
337
373
|
```
|
|
338
374
|
|
|
339
375
|
All `()` calls are tracked reads -- `effect(() => log(table.rowCount()))` will re-run whenever the row source changes.
|
|
@@ -366,12 +402,451 @@ table.isSelected(rowId)
|
|
|
366
402
|
table.moveFocus(direction) // "up" | "down" | "left" | "right" | "home" | "end" | "pageUp" | "pageDown"
|
|
367
403
|
table.cellId(rowId, columnKey) // stable string id (matches DOM)
|
|
368
404
|
|
|
405
|
+
// Filters (M2)
|
|
406
|
+
table.setColumnFilter(key, value) // null/""/whitespace clears that column
|
|
407
|
+
table.clearColumnFilters() // clear all
|
|
408
|
+
table.columnFilters() // ReadonlyMap<string, string>
|
|
409
|
+
table.filteredRows() // computed: rows post-filter, pre-sort
|
|
410
|
+
|
|
411
|
+
// Editing (M2)
|
|
412
|
+
table.startEdit(rowId, columnKey) // no-op on non-editable columns
|
|
413
|
+
table.commitEdit() // reads editingDraft
|
|
414
|
+
table.commitEdit(explicitValue) // commit a specific value
|
|
415
|
+
table.cancelEdit() // discard; no onCellEdit call
|
|
416
|
+
table.isEditing(rowId, columnKey) // O(1) predicate
|
|
417
|
+
table.editingCell() // { rowId, columnKey } | null
|
|
418
|
+
table.editingDraft() // current in-progress string
|
|
419
|
+
|
|
420
|
+
// Export (M1.1)
|
|
421
|
+
table.exportCsv({ rows?, columns?, delimiter?, quote?, headers?, newline?, bom?, formatter? }) // → string
|
|
422
|
+
table.exportJson({ rows?, columns?, indent?, format?, formatter? }) // → string | object[]
|
|
423
|
+
|
|
424
|
+
// Grouping + aggregation (M3)
|
|
425
|
+
table.setGroupBy(v) // string | string[] | null; unknown keys dropped
|
|
426
|
+
table.toggleGroup(path) // flip collapsed for one group path
|
|
427
|
+
table.expandGroup(path) // no-op if already expanded
|
|
428
|
+
table.collapseGroup(path) // no-op if already collapsed
|
|
429
|
+
table.expandAllGroups() // clears collapsed set
|
|
430
|
+
table.collapseAllGroups() // walks groupedRows() once
|
|
431
|
+
table.isGroupCollapsed(path) // O(1) predicate
|
|
432
|
+
table.groupAncestryAt(entryIndex) // GroupHeaderEntry[] -- for sticky headers
|
|
433
|
+
|
|
369
434
|
// Lifecycle
|
|
370
435
|
table.dispose()
|
|
371
436
|
```
|
|
372
437
|
|
|
373
438
|
---
|
|
374
439
|
|
|
440
|
+
## Export
|
|
441
|
+
|
|
442
|
+
`exportCsv` and `exportJson` materialize a row source into a string (or, for JSON, an array). Both methods take the same `rows` and `columns` selectors plus their own format-specific options.
|
|
443
|
+
|
|
444
|
+
### `rows` source
|
|
445
|
+
|
|
446
|
+
| Value | Meaning |
|
|
447
|
+
| ------------- | -------------------------------------------------------------------------------- |
|
|
448
|
+
| `"visible"` | The current `visibleRows()`. Honors sort + the row source (post-pagination view). **Default.** |
|
|
449
|
+
| `"all"` | `rowsGetter()` -- the raw source you gave to `createTable`. If you gave a function (paginated source), this is the current page, NOT the master. See pitfall below. |
|
|
450
|
+
| `"selected"` | The current selection materialized against `rowsGetter()`. Same caveat as `"all"`. |
|
|
451
|
+
| `Array` | An explicit row array (e.g. a master array held externally). |
|
|
452
|
+
|
|
453
|
+
**Paginated-getter pitfall**: if `createTable({ rows: () => allRows.slice(...) })` is a paginated function, `"all"` and `"selected"` resolve against that function -- which returns only the current page. To export beyond the page, pass the master array explicitly:
|
|
454
|
+
|
|
455
|
+
```js
|
|
456
|
+
table.exportCsv({ rows: allRows }); // entire master
|
|
457
|
+
table.exportCsv({ rows: table.selectedRows(allRows) }); // selected across master
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
### `columns` selector
|
|
461
|
+
|
|
462
|
+
| Value | Meaning |
|
|
463
|
+
| --------------- | ---------------------------------------------------------------- |
|
|
464
|
+
| `"visible"` | `visibleColumns()` -- honors hide state + current order. **Default.** |
|
|
465
|
+
| `"all"` | All declared columns in declaration order, including hidden. |
|
|
466
|
+
| `Array<string>` | Explicit projection by key. Order in the output matches array order. Unknown keys are silently dropped. |
|
|
467
|
+
|
|
468
|
+
### `exportCsv` options
|
|
469
|
+
|
|
470
|
+
| Option | Type | Default | Notes |
|
|
471
|
+
| ------------ | -------------------------- | --------- | ---------------------------------------------- |
|
|
472
|
+
| `delimiter` | `string` | `","` | `"\t"` for TSV, `";"` for European regional |
|
|
473
|
+
| `quote` | `string` | `'"'` | Per RFC 4180; embedded quotes are doubled |
|
|
474
|
+
| `headers` | `boolean` | `true` | Emit header row |
|
|
475
|
+
| `newline` | `string` | `"\r\n"` | RFC 4180 says CRLF; `"\n"` works for most consumers |
|
|
476
|
+
| `bom` | `boolean` | `false` | Prepend UTF-8 BOM for Excel-on-Windows |
|
|
477
|
+
| `formatter` | `(row, col) => unknown` | - | Per-cell formatter, runs before CSV escaping |
|
|
478
|
+
|
|
479
|
+
CSV escaping follows RFC 4180: a field is quoted if it contains the delimiter, the quote character, CR, or LF. Embedded quotes are doubled. The column's `accessor` (if any) is honored.
|
|
480
|
+
|
|
481
|
+
### `exportJson` options
|
|
482
|
+
|
|
483
|
+
| Option | Type | Default | Notes |
|
|
484
|
+
| ----------- | -------------------------- | ------------ | ---------------------------------------------------------------- |
|
|
485
|
+
| `indent` | `number` | `0` | `JSON.stringify` indent; 0 = single-line compact |
|
|
486
|
+
| `format` | `"string"` \| `"array"` | `"string"` | `"array"` skips JSON.stringify and returns the projected array |
|
|
487
|
+
| `formatter` | `(row, col) => unknown` | - | Per-cell formatter |
|
|
488
|
+
|
|
489
|
+
Fast path: `exportJson({ columns: "all", format: "array" })` with no formatter returns a shallow `rows.slice()` -- the row objects themselves, not copies. Use this when piping into IndexedDB / postMessage / structured clone.
|
|
490
|
+
|
|
491
|
+
### Triggering a browser download
|
|
492
|
+
|
|
493
|
+
The methods return strings; the consumer handles the download:
|
|
494
|
+
|
|
495
|
+
```js
|
|
496
|
+
function downloadFile(text, filename, mime) {
|
|
497
|
+
const blob = new Blob([text], { type: mime });
|
|
498
|
+
const url = URL.createObjectURL(blob);
|
|
499
|
+
const a = document.createElement("a");
|
|
500
|
+
a.href = url;
|
|
501
|
+
a.download = filename;
|
|
502
|
+
document.body.appendChild(a);
|
|
503
|
+
a.click();
|
|
504
|
+
document.body.removeChild(a);
|
|
505
|
+
URL.revokeObjectURL(url);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
downloadFile(table.exportCsv({ bom: true }), "data.csv", "text/csv;charset=utf-8");
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
The BOM (`bom: true`) is the difference between Excel opening your file as UTF-8 vs garbling non-ASCII characters on Windows.
|
|
512
|
+
|
|
513
|
+
---
|
|
514
|
+
|
|
515
|
+
## Cell editing
|
|
516
|
+
|
|
517
|
+
Opt-in per column via `editable: true`. When set, double-click (or `F2` / `Enter` on the focused cell) puts the cell into `contenteditable` mode with its current value pre-selected. Enter commits, Tab commits + moves to the next cell, Escape cancels.
|
|
518
|
+
|
|
519
|
+
```js
|
|
520
|
+
const table = createTable({
|
|
521
|
+
rows,
|
|
522
|
+
columns: [
|
|
523
|
+
{ key: "id", width: 60 },
|
|
524
|
+
{ key: "name", editable: true },
|
|
525
|
+
{ key: "email", editable: true },
|
|
526
|
+
{ key: "joined" }, // not editable -- no double-click affordance
|
|
527
|
+
],
|
|
528
|
+
getRowId: r => r.id,
|
|
529
|
+
onCellEdit: ({ row, columnKey, oldValue, newValue }) => {
|
|
530
|
+
// Mutate the row, send to backend, dispatch to a store, whatever.
|
|
531
|
+
// lite-table does NOT mutate the row for you.
|
|
532
|
+
row[columnKey] = newValue;
|
|
533
|
+
},
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
mountTable(host, table);
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
### Commit semantics
|
|
540
|
+
|
|
541
|
+
- **Enter** commits + moves focus to the row below (spreadsheet idiom)
|
|
542
|
+
- **Tab** / **Shift+Tab** commit + move focus right / left
|
|
543
|
+
- **Blur** (clicking outside, focusing another cell) commits
|
|
544
|
+
- **Escape** cancels: edit state cleared, no `onCellEdit` call
|
|
545
|
+
- **A second `startEdit` on a different cell** auto-commits the first
|
|
546
|
+
- **A `startEdit` on the same cell** is a no-op (does not re-seed the draft)
|
|
547
|
+
- `commitEdit` skips `onCellEdit` when the new value equals the old value (string-coerced: see below), so accidental Enter-without-typing is free even on numeric / typed columns
|
|
548
|
+
|
|
549
|
+
The `onCellEdit` handler is called with `{ row, columnKey, oldValue, newValue }`. The table does **not** mutate the row -- the handler is the consumer's hook to write somewhere (the row, a backend, a store). Throwing from the handler is caught + logged; subsequent edits work normally.
|
|
550
|
+
|
|
551
|
+
### `newValue` is always a string
|
|
552
|
+
|
|
553
|
+
When the edit comes from the DOM (double-click → contenteditable → Enter / Tab / blur), `newValue` is whatever the user typed: **always a string**. lite-table doesn't try to guess that "100" should be a number or "true" should be a boolean -- that's the consumer's call.
|
|
554
|
+
|
|
555
|
+
For non-string columns, coerce inside your handler:
|
|
556
|
+
|
|
557
|
+
```js
|
|
558
|
+
onCellEdit: ({ row, columnKey, newValue }) => {
|
|
559
|
+
if (columnKey === "count") row.count = Number(newValue);
|
|
560
|
+
else if (columnKey === "active") row.active = newValue === "true";
|
|
561
|
+
else if (columnKey === "due") row.due = new Date(newValue);
|
|
562
|
+
else row[columnKey] = newValue;
|
|
563
|
+
},
|
|
564
|
+
```
|
|
565
|
+
|
|
566
|
+
The **unchanged-guard** compares `String(oldValue) !== newValue` for string `newValue`s (the common case from the DOM), so pressing Enter on a numeric column without typing doesn't fire your handler -- `100` and `"100"` aren't strict-equal but they're "unchanged" from the user's perspective. When you call `commitEdit(explicitValue)` with a non-string explicit value, the guard falls back to strict equality so you have predictable control.
|
|
567
|
+
|
|
568
|
+
### Reactive surface
|
|
569
|
+
|
|
570
|
+
```js
|
|
571
|
+
table.editingCell() // { rowId, columnKey } | null
|
|
572
|
+
table.editingDraft() // current in-progress string
|
|
573
|
+
table.isEditing(rowId, columnKey) // O(1) predicate
|
|
574
|
+
|
|
575
|
+
table.startEdit(rowId, columnKey)
|
|
576
|
+
table.commitEdit() // reads editingDraft
|
|
577
|
+
table.commitEdit("explicit value")
|
|
578
|
+
table.cancelEdit()
|
|
579
|
+
```
|
|
580
|
+
|
|
581
|
+
### Programmatic editing
|
|
582
|
+
|
|
583
|
+
Skip the double-click affordance entirely if you want -- `startEdit` is the only entry point you need. Use it for "edit on selection", per-row action menus, or hotkey-triggered batch edits:
|
|
584
|
+
|
|
585
|
+
```js
|
|
586
|
+
// "Edit name on selected row" toolbar button:
|
|
587
|
+
editNameBtn.addEventListener("click", () => {
|
|
588
|
+
const f = table.focusedCell();
|
|
589
|
+
if (f) table.startEdit(f.rowId, "name");
|
|
590
|
+
});
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
### Editing + reactive row sources
|
|
594
|
+
|
|
595
|
+
If your `rows` is a function (paginated, filtered, etc.), the edited row may scroll out of view mid-edit. The edit state is keyed on `rowId`, so:
|
|
596
|
+
|
|
597
|
+
- The slot DOM gets recycled to show a different row; `contenteditable` is removed from the recycled cell automatically.
|
|
598
|
+
- The `editingCell` signal stays set, pointing at the row that's no longer visible.
|
|
599
|
+
- When that row scrolls back into view, the cell becomes `contenteditable` again with the draft preserved.
|
|
600
|
+
|
|
601
|
+
This is the spreadsheet idiom too -- you can scroll while typing without losing your input. To force a commit, call `table.commitEdit()` from your scroll handler if you want stricter semantics.
|
|
602
|
+
|
|
603
|
+
### Performance
|
|
604
|
+
|
|
605
|
+
Editable columns use one extra reactive effect per cell (the contenteditable management) and add two event listeners (input, keydown) plus two more on dblclick and blur. The text effect on editable cells skips its `textContent` write while the cell is the active edit target, so user keystrokes don't fight a reactive paint. Non-editable columns pay nothing -- the editing machinery is gated on `col.editable` and never attaches.
|
|
606
|
+
|
|
607
|
+
---
|
|
608
|
+
|
|
609
|
+
## Per-column filtering
|
|
610
|
+
|
|
611
|
+
Opt-in per column via `filterable: true`. A filter row appears between the header and the viewport, with one `<input>` per filterable column. The default predicate is case-insensitive substring match on the stringified cell value (after the column's `accessor` runs); pass a custom `filter` for richer semantics.
|
|
612
|
+
|
|
613
|
+
```js
|
|
614
|
+
const table = createTable({
|
|
615
|
+
rows,
|
|
616
|
+
columns: [
|
|
617
|
+
{ key: "id", width: 70 },
|
|
618
|
+
{ key: "name", filterable: true }, // default substring
|
|
619
|
+
{ key: "email", filterable: true, filterPlaceholder: "name@domain" },
|
|
620
|
+
{ key: "role", filterable: true, filterPlaceholder: "engineer / pm / …" },
|
|
621
|
+
{ key: "salary", filterable: true,
|
|
622
|
+
// ">N" / "<N" / "N" exact / substring fallback
|
|
623
|
+
filter: (v, q) => {
|
|
624
|
+
if (q.startsWith(">")) {
|
|
625
|
+
const n = Number(q.slice(1));
|
|
626
|
+
return Number.isFinite(n) && v > n;
|
|
627
|
+
}
|
|
628
|
+
if (q.startsWith("<")) {
|
|
629
|
+
const n = Number(q.slice(1));
|
|
630
|
+
return Number.isFinite(n) && v < n;
|
|
631
|
+
}
|
|
632
|
+
const n = Number(q);
|
|
633
|
+
if (Number.isFinite(n)) return v === n;
|
|
634
|
+
return String(v).indexOf(q) >= 0;
|
|
635
|
+
},
|
|
636
|
+
filterPlaceholder: ">100k" },
|
|
637
|
+
],
|
|
638
|
+
getRowId: r => r.id,
|
|
639
|
+
});
|
|
640
|
+
```
|
|
641
|
+
|
|
642
|
+
### Predicate contract
|
|
643
|
+
|
|
644
|
+
```ts
|
|
645
|
+
(value: unknown, query: string, row: Row) => boolean
|
|
646
|
+
```
|
|
647
|
+
|
|
648
|
+
- `value` is the column's value (post-`accessor`)
|
|
649
|
+
- `query` is the trimmed filter input. Empty / whitespace-only queries are treated as "no filter" and your predicate is not invoked for them
|
|
650
|
+
- `row` is the full row object -- handy for cross-field filters (e.g., "show rows where `firstName + lastName` matches")
|
|
651
|
+
|
|
652
|
+
Filters from multiple columns AND together. A row must pass every active filter to remain visible.
|
|
653
|
+
|
|
654
|
+
### Filter order in the pipeline
|
|
655
|
+
|
|
656
|
+
```
|
|
657
|
+
rowsGetter() → filteredRows → visibleRows → exports / mount / etc.
|
|
658
|
+
▲ ▲
|
|
659
|
+
│ └─ sort applied here
|
|
660
|
+
└─ filters applied here
|
|
661
|
+
```
|
|
662
|
+
|
|
663
|
+
This means **export of `rows: "visible"` is already filtered + sorted**, which is what you want for "export what the user sees". For "export the master regardless of filters/sort", use `rows: "all"` or pass the master array explicitly.
|
|
664
|
+
|
|
665
|
+
### Reactive surface
|
|
666
|
+
|
|
667
|
+
```js
|
|
668
|
+
table.columnFilters() // ReadonlyMap<string, string>
|
|
669
|
+
table.filteredRows() // computed: rows post-filter, pre-sort
|
|
670
|
+
table.setColumnFilter("role", "eng") // set
|
|
671
|
+
table.setColumnFilter("role", "") // clear that column
|
|
672
|
+
table.setColumnFilter("role", null) // also clears
|
|
673
|
+
table.clearColumnFilters() // clear all
|
|
674
|
+
```
|
|
675
|
+
|
|
676
|
+
The filter row's inputs are bound two-way to `columnFilters`. If you mutate the signal programmatically (e.g., to restore filter state from a URL), the inputs update automatically.
|
|
677
|
+
|
|
678
|
+
### Keyboard
|
|
679
|
+
|
|
680
|
+
- **Escape** on a filter input clears that column's filter (the input clears too)
|
|
681
|
+
- The filter row is part of the focusable tab order; Tab moves to the next filter input or to the next focusable element after the row
|
|
682
|
+
|
|
683
|
+
### Hiding the filter row
|
|
684
|
+
|
|
685
|
+
The filter row is only mounted if at least one declared column has `filterable: true`. To temporarily hide it without un-mounting, hide all filterable columns (`setColumnHidden`); their filter cells go to `display: none` with the rest of the column.
|
|
686
|
+
|
|
687
|
+
### Performance
|
|
688
|
+
|
|
689
|
+
Filtering is O(N) over the source rows on every filter change, executed inside a single computed. With 5000 rows + a handful of filterable columns, this runs in under a millisecond in our demo. The filtered array is a fresh allocation per change (Object.is inequality is required to notify the sort + selection downstream); for million-row data sources you typically want backend filtering anyway.
|
|
690
|
+
|
|
691
|
+
The fast path returns the source array identity when no filter is active, so just enabling `filterable: true` on columns costs nothing until the user types something.
|
|
692
|
+
|
|
693
|
+
---
|
|
694
|
+
|
|
695
|
+
## Grouping and aggregation
|
|
696
|
+
|
|
697
|
+
Group rows by one or more column keys and fold per-group aggregates. Group headers are pool slots like any other row -- same reactive rendering path, same zero-GC scroll math. There are no extra DOM primitives: a group header IS a `.lt-row` with a discriminator class.
|
|
698
|
+
|
|
699
|
+
```js
|
|
700
|
+
import { createTable, mountTable } from "@zakkster/lite-table";
|
|
701
|
+
|
|
702
|
+
const table = createTable({
|
|
703
|
+
rows,
|
|
704
|
+
columns: [
|
|
705
|
+
{ key: "id", width: 80 },
|
|
706
|
+
{ key: "region", width: 120 },
|
|
707
|
+
{ key: "status", width: 120 },
|
|
708
|
+
{ key: "value", width: 140, compare: (a, b) => a - b,
|
|
709
|
+
aggregate: "sum",
|
|
710
|
+
aggregateFormat: (v) => "$" + v.toFixed(0) }
|
|
711
|
+
],
|
|
712
|
+
getRowId: r => r.id,
|
|
713
|
+
groupBy: ["region", "status"], // multi-level
|
|
714
|
+
showGrandTotal: true // pinned "Total (N)" entry at tail
|
|
715
|
+
});
|
|
716
|
+
mountTable(host, table);
|
|
717
|
+
```
|
|
718
|
+
|
|
719
|
+
Clicking a group header row toggles its collapse; a chevron in the first cell flips between `▼` and `▶`. Aggregates render in the columns that declared an `aggregate` -- other columns stay blank on header rows.
|
|
720
|
+
|
|
721
|
+
### The pipeline
|
|
722
|
+
|
|
723
|
+
```
|
|
724
|
+
rowsGetter -> filteredRows -> groupedRows -> visibleEntries -> visibleRows
|
|
725
|
+
│ │
|
|
726
|
+
│ └── pool slots + sticky headers
|
|
727
|
+
└── sort applies WITHIN each leaf group
|
|
728
|
+
```
|
|
729
|
+
|
|
730
|
+
- Filter runs BEFORE grouping. Empty groups vanish -- no dead headers.
|
|
731
|
+
- Sort chain applies WITHIN leaves. Groups themselves are ordered by group key ascending; `null` values bucket last.
|
|
732
|
+
- Aggregates always fold over leaf rows at every depth. That's the safe default for reducers that don't compose (median, last-value-wins, `distinct-count`). Sum/avg/min/max/count don't care; you can supply a custom reducer if you have one that needs different semantics.
|
|
733
|
+
|
|
734
|
+
### Column config
|
|
735
|
+
|
|
736
|
+
| Field | Type | Purpose |
|
|
737
|
+
| ----------------- | ------------------------------------------ | --------------------------------------------------------------------------- |
|
|
738
|
+
| `aggregate` | `"sum" \| "avg" \| "min" \| "max" \| "count"` or `(rows, col) => any` | The reducer. Nullish values are skipped for `sum`/`avg`/`min`/`max`; `count` counts rows unconditionally. Custom function receives the leaf-row array. |
|
|
739
|
+
| `aggregateFormat` | `(value, col, count) => string` | Display formatter for header + grand-total cells. `entry.aggregates.get(key)` still returns the raw so exports stay authoritative. Errors caught + logged. |
|
|
740
|
+
|
|
741
|
+
### createTable config
|
|
742
|
+
|
|
743
|
+
| Field | Type | Default |
|
|
744
|
+
| ------------------------ | ----------------------------------- | ------- |
|
|
745
|
+
| `groupBy` | `string \| string[] \| null` | `null` |
|
|
746
|
+
| `initialCollapsedGroups` | `string[][]` | `[]` |
|
|
747
|
+
| `showGrandTotal` | `boolean` | `false` |
|
|
748
|
+
|
|
749
|
+
Unknown keys in `groupBy` are silently dropped, so persisted state that outlives a column config survives without crashes.
|
|
750
|
+
|
|
751
|
+
### Reactive surface
|
|
752
|
+
|
|
753
|
+
```js
|
|
754
|
+
table.groupBy() // Signal<string[]>
|
|
755
|
+
table.collapsedGroups() // Signal<Set<string>> — path strings
|
|
756
|
+
table.groupedRows() // Computed<GroupNode[] | null> — null when ungrouped
|
|
757
|
+
table.visibleEntries() // Computed<Entry[]> — interleaved for rendering
|
|
758
|
+
table.entryCount() // Computed<number> — drives the virtual axis
|
|
759
|
+
table.visibleRows() // Computed<Row[]> — data-only; unchanged 1.1.0 contract
|
|
760
|
+
table.rowCount() // Computed<number> — data-only; unchanged
|
|
761
|
+
```
|
|
762
|
+
|
|
763
|
+
An `Entry` is one of:
|
|
764
|
+
|
|
765
|
+
```
|
|
766
|
+
{ type: "data", row }
|
|
767
|
+
{ type: "group-header", depth, key, value, path, pathStr, count, aggregates, isCollapsed }
|
|
768
|
+
{ type: "grand-total", aggregates, count }
|
|
769
|
+
```
|
|
770
|
+
|
|
771
|
+
`path` is the array of ancestor group values (e.g., `["Europe", "Books"]`); `pathStr` is the U+001F-separated form used as the collapsed-set key. The mount layer dispatches per-slot rendering on `entry.type`; consumers driving their own renderer do the same.
|
|
772
|
+
|
|
773
|
+
### Methods
|
|
774
|
+
|
|
775
|
+
```js
|
|
776
|
+
table.setGroupBy(key | keys | null) // idempotent; unknown keys dropped
|
|
777
|
+
table.toggleGroup(path)
|
|
778
|
+
table.expandGroup(path)
|
|
779
|
+
table.collapseGroup(path)
|
|
780
|
+
table.expandAllGroups()
|
|
781
|
+
table.collapseAllGroups() // walks groupedRows() once
|
|
782
|
+
table.isGroupCollapsed(path) // O(1) predicate
|
|
783
|
+
table.groupAncestryAt(entryIndex) // for sticky group headers
|
|
784
|
+
```
|
|
785
|
+
|
|
786
|
+
### Ungrouped fast path
|
|
787
|
+
|
|
788
|
+
When `groupBy()` is empty, `groupedRows` short-circuits to `null` and `visibleRows` returns the sort-applied rows directly -- byte-identical to 1.1.0 behavior. Non-grouping tables pay one signal read and nothing else.
|
|
789
|
+
|
|
790
|
+
### Sticky group headers + grand total
|
|
791
|
+
|
|
792
|
+
Both are built in. Sticky group headers glue below the column header and reactively track the active ancestor group at every depth level as the user scrolls -- click one to collapse its subtree. Sticky grand total glues to the bottom of the viewport whenever `showGrandTotal: true`.
|
|
793
|
+
|
|
794
|
+
Under the hood, both are `position: sticky` zero-height containers with absolute-positioned rows -- they don't reserve scroll space and don't interfere with pool math. They render only when needed (no sticky container when ungrouped; no footer when grand total isn't configured). Nothing to opt into and nothing to opt out of.
|
|
795
|
+
|
|
796
|
+
The sticky ancestor lookup uses `axis.firstIndex()` (not `axis.start()`) so sticky matches what's under the column-header line -- `start` includes overscan slots above the viewport and would show ancestors of a not-yet-visible entry.
|
|
797
|
+
|
|
798
|
+
If you need a custom sticky design (e.g. a breadcrumb variant, a different offset for an app-level top bar), hide the built-ins with `display: none` on `.lt-sticky-groups` / `.lt-sticky-grand-total` and rebuild the same in ~20 lines using the exposed primitive:
|
|
799
|
+
|
|
800
|
+
```js
|
|
801
|
+
import { effect } from "@zakkster/lite-signal";
|
|
802
|
+
|
|
803
|
+
effect(() => {
|
|
804
|
+
const ancestors = table.groupAncestryAt(mount.axis.firstIndex());
|
|
805
|
+
// render `ancestors` however you like -- click each to toggleGroup(a.path)
|
|
806
|
+
});
|
|
807
|
+
```
|
|
808
|
+
|
|
809
|
+
### Rendered DOM
|
|
810
|
+
|
|
811
|
+
Group headers render as:
|
|
812
|
+
|
|
813
|
+
```html
|
|
814
|
+
<div class="lt-row lt-row-group-header" data-depth="N" data-collapsed="true|false">
|
|
815
|
+
<div class="lt-cell" data-key="…">▼ Asia (100)</div>
|
|
816
|
+
<div class="lt-cell" data-key="…"></div>
|
|
817
|
+
<div class="lt-cell" data-key="…">$5,092</div>
|
|
818
|
+
...
|
|
819
|
+
</div>
|
|
820
|
+
```
|
|
821
|
+
|
|
822
|
+
One cell per column matching the data-row grid template. The first visible cell holds the chevron + value + count; other cells hold their column's aggregate (or empty if the column has no aggregate). Aggregate cells right-align with tabular-numerics for column alignment across headers.
|
|
823
|
+
|
|
824
|
+
Grand-total rows use `<div class="lt-row lt-row-grand-total">` with the same cell structure; the first cell reads `Total (N)`.
|
|
825
|
+
|
|
826
|
+
Sticky group headers live in `<div class="lt-sticky-groups">` -- a direct child of `.lt-viewport`, before `.lt-inner`. Each depth's row also carries `.lt-sticky-group` so you can restyle them independently of pool-rendered headers. Sticky grand total lives in `<div class="lt-sticky-grand-total">` (after `.lt-inner`) with a `.lt-sticky-grand-total-row` inside. Sticky rows do NOT carry the base `.lt-row` class so `querySelectorAll(".lt-row")` still counts only pool slots.
|
|
827
|
+
|
|
828
|
+
### Interaction
|
|
829
|
+
|
|
830
|
+
- **Click on a group-header cell** (inline or sticky) toggles the group's collapse.
|
|
831
|
+
- **Click on a grand-total row** is ignored (no selection change, no side effect).
|
|
832
|
+
- **Selection / edit / focus** are gated on `entry.type === "data"`. Clicking a header doesn't clear the row selection, editable cells stay non-editing on headers, and arrow-key focus moves stay inside data rows.
|
|
833
|
+
|
|
834
|
+
### Performance notes
|
|
835
|
+
|
|
836
|
+
- Grouping is O(N) per rebuild (walk + bucket + per-leaf sort). Rebuilds fire on `filteredRows()`, `sortChain()`, or `groupBy()` changes -- never on scroll.
|
|
837
|
+
- Aggregates fold once per rebuild. For deep trees this dominates; a 100k-row / 3-level table with 4 aggregates folds in a few ms.
|
|
838
|
+
- Scroll cost stays at 1 transform write per pool slot per boundary cross, unchanged from 1.1.0. `axis.setCount(entryCount())` bumps when collapse changes -- boundary math re-derives on the next frame.
|
|
839
|
+
- Signal-node overhead: adding `aggregate` to a column costs one entry in a Map. Adding `groupBy` costs one extra computed per slot (`slotEntry`) beyond the 1.1.0 per-cell effects. For the recommended 6-col / 24-slot table, expect ~950 nodes with aggregates + grouping enabled -- still under the default 1024 registry cap. Bump the cap explicitly for larger footprints:
|
|
840
|
+
|
|
841
|
+
```js
|
|
842
|
+
import { createRegistry, setDefaultRegistry } from "@zakkster/lite-signal";
|
|
843
|
+
setDefaultRegistry(createRegistry({ initialNodes: 4096 }));
|
|
844
|
+
```
|
|
845
|
+
|
|
846
|
+
Verified zero-GC: 10,000 boundary scrolls across a 100,000-row grouped view with sticky overlays active produces signal-node delta 0, pool delta 0. See `bench/03-heap-grouped.js`.
|
|
847
|
+
|
|
848
|
+
---
|
|
849
|
+
|
|
375
850
|
## Pinning and sticky offsets
|
|
376
851
|
|
|
377
852
|
Pinned columns use `position: sticky` with cumulative offsets. The math is:
|
|
@@ -542,13 +1017,14 @@ setDefaultRegistry(createRegistry({
|
|
|
542
1017
|
|
|
543
1018
|
## Benchmarks
|
|
544
1019
|
|
|
545
|
-
|
|
1020
|
+
Five benchmarks, all in `bench/`:
|
|
546
1021
|
|
|
547
1022
|
| Bench | Measures |
|
|
548
1023
|
| ---------------------------------- | ----------------------------------------------------------- |
|
|
549
1024
|
| `01-scroll-writes.js` | DOM allocations vs in-place updates per scroll boundary cross, against clusterize.js + a naive virtual implementation |
|
|
550
1025
|
| `02-mount.js` | Time to first paint at 1k / 10k / 100k / 1M rows |
|
|
551
|
-
| `03-heap.js` | Steady-state heap delta + signal-node growth across 10k boundary scrolls |
|
|
1026
|
+
| `03-heap.js` | Steady-state heap delta + signal-node growth across 10k boundary scrolls (ungrouped) |
|
|
1027
|
+
| `03-heap-grouped.js` | Same shape as `03-heap.js` but with `groupBy: "status"` + `showGrandTotal: true` + sticky overlays active |
|
|
552
1028
|
| `04-sort.js` | 100k-row sort + cached re-read + toggle cycle |
|
|
553
1029
|
|
|
554
1030
|
Representative numbers on a 2016 MacBook (your hardware will be different; relative ordering is what matters):
|
|
@@ -594,7 +1070,7 @@ Two tiers, all reproducible.
|
|
|
594
1070
|
|
|
595
1071
|
### Tier 1 -- Behavior (unit tests, fast)
|
|
596
1072
|
|
|
597
|
-
`npm test` runs the suite in `test/`,
|
|
1073
|
+
`npm test` runs the suite in `test/`, **257 tests across 14 files**:
|
|
598
1074
|
|
|
599
1075
|
- **`core.test.js`** -- `createTable` API surface, reactive `visibleColumns`, `colTemplate`, `colPlacement` consistency under hide/pin/reorder, sort chain semantics, dispose idempotency.
|
|
600
1076
|
- **`dom.test.js`** -- `mountTable` produces correct DOM structure, header cells, slot pool sized to viewport, ARIA roles + indices, `aria-activedescendant` updates on focus moves, dispose tears down all bindings.
|
|
@@ -605,6 +1081,11 @@ Two tiers, all reproducible.
|
|
|
605
1081
|
- **`columns.test.js`** -- `setColumnWidth` clamping, `setColumnHidden`, `setColumnPin`, `setColumnFlex` and the **pinning-suspends-flex** invariant (regression for the offset-vs-rendered-width mismatch), `moveColumn` cases, `colTemplate` segment count consistency with `colPlacement`.
|
|
606
1082
|
- **`keyboard.test.js`** -- `moveFocus` arrow / Home / End / PageUp / PageDown, focus clamps at edges, focus survives row reorder.
|
|
607
1083
|
- **`extras.test.js`** -- `scrollToIndex` × 3 align modes, pointer-driven column resize + reorder, `injectStyles:false`, mount-disposes-table lifecycle, null / zero / string-ID cells, `addSort(null)` removal, `moveFocus` from null focus, 50-column stress, steady-state graph stability under 1000 sort flips + 1000 selection toggles + 500 resize ops.
|
|
1084
|
+
- **`export.test.js`** -- RFC-4180 escaping, all option combinations, edge cases including 10k-row dataset finishing < 500ms (M1.1).
|
|
1085
|
+
- **`scope-dispose.test.js`** -- the M1.1 dispose-leak fix, dispose idempotency, 50-cycle round trip.
|
|
1086
|
+
- **`m2.test.js`** -- filtering basics, case insensitivity, multi-column AND, custom predicates, predicate receiving the full row, filter+sort interaction, filter+export, null/undefined handling, edit state transitions, all startEdit/commitEdit/cancelEdit semantics, onCellEdit handler error containment, edit+filter interaction, edit+dispose, numeric column unchanged-guard.
|
|
1087
|
+
- **`grouping.test.js`** (new in 1.2.0) -- config parsing, tree structure (single- and multi-level), aggregate types with null handling and custom-function support, leaf-fold correctness, `visibleEntries` interleave, backwards compat with the ungrouped fast path, sort within groups, filter-then-group interaction, collapse/expand/all, grand-total stability across collapse, `groupAncestryAt`, reactive propagation via signal-backed rows, edge cases.
|
|
1088
|
+
- **`grouping.dom.test.js`** (new in 1.2.0) -- row classes, first-cell chevron+count, aggregate cell rendering, click-to-toggle, click preserves selection, grand-total class + content, chevron flip via `data-collapsed`, `entryCount` drives virtual axis, data-row selection interleaved with headers, collapse hides data rows immediately, dispose cleanup, sticky containers in the correct viewport slots, sticky hidden when ungrouped, sticky grand-total hidden when unconfigured, hiding the first column shifts chevron to the next, column reorder keeps sticky + pool aligned, filter-row + grouping coexist.
|
|
608
1089
|
|
|
609
1090
|
```bash
|
|
610
1091
|
npm test
|
|
@@ -612,19 +1093,20 @@ npm test
|
|
|
612
1093
|
|
|
613
1094
|
### Tier 2 -- Memory (zero-GC verification)
|
|
614
1095
|
|
|
615
|
-
The `03-heap.js` bench is the production-style memory invariant: 10,000 boundary scrolls, 100,000 rows, must show **zero signal-node growth, zero link growth, zero pool growth**, and a heap delta inside V8's noise floor.
|
|
1096
|
+
The `03-heap.js` bench is the production-style memory invariant: 10,000 boundary scrolls, 100,000 rows, must show **zero signal-node growth, zero link growth, zero pool growth**, and a heap delta inside V8's noise floor. `03-heap-grouped.js` is the same invariant for a grouped view with sticky overlays active.
|
|
616
1097
|
|
|
617
1098
|
```bash
|
|
618
1099
|
node --expose-gc bench/03-heap.js
|
|
1100
|
+
node --expose-gc bench/03-heap-grouped.js
|
|
619
1101
|
```
|
|
620
1102
|
|
|
621
|
-
If
|
|
1103
|
+
If either fails, something allocates in the hot scroll path and we want to find it before publish.
|
|
622
1104
|
|
|
623
1105
|
---
|
|
624
1106
|
|
|
625
1107
|
## What this is not
|
|
626
1108
|
|
|
627
|
-
- **A general-purpose grid component.** No
|
|
1109
|
+
- **A general-purpose grid component.** No in-place pagination. The headless core makes it buildable on top, but it's not in the box.
|
|
628
1110
|
- **A perfect fit for every workload.** For a 50-row, no-virtualization-needed table, the slot pool is over-engineering -- a plain `<table>` is simpler and just as fast. `lite-table` shines starting at ~1,000 rows or when the columns and rows are independently reactive.
|
|
629
1111
|
- **A renderer.** It owns the DOM topology and the bindings between reactive sources and DOM properties. It does not own your row data, your filtering pipeline, or your data fetching.
|
|
630
1112
|
- **A library for the server.** It works in Node under `happy-dom` for tests, but there's no SSR story. Use it on the client.
|
|
@@ -643,7 +1125,7 @@ Built on the `@zakkster/lite-*` zero-GC ESM family. All MIT.
|
|
|
643
1125
|
**Helpers**
|
|
644
1126
|
- [`@zakkster/lite-persist`](https://www.npmjs.com/package/@zakkster/lite-persist) -- drop-in localStorage / IndexedDB / file persistence for `Signal` and `Computed`. Use it for column-state, sort, and selection persistence across sessions.
|
|
645
1127
|
|
|
646
|
-
The package tree is intentionally small. `lite-table` itself is a single file ESM module (~
|
|
1128
|
+
The package tree is intentionally small. `lite-table` itself is a single file ESM module (~3100 lines including types-in-JSDoc and inline design commentary), zero runtime dependencies beyond the three substrate packages.
|
|
647
1129
|
|
|
648
1130
|
---
|
|
649
1131
|
|
|
@@ -725,6 +1207,68 @@ async function loadPage(n) {
|
|
|
725
1207
|
|
|
726
1208
|
</details>
|
|
727
1209
|
|
|
1210
|
+
<details>
|
|
1211
|
+
<summary>Client-side pagination via a reactive row source (M1.1 pattern).</summary>
|
|
1212
|
+
|
|
1213
|
+
The reactive `rows: () => ...` getter form makes pagination effectively free: the page index and page size are signals, and `visibleRows` derives from both. Changing either signal triggers `visibleRows` to recompute the slice -- the slot pool's text effects re-pull, and the DOM topology stays unchanged.
|
|
1214
|
+
|
|
1215
|
+
```js
|
|
1216
|
+
import { signal, computed } from "@zakkster/lite-signal";
|
|
1217
|
+
import { createTable, mountTable } from "@zakkster/lite-table";
|
|
1218
|
+
|
|
1219
|
+
const allRows = await fetchEverything(); // your master array
|
|
1220
|
+
|
|
1221
|
+
const pageSize = signal(25);
|
|
1222
|
+
const pageIndex = signal(0); // 0-based
|
|
1223
|
+
|
|
1224
|
+
const pageCount = computed(() => {
|
|
1225
|
+
const sz = pageSize();
|
|
1226
|
+
return sz === 0 ? 1 : Math.max(1, Math.ceil(allRows.length / sz));
|
|
1227
|
+
});
|
|
1228
|
+
|
|
1229
|
+
const table = createTable({
|
|
1230
|
+
rows: () => {
|
|
1231
|
+
const sz = pageSize();
|
|
1232
|
+
if (sz === 0) return allRows;
|
|
1233
|
+
const start = pageIndex() * sz;
|
|
1234
|
+
return allRows.slice(start, start + sz);
|
|
1235
|
+
},
|
|
1236
|
+
columns: COLS,
|
|
1237
|
+
getRowId: r => r.id,
|
|
1238
|
+
});
|
|
1239
|
+
|
|
1240
|
+
mountTable(host, table);
|
|
1241
|
+
|
|
1242
|
+
// Page-size dropdown
|
|
1243
|
+
pageSizeSelectEl.addEventListener("change", (e) => {
|
|
1244
|
+
pageSize.set(Number(e.target.value));
|
|
1245
|
+
pageIndex.set(0); // reset to first page when size changes
|
|
1246
|
+
});
|
|
1247
|
+
|
|
1248
|
+
// First / prev / next / last
|
|
1249
|
+
firstBtn.addEventListener("click", () => pageIndex.set(0));
|
|
1250
|
+
prevBtn.addEventListener("click", () => pageIndex.set(Math.max(0, pageIndex() - 1)));
|
|
1251
|
+
nextBtn.addEventListener("click", () => pageIndex.set(Math.min(pageCount() - 1, pageIndex() + 1)));
|
|
1252
|
+
lastBtn.addEventListener("click", () => pageIndex.set(pageCount() - 1));
|
|
1253
|
+
```
|
|
1254
|
+
|
|
1255
|
+
The slot pool isn't recreated when the page changes -- the same 24 row elements re-bind to the new slice. A `setPageSize(100)` after browsing to page 50 just sets two signals and the next paint is the new page.
|
|
1256
|
+
|
|
1257
|
+
**Exporting a paginated table**: because `rows: "all"` and `rows: "selected"` resolve against `rowsGetter()` (which IS your paginated function), those selectors give you the current page only. To export across the master, pass the master array explicitly:
|
|
1258
|
+
|
|
1259
|
+
```js
|
|
1260
|
+
// Just the current page (what the user sees):
|
|
1261
|
+
table.exportCsv(); // rows: "visible"
|
|
1262
|
+
|
|
1263
|
+
// All 5000 rows (the master):
|
|
1264
|
+
table.exportCsv({ rows: allRows });
|
|
1265
|
+
|
|
1266
|
+
// All rows the user picked (Select-All across all pages):
|
|
1267
|
+
table.exportCsv({ rows: table.selectedRows(allRows) });
|
|
1268
|
+
```
|
|
1269
|
+
|
|
1270
|
+
</details>
|
|
1271
|
+
|
|
728
1272
|
---
|
|
729
1273
|
|
|
730
1274
|
## FAQ
|
|
@@ -783,9 +1327,9 @@ Two patterns. Either render a checkbox character in the cell's `accessor` (`acce
|
|
|
783
1327
|
## npm scripts
|
|
784
1328
|
|
|
785
1329
|
```bash
|
|
786
|
-
npm test #
|
|
1330
|
+
npm test # 257 tests across 14 files (node:test, --expose-gc)
|
|
787
1331
|
npm run demo # zero-dep static server on http://localhost:8080
|
|
788
|
-
npm run bench # all
|
|
1332
|
+
npm run bench # all five benches sequentially (output: text or --md)
|
|
789
1333
|
```
|
|
790
1334
|
|
|
791
1335
|
To run a single bench, invoke it directly:
|
|
@@ -794,6 +1338,7 @@ To run a single bench, invoke it directly:
|
|
|
794
1338
|
node --expose-gc bench/01-scroll-writes.js
|
|
795
1339
|
node --expose-gc bench/02-mount.js
|
|
796
1340
|
node --expose-gc bench/03-heap.js
|
|
1341
|
+
node --expose-gc bench/03-heap-grouped.js
|
|
797
1342
|
node --expose-gc bench/04-sort.js
|
|
798
1343
|
```
|
|
799
1344
|
|