@zakkster/lite-table 1.1.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,145 @@ All notable changes to `@zakkster/lite-table` are documented here.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.2.0] — 2026-07-03
9
+
10
+ Row grouping, per-column aggregation, sticky group headers, and a sticky grand-total row. All feature additions are opt-in; consumers of 1.1.0 who don't pass `groupBy` are on the identical fast path they had in 1.1.0.
11
+
12
+ ### Added — Row grouping
13
+
14
+ - **`CreateTableConfig.groupBy?: string | string[] | null`** — group rows by one or more column keys. `null` (default) or `[]` is the ungrouped fast path — byte-identical to 1.1.0. A bare string is normalized to `[string]`. Unknown column keys are silently dropped so persisted state that outlives a column config survives without crashes.
15
+ - **`CreateTableConfig.initialCollapsedGroups?: string[][]`** — pre-seed the collapsed set. Each entry is a group path (e.g., `[["Europe"], ["Asia", "Books"]]`). Unknown paths become no-ops when their groups don't exist.
16
+
17
+ - **`table.groupBy: Signal<readonly string[]>`** — the raw group-key signal. Read-only for consumers; use `setGroupBy` to mutate.
18
+ - **`table.collapsedGroups: Signal<ReadonlySet<string>>`** — path-strings (U+001F separator) of currently-collapsed groups. Read-only for consumers; mutate via `toggleGroup` / `collapseGroup` / `expandGroup` / `expandAllGroups` / `collapseAllGroups`.
19
+ - **`table.groupedRows: Computed<GroupNode[] | null>`** — tree of `GroupNode`s (recursive `subGroups` at internal nodes, `rows` at leaves) sorted by group key ascending. Returns `null` when ungrouped, so consumers can branch on it to render a flat list without walking the tree.
20
+ - **`table.visibleEntries: Computed<Entry[]>`** — flat interleaved list of `{type: "data" | "group-header" | "grand-total"}` entries. Drives the mount layer's slot rendering. Length matches `entryCount()`.
21
+ - **`table.entryCount: Computed<number>`** — total entry count including group headers + grand total. Drives the virtual axis in the mount layer. `rowCount()` (data-only) is kept as a separate consumer stat.
22
+
23
+ - **Methods:**
24
+ - `setGroupBy(v)` — idempotent; unknown keys dropped.
25
+ - `toggleGroup(path)` / `expandGroup(path)` / `collapseGroup(path)` — mutate the collapsed set for one path.
26
+ - `expandAllGroups()` — clears the collapsed set.
27
+ - `collapseAllGroups()` — walks `groupedRows()` once to add every group path to the collapsed set.
28
+ - `isGroupCollapsed(path)` — O(1) predicate.
29
+ - `groupAncestryAt(entryIndex)` — the group headers that CONTAIN the entry at `entryIndex`, deepest last. Empty when ungrouped or when the target is a group header itself (its ancestors are strictly shallower). Used by consumers implementing their own sticky group-header overlays.
30
+
31
+ ### Added — Aggregation
32
+
33
+ - **`ColumnDef.aggregate?: AggregateSpec | null`** — opt-in per column. Built-ins: `"sum"`, `"avg"`, `"min"`, `"max"`, `"count"`. Custom function: `(rows, col) => any` — receives the leaf-row array for the fold and returns any value. Aggregates always fold over LEAF rows at every depth (safe default for reducers that don't compose associatively — median, last-value-wins, `distinct-count`). Nullish values are skipped for `sum` / `avg` / `min` / `max`; `count` counts rows unconditionally.
34
+ - **`ColumnDef.aggregateFormat?: (value, col, count) => string`** — display formatter for group-header and grand-total cells. `entry.aggregates.get(key)` still returns the raw value so exports and custom renderers stay authoritative. Errors thrown by the formatter are caught and logged; the raw value is displayed as fallback.
35
+ - **`CreateTableConfig.showGrandTotal?: boolean`** — append a grand-total entry at the tail of `visibleEntries`. Grand-total aggregates fold over `filteredRows()` and are stable across collapse operations.
36
+
37
+ ### Added — Sticky group headers + sticky grand total
38
+
39
+ Both are built into `mountTable` and render automatically when their prerequisites are met (any `groupBy`, any `showGrandTotal`). Zero-flow-space `position: sticky` containers holding absolute-positioned rows:
40
+
41
+ - **`.lt-sticky-groups`** — direct child of `.lt-viewport`, before `.lt-inner`. Sticks at `top: rowHeight` (below the column header). Contains one `.lt-sticky-group` row per active depth level; each row is a `<div class="lt-row-group-header lt-sticky-group" data-depth="N" data-collapsed="...">`. Reactively updates from `groupAncestryAt(axis.firstIndex())` — using `firstIndex` (not `start`) so sticky reflects what's actually visible at the top of the viewport, not the overscan-inflated slot window.
42
+ - **`.lt-sticky-grand-total`** — direct child of `.lt-viewport`, after `.lt-inner`. Sticks at `bottom: 0`. Contains one `.lt-sticky-grand-total-row` (a `<div class="lt-row-grand-total lt-sticky-grand-total-row">`).
43
+ - **Click on a sticky group header** toggles its group's collapsed state — same UX as clicking the inline pool-rendered header.
44
+ - **Sticky rows do NOT carry the `.lt-row` base class**, so `querySelectorAll(".lt-row")` still counts only pool slots (backwards-compatible with any 1.0/1.1 consumer inspecting the DOM this way). Their layout comes from inlined `display: grid; grid-template-columns: var(--lt-cols)`.
45
+
46
+ ### Pipeline
47
+
48
+ ```
49
+ rowsGetter() -> filteredRows -> groupedRows -> visibleEntries -> pool + sticky
50
+ │ │
51
+ │ └── mount renders per entry.type
52
+ └── sort applies WITHIN each leaf group
53
+ ```
54
+
55
+ - Filter runs BEFORE grouping. Empty groups vanish — no dead headers.
56
+ - Sort chain applies WITHIN leaves. Groups themselves are ordered by group key ascending; `null` values bucket last.
57
+ - The ungrouped fast path (`groupBy: []`) short-circuits `groupedRows` to `null` and `visibleRows` returns the sort-applied rows directly — one signal read of overhead vs 1.1.0.
58
+
59
+ ### DOM
60
+
61
+ Rendered under `mountTable`:
62
+
63
+ - **Group header row** — `<div class="lt-row lt-row-group-header" data-depth="N" data-collapsed="true|false">` with one cell per column matching the data-row grid template.
64
+ - **First visible cell** holds the chevron (`▼` when expanded, `▶` when collapsed) + the group's identifying value + `(N)` row count.
65
+ - **Other cells** hold the column's aggregate value if configured, empty otherwise.
66
+ - **Indent per depth** via CSS (`padding-left: 12px / 28px / 44px / 60px / 76px` for depths 0-4).
67
+ - **First cell overflows visibly** (`overflow: visible; white-space: nowrap`) so the label + chevron + count aren't truncated in narrow first columns.
68
+ - **Aggregate cells** right-align with `font-variant-numeric: tabular-nums` for cross-header column alignment.
69
+ - **Grand-total row** — `<div class="lt-row lt-row-grand-total">` with the same cell structure. First cell reads `Total (N)`.
70
+ - **Sticky elements** — see the "Sticky group headers + sticky grand total" section above.
71
+
72
+ ### Interaction
73
+
74
+ - **Click on a group-header cell** (inline or sticky) toggles the group's collapse. The delegated `pointerdown` on `.lt-root` dispatches on `entry.type` and calls `toggleGroup(entry.path)` for headers.
75
+ - **Click on a grand-total row** is ignored (no selection change, no side effect).
76
+ - **Selection and edit machinery** are gated on `entry.type === "data"`. Clicking a header does not clear the row selection, and editable cells stay non-editing on headers.
77
+ - **Focus / keyboard nav** stays inside data rows — `moveFocus` reads `visibleRows()` (data-only) so arrow-up/down skip over headers, matching the 1.1.0 focus model.
78
+
79
+ ### Reactive column state carries over
80
+
81
+ - Column reorder, hide, resize, and pin ALL apply to sticky rows: sticky cells register the same `colPlacement` + `pin` effects that pool cells do, so hiding the first-visible column shifts the chevron+label to the next one, reordering keeps sticky and pool aligned, and pinning applies to sticky cells too.
82
+
83
+ ### Zero-GC preservation
84
+
85
+ - Scroll fast path unchanged: 1 transform write per pool slot per boundary cross. `slotEntry` (the new per-slot computed added by M3) is allocated once at mount and re-read reactively — no new allocation per scroll.
86
+ - Grouped 100k-row / 10k boundary scrolls with sticky overlays active: **signal-node delta 0, link delta 2 (noise floor), pool delta 0** — verified via `bench/03-heap-grouped.js`.
87
+
88
+ ### Fixed — Stale `.lt-row` count in mount DOM
89
+
90
+ Consumers reading `mount.root.querySelectorAll(".lt-row").length` (as the 1.1.0 test suite does to count pool slots) previously would have picked up the sticky rows too. Sticky rows now carry only their discriminator classes (`.lt-sticky-group`, `.lt-sticky-grand-total-row`, plus their `.lt-row-group-header` / `.lt-row-grand-total` styling class). Grid layout is inlined on sticky rows so the missing `.lt-row` base rule doesn't affect them.
91
+
92
+ ### Types
93
+
94
+ `Table.d.ts` extended with:
95
+
96
+ - `AggregateSpec<Row>` type
97
+ - `GroupNode<Row>` interface
98
+ - `Entry<Row>` union — `DataEntry` | `GroupHeaderEntry` | `GrandTotalEntry`
99
+ - `ColumnDef.aggregate`, `ColumnDef.aggregateFormat`
100
+ - `CreateTableConfig.groupBy`, `initialCollapsedGroups`, `showGrandTotal`
101
+ - `TableCore.groupBy`, `collapsedGroups`, `groupedRows`, `visibleEntries`, `entryCount`
102
+ - `TableCore.setGroupBy`, `toggleGroup`, `expandGroup`, `collapseGroup`, `expandAllGroups`, `collapseAllGroups`, `isGroupCollapsed`, `groupAncestryAt`
103
+
104
+ ### Tests
105
+
106
+ - **44 headless tests** in `test/grouping.test.js`: config parsing, tree structure (single- and multi-level), aggregate types with null handling and custom-function support, leaf-fold correctness, `visibleEntries` interleave, backwards compat with the ungrouped fast path, sort within groups, filter-then-group interaction, collapse/expand/all, grand-total stability across collapse, `groupAncestryAt`, reactive propagation via signal-backed rows, edge cases.
107
+ - **17 DOM tests** in `test/grouping.dom.test.js` (happy-dom): row classes, first-cell chevron+count, aggregate cell rendering, click-to-toggle, click preserves selection, grand-total class + content, chevron flip via `data-collapsed`, `entryCount` drives virtual axis, data-row selection interleaved with headers, collapse hides data rows immediately, dispose cleanup, sticky containers in the correct viewport slots, sticky hidden when ungrouped, sticky grand-total hidden when unconfigured, hiding the first column shifts chevron to the next, column reorder keeps sticky + pool aligned, filter-row + grouping coexist.
108
+ - **All 196 pre-existing tests in the 1.1.0 suite pass unchanged.** Total: **257 tests across 14 files.**
109
+
110
+ ### Benchmarks
111
+
112
+ - **`bench/03-heap-grouped.js`** — the M3 companion to `03-heap.js`. Same 100k-row / 10k boundary-scroll shape but with `groupBy: "status"` + `showGrandTotal: true` + sticky overlays active. Signal-node delta 0, link delta 2 (noise), pool delta 0.
113
+
114
+ ### Documentation
115
+
116
+ - New **"Grouping and aggregation"** section in README with a full pipeline diagram, config tables, API surface, methods list, sticky-headers note, DOM structure, and performance notes.
117
+ - "What this is not" bullet updated: "no cell editing yet, no row groups, no aggregations" struck; only "no in-place pagination" remains (as always, all three are buildable on top).
118
+ - Testing strategy section updated: 251 → 257 tests across 14 files, plus the new `grouping.test.js` and `grouping.dom.test.js` enumerated.
119
+ - Benchmarks section adds `03-heap-grouped.js` to the bench table.
120
+ - `llms.txt` extended with the M3 grouping / aggregation surface, sticky DOM contract, and updated file inventory.
121
+
122
+ ### Demo
123
+
124
+ `demo/index.html` picks up an M3 "Group & Aggregate" panel between the Sort/Filter and Columns panels:
125
+
126
+ - **Three grouping targets** — `None` (ungrouped fast path), `By status` (single-level, 4 groups), `Region → Status` (two-level, 5 × 4 = 20 leaf groups). Active target is highlighted via a reactive `.is-active` class bound to `table.groupBy()`.
127
+ - **Expand all / Collapse all** buttons — one-click walk over `groupedRows()` to seed / clear the entire collapsed set.
128
+ - **Live group status line** — reads `groupBy()` + `entryCount()` + `collapsedGroups().size` reactively, e.g. `group: region → status · entries: 100,026 · collapsed: 0`.
129
+
130
+ The demo dataset gains a fifth `region` field (`Europe`, `Asia`, `Africa`, `Americas`, `Oceania`) to make multi-level grouping visibly meaningful. The `value` column carries an `aggregate: "sum"` with a `$X,XXX` formatter; the `id` column uses `aggregate: "count"` (renders as `N rows` in group headers). `showGrandTotal: true` pins a `Total (100,000) · $49,999,991` row to the bottom of the viewport across all grouping modes -- when a filter narrows the data, the sticky footer recomputes reactively.
131
+
132
+ The dark-theme overrides for `.lt-row-group-header` / `.lt-row-grand-total` / sticky rows are in the demo's stylesheet (the built-in library palette is tuned for a light background). Existing consumers with a dark theme can copy the same block.
133
+
134
+ Footer picks up an `Entries: N` stat next to `Rows: N` so the grouping overhead is visible at a glance (100k rows → 100,026 entries at `region → status`).
135
+
136
+ ### Compatibility
137
+
138
+ Drop-in for 1.1.0. Every M3 feature is opt-in: no `groupBy` config → no grouping, no `aggregate` on any column → no aggregation, no `showGrandTotal` → no grand total. Peer dependencies unchanged: `@zakkster/lite-signal ^1.2.1`, `@zakkster/lite-signal-dom ^1.0.1`, `@zakkster/lite-virtual ^1.1.0` (M3 uses `axis.firstIndex()` from lite-virtual 1.1.0).
139
+
140
+ ### Known limitations
141
+
142
+ - **Sticky-header vertical offset** is hard-coded to `rowHeight` (32px by default) as the column-header height. If a consumer restyles `.lt-header-cell` to a different height, sticky group headers may occlude or leave a gap below the header. A future release will surface this as a CSS variable.
143
+ - **Sticky group headers stack vertically inside `.lt-sticky-groups`**; there is no horizontal breadcrumb variant. At depth > 3, the stack starts eating meaningful viewport area. Consumers with deep hierarchies can override the sticky styling (`display: none` on `.lt-sticky-groups`) and build a compact breadcrumb using `groupAncestryAt(axis.firstIndex())`.
144
+
145
+ ---
146
+
8
147
  ## [1.1.0] — 2026-06-14
9
148
 
10
149
  Sort: plain click on a column already in a multi-column chain now cycles just that entry's direction instead of replacing the entire chain. Plain click on an un-chained column still resets to single-column sort. Shift+click semantics unchanged.
package/README.md CHANGED
@@ -52,6 +52,7 @@ Synchronous, virtual, allocation-free in the steady state. A 100,000-row scroll
52
52
  - [Export](#export)
53
53
  - [Cell editing](#cell-editing)
54
54
  - [Per-column filtering](#per-column-filtering)
55
+ - [Grouping and aggregation](#grouping-and-aggregation)
55
56
  - [Pinning and sticky offsets](#pinning-and-sticky-offsets)
56
57
  - [Flex columns](#flex-columns)
57
58
  - [Sorting](#sorting)
@@ -100,9 +101,9 @@ No microtask between `A` and `H`. No reconciliation. No diffing. Just version-st
100
101
 
101
102
  - **`createTable(config)`** -- headless `TableCore`. Reactive columns, visible rows, sort, selection, focused cell. Pure data, no DOM.
102
103
  - **`mountTable(host, table, options?)`** -- DOM mount. Builds a fixed slot pool, header bar, viewport, attaches all reactive bindings, returns `{ root, dispose }`.
103
- - **`createTable` accepts** rows (array or getter), columns, `getRowId`, `rowHeight`, `overscan`, `initialFocus`, `initialSort`.
104
- - **Reactive surface:** `visibleRows`, `rowCount`, `visibleColumns`, `displayIndexByKey`, `colTemplate`, `colPlacement`, `contentWidth`, `leftOffsets`, `rightOffsets`, `sortChain`, `focusedCell`, `selection`, `selectionAnchor`.
105
- - **Methods:** `setSort`, `addSort`, `toggleSort`, `clearSort`, `setColumnWidth`, `setColumnHidden`, `setColumnPin`, `setColumnFlex`, `setColumnOrder`, `moveColumn`, `selectRow`, `selectRowRange`, `selectAll`, `clearSelection`, `isSelected`, `moveFocus`, `cellId`, `dispose`.
104
+ - **`createTable` accepts** rows (array or getter), columns, `getRowId`, `rowHeight`, `overscan`, `initialFocus`, `initialSort`, `onCellEdit`, `groupBy`, `initialCollapsedGroups`, `showGrandTotal`.
105
+ - **Reactive surface:** `visibleRows`, `rowCount`, `filteredRows`, `visibleColumns`, `displayIndexByKey`, `colTemplate`, `colPlacement`, `contentWidth`, `leftOffsets`, `rightOffsets`, `sortChain`, `columnFilters`, `focusedCell`, `selection`, `selectionAnchor`, `editingCell`, `editingDraft`, `groupBy`, `collapsedGroups`, `groupedRows`, `visibleEntries`, `entryCount`.
106
+ - **Methods:** `setSort`, `addSort`, `toggleSort`, `clearSort`, `setColumnWidth`, `setColumnHidden`, `setColumnPin`, `setColumnFlex`, `setColumnOrder`, `moveColumn`, `setColumnFilter`, `clearColumnFilters`, `startEdit`, `commitEdit`, `cancelEdit`, `isEditing`, `exportCsv`, `exportJson`, `setGroupBy`, `toggleGroup`, `expandGroup`, `collapseGroup`, `expandAllGroups`, `collapseAllGroups`, `isGroupCollapsed`, `groupAncestryAt`, `selectRow`, `selectRowRange`, `selectAll`, `clearSelection`, `isSelected`, `moveFocus`, `cellId`, `dispose`.
106
107
  - **Per-column state:** every `ColumnState` exposes `width`, `hidden`, `pin`, `flex` as signals -- wire them directly to your own UI controls.
107
108
 
108
109
  Full type definitions ship in [`Table.d.ts`](./Table.d.ts) and are referenced from `package.json`. Every public symbol has JSDoc.
@@ -246,10 +247,18 @@ const table = createTable({
246
247
  rows: Row[] | (() => Row[]),
247
248
  columns: ColumnDef[],
248
249
  getRowId: (row: Row) => string | number,
249
- rowHeight?: number, // default 32
250
- overscan?: number, // default 4
250
+ rowHeight?: number, // default 32
251
+ overscan?: number, // default 4
251
252
  initialFocus?: CellId | null,
252
- initialSort?: SortEntry[]
253
+ initialSort?: SortEntry[],
254
+
255
+ // M2 -- editing
256
+ onCellEdit?: ({ row, columnKey, oldValue, newValue }) => void | Promise<any>,
257
+
258
+ // M3 -- grouping + aggregation
259
+ groupBy?: string | string[] | null, // default null (ungrouped fast path)
260
+ initialCollapsedGroups?: string[][], // e.g. [["Europe"], ["Asia","Books"]]
261
+ showGrandTotal?: boolean // default false
253
262
  });
254
263
  ```
255
264
 
@@ -289,6 +298,17 @@ interface ColumnDef {
289
298
  reorderable?: boolean; // default true
290
299
  accessor?: (row) => any; // default row[key]
291
300
  compare?: (a, b) => number; // default null-safe numeric/string
301
+
302
+ // M2
303
+ editable?: boolean; // opt-in for cell editing
304
+ filterable?: boolean; // opt-in for per-column filter
305
+ filter?: (value, query, row) => boolean; // custom filter predicate
306
+ filterPlaceholder?: string; // default "Filter…"
307
+
308
+ // M3
309
+ aggregate?: "sum" | "avg" | "min" | "max" | "count" // reducer for group + total cells
310
+ | ((rows, col) => any) | null;
311
+ aggregateFormat?: (value, col, count) => string; // display formatter (raw stays available)
292
312
  }
293
313
  ```
294
314
 
@@ -324,8 +344,9 @@ These are live signals. `column.width.set(160)` updates the CSS template, the st
324
344
  ```ts
325
345
  table.columns // ColumnState[]
326
346
  table.rowsGetter() // current row source
327
- table.visibleRows() // Computed<Row[]> -- sort applied
328
- table.rowCount() // Computed<number>
347
+ table.visibleRows() // Computed<Row[]> -- sort applied, data-only
348
+ table.rowCount() // Computed<number> -- data-only
349
+ table.filteredRows() // Computed<Row[]> -- post-filter, pre-sort (M2)
329
350
  table.columnOrder() // Signal<string[]>
330
351
  table.visibleColumns() // Computed<ColumnState[]>
331
352
  table.displayIndexByKey() // Computed<Map<key, 0-indexed display pos>>
@@ -335,9 +356,20 @@ table.contentWidth() // Computed<number> -- sum of widths
335
356
  table.leftOffsets() // Computed<Map<key, cumulative left px>>
336
357
  table.rightOffsets() // Computed<Map<key, cumulative right px>>
337
358
  table.sortChain() // Signal<SortEntry[]>
359
+ table.columnFilters() // Signal<ReadonlyMap<key, query>> (M2)
338
360
  table.focusedCell() // Signal<CellId | null>
339
- table.selection() // Signal<Set<RowId>>
361
+ table.selection() // Signal<{mode, set}> -- predicate, not a list
340
362
  table.selectionAnchor() // Signal<RowId | null>
363
+ table.selectedCount() // Computed<number> -- reactive O(1)
364
+ table.editingCell() // Signal<{rowId, columnKey} | null> (M2)
365
+ table.editingDraft() // Signal<string> (M2)
366
+
367
+ // M3: grouping + aggregation
368
+ table.groupBy() // Signal<string[]> -- current group keys ([] = ungrouped)
369
+ table.collapsedGroups() // Signal<Set<string>> -- path-strings (U+001F separator)
370
+ table.groupedRows() // Computed<GroupNode[] | null> -- null when ungrouped
371
+ table.visibleEntries() // Computed<Entry[]> -- data + group-headers + grand-total interleaved
372
+ table.entryCount() // Computed<number> -- length of visibleEntries; drives the axis
341
373
  ```
342
374
 
343
375
  All `()` calls are tracked reads -- `effect(() => log(table.rowCount()))` will re-run whenever the row source changes.
@@ -389,6 +421,16 @@ table.editingDraft() // current in-progress string
389
421
  table.exportCsv({ rows?, columns?, delimiter?, quote?, headers?, newline?, bom?, formatter? }) // → string
390
422
  table.exportJson({ rows?, columns?, indent?, format?, formatter? }) // → string | object[]
391
423
 
424
+ // Grouping + aggregation (M3)
425
+ table.setGroupBy(v) // string | string[] | null; unknown keys dropped
426
+ table.toggleGroup(path) // flip collapsed for one group path
427
+ table.expandGroup(path) // no-op if already expanded
428
+ table.collapseGroup(path) // no-op if already collapsed
429
+ table.expandAllGroups() // clears collapsed set
430
+ table.collapseAllGroups() // walks groupedRows() once
431
+ table.isGroupCollapsed(path) // O(1) predicate
432
+ table.groupAncestryAt(entryIndex) // GroupHeaderEntry[] -- for sticky headers
433
+
392
434
  // Lifecycle
393
435
  table.dispose()
394
436
  ```
@@ -650,6 +692,161 @@ The fast path returns the source array identity when no filter is active, so jus
650
692
 
651
693
  ---
652
694
 
695
+ ## Grouping and aggregation
696
+
697
+ Group rows by one or more column keys and fold per-group aggregates. Group headers are pool slots like any other row -- same reactive rendering path, same zero-GC scroll math. There are no extra DOM primitives: a group header IS a `.lt-row` with a discriminator class.
698
+
699
+ ```js
700
+ import { createTable, mountTable } from "@zakkster/lite-table";
701
+
702
+ const table = createTable({
703
+ rows,
704
+ columns: [
705
+ { key: "id", width: 80 },
706
+ { key: "region", width: 120 },
707
+ { key: "status", width: 120 },
708
+ { key: "value", width: 140, compare: (a, b) => a - b,
709
+ aggregate: "sum",
710
+ aggregateFormat: (v) => "$" + v.toFixed(0) }
711
+ ],
712
+ getRowId: r => r.id,
713
+ groupBy: ["region", "status"], // multi-level
714
+ showGrandTotal: true // pinned "Total (N)" entry at tail
715
+ });
716
+ mountTable(host, table);
717
+ ```
718
+
719
+ Clicking a group header row toggles its collapse; a chevron in the first cell flips between `▼` and `▶`. Aggregates render in the columns that declared an `aggregate` -- other columns stay blank on header rows.
720
+
721
+ ### The pipeline
722
+
723
+ ```
724
+ rowsGetter -> filteredRows -> groupedRows -> visibleEntries -> visibleRows
725
+ │ │
726
+ │ └── pool slots + sticky headers
727
+ └── sort applies WITHIN each leaf group
728
+ ```
729
+
730
+ - Filter runs BEFORE grouping. Empty groups vanish -- no dead headers.
731
+ - Sort chain applies WITHIN leaves. Groups themselves are ordered by group key ascending; `null` values bucket last.
732
+ - Aggregates always fold over leaf rows at every depth. That's the safe default for reducers that don't compose (median, last-value-wins, `distinct-count`). Sum/avg/min/max/count don't care; you can supply a custom reducer if you have one that needs different semantics.
733
+
734
+ ### Column config
735
+
736
+ | Field | Type | Purpose |
737
+ | ----------------- | ------------------------------------------ | --------------------------------------------------------------------------- |
738
+ | `aggregate` | `"sum" \| "avg" \| "min" \| "max" \| "count"` or `(rows, col) => any` | The reducer. Nullish values are skipped for `sum`/`avg`/`min`/`max`; `count` counts rows unconditionally. Custom function receives the leaf-row array. |
739
+ | `aggregateFormat` | `(value, col, count) => string` | Display formatter for header + grand-total cells. `entry.aggregates.get(key)` still returns the raw so exports stay authoritative. Errors caught + logged. |
740
+
741
+ ### createTable config
742
+
743
+ | Field | Type | Default |
744
+ | ------------------------ | ----------------------------------- | ------- |
745
+ | `groupBy` | `string \| string[] \| null` | `null` |
746
+ | `initialCollapsedGroups` | `string[][]` | `[]` |
747
+ | `showGrandTotal` | `boolean` | `false` |
748
+
749
+ Unknown keys in `groupBy` are silently dropped, so persisted state that outlives a column config survives without crashes.
750
+
751
+ ### Reactive surface
752
+
753
+ ```js
754
+ table.groupBy() // Signal<string[]>
755
+ table.collapsedGroups() // Signal<Set<string>> — path strings
756
+ table.groupedRows() // Computed<GroupNode[] | null> — null when ungrouped
757
+ table.visibleEntries() // Computed<Entry[]> — interleaved for rendering
758
+ table.entryCount() // Computed<number> — drives the virtual axis
759
+ table.visibleRows() // Computed<Row[]> — data-only; unchanged 1.1.0 contract
760
+ table.rowCount() // Computed<number> — data-only; unchanged
761
+ ```
762
+
763
+ An `Entry` is one of:
764
+
765
+ ```
766
+ { type: "data", row }
767
+ { type: "group-header", depth, key, value, path, pathStr, count, aggregates, isCollapsed }
768
+ { type: "grand-total", aggregates, count }
769
+ ```
770
+
771
+ `path` is the array of ancestor group values (e.g., `["Europe", "Books"]`); `pathStr` is the U+001F-separated form used as the collapsed-set key. The mount layer dispatches per-slot rendering on `entry.type`; consumers driving their own renderer do the same.
772
+
773
+ ### Methods
774
+
775
+ ```js
776
+ table.setGroupBy(key | keys | null) // idempotent; unknown keys dropped
777
+ table.toggleGroup(path)
778
+ table.expandGroup(path)
779
+ table.collapseGroup(path)
780
+ table.expandAllGroups()
781
+ table.collapseAllGroups() // walks groupedRows() once
782
+ table.isGroupCollapsed(path) // O(1) predicate
783
+ table.groupAncestryAt(entryIndex) // for sticky group headers
784
+ ```
785
+
786
+ ### Ungrouped fast path
787
+
788
+ When `groupBy()` is empty, `groupedRows` short-circuits to `null` and `visibleRows` returns the sort-applied rows directly -- byte-identical to 1.1.0 behavior. Non-grouping tables pay one signal read and nothing else.
789
+
790
+ ### Sticky group headers + grand total
791
+
792
+ Both are built in. Sticky group headers glue below the column header and reactively track the active ancestor group at every depth level as the user scrolls -- click one to collapse its subtree. Sticky grand total glues to the bottom of the viewport whenever `showGrandTotal: true`.
793
+
794
+ Under the hood, both are `position: sticky` zero-height containers with absolute-positioned rows -- they don't reserve scroll space and don't interfere with pool math. They render only when needed (no sticky container when ungrouped; no footer when grand total isn't configured). Nothing to opt into and nothing to opt out of.
795
+
796
+ The sticky ancestor lookup uses `axis.firstIndex()` (not `axis.start()`) so sticky matches what's under the column-header line -- `start` includes overscan slots above the viewport and would show ancestors of a not-yet-visible entry.
797
+
798
+ If you need a custom sticky design (e.g. a breadcrumb variant, a different offset for an app-level top bar), hide the built-ins with `display: none` on `.lt-sticky-groups` / `.lt-sticky-grand-total` and rebuild the same in ~20 lines using the exposed primitive:
799
+
800
+ ```js
801
+ import { effect } from "@zakkster/lite-signal";
802
+
803
+ effect(() => {
804
+ const ancestors = table.groupAncestryAt(mount.axis.firstIndex());
805
+ // render `ancestors` however you like -- click each to toggleGroup(a.path)
806
+ });
807
+ ```
808
+
809
+ ### Rendered DOM
810
+
811
+ Group headers render as:
812
+
813
+ ```html
814
+ <div class="lt-row lt-row-group-header" data-depth="N" data-collapsed="true|false">
815
+ <div class="lt-cell" data-key="…">▼ Asia (100)</div>
816
+ <div class="lt-cell" data-key="…"></div>
817
+ <div class="lt-cell" data-key="…">$5,092</div>
818
+ ...
819
+ </div>
820
+ ```
821
+
822
+ One cell per column matching the data-row grid template. The first visible cell holds the chevron + value + count; other cells hold their column's aggregate (or empty if the column has no aggregate). Aggregate cells right-align with tabular-numerics for column alignment across headers.
823
+
824
+ Grand-total rows use `<div class="lt-row lt-row-grand-total">` with the same cell structure; the first cell reads `Total (N)`.
825
+
826
+ Sticky group headers live in `<div class="lt-sticky-groups">` -- a direct child of `.lt-viewport`, before `.lt-inner`. Each depth's row also carries `.lt-sticky-group` so you can restyle them independently of pool-rendered headers. Sticky grand total lives in `<div class="lt-sticky-grand-total">` (after `.lt-inner`) with a `.lt-sticky-grand-total-row` inside. Sticky rows do NOT carry the base `.lt-row` class so `querySelectorAll(".lt-row")` still counts only pool slots.
827
+
828
+ ### Interaction
829
+
830
+ - **Click on a group-header cell** (inline or sticky) toggles the group's collapse.
831
+ - **Click on a grand-total row** is ignored (no selection change, no side effect).
832
+ - **Selection / edit / focus** are gated on `entry.type === "data"`. Clicking a header doesn't clear the row selection, editable cells stay non-editing on headers, and arrow-key focus moves stay inside data rows.
833
+
834
+ ### Performance notes
835
+
836
+ - Grouping is O(N) per rebuild (walk + bucket + per-leaf sort). Rebuilds fire on `filteredRows()`, `sortChain()`, or `groupBy()` changes -- never on scroll.
837
+ - Aggregates fold once per rebuild. For deep trees this dominates; a 100k-row / 3-level table with 4 aggregates folds in a few ms.
838
+ - Scroll cost stays at 1 transform write per pool slot per boundary cross, unchanged from 1.1.0. `axis.setCount(entryCount())` bumps when collapse changes -- boundary math re-derives on the next frame.
839
+ - Signal-node overhead: adding `aggregate` to a column costs one entry in a Map. Adding `groupBy` costs one extra computed per slot (`slotEntry`) beyond the 1.1.0 per-cell effects. For the recommended 6-col / 24-slot table, expect ~950 nodes with aggregates + grouping enabled -- still under the default 1024 registry cap. Bump the cap explicitly for larger footprints:
840
+
841
+ ```js
842
+ import { createRegistry, setDefaultRegistry } from "@zakkster/lite-signal";
843
+ setDefaultRegistry(createRegistry({ initialNodes: 4096 }));
844
+ ```
845
+
846
+ Verified zero-GC: 10,000 boundary scrolls across a 100,000-row grouped view with sticky overlays active produces signal-node delta 0, pool delta 0. See `bench/03-heap-grouped.js`.
847
+
848
+ ---
849
+
653
850
  ## Pinning and sticky offsets
654
851
 
655
852
  Pinned columns use `position: sticky` with cumulative offsets. The math is:
@@ -820,13 +1017,14 @@ setDefaultRegistry(createRegistry({
820
1017
 
821
1018
  ## Benchmarks
822
1019
 
823
- Four benchmarks, all in `bench/`:
1020
+ Five benchmarks, all in `bench/`:
824
1021
 
825
1022
  | Bench | Measures |
826
1023
  | ---------------------------------- | ----------------------------------------------------------- |
827
1024
  | `01-scroll-writes.js` | DOM allocations vs in-place updates per scroll boundary cross, against clusterize.js + a naive virtual implementation |
828
1025
  | `02-mount.js` | Time to first paint at 1k / 10k / 100k / 1M rows |
829
- | `03-heap.js` | Steady-state heap delta + signal-node growth across 10k boundary scrolls |
1026
+ | `03-heap.js` | Steady-state heap delta + signal-node growth across 10k boundary scrolls (ungrouped) |
1027
+ | `03-heap-grouped.js` | Same shape as `03-heap.js` but with `groupBy: "status"` + `showGrandTotal: true` + sticky overlays active |
830
1028
  | `04-sort.js` | 100k-row sort + cached re-read + toggle cycle |
831
1029
 
832
1030
  Representative numbers on a 2016 MacBook (your hardware will be different; relative ordering is what matters):
@@ -872,7 +1070,7 @@ Two tiers, all reproducible.
872
1070
 
873
1071
  ### Tier 1 -- Behavior (unit tests, fast)
874
1072
 
875
- `npm test` runs the suite in `test/`, 110 tests across 9 files:
1073
+ `npm test` runs the suite in `test/`, **257 tests across 14 files**:
876
1074
 
877
1075
  - **`core.test.js`** -- `createTable` API surface, reactive `visibleColumns`, `colTemplate`, `colPlacement` consistency under hide/pin/reorder, sort chain semantics, dispose idempotency.
878
1076
  - **`dom.test.js`** -- `mountTable` produces correct DOM structure, header cells, slot pool sized to viewport, ARIA roles + indices, `aria-activedescendant` updates on focus moves, dispose tears down all bindings.
@@ -883,6 +1081,11 @@ Two tiers, all reproducible.
883
1081
  - **`columns.test.js`** -- `setColumnWidth` clamping, `setColumnHidden`, `setColumnPin`, `setColumnFlex` and the **pinning-suspends-flex** invariant (regression for the offset-vs-rendered-width mismatch), `moveColumn` cases, `colTemplate` segment count consistency with `colPlacement`.
884
1082
  - **`keyboard.test.js`** -- `moveFocus` arrow / Home / End / PageUp / PageDown, focus clamps at edges, focus survives row reorder.
885
1083
  - **`extras.test.js`** -- `scrollToIndex` × 3 align modes, pointer-driven column resize + reorder, `injectStyles:false`, mount-disposes-table lifecycle, null / zero / string-ID cells, `addSort(null)` removal, `moveFocus` from null focus, 50-column stress, steady-state graph stability under 1000 sort flips + 1000 selection toggles + 500 resize ops.
1084
+ - **`export.test.js`** -- RFC-4180 escaping, all option combinations, edge cases including 10k-row dataset finishing < 500ms (M1.1).
1085
+ - **`scope-dispose.test.js`** -- the M1.1 dispose-leak fix, dispose idempotency, 50-cycle round trip.
1086
+ - **`m2.test.js`** -- filtering basics, case insensitivity, multi-column AND, custom predicates, predicate receiving the full row, filter+sort interaction, filter+export, null/undefined handling, edit state transitions, all startEdit/commitEdit/cancelEdit semantics, onCellEdit handler error containment, edit+filter interaction, edit+dispose, numeric column unchanged-guard.
1087
+ - **`grouping.test.js`** (new in 1.2.0) -- config parsing, tree structure (single- and multi-level), aggregate types with null handling and custom-function support, leaf-fold correctness, `visibleEntries` interleave, backwards compat with the ungrouped fast path, sort within groups, filter-then-group interaction, collapse/expand/all, grand-total stability across collapse, `groupAncestryAt`, reactive propagation via signal-backed rows, edge cases.
1088
+ - **`grouping.dom.test.js`** (new in 1.2.0) -- row classes, first-cell chevron+count, aggregate cell rendering, click-to-toggle, click preserves selection, grand-total class + content, chevron flip via `data-collapsed`, `entryCount` drives virtual axis, data-row selection interleaved with headers, collapse hides data rows immediately, dispose cleanup, sticky containers in the correct viewport slots, sticky hidden when ungrouped, sticky grand-total hidden when unconfigured, hiding the first column shifts chevron to the next, column reorder keeps sticky + pool aligned, filter-row + grouping coexist.
886
1089
 
887
1090
  ```bash
888
1091
  npm test
@@ -890,19 +1093,20 @@ npm test
890
1093
 
891
1094
  ### Tier 2 -- Memory (zero-GC verification)
892
1095
 
893
- The `03-heap.js` bench is the production-style memory invariant: 10,000 boundary scrolls, 100,000 rows, must show **zero signal-node growth, zero link growth, zero pool growth**, and a heap delta inside V8's noise floor.
1096
+ The `03-heap.js` bench is the production-style memory invariant: 10,000 boundary scrolls, 100,000 rows, must show **zero signal-node growth, zero link growth, zero pool growth**, and a heap delta inside V8's noise floor. `03-heap-grouped.js` is the same invariant for a grouped view with sticky overlays active.
894
1097
 
895
1098
  ```bash
896
1099
  node --expose-gc bench/03-heap.js
1100
+ node --expose-gc bench/03-heap-grouped.js
897
1101
  ```
898
1102
 
899
- If this fails, something allocates in the hot scroll path and we want to find it before publish.
1103
+ If either fails, something allocates in the hot scroll path and we want to find it before publish.
900
1104
 
901
1105
  ---
902
1106
 
903
1107
  ## What this is not
904
1108
 
905
- - **A general-purpose grid component.** No cell editing yet, no row groups, no aggregations, no in-place pagination. The headless core makes those buildable on top, but they're not in the box.
1109
+ - **A general-purpose grid component.** No in-place pagination. The headless core makes it buildable on top, but it's not in the box.
906
1110
  - **A perfect fit for every workload.** For a 50-row, no-virtualization-needed table, the slot pool is over-engineering -- a plain `<table>` is simpler and just as fast. `lite-table` shines starting at ~1,000 rows or when the columns and rows are independently reactive.
907
1111
  - **A renderer.** It owns the DOM topology and the bindings between reactive sources and DOM properties. It does not own your row data, your filtering pipeline, or your data fetching.
908
1112
  - **A library for the server.** It works in Node under `happy-dom` for tests, but there's no SSR story. Use it on the client.
@@ -921,7 +1125,7 @@ Built on the `@zakkster/lite-*` zero-GC ESM family. All MIT.
921
1125
  **Helpers**
922
1126
  - [`@zakkster/lite-persist`](https://www.npmjs.com/package/@zakkster/lite-persist) -- drop-in localStorage / IndexedDB / file persistence for `Signal` and `Computed`. Use it for column-state, sort, and selection persistence across sessions.
923
1127
 
924
- The package tree is intentionally small. `lite-table` itself is a single file ESM module (~1300 lines including types-in-JSDoc), zero runtime dependencies beyond the three substrate packages.
1128
+ The package tree is intentionally small. `lite-table` itself is a single file ESM module (~3100 lines including types-in-JSDoc and inline design commentary), zero runtime dependencies beyond the three substrate packages.
925
1129
 
926
1130
  ---
927
1131
 
@@ -1123,9 +1327,9 @@ Two patterns. Either render a checkbox character in the cell's `accessor` (`acce
1123
1327
  ## npm scripts
1124
1328
 
1125
1329
  ```bash
1126
- npm test # 110 tests across 9 files (node:test, --expose-gc)
1330
+ npm test # 257 tests across 14 files (node:test, --expose-gc)
1127
1331
  npm run demo # zero-dep static server on http://localhost:8080
1128
- npm run bench # all four benches sequentially (output: text or --md)
1332
+ npm run bench # all five benches sequentially (output: text or --md)
1129
1333
  ```
1130
1334
 
1131
1335
  To run a single bench, invoke it directly:
@@ -1134,6 +1338,7 @@ To run a single bench, invoke it directly:
1134
1338
  node --expose-gc bench/01-scroll-writes.js
1135
1339
  node --expose-gc bench/02-mount.js
1136
1340
  node --expose-gc bench/03-heap.js
1341
+ node --expose-gc bench/03-heap-grouped.js
1137
1342
  node --expose-gc bench/04-sort.js
1138
1343
  ```
1139
1344