@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.
Files changed (6) hide show
  1. package/CHANGELOG.md +344 -0
  2. package/README.md +564 -19
  3. package/Table.d.ts +258 -1
  4. package/Table.js +1644 -116
  5. package/llms.txt +291 -6
  6. package/package.json +15 -9
package/llms.txt CHANGED
@@ -3,6 +3,17 @@
3
3
  Headless reactive data tables. CSS Grid. Zero-GC scrolling. Built on
4
4
  @zakkster/lite-signal, @zakkster/lite-virtual, @zakkster/lite-signal-dom.
5
5
 
6
+ Feature milestones:
7
+ M1 (v1.0) headless core + DOM mount, virtualized rows on pooled slots,
8
+ sort, selection, resize, reorder, pin, flex.
9
+ M1.1 (v1.1) export (CSV / JSON), scope-dispose leak fix, sort-chain UX.
10
+ M2 (v1.1) cell editing, per-column filtering.
11
+ M3 (v1.2) row grouping (multi-level), per-column aggregation, sticky
12
+ group headers, sticky grand total.
13
+
14
+ All feature additions are opt-in; the ungrouped / non-editing / non-filtering
15
+ fast path is bit-identical to v1.0.
16
+
6
17
  ## Architecture invariants
7
18
 
8
19
  - No <table>. CSS Grid layout. role=grid / row / columnheader / gridcell.
@@ -130,10 +141,216 @@ TableCore methods (focus):
130
141
  rowStart, rowEnd, pageUp, pageDown
131
142
  skips hidden columns; follows current sort order
132
143
 
