@ixfx/components 0.15.5 → 0.16.1

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 (80) hide show
  1. package/bundle/index.d.ts +986 -49
  2. package/bundle/index.js +765 -729
  3. package/dist/ac-token.js +1 -1
  4. package/dist/crumb-navigation-BbqfmkYs.d.ts +147 -0
  5. package/dist/crumb-navigation-YnFVFqrx.js +1290 -0
  6. package/dist/crumbs.d.ts +2 -115
  7. package/dist/crumbs.js +1 -1179
  8. package/dist/{data-grid-C1sZX6n9.js → data-grid-Cqxuwgbb.js} +615 -210
  9. package/dist/{data-grid-D7qzmMMp.d.ts → data-grid-Csh5EJW9.d.ts} +55 -4
  10. package/dist/data-grid.d.ts +3 -3
  11. package/dist/data-grid.js +1 -1
  12. package/dist/{detail-list-q8bs67BM.js → detail-list-Bo5ze3SN.js} +234 -9
  13. package/dist/{detail-list-CKIKlszv.d.ts → detail-list-DSZ1DDor.d.ts} +48 -4
  14. package/dist/detail-list.d.ts +4 -4
  15. package/dist/detail-list.js +1 -1
  16. package/dist/{flat-list-DXMyDiSH.d.ts → flat-list-CT9kX3TQ.d.ts} +27 -4
  17. package/dist/flat-list.d.ts +2 -2
  18. package/dist/flat-list.js +123 -4
  19. package/dist/{grid-list-y6dWJYu3.js → grid-list-D0dJNVEF.js} +278 -76
  20. package/dist/{grid-list-0e7snW6t.d.ts → grid-list-DDLrWRxm.d.ts} +128 -22
  21. package/dist/grid-list.d.ts +3 -3
  22. package/dist/grid-list.js +1 -1
  23. package/dist/{incr-search-JIhwB49y.js → incr-search-CgbL-OlS.js} +140 -10
  24. package/dist/incr-search.d.ts +1 -1
  25. package/dist/incr-search.js +1 -1
  26. package/dist/index-9ttcdwlL.d.ts +71 -0
  27. package/dist/index-Bnm-kKGC.d.ts +475 -0
  28. package/dist/{index-KN2MW_xD.d.ts → index-CUErGhCg.d.ts} +31 -4
  29. package/dist/{index-DEVyVIn8.d.ts → index-D-lZNlHa.d.ts} +52 -6
  30. package/dist/index.d.ts +341 -21
  31. package/dist/index.js +2286 -884
  32. package/dist/{list-native-drag-controller-O2Nruqpi.d.ts → list-native-drag-controller-D_UrXipS.d.ts} +1 -1
  33. package/dist/{list-selection-controller-BeE70lbB.js → list-selection-controller-5L0Nj9tR.js} +87 -2
  34. package/dist/miller-DUqiUAWQ.js +2230 -0
  35. package/dist/miller.d.ts +4 -437
  36. package/dist/miller.js +1 -1815
  37. package/dist/narrowed-text.js +1 -1
  38. package/dist/pending-BGidHppL.js +91 -0
  39. package/dist/scroll-hover-guard-Cjw6179Q.js +35 -0
  40. package/dist/{split-layout-JK3xWVX2.d.ts → split-layout-CophkNeU.d.ts} +41 -3
  41. package/dist/{split-layout-Deoxy47d.js → split-layout-DTY4PJRp.js} +251 -19
  42. package/dist/split-layout.d.ts +1 -1
  43. package/dist/split-layout.js +1 -1
  44. package/dist/sunburst-tree.d.ts +2 -2
  45. package/dist/sunburst-tree.js +1 -1
  46. package/dist/{sunbursttree-list-yXwS-uEI.js → sunbursttree-list-B_M0YTEA.js} +299 -80
  47. package/dist/{sunbursttree-list--Z0uyh8O.d.ts → sunbursttree-list-DePuFYlU.d.ts} +40 -3
  48. package/dist/tree-CftHwtEb.js +146 -0
  49. package/dist/{tree-D8VxvR9u.js → tree-DAlgvaHB.js} +276 -48
  50. package/dist/{tree-component-C6UHO-w9.d.ts → tree-component-DgfkxmVY.d.ts} +19 -8
  51. package/dist/tree.d.ts +3 -3
  52. package/dist/tree.js +1 -1
  53. package/dist/types-LU1u0CB8.d.ts +87 -0
  54. package/dist/vertical-list-DyKPKGxK.js +1993 -0
  55. package/dist/{vertical-list-D8MksoTp.d.ts → vertical-list-SAx56U-l.d.ts} +31 -3
  56. package/dist/vertical-list.d.ts +3 -3
  57. package/dist/vertical-list.js +1 -1789
  58. package/dist/view-state-controller-BdyXWUUM.js +305 -0
  59. package/docs-user/README.md +2 -0
  60. package/docs-user/crumbs.md +27 -0
  61. package/docs-user/data-grid.md +79 -5
  62. package/docs-user/detail-list.md +69 -0
  63. package/docs-user/dock.md +63 -12
  64. package/docs-user/flat-list.md +27 -0
  65. package/docs-user/grid-list.md +69 -13
  66. package/docs-user/hyperbolic-tree.md +31 -4
  67. package/docs-user/icicle-tree.md +36 -0
  68. package/docs-user/index.json +12 -2
  69. package/docs-user/llms.txt +2 -0
  70. package/docs-user/miller.md +52 -21
  71. package/docs-user/multicol-list.md +50 -8
  72. package/docs-user/split-layout.md +45 -17
  73. package/docs-user/sunburst-tree.md +32 -2
  74. package/docs-user/tree.md +45 -6
  75. package/docs-user/treemap.md +32 -0
  76. package/docs-user/twosplit-layout.md +22 -0
  77. package/docs-user/user-catalog.md +1 -1
  78. package/docs-user/vertical-list.md +55 -7
  79. package/llms.txt +2 -0
  80. package/package.json +2 -2
