@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.
Files changed (6) hide show
  1. package/CHANGELOG.md +344 -0
  2. package/README.md +564 -19
  3. package/Table.d.ts +258 -1
  4. package/Table.js +1644 -116
  5. package/llms.txt +291 -6
  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