@ixfx/components 0.15.4 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/bundle/index.d.ts +1223 -48
  2. package/bundle/index.js +875 -700
  3. package/dist/ac-text.d.ts +1 -1
  4. package/dist/{ac-token-Cq7lsYf-.d.ts → ac-token-qEs3FsQv.d.ts} +1 -1
  5. package/dist/ac-token.d.ts +1 -1
  6. package/dist/colour-picker.d.ts +1 -1
  7. package/dist/crumb-navigation-BbqfmkYs.d.ts +147 -0
  8. package/dist/crumb-navigation-YnFVFqrx.js +1290 -0
  9. package/dist/crumbs.d.ts +2 -115
  10. package/dist/crumbs.js +1 -1179
  11. package/dist/{data-grid-CbiubdG2.d.ts → data-grid-CIG2DVip.d.ts} +58 -9
  12. package/dist/{data-grid-Dx3l3dxw.js → data-grid-rMkLX2qo.js} +630 -215
  13. package/dist/data-grid.d.ts +3 -3
  14. package/dist/data-grid.js +1 -1
  15. package/dist/{detail-list-ClkMV0SN.d.ts → detail-list-6iMTNP4N.d.ts} +35 -5
  16. package/dist/{detail-list-q8bs67BM.js → detail-list-C4pDSoI8.js} +218 -7
  17. package/dist/detail-list.d.ts +4 -4
  18. package/dist/detail-list.js +1 -1
  19. package/dist/{flat-list-0xTZdXzh.d.ts → flat-list-JPgQSmLW.d.ts} +28 -5
  20. package/dist/flat-list.d.ts +2 -2
  21. package/dist/flat-list.js +123 -4
  22. package/dist/{grid-list-y6dWJYu3.js → grid-list-CdEkScn-.js} +276 -75
  23. package/dist/{grid-list-BnWJUIBC.d.ts → grid-list-D6n7WH8l.d.ts} +128 -22
  24. package/dist/grid-list.d.ts +3 -3
  25. package/dist/grid-list.js +1 -1
  26. package/dist/{grouped-item-lister-Bhyo-dkH.d.ts → grouped-item-lister-DETte0Dh.d.ts} +1 -1
  27. package/dist/grouped-item-lister.d.ts +1 -1
  28. package/dist/{icon-DR_3rSyZ.d.ts → icon-CDGprSdE.d.ts} +1 -1
  29. package/dist/icons.d.ts +2 -2
  30. package/dist/incr-search.d.ts +1 -1
  31. package/dist/index-9ttcdwlL.d.ts +71 -0
  32. package/dist/{index-BAuqR9El.d.ts → index-Bx0BXPxI.d.ts} +52 -6
  33. package/dist/index-CYRc6Dah.d.ts +475 -0
  34. package/dist/{index-garxxqgV.d.ts → index-CYndvTWH.d.ts} +1 -1
  35. package/dist/{index-BUB5SICW.d.ts → index-CoqtMSd9.d.ts} +1 -1
  36. package/dist/{index-Bz3loIAf.d.ts → index-DN2BWctP.d.ts} +1 -1
  37. package/dist/{index-BPTuCRGb.d.ts → index-Dr80Zdl6.d.ts} +2 -2
  38. package/dist/{index-cnaRPQPp.d.ts → index-DxyyVqzA.d.ts} +2 -2
  39. package/dist/{index-CJ5fshD3.d.ts → index-UvnoZUAo.d.ts} +1 -1
  40. package/dist/index.d.ts +380 -33
  41. package/dist/index.js +2247 -866
  42. package/dist/{labelled-input-base-Dp_9KP0G.d.ts → labelled-input-base-DiTme1JO.d.ts} +1 -1
  43. package/dist/labelled-radial-input.d.ts +2 -2
  44. package/dist/labelled-range-input.d.ts +1 -1
  45. package/dist/labelled-slider-input.d.ts +1 -1
  46. package/dist/list-group-header.d.ts +1 -1
  47. package/dist/{list-native-drag-controller-O2Nruqpi.d.ts → list-native-drag-controller-D_UrXipS.d.ts} +1 -1
  48. package/dist/{list-selection-controller-BeE70lbB.js → list-selection-controller-5L0Nj9tR.js} +87 -2
  49. package/dist/{menu-item-B0zqw2xp.d.ts → menu-item-bF6narIz.d.ts} +1 -1
  50. package/dist/menu.d.ts +2 -2
  51. package/dist/miller-BFRfn0ve.js +2237 -0
  52. package/dist/miller.d.ts +4 -437
  53. package/dist/miller.js +1 -1815
  54. package/dist/panel.d.ts +1 -1
  55. package/dist/pending-BGidHppL.js +91 -0
  56. package/dist/scroll-hover-guard-Cjw6179Q.js +35 -0
  57. package/dist/{split-layout-C5SYbDrq.d.ts → split-layout-CophkNeU.d.ts} +42 -4
  58. package/dist/{split-layout-Deoxy47d.js → split-layout-DTY4PJRp.js} +251 -19
  59. package/dist/split-layout.d.ts +1 -1
  60. package/dist/split-layout.js +1 -1
  61. package/dist/sunburst-tree.d.ts +2 -0
  62. package/dist/sunburst-tree.js +2 -0
  63. package/dist/sunbursttree-list-B_VxOHlo.js +1556 -0
  64. package/dist/sunbursttree-list-D6j5BxVQ.d.ts +303 -0
  65. package/dist/{tab-list-wC0xNz1b.d.ts → tab-list-Df371rqw.d.ts} +1 -1
  66. package/dist/tabs.d.ts +1 -1
  67. package/dist/{titlebar-B5zSTZnF.d.ts → titlebar-Tqu-VvMU.d.ts} +1 -1
  68. package/dist/titlebar.d.ts +1 -1
  69. package/dist/tree-CftHwtEb.js +146 -0
  70. package/dist/{tree-D8VxvR9u.js → tree-Crdpl4nc.js} +275 -47
  71. package/dist/{tree-component-C6UHO-w9.d.ts → tree-component-DgfkxmVY.d.ts} +19 -8
  72. package/dist/tree.d.ts +3 -3
  73. package/dist/tree.js +1 -1
  74. package/dist/types-LU1u0CB8.d.ts +87 -0
  75. package/dist/vertical-list-1V1Vg2O5.js +1993 -0
  76. package/dist/{vertical-list-B8FoU-pU.d.ts → vertical-list-BPDmZx6T.d.ts} +31 -3
  77. package/dist/vertical-list.d.ts +3 -3
  78. package/dist/vertical-list.js +1 -1789
  79. package/dist/view-state-controller-BdyXWUUM.js +305 -0
  80. package/docs-user/README.md +3 -0
  81. package/docs-user/crumbs.md +27 -0
  82. package/docs-user/data-grid.md +77 -5
  83. package/docs-user/detail-list.md +54 -0
  84. package/docs-user/dock.md +54 -12
  85. package/docs-user/flat-list.md +27 -0
  86. package/docs-user/grid-list.md +64 -11
  87. package/docs-user/hyperbolic-tree.md +31 -4
  88. package/docs-user/icicle-tree.md +36 -0
  89. package/docs-user/index.json +17 -2
  90. package/docs-user/llms.txt +3 -0
  91. package/docs-user/miller.md +52 -21
  92. package/docs-user/multicol-list.md +50 -8
  93. package/docs-user/split-layout.md +45 -17
  94. package/docs-user/sunburst-tree.md +223 -0
  95. package/docs-user/tree.md +46 -7
  96. package/docs-user/treemap.md +32 -0
  97. package/docs-user/twosplit-layout.md +22 -0
  98. package/docs-user/user-catalog.md +1 -1
  99. package/docs-user/vertical-list.md +55 -7
  100. package/llms.txt +3 -0
  101. package/package.json +5 -1
