@ixfx/components 0.16.3 → 0.16.4

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 (39) hide show
  1. package/bundle/index.d.ts +2 -2
  2. package/bundle/index.js +5 -5
  3. package/dist/{index-DyaHA9Kk.d.ts → index-YCDugjdu.d.ts} +2 -2
  4. package/dist/index.d.ts +1 -1
  5. package/dist/index.js +1 -1
  6. package/dist/{miller-Be7x4ufN.js → miller-BrmBywNZ.js} +17 -7
  7. package/dist/miller.d.ts +1 -1
  8. package/dist/miller.js +1 -1
  9. package/docs-user/README.md +5 -0
  10. package/docs-user/bottom-drawer.md +2 -2
  11. package/docs-user/crumbs.md +1 -1
  12. package/docs-user/data-grid.md +2 -2
  13. package/docs-user/detail-list.md +3 -3
  14. package/docs-user/dock.md +3 -3
  15. package/docs-user/flat-list.md +7 -7
  16. package/docs-user/grid-list.md +3 -3
  17. package/docs-user/hyperbolic-tree.md +6 -6
  18. package/docs-user/icicle-tree.md +2 -2
  19. package/docs-user/incr-search.md +1 -1
  20. package/docs-user/index.json +22 -2
  21. package/docs-user/infra-handle.md +122 -0
  22. package/docs-user/infra-incremental.md +154 -0
  23. package/docs-user/infra-theming.md +510 -0
  24. package/docs-user/infra-view-state.md +210 -0
  25. package/docs-user/list-state.md +656 -0
  26. package/docs-user/llms.txt +5 -0
  27. package/docs-user/miller.md +2 -2
  28. package/docs-user/panel.md +3 -3
  29. package/docs-user/split-layout.md +2 -2
  30. package/docs-user/sunburst-tree.md +3 -3
  31. package/docs-user/titlebar.md +2 -2
  32. package/docs-user/tree.md +2 -2
  33. package/docs-user/treemap.md +2 -2
  34. package/docs-user/twosplit-layout.md +3 -3
  35. package/docs-user/user-selection-intents.md +3 -3
  36. package/docs-user/user-theming.md +1 -1
  37. package/docs-user/vertical-list.md +2 -2
  38. package/llms.txt +5 -0
  39. package/package.json +1 -1
