@zakkster/lite-table 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +344 -0
- package/README.md +564 -19
- package/Table.d.ts +258 -1
- package/Table.js +1644 -116
- package/llms.txt +291 -6
- package/package.json +15 -9
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,350 @@ 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
|
+
|
|
147
|
+
## [1.1.0] — 2026-06-14
|
|
148
|
+
|
|
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.
|
|
150
|
+
|
|
151
|
+
Two feature additions and two bug fixes since 1.0.0. All API additions are
|
|
152
|
+
opt-in; all consumers of 1.0.0 are unaffected unless they enable the new
|
|
153
|
+
features per-column.
|
|
154
|
+
|
|
155
|
+
### Added — Export
|
|
156
|
+
|
|
157
|
+
- **`table.exportCsv(opts)`** — RFC-4180-compliant CSV string.
|
|
158
|
+
- `rows`: `"visible"` (default) / `"all"` / `"selected"` / explicit array
|
|
159
|
+
- `columns`: `"visible"` (default) / `"all"` / array of keys (projection + reordering)
|
|
160
|
+
- `delimiter`, `quote`, `headers`, `newline`, `bom` for format control
|
|
161
|
+
- `formatter: (row, col) => unknown` per-cell hook (runs before CSV escaping)
|
|
162
|
+
- Numbers stringify naturally; null/undefined become empty fields; embedded
|
|
163
|
+
quotes are doubled; fields containing the delimiter, the quote, CR, or LF
|
|
164
|
+
are quoted. UTF-8 BOM via `bom: true` for Excel-on-Windows.
|
|
165
|
+
|
|
166
|
+
- **`table.exportJson(opts)`** — JSON string or array.
|
|
167
|
+
- Same `rows` + `columns` selectors as `exportCsv`
|
|
168
|
+
- `format: "string"` (default) returns a JSON string; `"array"` returns the
|
|
169
|
+
projected array directly (skip `JSON.stringify`, useful for piping into
|
|
170
|
+
IndexedDB / postMessage / structured clone)
|
|
171
|
+
- `indent`, `formatter` options
|
|
172
|
+
- Fast path: `columns: "all"` + no formatter returns a shallow `rows.slice()`,
|
|
173
|
+
not deep copies — row identity preserved.
|
|
174
|
+
|
|
175
|
+
### Added — Cell editing
|
|
176
|
+
|
|
177
|
+
- **`ColumnDef.editable?: boolean`** — opt-in per column.
|
|
178
|
+
- **`CreateTableConfig.onCellEdit?: ({ row, columnKey, oldValue, newValue }) => void`**
|
|
179
|
+
— commit hook. lite-table never mutates rows itself; the handler is the
|
|
180
|
+
consumer's hook to write to a row, a backend, or a store.
|
|
181
|
+
- **`newValue` is always a string** (from contenteditable / `editingDraft`).
|
|
182
|
+
Consumers editing non-string columns are responsible for coercion in their
|
|
183
|
+
handler (e.g. `row.value = Number(newValue)`).
|
|
184
|
+
- Skipped when the change would be a no-op: the guard compares
|
|
185
|
+
`String(oldValue) !== newValue`, so pressing Enter on a numeric column
|
|
186
|
+
without typing doesn't spuriously fire the hook.
|
|
187
|
+
- Errors thrown by the handler are caught + logged.
|
|
188
|
+
|
|
189
|
+
- **`table.editingCell: Signal<{ rowId, columnKey } | null>`** — current edit
|
|
190
|
+
target. Keyed on row identity so edits survive scroll, sort, filter, and
|
|
191
|
+
slot recycling.
|
|
192
|
+
- **`table.editingDraft: Signal<string>`** — in-progress edit value
|
|
193
|
+
(mountTable writes to this from `input` events).
|
|
194
|
+
- **`table.startEdit(rowId, columnKey)`** — start editing. No-op on
|
|
195
|
+
non-editable columns. Auto-commits any in-flight edit on a different cell.
|
|
196
|
+
Idempotent on the same cell.
|
|
197
|
+
- **`table.commitEdit(value?)`** — commit. With no argument, reads from
|
|
198
|
+
`editingDraft`. Skips `onCellEdit` when value unchanged (string-coerced).
|
|
199
|
+
- **`table.cancelEdit()`** — discard the in-flight edit.
|
|
200
|
+
- **`table.isEditing(rowId, columnKey)`** — O(1) predicate.
|
|
201
|
+
|
|
202
|
+
- **DOM wiring** (when mounted via `mountTable`):
|
|
203
|
+
- Double-click on an editable cell starts editing.
|
|
204
|
+
- F2 / Enter on the focused cell starts editing if the column is editable.
|
|
205
|
+
- Enter commits + moves focus down (spreadsheet idiom).
|
|
206
|
+
- Tab / Shift+Tab commit + move focus right / left.
|
|
207
|
+
- Escape cancels + restores root focus.
|
|
208
|
+
- Blur on the cell commits.
|
|
209
|
+
- The active editing cell gets `contenteditable="true"`, the
|
|
210
|
+
`.lt-cell.is-editing` class (default style: orange outline + white-space
|
|
211
|
+
normal), and its `textContent` write binding is suspended while editing
|
|
212
|
+
so user keystrokes aren't clobbered by reactive paint.
|
|
213
|
+
|
|
214
|
+
### Added — Per-column filtering
|
|
215
|
+
|
|
216
|
+
- **`ColumnDef.filterable?: boolean`** — opt-in per column.
|
|
217
|
+
- **`ColumnDef.filter?: (value, query, row) => boolean`** — custom predicate.
|
|
218
|
+
Defaults to case-insensitive substring match on the stringified value.
|
|
219
|
+
- **`ColumnDef.filterPlaceholder?: string`** — placeholder text for the
|
|
220
|
+
filter input. Default `"Filter…"`.
|
|
221
|
+
|
|
222
|
+
- **`table.columnFilters: Signal<ReadonlyMap<string, string>>`** — reactive
|
|
223
|
+
filter state.
|
|
224
|
+
- **`table.filteredRows: Computed<readonly Row[]>`** — rows post-filter,
|
|
225
|
+
pre-sort. `visibleRows` reads from here.
|
|
226
|
+
- **`table.setColumnFilter(key, value)`** — pass `null`/`undefined`/`""`/
|
|
227
|
+
whitespace-only to clear that column. No-op on non-filterable or unknown
|
|
228
|
+
columns.
|
|
229
|
+
- **`table.clearColumnFilters()`** — clear all. No notify if already empty.
|
|
230
|
+
|
|
231
|
+
- **DOM wiring** (when mounted via `mountTable`):
|
|
232
|
+
- A `.lt-filter-row` is mounted between header and viewport if any column
|
|
233
|
+
has `filterable: true`. One `<input>` per filterable column, two-way
|
|
234
|
+
bound to `columnFilters`.
|
|
235
|
+
- Escape on a filter input clears that column.
|
|
236
|
+
- The filter row participates in the grid layout (shares `--lt-cols` with
|
|
237
|
+
header and rows) and respects pin offsets + hidden state.
|
|
238
|
+
- Sticky `top: var(--lt-header-height, 32px)` so the row stays visible
|
|
239
|
+
during vertical scroll. Override `--lt-header-height` on the root if you
|
|
240
|
+
restyle the header to a different height.
|
|
241
|
+
|
|
242
|
+
### Pipeline
|
|
243
|
+
|
|
244
|
+
```
|
|
245
|
+
rowsGetter() → filteredRows → visibleRows → exports / mount / etc.
|
|
246
|
+
▲ ▲
|
|
247
|
+
│ └─ sort applied here
|
|
248
|
+
└─ filters applied here
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
This means `exportCsv({ rows: "visible" })` is already filtered + sorted,
|
|
252
|
+
which is the expected "what the user sees" semantic.
|
|
253
|
+
|
|
254
|
+
### Fixed — `createScope` dispose leak (M1.0 latent bug)
|
|
255
|
+
|
|
256
|
+
`createScope` no longer leaks signals/computeds on `dispose()`. The v1.0.0
|
|
257
|
+
cleanup loop pushed signal/computed handles (which are callable) and then
|
|
258
|
+
iterated calling `c()` — which READS the signal, not disposes it. Every
|
|
259
|
+
`createTable` + `dispose()` cycle was leaving its ~24 reactive nodes pinned
|
|
260
|
+
in the lite-signal registry, eventually exhausting capacity in long-running
|
|
261
|
+
test runs or apps that create/destroy tables. The fix discriminates entries
|
|
262
|
+
by kind (`KIND_NODE` for handles → lite-signal `dispose()`; `KIND_THUNK` for
|
|
263
|
+
effect disposers, event removers, user `onCleanup`).
|
|
264
|
+
|
|
265
|
+
Verified: a `createTable` allocates 24 nodes; after `t.dispose()` the count
|
|
266
|
+
returns to 0. 50 create+dispose cycles round-trip cleanly with `activeNodes`
|
|
267
|
+
flat.
|
|
268
|
+
|
|
269
|
+
### Fixed — `commitEdit` unchanged-guard coerces oldValue
|
|
270
|
+
|
|
271
|
+
`commitEdit` compares `String(oldValue) !== newValue` before firing
|
|
272
|
+
`onCellEdit`. Earlier drafts used strict `!==`, which fired the handler on
|
|
273
|
+
no-op Enter for any non-string column (a `100` cell compared to `"100"`
|
|
274
|
+
fails strict equality and looked like a change).
|
|
275
|
+
|
|
276
|
+
### Demo
|
|
277
|
+
|
|
278
|
+
A new `demo/` directory ships with the package source (not published to
|
|
279
|
+
npm). The demo is a 5000-row paginated grid driven entirely by reactive
|
|
280
|
+
signals:
|
|
281
|
+
|
|
282
|
+
- **Page-size dropdown** (10 / 25 / 50 / 100 / all)
|
|
283
|
+
- **Pagination via reactive row source**: `rows: () => allRows.slice(...)`
|
|
284
|
+
where the slice is driven by `pageIndex()` and `pageSize()` signals
|
|
285
|
+
- **Three export buttons**: visible page (with role-titlecasing formatter),
|
|
286
|
+
selection across master, all 5000 as JSON
|
|
287
|
+
- **Per-column filter inputs** on name/email/role/team/value, with the value
|
|
288
|
+
column demonstrating a custom predicate (`>N` / `<N` / exact / substring)
|
|
289
|
+
- **Editable cells** on name/email/role/team/value with an `onCellEdit`
|
|
290
|
+
hook that mutates the master array + bumps a `rowsVersion` signal that
|
|
291
|
+
the rowsGetter reads (the documented pattern for "consumer mutates row
|
|
292
|
+
data, wants the table to repaint without changing pagination/filter
|
|
293
|
+
state")
|
|
294
|
+
- **Clear filters** + **edit log** showing the last commit
|
|
295
|
+
- Larger lite-signal registry configured up front (4096 nodes, growth
|
|
296
|
+
policy) — documented pattern for any non-trivial table app
|
|
297
|
+
|
|
298
|
+
### Tests
|
|
299
|
+
|
|
300
|
+
- **80 unit tests** across `test/export.test.js` (41: RFC-4180 escaping, all
|
|
301
|
+
option combinations, edge cases including 10k-row dataset finishing <
|
|
302
|
+
500ms), `test/scope-dispose.test.js` (5: the leak fix, dispose idempotency,
|
|
303
|
+
50-cycle round trip), and `test/m2.test.js` (34: filtering basics, case
|
|
304
|
+
insensitivity, multi-column AND, custom predicates, predicate receiving
|
|
305
|
+
the full row, filter+sort interaction, filter+export, null/undefined
|
|
306
|
+
handling, edit state transitions, all startEdit/commitEdit/cancelEdit
|
|
307
|
+
semantics, onCellEdit handler error containment, edit+filter interaction,
|
|
308
|
+
edit+dispose, numeric column unchanged-guard).
|
|
309
|
+
- **32 browser tests** across `test-browser/demo.spec.js` (14: pagination
|
|
310
|
+
behavior, page-size dropdown, export buttons, leak-free across 50
|
|
311
|
+
createTable+dispose cycles in the browser) and `test-browser/m2.spec.js`
|
|
312
|
+
(18: filter row rendering, two-way input binding, Escape clearing,
|
|
313
|
+
custom predicate, multi-filter AND, double-click editing, F2, Enter to
|
|
314
|
+
commit, Tab to commit + move, Escape to cancel, blur to commit,
|
|
315
|
+
unchanged-value skip, second-cell auto-commit).
|
|
316
|
+
|
|
317
|
+
### Documentation
|
|
318
|
+
|
|
319
|
+
- New "Export" section in README with full `rows` / `columns` selector
|
|
320
|
+
tables, per-format option tables, paginated-getter pitfall note, and a
|
|
321
|
+
copy-pasteable `downloadFile` helper.
|
|
322
|
+
- New "Cell editing" section with commit semantics, programmatic editing
|
|
323
|
+
pattern, reactive surface, and a note that `newValue` is always a string.
|
|
324
|
+
- New "Per-column filtering" section with predicate contract, pipeline
|
|
325
|
+
diagram, reactive surface, keyboard, and the `--lt-header-height`
|
|
326
|
+
customization point.
|
|
327
|
+
- New "Client-side pagination via a reactive row source" recipe in the
|
|
328
|
+
Integration recipes section.
|
|
329
|
+
- TOC updated.
|
|
330
|
+
- Types: `ExportRowsSource`, `ExportColumnsSelector`, `ExportCsvOptions`,
|
|
331
|
+
`ExportJsonOptions`, `EditingCell`, `CellEditPayload` interfaces added to
|
|
332
|
+
`Table.d.ts`. `ColumnDef`, `ColumnState`, `CreateTableConfig`, `TableCore`
|
|
333
|
+
all extended with the new surfaces.
|
|
334
|
+
|
|
335
|
+
### Compatibility
|
|
336
|
+
|
|
337
|
+
Drop-in for v1.0.0 consumers. The new methods are additive; both editing
|
|
338
|
+
and filtering are opt-in per column. The scope-dispose fix only changes
|
|
339
|
+
lifecycle behavior (more thorough cleanup, no leaks). Peer dependencies
|
|
340
|
+
unchanged (`@zakkster/lite-signal ^1.2.0`).
|
|
341
|
+
|
|
342
|
+
### Known limitations
|
|
343
|
+
|
|
344
|
+
- Editing during scroll: if the edited row scrolls out of view, the edit
|
|
345
|
+
state survives (it's keyed on rowId) but the `contenteditable` cell
|
|
346
|
+
unmounts as the slot recycles. When the row scrolls back, editing
|
|
347
|
+
resumes with the draft preserved. To force a commit on scroll, call
|
|
348
|
+
`table.commitEdit()` from your scroll handler.
|
|
349
|
+
|
|
350
|
+
---
|
|
351
|
+
|
|
8
352
|
## [1.0.0] — 2026-06 (M1 stable)
|
|
9
353
|
|
|
10
354
|
First public release. The headless core + DOM mount, virtualized rows on a
|