@zakkster/lite-table 1.2.0 → 1.3.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,18 @@ 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.3.0] — 2026-09-13
9
+
10
+ ### Added — View-state persistence seam
11
+
12
+ Two additive `TableCore` methods that snapshot and restore the table's layout and query as plain JSON. This is the seam `@zakkster/lite-headless` `createSavedViews` (G-03) consumes; the named-view manager lives there, not here. Purely additive: the default path and every existing setter/signal/computed are byte-identical.
13
+
14
+ - **`table.getViewState(): ViewState`** — a JSON-serializable snapshot of the layout + query: `v` (schema version, always `1`), `sort`, `columnOrder`, full per-column `columns` layout (`width` / `hidden` / `pin` / `flex` for every current column, not deltas), active `filters` (empty/whitespace queries omitted), `groupBy`, and `collapsedGroups`. No live signal refs, no `Map`/`Set` instances — `filters` is a plain object, `collapsedGroups` an array. Selection/focus/edit state are transient and excluded.
15
+ - **`table.setViewState(view, opts?): void`** — restore a `ViewState` with REPLACE semantics, applied atomically inside a single `batch()` so downstream computeds recompute once. Throws `TypeError` before any mutation on a non-object, a missing `v`, or a `v` other than `1`. Within a v1 view a single malformed entry (bad sort dir, non-number width, unknown key) is skipped and the rest applied. `columnOrder` is reconciled against the live column set — saved keys still present are kept in order, new columns appended, dead keys dropped — so a stale order never trips `setColumnOrder`'s non-permutation guard. Columns absent from `view.columns` keep their current values. `opts` is reserved (no merge mode in v1) and ignored.
16
+ - **`ViewState` / `ViewColumnState` / `SetViewStateOptions`** — exported type declarations.
17
+
18
+ `setViewState(getViewState())` is an identity round-trip. Views are keyed (not positional), so they survive column-set drift. `batch` is now imported from `@zakkster/lite-signal`.
19
+
8
20
  ## [1.2.0] — 2026-07-03
9
21
 
10
22
  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.
package/Table.d.ts CHANGED
@@ -132,6 +132,42 @@ export interface SortEntry {
132
132
  dir: SortDir;
133
133
  }
134
134
 
135
+ /** Per-column layout snapshot inside a {@link ViewState}. */
136
+ export interface ViewColumnState {
137
+ width: number;
138
+ hidden: boolean;
139
+ pin: PinSide;
140
+ flex: number;
141
+ }
142
+
143
+ /**
144
+ * A JSON-serializable snapshot of the table's LAYOUT + QUERY -- sort,
145
+ * per-column layout, order, filters, grouping. Produced by
146
+ * `getViewState()` and consumed by `setViewState()`. No live signal refs,
147
+ * no `Map`/`Set` instances: `filters` is a plain object, `collapsedGroups`
148
+ * an array. This is the persistence seam lite-headless `createSavedViews`
149
+ * consumes; selection/focus/edit state are transient and excluded.
150
+ */
151
+ export interface ViewState {
152
+ /** Schema version. Always `1` in this release. */
153
+ v: 1;
154
+ /** Sort chain, outermost key first. */
155
+ sort: SortEntry[];
156
+ /** Column order by key. */
157
+ columnOrder: string[];
158
+ /** Full per-column layout for every current column, keyed by column key. */
159
+ columns: { [key: string]: ViewColumnState };
160
+ /** Active column filters by key; empty/whitespace queries are omitted. */
161
+ filters: { [key: string]: string };
162
+ /** Group-by keys, outermost first. */
163
+ groupBy: string[];
164
+ /** Collapsed group path-strings (U+001F separator). */
165
+ collapsedGroups: string[];
166
+ }
167
+
168
+ /** Reserved options for {@link TableCore.setViewState}. Ignored in v1. */
169
+ export interface SetViewStateOptions {}
170
+
135
171
  export type SelectMode = "set" | "add" | "toggle" | "range";
136
172
 
