@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
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,11 @@ 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 // drops placeholders (panels the host doesn't hold)
232
+ saveLayout(): DockLayout // deprecated alias of getViewState().layout
233
+ loadLayout(layout: DockLayout): void // deprecated alias of setViewState({ layout })
231
234
  addPanel(panel: DockPanelElement | DockPanelInit, target?: DockAddPanelTarget): boolean
232
235
  type DockAddPanelTarget = {
233
236
  container?: string; // named stack leaf — takes precedence over `stack`
@@ -258,6 +261,7 @@ getNeighbours(leaf: DockLeafRef): DockNeighbours | null
258
261
  moveLeaf(leaf: DockLeafRef, direction: 'up' | 'down' | 'left' | 'right'): boolean
259
262
  moveLeafToEdge(leaf: DockLeafRef, edge: 'top' | 'bottom' | 'left' | 'right'): boolean
260
263
  canAccept?: (panelId: string, op: DockOperation) => boolean
264
+ placeholderLabel?: (panelId: string) => string // tab label for placeholders (default: the id)
261
265
 
262
266
  // Cross-window hooks (serializable in/out; no element handoff)
263
267
  getPanelSnapshot(panelId: string): DockPanelSnapshot | null
@@ -427,13 +431,60 @@ it can measure.
427
431
  window. `acceptExternalPanel` returns `null` if that id is already docked in
428
432
  the target host (adopt / focus it yourself instead).
429
433
 
430
- ## Serialization
434
+ ## View state
431
435
 
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.
436
+ The dock implements the shared view-state API
437
+ ([docs/infra-view-state.md](../../docs/infra-view-state.md)). The component
438
+ never persists anything; the host does, e.g. with `bindViewState()`:
439
+
440
+ ```ts
441
+ import { bindViewState } from '@ixfx/components';
442
+
443
+ bindViewState(dock, {
444
+ load: () => JSON.parse(localStorage.getItem(`workspace`) ?? `null`),
445
+ save: s => localStorage.setItem(`workspace`, JSON.stringify(s)),
446
+ });
447
+ ```
448
+
449
+ | Field | Type | Meaning |
450
+ |---|---|---|
451
+ | `layout` | `DockLayout` | The whole tree: splits with `sizes`, stacks with tab order, `active`, `name`, `collapsed` / `preCollapseSize`, strip flags. Keyed by panel id. |
452
+
453
+ The dock layout is close to structure, so the saved state *is* the tree
454
+ (collapse and the active tab stay inside it rather than in separate
455
+ `expanded` / `selected` fields).
456
+
457
+ - `getViewState()` folds live gutter weights from each rendered
458
+ `ixfx-split-layout` (via `getSizes()`) into `DockSplitNode.sizes` first, so
459
+ a save → drag → save round-trip preserves user adjustments.
460
+ - **Panels not in the host yet.** `setViewState()` keeps every panel id the
461
+ layout names, held or not. One the host doesn't hold is a *placeholder*:
462
+ it keeps its place in the tree (shown as a tab labelled by the
463
+ `placeholderLabel(panelId)` property, or its id when unset) and
464
+ the panel slots into that place when it arrives — appended as a light-DOM
465
+ child, `addPanel()` **without** a target, `acceptExternalPanel()` without
466
+ `at`, or a cross-host adoption. An explicit `addPanel()` target still wins.
467
+ Placeholders are saved by `getViewState()`; `includePending: false` leaves
468
+ them out (stacks they empty are dropped unless named). Remove one with
469
+ `removePanel(id)`, or all of them with `clearPendingViewState()`.
470
+ - **Persisting with the default keeps stale ids.** A placeholder for a panel
471
+ that no longer exists is saved again on every `getViewState()`, so it never
472
+ goes away. Apps whose panels can disappear between save and restore should
473
+ call `clearPendingViewState()` once they have added the panels they hold,
474
+ or save with `includePending: false`.
475
+ - Pooled panels the layout doesn't name are appended to the first stack
476
+ admitting their kind.
477
+ - A panel that is closed / removed (`removePanel`, close button, removed from
478
+ the DOM, moved to another host) leaves the layout — closing is a structural
479
+ change, so its place is not remembered.
480
+ - State set before the host is connected wins over the `layout` attribute.
481
+ - A malformed `layout` (or a state for another `kind`) is ignored with a
482
+ warning.
483
+ - `view-state-change` fires (debounced) after structural changes (drops,
484
+ add / remove, close, leaf moves), gutter resizes, collapse / expand and
485
+ tab activation. `reason` is `data` when the pool changed under the host,
486
+ `restore` for `setViewState()`. The dock's internal split layouts' own
487
+ `view-state-change` events don't escape the host.
437
488
 
438
489
  ## CSS variables
439
490
 
@@ -51,6 +51,7 @@ A node counts as a branch unless `item.isLeaf` is `true` or its `children` is `[
51
51
  | `beginEdit(key)` | Rename a row in place. Needs `validateEdit` and `item.editable`; `false` otherwise |
52
52
  | `registry` / `dispatch(id, args)` | The inner list's command registry, e.g. `dispatch('list.search.filter')` |
53
53
  | `list` | The inner `ixfx-vertical-list` |
54
+ | `getViewState(options?)` / `setViewState(state)` / `clearPendingViewState()` | See [View state](#view-state) |
54
55
 
55
56
  ## Events
56
57
 
@@ -77,6 +78,32 @@ The inner list's `list-*` events also cross the shadow boundary, with row elemen
77
78
 
78
79
  Selection is cleared on every level change. Arrow Left and Backspace are ignored while the search field is focused.
79
80
 
81
+ ## View state
82
+
83
+ The level shown and its selection, cursor and scroll, as plain JSON for the
84
+ host to persist (see [docs/infra-view-state.md](../../docs/infra-view-state.md)):
85
+
86
+ ```ts
87
+ { v: 1, kind: 'flat-list', path: ['docs', 'work'], selected: ['a.txt'], focus: 'a.txt', scroll: { top: 0 } }
88
+ ```
89
+
90
+ `selected`, `focus` and `scroll` belong to the level at `path`. They are held
91
+ until that level is shown, then handed to the inner list, which applies them
92
+ as its rows arrive (see the vertical-list README).
93
+
94
+ - Keys not loaded yet stay pending: the list shows the deepest level it can
95
+ reach and continues as data arrives. Showing a level loads it (via
96
+ `loadChildren` / `model`), so a saved path through lazy levels loads one
97
+ level at a time.
98
+ - If new data drops the level shown, the list steps up to the closest
99
+ ancestor and the level (with its selection) goes pending; it is returned to
100
+ when its keys reappear. This also applies to a `path` assigned before the
101
+ data has loaded.
102
+ - An explicit navigation (gesture, `navigate*()`, or assigning `path`) drops
103
+ a pending level; an explicit selection drops a pending selection.
104
+ - The inner crumb's and list's own `view-state-change` events are stopped at
105
+ this element; it fires its own.
106
+
80
107
  ## Drops
81
108
 
82
109
  With `items-droppable`, a drag over a row, or over empty space, is accepted unless `canDrop` refuses it. A drop fires `flat-list-drop` with that row's node, or the **current level's node** for empty space. Dropping on a branch row reports it and does **not** enter it. Native OS drags work: `dataTransfer` carries `files`, `text/uri-list` and any custom MIME type the source wrote. The row under the drag gets the vertical-list [drop highlight](../vertical-list/README.md#drop-targets).
@@ -20,10 +20,11 @@ single vertical column, or a horizontally-scrolling strip.
20
20
  9. [Native drag-out](#native-drag-out)
21
21
  10. [Keyboard](#keyboard)
22
22
  11. [Grouping](#grouping)
23
- 12. [Virtualisation](#virtualisation)
24
- 13. [Performance](#performance)
25
- 14. [CSS parts](#css-parts)
26
- 15. [Choosing a list component](#choosing-a-list-component)
23
+ 12. [View state](#view-state)
24
+ 13. [Virtualisation](#virtualisation)
25
+ 14. [Performance](#performance)
26
+ 15. [CSS parts](#css-parts)
27
+ 16. [Choosing a list component](#choosing-a-list-component)
27
28
 
28
29
  ---
29
30
 
@@ -214,7 +215,7 @@ grid.tickledKey; // key under the cursor, or undefined
214
215
  | Property / attribute | Default | Description |
215
216
  |---|---|---|
216
217
  | `disableHoverTickle` / `disable-hover-tickle` | `false` | When set, pointer hover no longer moves the cursor, fires `list-tickle`, or changes where arrow-key navigation resumes from, and the hover highlight is dropped so nothing *looks* interactive. Keyboard navigation, selection, and `focusKey()` are unaffected. |
217
- | `disableScrollHoverTickle` / `disable-scroll-hover-tickle` | `false` | When set, a scroll gesture moving cells beneath a stationary pointer no longer moves the cursor: virtualised `pointerenter` events from scrolling are ignored, so `list-tickle` and the highlight hold still. Hover resumes on the next real pointer movement, or after the pointer leaves and re-enters the list. Click selection, keyboard navigation, and `focusKey()` are unaffected. |
218
+ | `scrollHoverTickle` / `scroll-hover-tickle` | `false` | Let hover follow cells that scroll beneath a stationary pointer. Off by default: during a scroll gesture, virtualised `pointerenter` events are ignored so `list-tickle` and the highlight hold still. Hover resumes on the next real pointer movement, or after the pointer leaves and re-enters the list. Click selection, keyboard navigation, and `focusKey()` are unaffected. |
218
219
 
219
220
  ```html
220
221
  <ixfx-grid-list disable-hover-tickle></ixfx-grid-list>
@@ -229,8 +230,8 @@ Because cells are virtualised, wheel / trackpad scrolling inserts new cells
229
230
  under a stationary pointer, and each fresh cell fires `pointerenter` — the
230
231
  cursor and any cursor-driven rendering (e.g. a large preview panel) churn for
231
232
  as long as the gesture lasts, even though the pointer never moved.
232
- `disable-scroll-hover-tickle` ignores those synthetic enters while the gesture
233
- is active. While suppressed, the host carries a `data-scroll-hover-suppress`
233
+ By default those synthetic enters are ignored while the gesture is active
234
+ (set `scroll-hover-tickle` to let hover follow the content instead). While suppressed, the host carries a `data-scroll-hover-suppress`
234
235
  attribute (the CSS `:hover` highlight is gated on it), which is removed on the
235
236
  next real pointer movement or when the pointer leaves the list.
236
237
 
@@ -310,8 +311,11 @@ grid.registry.invoke('list.search.filter'); // or .navigate / .select
310
311
 
311
312
  Search matches `data-label` (or `[data-grid-list-caption]` text, then
312
313
  `data-search-label`, then `textContent`). Filter mode genuinely removes items
313
- from the virtualised layout. Add a page-level highlight rule to see matched
314
- characters:
314
+ from the virtualised layout. Matched characters are highlighted under the
315
+ `grid-list-search` key. In data mode (`itemAdapter`) the captions live in the
316
+ shadow root and the grid styles them itself (`--search-highlight-bg` /
317
+ `--search-highlight-color`, default `--accent` / `--accent-text`). In element
318
+ mode the items are light DOM, so add a page-level rule:
315
319
 
316
320
  ```css
317
321
  ::highlight(grid-list-search) { background: var(--accent); color: var(--accent-text); }
@@ -402,11 +406,11 @@ Parts: `part="icon"` and `part="badge"` on the wrappers this adds.
402
406
 
403
407
  | Method | Effect |
404
408
  |---|---|
405
- | `setItems(items)` | Replace the collection. Selection / cursor keep any key still present. |
409
+ | `setItems(items)` | Replace the collection. Selection / cursor keep any key still present; vanished keys are deselected silently and held pending (see [View state](#view-state)). |
406
410
  | `appendItems(items)` | Add to the end. Scroll, selection and cursor untouched; no reflow animation. |
407
411
  | `updateItem(key, item)` | Replace one item. A visible cell re-renders in place; an offscreen one updates when scrolled in. |
408
- | `removeItems(keys)` | Remove items. Removed keys drop out of selection / cursor. |
409
- | `clearItems()` | Drop everything. Resets selection / cursor and emits one empty `list-select`. |
412
+ | `removeItems(keys)` | Remove items. Removed keys drop out of selection / cursor silently and are held pending. |
413
+ | `clearItems()` | Drop everything. Resets selection / cursor and emits one empty `list-select`; the keys are held pending, so re-adding them restores the selection. |
410
414
 
411
415
  Selection, cursor, keyboard nav, search, filtering, `count`, `renderedCount`,
412
416
  `renderedKeys()` and `visibleKeys()` behave identically to element mode — all
@@ -442,7 +446,7 @@ grid.collapseAllGroups();
442
446
 
443
447
  - Groups appear in first-encounter order. Filters (`filterPredicate`, `itemFilterPredicate`, incremental filter) run first, so an emptied group loses its header and counts follow the filter.
444
448
  - **Collapsed groups** leave the effective order: not navigable, not rendered, not in select-all, range, marquee or drag-out. Their selection state is kept. A collapsed key is not auto-expanded by `focusKey()`.
445
- - Changing `groupBy` (from any path) clears the collapse state.
449
+ - Changing `groupBy` (from any path) clears the collapse state. Saved collapse state from `setViewState()` that hasn't matched a group yet is kept.
446
450
  - **Layout:** in `grid` / `column` a header spans the full width and its items restart at column 0 beneath it. In `row` orientation the header is an inline cell of `group-header-width`.
447
451
  - **Keyboard:** Left/Right step through items, crossing groups. Up/Down keep the column, hop across headers and clamp on a short last row. Headers are never focused; collapsing is by click or the API.
448
452
  - Events: `list-group-toggle` `{ groupKey, collapsed }`, `list-group-change` `{ groupBy }`.
@@ -450,6 +454,58 @@ grid.collapseAllGroups();
450
454
 
451
455
  Differences from data-grid: group state is reset on every `groupBy` change, the empty key is labelled `(none)`, headers are skipped by arrow keys, and the collapse API and events are exposed.
452
456
 
457
+ ---
458
+
459
+ ## View state
460
+
461
+ Snapshot and restore how the grid is showing its data, separately from the
462
+ data. The contract is shared with the other list components; see
463
+ [`docs/infra-view-state.md`](../../docs/infra-view-state.md).
464
+
465
+ ```ts
466
+ import { bindViewState } from '@ixfx/components';
467
+
468
+ bindViewState(grid, {
469
+ load: () => JSON.parse(localStorage.getItem(`photos-grid`) ?? `null`),
470
+ save: s => localStorage.setItem(`photos-grid`, JSON.stringify(s)),
471
+ });
472
+ grid.items = await fetchPhotos(); // pending state applies as items arrive
473
+ ```
474
+
475
+ `kind`: `grid-list`. Type: `GridListViewState`.
476
+
477
+ | Field | Shape | Meaning |
478
+ |---|---|---|
479
+ | `selected` | `string[]` | Selected item keys. |
480
+ | `expanded` | `Record<string, boolean>` | Group key → open. Groups default open, so only collapsed groups (`false`) are recorded. |
481
+ | `focus` | `string` | Keyboard-cursor key. Recorded only while the cursor was last moved by keyboard. |
482
+ | `scroll` | `{ top, left?, anchor? }` | `anchor` is the first item of the first visible line plus the px offset into that line, along the main axis (`left` in `row` orientation, `top` otherwise). |
483
+
484
+ Members: `getViewState(options?)`, `setViewState(state)`,
485
+ `clearPendingViewState()`, and the debounced `view-state-change` event.
486
+
487
+ Grid-specific behaviour:
488
+
489
+ - Works the same in element mode (light-DOM children with `data-key`) and data
490
+ mode (`items` + `itemAdapter`). Keyless children have auto-assigned
491
+ positional keys, which never appear in view state.
492
+ - A key counts as present when its item is in the effective order: an item
493
+ that is filtered out, or inside a collapsed group, keeps its saved selection
494
+ pending until it shows. Restoring `expanded` and `selected` together re-opens
495
+ groups first, so a saved selection inside a saved-open group resolves.
496
+ - A collapsed group whose items all leave (data reset, filter) keeps its
497
+ collapsed state pending and is collapsed again when it comes back.
498
+ Restoring collapse state does not fire `list-group-toggle`.
499
+ - Items that leave the data are deselected **without** a `list-select`; their
500
+ keys go pending and are re-selected (with a `list-select`) if they return.
501
+ - A restored cursor puts the list in keyboard input mode, so it is shown and
502
+ recorded like a keyboard-moved cursor.
503
+ - Scroll restore waits until the anchor item is present and the viewport is
504
+ measured, then until the content is long enough. It is cancelled by the
505
+ user's own wheel, touch, pointer or navigation key. The anchor is re-laid out
506
+ with the current geometry, so a different column count still lands on the
507
+ same item's line.
508
+
453
509
  ## Virtualisation
454
510
 
455
511
  Only the cells in (or near) the viewport are in the DOM — in both element and
@@ -17,10 +17,11 @@ It implements the shared `TreeComponent` interface (see [`src/tree/README.md`](.
17
17
  7. [Targeting a specific node or edge](#targeting-a-specific-node-or-edge)
18
18
  8. [Custom edge rendering](#custom-edge-rendering)
19
19
  9. [Focus, selection, navigation](#focus-selection-navigation)
20
- 10. [Events](#events)
21
- 11. [Keyboard / pointer interaction](#keyboard--pointer-interaction)
22
- 12. [CSS variables and parts](#css-variables-and-parts)
23
- 13. [Types reference](#types-reference)
20
+ 10. [View state](#view-state)
21
+ 11. [Events](#events)
22
+ 12. [Keyboard / pointer interaction](#keyboard--pointer-interaction)
23
+ 13. [CSS variables and parts](#css-variables-and-parts)
24
+ 14. [Types reference](#types-reference)
24
25
 
25
26
  ---
26
27
 
@@ -56,6 +57,7 @@ el.root = {
56
57
  | `root` | `TreeNode \| undefined` | `undefined` | Root node of the tree |
57
58
  | `model` | `TreeDataModel \| undefined` | `undefined` | Shared data model; takes precedence over `loadChildren` |
58
59
  | `loadChildren` | `LoadChildrenCallback \| undefined` | `undefined` | Callback for lazy child loading |
60
+ | `restoreLoads` | `boolean` | `true` | Whether restoring a saved `center` may load its unloaded ancestors. See [View state](#view-state) |
59
61
  | `interactionMode` (`interaction-mode`) | `'implicit' \| 'standard' \| 'manual'` | `'standard'` | Gesture mapping — see [`src/tree/README.md`](../tree/README.md#interactionmode-ixfx-tree-list--ixfx-miller-list) (`'checked'` is unsupported here) |
60
62
  | `selectionFilter` (`selection-filter`) | `'none' \| 'leaf' \| 'branch' \| 'all'` | `'all'` | Which node kinds can be selected |
61
63
  | `exclusivity` | `TreeExclusivity` | `'none'` | Accepted for interface compatibility, ignored |
@@ -252,6 +254,31 @@ el.navigateToNode(n => n.item.key === 'readme.md');
252
254
  const nodes = el.getNodes(); // ReadonlyArray<{ node: TreeNode; depth: number }>
253
255
  ```
254
256
 
257
+ ## View state
258
+
259
+ Implements the shared view-state API (`getViewState()` / `setViewState()` / `clearPendingViewState()` / `view-state-change`) — see [`docs/infra-view-state.md`](../../docs/infra-view-state.md). `kind` is `hyperbolictree-list`.
260
+
261
+ ```typescript
262
+ type HyperbolicTreeListViewState = ViewStateBase & {
263
+ center?: string[]; // key path from the root's children to the centred node; [] = root
264
+ zoom?: number; // visual magnification (the `zoom` property)
265
+ };
266
+ ```
267
+
268
+ | Field | Meaning |
269
+ |---|---|
270
+ | `selected` | Selected item keys. |
271
+ | `focus` | Keyboard cursor key — only recorded when the cursor was moved by keyboard or search navigation. |
272
+ | `center` | The centred node, as a key path. A path rather than a lone key because keys may repeat across branches here (e.g. `readme.md` in many folders). |
273
+ | `zoom` | Magnification. Not keyed: it doesn't depend on the data. |
274
+
275
+ - **What isn't saved:** a free pan (dragging empty space) and the disk's rotation are raw transforms in the centred node's local frame — they break as soon as the data changes, so they are left out. A restored view sits exactly on `center`, unpanned.
276
+ - **Data changes keep state.** A new `root`, a model `root` / `invalidate` event or a lazy load re-applies state by key. If the centred node goes away the view falls back to the root silently and `center` stays pending; selected nodes that go away are deselected silently and their keys stay pending.
277
+ - **Lazy data.** With `restoreLoads` (default `true`), a saved `center` whose ancestors aren't loaded is reached by loading each one in turn via `loadChildren` / `model`.
278
+ - **Events.** A restored selection fires `select`; a restored centre fires `focus` (no `expand`/`collapse`) and snaps rather than animating.
279
+ - An explicit `focusOn()` (click, key, navigation) drops a pending `center`; any explicit selection change drops pending `selected`.
280
+ - With repeated keys, a saved selection resolves to the first node (depth-first) with that key.
281
+
255
282
  ## Events
256
283
 
257
284
  All events **bubble** and are **composed**.
@@ -0,0 +1,36 @@
1
+ # Icicle Tree
2
+
3
+ `<ixfx-icicletree-list>` — an icicle (partition) view over tree data. Each depth is a band; each node a rectangle sized by its `valueAccessor` share of its parent. Clicking a branch zooms so it becomes the visual root, with its ancestors in a breadcrumb strip.
4
+
5
+ It implements the shared `TreeComponent` interface (see [`src/tree/README.md`](../tree/README.md)). The [sunburst](../sunburst-tree/) is its polar counterpart.
6
+
7
+ ---
8
+
9
+ ## View state
10
+
11
+ Implements the shared view-state API (`getViewState()` / `setViewState()` / `clearPendingViewState()` / `view-state-change`) — see [`docs/infra-view-state.md`](../../docs/infra-view-state.md). `kind` is `icicletree-list`.
12
+
13
+ | Field | Type | Meaning |
14
+ |---|---|---|
15
+ | `selected` | `string[]` | Selected `item.key`s |
16
+ | `zoom` | `string[]` | Key path from the root's children to the zoom node; `[]` = full tree |
17
+ | `focus` | `string` | Keyboard-cursor `item.key` (recorded only when the cursor was moved by keyboard) |
18
+ | `scroll` | `{ top, left? }` | The host's scroll offset (pixels; rects aren't a linear list, so there is no anchor) |
19
+
20
+ There is no `expanded` (expansion *is* zoom).
21
+
22
+ ```typescript
23
+ const stop = bindViewState(el, {
24
+ load: () => JSON.parse(localStorage.getItem(`org`) ?? `null`),
25
+ save: s => localStorage.setItem(`org`, JSON.stringify(s)),
26
+ });
27
+ el.root = await fetchOrg(); // zoom + selection apply as their nodes arrive
28
+ ```
29
+
30
+ - State for nodes not loaded yet stays pending and applies when they appear — whether the tree is reassigned, grown in place (then `requestUpdate()`), or lazily loaded. **Reassigning `root` keeps zoom, selection and cursor** where keys still exist; state for keys that have gone moves to pending (silently) and returns with them.
31
+ - `zoom` is a path so a lazy tree can be opened down to it: while pending, the nodes along it are loaded one at a time via `loadChildren` / `model` (set `restoreLoads = false` to only apply it as the host loads them). A zoom node that moved elsewhere in the tree is still found by its key.
32
+ - Restored selection fires `select`; a restored zoom fires `zoom` (and `expand`). Any explicit selection change drops pending selection; any explicit zoom (`zoomTo()`, `zoomOut()`, clicks, crumbs, `navigateTo()`…) drops a pending zoom.
33
+
34
+ | Property | Type | Default | Description |
35
+ |---|---|---|---|
36
+ | `restoreLoads` | `boolean` | `true` | Load nodes along a pending saved zoom path |
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ixfx/components",
3
- "version": "0.15.5",
4
- "generated": "2026-10-23T07:10:01.881Z",
3
+ "version": "0.16.1",
4
+ "generated": "2026-10-26T15:36:19.925Z",
5
5
  "components": [
6
6
  {
7
7
  "name": "ac-text",
@@ -98,6 +98,11 @@
98
98
  "summary": "`<ixfx-hyperbolictree-list>` — a hyperbolic (Poincaré disk) browser over tree data. The focused node sits at the disk's center; its neighbourhood fans outward, shrinking toward the edge with tree-grap",
99
99
  "path": "docs-user/hyperbolic-tree.md"
100
100
  },
101
+ {
102
+ "name": "icicle-tree",
103
+ "summary": "`<ixfx-icicletree-list>` — an icicle (partition) view over tree data. Each depth is a band; each node a rectangle sized by its `valueAccessor` share of its parent. Clicking a branch zooms so it become",
104
+ "path": "docs-user/icicle-tree.md"
105
+ },
101
106
  {
102
107
  "name": "icons",
103
108
  "summary": "A centralized SVG icon system for `@ixfx/components`. Icons are stored by name, can be overridden globally, and all components that use a given icon re-render automatically when it changes.",
@@ -273,6 +278,11 @@
273
278
  "summary": "Three components share a common `TreeComponent` interface: **`ixfx-tree-list`**, **`ixfx-miller-list`**, and **`ixfx-crumb-navigation`**. This document describes the shared API, plus component-specifi",
274
279
  "path": "docs-user/tree.md"
275
280
  },
281
+ {
282
+ "name": "treemap",
283
+ "summary": "`<ixfx-treemap-list>` — a nested-area (treemap) view over tree data. The zoom root fills the viewport; each node's rectangle is subdivided among its children by `valueAccessor` share. Clicking a branc",
284
+ "path": "docs-user/treemap.md"
285
+ },
276
286
  {
277
287
  "name": "twosplit-layout",
278
288
  "summary": "A Lit web component that lays out two elements within a container with a split bar to adjust their proportions.",
@@ -30,6 +30,7 @@ import '@ixfx/components/data-grid';
30
30
  - [grid-list](docs-user/grid-list.md): A virtualised grid of items — thumbnails, cards, or arbitrary slotted markup —
31
31
  - [grouped-item-lister](docs-user/grouped-item-lister.md): A meta-component that renders a typed item list using any sub-component you supply. Items can be displayed in a single sub-component (_ungrouped_ mode) or split into labelled groups, each with its own
32
32
  - [hyperbolic-tree](docs-user/hyperbolic-tree.md): `<ixfx-hyperbolictree-list>` — a hyperbolic (Poincaré disk) browser over tree data. The focused node sits at the disk's center; its neighbourhood fans outward, shrinking toward the edge with tree-grap
33
+ - [icicle-tree](docs-user/icicle-tree.md): `<ixfx-icicletree-list>` — an icicle (partition) view over tree data. Each depth is a band; each node a rectangle sized by its `valueAccessor` share of its parent. Clicking a branch zooms so it become
33
34
  - [icons](docs-user/icons.md): A centralized SVG icon system for `@ixfx/components`. Icons are stored by name, can be overridden globally, and all components that use a given icon re-render automatically when it changes.
34
35
  - [incr-search](docs-user/incr-search.md): Utilities for incremental (type-ahead) search over data collections or DOM-backed lists.
35
36
  - [labelled-radial-input](docs-user/labelled-radial-input.md): A composite radial input that pairs an editable label (`ixfx-editable-number`) with a rotary dial (`ixfx-radial-input`). The two controls are always in sync — turning the dial updates the number, and
@@ -65,6 +66,7 @@ import '@ixfx/components/data-grid';
65
66
  - [toolbar](docs-user/toolbar.md): Two components for building toolbars:
66
67
  - [transitory-label](docs-user/transitory-label.md): An inline text label that animates between values instead of changing instantly. Displays as `inline-block` and inherits text styling from its parent.
67
68
  - [tree](docs-user/tree.md): Three components share a common `TreeComponent` interface: **`ixfx-tree-list`**, **`ixfx-miller-list`**, and **`ixfx-crumb-navigation`**. This document describes the shared API, plus component-specifi
69
+ - [treemap](docs-user/treemap.md): `<ixfx-treemap-list>` — a nested-area (treemap) view over tree data. The zoom root fills the viewport; each node's rectangle is subdivided among its children by `valueAccessor` share. Clicking a branc
68
70
  - [twosplit-layout](docs-user/twosplit-layout.md): A Lit web component that lays out two elements within a container with a split bar to adjust their proportions.
69
71
  - [util](docs-user/util.md): A tooltip component that renders a value with optional icon and formatted number display. Uses native render root (no shadow DOM).
70
72
  - [vertical-list](docs-user/vertical-list.md): A scrollable vertical list of items with selection, keyboard navigation, and incremental search. Items can be plain `<li>` elements declared in HTML or rich custom elements added programmatically. Lar
@@ -16,7 +16,8 @@ A Miller column component displays hierarchical data as multiple vertical column
16
16
 
17
17
  | Property | Type | Default | Description |
18
18
  |----------|------|---------|-------------|
19
- | `root` | `MillerNode \| undefined` | — | The root node of the tree. Assigning a new root resets all columns and selection state. |
19
+ | `root` | `MillerNode \| undefined` | — | The root node of the tree. Assigning a new root re-applies the open path, selection and cursor by key (see [View state](#view-state)); a rebased view is reset. |
20
+ | `restoreLoads` | `boolean` | `true` | Whether restoring view state may call `loadChildren` / `model` to open a saved path through unloaded branches. |
20
21
  | `selectionMode` | `MillerSelectionMode` | `'implicit'` | Controls how items are selected. See [Selection Modes](#selection-modes) below. |
21
22
  | `selectedKeys` | `ReadonlySet<string>` (getter) / `Iterable<string>` (setter) | — | The set of currently selected item keys. Assigning replaces the entire selection and triggers a re-render. |
22
23
  | `loadChildren` | `MillerLoadChildrenCallback \| undefined` | — | Callback to load children for lazy nodes. If undefined, clicking a lazy node opens an empty column. |
@@ -57,18 +58,18 @@ Inherited from theme:
57
58
  navigateTo(keys: readonly string[], options?: { reveal?: 'edge' | 'center-if-hidden'; silent?: boolean }): boolean
58
59
 
59
60
  // Re-sync after children arrays were patched in place (used by MillerTreeController.sync()):
60
- // closes columns whose selected node was removed, then re-renders.
61
+ // the open path and selection re-resolve by key; removed nodes close their
62
+ // columns / are deselected, and come back if their key reappears.
61
63
  invalidateStructure(): void
62
64
 
63
65
  // Navigate to the first loaded node (breadth-first) matching a predicate.
64
66
  // Returns false if no match is found in the currently loaded tree.
65
67
  navigateToNode(predicate: (node: MillerNode) => boolean): boolean
66
68
 
67
- // Get current configuration (column widths, etc.)
68
- getConfig(): MillerConfig
69
-
70
- // Set configuration. Requires a full MillerConfig object.
71
- setConfig(config: MillerConfig): void
69
+ // View state — see "View state" below
70
+ getViewState(options?): MillerListViewState
71
+ setViewState(state: Partial<MillerListViewState>): void
72
+ clearPendingViewState(): void
72
73
  ```
73
74
 
74
75
  ### Events
@@ -190,10 +191,6 @@ type MillerNode = {
190
191
  item: MillerItem;
191
192
  children?: MillerNode[]; // undefined = unloaded, [] = confirmed empty
192
193
  };
193
-
194
- type MillerConfig = {
195
- columnWidths: Record<number, number>; // depth -> px
196
- };
197
194
  ```
198
195
 
199
196
  ### Per-item icon colour, text colour and badges
@@ -338,24 +335,58 @@ el.navigateToNode(node => node.item.label === 'System32');
338
335
 
339
336
  ### Column Resizing
340
337
 
341
- Columns can be resized by dragging the handle on the right edge of each column. The widths are persisted in the config:
338
+ Columns can be resized by dragging the handle on the right edge of each
339
+ column. Widths are part of the view state (`columnWidths`, keyed by depth):
342
340
 
343
341
  ```javascript
344
- // Serialize current state (e.g. to localStorage)
345
- const config = el.getConfig();
346
- localStorage.setItem('millerConfig', JSON.stringify(config));
347
-
348
- // Restore saved state
349
- el.setConfig(JSON.parse(localStorage.getItem('millerConfig')));
342
+ el.setViewState({ columnWidths: { 0: 150, 1: 200, 2: 180 } });
350
343
  ```
351
344
 
352
- ### Configuration
345
+ ### View state
346
+
347
+ `getViewState()` / `setViewState()` snapshot and restore how the columns are
348
+ showing the data, as plain JSON keyed by `item.key`. The component never
349
+ stores it; `bindViewState()` wires it to storage. See
350
+ [docs/infra-view-state.md](../../docs/infra-view-state.md).
353
351
 
354
352
  ```javascript
355
- // Set custom column widths (depth index → width in px)
356
- el.setConfig({ columnWidths: { 0: 150, 1: 200, 2: 180 } });
353
+ import { bindViewState } from '@ixfx/components';
354
+
355
+ bindViewState(el, {
356
+ load: () => JSON.parse(localStorage.getItem('miller') ?? 'null'),
357
+ save: s => localStorage.setItem('miller', JSON.stringify(s)),
358
+ });
359
+ el.root = await fetchRoot(); // saved state applies as nodes arrive
357
360
  ```
358
361
 
362
+ `kind: 'miller-list'`. Fields:
363
+
364
+ | Field | Type | Meaning |
365
+ |---|---|---|
366
+ | `path` | `string[]` | Key of the open branch at each depth, column 0 first, relative to the view root. |
367
+ | `selected` | `string[]` | `selectedKeys` (the checked set in `checked` mode). |
368
+ | `focus` | `string` | Keyboard cursor key, only when last moved by keyboard. |
369
+ | `columnWidths` | `Record<string, number>` | User-resized widths in px, keyed by depth. |
370
+ | `columnScroll` | `Record<string, ScrollState>` | Scroll of each scrolled column, keyed by the key of the node whose children it lists (the view root's key for column 0). |
371
+
372
+ `path` is an ordered array rather than the shared `expanded` record because a
373
+ Miller view has exactly one open branch per column.
374
+
375
+ - State for keys not loaded yet stays **pending** and applies as nodes appear:
376
+ a new `root`, `invalidateStructure()` after children were patched in place,
377
+ or a lazy load. A saved path `a > b > c` opens as each node arrives.
378
+ - With `restoreLoads` (default `true`) a saved path through unloaded branches
379
+ is opened via `loadChildren` / `model`, one load at a time, so it cascades.
380
+ - New `root` data keeps the open path, selection and cursor for keys that
381
+ still exist; keys that left go pending (deselected silently) and come back
382
+ if they reappear. Restored selection fires `select` with
383
+ `origin: 'programmatic'`.
384
+ - Any explicit selection change drops pending selection; any explicit
385
+ navigation (click, arrow keys, `navigateTo`, rebasing) drops the pending
386
+ path. The view root itself is not part of the state.
387
+ - `view-state-change` fires (debounced) on selection, navigation, keyboard
388
+ cursor moves, column resize and column scroll.
389
+
359
390
  ---
360
391
 
361
392
  ## MillerTreeController