@zakkster/lite-table 1.0.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/README.md ADDED
@@ -0,0 +1,802 @@
1
+ # @zakkster/lite-table
2
+
3
+ > Zero-GC virtual data grid. Slot-recycled DOM, position-keyed reactivity, single-allocation sort. Built for 100k-row scrolls on the same 16ms frame budget that runs the rest of your UI.
4
+
5
+ [![npm version](https://img.shields.io/npm/v/@zakkster/lite-table.svg?style=for-the-badge&color=latest)](https://www.npmjs.com/package/@zakkster/lite-table)
6
+ [![sponsor](https://img.shields.io/badge/sponsor-PeshoVurtoleta-ea4aaa.svg?logo=github)](https://github.com/sponsors/PeshoVurtoleta)
7
+ ![Zero-GC](https://img.shields.io/badge/Zero--GC-Scroll-00C853?style=for-the-badge&logo=leaf&logoColor=white)
8
+ [![npm bundle size](https://img.shields.io/bundlephobia/minzip/@zakkster/lite-table?style=for-the-badge)](https://bundlephobia.com/result?p=@zakkster/lite-table)
9
+ [![npm downloads](https://img.shields.io/npm/dm/@zakkster/lite-table?style=for-the-badge&color=blue)](https://www.npmjs.com/package/@zakkster/lite-table)
10
+ [![npm total downloads](https://img.shields.io/npm/dt/@zakkster/lite-table?style=for-the-badge&color=blue)](https://www.npmjs.com/package/@zakkster/lite-table)
11
+ ![TypeScript](https://img.shields.io/badge/TypeScript-Types-informational)
12
+ ![Dependencies](https://img.shields.io/badge/dependencies-3-brightgreen)
13
+ [![license](https://img.shields.io/badge/license-MIT-blue?style=flat-square)](./LICENSE.txt)
14
+
15
+ ```bash
16
+ npm install @zakkster/lite-table \
17
+ @zakkster/lite-signal \
18
+ @zakkster/lite-virtual \
19
+ @zakkster/lite-signal-dom
20
+ ```
21
+
22
+ ```js
23
+ import { createTable, mountTable } from "@zakkster/lite-table";
24
+
25
+ const table = createTable({
26
+ rows: bigDataset, // any [] of objects, including 1M+
27
+ columns: [
28
+ { key: "id", width: 90 },
29
+ { key: "name", width: 220, flex: 1 },
30
+ { key: "email", width: 280, flex: 2 },
31
+ { key: "value", width: 120, compare: (a, b) => a - b }
32
+ ],
33
+ getRowId: (row) => row.id
34
+ });
35
+
36
+ mountTable(document.getElementById("host"), table);
37
+ ```
38
+
39
+ Synchronous, virtual, allocation-free in the steady state. A 100,000-row scroll touches **zero** new signal nodes, **zero** new DOM elements, and writes one `transform: translateY(...)` per visible row per boundary cross.
40
+
41
+ ---
42
+
43
+ ## Table of contents
44
+
45
+ - [Why this exists](#why-this-exists)
46
+ - [What you get](#what-you-get)
47
+ - [The case for slot recycling](#the-case-for-slot-recycling)
48
+ - [Architecture in one diagram](#architecture-in-one-diagram)
49
+ - [How a scroll propagates](#how-a-scroll-propagates)
50
+ - [API reference](#api-reference)
51
+ - [Pinning and sticky offsets](#pinning-and-sticky-offsets)
52
+ - [Flex columns](#flex-columns)
53
+ - [Sorting](#sorting)
54
+ - [Selection](#selection)
55
+ - [Keyboard navigation](#keyboard-navigation)
56
+ - [Capacity, growth, and the signal-registry ceiling](#capacity-growth-and-the-signal-registry-ceiling)
57
+ - [Benchmarks](#benchmarks)
58
+ - [Testing strategy](#testing-strategy)
59
+ - [What this is not](#what-this-is-not)
60
+ - [Ecosystem](#ecosystem)
61
+ - [Browser and runtime support](#browser-and-runtime-support)
62
+ - [Integration recipes](#integration-recipes)
63
+ - [FAQ](#faq)
64
+
65
+ ---
66
+
67
+ ## Why this exists
68
+
69
+ Data grids are the worst case for a UI framework. A million rows are common. Scrolling fires hundreds of events per second. Sorts produce N-row reorders. Pinned columns cross every layout pass. Almost every grid library on npm collapses on one of these.
70
+
71
+ `lite-table` was built under four constraints simultaneously:
72
+
73
+ 1. **No allocation while scrolling.** A 100k-row table at 120fps cannot allocate. Steady-state scrolling touches no heap.
74
+ 2. **Constant DOM topology.** The DOM tree is built once at mount and never modified again. No rows are added or removed during scroll, sort, filter, or column reorder. Cells are recycled in place.
75
+ 3. **Reactivity that doesn't fan out.** Every cell subscribes to its own row index and column key -- not the whole row, not the whole dataset. A single-cell update touches one effect.
76
+ 4. **Headless core + thin DOM mount.** The same `TableCore` runs in tests under `happy-dom` and in production under Chrome. The DOM layer is one file, optional, replaceable.
77
+
78
+ The result is a grid where the cost of *being a 1,000,000-row table* and the cost of *being a 1,000-row table* are within noise of each other.
79
+
80
+ ```mermaid
81
+ flowchart LR
82
+ A[scroll event] --> B[virtual axis<br/>recalc start index]
83
+ B --> C{boundary<br/>crossed?}
84
+ C -- no --> D[1 transform write<br/>per visible row, return]
85
+ C -- yes --> E[bump axis.start signal]
86
+ E --> F[per-slot effects re-pull<br/>compare slotIndex via Object.is]
87
+ F --> G[update text + aria-rowindex<br/>only on the slots that moved]
88
+ G --> H[zero allocations, zero<br/>DOM appendChild / removeChild]
89
+ ```
90
+
91
+ No microtask between `A` and `H`. No reconciliation. No diffing. Just version-stamped pulls through the reactive graph.
92
+
93
+ ---
94
+
95
+ ## What you get
96
+
97
+ - **`createTable(config)`** -- headless `TableCore`. Reactive columns, visible rows, sort, selection, focused cell. Pure data, no DOM.
98
+ - **`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`.
102
+ - **Per-column state:** every `ColumnState` exposes `width`, `hidden`, `pin`, `flex` as signals -- wire them directly to your own UI controls.
103
+
104
+ Full type definitions ship in [`Table.d.ts`](./Table.d.ts) and are referenced from `package.json`. Every public symbol has JSDoc.
105
+
106
+ ---
107
+
108
+ ## The case for slot recycling
109
+
110
+ <details>
111
+ <summary>Why a fixed DOM pool: the per-scroll allocation table, and why diffing loses here.</summary>
112
+
113
+ A naive virtual grid builds rows from a `visibleRowSlice` array. The grid library compares old slice vs new slice, calls `insertBefore` / `removeChild` for the difference. With overscan = 4 and `rowHeight = 32`, a moderately fast scroll touches ~10 rows per frame; each row is a freshly-allocated DOM tree of `1 + N` elements (row + cells). 600 DOM nodes per second isn't catastrophic in isolation, but combined with the cell-content allocations (text nodes, span wrappers), the garbage collector will pause inside your scroll handler.
114
+
115
+ `lite-table` solves this by **never adding or removing rows after mount**. The viewport contains a fixed pool of `ceil(viewportHeight / rowHeight) + overscan * 2 + 1` row elements. Each pool slot has a stable index from `0` to `pool.length - 1`. The slot's `position: absolute; transform: translateY(rowIndex * rowHeight)` does all the visual work.
116
+
117
+ | Scroll event | DOM allocations | DOM updates | Notes |
118
+ | ------------------------ | --------------- | ------------------------ | -------------------------------------------------------- |
119
+ | Sub-row scroll (< 32px) | **0** | **0** | Boundary not crossed, no slot moves |
120
+ | Boundary cross (1 row) | **0** | **N transforms** | One transform per pool slot |
121
+ | Boundary cross (10 rows) | **0** | **N transforms + <=NxC text writes** | Only the slots whose `slotIndex` changed re-pull text |
122
+ | Sort | **0** | **NxC text writes** | All visible cells re-read from the resorted index buffer |
123
+ | Column resize | **0** | **1 style write** | Updates `--lt-cols` on root; CSS handles the rest |
124
+ | Column reorder | **0** | **N style writes** | `gridColumn` per visible cell; no nodes touched |
125
+
126
+ The pool is sized to the *viewport*, not the dataset. Switching from a 1,000-row table to a 1,000,000-row table changes the pool size by zero. Memory footprint is `O(viewport)`, not `O(rows)`.
127
+
128
+ </details>
129
+
130
+ ---
131
+
132
+ ## Architecture in one diagram
133
+
134
+ <details>
135
+ <summary>The split between core and mount, the slot pool, and where reactivity flows.</summary>
136
+
137
+ ```mermaid
138
+ flowchart TB
139
+ subgraph Core[TableCore - headless, no DOM]
140
+ Cols[columns: ColumnState array<br/>each with width, hidden, pin, flex signals]
141
+ Order[columnOrder signal]
142
+ VC[visibleColumns computed]
143
+ CL[colLayout computed<br/>template + placement, one loop]
144
+ LR[leftOffsets / rightOffsets computeds]
145
+ Sort[sortChain signal]
146
+ Idx[indexBuffer: Uint32Array]
147
+ VR[visibleRows computed]
148
+ Sel[selection signal]
149
+ Foc[focusedCell signal]
150
+ end
151
+
152
+ subgraph Mount[mountTable - DOM layer]
153
+ Root[lt-root]
154
+ Header[lt-header<br/>display: grid]
155
+ HC[N header cells<br/>stable DOM]
156
+ VP[lt-viewport<br/>overflow: auto]
157
+ Inner[lt-inner<br/>position: relative<br/>width: max-content; min-width: 100%]
158
+ Pool[Slot pool<br/>position: absolute<br/>transform: translateY]
159
+ Axis[virtualAxis<br/>start, end, viewportHeight]
160
+ end
161
+
162
+ Cols --> VC
163
+ Order --> VC
164
+ VC --> CL
165
+ VC --> LR
166
+ Idx --> VR
167
+ Sort --> VR
168
+
169
+ CL -.->|reactive --lt-cols write| Root
170
+ CL -.->|reactive grid-column writes| HC
171
+ CL -.->|reactive grid-column writes| Pool
172
+ LR -.->|reactive left/right writes| HC
173
+ LR -.->|reactive left/right writes| Pool
174
+ VR -.->|reactive textContent writes| Pool
175
+ Sel -.->|reactive is-selected class| Pool
176
+ Foc -.->|reactive aria-activedescendant| Root
177
+
178
+ Axis -->|drives slotIndex computed| Pool
179
+ VP -->|scrollTop -> axis| Axis
180
+ ```
181
+
182
+ Every binding from `Core` to `Mount` is one effect per (slot, cell) pair, created at mount and never re-created. There are no diffing passes. There is no reconciler. A signal write propagates through the reactive graph and lands on `setAttribute`, `style.transform`, or `textContent` -- nothing else.
183
+
184
+ </details>
185
+
186
+ ---
187
+
188
+ ## How a scroll propagates
189
+
190
+ <details>
191
+ <summary>The scroll right axis right slotIndex right cell pull sequence, and why Object.is on the slot index is the whole optimization.</summary>
192
+
193
+ ```mermaid
194
+ sequenceDiagram
195
+ participant U as User scroll
196
+ participant VP as lt-viewport
197
+ participant Ax as virtualAxis
198
+ participant Slot as Slot computed<br/>(axis.start + poolIdx)
199
+ participant Cell as Cell effects
200
+ participant DOM as DOM writes
201
+
202
+ U->>VP: scroll event
203
+ VP->>Ax: setScrollTop(scrollY)
204
+ Ax->>Ax: floor(scrollY / rowHeight)<br/>compute new start
205
+ Ax->>Ax: emit only if start changed<br/>(equals = Object.is)
206
+ Note over Ax: sub-row scroll: no emit, return
207
+ Ax->>Slot: dependency notification<br/>(only for slots whose index changed)
208
+ Slot->>Cell: per-cell pull: textContent fn<br/>reads visibleRows()[slotIndex]
209
+ Cell->>DOM: textContent = "..."<br/>setAttribute("id", cellId)
210
+ Note over DOM: 1 transform per slot,<br/>K texts where K = (rows scrolled) x visibleCols
211
+ ```
212
+
213
+ The trick is **`Object.is` on the slot's truncated row index**. Each slot is a computed that returns `axis.start() + poolIdx`. When the user scrolls 12 pixels and `rowHeight` is 32, `axis.start()` doesn't change -- `Object.is` short-circuits the computed propagation and zero cells re-pull. When the user scrolls 32 pixels (one row), only the *one* slot whose index now points to a different row re-pulls its text.
214
+
215
+ This is also why selection updates are free: clicking a row sets `selection` (a `Set`), the per-row `bindClass` effect re-runs, the row's `is-selected` class flips. No siblings touched, no parents touched, no scroll reflow.
216
+
217
+ The pull is **fully synchronous**, inherited from `@zakkster/lite-signal`. No microtask, no `requestAnimationFrame`, no scheduler queue.
218
+
219
+ </details>
220
+
221
+ ---
222
+
223
+ ## API reference
224
+
225
+ ### Top-level
226
+
227
+ ```ts
228
+ import {
229
+ createTable, mountTable,
230
+ // types only:
231
+ type TableCore, type TableMount,
232
+ type ColumnDef, type ColumnState,
233
+ type PinSide, type SortEntry,
234
+ type CellId, type SelectionMode
235
+ } from "@zakkster/lite-table";
236
+ ```
237
+
238
+ ### `createTable(config)` right `TableCore`
239
+
240
+ ```ts
241
+ const table = createTable({
242
+ rows: Row[] | (() => Row[]),
243
+ columns: ColumnDef[],
244
+ getRowId: (row: Row) => string | number,
245
+ rowHeight?: number, // default 32
246
+ overscan?: number, // default 4
247
+ initialFocus?: CellId | null,
248
+ initialSort?: SortEntry[]
249
+ });
250
+ ```
251
+
252
+ Builds a headless reactive grid. No DOM is touched. Suitable for tests, server-side row-count derivation, or driving a non-DOM renderer (canvas, WebGL).
253
+
254
+ `rows` can be a plain array or a zero-arg getter; the getter form makes the row source reactive (e.g. `() => filtered()` where `filtered` is a computed).
255
+
256
+ ### `mountTable(host, table, options?)` right `TableMount`
257
+
258
+ ```ts
259
+ const mount = mountTable(host, table, {
260
+ initialViewportHeight?: number // default 480, used until ResizeObserver fires
261
+ });
262
+
263
+ // mount.root -> HTMLDivElement (the lt-root)
264
+ // mount.dispose() -> tear down all bindings; rebuild-safe
265
+ ```
266
+
267
+ Builds the DOM, creates the fixed slot pool, attaches every reactive binding. Re-mounts are safe: `dispose()` returns every signal node and DOM element to a clean state.
268
+
269
+ ### `ColumnDef`
270
+
271
+ ```ts
272
+ interface ColumnDef {
273
+ key: string; // unique within columns
274
+ header?: string; // display label (default: key)
275
+ width?: number; // default 120
276
+ minWidth?: number; // resize floor (default 40)
277
+ maxWidth?: number; // resize ceiling (default 1600)
278
+ hidden?: boolean; // initially hidden
279
+ pin?: "left" | "none" | "right"; // initial pin side
280
+ flex?: number; // 0 = fixed, >0 = share leftover space (see Flex columns)
281
+ sortable?: boolean; // default true
282
+ resizable?: boolean; // default true
283
+ pinnable?: boolean; // default true
284
+ hideable?: boolean; // default true
285
+ reorderable?: boolean; // default true
286
+ accessor?: (row) => any; // default row[key]
287
+ compare?: (a, b) => number; // default null-safe numeric/string
288
+ }
289
+ ```
290
+
291
+ ### `ColumnState`
292
+
293
+ ```ts
294
+ interface ColumnState {
295
+ // static
296
+ readonly key: string;
297
+ readonly header: string;
298
+ readonly minWidth: number;
299
+ readonly maxWidth: number;
300
+ readonly sortable: boolean;
301
+ readonly resizable: boolean;
302
+ readonly pinnable: boolean;
303
+ readonly hideable: boolean;
304
+ readonly reorderable: boolean;
305
+ readonly accessor: ((row) => any) | null;
306
+ readonly compare: (a, b) => number;
307
+
308
+ // reactive
309
+ readonly width: Signal<number>;
310
+ readonly hidden: Signal<boolean>;
311
+ readonly pin: Signal<"left" | "none" | "right">;
312
+ readonly flex: Signal<number>;
313
+ }
314
+ ```
315
+
316
+ These are live signals. `column.width.set(160)` updates the CSS template, the sticky offset map, and every cell in the column in one synchronous pass.
317
+
318
+ ### Reactive surface on `TableCore`
319
+
320
+ ```ts
321
+ table.columns // ColumnState[]
322
+ table.rowsGetter() // current row source
323
+ table.visibleRows() // Computed<Row[]> -- sort applied
324
+ table.rowCount() // Computed<number>
325
+ table.columnOrder() // Signal<string[]>
326
+ table.visibleColumns() // Computed<ColumnState[]>
327
+ table.displayIndexByKey() // Computed<Map<key, 0-indexed display pos>>
328
+ table.colTemplate() // Computed<string> -- grid-template-columns
329
+ table.colPlacement() // Computed<Map<key, 1-indexed grid-column>>
330
+ table.contentWidth() // Computed<number> -- sum of widths
331
+ table.leftOffsets() // Computed<Map<key, cumulative left px>>
332
+ table.rightOffsets() // Computed<Map<key, cumulative right px>>
333
+ table.sortChain() // Signal<SortEntry[]>
334
+ table.focusedCell() // Signal<CellId | null>
335
+ table.selection() // Signal<Set<RowId>>
336
+ table.selectionAnchor() // Signal<RowId | null>
337
+ ```
338
+
339
+ All `()` calls are tracked reads -- `effect(() => log(table.rowCount()))` will re-run whenever the row source changes.
340
+
341
+ ### Methods
342
+
343
+ ```ts
344
+ // Sort
345
+ table.setSort(entries) // replace chain
346
+ table.addSort(key, dir?) // append (or rotate existing entry)
347
+ table.toggleSort(key, { additive? }) // click handler with shift-chain
348
+ table.clearSort()
349
+
350
+ // Columns
351
+ table.setColumnWidth(key, w) // clamps to min/max
352
+ table.setColumnHidden(key, hidden)
353
+ table.setColumnPin(key, side)
354
+ table.setColumnFlex(key, flex) // 0 = fixed, >0 = share leftover
355
+ table.setColumnOrder(keys[]) // rejects non-permutations
356
+ table.moveColumn(fromKey, toKey, { before? })
357
+
358
+ // Selection
359
+ table.selectRow(rowId, mode) // "single" | "toggle" | "additive"
360
+ table.selectRowRange(fromRowId, toRowId)
361
+ table.selectAll()
362
+ table.clearSelection()
363
+ table.isSelected(rowId)
364
+
365
+ // Focus
366
+ table.moveFocus(direction) // "up" | "down" | "left" | "right" | "home" | "end" | "pageUp" | "pageDown"
367
+ table.cellId(rowId, columnKey) // stable string id (matches DOM)
368
+
369
+ // Lifecycle
370
+ table.dispose()
371
+ ```
372
+
373
+ ---
374
+
375
+ ## Pinning and sticky offsets
376
+
377
+ Pinned columns use `position: sticky` with cumulative offsets. The math is:
378
+
379
+ - `leftOffsets[key] = sum of width() of left-pinned columns BEFORE this one`
380
+ - `rightOffsets[key] = sum of width() of right-pinned columns AFTER this one`
381
+
382
+ Both are reactive computeds. Resizing a left-pinned column updates the left offsets of every left-pinned column to its right, in one synchronous propagation.
383
+
384
+ ```mermaid
385
+ flowchart LR
386
+ subgraph Container[scrollable container]
387
+ direction LR
388
+ L1["left:0<br/>id (90px)"]
389
+ L2["left:90<br/>name (180px)"]
390
+ Mid["...unpinned columns,<br/>scroll horizontally"]
391
+ R2["right:110<br/>value (100px)"]
392
+ R1["right:0<br/>status (110px)"]
393
+ end
394
+ ```
395
+
396
+ > **Pinning suspends flex.** If a column has `flex > 0` and is then pinned, the column's rendered width must equal `c.width()` because `leftOffsets` / `rightOffsets` are pre-computed cumulative sums of `c.width()`. Letting a pinned column take an fr-distributed track would make the offset arithmetic wrong (a "180px" name pinned right with `right: 450` would render as ~357px in a wide viewport, sliding visually over the adjacent right-pinned cell). The library silently treats `flex` as `0` for pinned columns; unpinning restores it. This is in [`test/columns.test.js`](./test/columns.test.js).
397
+
398
+ ---
399
+
400
+ ## Flex columns
401
+
402
+ By default a column's `width` is exact -- what you set is what you get, and a trailing `1fr` filler absorbs leftover horizontal space. Opt in to space-sharing per column with `flex`:
403
+
404
+ ```js
405
+ columns: [
406
+ { key: "id", width: 90 }, // exact 90px
407
+ { key: "name", width: 180, minWidth: 120, flex: 1 },// grows
408
+ { key: "email", width: 240, minWidth: 180, flex: 2 },// grows 2x as much
409
+ { key: "value", width: 100 } // exact 100px
410
+ ]
411
+ ```
412
+
413
+ Unpinned flex columns render as `minmax(<minWidth>px, <flex>fr)` in the grid template. When any column has `flex > 0` and is unpinned, the trailing `1fr` is dropped -- flex columns absorb the leftover space themselves.
414
+
415
+ `flex` is a signal on `ColumnState`, mutable via `table.setColumnFlex(key, n)`. The same column-resize handles still work; they update `width()`, which becomes the floor for the flex distribution.
416
+
417
+ The scroll surface uses `width: max-content; min-width: 100%` on both header and inner. This means the grid container is sized by the natural width of its tracks and stretches to the viewport when the viewport is wider so flex columns can absorb the space. Earlier prototypes forced a calculated `min-width: <px>` from JavaScript; that disagreed with what the grid template actually distributed when flex columns had a `minWidth` floor above their share of `fr`, and could push cells into a second implicit row.
418
+
419
+ ---
420
+
421
+ ## Sorting
422
+
423
+ ```ts
424
+ table.toggleSort("name"); // single-key cycle: none -> asc -> desc -> none
425
+ table.toggleSort("status", { additive: true }); // shift-click: append to chain
426
+
427
+ table.sortChain();
428
+ // -> [{ key: "name", dir: "asc" }, { key: "status", dir: "desc" }]
429
+ ```
430
+
431
+ The sort engine is a **reusable `Uint32Array`** of row indices, in-place sorted by `TypedArray.prototype.sort`. One typed array allocated at construction, never re-allocated, never grown. A 100,000-row sort with a 2-key chain is approximately:
432
+
433
+ | Step | Allocations | Notes |
434
+ | --------------------------- | ----------- | ---------------------------------------------------- |
435
+ | Fill index buffer | **0** | `for (i) buf[i] = i;` -- typed-array write |
436
+ | `buf.sort(cmp)` | **0** | In-place; V8/SpiderMonkey sort is stable per spec |
437
+ | Build output `visibleRows` | **1** | One regular `Array` -- the typed result |
438
+ | Cache hit | **0** | Re-reading `visibleRows()` with no input changes returns the cached array |
439
+
440
+ The earlier decorate-sort-undecorate pattern (build N tuple arrays, sort them, extract the rows) was abandoned because V8's sort is natively stable; the tie-breaker on original index in the comparator preserves multi-key stability regardless of engine.
441
+
442
+ ---
443
+
444
+ ## Selection
445
+
446
+ Selection is a **predicate**, not a list of IDs. Two modes:
447
+
448
+ ```ts
449
+ selection: Signal<{ mode: "whitelist" | "all", set: Set<RowId> }>
450
+ ```
451
+
452
+ - `mode: "whitelist"` -- `set` contains the selected IDs (the classic case).
453
+ - `mode: "all"` -- `set` is a blacklist. Every row is selected EXCEPT those in `set`.
454
+
455
+ This makes Ctrl+A across 1M rows an O(1) operation -- no Set construction, no walk of the row source, no per-ID allocation. The predicate `isSelected(rowId)` transparently handles both modes.
456
+
457
+ ```ts
458
+ table.selectRow(rowId, "set"); // single-click; collapses to a 1-row whitelist
459
+ table.selectRow(rowId, "toggle"); // ctrl/cmd-click; flips membership in either mode
460
+ table.selectRow(rowId, "add"); // additive without clearing
461
+ table.selectRowRange(anchor, here); // shift-click; collapses to a whitelist of the range
462
+ table.selectAll(); // O(1) flip to all-mode, empty blacklist
463
+ table.clearSelection(); // back to whitelist mode, empty set
464
+
465
+ table.isSelected(rowId); // O(1) predicate
466
+ table.selectedCount(); // O(1) reactive count
467
+ ```
468
+
469
+ `selectionAnchor` tracks the last single/toggle target so shift-click selects a contiguous range from the anchor to the current row, in current display order (after sort, after filter).
470
+
471
+ ### Materialization
472
+
473
+ You only enumerate when you actually need the list -- never on Ctrl+A, never per keystroke. Three APIs, all O(N) in the source:
474
+
475
+ ```ts
476
+ table.selectedIds(source?) // rowId[]
477
+ table.selectedRows(source?) // Row[]
478
+ table.forEachSelected(fn, source?) // streams; return false to stop
479
+ ```
480
+
481
+ `source` defaults to the current `visibleRows()`. Pass a different source (e.g. an unsorted master list) when exporting against data the grid doesn't currently render. `forEachSelected` is the path you want for CSV export, server upload, or any per-row processing -- it iterates through the predicate without ever materializing the full list.
482
+
483
+ ```js
484
+ // Stream 1M selected rows to a server without holding the list in memory:
485
+ table.forEachSelected((row, id) => {
486
+ socket.send(JSON.stringify(row));
487
+ });
488
+
489
+ // Export top 1000 to CSV:
490
+ let count = 0;
491
+ const lines = [];
492
+ table.forEachSelected((row, id) => {
493
+ lines.push(formatCsvRow(row));
494
+ if (++count >= 1000) return false;
495
+ });
496
+ ```
497
+
498
+ This streaming form was structurally impossible with a whitelist-only API under "select all" -- you had no way to partially know what was selected. With the predicate, the cost of selection state stays small (~one boolean + a blacklist of a few deselected IDs), and the cost of producing the list is paid exactly once, at the moment you ship it across a boundary.
499
+
500
+ ### Per-row reactivity
501
+
502
+ Rendering is reactive per row. A `bindClass` on each pool slot reads `isSelected(getRowId(visibleRows()[slotIndex]))`. Selecting one row touches one slot's class list -- the same fast path works in both modes.
503
+
504
+ ---
505
+
506
+ ## Keyboard navigation
507
+
508
+ The root element holds `tabindex=0` and `aria-activedescendant` pointing at the focused cell's id. Cells themselves are **not focusable** -- there's no per-cell `tabindex`, no per-cell focus listener, and no DOM focus calls during arrow-key navigation. The focus indicator is a `.is-focused` class applied via `bindClass`, styled with `outline: 2px solid; outline-offset: -2px` so it doesn't promote the cell to a new stacking context or shift content.
509
+
510
+ ```ts
511
+ table.moveFocus("down"); // down
512
+ table.moveFocus("right"); // right
513
+ table.moveFocus("home"); // Home -> first column, current row
514
+ table.moveFocus("end"); // End -> last column, current row
515
+ table.moveFocus("pageDown"); // PgDn -> jumps by viewport height
516
+ ```
517
+
518
+ `table.cellId(rowId, columnKey)` is the canonical id format (`lt_<rowId>__<columnKey>`) so screen readers can announce focus changes through a single `aria-activedescendant` update on the root, without DOM focus thrash.
519
+
520
+ ---
521
+
522
+ ## Capacity, growth, and the signal-registry ceiling
523
+
524
+ `@zakkster/lite-signal` allocates reactive nodes from a fixed-size pool -- default 1024 nodes per registry. A mounted table consumes roughly `30 + (poolSize x 6 x cols)` nodes: per-column state, per-slot state, per-cell text + class + grid-column + pin effects.
525
+
526
+ For a 6-column, 100k-row table in a 480-tall viewport with `rowHeight = 32`, that's about `30 + (24 x 6 x 6) ~ 900 nodes`. Comfortably under 1024.
527
+
528
+ For wider columns, more visible rows (taller viewport, smaller `rowHeight`), or extra effects from your application code, configure the registry up front:
529
+
530
+ ```js
531
+ import { createRegistry, setDefaultRegistry } from "@zakkster/lite-signal";
532
+
533
+ setDefaultRegistry(createRegistry({
534
+ onCapacityExceeded: "grow", // or "throw" for hard failure (default)
535
+ initialNodes: 4096
536
+ }));
537
+ ```
538
+
539
+ `"grow"` doubles the pool when the ceiling is hit. `"throw"` raises `CapacityError` -- useful in development to catch leaks. Pick deliberately; the library does not silently raise the limit.
540
+
541
+ ---
542
+
543
+ ## Benchmarks
544
+
545
+ Four benchmarks, all in `bench/`:
546
+
547
+ | Bench | Measures |
548
+ | ---------------------------------- | ----------------------------------------------------------- |
549
+ | `01-scroll-writes.js` | DOM allocations vs in-place updates per scroll boundary cross, against clusterize.js + a naive virtual implementation |
550
+ | `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 |
552
+ | `04-sort.js` | 100k-row sort + cached re-read + toggle cycle |
553
+
554
+ Representative numbers on a 2016 MacBook (your hardware will be different; relative ordering is what matters):
555
+
556
+ ```
557
+ Scroll writes (100,000 rows, 100 boundary scrolls)
558
+ allocations in-place updates
559
+ lite-table 0 600
560
+ clusterize.js 200 200
561
+ naive virtual 1000 100
562
+
563
+ Mount cost (time to first frame)
564
+ 1k 10k 100k 1M
565
+ lite-table 42ms 48ms 53ms 64ms
566
+ clusterize.js 78ms 170ms 1140ms OOM
567
+ naive virtual 35ms 290ms stall(>30s) OOM
568
+
569
+ Heap stability (10,000 boundary scrolls)
570
+ signal nodes delta: 0 (was 811, now 811)
571
+ signal links delta: 0 (was 1401, now 1401)
572
+ pool size delta: 0 (was 24, now 24)
573
+ heap delta: ~0 KB (V8 noise floor)
574
+
575
+ Sort cost (100,000 rows)
576
+ first sort ~220ms (allocates one output array)
577
+ cached re-read 0.13ms (computed cache hit)
578
+ toggleSort cycle 3-22ms (rebuild sortChain, re-sort)
579
+ ```
580
+
581
+ The `01` bench patches `Element.prototype.appendChild` and tracks every call; the harness has a synthetic-write guard so happy-dom's internal `appendChild` during `textContent` doesn't double-count.
582
+
583
+ Run the suite:
584
+
585
+ ```bash
586
+ npm run bench
587
+ ```
588
+
589
+ ---
590
+
591
+ ## Testing strategy
592
+
593
+ Two tiers, all reproducible.
594
+
595
+ ### Tier 1 -- Behavior (unit tests, fast)
596
+
597
+ `npm test` runs the suite in `test/`, 110 tests across 9 files:
598
+
599
+ - **`core.test.js`** -- `createTable` API surface, reactive `visibleColumns`, `colTemplate`, `colPlacement` consistency under hide/pin/reorder, sort chain semantics, dispose idempotency.
600
+ - **`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.
601
+ - **`recycle.test.js`** -- slot pool reuse across boundary crosses; cell IDs follow the row, not the slot; logical focus survives scroll-out + filter-out + re-add.
602
+ - **`alloc.test.js`** -- scroll, sort, and column-reorder paths allocate no new signal nodes or links once warmed up; 5000 boundary scrolls produce zero graph growth.
603
+ - **`sort.test.js`** -- multi-key sort chain stability, null-safe comparator default, custom `compare` per column, sort toggle cycle (none -> asc -> desc -> none), index-buffer reuse.
604
+ - **`selection.test.js`** -- single / toggle / additive / range modes, anchor preservation across sort, `selectAll` O(1) all-mode + blacklist, `forEachSelected` streaming.
605
+ - **`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
+ - **`keyboard.test.js`** -- `moveFocus` arrow / Home / End / PageUp / PageDown, focus clamps at edges, focus survives row reorder.
607
+ - **`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.
608
+
609
+ ```bash
610
+ npm test
611
+ ```
612
+
613
+ ### Tier 2 -- Memory (zero-GC verification)
614
+
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.
616
+
617
+ ```bash
618
+ node --expose-gc bench/03-heap.js
619
+ ```
620
+
621
+ If this fails, something allocates in the hot scroll path and we want to find it before publish.
622
+
623
+ ---
624
+
625
+ ## What this is not
626
+
627
+ - **A general-purpose grid component.** No cell editing yet, no row groups, no aggregations, no in-place pagination. The headless core makes those buildable on top, but they're not in the box.
628
+ - **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
+ - **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
+ - **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.
631
+
632
+ ---
633
+
634
+ ## Ecosystem
635
+
636
+ Built on the `@zakkster/lite-*` zero-GC ESM family. All MIT.
637
+
638
+ **Substrate**
639
+ - [`@zakkster/lite-signal`](https://www.npmjs.com/package/@zakkster/lite-signal) -- the reactive graph. Synchronous, zero-microtask, version-stamped pull. Required.
640
+ - [`@zakkster/lite-virtual`](https://www.npmjs.com/package/@zakkster/lite-virtual) -- the virtual axis. `start`, `end`, `viewportHeight` as signals; one `Object.is`-comparable integer per scroll frame. Required.
641
+ - [`@zakkster/lite-signal-dom`](https://www.npmjs.com/package/@zakkster/lite-signal-dom) -- `bindText`, `bindAttr`, `bindClass`, `bindOn`. The thin DOM-side glue. Required.
642
+
643
+ **Helpers**
644
+ - [`@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
+
646
+ The package tree is intentionally small. `lite-table` itself is a single file ESM module (~1300 lines including types-in-JSDoc), zero runtime dependencies beyond the three substrate packages.
647
+
648
+ ---
649
+
650
+ ## Browser and runtime support
651
+
652
+ - **Browsers:** Chromium 89+, Firefox 90+, Safari 14+. Anything with `position: sticky`, `ResizeObserver`, `PointerEvent`, and CSS Grid (i.e. evergreen browsers since 2021).
653
+ - **Node:** ESM-only. Tested under Node 20+ with `happy-dom` for SSR-style introspection / unit tests.
654
+ - **Bundlers:** vite, rollup, esbuild, parcel. No special config required; the package exports a single ESM entry.
655
+
656
+ ---
657
+
658
+ ## Integration recipes
659
+
660
+ <details>
661
+ <summary>Persisting column state to localStorage with @zakkster/lite-persist.</summary>
662
+
663
+ ```js
664
+ import { persist } from "@zakkster/lite-persist";
665
+
666
+ const t = createTable({ /* ... */ });
667
+
668
+ // Persist width, hidden, pin, flex for every column.
669
+ for (const c of t.columns) {
670
+ persist(c.width, "table.cols." + c.key + ".width");
671
+ persist(c.hidden, "table.cols." + c.key + ".hidden");
672
+ persist(c.pin, "table.cols." + c.key + ".pin");
673
+ persist(c.flex, "table.cols." + c.key + ".flex");
674
+ }
675
+ persist(t.sortChain, "table.sort");
676
+ persist(t.columnOrder, "table.colOrder");
677
+ ```
678
+
679
+ Restart the app -- every column comes back to where the user left it. The pool, the sort buffer, and the selection live in memory only.
680
+
681
+ </details>
682
+
683
+ <details>
684
+ <summary>Driving the table from a reactive row source (filter + search).</summary>
685
+
686
+ ```js
687
+ import { signal, computed } from "@zakkster/lite-signal";
688
+
689
+ const search = signal("");
690
+ const rows = signal(initialRows);
691
+
692
+ const filtered = computed(() => {
693
+ const q = search().toLowerCase();
694
+ if (!q) return rows();
695
+ return rows().filter((r) => r.name.toLowerCase().includes(q));
696
+ });
697
+
698
+ const table = createTable({
699
+ rows: () => filtered(), // getter form -- reactive row source
700
+ columns: COLS,
701
+ getRowId: (r) => r.id
702
+ });
703
+
704
+ // Wire to an input:
705
+ searchEl.addEventListener("input", (e) => search.set(e.target.value));
706
+ ```
707
+
708
+ Every keystroke runs the filter, `visibleRows` recomputes, the slot pool's text content updates. No row-list diffing, no fade-in animation, no `key=` ceremony.
709
+
710
+ </details>
711
+
712
+ <details>
713
+ <summary>Replacing the row dataset wholesale (paged backend).</summary>
714
+
715
+ ```js
716
+ async function loadPage(n) {
717
+ const next = await fetch(`/rows?page=${n}`).then(r => r.json());
718
+ rows.set(next); // single signal write
719
+ table.clearSelection(); // optional
720
+ table.setSort([]); // optional
721
+ }
722
+ ```
723
+
724
+ `rows.set(next)` invalidates `visibleRows`, which causes the slot pool's text effects to re-pull. The DOM topology is unchanged -- same 24 row elements, same N cells per row, new text content.
725
+
726
+ </details>
727
+
728
+ ---
729
+
730
+ ## FAQ
731
+
732
+ <details>
733
+ <summary>Why not <code>&lt;table&gt;</code>?</summary>
734
+
735
+ CSS Grid gives precise per-column placement (grid-column + grid-template-columns), pinned columns via `position: sticky`, and an inner `position: relative` for the slot pool -- all without the table layout algorithm's quirks (no `colspan` inference, no row-height surprises from cell content). The `role="grid"` / `role="row"` / `role="gridcell"` ARIA roles plus `aria-activedescendant` give screen readers everything a `<table>` would, while the layout stays under the library's control.
736
+
737
+ </details>
738
+
739
+ <details>
740
+ <summary>How does scroll work without re-creating rows?</summary>
741
+
742
+ The viewport contains `pool.length` row elements, each `position: absolute; transform: translateY(slotIndex x rowHeight)`. Scrolling updates the transforms -- no rows are added, removed, or moved in the DOM. The slot at pool index 0 might show row 0 at scroll position 0 and row 47 after a 1500px scroll. The same DOM element, different `textContent`.
743
+
744
+ </details>
745
+
746
+ <details>
747
+ <summary>What happens at the bottom of the dataset?</summary>
748
+
749
+ Slots whose `slotIndex >= rowCount` set `display: none` via a reactive effect. The DOM elements stay in the pool; they're just hidden. No allocation, no removal.
750
+
751
+ </details>
752
+
753
+ <details>
754
+ <summary>Can I disable virtualization for small tables?</summary>
755
+
756
+ Not yet. The pool sizing always matches the viewport. For tables under ~100 rows the overhead is negligible (pool of ~20 elements, same as a hand-rolled `<tbody>`). If you need a non-virtual rendering target, drive the headless core directly -- `visibleRows()` gives you the sorted row list to feed into whatever renderer you prefer.
757
+
758
+ </details>
759
+
760
+ <details>
761
+ <summary>Why does pinning suspend flex?</summary>
762
+
763
+ `leftOffsets` and `rightOffsets` are cumulative sums of `c.width()` computed once per layout pass. A pinned column's `position: sticky; left/right: <offset>px` math is only correct if the column's rendered width equals `c.width()`. Flex columns render as `minmax(<minWidth>px, <flex>fr)`, whose resolved width depends on the available horizontal space -- it can differ from `c.width()` by hundreds of pixels. Pinning with flex active would make the offset arithmetic systematically wrong and cells would overlap visually. The library silently ignores `flex` for pinned columns; unpinning restores it. Test: `test/columns.test.js`, "pinning suspends flex".
764
+
765
+ </details>
766
+
767
+ <details>
768
+ <summary>How do I make a checkbox column?</summary>
769
+
770
+ Two patterns. Either render a checkbox character in the cell's `accessor` (`accessor: (row) => isSelected(row) ? "[X]" : "[ ]"`) and toggle `selection` in your own `pointerdown` handler on the cell -- or layer a real `<input type="checkbox">` over the cell via a second DOM tree. The library doesn't ship a checkbox column primitive; cell content is text by design (one `textContent` write per change is the zero-GC contract).
771
+
772
+ </details>
773
+
774
+ <details>
775
+ <summary>What's the relationship to the other <code>@zakkster/lite-*</code> packages?</summary>
776
+
777
+ `lite-table` is the highest-level package in the family. It depends on three substrate libs (`lite-signal`, `lite-virtual`, `lite-signal-dom`) and is intended to be the reference example of how to wire them together for a serious UI surface. If you build your own grid / list / canvas component on the same substrate, you should expect the same allocation profile.
778
+
779
+ </details>
780
+
781
+ ---
782
+
783
+ ## npm scripts
784
+
785
+ ```bash
786
+ npm test # 110 tests across 9 files (node:test, --expose-gc)
787
+ npm run demo # zero-dep static server on http://localhost:8080
788
+ npm run bench # all four benches sequentially (output: text or --md)
789
+ ```
790
+
791
+ To run a single bench, invoke it directly:
792
+
793
+ ```bash
794
+ node --expose-gc bench/01-scroll-writes.js
795
+ node --expose-gc bench/02-mount.js
796
+ node --expose-gc bench/03-heap.js
797
+ node --expose-gc bench/04-sort.js
798
+ ```
799
+
800
+ ---
801
+
802
+ Copyright (c) Zahary Shinikchiev. MIT.