144
+ TableCore methods (export, M1.1):
145
+ exportCsv(opts?) -> string
146
+ opts.rows: "visible"|"all"|"selected"|Array (default "visible")
147
+ opts.columns: "visible"|"all"|Array<key> (default "visible")
148
+ opts.delimiter: string (default ",")
149
+ opts.quote: string (default '"')
150
+ opts.headers: boolean (default true)
151
+ opts.newline: string (default "\r\n")
152
+ opts.bom: boolean (default false; UTF-8 BOM for Excel-on-Windows)
153
+ opts.formatter: (row, col) => unknown
154
+ RFC 4180 escaping; column accessor honored; numbers stringify naturally;
155
+ null/undefined become empty fields.
156
+
157
+ exportJson(opts?) -> string | object[]
158
+ opts.rows: same as exportCsv
159
+ opts.columns: same as exportCsv
160
+ opts.indent: number (default 0 = compact single-line)
161
+ opts.format: "string"|"array" (default "string"; "array" skips JSON.stringify)
162
+ opts.formatter: (row, col) => unknown
163
+ Fast path: columns:"all"+no formatter returns rows.slice() (shallow).
164
+
165
+ Paginated-getter pitfall: rows:"all" and rows:"selected" resolve against
166
+ rowsGetter(), which for a function-form rows IS the current page. To
167
+ export across the master, pass the master array:
168
+ table.exportCsv({rows: allRows})
169
+ table.exportCsv({rows: table.selectedRows(allRows)})
170
+
171
+ TableCore filtering (M2):
172
+ Column opt-in: { key, filterable: true,
173
+ filter?: (value, query, row) => boolean,
174
+ filterPlaceholder?: string }
175
+
176
+ Default predicate: case-insensitive substring on stringified value.
177
+ Empty / whitespace-only query: filter is treated as inactive (predicate
178
+ not invoked).
179
+
180
+ Reactive surface:
181
+ columnFilters() -> ReadonlyMap<columnKey, queryString>
182
+ filteredRows() -> Computed rows post-filter, pre-sort
183
+ visibleRows() -> Computed rows post-filter AND post-sort
184
+
185
+ Methods:
186
+ setColumnFilter(key, value) value=null/""/whitespace clears that column
187
+ clearColumnFilters() no-op + no notify if already empty
188
+
189
+ Pipeline: rowsGetter -> filteredRows -> visibleRows -> exports/mount
190
+ Multiple active filters apply as AND.
191
+
192
+ DOM (when mounted): a .lt-filter-row is mounted between header + viewport
193
+ if any column is filterable. One <input> per filterable column, two-way
194
+ bound. Escape on input clears that column's filter. Sticky top.
195
+
196
+ TableCore editing (M2):
197
+ Column opt-in: { key, editable: true }
198
+ Config: createTable({ ...,
199
+ onCellEdit: ({row, columnKey, oldValue, newValue}) => ... })
200
+
201
+ lite-table does NOT mutate rows. onCellEdit is the consumer's write hook.
202
+
203
+ Reactive surface:
204
+ editingCell() -> { rowId, columnKey } | null
205
+ editingDraft() -> current in-progress string
206
+
207
+ Methods:
208
+ startEdit(rowId, columnKey) no-op on non-editable; auto-commits
209
+ in-flight on a different cell;
210
+ idempotent on the same cell.
211
+ commitEdit() reads editingDraft
212
+ commitEdit("explicit") commits a specific value
213
+ cancelEdit() discard; no onCellEdit
214
+ isEditing(rowId, columnKey) O(1) predicate
215
+
216
+ Commit-skip: onCellEdit is NOT called when the value is unchanged.
217
+ DOM edits produce string newValue; guard compares
218
+ String(oldValue) !== newValue (so 100 vs "100" is unchanged).
219
+ Explicit commitEdit(explicitValue) falls back to strict ===.
220
+ newValue is ALWAYS a string from the DOM path -- consumers
221
+ coerce in their handler (Number(newValue) / new Date(newValue) /
222
+ newValue === "true" / etc.).
223
+ Handler errors are caught + logged; subsequent edits work.
224
+
225
+ DOM keyboard (when mounted):
226
+ dblclick on editable cell -> startEdit
227
+ F2 / Enter on focused cell with editable column -> startEdit
228
+ Enter -> commit + moveFocus down
229
+ Tab -> commit + moveFocus right (Shift+Tab = left)
230
+ Escape -> cancel + return focus to root
231
+ blur -> commit
232
+
233
+ Edit state survives scroll: keyed on rowId, so contenteditable rebinds
234
+ when the row scrolls back into view. Draft preserved across scroll.
235
+
236
+ TableCore grouping + aggregation (M3):
237
+ Config:
238
+ createTable({ ...,
239
+ groupBy: "region" | ["region","status"] | null,
240
+ initialCollapsedGroups: [["Europe"], ["Asia","Books"]],
241
+ showGrandTotal: true })
242
+ Column opt-in for aggregates:
243
+ { key, aggregate: "sum"|"avg"|"min"|"max"|"count" | (rows,col)=>any,
244
+ aggregateFormat?: (value, col, count) => string }
245
+
246
+ Aggregates fold over LEAF rows at every depth. Safe default for reducers
247
+ that don't compose associatively (median, last-value-wins, distinct).
248
+ Nullish values are skipped for sum/avg/min/max; count is unconditional.
249
+ aggregateFormat is display-only; entry.aggregates.get(key) stays raw for
250
+ export. Errors in aggregateFormat are caught + logged.
251
+
252
+ Pipeline: rowsGetter -> filteredRows -> groupedRows -> visibleEntries
253
+ Sort applies WITHIN each leaf group. Groups themselves are sorted by
254
+ group key ascending; nulls bucket last. Filter runs BEFORE grouping so
255
+ empty groups vanish.
256
+
257
+ Reactive surface:
258
+ groupBy() -> readonly string[] -- [] = ungrouped
259
+ collapsedGroups() -> ReadonlySet<string> -- path-strings
260
+ groupedRows() -> GroupNode[] | null -- null when ungrouped
261
+ visibleEntries() -> Entry[] -- interleaved rendering feed
262
+ entryCount() -> number -- drives virtual axis
263
+ visibleRows() -> Row[] -- DATA-ONLY, unchanged 1.1 contract
264
+ rowCount() -> number -- DATA-ONLY, unchanged
265
+
266
+ Entry union:
267
+ { type: "data", row }
268
+ { type: "group-header", depth, key, value, path, pathStr, count,
269
+ aggregates, isCollapsed }
270
+ { type: "grand-total", aggregates, count }
271
+
272
+ GroupNode:
273
+ { depth, key, value, path, pathStr, count,
274
+ aggregates: Map<colKey, any>,
275
+ subGroups: GroupNode[] | null, -- internal nodes only
276
+ rows: Row[] | null -- leaf nodes only
277
+ }
278
+
279
+ Methods:
280
+ setGroupBy(v) idempotent; unknown keys dropped
281
+ toggleGroup(path)
282
+ expandGroup(path) no-op if already expanded
283
+ collapseGroup(path) no-op if already collapsed
284
+ expandAllGroups() clears collapsed set
285
+ collapseAllGroups() walks groupedRows() once
286
+ isGroupCollapsed(path) O(1) predicate
287
+ groupAncestryAt(entryIndex) GroupHeaderEntry[]; for sticky headers.
288
+ Returns strictly-shallower ancestors --
289
+ a depth-0 group-header at entryIndex
290
+ yields [], so sticky doesn't duplicate
291
+ the inline row.
292
+
293
+ Ungrouped fast path: when groupBy() is empty, groupedRows short-circuits
294
+ to null and visibleRows returns sort-applied rows directly. One extra
295
+ signal read of overhead vs 1.1.0.
296
+
297
+ DOM (when mounted):
298
+ Pool-rendered group header:
299
+ <div class="lt-row lt-row-group-header"
300
+ data-depth data-collapsed>
301
+ <div class="lt-cell" data-key>chevron + value + (N)</div>
302
+ <div class="lt-cell" data-key>aggregate or empty</div>
303
+ ...
304
+ </div>
305
+ Grand total (pool-rendered): same shape, class="lt-row lt-row-grand-total",
306
+ first cell reads "Total (N)".
307
+
308
+ Sticky group headers (always mounted; hidden when ungrouped):
309
+ viewport > .lt-sticky-groups -- position:sticky; top:rowHeight;
310
+ height:0; z-index:2
311
+ > .lt-row-group-header.lt-sticky-group[data-depth][data-collapsed]
312
+ absolute; top:depth*rowHeight; grid layout inline
313
+ Note: sticky rows do NOT carry .lt-row so querySelectorAll(".lt-row")
314
+ still counts only pool slots.
315
+
316
+ Sticky grand total (always mounted; hidden when showGrandTotal is off):
317
+ viewport > .lt-sticky-grand-total -- position:sticky; bottom:0;
318
+ height:0; z-index:2
319
+ > .lt-row-grand-total.lt-sticky-grand-total-row
320
+ absolute; top:-rowHeight to anchor bottom edge to container top
321
+
322
+ DOM order in viewport:
323
+ .lt-header (sticky top:0)
324
+ .lt-sticky-groups (sticky top:rowHeight, before inner)
325
+ .lt-inner (pool slots absolute translateY inside)
326
+ .lt-sticky-grand-total (sticky bottom:0, after inner)
327
+
328
+ Sticky ancestor source: axis.firstIndex() (from @zakkster/lite-virtual,
329
+ the pre-overscan first-visible entry). Using axis.start() would show
330
+ stale ancestors when scrolling across group boundaries because start
331
+ includes overscan slots ABOVE the viewport.
332
+
333
+ Click on sticky group row: same behavior as inline -- toggleGroup(path).
334
+ Click on grand-total row: ignored (no selection change).
335
+
336
+ Reactive column state carries over:
337
+ Sticky cells register the same colPlacement + pin effects that pool
338
+ cells do. Hiding the first-visible column moves the chevron+label to
339
+ the next; reordering keeps sticky + pool aligned; pin applies to sticky.
340
+
341
+ Zero-GC: 10k boundary scrolls, 100k rows, groupBy:"status",
342
+ showGrandTotal, sticky overlays active. signal-node delta 0,
343
+ link delta 2 (noise), pool delta 0. See bench/03-heap-grouped.js.
344
+
133
345
  TableCore lifecycle:
