@lavalogic/scoria 0.37.52 → 0.37.53

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.
@@ -0,0 +1,42 @@
1
+ import type { ColumnSizingState } from './ColumnSizingState.js';
2
+ import type { JSONColumnPinningState } from './JSONColumnPinningState.js';
3
+ import type { VisibilityState } from './VisibilityState.js';
4
+ /**
5
+ * Persisted-to-localStorage snapshot of a table's *live* column layout -
6
+ * the order / visibility / pinning / width state a user builds up by
7
+ * dragging, hiding and resizing columns, as distinct from an explicitly
8
+ * saved named preset (`JSONColumnDefSet`).
9
+ *
10
+ * `TableContext` writes this automatically on every layout change and
11
+ * re-applies it on the next mount, so a user's tweaks survive navigation
12
+ * and reloads without having to save a preset - persisted until the
13
+ * browser storage is cleared or the table's column set changes (schema
14
+ * drift, which drops the stale snapshot).
15
+ *
16
+ * Only ids and primitive maps are stored - never the column-def objects
17
+ * themselves - so the snapshot stays small and is trivially validated.
18
+ */
19
+ export interface JSONTableLayout {
20
+ /** Column ids in display order (memory-index order). */
21
+ columnOrder: Array<string>;
22
+ /** Tri-state per-column visibility map. */
23
+ visibility: VisibilityState;
24
+ /** Pinned-left / pinned-right column ids with their pinned widths. */
25
+ pinning: JSONColumnPinningState;
26
+ /** Per-column user-set widths in pixels. */
27
+ sizing: ColumnSizingState;
28
+ }
29
+ /**
30
+ * Schema version for the persisted `JSONTableLayout` envelope. Bump when
31
+ * the shape changes in a backwards-incompatible way; `readVersionedJSON`
32
+ * silently drops envelopes written under a different version.
33
+ */
34
+ export declare const TABLE_LAYOUT_SCHEMA_VERSION = 1;
35
+ /**
36
+ * Conservative typeguard for a persisted `JSONTableLayout`. localStorage
37
+ * is same-origin-writable so the decoded value is untrusted; this checks
38
+ * the envelope shape and every field's primitive type. `TableContext`
39
+ * performs the deeper "do these ids still match the live column set"
40
+ * check before applying.
41
+ */
42
+ export declare function isJSONTableLayout(raw: unknown): raw is JSONTableLayout;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Schema version for the persisted `JSONTableLayout` envelope. Bump when
3
+ * the shape changes in a backwards-incompatible way; `readVersionedJSON`
4
+ * silently drops envelopes written under a different version.
5
+ */
6
+ export const TABLE_LAYOUT_SCHEMA_VERSION = 1;
7
+ /**
8
+ * Conservative typeguard for a persisted `JSONTableLayout`. localStorage
9
+ * is same-origin-writable so the decoded value is untrusted; this checks
10
+ * the envelope shape and every field's primitive type. `TableContext`
11
+ * performs the deeper "do these ids still match the live column set"
12
+ * check before applying.
13
+ */
14
+ export function isJSONTableLayout(raw) {
15
+ if (typeof raw !== 'object' || raw === null) {
16
+ return false;
17
+ }
18
+ const candidate = raw;
19
+ if (!Array.isArray(candidate.columnOrder) ||
20
+ !candidate.columnOrder.every((id) => typeof id === 'string')) {
21
+ return false;
22
+ }
23
+ if (!isPrimitiveRecord(candidate.visibility, (v) => typeof v === 'boolean')) {
24
+ return false;
25
+ }
26
+ if (!isPrimitiveRecord(candidate.sizing, (v) => typeof v === 'number')) {
27
+ return false;
28
+ }
29
+ return isJSONPinningState(candidate.pinning);
30
+ }
31
+ /** Validates a plain (non-array) object whose values all satisfy `valueGuard`. */
32
+ function isPrimitiveRecord(raw, valueGuard) {
33
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
34
+ return false;
35
+ }
36
+ return Object.values(raw).every(valueGuard);
37
+ }
38
+ /** Validates the `{ left?, right? }` array-pair pinning shape. */
39
+ function isJSONPinningState(raw) {
40
+ if (typeof raw !== 'object' || raw === null) {
41
+ return false;
42
+ }
43
+ const candidate = raw;
44
+ return isPinSide(candidate.left) && isPinSide(candidate.right);
45
+ }
46
+ /** Validates one optional pinning side: absent, or an array of `[id, width]`. */
47
+ function isPinSide(raw) {
48
+ if (raw === undefined) {
49
+ return true;
50
+ }
51
+ if (!Array.isArray(raw)) {
52
+ return false;
53
+ }
54
+ return raw.every((entry) => Array.isArray(entry) &&
55
+ entry.length === 2 &&
56
+ typeof entry[0] === 'string' &&
57
+ typeof entry[1] === 'number');
58
+ }
@@ -1,4 +1,5 @@
1
1
  import type { ColumnPinningState } from '../Columns/ColumnPinningState.js';