137
173
  /** Selection state is a PREDICATE, not a list of IDs.
@@ -438,6 +474,24 @@ export interface TableCore<Row = any> {
438
474
  // ---- Methods: focus ----
439
475
  moveFocus(direction: FocusDirection, opts?: { pageSize?: number }): void;
440
476
 
477
+ // ---- Methods: view state (persistence seam) ----
478
+ /**
479
+ * Snapshot the full layout + query as a JSON-serializable {@link ViewState}
480
+ * -- deep-copied, no live signal refs, no `Map`/`Set` instances. Persist
481
+ * it, then restore it with {@link TableCore.setViewState}.
482
+ */
483
+ getViewState(): ViewState;
484
+ /**
485
+ * Restore a {@link ViewState} with REPLACE semantics, applied atomically
486
+ * inside a single batch. Throws `TypeError` before any mutation on a
487
+ * non-object, a missing `v`, or a `v` other than `1`. Within a v1 view a
488
+ * single malformed entry (bad sort dir, non-number width, unknown key) is
489
+ * skipped and the rest applied. Stale column orders are reconciled, so a
490
+ * dropped/added column never rejects the restore. `opts` is reserved and
491
+ * ignored in v1.
492
+ */
493
+ setViewState(view: ViewState, opts?: SetViewStateOptions): void;
494
+
441
495
  // ---- Lifecycle ----
442
496
  dispose(): void;
443
497
 
package/Table.js CHANGED
@@ -58,7 +58,7 @@
58
58
  */
59
59
 
60
60
  import {
61
- signal, computed, effect, untrack,
61
+ signal, computed, effect, untrack, batch,
62
62
  dispose as disposeNode
63
63
  } from "@zakkster/lite-signal";
64
64
  import { virtualAxis } from "@zakkster/lite-virtual";
@@ -1624,6 +1624,165 @@ export function createTable(config) {
1624
1624
  return indent > 0 ? JSON.stringify(out, null, indent) : JSON.stringify(out);
1625
1625
  }
1626
1626
 
