@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/llms.txt ADDED
@@ -0,0 +1,262 @@
1
+ # @zakkster/lite-table
2
+
3
+ Headless reactive data tables. CSS Grid. Zero-GC scrolling. Built on
4
+ @zakkster/lite-signal, @zakkster/lite-virtual, @zakkster/lite-signal-dom.
5
+
6
+ ## Architecture invariants
7
+
8
+ - No <table>. CSS Grid layout. role=grid / row / columnheader / gridcell.
9
+ - Position-keyed slot pool. DOM topology never changes after mount.
10
+ Scroll, sort, filter, hide, reorder, pin -- all mutate bindings, not DOM.
11
+ - Object.is cutoff on truncated row indices: sub-row scrolls are 0 writes.
12
+ - Logical focus is a signal (focusedCell). DOM focus stays on the root
13
+ container, which has tabindex=0 and aria-activedescendant.
14
+ - Focus indicator is a class (.is-focused) applied via bindClass per cell.
15
+ Cheaper than a dynamic <style> rewrite because it doesn't invalidate
16
+ CSSOM globally; per focus move, only the 1-2 cells whose match state
17
+ flipped actually write classList.
18
+ - Pin uses one CSS Grid with position:sticky on individual cells, NOT
19
+ three split grids. Sticky offsets are cumulative-width computeds.
20
+ - pointerdown for input, not mousedown. isPrimary guard. No preventDefault
21
+ on cell pointerdown (preserves text selection on mouse, scroll on touch).
22
+ - Cell pointerdown is delegated to the root container -- ONE listener
23
+ handles all cells via closest('.lt-cell') + data-key + slot-index lookup.
24
+ Avoids attaching N x M listeners per mount.
25
+ - Sort uses a reusable Uint32Array of indices. One fill (0..N-1), one
26
+ in-place sort with a comparator dereferencing rows, one output array.
27
+ Zero per-row tuple allocations.
28
+ - Reactive columns: each ColumnState has width/hidden/pin signals.
29
+ columnOrder is a Signal<string[]>. visibleColumns is a computed that
30
+ filters hidden + buckets by pin in [left, none, right] order.
31
+ - visibleRows is a computed that applies sortChain (stable multi-key sort).
32
+ - Selection is Signal<Set<rowId>>. Range uses an anchor and current
33
+ visibleRows ordering. is-selected class + aria-selected applied per row
34
+ via bindClass/bindAttr against the slot's projected row id.
35
+ - Scope tracks all signals, computeds, effects, listeners, bindings.
36
+ dispose() is symmetric with construct: every node tracked, every node
37
+ released.
38
+
39
+ ## Public surface
40
+
41
+ ### createTable(config) -> TableCore
42
+
43
+ config: {
44
+ rows: Row[] | () => Row[],
45
+ columns: ColumnDef[],
46
+ getRowId: (row) => string|number, // REQUIRED
47
+ rowHeight?: number = 32,
48
+ overscan?: number = 4,
49
+ initialFocus?: {rowId, columnKey} | null,
50
+ initialSort?: SortEntry[]
51
+ }
52
+
53
+ ColumnDef: {
54
+ key, header?, width?=120, minWidth?=40, maxWidth?=1600,
55
+ hidden?=false, pin?="none", flex?=0,
56
+ sortable?=true, resizable?=true, pinnable?=true,
57
+ hideable?=true, reorderable?=true,
58
+ accessor?: (row) => any, // default row[key]
59
+ compare?: (a, b) => number // default null-safe num/string
60
+ }
61
+
62
+ flex=0 (default): column is exactly width()px. Trailing 1fr fills empty.
63
+ flex>0: column renders as minmax(minWidth, flex+"fr") and shares
64
+ leftover horizontal space proportionally with other flex
65
+ columns. When ANY column has flex>0 the trailing 1fr is
66
+ dropped (flex columns absorb the space themselves).
67
+
68
+ TableCore reactive state:
69
+ columns readonly ColumnState[]
70
+ rowsGetter() -> readonly Row[]
71
+ visibleRows Computed<Row[]> (sort applied)
72
+ rowCount Computed<number>
73
+ columnOrder Signal<string[]>
74
+ visibleColumns Computed<ColumnState[]>
75
+ displayIndexByKey Computed<Map<key, number>>
76
+ colTemplate Computed<string> (grid-template-columns)
77
+ leftOffsets Computed<Map<key, number>>
78
+ rightOffsets Computed<Map<key, number>>
79
+ sortChain Signal<SortEntry[]>
80
+ focusedCell Signal<{rowId, columnKey} | null>
81
+ selection Signal<{mode, set}> -- predicate, NOT a list
82
+ selectionAnchor Signal<rowId | null>
83
+ selectedCount Computed<number> -- reactive O(1)
84
+
85
+ selection.mode:
86
+ "whitelist" set = the selected IDs (classic)
87
+ "all" set = a BLACKLIST. Every row selected EXCEPT those in set.
88
+ Ctrl+A is O(1) -- no walk, no per-ID allocation.
89
+ Materialize via selectedIds() / selectedRows() /
90
+ forEachSelected() only when actually needed (submit,
91
+ copy, export). Range-select (shift-click) collapses
92
+ back to whitelist of the range.
93
+
94
+ ColumnState fields:
95
+ Static: key, header, accessor, compare, sortable, resizable,
96
+ pinnable, hideable, reorderable, minWidth, maxWidth
97
+ Reactive: width: Signal<number>
98
+ hidden: Signal<boolean>
99
+ pin: Signal<"left"|"none"|"right">
100
+ flex: Signal<number> // 0 = fixed, >0 = share leftover space
101
+
102
+ TableCore methods (sort):
103
+ setSort(key, dir) replace chain
104
+ addSort(key, dir | null) append / update / remove
105
+ toggleSort(key, {additive?}) cycle none -> asc -> desc -> none
106
+ clearSort()
107
+
108
+ TableCore methods (selection):
109
+ selectRow(rowId, mode = "set") mode: set | add | toggle | range
110
+ selectRowRange(anchorId, targetId) collapses to whitelist of range
111
+ selectAll() O(1) flip to all-mode, empty blacklist
112
+ clearSelection() reset to whitelist-mode, empty set
113
+ isSelected(rowId) -> boolean O(1) predicate (both modes)
114
+ selectedIds(source?) -> rowId[] materialize; O(N) in source
115
+ selectedRows(source?) -> Row[] same, returns row objects
116
+ forEachSelected(fn, source?) streams; never materializes; return
117
+ false from fn to stop early
118
+
119
+ TableCore methods (columns):
120
+ setColumnWidth(key, w) clamps to min/max
121
+ setColumnHidden(key, hidden)
122
+ setColumnPin(key, side) side: "left" | "none" | "right"
123
+ setColumnFlex(key, flex) flex>0 = share space; 0 = fixed
124
+ setColumnOrder(keys[]) rejects non-permutations
125
+ moveColumn(fromKey, toKey, {before?}) splice one column
126
+
127
+ TableCore methods (focus):
128
+ moveFocus(direction, {pageSize?})
129
+ directions: up, down, left, right, home, end,
130
+ rowStart, rowEnd, pageUp, pageDown
131
+ skips hidden columns; follows current sort order
132
+
133
+ TableCore lifecycle:
134
+ dispose() releases all reactive nodes
135
+ cellId(rowId, columnKey) -> "lt_<rowId>__<columnKey>"
136
+
137
+ ### mountTable(host, table, options?) -> TableMount
138
+
139
+ options: { injectStyles?=true, initialViewportHeight?=480 }
140
+
141
+ TableMount: {
142
+ root, viewport, axis,
143
+ scrollToIndex(index, align?), // align: "start"|"center"|"end"
144
+ poolSize() -> number,
145
+ dispose() // tears down + removes from host
146
+ }
147
+
148
+ ## DOM contract
149
+
150
+ Root: <div class="lt-root" role="grid" tabindex="0"
151
+ aria-multiselectable="true"
152
+ aria-rowcount aria-colcount aria-activedescendant?
153
+ style="--lt-cols: <colTemplate>">
154
+ Header: <div class="lt-header" role="row" aria-rowindex="1">
155
+ <div class="lt-header-cell [is-sortable] [is-dragging]
156
+ [is-drop-before|is-drop-after]"
157
+ role="columnheader"
158
+ aria-colindex aria-sort="none|ascending|descending"
159
+ data-key data-pin="left|none|right"
160
+ style="grid-column; left|right (if pinned)">
161
+ <span class="lt-header-cell-label">...</span>
162
+ <span class="lt-header-sort">arrow or chain index+arrow</span>
163
+ <span class="lt-header-resize"></span> <!-- if resizable -->
164
+ </div> ...
165
+ </div>
166
+ Body: <div class="lt-viewport"><div class="lt-inner">
167
+ <div class="lt-row [lt-row-alt] [is-selected]"
168
+ role="row"
169
+ aria-rowindex aria-selected
170
+ style="transform: translateY(<i*rowHeight>px)">
171
+ <div class="lt-cell" role="gridcell"
172
+ id="lt_<rowId>__<columnKey>"
173
+ aria-colindex
174
+ data-pin
175
+ style="grid-column; left|right (if pinned)">text</div>
176
+ ...
177
+ </div> ...
178
+ </div></div>
179
+
180
+ ## Keyboard
181
+
182
+ ArrowUp/Down/Left/Right move focus (skips hidden cols)
183
+ Home/End row start/end (visible col 0 / last)
184
+ Ctrl+Home/End grid corners (0,0) / (last,last)
185
+ PageUp/PageDown +/- floor(viewportHeight / rowHeight)
186
+ Space selectRow(focused.rowId, "set")
187
+ Ctrl+A selectAll()
188
+ Escape clearSelection()
189
+ Click cell selectRow(set) + focus
190
+ Shift+click selectRow(range)
191
+ Ctrl/Cmd+click selectRow(toggle)
192
+ Drag header reorder
193
+ Drag right edge of header resize
194
+ Click header toggleSort
195
+ Shift+click header toggleSort additive
196
+
197
+ ## Performance
198
+
199
+ Sub-row scroll 0 DOM writes
200
+ Boundary cross ~6 writes per moved slot
201
+ Long scroll N rows O(N) writes, not O(px)
202
+ Pool size bounded by viewport; monotonic with viewport grow
203
+ Sort change 1 visibleRows recompute
204
+ Focus move O(1) -- 1 style rewrite + 1 attr write
205
+ Column resize O(1) per drag pixel
206
+ Reorder O(visibleCells) gridColumn updates
207
+ Hide O(visibleCells) display updates
208
+
209
+ ## Style hooks
210
+
211
+ CSS variables on .lt-root:
212
+ --lt-cols grid-template-columns (set reactively)
213
+ --lt-pin-bg pinned cell background (default #fff)
214
+ --lt-pin-alt-bg pinned cell alt-row background
215
+
216
+ Classes for styling:
217
+ .lt-root :focus-visible ring on root
218
+ .lt-row.lt-row-alt odd rows
219
+ .lt-row.is-selected selected row
220
+ .lt-header-cell.is-sortable
221
+ .lt-header-cell.is-dragging
222
+ .lt-header-cell.is-drop-before / .is-drop-after
223
+ .lt-header-resize:hover / .is-active
224
+
225
+ ## Files
226
+
227
+ Table.js single-file ESM source
228
+ Table.d.ts type declarations
229
+ demo/index.html QA demo with controls for all M1 features
230
+ demo/serve.js zero-dep static server (npm run demo)
231
+ test/*.test.js node:test suite (npm test, 110 tests across 9 files)
232
+ bench/*.js benchmarks vs clusterize.js + naive virtual (npm run bench)
233
+ llms.txt this file
234
+ README.md human docs with Mermaid diagrams + bench numbers
235
+
236
+ ## Empirical zero-GC verification
237
+
238
+ Measured under Node 22 + happy-dom. Counters track DOM mutation API calls
239
+ (appendChild, insertBefore, innerHTML, setAttribute, textContent, etc.)
240
+ and split into:
241
+ allocations = appendChild + insertBefore + innerHTML (GC pressure)
242
+ updates = setAttribute + textContent (in-place)
243
+
244
+ Headline:
245
+ - sub-row scroll: 0 allocations, 0 updates (Object.is cutoff)
246
+ - boundary scroll: 0 allocations, ~370 updates per row crossed
247
+ - 1000-row jump: 0 allocations, ~384 updates total
248
+ - 10K boundary scrolls: signal nodes delta 0, signal links delta 0,
249
+ DOM pool delta 0, process heap noise floor
250
+ - mount 1M rows: ~56ms, 24-element pool, constant vs dataset size
251
+
252
+ The reactive graph and DOM pool are stable across all steady-state ops.
253
+ Allocations happen only during mount and on demand (column reorder may
254
+ trigger grid-column updates, but no new DOM).
255
+
256
+ ## Conventions
257
+
258
+ - ASCII-only source. Exceptions allowed: \u00d7 and \u00b5 (none used here).
259
+ - Unicode in source as \u escapes (sort arrows: \u25b2 \u25bc).
260
+ - No runtime deps beyond peers.
261
+ - Author: Zahary Shinikchiev. License: MIT.
262
+ - Zero-GC discipline: pre-allocate, reuse slots, no per-frame closures.
package/package.json ADDED
@@ -0,0 +1,71 @@
1
+ {
2
+ "name": "@zakkster/lite-table",
3
+ "version": "1.0.0",
4
+ "description": "Headless reactive data tables on @zakkster/lite-signal. CSS Grid (no <table>), pooled slots that never reparent, aria-activedescendant focus model. Zero-GC scroll path on @zakkster/lite-virtual.",
5
+ "sideEffects": false,
6
+ "keywords": [
7
+ "table",
8
+ "datagrid",
9
+ "data-table",
10
+ "virtualized",
11
+ "headless",
12
+ "signal",
13
+ "reactive",
14
+ "zero-gc",
15
+ "lite-signal",
16
+ "lite-virtual",
17
+ "aria",
18
+ "a11y",
19
+ "admin"
20
+ ],
21
+ "type": "module",
22
+ "main": "./Table.js",
23
+ "module": "./Table.js",
24
+ "types": "./Table.d.ts",
25
+ "exports": {
26
+ ".": {
27
+ "types": "./Table.d.ts",
28
+ "import": "./Table.js"
29
+ }
30
+ },
31
+ "files": [
32
+ "Table.js",
33
+ "Table.d.ts",
34
+ "llms.txt",
35
+ "LICENSE.txt",
36
+ "README.md",
37
+ "CHANGELOG.md"
38
+ ],
39
+ "scripts": {
40
+ "test": "node --expose-gc --test test/*.test.js",
41
+ "demo": "node demo/serve.js",
42
+ "bench": "node --expose-gc --max-old-space-size=4096 bench/run.js"
43
+ },
44
+ "peerDependencies": {
45
+ "@zakkster/lite-signal": "^1.2.0",
46
+ "@zakkster/lite-signal-dom": "^1.0.1",
47
+ "@zakkster/lite-virtual": "^1.1.0"
48
+ },
49
+ "devDependencies": {
50
+ "clusterize.js": "^1.0.0",
51
+ "happy-dom": "^15.11.7"
52
+ },
53
+ "engines": {
54
+ "node": ">=20"
55
+ },
56
+ "homepage": "https://github.com/PeshoVurtoleta/lite-signal#readme",
57
+ "repository": {
58
+ "type": "git",
59
+ "url": "git+https://github.com/PeshoVurtoleta/lite-signal.git"
60
+ },
61
+ "bugs": {
62
+ "url": "https://github.com/PeshoVurtoleta/lite-signal/issues",
63
+ "email": "shinikchiev@yahoo.com"
64
+ },
65
+ "funding": {
66
+ "type": "github",
67
+ "url": "https://github.com/sponsors/PeshoVurtoleta"
68
+ },
69
+ "license": "MIT",
70
+ "author": "Zahary Shinikchiev <shinikchiev@yahoo.com>"
71
+ }