@@ -0,0 +1,305 @@
1
+ //#region src/common/view-state/types.ts
2
+ /**
3
+ * Shared view-state contract for list-like components.
4
+ *
5
+ * View state is everything about *how* a component is showing its data —
6
+ * selection, expansion, scroll, column sizes — kept separate from the data
7
+ * itself. It is plain JSON (arrays and records, never Sets or Maps) so a host
8
+ * can `JSON.stringify` it straight into whatever storage it likes. Components
9
+ * never persist it themselves; see `bindViewState()`.
10
+ *
11
+ * Every field is keyed by the stable item / column / group key, never by
12
+ * index, so state can be re-applied to data that has been rebuilt, re-sorted
13
+ * or streamed in over time. Keys the current data doesn't contain are kept
14
+ * as pending and applied once a matching item appears.
15
+ */
16
+ /** Current schema version. Bump on breaking shape changes. */
17
+ const VIEW_STATE_VERSION = 1;
18
+ /** Name of the debounced change event every view-stateful component fires. */
19
+ const VIEW_STATE_CHANGE = `view-state-change`;
20
+ //#endregion
21
+ //#region src/common/view-state/scroll-anchor.ts
22
+ /**
23
+ * Capture a {@link ScrollState} for a DOM-rendered list. `items` must be in
24
+ * visual order along `axis`; the first one whose far edge is below / right of
25
+ * the viewport start is the anchor (found by binary search).
26
+ */
27
+ function captureElementScroll(viewport, items, getKey, axis = `vertical`) {
28
+ const top = viewport.scrollTop;
29
+ const left = viewport.scrollLeft;
30
+ const base = left === 0 ? { top } : {
31
+ top,
32
+ left
33
+ };
34
+ if (items.length === 0) return base;
35
+ const vpRect = viewport.getBoundingClientRect();
36
+ const start = axis === `vertical` ? vpRect.top : vpRect.left;
37
+ const edges = (el) => {
38
+ const r = el.getBoundingClientRect();
39
+ return axis === `vertical` ? {
40
+ lo: r.top,
41
+ hi: r.bottom
42
+ } : {
43
+ lo: r.left,
44
+ hi: r.right
45
+ };
46
+ };
47
+ let lo = 0;
48
+ let hi = items.length - 1;
49
+ let found = -1;
50
+ while (lo <= hi) {
51
+ const mid = lo + hi >> 1;
52
+ if (edges(items[mid]).hi > start) {
53
+ found = mid;
54
+ hi = mid - 1;
55
+ } else lo = mid + 1;
56
+ }
57
+ if (found === -1) return base;
58
+ for (let i = found; i < items.length; i++) {
59
+ const key = getKey(items[i]);
60
+ if (key !== void 0) return {
61
+ ...base,
62
+ anchor: {
63
+ key,
64
+ offset: Math.round(start - edges(items[i]).lo)
65
+ }
66
+ };
67
+ }
68
+ return base;
69
+ }
70
+ /**
71
+ * Scroll `viewport` so `item` sits at the anchor's offset. Returns `false`
72
+ * when the viewport can't scroll far enough yet (content still arriving),
73
+ * so the caller can keep the anchor pending.
74
+ */
75
+ function restoreElementScroll(viewport, item, offset, axis = `vertical`) {
76
+ const vpRect = viewport.getBoundingClientRect();
77
+ const r = item.getBoundingClientRect();
78
+ if (axis === `vertical`) return scrollToOffset(viewport, viewport.scrollTop + (r.top - vpRect.top) + offset, axis);
79
+ return scrollToOffset(viewport, viewport.scrollLeft + (r.left - vpRect.left) + offset, axis);
80
+ }
81
+ /**
82
+ * Set the scroll offset along `axis`. Returns `false` when `target` is beyond
83
+ * the current scroll range (it is clamped and should be retried later).
84
+ */
85
+ function scrollToOffset(viewport, target, axis = `vertical`) {
86
+ const max = axis === `vertical` ? viewport.scrollHeight - viewport.clientHeight : viewport.scrollWidth - viewport.clientWidth;
87
+ const clamped = Math.max(0, Math.min(target, max));
88
+ if (axis === `vertical`) viewport.scrollTop = clamped;
89
+ else viewport.scrollLeft = clamped;
90
+ return target <= max + 1;
91
+ }
92
+ /**
93
+ * Anchor for a virtualised list with arithmetic row geometry: pass the first
94
+ * visible index, the key at that index and that row's start offset.
95
+ */
96
+ function anchorFromRow(scrollOffset, key, rowStart, crossOffset = 0) {
97
+ const base = crossOffset === 0 ? { top: scrollOffset } : {
98
+ top: scrollOffset,
99
+ left: crossOffset
100
+ };
101
+ if (key === void 0) return base;
102
+ return {
103
+ ...base,
104
+ anchor: {
105
+ key,
106
+ offset: Math.round(scrollOffset - rowStart)
107
+ }
108
+ };
109
+ }
110
+ const SCROLL_KEYS = /* @__PURE__ */ new Set([
111
+ `ArrowUp`,
112
+ `ArrowDown`,
113
+ `ArrowLeft`,
114
+ `ArrowRight`,
115
+ `PageUp`,
116
+ `PageDown`,
117
+ `Home`,
118
+ `End`,
119
+ ` `
120
+ ]);
121
+ /**
122
+ * Call `onIntent` the first time the user does something that scrolls or
123
+ * moves within `target` (wheel, touch, pointer on the scrollbar, navigation
124
+ * key). Used to cancel a pending scroll restore — the user's own scroll wins.
125
+ * Programmatic `scrollTop` writes do not trigger it. Returns a disposer.
126
+ */
127
+ function onUserScrollIntent(target, onIntent) {
128
+ const events = [
129
+ `wheel`,
130
+ `touchstart`,
131
+ `pointerdown`,
132
+ `keydown`
133
+ ];
134
+ const handler = (event) => {
135
+ if (event.type === `keydown` && !SCROLL_KEYS.has(event.key)) return;
136
+ onIntent();
137
+ };
138
+ for (const type of events) target.addEventListener(type, handler, {
139
+ passive: true,
140
+ capture: true
141
+ });
142
+ return () => {
143
+ for (const type of events) target.removeEventListener(type, handler, { capture: true });
144
+ };
145
+ }
146
+ //#endregion
147
+ //#region src/common/view-state/view-state-controller.ts
148
+ const REASON_RANK = {
149
+ restore: 0,
150
+ data: 1,
151
+ user: 2
152
+ };
153
+ /**
154
+ * Plumbing shared by every view-stateful component:
155
+ *
156
+ * - **change events** — `notify(reason)` coalesces changes into one debounced
157
+ * `view-state-change` event (strongest reason wins: user > data > restore),
158
+ * flushed immediately on disconnect so the last change is never lost.
159
+ * - **restore marking** — changes made inside `restoring(fn)` are reported as
160
+ * `restore`, so persistence code can skip writing back what it just loaded.
161
+ * - **validation** — `accepts(state)` rejects a state for another kind / version.
162
+ * - **pending scroll** — a scroll position waiting for its anchor item (or for
163
+ * enough content). Cancelled by the user's own scroll intent on the target
164
+ * given to `observeScrollTarget()`.
165
+ *
166
+ * The component owns its facets (selection, expansion, columns) and decides
167
+ * what's pending; this controller only carries the cross-cutting parts.
168
+ */
169
+ var ViewStateController = class {
170
+ #host;
171
+ #opts;
172
+ #timer;
173
+ #reason;
174
+ #restoreDepth = 0;
175
+ #pendingScroll;
176
+ /** Set when a restore wrote the scroll offset; the scroll event that follows is not the user's. */
177
+ #restoreScrolled = false;
178
+ #disposeScrollIntent;
179
+ #scrollTarget;
180
+ constructor(host, options) {
181
+ this.#host = host;
182
+ this.#opts = options;
183
+ host.addController(this);
184
+ }
185
+ get kind() {
186
+ return this.#opts.kind;
187
+ }
188
+ hostConnected() {
189
+ if (this.#scrollTarget && !this.#disposeScrollIntent) this.observeScrollTarget(this.#scrollTarget);
190
+ }
191
+ hostDisconnected() {
192
+ this.flush();
193
+ this.#disposeScrollIntent?.();
194
+ this.#disposeScrollIntent = void 0;
195
+ }
196
+ /** Record a change. Inside `restoring()` the reason is forced to `restore`. */
197
+ notify(reason = `user`) {
198
+ const effective = this.#restoreDepth > 0 ? `restore` : reason;
199
+ if (this.#reason === void 0 || REASON_RANK[effective] > REASON_RANK[this.#reason]) this.#reason = effective;
200
+ if (this.#timer !== void 0) clearTimeout(this.#timer);
201
+ this.#timer = setTimeout(() => this.flush(), this.#opts.debounceMs ?? 150);
202
+ }
203
+ /** Fire any pending change event now. */
204
+ flush() {
205
+ if (this.#timer !== void 0) {
206
+ clearTimeout(this.#timer);
207
+ this.#timer = void 0;
208
+ }
209
+ const reason = this.#reason;
210
+ if (reason === void 0) return;
211
+ this.#reason = void 0;
212
+ this.#host.dispatchEvent(new CustomEvent(VIEW_STATE_CHANGE, {
213
+ detail: {
214
+ state: this.#opts.capture(),
215
+ reason
216
+ },
217
+ bubbles: true
218
+ }));
219
+ }
220
+ /** Run `fn` with every `notify()` inside it reported as `restore`. */
221
+ restoring(fn) {
222
+ this.#restoreDepth++;
223
+ try {
224
+ return fn();
225
+ } finally {
226
+ this.#restoreDepth--;
227
+ }
228
+ }
229
+ /** True while inside `restoring()`. */
230
+ get isRestoring() {
231
+ return this.#restoreDepth > 0;
232
+ }
233
+ /** Whether `state` is for this component kind and schema version. Warns when not. */
234
+ accepts(state) {
235
+ if (state === null || typeof state !== `object`) return false;
236
+ if (state.v !== void 0 && state.v !== 1) {
237
+ console.warn(`[${this.#opts.kind}] ignoring view state with version ${String(state.v)}`);
238
+ return false;
239
+ }
240
+ if (state.kind !== void 0 && state.kind !== this.#opts.kind) {
241
+ console.warn(`[${this.#opts.kind}] ignoring view state for kind '${state.kind}'`);
242
+ return false;
243
+ }
244
+ return true;
245
+ }
246
+ /**
247
+ * Stamp `v` / `kind` onto `fields` and apply `include`. Components build
248
+ * their full field set and pass it through here from `getViewState()`.
249
+ */
250
+ shape(fields, options) {
251
+ const out = {
252
+ v: 1,
253
+ kind: this.#opts.kind
254
+ };
255
+ const include = options?.include;
256
+ for (const [key, value] of Object.entries(fields)) {
257
+ if (value === void 0) continue;
258
+ if (include && !include.includes(key)) continue;
259
+ out[key] = value;
260
+ }
261
+ return out;
262
+ }
263
+ /**
264
+ * Watch `target` for user scroll intent (wheel / touch / pointer / nav key);
265
+ * any intent drops the pending scroll. Call again if the scroll container
266
+ * is replaced.
267
+ */
268
+ observeScrollTarget(target) {
269
+ this.#disposeScrollIntent?.();
270
+ this.#scrollTarget = target;
271
+ this.#disposeScrollIntent = onUserScrollIntent(target, () => {
272
+ this.#pendingScroll = void 0;
273
+ });
274
+ }
275
+ get pendingScroll() {
276
+ return this.#pendingScroll;
277
+ }
278
+ set pendingScroll(scroll) {
279
+ this.#pendingScroll = scroll;
280
+ }
281
+ /**
282
+ * Try the pending scroll with `apply`; it returns `true` once the position
283
+ * was fully reached (anchor present, content long enough), which clears it.
284
+ * Call after each render that may have added items.
285
+ */
286
+ applyPendingScroll(apply) {
287
+ const scroll = this.#pendingScroll;
288
+ if (!scroll) return;
289
+ this.#restoreScrolled = true;
290
+ if (apply(scroll)) this.#pendingScroll = void 0;
291
+ }
292
+ /**
293
+ * Call from the viewport's `scroll` listener. Ignored while a scroll restore
294
+ * is still pending; the event caused by a restore's own write is reported
295
+ * as `restore`, anything else as `user`.
296
+ */
297
+ notifyScroll() {
298
+ if (this.#pendingScroll) return;
299
+ const reason = this.#restoreScrolled ? `restore` : `user`;
300
+ this.#restoreScrolled = false;
301
+ this.notify(reason);
302
+ }
303
+ };
304
+ //#endregion
305
+ export { restoreElementScroll as a, VIEW_STATE_VERSION as c, onUserScrollIntent as i, anchorFromRow as n, scrollToOffset as o, captureElementScroll as r, VIEW_STATE_CHANGE as s, ViewStateController as t };
@@ -23,6 +23,7 @@ This directory contains documentation for each component and some architectural
23
23
  - [grid-list](./grid-list.md)