@@ -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)
@@ -58,6 +59,7 @@ This directory contains documentation for each component and some architectural
58
59
  - [toolbar](./toolbar.md)
59
60
  - [transitory-label](./transitory-label.md)
60
61
  - [tree](./tree.md)
62
+ - [treemap](./treemap.md)
61
63
  - [twosplit-layout](./twosplit-layout.md)
62
64
  - [util](./util.md)
63
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
 
@@ -505,10 +506,19 @@ rows dropped from the flat list) modes. No default shortcut:
505
506
  grid.registry.invoke('list.search.filter'); // or .navigate / .select
506
507
  ```
507
508
 
508
- A row is matched against `rowSearchText(row)` — by default its visible cells'
509
- values joined by a space; override with the `rowSearchText` property. `select`
510
- mode needs `selection-mode="multiple"` to hold more than one row. Character
511
- highlights are not drawn (a match position spans multiple cells). `filterPredicate`
509
+ A row is matched against the displayed (formatted) text of one column: the
510
+ `search-column` attribute (`searchColumn` property) names it, defaulting to the
511
+ first visible column. Searching every column makes dates and sizes match
512
+ queries like `12`, which is rarely what Find is for. Set the `rowSearchText`
513
+ property, `(row) => string`, to match against something else, e.g. several
514
+ columns joined. `select` mode needs `selection-mode="multiple"` to hold more
515
+ than one row.
516
+
517
+ Matched characters in the search column are highlighted under the
518
+ `data-grid-search` key, coloured by `--search-highlight-bg` /
519
+ `--search-highlight-color` (default `--accent` / `--accent-text`). Matches
520
+ against a custom `rowSearchText` aren't highlighted, since they can't be
521
+ mapped onto a cell, and neither are cells drawn by a column `renderer`. `filterPredicate`
512
522
  (a `(row) => boolean` property) filters rows directly. `search-modes` limits the
513
523
  offered modes. Full model: [`docs/infra-incremental.md`](../../docs/infra-incremental.md).
514
524
 
@@ -549,6 +559,7 @@ still shadow-internal.
549
559
  | `appendRows` | `(rows)` | Add rows without a full re-sort/rebuild — for streaming |
550
560
  | `selectKeys` / `set selectedKeys` | `(keys)` | Replace the selection by row key |
551
561
  | `tickleRow` | `(key)` | Move the row cursor and scroll it into view |
562
+ | `getViewState` / `setViewState` / `clearPendingViewState` | see [View state](#view-state) | Snapshot / restore selection, cursor, scroll, groups, columns, sort |
552
563
  | `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). |
553
564
 
554
565
  | Getter | Type | |
@@ -567,6 +578,69 @@ when the key sets actually differ from the last dispatch. Mirrors
567
578
 
568
579
  ---
569
580
 