134
346
  dispose() releases all reactive nodes
135
347
  cellId(rowId, columnKey) -> "lt_<rowId>__<columnKey>"
136
348
 
349
+ Note: v1.1.0 fixed a latent leak in dispose() (handles were called,
350
+ not freed). After dispose() every reactive node the table allocated
351
+ returns to the registry pool. 50 createTable+dispose cycles round-trip
352
+ cleanly with activeNodes flat.
353
+
137
354
  ### mountTable(host, table, options?) -> TableMount
138
355
 
139
356
  options: { injectStyles?=true, initialViewportHeight?=480 }
@@ -163,20 +380,63 @@ Header: <div class="lt-header" role="row" aria-rowindex="1">
163
380
  <span class="lt-header-resize"></span> <!-- if resizable -->
164
381
  </div> ...
165
382
  </div>
383
+ Filters (M2; mounted only when any column has filterable: true):
384
+ <div class="lt-filter-row" role="row" aria-rowindex="2">
385
+ <div class="lt-filter-cell" data-key data-pin
386
+ style="grid-column; left|right (if pinned)">
387
+ <input class="lt-filter-input"
388
+ aria-label="Filter <header>"
389
+ placeholder="Filter…">
390
+ <!-- empty cells for non-filterable columns -->
391
+ </div> ...
392
+ </div>
166
393
  Body: <div class="lt-viewport"><div class="lt-inner">
