@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.
- package/CHANGELOG.md +151 -0
- package/README.md +223 -18
- package/Table.d.ts +178 -0
- package/Table.js +1136 -148
- package/llms.txt +212 -4
- 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,
|
|
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.
|
|
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",
|