24
24
  - [grouped-item-lister](./grouped-item-lister.md)
25
25
  - [hyperbolic-tree](./hyperbolic-tree.md)
26
+ - [icicle-tree](./icicle-tree.md)
26
27
  - [icons](./icons.md)
27
28
  - [incr-search](./incr-search.md)
28
29
  - [labelled-radial-input](./labelled-radial-input.md)
@@ -49,6 +50,7 @@ This directory contains documentation for each component and some architectural
49
50
  - [snackbar](./snackbar.md)
50
51
  - [snap-container](./snap-container.md)
51
52
  - [split-layout](./split-layout.md)
53
+ - [sunburst-tree](./sunburst-tree.md)
52
54
  - [swipe](./swipe.md)
53
55
  - [tabs](./tabs.md)
54
56
  - [text-shimmer](./text-shimmer.md)
@@ -57,6 +59,7 @@ This directory contains documentation for each component and some architectural
57
59
  - [toolbar](./toolbar.md)
58
60
  - [transitory-label](./transitory-label.md)
59
61
  - [tree](./tree.md)
62
+ - [treemap](./treemap.md)
60
63
  - [twosplit-layout](./twosplit-layout.md)
61
64
  - [util](./util.md)
62
65
  - [vertical-list](./vertical-list.md)