167
- <div class="lt-row [lt-row-alt] [is-selected]"
394
+ <div class="lt-row [lt-row-alt] [is-selected]
395
+ [lt-row-group-header] [lt-row-grand-total]
396
+ [data-depth data-collapsed]" <!-- M3 header/total attrs -->
168
397
  role="row"
169
398
  aria-rowindex aria-selected
170
399
  style="transform: translateY(<i*rowHeight>px)">
171
- <div class="lt-cell" role="gridcell"
400
+ <div class="lt-cell [is-focused] [is-editing]"
401
+ role="gridcell"
172
402
  id="lt_<rowId>__<columnKey>"
173
403
  aria-colindex
174
404
  data-pin
405
+ [data-editable="true"]
406
+ [contenteditable="true"] <!-- only the active edit cell -->
175
407
  style="grid-column; left|right (if pinned)">text</div>
176
408
  ...
177
409
  </div> ...
178
410
  </div></div>
179
411
 
412
+ Sticky overlays (M3; direct children of .lt-viewport):
413
+ <div class="lt-sticky-groups" aria-hidden="true"
414
+ style="position:sticky; top:<rowHeight>px;
415
+ height:0; z-index:2">
416
+ <div class="lt-row-group-header lt-sticky-group"
417
+ data-depth data-collapsed
418
+ style="position:absolute; top:<depth*rowHeight>px;
419
+ height:<rowHeight>px;
420
+ display:grid; grid-template-columns:var(--lt-cols)">
421
+ <div class="lt-cell" data-key>chevron + value + (N)</div>
422
+ ...
423
+ </div> ...
424
+ </div>
425
+ <div class="lt-sticky-grand-total" aria-hidden="true"
426
+ style="position:sticky; bottom:0; height:0; z-index:2">
427
+ <div class="lt-row-grand-total lt-sticky-grand-total-row"
428
+ style="position:absolute; top:-<rowHeight>px;
429
+ height:<rowHeight>px;
430
+ display:grid; grid-template-columns:var(--lt-cols)">
431
+ <div class="lt-cell" data-key>Total (N) / aggregate</div>
432
+ ...
433
+ </div>
434
+ </div>
435
+
436
+ Sticky rows deliberately do NOT carry the base .lt-row class so consumers'
437
+ querySelectorAll(".lt-row") still counts only pool slots (backwards-compatible
438
+ with 1.0/1.1 code that inspects the DOM this way).
439
+
180
440
  ## Keyboard
181
441
 
182
442
  ArrowUp/Down/Left/Right move focus (skips hidden cols)
@@ -185,14 +445,26 @@ Ctrl+Home/End grid corners (0,0) / (last,last)
185
445
  PageUp/PageDown +/- floor(viewportHeight / rowHeight)
186
446
  Space selectRow(focused.rowId, "set")
187
447
  Ctrl+A selectAll()
188
- Escape clearSelection()
448
+ Escape clearSelection() (when not editing)
449
+ F2 / Enter (on editable) startEdit(focused) (M2)
450
+ Dblclick on editable cell startEdit (M2)
189
451
  Click cell selectRow(set) + focus
190
452
  Shift+click selectRow(range)
191
453
  Ctrl/Cmd+click selectRow(toggle)
192
454
  Drag header reorder
193
455
  Drag right edge of header resize
194
456
  Click header toggleSort
457
+ (while editing):
458
+ Enter commitEdit + moveFocus("down")
459
+ Tab / Shift+Tab commitEdit + moveFocus("right"/"left")
460
+ Escape cancelEdit + return focus to root
461
+ blur commitEdit
462
+ (on a filter input):
463
+ Escape setColumnFilter(key, "")
195
464
  Shift+click header toggleSort additive
465
+ Click group-header row toggleGroup(entry.path) (M3, inline OR sticky)
466
+ Click grand-total row ignored (M3)
467
+ Arrow-up/down stays inside data rows -- skips headers (M3)
196
468
 
197
469
  ## Performance
198
470
 
@@ -222,13 +494,23 @@ Classes for styling:
222
494
  .lt-header-cell.is-drop-before / .is-drop-after
223
495
  .lt-header-resize:hover / .is-active
224
496
 