581
+ ## View state
582
+
583
+ The grid implements the shared view-state API (`docs/infra-view-state.md`):
584
+ `getViewState()`, `setViewState()`, `clearPendingViewState()` and a debounced
585
+ `view-state-change` event. `bindViewState()` persists it in two lines:
586
+
587
+ ```ts
588
+ import { bindViewState } from '@ixfx/components';
589
+
590
+ bindViewState(grid, {
591
+ load: () => JSON.parse(localStorage.getItem(`orders-grid`) ?? `null`),
592
+ save: s => localStorage.setItem(`orders-grid`, JSON.stringify(s)),
593
+ });
594
+ grid.columns = defs;
595
+ for await (const batch of stream)
596
+ grid.appendRows(batch); // saved selection / cursor / scroll apply as rows arrive
597
+ ```
598
+
599
+ `kind: 'data-grid'`. Fields (all optional on `setViewState`; present fields
600
+ replace, absent ones are untouched):
601
+
602
+ | Field | Shape | |
603
+ |---|---|---|
604
+ | `selected` | `string[]` | Selected row keys (the checked set in `checked` mode) |
605
+ | `focus` | `string` | Row cursor key — recorded only after keyboard navigation |
606
+ | `scroll` | `{ top, left?, anchor?: { key, offset } }` | `anchor` is the first visible row's key; `left` is raw px |
607
+ | `expanded` | `Record<groupKey, boolean>` | Groups default open, so only collapsed groups (`false`) are recorded |
608
+ | `columns` | `Record<columnKey, { width?, visible?, order? }>` | `order` is a rank: lower is further left |
609
+ | `sort` | `{ key, direction } \| null` | `null` = unsorted |
610
+ | `groupBy` | `string \| null` | Only recorded when `allowGrouping` is on (the user can change it); a function `groupBy` is never recorded |
611
+
612
+ **Pending state.** Keys that aren't in the data stay pending and are applied
613
+ when a matching row, group or column appears, which covers restoring before
614
+ data, `rows` resets and rebuilds, and `appendRows()` streams. When a selected
615
+ row leaves `rows` it is deselected silently and its key goes pending. When it
616
+ comes back it is reselected and `datagrid-select` fires. Any explicit
617
+ selection change (`selectKeys`, click, key, `Esc`) drops pending selection.
618
+ Resolving pending state on `appendRows()` looks only at the appended slice.
619
+
620
+ **Columns.** Saved width and visibility for a column apply once, when the
621
+ column is first defined. After that the column definition is the source of
622
+ truth, so a host that later redefines the column wins. A column that is
623
+ removed from `columns` keeps its last width and visibility pending until it
624
+ returns. A saved `order` reorders the columns it names within the positions
625
+ they occupy. Columns it doesn't name keep their place. It is kept until every
626
+ column it names has appeared, so late columns still land in their saved
627
+ position. Dragging a header to reorder drops it. Applied layout goes onto
628
+ copies of the column objects, and `columns` is reassigned.
629
+
630
+ **Rows keyed by index.** With the default `getRowKey`, a row with neither
631
+ `key` nor `id` is keyed by its sorted index. Index keys change on every sort,
632
+ insert or rebuild, so these rows are never recorded in `selected`, `focus` or
633
+ the scroll anchor (`scroll.top` / `left` are still recorded). Supply a
634
+ `getRowKey` (or `key` / `id` fields) to make their state restorable. A
635
+ custom `getRowKey` is trusted to be stable.
636
+
637
+ Change events: `user` for selection, cursor moves, scrolling, sort clicks,
638
+ column resize / auto-size / reorder / visibility, group collapse and group-by
639
+ menu choices; `restore` for state applied from `setViewState()` or pending
640
+ state.
641
+
642
+ ---
643
+
570
644
  ## Demo
571
645
 
572
646
  `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)
@@ -161,6 +162,7 @@ list.keyProperty = 'name'; // Preserves selection across rebuilds
161
162
  | `textColorProperty` | `string` | Property name for the caption's CSS colour |
162
163
  | `badgeProperty` | `string` | Property name for a Finder-style badge |
163
164
  | `formatter` | `(item: unknown, index: number) => string \| Node` | Custom renderer |
165
+ | `searchText` | `(item: unknown, index: number) => string` | Text incremental search matches (default: the caption if a string, else the row's whole text) |
164
166
 
165
167
  ```typescript