2
+ import { type JSONTableLayout } from '../Columns/JSONTableLayout.js';
2
3
  import type { ColumnDef } from '../Columns/Definitions/ColumnDef.svelte.js';
3
4
  import type { ColumnDefSet } from '../Columns/Definitions/ColumnDefSet.js';
4
5
  import type { JSONColumnDefSet } from '../Columns/Definitions/JSONColumnDefSet.js';
@@ -139,7 +140,7 @@ export declare class PreferencesState<T extends object> {
139
140
  * for the same namespacing convention; the `<user>` segment is
140
141
  * `getUserId() ?? 'anon'` (see `userIdSegment`).
141
142
  */
142
- storageKey(suffix: 'presets' | 'selected-preset-name' | 'settings'): string;
143
+ storageKey(suffix: 'presets' | 'selected-preset-name' | 'settings' | 'layout'): string;
143
144
  /**
144
145
  * Boot-time hydration helper. Reads the persisted preset list from
145
146
  * `localStorage`, validates it against `isJSONColumnDefSetArray`,
@@ -172,6 +173,30 @@ export declare class PreferencesState<T extends object> {
172
173
  * the live column-def shape (schema drift or column-set change).
173
174
  */
174
175
  clearPresetsAndSelection(): void;
176
+ /**
177
+ * Boot-time hydration helper. Reads the auto-saved live-layout
178
+ * snapshot (column order / visibility / pinning / sizing the user
179
+ * built up without saving a named preset). Returns `null` for a
180
+ * missing slot, a schema-version mismatch, or a value that fails
181
+ * validation, so the parent can fall back to the default layout.
182
+ *
183
+ * `localStorage` access is gated by the parent's `browser` check;
184
+ * this method assumes the caller already verified the environment.
185
+ */
186
+ loadLayout(): JSONTableLayout | null;
187
+ /**
188
+ * Persist the live-layout snapshot. Written by the parent on every
189
+ * column order / visibility / pinning / sizing change, wrapped in a
190
+ * versioned envelope so a future schema bump drops stale snapshots
191
+ * cleanly. Storage failures are swallowed inside `writeJSON`.
192
+ */
193
+ persistLayout(layout: JSONTableLayout): void;
194
+ /**
195
+ * Drop the persisted live-layout snapshot. Called when the snapshot
196
+ * no longer matches the table's column set (schema drift) or when
197
+ * table state is explicitly reset.
198
+ */
199
+ clearLayout(): void;
175
200
  /**
176
201
  * Persist the selected-preset name as plain text (no JSON
177
202
  * envelope - the slot is a single string by design).
@@ -1,5 +1,6 @@
1
1
  import { dev } from '$app/environment';
2
- import { readJSON } from '../../../../Helpers/Storage.js';
2
+ import { readJSON, readVersionedJSON, writeJSON } from '../../../../Helpers/Storage.js';
3
+ import { isJSONTableLayout, TABLE_LAYOUT_SCHEMA_VERSION, } from '../Columns/JSONTableLayout.js';
3
4
  /**
4
5
  * Conservative typeguard for the persisted preset list. Storage is
5
6
  * same-origin-writable so only the array shape can be trusted;
@@ -192,6 +193,53 @@ export class PreferencesState {
192
193
  localStorage.removeItem(this.storageKey('presets'));
193
194
  localStorage.removeItem(this.storageKey('selected-preset-name'));
194
195
  }
196
+ /**
197
+ * Boot-time hydration helper. Reads the auto-saved live-layout
198
+ * snapshot (column order / visibility / pinning / sizing the user
199
+ * built up without saving a named preset). Returns `null` for a
200
+ * missing slot, a schema-version mismatch, or a value that fails
201
+ * validation, so the parent can fall back to the default layout.
202
+ *
203
+ * `localStorage` access is gated by the parent's `browser` check;
204
+ * this method assumes the caller already verified the environment.
205
+ */
206
+ loadLayout() {
207
+ // Reference-equality sentinel: `readVersionedJSON` demands a
208
+ // fallback of the value type, so a distinct object instance is
209
+ // used to distinguish "no usable slot" from a real snapshot.
210
+ const fallback = {
211
+ columnOrder: [],
212
+ visibility: {},
213
+ pinning: {},
214
+ sizing: {},
215
+ };
216
+ const saved = readVersionedJSON(localStorage, this.storageKey('layout'), TABLE_LAYOUT_SCHEMA_VERSION, isJSONTableLayout, fallback);
217
+ return saved === fallback ? null : saved;
218
+ }
219
+ /**
220
+ * Persist the live-layout snapshot. Written by the parent on every
221
+ * column order / visibility / pinning / sizing change, wrapped in a
222
+ * versioned envelope so a future schema bump drops stale snapshots
223
+ * cleanly. Storage failures are swallowed inside `writeJSON`.
224
+ */
225
+ persistLayout(layout) {
226
+ writeJSON(localStorage, this.storageKey('layout'), layout, TABLE_LAYOUT_SCHEMA_VERSION);
227
+ }
228
+ /**
229
+ * Drop the persisted live-layout snapshot. Called when the snapshot
230
+ * no longer matches the table's column set (schema drift) or when
231
+ * table state is explicitly reset.
232
+ */
233
+ clearLayout() {
234
+ try {
235
+ localStorage.removeItem(this.storageKey('layout'));
236
+ }
237
+ catch (e) {
238
+ if (dev) {
239
+ console.warn('table layout slot clear failed:', e);
240
+ }
241
+ }
242
+ }
195
243
  /**
196
244
  * Persist the selected-preset name as plain text (no JSON
197
245
  * envelope - the slot is a single string by design).
@@ -73,6 +73,21 @@ export declare class TableContext<T extends object, RowIdType extends Primitive>
73
73
  static init<T extends object, RowIdType extends Primitive>(defaultColumnDefs: DefsWrapper<T>, tableName: string, dataRepo: IDataRepository<T>, options: TableInitOptions<T, RowIdType>): TableContext<T, RowIdType>;
74
74
  protected constructor(INTERNAL_ONLY: symbol, defaultColumnDefs: DefsWrapper<T>, tableName: string, dataRepo: IDataRepository<T>, allowCopy: boolean, enableResize: boolean, allowColumnReordering: boolean, allowQuickFiltering: boolean, showQuickFilterByDefault: boolean, allowAdvancedFiltering: boolean, toolbarPosition: ToolbarPosition, displayFooter: boolean, selectionExtractor: TableInitOptions<T, RowIdType>['selectionExtractor'], getUserId: () => string | undefined, _onEditCell: TableInitOptions<T, RowIdType>['onEditCell'], debug: boolean);
75
75
  private isSelectable;
76
+ /**
77
+ * `false` until the boot-time layout hydration (`_hydrateLayout`)
78
+ * has run. The auto-persist `$effect` reads this gate so the live
79
+ * layout is not written back during construction / hydration -
80
+ * which would clobber the saved snapshot with the default layout.
81
+ */
82
+ private _layoutHydrated;
83
+ /**
84
+ * Pending debounce timer for the layout auto-persist. Deliberately
85
+ * not cleared on teardown: a still-pending write should flush even
86
+ * if the user navigates away mid-debounce, and `persistLayout` only
87
+ * touches `localStorage` (no reactive state, no DOM) so it is safe
88
+ * to run after the component is gone.
89
+ */
90
+ private _layoutPersistTimer;
76
91
  private _paginationRepo;
77
92
  get paginationRepo(): IDataRepository<T> | undefined;
78
93
  set paginationRepo(v: IDataRepository<T> | undefined);
@@ -620,6 +635,33 @@ export declare class TableContext<T extends object, RowIdType extends Primitive>
620
635
  * persistence boundary lives in the sub-context.
621
636
  */
622
637
  private _updateLocalStorageSettings;
638
+ /**
639
+ * Snapshot the live column layout - order / visibility / pinning /
640
+ * sizing - into the persistable `JSONTableLayout` shape. Reads every
641
+ * layout `$state` slot so the auto-persist `$effect` re-runs whenever
642
+ * any of them change.
643
+ */
644
+ private _captureLayout;
645
+ /**
646
+ * Apply a persisted `JSONTableLayout` to the live column state. The
647
+ * snapshot is dropped (and ignored) when its column-id set no longer
648
+ * matches the table's current columns - a column added or removed
649
+ * since the snapshot was written invalidates it.
650
+ */
651
+ private _applyLayout;
652
+ /**
653
+ * Boot-time hydration. Reads the auto-saved live-layout snapshot and
654
+ * applies it on top of whatever the preset flow produced, restoring
655
+ * the user's last column order / visibility / pinning / sizing.
656
+ * No-op when no snapshot is stored.
657
+ */
658
+ private _hydrateLayout;
659
+ /**
660
+ * Drop the persisted live-layout snapshot for this table. The next
661
+ * mount falls back to the default column layout. Exposed so a host
662
+ * app can wire an explicit "reset table layout" control.
663
+ */
664
+ clearPersistedLayout(): void;
623
665
  /**
624
666
  * Delegates to `_columnLayout.visualColumns`. The underscore prefix
625
667
  * is preserved so the existing read sites in `tabForward` /
@@ -19,6 +19,13 @@ import { PaginationType } from '../Pagination/PaginationType.js';
19
19
  import { ToolbarPosition } from '../Toolbar/ToolbarPosition.js';
20
20
  const SORTING_ICON_WIDTH = 24;
21
21
  const InternalSymbol = Symbol();
22
+ /**
23
+ * Debounce window (ms) for the auto-persist of the live column layout.
24
+ * Collapses a burst of changes - notably a drag-resize, which fires a
25
+ * width update on every pointer move - into a single `localStorage`
26
+ * write once the user settles.
27
+ */
28
+ const LAYOUT_PERSIST_DEBOUNCE_MS = 250;
22
29
  /**
23
30
  * Default values applied by `TableContext.init` for any `TableInitOptions`
24
31
  * field the consumer omits. Frozen so callers (notably `NestedTable`)
@@ -339,6 +346,26 @@ export class TableContext {
339
346
  if (hasPagination(dataRepo)) {
340
347
  this.paginationRepo = dataRepo;
341
348
  }
349
+ // Auto-persist the live column layout (order / visibility /
350
+ // pinning / sizing) to localStorage on every change, so a user's
351
+ // drag-reorder, hide/show and resize tweaks survive navigation
352
+ // and reload without having to save a named preset. Gated on
353
+ // `_layoutHydrated` so the boot-time hydration below does not race
354
+ // a write of the default layout over the saved snapshot, and
355
+ // debounced so a drag-resize storm collapses to a single write.
356
+ $effect(() => {
357
+ const snapshot = this._captureLayout();
358
+ if (!this._layoutHydrated) {
359
+ return;
360
+ }
361
+ if (this._layoutPersistTimer !== undefined) {
362
+ clearTimeout(this._layoutPersistTimer);
363
+ }
364
+ this._layoutPersistTimer = setTimeout(() => {
365
+ this._layoutPersistTimer = undefined;
366
+ this._preferences.persistLayout(snapshot);
367
+ }, LAYOUT_PERSIST_DEBOUNCE_MS);
368
+ });
342
369
  if (browser) {
343
370
  this._getUserDefaults();
344
371
  const removeInvalidPresets = () => {
@@ -400,6 +427,13 @@ export class TableContext {
400
427
  }
401
428
  this._preferences.setPresets(presets);
402
429
  }
430
+ })
431
+ .then(() => {
432
+ // Restore the auto-saved live layout on top of whatever
433
+ // the preset flow produced, then open the auto-persist
434
+ // gate. Runs regardless of which preset branch was taken.
435
+ this._hydrateLayout();
436
+ this._layoutHydrated = true;
403
437
  })
404
438
  .catch(devCatch);
405
439
  }
@@ -407,6 +441,21 @@ export class TableContext {
407
441
  // #endregion
408
442
  // #region stores
409
443
  isSelectable = $state(false);
444
+ /**
445
+ * `false` until the boot-time layout hydration (`_hydrateLayout`)
446
+ * has run. The auto-persist `$effect` reads this gate so the live
447
+ * layout is not written back during construction / hydration -
448
+ * which would clobber the saved snapshot with the default layout.
449
+ */
450
+ _layoutHydrated = $state(false);
451
+ /**
452
+ * Pending debounce timer for the layout auto-persist. Deliberately
453
+ * not cleared on teardown: a still-pending write should flush even
454
+ * if the user navigates away mid-debounce, and `persistLayout` only
455
+ * touches `localStorage` (no reactive state, no DOM) so it is safe
456
+ * to run after the component is gone.
457
+ */
458
+ _layoutPersistTimer = undefined;
410
459
  _paginationRepo = $state();
411
460
  get paginationRepo() {
412
461
  return this._paginationRepo;
@@ -1762,6 +1811,95 @@ export class TableContext {
1762
1811
  };
1763
1812
  this._preferences.persistSettings(settingsObject);
1764
1813
  }
1814
+ /**
1815
+ * Snapshot the live column layout - order / visibility / pinning /
1816
+ * sizing - into the persistable `JSONTableLayout` shape. Reads every
1817
+ * layout `$state` slot so the auto-persist `$effect` re-runs whenever
1818
+ * any of them change.
1819
+ */
1820
+ _captureLayout() {
1821
+ const columnOrder = this.columnDefs
1822
+ .slice()
1823
+ .sort((a, b) => a.index - b.index)
1824
+ .map((def) => def.id);
1825
+ const pinning = {
1826
+ left: [...(this.columnPinning.left?.entries() ?? [])],
1827
+ right: [...(this.columnPinning.right?.entries() ?? [])],
1828
+ };
1829
+ return {
1830
+ columnOrder,
1831
+ visibility: { ...this.columnVisibility },
1832
+ pinning,
1833
+ sizing: { ...this.columnSizing },
1834
+ };
1835
+ }
1836
+ /**
1837
+ * Apply a persisted `JSONTableLayout` to the live column state. The
1838
+ * snapshot is dropped (and ignored) when its column-id set no longer
1839
+ * matches the table's current columns - a column added or removed
1840
+ * since the snapshot was written invalidates it.
1841
+ */
1842
+ _applyLayout(layout) {
1843
+ const defs = this.columnDefs;
1844
+ // Keyed by plain `string` (not the branded `ColumnId`) so the
1845
+ // persisted-snapshot ids - read back as plain strings - look up
1846
+ // cleanly.
1847
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
1848
+ const byId = new Map(defs.map((def) => [def.id, def]));
1849
+ // Schema-drift guard: the snapshot must describe exactly the
1850
+ // current column set, otherwise it is stale and is discarded.
1851
+ const matchesColumnSet = layout.columnOrder.length === defs.length && layout.columnOrder.every((id) => byId.has(id));
1852
+ if (!matchesColumnSet) {
1853
+ this._preferences.clearLayout();
1854
+ return;
1855
+ }
1856
+ // Order: re-index the defs into the saved order.
1857
+ const ordered = layout.columnOrder.map((id, index) => {
1858
+ const def = byId.get(id);
1859
+ if (!def) {
1860
+ throw new Error(`_applyLayout: missing column def for id "${id}"`);
1861
+ }
1862
+ def.index = index;
1863
+ return def;
1864
+ });
1865
+ this.columnDefs = ordered;
1866
+ // Visibility / sizing: keep only ids still present in the table.
1867
+ const visibility = {};
1868
+ for (const [id, visible] of Object.entries(layout.visibility)) {
1869
+ if (byId.has(id)) {
1870
+ visibility[id] = visible;
1871
+ }
1872
+ }
1873
+ this.columnVisibility = visibility;
1874
+ const sizing = {};
1875
+ for (const [id, width] of Object.entries(layout.sizing)) {
1876
+ if (byId.has(id)) {
1877
+ sizing[id] = width;
1878
+ }
1879
+ }
1880
+ this.columnSizing = sizing;
1881
+ this.columnPinning = adaptJSONPinningState(layout.pinning);
1882
+ }
1883
+ /**
1884
+ * Boot-time hydration. Reads the auto-saved live-layout snapshot and
1885
+ * applies it on top of whatever the preset flow produced, restoring
1886
+ * the user's last column order / visibility / pinning / sizing.
1887
+ * No-op when no snapshot is stored.
1888
+ */
1889
+ _hydrateLayout() {
1890
+ const layout = this._preferences.loadLayout();
1891
+ if (layout) {
1892
+ this._applyLayout(layout);
1893
+ }
1894
+ }
1895
+ /**
1896
+ * Drop the persisted live-layout snapshot for this table. The next
1897
+ * mount falls back to the default column layout. Exposed so a host
1898
+ * app can wire an explicit "reset table layout" control.
1899
+ */
1900
+ clearPersistedLayout() {
1901
+ this._preferences.clearLayout();
1902
+ }
1765
1903
  /**
1766
1904
  * Delegates to `_columnLayout.visualColumns`. The underscore prefix
1767
1905
  * is preserved so the existing read sites in `tabForward` /
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lavalogic/scoria",
3
3
  "description": "Svelte components used for the FloWMS Web Frontend",
4
- "version": "0.37.52",
4
+ "version": "0.37.53",
5
5
  "publishConfig": {
6
6
  "access": "restricted"
7
7
  },