@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 +205 -0
- package/README.md +341 -1
- package/Table.d.ts +134 -1
- package/Table.js +737 -35
- package/llms.txt +121 -2
- package/package.json +11 -7
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
|
[](https://bundlephobia.com/result?p=@zakkster/lite-table)
|
|
9
9
|
[](https://www.npmjs.com/package/@zakkster/lite-table)
|
|
10
10
|
[](https://www.npmjs.com/package/@zakkster/lite-table)
|
|
11
|
+
[](https://github.com/PeshoVurtoleta/lite-signal)
|
|
11
12
|

|
|
12
|
-

|
|
13
14
|
[](./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
|