166
168
  list.formatter = (item, index) => {
@@ -176,6 +178,18 @@ put, and the new row animates in like `addItem()`. Pass new objects (or change
176
178
  `displayProperty`, `formatter`, …) to rebuild rows. With a `formatter`, a row
177
179
  is only kept at the same index, since the formatter receives the index.
178
180
 
181
+ Because reused rows aren't re-run through the `formatter`, state it reads from
182
+ outside the item (sizes, fit mode, …) doesn't reach existing rows when the same
183
+ objects are reassigned. Call `list.reformat()` to rebuild every row, or assign a
184
+ new `formatter` function (a new identity counts as a shape change).
185
+
186
+ When `formatter` returns a `Node`, search falls back to the row's whole text
187
+ (sublines, sizes, dates). Set `searchText` to match on just the name:
188
+
189
+ ```typescript
190
+ list.searchText = item => (item as { name: string }).name;
191
+ ```
192
+
179
193
  Retrieve the original data for an element:
180
194
 
181
195
  ```typescript
@@ -458,6 +472,8 @@ Search matches against `data-search-label` (or `textContent` as fallback). Use `
458
472
  <li data-search-label="document a alpha">Document A</li>
459
473
  ```
460
474
 
475
+ For object items, `searchText` sets `data-search-label` per row (see [Object items](#object-items)).
476
+
461
477
  ### Highlight styling
462
478
 
463
479
  Matched characters are highlighted with the `detail-list-search` highlight key. Style matches with:
@@ -587,6 +603,59 @@ replaced) falls back to a full rebuild.
587
603
 
588
604
  ---
589
605
 
606
+ ## View state
607
+
608
+ Snapshot and restore *how* the list is showing its data, separately from the
609
+ data, as plain JSON keyed by item / group key. See
610
+ [`docs/infra-view-state.md`](../../docs/infra-view-state.md) for the shared
611
+ contract.
612
+
613
+ ```ts
614
+ import { bindViewState } from '@ixfx/components';
615
+
616
+ bindViewState(list, {
617
+ load: () => JSON.parse(localStorage.getItem(`files`) ?? `null`),
618
+ save: s => localStorage.setItem(`files`, JSON.stringify(s)),
619
+ });
620
+ list.provider = fetchFiles; // pending state applies as batches arrive
621
+ ```
622
+
623
+ `kind: 'detail-list'`. Fields (`DetailListViewState`):
624
+
625
+ | Field | Shape | Notes |
626
+ |---|---|---|
627
+ | `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. |
628
+ | `expanded` | `Record<string, boolean>` | `{ [groupKey]: false }` per collapsed group. Groups default open, so only collapses are recorded; `true` re-opens. |
629
+ | `focus` | `string` | Keyboard-cursor key, recorded only when the cursor was moved by keyboard. Restoring it puts the list in keyboard mode. |
630
+ | `scroll` | `{ top, left?, anchor? }` | Horizontal: `left`, plus `anchor` = the top item of the first visible column and the px offset into it. |
631
+
632
+ `sort` is not view state: the list has no UI to change it, so it stays host
633
+ configuration.
634
+
635
+ - **Pending.** Keys not in the data stay pending and apply when a matching
636
+ item appears — via `items`, a provider batch (only the new slice is
637
+ searched), `addItem` / `setItems`, or a light-DOM append. Selected items that
638
+ leave the data (rebuild, `clearItems`, removed `<li>`) are deselected
639
+ silently and their keys go pending.
640
+ - Restored selection fires `list-select`; restored collapses fire
641
+ `list-group-toggle` for groups that are present. Any explicit selection
642
+ change drops pending selection.
643
+ - Changing `groupBy` drops collapses of the current groups but keeps pending
644
+ (never-matched) collapsed keys, so a state restored before `groupBy` is set
645
+ still applies.
646
+ - A pending scroll waits until its anchor item is laid out and the columns are
647
+ wide enough; a wheel, pointer or navigation key cancels it.
648
+
649
+ ```ts
650
+ list.getViewState(); // pending included
651
+ list.getViewState({ includePending: false });
652
+ list.setViewState({ selected: [`row-3`] }); // partial: only fields present apply
653
+ list.clearPendingViewState();
654
+ list.addEventListener(`view-state-change`, e => e.detail /* { state, reason } */);
655
+ ```
656
+
657
+ ---
658
+
590
659
  ## CSS variables
591
660
 
592
661
  | Variable | Default | Description |