1627
+ // =========================================================================
1628
+ // --- View state (persistence seam, v1.3.0) -------------------------------
1629
+ // =========================================================================
1630
+ //
1631
+ // getViewState() snapshots the LAYOUT + QUERY (sort, per-column layout,
1632
+ // order, filters, grouping) to plain JSON-safe data -- no live signal
1633
+ // refs, no Map/Set instances. setViewState() restores it with REPLACE
1634
+ // semantics, atomically inside batch(), fail-closed on garbage. This is
1635
+ // the seam lite-headless createSavedViews (G-03) consumes; the named-view
1636
+ // MANAGER lives there, not here. Cold user-gesture path, not a hot path.
1637
+
1638
+ // Reconcile a saved column order against the CURRENT column set: keep saved
1639
+ // keys still present (in saved order), append current keys absent from the
1640
+ // saved order, drop saved keys no longer present. Always returns a
1641
+ // permutation of the current columns, so setColumnOrder's non-permutation
1642
+ // guard never trips on a stale-but-valid order.
1643
+ function _reconcileOrder(savedOrder) {
1644
+ const result = [];
1645
+ const used = new Set();
1646
+ if (Array.isArray(savedOrder)) {
1647
+ for (let i = 0; i < savedOrder.length; i++) {
1648
+ const k = savedOrder[i];
1649
+ if (columnsByKey.has(k) && !used.has(k)) {
1650
+ result.push(k);
1651
+ used.add(k);
1652
+ }
1653
+ }
1654
+ }
1655
+ const cur = columnOrder();
1656
+ for (let i = 0; i < cur.length; i++) {
1657
+ const k = cur[i];
1658
+ if (!used.has(k)) {
1659
+ result.push(k);
1660
+ used.add(k);
1661
+ }
1662
+ }
1663
+ return result;
1664
+ }
1665
+
1666
+ function getViewState() {
1667
+ // Full per-column layout for EVERY current column -- restore is
1668
+ // default-independent (never a delta against unknown defaults).
1669
+ const cols = {};
1670
+ for (let i = 0; i < columns.length; i++) {
1671
+ const c = columns[i];
1672
+ cols[c.key] = {
1673
+ width: c.width(),
1674
+ hidden: c.hidden(),
1675
+ pin: c.pin(),
1676
+ flex: c.flex()
1677
+ };
1678
+ }
1679
+ // Map -> object; skip empty/whitespace queries (they carry no state).
1680
+ const filters = {};
1681
+ for (const [key, q] of columnFilters()) {
1682
+ if (typeof q === "string" && q.trim() !== "") filters[key] = q;
1683
+ }
1684
+ // Copy each sort entry -- no shared refs to the live chain.
1685
+ const sort = [];
1686
+ const chain = sortChain();
1687
+ for (let i = 0; i < chain.length; i++) {
1688
+ sort.push({ key: chain[i].key, dir: chain[i].dir });
1689
+ }
1690
+ // Set -> array of pathStr.
1691
+ const collapsed = [];
1692
+ for (const p of collapsedGroups()) collapsed.push(p);
1693
+ return {
1694
+ v: 1,
1695
+ sort,
1696
+ columnOrder: columnOrder().slice(),
1697
+ columns: cols,
1698
+ filters,
1699
+ groupBy: groupBy().slice(),
1700
+ collapsedGroups: collapsed
1701
+ };
1702
+ }
1703
+
1704
+ function setViewState(view, opts) {
1705
+ // Whole-view fail-closed BEFORE any mutation: garbage must never brick
1706
+ // the table, and a future major version must not partially apply.
1707
+ // null is not zero.
1708
+ if (view === null || typeof view !== "object" || Array.isArray(view)) {
1709
+ throw new TypeError("setViewState: view must be a plain object");
1710
+ }
1711
+ if (view.v !== 1) {
1712
+ throw new TypeError(
1713
+ "setViewState: unsupported view version " + String(view.v) +
1714
+ " (expected 1)"
1715
+ );
1716
+ }
1717
+ // `opts` is accepted and reserved for future use (e.g. a merge mode);
1718
+ // ignored in v1. REPLACE semantics only.
1719
+ void opts;
1720
+ // Atomic: one batch so downstream computeds (visibleRows,
1721
+ // visibleColumns, colTemplate, offsets) recompute ONCE, not per field.
1722
+ batch(() => {
1723
+ // 1. Column order -- reconcile first so the guard never trips.
1724
+ setColumnOrder(_reconcileOrder(view.columnOrder));
1725
+ // 2. Per-column layout via the four public setters. Skip malformed
1726
+ // entries; columns absent from view.columns keep current values.
1727
+ const cols = view.columns;
1728
+ if (cols !== null && typeof cols === "object" && !Array.isArray(cols)) {
1729
+ const keys = Object.keys(cols);
1730
+ for (let i = 0; i < keys.length; i++) {
1731
+ const key = keys[i];
1732
+ if (!columnsByKey.has(key)) continue;
1733
+ const c = cols[key];
1734
+ if (c === null || typeof c !== "object") continue;
1735
+ if (typeof c.width === "number" && Number.isFinite(c.width)) {
1736
+ setColumnWidth(key, c.width);
1737
+ }
1738
+ if (typeof c.hidden === "boolean") setColumnHidden(key, c.hidden);
1739
+ if (c.pin === "left" || c.pin === "right" || c.pin === "none") {
1740
+ setColumnPin(key, c.pin);
1741
+ }
1742
+ if (typeof c.flex === "number" && Number.isFinite(c.flex)) {
1743
+ setColumnFlex(key, c.flex);
1744
+ }
1745
+ }
1746
+ }
1747
+ // 3. Filters (REPLACE) -- clear, then set each named entry.
1748
+ clearColumnFilters();
1749
+ const filters = view.filters;
1750
+ if (filters !== null && typeof filters === "object" && !Array.isArray(filters)) {
1751
+ const keys = Object.keys(filters);
1752
+ for (let i = 0; i < keys.length; i++) {
1753
+ const key = keys[i];
1754
+ const q = filters[key];
1755
+ if (typeof q === "string") setColumnFilter(key, q);
1756
+ }
1757
+ }
1758
+ // 4. Sort (REPLACE) -- clear, then add each valid entry.
1759
+ clearSort();
1760
+ const sort = view.sort;
1761
+ if (Array.isArray(sort)) {
1762
+ for (let i = 0; i < sort.length; i++) {
1763
+ const e = sort[i];
1764
+ if (e === null || typeof e !== "object") continue;
1765
+ if (e.dir !== "asc" && e.dir !== "desc") continue;
1766
+ addSort(e.key, e.dir);
1767
+ }
1768
+ }
1769
+ // 5. Grouping (REPLACE) -- _normalizeGroupBy drops unknown keys.
1770
+ setGroupBy(view.groupBy);
1771
+ // 6. Collapsed groups (REPLACE) -- expand all, then collapse each
1772
+ // saved path. Saved pathStr strings split back to path arrays;
1773
+ // collapseGroup re-joins them to the identical pathStr.
1774
+ expandAllGroups();
1775
+ const collapsed = view.collapsedGroups;
1776
+ if (Array.isArray(collapsed)) {
1777
+ for (let i = 0; i < collapsed.length; i++) {
1778
+ const p = collapsed[i];
1779
+ if (typeof p === "string") collapseGroup(p.split(GROUP_PATH_SEP));
1780
+ else if (Array.isArray(p)) collapseGroup(p);
1781
+ }
1782
+ }
1783
+ });
1784
+ }
1785
+
1627
1786
  return {
1628
1787
  // Static
1629
1788
  columns,
@@ -1695,6 +1854,9 @@ export function createTable(config) {
1695
1854
  // Methods: focus
1696
1855
  moveFocus,
1697
1856
 
1857
+ // Methods: view state (persistence seam)
1858
+ getViewState, setViewState,
1859
+
1698
1860
  // Lifecycle
1699
1861
  dispose,
1700
1862
  _scope: scope
package/llms.txt CHANGED
@@ -351,6 +351,48 @@ TableCore lifecycle:
351
351
  returns to the registry pool. 50 createTable+dispose cycles round-trip
352
352
  cleanly with activeNodes flat.
353
353
 
354
+ View state (persistence seam):
355
+ Two TableCore methods (v1.3.0+) snapshot and restore the LAYOUT + QUERY
356
+ as plain JSON. Selection/focus/edit state are transient and excluded.
357
+ This is the seam @zakkster/lite-headless createSavedViews (G-03) consumes;
358
+ the named-view MANAGER lives there, not here. Cold user-gesture path.
359
+
360
+ getViewState() -> ViewState JSON-safe snapshot; no live signal
361
+ refs, no Map/Set. Map -> object,
362
+ Set -> array. Full per-column layout
363
+ for EVERY current column (not deltas)
364
+ so restore is default-independent.
365
+ setViewState(view, opts?) REPLACE semantics, atomic inside one
366
+ batch() (computeds recompute once).
367
+ opts reserved (no merge mode in v1),
368
+ ignored. Throws TypeError BEFORE any
369
+ mutation on a non-object, missing v,
370
+ or v !== 1. Within a v1 view a single
371
+ malformed entry is skipped, the rest
372
+ applied. columnOrder is reconciled
373
+ against the live column set (keep saved
374
+ keys still present, append new columns,
375
+ drop dead keys) so a stale order never
376
+ trips setColumnOrder's guard. Filters
377
+ and sort are cleared then re-applied;
378
+ groupBy replaced (unknown keys dropped);
379
+ collapsed groups expanded then
380
+ re-collapsed per saved path.
381
+
382
+ ViewState shape:
383
+ {
384
+ v: 1, // schema version (integer)
385
+ sort: [{ key, dir }], // outermost key first
386
+ columnOrder: [key, ...],
387
+ columns: { [key]: { width, hidden, pin, flex } }, // full layout
388
+ filters: { [key]: query }, // empty/whitespace omitted
389
+ groupBy: [key, ...],
390
+ collapsedGroups: [pathStr, ...] // U+001F separator
391
+ }
392
+
393
+ Round-trip: setViewState(getViewState()) is identity. Keyed (not
394
+ positional) so a view survives column-set drift.
395
+
354
396
  ### mountTable(host, table, options?) -> TableMount
355
397
 
356
398
  options: { injectStyles?=true, initialViewportHeight?=480 }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zakkster/lite-table",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Headless reactive data tables on @zakkster/lite-signal. CSS Grid (no <table>), pooled slots that never reparent, aria-activedescendant focus model. Zero-GC scroll path on @zakkster/lite-virtual. Grouping, aggregation, sticky group headers, and grand totals in v1.2.",
5
5
  "sideEffects": false,
6
6
  "keywords": [