@@ -38,6 +38,7 @@ nav.model = {
38
38
  | `inputMode` | `string` | `''` | Input mode for styling (`'keyboard'`, etc.) |
39
39
  | `padEnding` | `boolean` | `false` | Pad trailing space to enable left-align scroll |
40
40
  | `withUp` | `boolean` | `false` | Show an Up button to navigate to parent |
41
+ | `restoreLoads` | `boolean` | `true` | Let a restored location load unloaded branches via `loadChildren` |
41
42
 
42
43
  ### Events
43
44
 
@@ -55,6 +56,32 @@ nav.model = {
55
56
  |-------------------------|---------|------------------------------------------------|
56
57
  | `navigateTo(keys)` | `boolean` | Navigate to a node by key path. Returns false if not found. |
57
58
  | `navigateToNode(predicate)` | `boolean` | Navigate to the first node matching a predicate. |
59
+ | `getViewState(options?)` | `CrumbNavigationViewState` | Snapshot the location. See [View state](#view-state). |
60
+ | `setViewState(state)` | `void` | Go to a saved location, now or as its nodes arrive. |
61
+ | `clearPendingViewState()` | `void` | Forget a saved location not reached yet. |
62
+
63
+ ### View state
64
+
65
+ The current location as plain JSON, for the host to persist (see
66
+ [docs/infra-view-state.md](../../docs/infra-view-state.md)):
67
+
68
+ ```ts
69
+ { v: 1, kind: 'crumb-navigation', path: ['dev', 'workspace'] } // keys below the root; [] = root
70
+ ```
71
+
72
+ The location *is* the selection, so there is no separate `selected` field, and
73
+ it is a key path rather than a lone key so a deep saved location can be loaded
74
+ down to. The keyboard cursor is transient and not recorded.
75
+
76
+ - Keys not loaded yet stay pending: the crumb moves to the deepest loaded
77
+ ancestor and continues as nodes arrive (new `root`, or children filled in
78
+ followed by `requestUpdate()`). With `restoreLoads` it calls `loadChildren`
79
+ itself, one branch at a time. Each step fires `select`.
80
+ - New `root` data keeps the location by key. If it's gone, the crumb steps
81
+ back to the closest ancestor silently and returns when the key reappears.
82
+ - Any explicit navigation — a click, `select()`, `navigateTo()`, or the host
83
+ assigning `selectedNode` — drops a pending location. Set the initial
84
+ `selectedNode` *before* `setViewState()` / `bindViewState()`.
58
85
 
59
86
  ### CSS custom properties
60
87
 
@@ -223,7 +223,8 @@ grid.tickledKey; // row key under the cursor, or undefined
223
223
  ```
224
224
 
225
225
  The setter fires `datagrid-select` `{ selectedKeys, previous }` only when the
226
- selection actually changes.
226
+ selection actually changes. Selected rows that leave `rows` are deselected
227
+ silently, and reselected if they return. See [View state](#view-state).
227
228
 
228
229
  ---
229
230
 
@@ -306,9 +307,12 @@ for plain text columns. It returns `false` and does nothing for:
306
307
 
307
308
  - an unknown column key,
308
309
  - a column with `resizable: false`,
309
- - a column with a `renderer` — its rendered output (icons, badges, custom
310
- markup, etc.) can't be reliably measured from the raw value, so sizing to
311
- it would be a guess.
310
+ - a column with a `renderer` that gives no hint of its size — its rendered
311
+ output (icons, badges, custom markup, etc.) can't be reliably measured from
312
+ the raw value. Opt in with either:
313
+ - `fixedExtraWidth: n` — pixels added to the measured text width (e.g. a
314
+ thumbnail beside the text), or
315
+ - `measure: (row, col) => px` — your own content-width measure per row.
312
316
 
313
317
  `--data-grid-column-resize-duration` (default `180ms`) controls the
314
318
  transition speed.
@@ -459,6 +463,10 @@ Demo: `Score` is random float `0–100` with 0–3 decimals, `align:'right'`, `f
459
463
 
460
464
  Gear menu (`ixfx-menu-trigger` placement `bottom-end`, `inset-area: block-end span-inline-end`) lists `Columns` (check-items) and `Group by` (radio). Toggle visibility via `col.visible=false` or menu. Event: `datagrid-column-visibility` `{key, visible}`.
461
465
 
466
+ Set `col.group` to a heading (e.g. the facet a column came from) and the menu
467
+ lists columns sharing that group in one submenu; ungrouped columns stay at the
468
+ top level.
469
+
462
470
  `column-menu` attribute: `none` (default) hides the gear menu; `auto` opts in to
463
471
  rendering it. `col.visible` + `datagrid-column-visibility` give full external
464
472
  control, so a host can own the column-visibility UI.
@@ -542,7 +550,8 @@ still shadow-internal.
542
550
  | `appendRows` | `(rows)` | Add rows without a full re-sort/rebuild — for streaming |
543
551
  | `selectKeys` / `set selectedKeys` | `(keys)` | Replace the selection by row key |
544
552
  | `tickleRow` | `(key)` | Move the row cursor and scroll it into view |
545
- | `autoSizeColumn` | `(key)=>boolean` | Resize a column to fit its contents, animating the width. Returns `false` (no-op) for an unknown column, `resizable: false`, or a column with a `renderer` — see [Auto-sizing a column](#auto-sizing-a-column). |
553
+ | `getViewState` / `setViewState` / `clearPendingViewState` | see [View state](#view-state) | Snapshot / restore selection, cursor, scroll, groups, columns, sort |
554
+ | `autoSizeColumn` | `(key)=>boolean` | Resize a column to fit its contents, animating the width. Returns `false` (no-op) for an unknown column, `resizable: false`, or a `renderer` column without `measure`/`fixedExtraWidth` — see [Auto-sizing a column](#auto-sizing-a-column). |
546
555
 
547
556
  | Getter | Type | |
548
557
  |---|---|---|
@@ -560,6 +569,69 @@ when the key sets actually differ from the last dispatch. Mirrors
560
569
 
561
570
  ---
562
571
 
572
+ ## View state
573
+
574
+ The grid implements the shared view-state API (`docs/infra-view-state.md`):
575
+ `getViewState()`, `setViewState()`, `clearPendingViewState()` and a debounced
576
+ `view-state-change` event. `bindViewState()` persists it in two lines:
577
+
578
+ ```ts
579
+ import { bindViewState } from '@ixfx/components';
580
+
581
+ bindViewState(grid, {
582
+ load: () => JSON.parse(localStorage.getItem(`orders-grid`) ?? `null`),
583
+ save: s => localStorage.setItem(`orders-grid`, JSON.stringify(s)),
584
+ });
585
+ grid.columns = defs;
586
+ for await (const batch of stream)
587
+ grid.appendRows(batch); // saved selection / cursor / scroll apply as rows arrive
588
+ ```
589
+
590
+ `kind: 'data-grid'`. Fields (all optional on `setViewState`; present fields
591
+ replace, absent ones are untouched):
592
+
593
+ | Field | Shape | |
594
+ |---|---|---|
595
+ | `selected` | `string[]` | Selected row keys (the checked set in `checked` mode) |
596
+ | `focus` | `string` | Row cursor key — recorded only after keyboard navigation |
597
+ | `scroll` | `{ top, left?, anchor?: { key, offset } }` | `anchor` is the first visible row's key; `left` is raw px |
598
+ | `expanded` | `Record<groupKey, boolean>` | Groups default open, so only collapsed groups (`false`) are recorded |
599
+ | `columns` | `Record<columnKey, { width?, visible?, order? }>` | `order` is a rank: lower is further left |
600
+ | `sort` | `{ key, direction } \| null` | `null` = unsorted |
601
+ | `groupBy` | `string \| null` | Only recorded when `allowGrouping` is on (the user can change it); a function `groupBy` is never recorded |
602
+
603
+ **Pending state.** Keys that aren't in the data stay pending and are applied
604
+ when a matching row, group or column appears, which covers restoring before
605
+ data, `rows` resets and rebuilds, and `appendRows()` streams. When a selected
606
+ row leaves `rows` it is deselected silently and its key goes pending. When it
607
+ comes back it is reselected and `datagrid-select` fires. Any explicit
608
+ selection change (`selectKeys`, click, key, `Esc`) drops pending selection.
609
+ Resolving pending state on `appendRows()` looks only at the appended slice.
610
+
611
+ **Columns.** Saved width and visibility for a column apply once, when the
612
+ column is first defined. After that the column definition is the source of
613
+ truth, so a host that later redefines the column wins. A column that is
614
+ removed from `columns` keeps its last width and visibility pending until it
615
+ returns. A saved `order` reorders the columns it names within the positions
616
+ they occupy. Columns it doesn't name keep their place. It is kept until every
617
+ column it names has appeared, so late columns still land in their saved
618
+ position. Dragging a header to reorder drops it. Applied layout goes onto
619
+ copies of the column objects, and `columns` is reassigned.
620
+
621
+ **Rows keyed by index.** With the default `getRowKey`, a row with neither
622
+ `key` nor `id` is keyed by its sorted index. Index keys change on every sort,
623
+ insert or rebuild, so these rows are never recorded in `selected`, `focus` or
624
+ the scroll anchor (`scroll.top` / `left` are still recorded). Supply a
625
+ `getRowKey` (or `key` / `id` fields) to make their state restorable. A
626
+ custom `getRowKey` is trusted to be stable.
627
+
628
+ Change events: `user` for selection, cursor moves, scrolling, sort clicks,
629
+ column resize / auto-size / reorder / visibility, group collapse and group-by
630
+ menu choices; `restore` for state applied from `setViewState()` or pending
631
+ state.
632
+
633
+ ---
634
+
563
635
  ## Demo
564
636
 
565
637
  `demo/data-grid.html` + `demo/data-grid.ts`: 200 rows, `Department` random, `Score` float, `Name`/`Department` editable (`user` mode, slow double-click; `Tab`/`Up`/`Down` navigation; `Enter` from tickled row). Controls: Selection/Interaction/Group by/Editability/`column-menu` + `Edit first row` (manual), a `Stream +5,000 rows` button (`appendRows`), and `selectKeys` / `tickleRow` buttons.
@@ -38,6 +38,7 @@ Selection semantics match `ixfx-vertical-list` — same modes, same `list-*` eve
38
38
  - [Asynchronous data](#asynchronous-data)
39
39
  - [Promise provider](#promise-provider)
40
40
  - [Streaming provider](#streaming-provider)
41
+ - [View state](#view-state)
41
42
  - [CSS variables](#css-variables)
42
43
  - [CSS parts](#css-parts)
43
44
  - [Performance](#performance)
@@ -587,6 +588,59 @@ replaced) falls back to a full rebuild.
587
588
 
588
589
  ---
589
590
 
591
+ ## View state
592
+
593
+ Snapshot and restore *how* the list is showing its data, separately from the
594
+ data, as plain JSON keyed by item / group key. See
595
+ [`docs/infra-view-state.md`](../../docs/infra-view-state.md) for the shared
596
+ contract.
597
+
598
+ ```ts
599
+ import { bindViewState } from '@ixfx/components';
600
+
601
+ bindViewState(list, {
602
+ load: () => JSON.parse(localStorage.getItem(`files`) ?? `null`),
603
+ save: s => localStorage.setItem(`files`, JSON.stringify(s)),
604
+ });
605
+ list.provider = fetchFiles; // pending state applies as batches arrive
606
+ ```
607
+
608
+ `kind: 'detail-list'`. Fields (`DetailListViewState`):
609
+
610
+ | Field | Shape | Notes |
611
+ |---|---|---|
612
+ | `selected` | `string[]` | Selected item keys (`data-key` / `data-value`, or `keyProperty`). In `checked` mode this is the checked set — `data-checked` is derived from the selection, so there is no separate field. |
613
+ | `expanded` | `Record<string, boolean>` | `{ [groupKey]: false }` per collapsed group. Groups default open, so only collapses are recorded; `true` re-opens. |
614
+ | `focus` | `string` | Keyboard-cursor key, recorded only when the cursor was moved by keyboard. Restoring it puts the list in keyboard mode. |
615
+ | `scroll` | `{ top, left?, anchor? }` | Horizontal: `left`, plus `anchor` = the top item of the first visible column and the px offset into it. |
616
+
617
+ `sort` is not view state: the list has no UI to change it, so it stays host
618
+ configuration.
619
+
620
+ - **Pending.** Keys not in the data stay pending and apply when a matching
621
+ item appears — via `items`, a provider batch (only the new slice is
622
+ searched), `addItem` / `setItems`, or a light-DOM append. Selected items that
623
+ leave the data (rebuild, `clearItems`, removed `<li>`) are deselected
624
+ silently and their keys go pending.
625
+ - Restored selection fires `list-select`; restored collapses fire
626
+ `list-group-toggle` for groups that are present. Any explicit selection
627
+ change drops pending selection.
628
+ - Changing `groupBy` drops collapses of the current groups but keeps pending
629
+ (never-matched) collapsed keys, so a state restored before `groupBy` is set
630
+ still applies.
631
+ - A pending scroll waits until its anchor item is laid out and the columns are
632
+ wide enough; a wheel, pointer or navigation key cancels it.
633
+
634
+ ```ts
635
+ list.getViewState(); // pending included
636
+ list.getViewState({ includePending: false });
637
+ list.setViewState({ selected: [`row-3`] }); // partial: only fields present apply
638
+ list.clearPendingViewState();
639
+ list.addEventListener(`view-state-change`, e => e.detail /* { state, reason } */);
640
+ ```
641
+
642
+ ---
643
+
590
644
  ## CSS variables
591
645
 
592
646
  | Variable | Default | Description |
package/docs-user/dock.md CHANGED
@@ -63,7 +63,7 @@ The tree is plain and mutable, owned exclusively by the host.
63
63
 
64
64
  Named containers: give a stack leaf `name` (e.g. `"primary-sidebar"`) in the
65
65
  initial layout JSON. Names must be unique per layout (first match wins),
66
- serialize with `saveLayout()` / `loadLayout()`, and render as
66
+ are saved and restored with the [view state](#view-state), and render as
67
67
  `data-dock-name` on the leaf. Explicit leaf close still removes the stack.
68
68
  When a `tabs` drop empties a named stack into an *unnamed* one, the name
69
69
  (and its content rules) follows the panels to the merged leaf instead of
@@ -117,7 +117,7 @@ split resizes, and host resizes; handles are hidden during dock drags.
117
117
  `setLeafExclusiveContents(name, kinds)` admits only those kinds into the
118
118
  named leaf, `setLeafDenyContents(name, kinds)` refuses those kinds.
119
119
  `clearLeafContentRestrictions(name)` lifts both; `[]` seals (exclusive) or
120
- lifts (deny). Rules are host policy — kept across `loadLayout()`, never
120
+ lifts (deny). Rules are host policy — kept across `setViewState()`, never
121
121
  serialized; already-docked panels are grandfathered.
122
122
  - Leaf placement policy — `setLeafPlacementPolicy(name, policy)` is a superset
123
123
  of the two calls above (`allowKinds` / `denyKinds`) plus `allowEdgeSplit`
@@ -181,7 +181,7 @@ While hidden:
181
181
  ## Events
182
182
 
183
183
  `ixfx-dock-host`: `dock-change` (`{ layout }` after structural mutations),
184
- `dock-panel-added`, `dock-panel-removed`, `dock-panel-activated`,
184
+ `view-state-change` (debounced, see [View state](#view-state)), `dock-panel-added`, `dock-panel-removed`, `dock-panel-activated`,
185
185
  `dock-panel-drag-start`, `dock-panel-moved`, `dock-stack-empty`,
186
186
  `dock-tab-contextmenu`.
187
187
 
@@ -196,7 +196,7 @@ While hidden:
196
196
  - `dock-stack-empty` — `{ name, hostEmpty }` when a stack loses its last panel
197
197
  (`name` is the container name or `null`). `hostEmpty` is true when the host
198
198
  holds no panels at all — the cue to close an owning group. Fires before the
199
- matching `dock-change`; not fired by `loadLayout()`.
199
+ matching `dock-change`; not fired by `setViewState()`.
200
200
  - `dock-tab-contextmenu` — `{ panelId, clientX, clientY }`, cancelable
201
201
  (`preventDefault()` suppresses the native menu). Visible tabs only; with a
202
202
  hidden strip handle `contextmenu` on your own element.
@@ -226,8 +226,9 @@ The host consumes `ixfx-split-layout` events: `ixfx-split-resize` /
226
226
  ## Public API — `ixfx-dock-host`
227
227
 
228
228
  ```ts
229
- saveLayout(): DockLayout // folds live split weights from the DOM
230
- loadLayout(layout: DockLayout): void
229
+ getViewState(options?): DockHostViewState // { v, kind: 'dock-host', layout }; folds live split weights
230
+ setViewState(state: Partial<DockHostViewState>): void
231
+ clearPendingViewState(): void // no-op: placeholders live in the layout tree
231
232
  addPanel(panel: DockPanelElement | DockPanelInit, target?: DockAddPanelTarget): boolean
232
233
  type DockAddPanelTarget = {
233
234
  container?: string; // named stack leaf — takes precedence over `stack`
@@ -427,13 +428,54 @@ it can measure.
427
428
  window. `acceptExternalPanel` returns `null` if that id is already docked in
428
429
  the target host (adopt / focus it yourself instead).
429
430
 
430
- ## Serialization
431
+ ## View state
431
432
 
432
- `saveLayout()` folds live gutter weights from each rendered
433
- `ixfx-split-layout` (via `getSizes()`) into `DockSplitNode.sizes` before
434
- serializing, so a save → drag → save round-trip preserves user adjustments.
435
- `DockStackNode.collapsed` / `preCollapseSize` serialize so a saved layout can
436
- restore collapse state without a live element.
433
+ The dock implements the shared view-state API
434
+ ([docs/infra-view-state.md](../../docs/infra-view-state.md)). The component
435
+ never persists anything; the host does, e.g. with `bindViewState()`:
436
+
437
+ ```ts
438
+ import { bindViewState } from '@ixfx/components';
439
+
440
+ bindViewState(dock, {
441
+ load: () => JSON.parse(localStorage.getItem(`workspace`) ?? `null`),
442
+ save: s => localStorage.setItem(`workspace`, JSON.stringify(s)),
443
+ });
444
+ ```
445
+
446
+ | Field | Type | Meaning |
447
+ |---|---|---|
448
+ | `layout` | `DockLayout` | The whole tree: splits with `sizes`, stacks with tab order, `active`, `name`, `collapsed` / `preCollapseSize`, strip flags. Keyed by panel id. |
449
+
450
+ The dock layout is close to structure, so the saved state *is* the tree
451
+ (collapse and the active tab stay inside it rather than in separate
452
+ `expanded` / `selected` fields).
453
+
454
+ - `getViewState()` folds live gutter weights from each rendered
455
+ `ixfx-split-layout` (via `getSizes()`) into `DockSplitNode.sizes` first, so
456
+ a save → drag → save round-trip preserves user adjustments.
457
+ - **Panels not in the host yet.** `setViewState()` keeps every panel id the
458
+ layout names, held or not. One the host doesn't hold is a *placeholder*:
459
+ it keeps its place in the tree (shown as a tab labelled with its id) and
460
+ the panel slots into that place when it arrives — appended as a light-DOM
461
+ child, `addPanel()` **without** a target, `acceptExternalPanel()` without
462
+ `at`, or a cross-host adoption. An explicit `addPanel()` target still wins.
463
+ Placeholders are saved by `getViewState()`; `includePending: false` leaves
464
+ them out (stacks they empty are dropped unless named). Remove one with
465
+ `removePanel(id)`; `clearPendingViewState()` is a no-op.
466
+ - Pooled panels the layout doesn't name are appended to the first stack
467
+ admitting their kind.
468
+ - A panel that is closed / removed (`removePanel`, close button, removed from
469
+ the DOM, moved to another host) leaves the layout — closing is a structural
470
+ change, so its place is not remembered.
471
+ - State set before the host is connected wins over the `layout` attribute.
472
+ - A malformed `layout` (or a state for another `kind`) is ignored with a
473
+ warning.
474
+ - `view-state-change` fires (debounced) after structural changes (drops,
475
+ add / remove, close, leaf moves), gutter resizes, collapse / expand and
476
+ tab activation. `reason` is `data` when the pool changed under the host,
477
+ `restore` for `setViewState()`. The dock's internal split layouts' own
478
+ `view-state-change` events don't escape the host.
437
479
 
438
480
  ## CSS variables
439
481