@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/CHANGELOG.md +93 -0
- package/LICENSE.txt +21 -0
- package/README.md +802 -0
- package/Table.d.ts +227 -0
- package/Table.js +1586 -0
- package/llms.txt +262 -0
- package/package.json +71 -0
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
|
+
}
|