@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 +139 -0
- package/README.md +223 -18
- package/Table.d.ts +124 -0
- package/Table.js +973 -147
- package/llms.txt +170 -4
- package/package.json +5 -3
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,
|
|
250
|
-
overscan?: number,
|
|
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<
|
|
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
|
-
|
|
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/`,
|
|
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
|
|
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
|
|
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 (~
|
|
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 #
|
|
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
|
|
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
|
|