@zakkster/lite-table 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,211 @@ 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.1.0] — 2026-06-14
9
+
10
+ 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.
11
+
12
+ Two feature additions and two bug fixes since 1.0.0. All API additions are
13
+ opt-in; all consumers of 1.0.0 are unaffected unless they enable the new
14
+ features per-column.
15
+
16
+ ### Added — Export
17
+
18
+ - **`table.exportCsv(opts)`** — RFC-4180-compliant CSV string.
19
+ - `rows`: `"visible"` (default) / `"all"` / `"selected"` / explicit array
20
+ - `columns`: `"visible"` (default) / `"all"` / array of keys (projection + reordering)
21
+ - `delimiter`, `quote`, `headers`, `newline`, `bom` for format control
22
+ - `formatter: (row, col) => unknown` per-cell hook (runs before CSV escaping)
23
+ - Numbers stringify naturally; null/undefined become empty fields; embedded
24
+ quotes are doubled; fields containing the delimiter, the quote, CR, or LF
25
+ are quoted. UTF-8 BOM via `bom: true` for Excel-on-Windows.
26
+
27
+ - **`table.exportJson(opts)`** — JSON string or array.
28
+ - Same `rows` + `columns` selectors as `exportCsv`
29
+ - `format: "string"` (default) returns a JSON string; `"array"` returns the
30
+ projected array directly (skip `JSON.stringify`, useful for piping into
31
+ IndexedDB / postMessage / structured clone)
32
+ - `indent`, `formatter` options
33
+ - Fast path: `columns: "all"` + no formatter returns a shallow `rows.slice()`,
34
+ not deep copies — row identity preserved.
35
+
36
+ ### Added — Cell editing
37
+
38
+ - **`ColumnDef.editable?: boolean`** — opt-in per column.
39
+ - **`CreateTableConfig.onCellEdit?: ({ row, columnKey, oldValue, newValue }) => void`**
40
+ — commit hook. lite-table never mutates rows itself; the handler is the
41
+ consumer's hook to write to a row, a backend, or a store.
42
+ - **`newValue` is always a string** (from contenteditable / `editingDraft`).
43
+ Consumers editing non-string columns are responsible for coercion in their
44
+ handler (e.g. `row.value = Number(newValue)`).
45
+ - Skipped when the change would be a no-op: the guard compares
46
+ `String(oldValue) !== newValue`, so pressing Enter on a numeric column
47
+ without typing doesn't spuriously fire the hook.
48
+ - Errors thrown by the handler are caught + logged.
49
+
50
+ - **`table.editingCell: Signal<{ rowId, columnKey } | null>`** — current edit
51
+ target. Keyed on row identity so edits survive scroll, sort, filter, and
52
+ slot recycling.
53
+ - **`table.editingDraft: Signal<string>`** — in-progress edit value
54
+ (mountTable writes to this from `input` events).
55
+ - **`table.startEdit(rowId, columnKey)`** — start editing. No-op on
56
+ non-editable columns. Auto-commits any in-flight edit on a different cell.
57
+ Idempotent on the same cell.
58
+ - **`table.commitEdit(value?)`** — commit. With no argument, reads from
59
+ `editingDraft`. Skips `onCellEdit` when value unchanged (string-coerced).
60
+ - **`table.cancelEdit()`** — discard the in-flight edit.
61
+ - **`table.isEditing(rowId, columnKey)`** — O(1) predicate.
62
+
63
+ - **DOM wiring** (when mounted via `mountTable`):
64
+ - Double-click on an editable cell starts editing.
65
+ - F2 / Enter on the focused cell starts editing if the column is editable.
66
+ - Enter commits + moves focus down (spreadsheet idiom).
67
+ - Tab / Shift+Tab commit + move focus right / left.
68
+ - Escape cancels + restores root focus.
69
+ - Blur on the cell commits.
70
+ - The active editing cell gets `contenteditable="true"`, the
71
+ `.lt-cell.is-editing` class (default style: orange outline + white-space
72
+ normal), and its `textContent` write binding is suspended while editing
73
+ so user keystrokes aren't clobbered by reactive paint.
74
+
75
+ ### Added — Per-column filtering
76
+
77
+ - **`ColumnDef.filterable?: boolean`** — opt-in per column.
78
+ - **`ColumnDef.filter?: (value, query, row) => boolean`** — custom predicate.
79
+ Defaults to case-insensitive substring match on the stringified value.
80
+ - **`ColumnDef.filterPlaceholder?: string`** — placeholder text for the
81
+ filter input. Default `"Filter…"`.
82
+
83
+ - **`table.columnFilters: Signal<ReadonlyMap<string, string>>`** — reactive
84
+ filter state.
85
+ - **`table.filteredRows: Computed<readonly Row[]>`** — rows post-filter,
86
+ pre-sort. `visibleRows` reads from here.
87
+ - **`table.setColumnFilter(key, value)`** — pass `null`/`undefined`/`""`/
88
+ whitespace-only to clear that column. No-op on non-filterable or unknown
89
+ columns.
90
+ - **`table.clearColumnFilters()`** — clear all. No notify if already empty.
91
+
92
+ - **DOM wiring** (when mounted via `mountTable`):
93
+ - A `.lt-filter-row` is mounted between header and viewport if any column
94
+ has `filterable: true`. One `<input>` per filterable column, two-way
95
+ bound to `columnFilters`.
96
+ - Escape on a filter input clears that column.
97
+ - The filter row participates in the grid layout (shares `--lt-cols` with
98
+ header and rows) and respects pin offsets + hidden state.
99
+ - Sticky `top: var(--lt-header-height, 32px)` so the row stays visible
100
+ during vertical scroll. Override `--lt-header-height` on the root if you
101
+ restyle the header to a different height.
102
+
103
+ ### Pipeline
104
+
105
+ ```
106
+ rowsGetter() → filteredRows → visibleRows → exports / mount / etc.
107
+ ▲ ▲
108
+ │ └─ sort applied here
109
+ └─ filters applied here
110
+ ```
111
+
112
+ This means `exportCsv({ rows: "visible" })` is already filtered + sorted,
113
+ which is the expected "what the user sees" semantic.
114
+
115
+ ### Fixed — `createScope` dispose leak (M1.0 latent bug)
116
+
117
+ `createScope` no longer leaks signals/computeds on `dispose()`. The v1.0.0
118
+ cleanup loop pushed signal/computed handles (which are callable) and then
119
+ iterated calling `c()` — which READS the signal, not disposes it. Every
120
+ `createTable` + `dispose()` cycle was leaving its ~24 reactive nodes pinned
121
+ in the lite-signal registry, eventually exhausting capacity in long-running
122
+ test runs or apps that create/destroy tables. The fix discriminates entries
123
+ by kind (`KIND_NODE` for handles → lite-signal `dispose()`; `KIND_THUNK` for
124
+ effect disposers, event removers, user `onCleanup`).
125
+
126
+ Verified: a `createTable` allocates 24 nodes; after `t.dispose()` the count
127
+ returns to 0. 50 create+dispose cycles round-trip cleanly with `activeNodes`
128
+ flat.
129
+
130
+ ### Fixed — `commitEdit` unchanged-guard coerces oldValue
131
+
132
+ `commitEdit` compares `String(oldValue) !== newValue` before firing
133
+ `onCellEdit`. Earlier drafts used strict `!==`, which fired the handler on
134
+ no-op Enter for any non-string column (a `100` cell compared to `"100"`
135
+ fails strict equality and looked like a change).
136
+
137
+ ### Demo
138
+
139
+ A new `demo/` directory ships with the package source (not published to
140
+ npm). The demo is a 5000-row paginated grid driven entirely by reactive
141
+ signals:
142
+
143
+ - **Page-size dropdown** (10 / 25 / 50 / 100 / all)
144
+ - **Pagination via reactive row source**: `rows: () => allRows.slice(...)`
145
+ where the slice is driven by `pageIndex()` and `pageSize()` signals
146
+ - **Three export buttons**: visible page (with role-titlecasing formatter),
147
+ selection across master, all 5000 as JSON
148
+ - **Per-column filter inputs** on name/email/role/team/value, with the value
149
+ column demonstrating a custom predicate (`>N` / `<N` / exact / substring)
150
+ - **Editable cells** on name/email/role/team/value with an `onCellEdit`
151
+ hook that mutates the master array + bumps a `rowsVersion` signal that
152
+ the rowsGetter reads (the documented pattern for "consumer mutates row
153
+ data, wants the table to repaint without changing pagination/filter
154
+ state")
155
+ - **Clear filters** + **edit log** showing the last commit
156
+ - Larger lite-signal registry configured up front (4096 nodes, growth
157
+ policy) — documented pattern for any non-trivial table app
158
+
159
+ ### Tests
160
+
161
+ - **80 unit tests** across `test/export.test.js` (41: RFC-4180 escaping, all
162
+ option combinations, edge cases including 10k-row dataset finishing <
163
+ 500ms), `test/scope-dispose.test.js` (5: the leak fix, dispose idempotency,
164
+ 50-cycle round trip), and `test/m2.test.js` (34: filtering basics, case
165
+ insensitivity, multi-column AND, custom predicates, predicate receiving
166
+ the full row, filter+sort interaction, filter+export, null/undefined
167
+ handling, edit state transitions, all startEdit/commitEdit/cancelEdit
168
+ semantics, onCellEdit handler error containment, edit+filter interaction,
169
+ edit+dispose, numeric column unchanged-guard).
170
+ - **32 browser tests** across `test-browser/demo.spec.js` (14: pagination
171
+ behavior, page-size dropdown, export buttons, leak-free across 50
172
+ createTable+dispose cycles in the browser) and `test-browser/m2.spec.js`
173
+ (18: filter row rendering, two-way input binding, Escape clearing,
174
+ custom predicate, multi-filter AND, double-click editing, F2, Enter to
175
+ commit, Tab to commit + move, Escape to cancel, blur to commit,
176
+ unchanged-value skip, second-cell auto-commit).
177
+
178
+ ### Documentation
179
+
180
+ - New "Export" section in README with full `rows` / `columns` selector
181
+ tables, per-format option tables, paginated-getter pitfall note, and a
182
+ copy-pasteable `downloadFile` helper.
183
+ - New "Cell editing" section with commit semantics, programmatic editing
184
+ pattern, reactive surface, and a note that `newValue` is always a string.
185
+ - New "Per-column filtering" section with predicate contract, pipeline
186
+ diagram, reactive surface, keyboard, and the `--lt-header-height`
187
+ customization point.
188
+ - New "Client-side pagination via a reactive row source" recipe in the
189
+ Integration recipes section.
190
+ - TOC updated.
191
+ - Types: `ExportRowsSource`, `ExportColumnsSelector`, `ExportCsvOptions`,
192
+ `ExportJsonOptions`, `EditingCell`, `CellEditPayload` interfaces added to
193
+ `Table.d.ts`. `ColumnDef`, `ColumnState`, `CreateTableConfig`, `TableCore`
194
+ all extended with the new surfaces.
195
+
196
+ ### Compatibility
197
+
198
+ Drop-in for v1.0.0 consumers. The new methods are additive; both editing
199
+ and filtering are opt-in per column. The scope-dispose fix only changes
200
+ lifecycle behavior (more thorough cleanup, no leaks). Peer dependencies
201
+ unchanged (`@zakkster/lite-signal ^1.2.0`).
202
+
203
+ ### Known limitations
204
+
205
+ - Editing during scroll: if the edited row scrolls out of view, the edit
206
+ state survives (it's keyed on rowId) but the `contenteditable` cell
207
+ unmounts as the slot recycles. When the row scrolls back, editing
208
+ resumes with the draft preserved. To force a commit on scroll, call
209
+ `table.commitEdit()` from your scroll handler.
210
+
211
+ ---
212
+
8
213
  ## [1.0.0] — 2026-06 (M1 stable)
9
214
 
10
215
  First public release. The headless core + DOM mount, virtualized rows on a
package/README.md CHANGED
@@ -8,8 +8,9 @@
8
8
  [![npm bundle size](https://img.shields.io/bundlephobia/minzip/@zakkster/lite-table?style=for-the-badge)](https://bundlephobia.com/result?p=@zakkster/lite-table)
9
9
  [![npm downloads](https://img.shields.io/npm/dm/@zakkster/lite-table?style=for-the-badge&color=blue)](https://www.npmjs.com/package/@zakkster/lite-table)
10
10
  [![npm total downloads](https://img.shields.io/npm/dt/@zakkster/lite-table?style=for-the-badge&color=blue)](https://www.npmjs.com/package/@zakkster/lite-table)
11
+ [![lite-signal peer](https://img.shields.io/npm/dependency-version/@zakkster/lite-table/peer/@zakkster/lite-signal?style=for-the-badge&color=blue)](https://github.com/PeshoVurtoleta/lite-signal)
11
12
  ![TypeScript](https://img.shields.io/badge/TypeScript-Types-informational)
12
- ![Dependencies](https://img.shields.io/badge/dependencies-3-brightgreen)
13
+ ![Dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)
13
14
  [![license](https://img.shields.io/badge/license-MIT-blue?style=flat-square)](./LICENSE.txt)
14
15
 
15
16
  ```bash
@@ -48,6 +49,9 @@ Synchronous, virtual, allocation-free in the steady state. A 100,000-row scroll
48
49
  - [Architecture in one diagram](#architecture-in-one-diagram)
49
50
  - [How a scroll propagates](#how-a-scroll-propagates)
50
51
  - [API reference](#api-reference)
52
+ - [Export](#export)
53
+ - [Cell editing](#cell-editing)
54
+ - [Per-column filtering](#per-column-filtering)
51
55
  - [Pinning and sticky offsets](#pinning-and-sticky-offsets)
52
56
  - [Flex columns](#flex-columns)
53
57
  - [Sorting](#sorting)
@@ -366,12 +370,286 @@ table.isSelected(rowId)
366
370
  table.moveFocus(direction) // "up" | "down" | "left" | "right" | "home" | "end" | "pageUp" | "pageDown"
367
371
  table.cellId(rowId, columnKey) // stable string id (matches DOM)
368
372
 
373
+ // Filters (M2)
374
+ table.setColumnFilter(key, value) // null/""/whitespace clears that column
375
+ table.clearColumnFilters() // clear all
376
+ table.columnFilters() // ReadonlyMap<string, string>
377
+ table.filteredRows() // computed: rows post-filter, pre-sort
378
+
379
+ // Editing (M2)
380
+ table.startEdit(rowId, columnKey) // no-op on non-editable columns
381
+ table.commitEdit() // reads editingDraft
382
+ table.commitEdit(explicitValue) // commit a specific value
383
+ table.cancelEdit() // discard; no onCellEdit call
384
+ table.isEditing(rowId, columnKey) // O(1) predicate
385
+ table.editingCell() // { rowId, columnKey } | null
386
+ table.editingDraft() // current in-progress string
387
+
388
+ // Export (M1.1)
389
+ table.exportCsv({ rows?, columns?, delimiter?, quote?, headers?, newline?, bom?, formatter? }) // → string
390
+ table.exportJson({ rows?, columns?, indent?, format?, formatter? }) // → string | object[]
391
+
369
392
  // Lifecycle
370
393
  table.dispose()
371
394
  ```
372
395
 
373
396
  ---
374
397
 
398
+ ## Export
399
+
400
+ `exportCsv` and `exportJson` materialize a row source into a string (or, for JSON, an array). Both methods take the same `rows` and `columns` selectors plus their own format-specific options.
401
+
402
+ ### `rows` source
403
+
404
+ | Value | Meaning |
405
+ | ------------- | -------------------------------------------------------------------------------- |
406
+ | `"visible"` | The current `visibleRows()`. Honors sort + the row source (post-pagination view). **Default.** |
407
+ | `"all"` | `rowsGetter()` -- the raw source you gave to `createTable`. If you gave a function (paginated source), this is the current page, NOT the master. See pitfall below. |
408
+ | `"selected"` | The current selection materialized against `rowsGetter()`. Same caveat as `"all"`. |
409
+ | `Array` | An explicit row array (e.g. a master array held externally). |
410
+
411
+ **Paginated-getter pitfall**: if `createTable({ rows: () => allRows.slice(...) })` is a paginated function, `"all"` and `"selected"` resolve against that function -- which returns only the current page. To export beyond the page, pass the master array explicitly:
412
+
413
+ ```js
414
+ table.exportCsv({ rows: allRows }); // entire master
415
+ table.exportCsv({ rows: table.selectedRows(allRows) }); // selected across master
416
+ ```
417
+
418
+ ### `columns` selector
419
+
420
+ | Value | Meaning |
421
+ | --------------- | ---------------------------------------------------------------- |
422
+ | `"visible"` | `visibleColumns()` -- honors hide state + current order. **Default.** |
423
+ | `"all"` | All declared columns in declaration order, including hidden. |
424
+ | `Array<string>` | Explicit projection by key. Order in the output matches array order. Unknown keys are silently dropped. |
425
+
426
+ ### `exportCsv` options
427
+
428
+ | Option | Type | Default | Notes |
429
+ | ------------ | -------------------------- | --------- | ---------------------------------------------- |
430
+ | `delimiter` | `string` | `","` | `"\t"` for TSV, `";"` for European regional |
431
+ | `quote` | `string` | `'"'` | Per RFC 4180; embedded quotes are doubled |
432
+ | `headers` | `boolean` | `true` | Emit header row |
433
+ | `newline` | `string` | `"\r\n"` | RFC 4180 says CRLF; `"\n"` works for most consumers |
434
+ | `bom` | `boolean` | `false` | Prepend UTF-8 BOM for Excel-on-Windows |
435
+ | `formatter` | `(row, col) => unknown` | - | Per-cell formatter, runs before CSV escaping |
436
+
437
+ CSV escaping follows RFC 4180: a field is quoted if it contains the delimiter, the quote character, CR, or LF. Embedded quotes are doubled. The column's `accessor` (if any) is honored.
438
+
439
+ ### `exportJson` options
440
+
441
+ | Option | Type | Default | Notes |
442
+ | ----------- | -------------------------- | ------------ | ---------------------------------------------------------------- |
443
+ | `indent` | `number` | `0` | `JSON.stringify` indent; 0 = single-line compact |
444
+ | `format` | `"string"` \| `"array"` | `"string"` | `"array"` skips JSON.stringify and returns the projected array |
445
+ | `formatter` | `(row, col) => unknown` | - | Per-cell formatter |
446
+
447
+ Fast path: `exportJson({ columns: "all", format: "array" })` with no formatter returns a shallow `rows.slice()` -- the row objects themselves, not copies. Use this when piping into IndexedDB / postMessage / structured clone.
448
+
449
+ ### Triggering a browser download
450
+
451
+ The methods return strings; the consumer handles the download:
452
+
453
+ ```js
454
+ function downloadFile(text, filename, mime) {
455
+ const blob = new Blob([text], { type: mime });
456
+ const url = URL.createObjectURL(blob);
457
+ const a = document.createElement("a");
458
+ a.href = url;
459
+ a.download = filename;
460
+ document.body.appendChild(a);
461
+ a.click();
462
+ document.body.removeChild(a);
463
+ URL.revokeObjectURL(url);
464
+ }
465
+
466
+ downloadFile(table.exportCsv({ bom: true }), "data.csv", "text/csv;charset=utf-8");
467
+ ```
468
+
469
+ The BOM (`bom: true`) is the difference between Excel opening your file as UTF-8 vs garbling non-ASCII characters on Windows.
470
+
471
+ ---
472
+
473
+ ## Cell editing
474
+
475
+ Opt-in per column via `editable: true`. When set, double-click (or `F2` / `Enter` on the focused cell) puts the cell into `contenteditable` mode with its current value pre-selected. Enter commits, Tab commits + moves to the next cell, Escape cancels.
476
+
477
+ ```js
478
+ const table = createTable({
479
+ rows,
480
+ columns: [
481
+ { key: "id", width: 60 },
482
+ { key: "name", editable: true },
483
+ { key: "email", editable: true },
484
+ { key: "joined" }, // not editable -- no double-click affordance
485
+ ],
486
+ getRowId: r => r.id,
487
+ onCellEdit: ({ row, columnKey, oldValue, newValue }) => {
488
+ // Mutate the row, send to backend, dispatch to a store, whatever.
489
+ // lite-table does NOT mutate the row for you.
490
+ row[columnKey] = newValue;
491
+ },
492
+ });
493
+
494
+ mountTable(host, table);
495
+ ```
496
+
497
+ ### Commit semantics
498
+
499
+ - **Enter** commits + moves focus to the row below (spreadsheet idiom)
500
+ - **Tab** / **Shift+Tab** commit + move focus right / left
501
+ - **Blur** (clicking outside, focusing another cell) commits
502
+ - **Escape** cancels: edit state cleared, no `onCellEdit` call
503
+ - **A second `startEdit` on a different cell** auto-commits the first
504
+ - **A `startEdit` on the same cell** is a no-op (does not re-seed the draft)
505
+ - `commitEdit` skips `onCellEdit` when the new value equals the old value (string-coerced: see below), so accidental Enter-without-typing is free even on numeric / typed columns
506
+
507
+ The `onCellEdit` handler is called with `{ row, columnKey, oldValue, newValue }`. The table does **not** mutate the row -- the handler is the consumer's hook to write somewhere (the row, a backend, a store). Throwing from the handler is caught + logged; subsequent edits work normally.
508
+
509
+ ### `newValue` is always a string
510
+
511
+ When the edit comes from the DOM (double-click → contenteditable → Enter / Tab / blur), `newValue` is whatever the user typed: **always a string**. lite-table doesn't try to guess that "100" should be a number or "true" should be a boolean -- that's the consumer's call.
512
+
513
+ For non-string columns, coerce inside your handler:
514
+
515
+ ```js
516
+ onCellEdit: ({ row, columnKey, newValue }) => {
517
+ if (columnKey === "count") row.count = Number(newValue);
518
+ else if (columnKey === "active") row.active = newValue === "true";
519
+ else if (columnKey === "due") row.due = new Date(newValue);
520
+ else row[columnKey] = newValue;
521
+ },
522
+ ```
523
+
524
+ The **unchanged-guard** compares `String(oldValue) !== newValue` for string `newValue`s (the common case from the DOM), so pressing Enter on a numeric column without typing doesn't fire your handler -- `100` and `"100"` aren't strict-equal but they're "unchanged" from the user's perspective. When you call `commitEdit(explicitValue)` with a non-string explicit value, the guard falls back to strict equality so you have predictable control.
525
+
526
+ ### Reactive surface
527
+
528
+ ```js
529
+ table.editingCell() // { rowId, columnKey } | null
530
+ table.editingDraft() // current in-progress string
531
+ table.isEditing(rowId, columnKey) // O(1) predicate
532
+
533
+ table.startEdit(rowId, columnKey)
534
+ table.commitEdit() // reads editingDraft
535
+ table.commitEdit("explicit value")
536
+ table.cancelEdit()
537
+ ```
538
+
539
+ ### Programmatic editing
540
+
541
+ Skip the double-click affordance entirely if you want -- `startEdit` is the only entry point you need. Use it for "edit on selection", per-row action menus, or hotkey-triggered batch edits:
542
+
543
+ ```js
544
+ // "Edit name on selected row" toolbar button:
545
+ editNameBtn.addEventListener("click", () => {
546
+ const f = table.focusedCell();
547
+ if (f) table.startEdit(f.rowId, "name");
548
+ });
549
+ ```
550
+
551
+ ### Editing + reactive row sources
552
+
553
+ If your `rows` is a function (paginated, filtered, etc.), the edited row may scroll out of view mid-edit. The edit state is keyed on `rowId`, so:
554
+
555
+ - The slot DOM gets recycled to show a different row; `contenteditable` is removed from the recycled cell automatically.
556
+ - The `editingCell` signal stays set, pointing at the row that's no longer visible.
557
+ - When that row scrolls back into view, the cell becomes `contenteditable` again with the draft preserved.
558
+
559
+ This is the spreadsheet idiom too -- you can scroll while typing without losing your input. To force a commit, call `table.commitEdit()` from your scroll handler if you want stricter semantics.
560
+
561
+ ### Performance
562
+
563
+ Editable columns use one extra reactive effect per cell (the contenteditable management) and add two event listeners (input, keydown) plus two more on dblclick and blur. The text effect on editable cells skips its `textContent` write while the cell is the active edit target, so user keystrokes don't fight a reactive paint. Non-editable columns pay nothing -- the editing machinery is gated on `col.editable` and never attaches.
564
+
565
+ ---
566
+
567
+ ## Per-column filtering
568
+
569
+ Opt-in per column via `filterable: true`. A filter row appears between the header and the viewport, with one `<input>` per filterable column. The default predicate is case-insensitive substring match on the stringified cell value (after the column's `accessor` runs); pass a custom `filter` for richer semantics.
570
+
571
+ ```js
572
+ const table = createTable({
573
+ rows,
574
+ columns: [
575
+ { key: "id", width: 70 },
576
+ { key: "name", filterable: true }, // default substring
577
+ { key: "email", filterable: true, filterPlaceholder: "name@domain" },
578
+ { key: "role", filterable: true, filterPlaceholder: "engineer / pm / …" },
579
+ { key: "salary", filterable: true,
580
+ // ">N" / "<N" / "N" exact / substring fallback
581
+ filter: (v, q) => {
582
+ if (q.startsWith(">")) {
583
+ const n = Number(q.slice(1));
584
+ return Number.isFinite(n) && v > n;
585
+ }
586
+ if (q.startsWith("<")) {
587
+ const n = Number(q.slice(1));
588
+ return Number.isFinite(n) && v < n;
589
+ }
590
+ const n = Number(q);
591
+ if (Number.isFinite(n)) return v === n;
592
+ return String(v).indexOf(q) >= 0;
593
+ },
594
+ filterPlaceholder: ">100k" },
595
+ ],
596
+ getRowId: r => r.id,
597
+ });
598
+ ```
599
+
600
+ ### Predicate contract
601
+
602
+ ```ts
603
+ (value: unknown, query: string, row: Row) => boolean
604
+ ```
605
+
606
+ - `value` is the column's value (post-`accessor`)
607
+ - `query` is the trimmed filter input. Empty / whitespace-only queries are treated as "no filter" and your predicate is not invoked for them
608
+ - `row` is the full row object -- handy for cross-field filters (e.g., "show rows where `firstName + lastName` matches")
609
+
610
+ Filters from multiple columns AND together. A row must pass every active filter to remain visible.
611
+
612
+ ### Filter order in the pipeline
613
+
614
+ ```
615
+ rowsGetter() → filteredRows → visibleRows → exports / mount / etc.
616
+ ▲ ▲
617
+ │ └─ sort applied here
618
+ └─ filters applied here
619
+ ```
620
+
621
+ This means **export of `rows: "visible"` is already filtered + sorted**, which is what you want for "export what the user sees". For "export the master regardless of filters/sort", use `rows: "all"` or pass the master array explicitly.
622
+
623
+ ### Reactive surface
624
+
625
+ ```js
626
+ table.columnFilters() // ReadonlyMap<string, string>
627
+ table.filteredRows() // computed: rows post-filter, pre-sort
628
+ table.setColumnFilter("role", "eng") // set
629
+ table.setColumnFilter("role", "") // clear that column
630
+ table.setColumnFilter("role", null) // also clears
631
+ table.clearColumnFilters() // clear all
632
+ ```
633
+
634
+ The filter row's inputs are bound two-way to `columnFilters`. If you mutate the signal programmatically (e.g., to restore filter state from a URL), the inputs update automatically.
635
+
636
+ ### Keyboard
637
+
638
+ - **Escape** on a filter input clears that column's filter (the input clears too)
639
+ - The filter row is part of the focusable tab order; Tab moves to the next filter input or to the next focusable element after the row
640
+
641
+ ### Hiding the filter row
642
+
643
+ The filter row is only mounted if at least one declared column has `filterable: true`. To temporarily hide it without un-mounting, hide all filterable columns (`setColumnHidden`); their filter cells go to `display: none` with the rest of the column.
644
+
645
+ ### Performance
646
+
647
+ Filtering is O(N) over the source rows on every filter change, executed inside a single computed. With 5000 rows + a handful of filterable columns, this runs in under a millisecond in our demo. The filtered array is a fresh allocation per change (Object.is inequality is required to notify the sort + selection downstream); for million-row data sources you typically want backend filtering anyway.
648
+
649
+ The fast path returns the source array identity when no filter is active, so just enabling `filterable: true` on columns costs nothing until the user types something.
650
+
651
+ ---
652
+
375
653
  ## Pinning and sticky offsets
376
654
 
377
655
  Pinned columns use `position: sticky` with cumulative offsets. The math is:
@@ -725,6 +1003,68 @@ async function loadPage(n) {
725
1003
 
726
1004
  </details>
727
1005
 
1006
+ <details>
1007
+ <summary>Client-side pagination via a reactive row source (M1.1 pattern).</summary>
1008
+
1009
+ The reactive `rows: () => ...` getter form makes pagination effectively free: the page index and page size are signals, and `visibleRows` derives from both. Changing either signal triggers `visibleRows` to recompute the slice -- the slot pool's text effects re-pull, and the DOM topology stays unchanged.
1010
+
1011
+ ```js
1012
+ import { signal, computed } from "@zakkster/lite-signal";
1013
+ import { createTable, mountTable } from "@zakkster/lite-table";
1014
+
1015
+ const allRows = await fetchEverything(); // your master array
1016
+
1017
+ const pageSize = signal(25);
1018
+ const pageIndex = signal(0); // 0-based
1019
+
1020
+ const pageCount = computed(() => {
1021
+ const sz = pageSize();
1022
+ return sz === 0 ? 1 : Math.max(1, Math.ceil(allRows.length / sz));
1023
+ });
1024
+
1025
+ const table = createTable({
1026
+ rows: () => {
1027
+ const sz = pageSize();
1028
+ if (sz === 0) return allRows;
1029
+ const start = pageIndex() * sz;
1030
+ return allRows.slice(start, start + sz);
1031
+ },
1032
+ columns: COLS,
1033
+ getRowId: r => r.id,
1034
+ });
1035
+
1036
+ mountTable(host, table);
1037
+
1038
+ // Page-size dropdown
1039
+ pageSizeSelectEl.addEventListener("change", (e) => {
1040
+ pageSize.set(Number(e.target.value));
1041
+ pageIndex.set(0); // reset to first page when size changes
1042
+ });
1043
+
1044
+ // First / prev / next / last
1045
+ firstBtn.addEventListener("click", () => pageIndex.set(0));
1046
+ prevBtn.addEventListener("click", () => pageIndex.set(Math.max(0, pageIndex() - 1)));
1047
+ nextBtn.addEventListener("click", () => pageIndex.set(Math.min(pageCount() - 1, pageIndex() + 1)));
1048
+ lastBtn.addEventListener("click", () => pageIndex.set(pageCount() - 1));
1049
+ ```
1050
+
1051
+ The slot pool isn't recreated when the page changes -- the same 24 row elements re-bind to the new slice. A `setPageSize(100)` after browsing to page 50 just sets two signals and the next paint is the new page.
1052
+
1053
+ **Exporting a paginated table**: because `rows: "all"` and `rows: "selected"` resolve against `rowsGetter()` (which IS your paginated function), those selectors give you the current page only. To export across the master, pass the master array explicitly:
1054
+
1055
+ ```js
1056
+ // Just the current page (what the user sees):
1057
+ table.exportCsv(); // rows: "visible"
1058
+
1059
+ // All 5000 rows (the master):
1060
+ table.exportCsv({ rows: allRows });
1061
+
1062
+ // All rows the user picked (Select-All across all pages):
1063
+ table.exportCsv({ rows: table.selectedRows(allRows) });
1064
+ ```
1065
+
1066
+ </details>
1067
+
728
1068
  ---
729
1069
 
730
1070
  ## FAQ