@zakkster/lite-table 1.1.0 → 1.3.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 +151 -0
  2. package/README.md +223 -18
  3. package/Table.d.ts +178 -0
  4. package/Table.js +1136 -148
  5. package/llms.txt +212 -4
  6. package/package.json +5 -3
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.
@@ -222,6 +233,115 @@ TableCore editing (M2):
222
233
  Edit state survives scroll: keyed on rowId, so contenteditable rebinds
223
234
  when the row scrolls back into view. Draft preserved across scroll.
224
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
+
225
345
  TableCore lifecycle:
226
346
  dispose() releases all reactive nodes
227
347
  cellId(rowId, columnKey) -> "lt_<rowId>__<columnKey>"
@@ -231,6 +351,48 @@ TableCore lifecycle:
231
351
  returns to the registry pool. 50 createTable+dispose cycles round-trip
232
352
  cleanly with activeNodes flat.
233
353
 
354
+ View state (persistence seam):
355
+ Two TableCore methods (v1.3.0+) snapshot and restore the LAYOUT + QUERY
356
+ as plain JSON. Selection/focus/edit state are transient and excluded.
357
+ This is the seam @zakkster/lite-headless createSavedViews (G-03) consumes;
358
+ the named-view MANAGER lives there, not here. Cold user-gesture path.
359
+
360
+ getViewState() -> ViewState JSON-safe snapshot; no live signal
361
+ refs, no Map/Set. Map -> object,
362
+ Set -> array. Full per-column layout
363
+ for EVERY current column (not deltas)
364
+ so restore is default-independent.
365
+ setViewState(view, opts?) REPLACE semantics, atomic inside one
366
+ batch() (computeds recompute once).
367
+ opts reserved (no merge mode in v1),
368
+ ignored. Throws TypeError BEFORE any
369
+ mutation on a non-object, missing v,
370
+ or v !== 1. Within a v1 view a single
371
+ malformed entry is skipped, the rest
372
+ applied. columnOrder is reconciled
373
+ against the live column set (keep saved
374
+ keys still present, append new columns,
375
+ drop dead keys) so a stale order never
376
+ trips setColumnOrder's guard. Filters
377
+ and sort are cleared then re-applied;
378
+ groupBy replaced (unknown keys dropped);
379
+ collapsed groups expanded then
380
+ re-collapsed per saved path.
381
+
382
+ ViewState shape:
383
+ {
384
+ v: 1, // schema version (integer)
385
+ sort: [{ key, dir }], // outermost key first
386
+ columnOrder: [key, ...],
387
+ columns: { [key]: { width, hidden, pin, flex } }, // full layout
388
+ filters: { [key]: query }, // empty/whitespace omitted
389
+ groupBy: [key, ...],
390
+ collapsedGroups: [pathStr, ...] // U+001F separator
391
+ }
392
+
393
+ Round-trip: setViewState(getViewState()) is identity. Keyed (not
394
+ positional) so a view survives column-set drift.
395
+
234
396
  ### mountTable(host, table, options?) -> TableMount
235
397
 
236
398
  options: { injectStyles?=true, initialViewportHeight?=480 }
@@ -271,7 +433,9 @@ Filters (M2; mounted only when any column has filterable: true):
271
433
  </div> ...
272
434
  </div>
273
435
  Body: <div class="lt-viewport"><div class="lt-inner">
274
- <div class="lt-row [lt-row-alt] [is-selected]"
436
+ <div class="lt-row [lt-row-alt] [is-selected]
437
+ [lt-row-group-header] [lt-row-grand-total]
438
+ [data-depth data-collapsed]" <!-- M3 header/total attrs -->
275
439
  role="row"
276
440
  aria-rowindex aria-selected
277
441
  style="transform: translateY(<i*rowHeight>px)">
@@ -287,6 +451,34 @@ Body: <div class="lt-viewport"><div class="lt-inner">
287
451
  </div> ...
288
452
  </div></div>
289
453
 