497
+ M3 grouping / total classes:
498
+ .lt-row-group-header pool-rendered group header
499
+ .lt-row-group-header[data-depth] 0 / 1 / 2 / 3 / 4 -> indent
500
+ .lt-row-group-header[data-collapsed="true"|"false"]
501
+ .lt-row-grand-total pool-rendered grand total
502
+ .lt-sticky-groups sticky-header container (viewport child)
503
+ .lt-sticky-group sticky group-header row inside container
504
+ .lt-sticky-grand-total sticky grand-total container
505
+ .lt-sticky-grand-total-row sticky grand-total row inside container
506
+
225
507
  ## Files
226
508
 
227
- Table.js single-file ESM source
509
+ Table.js single-file ESM source (~3100 lines)
228
510
  Table.d.ts type declarations
229
- demo/index.html QA demo with controls for all M1 features
511
+ demo/index.html QA demo with controls for all M1 / M2 / M3 features
230
512
  demo/serve.js zero-dep static server (npm run demo)
231
- test/*.test.js node:test suite (npm test, 110 tests across 9 files)
513
+ test/*.test.js node:test suite (npm test, 257 tests across 14 files)
232
514
  bench/*.js benchmarks vs clusterize.js + naive virtual (npm run bench)
233
515
  llms.txt this file
234
516
  README.md human docs with Mermaid diagrams + bench numbers
@@ -248,6 +530,9 @@ Headline:
248
530
  - 10K boundary scrolls: signal nodes delta 0, signal links delta 0,
249
531
  DOM pool delta 0, process heap noise floor
250
532
  - mount 1M rows: ~56ms, 24-element pool, constant vs dataset size
533
+ - 10K boundary scrolls in a GROUPED 100k-row table with sticky group
534
+ headers + sticky grand total active: signal nodes delta 0, link
535
+ delta 2 (noise), pool delta 0. See bench/03-heap-grouped.js.
251
536
 
252
537
  The reactive graph and DOM pool are stable across all steady-state ops.
253
538
  Allocations happen only during mount and on demand (column reorder may
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
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.",
3
+ "version": "1.2.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. Grouping, aggregation, sticky group headers, and grand totals in v1.2.",
5
5
  "sideEffects": false,
6
6
  "keywords": [
7
7
  "table",
@@ -16,7 +16,9 @@
16
16
  "lite-virtual",
17
17
  "aria",
18
18
  "a11y",
19
- "admin"
19
+ "admin",
20
+ "grouping",
21
+ "aggregation"
20
22
  ],
21
23
  "type": "module",
22
24
  "main": "./Table.js",
@@ -24,8 +26,10 @@
24
26
  "types": "./Table.d.ts",
25
27
  "exports": {
26
28
  ".": {
29
+ "node": "./Table.js",
30
+ "import": "./Table.js",
27
31
  "types": "./Table.d.ts",
28
- "import": "./Table.js"
32
+ "default": "./Table.js"
29
33
  }
30
34
  },
31
35
  "files": [
@@ -42,24 +46,26 @@
42
46
  "bench": "node --expose-gc --max-old-space-size=4096 bench/run.js"
43
47
  },
44
48
  "peerDependencies": {
45
- "@zakkster/lite-signal": "^1.2.0",
49
+ "@zakkster/lite-signal": "^1.2.1",
46
50
  "@zakkster/lite-signal-dom": "^1.0.1",
47
51
  "@zakkster/lite-virtual": "^1.1.0"
48
52
  },
49
53
  "devDependencies": {
54
+ "@playwright/test": "^1.60.0",
50
55
  "clusterize.js": "^1.0.0",
51
- "happy-dom": "^15.11.7"
56
+ "happy-dom": "^15.11.7",
57
+ "playwright": "^1.60.0"
52
58
  },
53
59
  "engines": {
54
60
  "node": ">=20"
55
61
  },
56
- "homepage": "https://github.com/PeshoVurtoleta/lite-signal#readme",
62
+ "homepage": "https://github.com/PeshoVurtoleta/lite-table#readme",
57
63
  "repository": {
58
64
  "type": "git",
59
- "url": "git+https://github.com/PeshoVurtoleta/lite-signal.git"
65
+ "url": "git+https://github.com/PeshoVurtoleta/lite-table.git"
60
66
  },
61
67
  "bugs": {
62
- "url": "https://github.com/PeshoVurtoleta/lite-signal/issues",
68
+ "url": "https://github.com/PeshoVurtoleta/lite-table/issues",
63
69
  "email": "shinikchiev@yahoo.com"
64
70
  },
65
71
  "funding": {