@@ -0,0 +1,656 @@
1
+ # Saving and restoring list state
2
+
3
+ The list, tree, grid and layout components can give you a snapshot of their
4
+ *view state*: what the user has done to them, as opposed to the data you gave
5
+ them. That covers:
6
+
7
+ - which items are selected (or checked)
8
+ - which tree branches and groups are open or collapsed
9
+ - where the list is scrolled to
10
+ - where the keyboard cursor is
11
+ - column widths, column order, column visibility and sort, in grids
12
+ - panel sizes and arrangement, in layouts
13
+
14
+ You give the component data. The component keeps track of the view state.
15
+ When you want that state to survive (a reload, a route change, a data
16
+ refresh), ask for a snapshot and hand it back later.
17
+
18
+ The components never store anything themselves. Where the snapshot goes
19
+ (localStorage, a URL, a server, nowhere) is up to you.
20
+
21
+ Internal design notes for component authors are in
22
+ [`infra-view-state.md`](./infra-view-state.md).
23
+
24
+ ---
25
+
26
+ ## Quick start
27
+
28
+ ```ts
29
+ import { bindViewState } from '@ixfx/components';
30
+
31
+ const tree = document.querySelector(`ixfx-tree-list`)!;
32
+
33
+ bindViewState(tree, {
34
+ load: () => JSON.parse(localStorage.getItem(`files-tree`) ?? `null`),
35
+ save: state => localStorage.setItem(`files-tree`, JSON.stringify(state)),
36
+ });
37
+
38
+ tree.root = await fetchTree(); // before or after, it doesn't matter
39
+ ```
40
+
41
+ That's all. `bindViewState`:
42
+
43
+ 1. calls `load()` once and applies the result,
44
+ 2. calls `save()` after each change (debounced, about 150 ms after the user stops),
45
+ 3. calls `save()` again when the page is hidden (`pagehide`), so the last change isn't lost.
46
+
47
+ It returns a function that stops saving:
48
+
49
+ ```ts
50
+ const stop = bindViewState(el, { load, save });
51
+ // later
52
+ stop();
53
+ ```
54
+
55
+ If `load()` throws (bad JSON, say), it logs a warning and carries on with no
56
+ saved state.
57
+
58
+ ---
59
+
60
+ ## Doing it by hand
61
+
62
+ Every view-stateful component has the same three methods and one event:
63
+
64
+ ```ts
65
+ const state = el.getViewState(); // plain JSON object
66
+ el.setViewState(state); // apply it, any time
67
+
68
+ el.addEventListener(`view-state-change`, (e) => {
69
+ e.detail.state; // the full current state
70
+ e.detail.reason; // 'user' | 'data' | 'restore'
71
+ });
72
+
73
+ el.clearPendingViewState(); // see "Pending state" below
74
+ ```
75
+
76
+ ### `getViewState(options?)`
77
+
78
+ Returns a plain object you can pass straight to `JSON.stringify`. It contains
79
+ only arrays, records, strings, numbers and booleans.
80
+
81
+ ```ts
82
+ el.getViewState(); // everything
83
+ el.getViewState({ include: [`columns`, `sort`] }); // only some fields
84
+ el.getViewState({ includePending: false }); // only what's live now
85
+ ```
86
+
87
+ `includePending` defaults to `true`, which is almost always what you want; see
88
+ [Pending state](#pending-state).
89
+
90
+ ### `setViewState(state)`
91
+
92
+ - Can be called before the component has any data, before it's connected, or
93
+ at any point afterwards.
94
+ - Accepts a **partial** state. Fields you include replace the current value,
95
+ and fields you leave out are untouched:
96
+
97
+ ```ts
98
+ el.setViewState({ selected: [`invoice-42`] }); // only changes the selection
99
+ ```
100
+
101
+ - Ignores state meant for a different component, or from a different schema
102
+ version, and logs a warning. Feeding a data-grid's saved state into a tree
103
+ does nothing.
104
+
105
+ ### `view-state-change`
106
+
107
+ Fired after changes, debounced. It bubbles, but is **not** composed: it won't
108
+ escape the shadow root of a component that contains it. Listen on the
109
+ component itself.
110
+
111
+ `reason` tells you what caused the change:
112
+
113
+ | `reason` | Meaning |
114
+ |---|---|
115
+ | `user` | The user clicked, typed, dragged or scrolled. |
116
+ | `data` | You changed the data and the component adjusted. |
117
+ | `restore` | Saved state was applied, either by `setViewState()` or because a matching item just arrived. |
118
+
119
+ When several things happen inside one debounce window, the strongest reason
120
+ wins (`user` > `data` > `restore`). `bindViewState` skips `restore` events by
121
+ default, because they only echo what you just loaded. Pass
122
+ `saveOnRestore: true` if you want them saved too.
123
+
124
+ ---
125
+
126
+ ## Key ideas
127
+
128
+ ### Everything is keyed
129
+
130
+ State refers to items by **key**, never by position: `item.key` for trees,
131
+ `data-key` (or `data-value`) for element lists, the row key in data-grid, the
132
+ column `key` or `id`, the group key, the panel id. That's what lets saved state
133
+ survive sorting, filtering, inserting, and rebuilding the data from scratch.
134
+
135
+ **Your items need stable keys.** An item without a key can't be restored. In
136
+ data-grid, rows without a `key` or `id` fall back to their row index, and those
137
+ index keys are deliberately never saved. Give rows real keys, or a stable
138
+ `getRowKey`.
139
+
140
+ ### Pending state
141
+
142
+ When saved state names a key that isn't in the current data, the component
143
+ **keeps it pending** and applies it as soon as an item with that key appears.
144
+
145
+ This means you don't have to wait until all your data has loaded before
146
+ restoring:
147
+
148
+ ```ts
149
+ tree.setViewState({ expanded: { A: true, B: true, C: true } });
150
+
151
+ tree.root = rootWith(A); // A opens
152
+ // … later
153
+ addChild(A, B); tree.invalidateStructure(); // B opens
154
+ // … later
155
+ addChild(B, C); tree.invalidateStructure(); // C opens
156
+ ```
157
+
158
+ It also works the other way. When your data changes and an item disappears,
159
+ its state isn't thrown away; it goes pending. If the item comes back, so does
160
+ its state:
161
+
162
+ ```ts
163
+ list.setItems(page1); // user selects 'x'
164
+ list.clearItems(); // 'x' is gone, so its selection is pending
165
+ list.setItems(page1); // 'x' is selected again
166
+ ```
167
+
168
+ `getViewState()` includes pending state by default. If you save mid-reload,
169
+ you get the full saved state, not just the part that has loaded so far.
170
+
171
+ Pending state is dropped when:
172
+
173
+ | State | Pending entries are dropped when… |
174
+ |---|---|
175
+ | selection | the selection changes in any other way: a click, a key, `select…()`, `clearSelection()`. An old saved selection never merges into a new one. |
176
+ | expanded / collapsed | never on their own. Each key is tracked separately, so collapsing `A` doesn't forget that `C`, below it, was open. |
177
+ | scroll | the user scrolls (wheel, touch, drag, arrow or page keys), or the saved position has been reached. |
178
+ | keyboard cursor | the cursor moves. |
179
+ | anything | you call `setViewState()` with that field, or `clearPendingViewState()`. |
180
+
181
+ Each kind of pending state is capped at 2,000 keys, and the oldest are dropped
182
+ first.
183
+
184
+ ### Selection events
185
+
186
+ When pending selection is applied because its item arrived, the component
187
+ fires its **normal selection event** (`select`, `list-select`,
188
+ `datagrid-select`, and so on), so your selection handlers run as usual. Where
189
+ the event has an `origin` field, it is `'programmatic'`.
190
+
191
+ When a selected item *leaves* the data, it is deselected **silently**, with no
192
+ event, and its key goes pending.
193
+
194
+ ### Scroll
195
+
196
+ Scroll is saved as the **key of the first visible item plus a pixel offset**,
197
+ along with the raw pixel position as a fallback:
198
+
199
+ ```json
200
+ { "top": 4820, "anchor": { "key": "row-311", "offset": 12 } }
201
+ ```
202
+
203
+ When restored, the component waits until that item exists and there's enough
204
+ content to scroll to, then scrolls there. If the user scrolls first, the saved
205
+ position is dropped, because the user's scroll wins.
206
+
207
+ ### Keyboard cursor
208
+
209
+ `focus` is only recorded when the cursor was moved with the keyboard. Mouse
210
+ hover isn't saved. Restoring `focus` puts the component in keyboard mode, so
211
+ the cursor is visible.
212
+
213
+ ### Lazy trees
214
+
215
+ `ixfx-tree-list` has a `restoreLoads` property, which defaults to `true`. If a
216
+ saved-open branch hasn't loaded its children, the tree loads them through
217
+ `loadChildren` / `model`, one branch at a time, so a saved deep path opens all
218
+ the way down. That can mean several requests in a row. Set
219
+ `tree.restoreLoads = false` if you'd rather only re-open branches as you load
220
+ them yourself.
221
+
222
+ ---
223
+
224
+ ## Component reference
225
+
226
+ | Component | `kind` | Fields |
227
+ |---|---|---|
228
+ | `ixfx-tree-list` | `tree-list` | `selected`, `expanded`, `focus`, `scroll` |
229
+ | `ixfx-vertical-list` | `vertical-list` | `selected`, `focus`, `scroll` |
230
+ | `ixfx-detail-list` | `detail-list` | `selected`, `expanded` (groups), `focus`, `scroll` |
231
+ | `ixfx-grid-list` | `grid-list` | `selected`, `expanded` (groups), `focus`, `scroll` |
232
+ | `ixfx-data-grid` | `data-grid` | `selected`, `expanded` (groups), `focus`, `scroll`, `columns`, `sort`, `groupBy` |
233
+ | `ixfx-multicol-list` | `multicol-list` | `columns`, `selectedByColumn`, `scrollByColumn` |
234
+ | `ixfx-miller-list` | `miller-list` | `selected`, `focus`, `path`, `columnWidths`, `columnScroll` |
235
+ | `ixfx-crumb-navigation` | `crumb-navigation` | `path` |
236
+ | `ixfx-flat-list` | `flat-list` | `path`, `selected`, `focus`, `scroll` |
237
+ | `ixfx-sunbursttree-list` | `sunbursttree-list` | `selected`, `focus`, `zoom` |
238
+ | `ixfx-icicletree-list` | `icicletree-list` | `selected`, `focus`, `zoom`, `scroll` |
239
+ | `ixfx-treemap-list` | `treemap-list` | `selected`, `focus`, `zoom` |
240
+ | `ixfx-hyperbolictree-list` | `hyperbolictree-list` | `selected`, `focus`, `center`, `zoom` |
241
+ | `ixfx-split-layout` | `split-layout` | `layout` |
242
+ | `ixfx-twosplit-layout` | `twosplit-layout` | `layout` |
243
+ | `ixfx-dock-host` | `dock-host` | `layout` |
244
+
245
+ Each component's README has a "View state" section with details.
246
+
247
+ ### Shared fields
248
+
249
+ | Field | Type | Notes |
250
+ |---|---|---|
251
+ | `v` | `1` | Schema version. |
252
+ | `kind` | `string` | Which component the state is for. |
253
+ | `selected` | `string[]` | Selected keys. In `checked` interaction mode this is the checked set. |
254
+ | `expanded` | `Record<string, boolean>` | Only explicit choices are recorded, and a missing key means "default". Tree branches default to closed, so open ones are `true`. Groups default to open, so collapsed ones are `false`. |
255
+ | `focus` | `string` | Keyboard cursor key. |
256
+ | `scroll` | `{ top, left?, anchor?: { key, offset } }` | See [Scroll](#scroll). |
257
+
258
+ ### `ixfx-data-grid` extras
259
+
260
+ ```ts
261
+ {
262
+ columns: { [columnKey]: { width?: number; visible?: boolean; order?: number } },
263
+ sort: { key: string; direction: 'asc' | 'desc' } | null,
264
+ groupBy: string | null, // only when allowGrouping is on; a function groupBy is never saved
265
+ }
266
+ ```
267
+
268
+ - A saved width or visibility is applied when the column first appears. If you
269
+ redefine that column afterwards, your definition wins.
270
+ - A column you remove keeps its saved width and visibility pending, ready for
271
+ when it comes back.
272
+ - A saved `order` places the columns it names; other columns stay where they are.
273
+
274
+ ### `ixfx-multicol-list` extras
275
+
276
+ ```ts
277
+ {
278
+ columns: MulticolColumnState[], // display order: { id, categoryKey, sort, grouped, menu?, width? }
279
+ selectedByColumn: { [columnId]: string[] }, // missing or [] means 'All'
280
+ scrollByColumn: { [columnId]: ScrollState },
281
+ }
282
+ ```
283
+
284
+ A saved column whose category isn't in `categories` yet is inserted at its
285
+ saved position once that category appears.
286
+
287
+ ### `ixfx-miller-list` extras
288
+
289
+ ```ts
290
+ {
291
+ path: string[], // key of the open branch at each depth, column 0 first
292
+ columnWidths: { [depth: string]: number }, // px
293
+ columnScroll: { [parentKey: string]: ScrollState }, // only scrolled columns; column 0 uses the view root's key
294
+ }
295
+ ```
296
+
297
+ - `path` is relative to the current view root, which isn't saved. A saved path
298
+ only matches when restored under the same view root.
299
+ - `restoreLoads` (default `true`) opens a saved path through unloaded branches
300
+ one load at a time.
301
+ - Any explicit navigation (click, arrow keys, Escape, `navigateTo`,
302
+ `revealKey`, rebasing) drops a pending path.
303
+
304
+ ### `ixfx-crumb-navigation` extras
305
+
306
+ ```ts
307
+ {
308
+ path: string[], // keys from the root down to the current location
309
+ }
310
+ ```
311
+
312
+ - The location *is* the selection, so there's no separate `selected`.
313
+ - With a pending path, the crumb moves to the deepest loaded ancestor and keeps
314
+ going as data arrives, firing `select` at each step. With `restoreLoads` it
315
+ calls `loadChildren` itself.
316
+ - Setting `selectedNode` counts as an explicit move and drops a pending path.
317
+ **Set the initial `selectedNode` before calling `setViewState` /
318
+ `bindViewState`**, or it will override what you restore.
319
+
320
+ ### `ixfx-flat-list` extras
321
+
322
+ ```ts
323
+ {
324
+ path: string[], // the folder being shown
325
+ // selected / focus / scroll apply to the level at `path`
326
+ }
327
+ ```
328
+
329
+ A saved path through lazy levels loads one level at a time, because showing a
330
+ level loads it. If new data removes the level being shown, the list steps up
331
+ and that level, with its selection, goes pending.
332
+
333
+ ### `ixfx-sunbursttree-list` and `ixfx-icicletree-list` extras
334
+
335
+ ```ts
336
+ {
337
+ zoom: string[], // key path from the root's children to the zoomed-in node; [] = whole tree
338
+ }
339
+ ```
340
+
341
+ - `zoom` is a path so a lazy tree can be loaded down to it. `restoreLoads`
342
+ (default `true`) loads the path one level at a time.
343
+ - A restored zoom fires `zoom` and `expand`, just like a user's zoom.
344
+ - Any explicit zoom (click, `zoomTo`, `navigateTo`, Escape, crumbs) drops a
345
+ pending one.
346
+ - The icicle also saves `scroll`, as plain `top` / `left` pixels with no anchor.
347
+ - The sunburst only needs keys to be unique among siblings, so a restored
348
+ `selected` key that repeats in several folders matches the first one,
349
+ depth-first.
350
+
351
+ ### `ixfx-treemap-list` extras
352
+
353
+ ```ts
354
+ {
355
+ zoom: string[], // key path from the root's children to the zoomed-in node; [] = whole tree
356
+ }
357
+ ```
358
+
359
+ There's no `expanded`, because zooming takes its place, and no `scroll`,
360
+ because the treemap fills its box. Like the tree, it has `restoreLoads`
361
+ (default `true`): a deep saved zoom loads each ancestor in turn. A restored zoom
362
+ fires `zoom`, not `expand`, and doesn't animate.
363
+
364
+ ### `ixfx-hyperbolictree-list` extras
365
+
366
+ ```ts
367
+ {
368
+ center: string[], // key path from the root's children to the centred node; [] = root
369
+ zoom: number, // magnification
370
+ }
371
+ ```
372
+
373
+ - `center` is a **path**, not a single key, because keys may repeat across
374
+ branches in this component. Saving the path keeps the right node.
375
+ - Free panning and disk rotation **aren't saved**. They're raw geometry that
376
+ stops making sense when the data changes, so a restored view sits exactly on
377
+ `center`.
378
+ - `restoreLoads` (default `true`) loads the path one level at a time.
379
+ - A restored centre fires `focus`, not `expand`, and snaps instead of animating.
380
+
381
+ ### `ixfx-split-layout` extras
382
+
383
+ ```ts
384
+ {
385
+ layout: { [childId]: { weight: number; collapsed?: boolean } },
386
+ }
387
+ ```
388
+
389
+ - `weight` is the child's share (0 to 1) of the resizable space. For a
390
+ collapsed child it's the share it expands back to.
391
+ - **Only direct children with an `id` you set are saved.** Generated ids
392
+ (`p-1`, `g-2`) depend on position, so they'd apply sizes to the wrong child.
393
+ Give every child you want remembered an `id`.
394
+ - A nested `ixfx-split-layout` is saved as one entry. Its own children are in
395
+ its own view state, so bind it separately.
396
+ - Sizes for a child that isn't slotted yet are applied when a child with that
397
+ id appears. A child that's removed keeps its size pending, so re-adding it
398
+ restores the size.
399
+ - Set the `forget-absent` attribute to turn pending off, so children that leave
400
+ are forgotten.
401
+
402
+ ### `ixfx-twosplit-layout` extras
403
+
404
+ ```ts
405
+ {
406
+ layout: { a?: { size: string; collapsed?: boolean }, b?: { size: string; collapsed?: boolean } },
407
+ }
408
+ ```
409
+
410
+ This view-state `layout` field has nothing to do with the `layout`
411
+ (orientation) attribute.
412
+
413
+ ### `ixfx-dock-host` extras
414
+
415
+ ```ts
416
+ {
417
+ layout: DockLayout, // the whole tree: splits, stacks, sizes, active tabs, collapse
418
+ }
419
+ ```
420
+
421
+ - A panel id that's saved in the layout but hasn't been added yet keeps its
422
+ place. It shows as a placeholder tab until the panel arrives, and
423
+ `addPanel()` without a target then puts it there.
424
+ - Closing or removing a panel takes it out of the layout. Its place isn't
425
+ remembered.
426
+ - `clearPendingViewState()` does nothing on the dock, because placeholders are
427
+ part of the layout. Use `removePanel(id)` to drop one.
428
+ - `setViewState()` before the dock is connected wins over a `layout` attribute.
429
+
430
+ ---
431
+
432
+ ## Porting from the old APIs
433
+
434
+ ### Removed methods
435
+
436
+ | Component | Old | New |
437
+ |---|---|---|
438
+ | `ixfx-multicol-list` | `el.columns` (getter) | `el.getViewState().columns` |
439
+ | `ixfx-multicol-list` | `el.setColumns(states)` | `el.setViewState({ columns: states })` |
440
+ | `ixfx-miller-list` | `el.getConfig()` / `el.setConfig({ columnWidths })` | `el.getViewState().columnWidths` / `el.setViewState({ columnWidths })`. Same shape. The `MillerConfig` type is gone too. |
441
+ | `ixfx-split-layout` | `el.getLayout()` / `el.setLayout(snapshot)` | `el.getViewState().layout` / `el.setViewState({ layout })`. **The shape changed**; see below. |
442
+ | `ixfx-dock-host` | `el.saveLayout()` / `el.loadLayout(layout)` | `el.getViewState().layout` / `el.setViewState({ layout })`. Same `DockLayout` shape as before. |
443
+
444
+ `ixfx-tree-list`'s `getConfig()` / `setConfig()` is **not** removed. It only
445
+ holds display configuration (`indent`), not view state.
446
+
447
+ **Saved data from the old APIs is not read by the new ones.** If you have
448
+ stored layouts or configs you need to keep, convert them once on load:
449
+
450
+ ```ts
451
+ // multicol-list: an old saved `columns` array
452
+ const old = JSON.parse(localStorage.getItem(`multicol-columns`) ?? `null`);
453
+ if (Array.isArray(old))
454
+ el.setViewState({ columns: old }); // same shape as before
455
+ ```
456
+
457
+ ```ts
458
+ // miller-list: an old saved MillerConfig
459
+ const oldMiller = JSON.parse(localStorage.getItem(`miller-config`) ?? `null`);
460
+ if (oldMiller?.columnWidths)
461
+ miller.setViewState({ columnWidths: oldMiller.columnWidths });
462
+ ```
463
+
464
+ ```ts
465
+ // dock-host: an old saved DockLayout is already the right shape
466
+ const oldDock = JSON.parse(localStorage.getItem(`dock-layout`) ?? `null`);
467
+ if (oldDock)
468
+ dock.setViewState({ layout: oldDock });
469
+ ```
470
+
471
+ ```ts
472
+ // split-layout: an old LayoutSnapshot ({ root: { kind, id, weight, collapsed, children } })
473
+ // becomes a record of the layout's direct children
474
+ const snap = JSON.parse(localStorage.getItem(`split-layout`) ?? `null`);
475
+ if (snap?.root?.children) {
476
+ const kids = snap.root.children;
477
+ const total = kids.reduce((sum, c) => sum + (c.weight ?? 1), 0) || 1;
478
+ const layout = Object.fromEntries(kids.map(c =>
479
+ [c.id, { weight: (c.weight ?? 1) / total, ...(c.collapsed ? { collapsed: true } : {}) }]));
480
+ split.setViewState({ layout });
481
+ }
482
+ ```
483
+
484
+ Check the result for split-layout: the new state keeps only children that have
485
+ an `id` you gave them (see below), and weights are shares of the resizable space.
486
+
487
+ ### Replacing hand-rolled persistence
488
+
489
+ The common old pattern was to save on some event and restore with a setter:
490
+
491
+ ```ts
492
+ // Before
493
+ const saved = localStorage.getItem(`miller-config`);
494
+ if (saved) el.setConfig(JSON.parse(saved));
495
+ el.addEventListener(`pointerup`, () =>
496
+ localStorage.setItem(`miller-config`, JSON.stringify(el.getConfig())));
497
+ ```
498
+
499
+ ```ts
500
+ // After
501
+ bindViewState(el, {
502
+ load: () => JSON.parse(localStorage.getItem(`miller-state`) ?? `null`),
503
+ save: s => localStorage.setItem(`miller-state`, JSON.stringify(s)),
504
+ });
505
+ ```
506
+
507
+ The new version also saves selection, expansion, cursor and scroll, and it
508
+ saves after keyboard changes and on `pagehide`, which `pointerup` missed.
509
+
510
+ ### Re-selecting after a data refresh
511
+
512
+ You no longer need to remember the selection yourself across a data refresh:
513
+
514
+ ```ts
515
+ // Before
516
+ const keep = [...list.selectedKeys];
517
+ list.setItems(fresh);
518
+ list.selectKeys(keep);
519
+ ```
520
+
521
+ ```ts
522
+ // After
523
+ list.setItems(fresh); // selection for keys still present (or returning later) is kept
524
+ ```
525
+
526
+ The same applies to replacing `tree.root`. Selection, expansion and cursor
527
+ carry over for every key the new root still has.
528
+
529
+ If you want the *old* behaviour, where a refresh clears everything, ask for it
530
+ explicitly:
531
+
532
+ ```ts
533
+ list.setItems(fresh);
534
+ list.clearPendingViewState();
535
+ list.clearSelection();
536
+ ```
537
+
538
+ ### Behaviour changes to check
539
+
540
+ These used to work differently. If your code relied on the old behaviour,
541
+ adjust it.
542
+
543
+ | Component | Before | Now |
544
+ |---|---|---|
545
+ | `ixfx-tree-list` | Assigning a new `root` cleared the selection and keyboard cursor. | Selection and cursor are kept for every key the new root still contains. Others go pending. |
546
+ | `ixfx-tree-list` | Expansion for a key that disappeared and came back was forgotten. | It comes back open. |
547
+ | `ixfx-tree-list` | Selection held references to the old root's nodes after `root` was replaced. | Selection is re-matched by key onto the new root's nodes. |
548
+ | `ixfx-vertical-list` | `removeItem()` on a selected item fired `list-select`. | It deselects silently, and the key goes pending. |
549
+ | `ixfx-vertical-list`, `ixfx-detail-list` | A selected element removed straight from the DOM stayed in the selection as a stale reference. | It's dropped from the selection, and its key goes pending. |
550
+ | `ixfx-detail-list`, `ixfx-vertical-list` | `clearItems()` followed by `setItems()` lost the selection. | Matching keys are re-selected. |
551
+ | `ixfx-grid-list` | Items leaving `items` / `removeItems()` fired one `list-select`. | They're deselected silently, and their keys go pending. `clearItems()` still fires its empty `list-select`. |
552
+ | `ixfx-grid-list` | | A saved selection for an item that's filtered out or inside a collapsed group stays pending until the item is shown. |
553
+ | `ixfx-data-grid` | Keys of rows removed from `rows` stayed in `selectedKeys`. | They're deselected silently and go pending, and they're re-selected (with `datagrid-select`) if the rows return. |
554
+ | `ixfx-data-grid`, `ixfx-tree-list`, lists | A cursor on a removed item was left dangling. | The cursor is cleared. If it was a keyboard cursor, it comes back when the item does. |
555
+ | `ixfx-miller-list` | Assigning a new `root` cleared the selection and the open columns. | The open path, selection and cursor carry over by key; missing keys go pending. |
556
+ | `ixfx-miller-list` | `invalidateStructure()` left removed nodes selected or open. | Removed parts close or deselect, and come back if their keys reappear. |
557
+ | `ixfx-crumb-navigation` | A new `root` without the current location left it pointing at a missing node. | It steps back to the closest ancestor silently, and returns if the key reappears. |
558
+ | `ixfx-flat-list` | A `path` set before its data loaded was cut short for good. | It waits, and extends as the data arrives. |
559
+ | `ixfx-icicletree-list` | Assigning a new `root` reset selection, zoom and cursor. | They're kept by key; missing ones go pending. |
560
+ | `ixfx-sunbursttree-list`, `ixfx-icicletree-list` | A zoom node that disappeared fell back to the whole tree for good. | It still falls back, but the zoom comes back if the node does. |
561
+ | `ixfx-treemap-list` | Assigning a new `root` cleared selection, zoom and cursor. | They're kept by key; missing ones go pending. |
562
+ | `ixfx-hyperbolictree-list` | Assigning a new `root` / `model` reset selection, cursor and centre. | They're kept by key. If the centred node survives, so do pan and rotation; if not, the view returns to the root and the centre goes pending. |
563
+ | `ixfx-split-layout` | Adding, removing or collapsing a child re-read every size from the DOM attributes, which reset sizes the user had dragged. | Sizes the user dragged are kept. The DOM only wins when a `size` / `weight` / `sizes` / `fixed` / `collapsed` attribute actually changes. |
564
+ | `ixfx-split-layout` | (README described `storage-key` persistence.) | That was never implemented and has been removed from the README. Use `bindViewState`. |
565
+ | `ixfx-dock-host` | `addPanel(el)` with no target always added to the first stack. | If the panel's id has a saved place, it goes there. |
566
+ | `ixfx-multicol-list` | `setColumns()` dropped columns whose category didn't exist. | They stay pending and appear when the category does. |
567
+
568
+ "Deselected silently" means your selection handler won't run when an item
569
+ leaves the data. If you mirror the selection elsewhere, read `selectedKeys`
570
+ after changing the data, or listen for `view-state-change`.
571
+
572
+ ---
573
+
574
+ ## Recipes
575
+
576
+ ### Several components, one storage entry
577
+
578
+ ```ts
579
+ const KEY = `workspace`;
580
+ const saved = JSON.parse(localStorage.getItem(KEY) ?? `{}`);
581
+ const parts = { tree, grid, layout };
582
+
583
+ for (const [name, el] of Object.entries(parts)) {
584
+ bindViewState(el, {
585
+ load: () => saved[name],
586
+ save: (s) => {
587
+ saved[name] = s;
588
+ localStorage.setItem(KEY, JSON.stringify(saved));
589
+ },
590
+ });
591
+ }
592
+ ```
593
+
594
+ ### State per dataset
595
+
596
+ Keys are only meaningful within one dataset, so include the dataset in your
597
+ storage key:
598
+
599
+ ```ts
600
+ bindViewState(grid, {
601
+ load: () => JSON.parse(localStorage.getItem(`grid:${projectId}`) ?? `null`),
602
+ save: s => localStorage.setItem(`grid:${projectId}`, JSON.stringify(s)),
603
+ });
604
+ ```
605
+
606
+ ### Saving only some of it
607
+
608
+ ```ts
609
+ bindViewState(grid, {
610
+ load: () => JSON.parse(localStorage.getItem(`grid-columns`) ?? `null`),
611
+ save: () => localStorage.setItem(`grid-columns`,
612
+ JSON.stringify(grid.getViewState({ include: [`columns`, `sort`] }))),
613
+ });
614
+ ```
615
+
616
+ ### State in the URL
617
+
618
+ ```ts
619
+ const fromUrl = new URLSearchParams(location.search).get(`sel`);
620
+ if (fromUrl)
621
+ list.setViewState({ selected: fromUrl.split(`,`) });
622
+
623
+ list.addEventListener(`view-state-change`, (e) => {
624
+ const url = new URL(location.href);
625
+ url.searchParams.set(`sel`, (e.detail.state.selected ?? []).join(`,`));
626
+ history.replaceState(null, ``, url);
627
+ });
628
+ ```
629
+
630
+ ### Reset to defaults
631
+
632
+ ```ts
633
+ el.clearPendingViewState();
634
+ el.setViewState({ selected: [], expanded: {} });
635
+ localStorage.removeItem(`files-tree`);
636
+ ```
637
+
638
+ ---
639
+
640
+ ## Caveats
641
+
642
+ - **Stable keys are required.** Items without a key (or with index-based keys)
643
+ can't have their state restored.
644
+ - **No `origin` on some events.** The treemap's and hyperbolic tree's `select`
645
+ events have no `origin` field, so a restored selection can't be told apart
646
+ from a user's there. Use `view-state-change`'s `reason` instead.
647
+ - **Key collisions.** Tree keys must be unique across the whole tree. If a node
648
+ moves to a different parent with the same key, its open/selected state moves
649
+ with it.
650
+ - **Lazy restore makes requests.** With `restoreLoads`, restoring a deep tree
651
+ makes one `loadChildren` call per saved-open branch, one after another.
652
+ - **Hidden items.** A cursor or scroll anchor on an item that's filtered out or
653
+ inside a collapsed group stays pending until the item is visible again. Some
654
+ components only retry when items are added, not when a filter is lifted.
655
+ - **Uniform row heights.** The tree's scroll anchor assumes rows are all the
656
+ same height, as its virtualisation already does.
@@ -73,7 +73,12 @@ import '@ixfx/components/data-grid';
73
73
  - [xy-pad](docs-user/xy-pad.md): A 2D touch-friendly XY pad for controlling two parameters simultaneously. Each axis supports independent linear/logarithmic scaling.
74
74
 
75
75
  ## Overviews
76
+ - [infra-handle.md](docs-user/infra-handle.md)
77
+ - [infra-incremental.md](docs-user/infra-incremental.md)
78
+ - [infra-theming.md](docs-user/infra-theming.md)
76
79
  - [infra-tickled.md](docs-user/infra-tickled.md)
80
+ - [infra-view-state.md](docs-user/infra-view-state.md)
81
+ - [list-state.md](docs-user/list-state.md)
77
82
  - [user-catalog.md](docs-user/user-catalog.md)
78
83
  - [user-selection-intents.md](docs-user/user-selection-intents.md)
79
84
  - [user-theming.md](docs-user/user-theming.md)
@@ -151,7 +151,7 @@ The `change` event is fired on the component after every checkbox toggle.
151
151
 
152
152
  ## Selection intents and partial rows
153
153
 
154
- `ixfx-miller-list` fires a cancelable `list-select-intent` before a click (all interaction modes, including `manual`), a checkbox toggle or a Shift+Arrow range changes the selection; `preventDefault()` means "I'll apply it". Navigation (opening the node's column) still happens. It also fires `list-select` `{ selectedKeys, previousKeys, previous }` next to `select`, and supports `partialKeys` (indeterminate checkbox in `checked` mode, `.partial` otherwise; selected wins; in `checked` mode it overrides the state derived from loaded descendants). In `checked` mode the checkbox intent is for the node only: the cascade to loaded descendants is the caller's call. See [Selection intents, partial rows, unloaded keys](./user-selection-intents.md).
154
+ `ixfx-miller-list` fires a cancelable `list-select-intent` before a click (all interaction modes, including `manual`), a checkbox toggle or a Shift+Arrow range changes the selection; `preventDefault()` means "I'll apply it". Navigation (opening the node's column) still happens. It also fires `list-select` `{ selectedKeys, previousKeys, previous }` next to `select`, and supports `partialKeys` (indeterminate checkbox in `checked` mode, `.partial` otherwise; selected wins; in `checked` mode a node is indeterminate if either the loaded descendants or `partialKeys` say so, as in the tree). Ticking a branch in `checked` mode is one `toggle` intent with `descendants: true` and `checked` (the state it is being set to), exactly as in `ixfx-tree-list`: `keys` lists the node and every loaded descendant, and the caller can express it as a rule ("this folder and everything under it"). See [Selection intents, partial rows, unloaded keys](./user-selection-intents.md).
155
155
 
156
156
  ## Selection API
157
157
 
@@ -351,7 +351,7 @@ el.setViewState({ columnWidths: { 0: 150, 1: 200, 2: 180 } });
351
351
  `getViewState()` / `setViewState()` snapshot and restore how the columns are
352
352
  showing the data, as plain JSON keyed by `item.key`. The component never
353
353
  stores it; `bindViewState()` wires it to storage. See
354
- [docs/infra-view-state.md](../../docs/infra-view-state.md).
354
+ [docs/infra-view-state.md](./infra-view-state.md).
355
355
 
356
356
  ```javascript
357
357
  import { bindViewState } from '@ixfx/components';