454
+ Sticky overlays (M3; direct children of .lt-viewport):
455
+ <div class="lt-sticky-groups" aria-hidden="true"
456
+ style="position:sticky; top:<rowHeight>px;
457
+ height:0; z-index:2">
458
+ <div class="lt-row-group-header lt-sticky-group"
459
+ data-depth data-collapsed
460
+ style="position:absolute; top:<depth*rowHeight>px;
461
+ height:<rowHeight>px;
462
+ display:grid; grid-template-columns:var(--lt-cols)">
463
+ <div class="lt-cell" data-key>chevron + value + (N)</div>
464
+ ...
465
+ </div> ...
466
+ </div>
467
+ <div class="lt-sticky-grand-total" aria-hidden="true"
468
+ style="position:sticky; bottom:0; height:0; z-index:2">
469
+ <div class="lt-row-grand-total lt-sticky-grand-total-row"
470
+ style="position:absolute; top:-<rowHeight>px;
471
+ height:<rowHeight>px;
472
+ display:grid; grid-template-columns:var(--lt-cols)">
473
+ <div class="lt-cell" data-key>Total (N) / aggregate</div>
474
+ ...
475
+ </div>
476
+ </div>
477
+
478
+ Sticky rows deliberately do NOT carry the base .lt-row class so consumers'
479
+ querySelectorAll(".lt-row") still counts only pool slots (backwards-compatible
480
+ with 1.0/1.1 code that inspects the DOM this way).
481
+
290
482
  ## Keyboard
291
483
 
292
484
  ArrowUp/Down/Left/Right move focus (skips hidden cols)
@@ -312,6 +504,9 @@ Click header toggleSort
312
504
  (on a filter input):
313
505
  Escape setColumnFilter(key, "")
314
506
  Shift+click header toggleSort additive
507
+ Click group-header row toggleGroup(entry.path) (M3, inline OR sticky)
508
+ Click grand-total row ignored (M3)
509
+ Arrow-up/down stays inside data rows -- skips headers (M3)
315
510
 
316
511
  ## Performance
317
512
 
@@ -341,13 +536,23 @@ Classes for styling:
341
536
  .lt-header-cell.is-drop-before / .is-drop-after
342
537
  .lt-header-resize:hover / .is-active
343
538
 
539
+ M3 grouping / total classes:
540
+ .lt-row-group-header pool-rendered group header
541
+ .lt-row-group-header[data-depth] 0 / 1 / 2 / 3 / 4 -> indent
542
+ .lt-row-group-header[data-collapsed="true"|"false"]
543
+ .lt-row-grand-total pool-rendered grand total
544
+ .lt-sticky-groups sticky-header container (viewport child)
545
+ .lt-sticky-group sticky group-header row inside container
546
+ .lt-sticky-grand-total sticky grand-total container
547
+ .lt-sticky-grand-total-row sticky grand-total row inside container
548
+
344
549
  ## Files
345
550
 
346
- Table.js single-file ESM source
551
+ Table.js single-file ESM source (~3100 lines)
347
552
  Table.d.ts type declarations
348
- demo/index.html QA demo with controls for all M1 features
553
+ demo/index.html QA demo with controls for all M1 / M2 / M3 features
349
554
  demo/serve.js zero-dep static server (npm run demo)
350
- test/*.test.js node:test suite (npm test, 110 tests across 9 files)
555
+ test/*.test.js node:test suite (npm test, 257 tests across 14 files)
351
556
  bench/*.js benchmarks vs clusterize.js + naive virtual (npm run bench)
352
557
  llms.txt this file
353
558
  README.md human docs with Mermaid diagrams + bench numbers
@@ -367,6 +572,9 @@ Headline:
367
572
  - 10K boundary scrolls: signal nodes delta 0, signal links delta 0,
368
573
  DOM pool delta 0, process heap noise floor
369
574
  - mount 1M rows: ~56ms, 24-element pool, constant vs dataset size
575
+ - 10K boundary scrolls in a GROUPED 100k-row table with sticky group
576
+ headers + sticky grand total active: signal nodes delta 0, link
577
+ delta 2 (noise), pool delta 0. See bench/03-heap-grouped.js.
370
578
 
371
579
  The reactive graph and DOM pool are stable across all steady-state ops.
372
580
  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.1.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.3.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",