@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/dist/index.js CHANGED
@@ -8,7 +8,7 @@ import { a as iconBus, c as setIconFont, i as hasIcon, l as svgToDataUri, n as g
8
8
  import { t as IxfxIconElement } from "./icon-1gpwtPx8.js";
9
9
  import { AcTextElement } from "./ac-text.js";
10
10
  import { t as DataController } from "./data-controller-DOlmqNKb.js";
11
- import { a as LIST_SEARCH_COMMAND_IDS, c as createElementListSearchAdapter, d as createDataListSearchAdapter, f as IncrSearchTreeController, h as createFuzzySearch, i as ListSearchController, l as KeyedElementIndex, m as createAcCompletionProvider, n as listSearchStyles, o as registerListSearchCommands, p as createIncrSearch, r as LIST_SEARCH_MODES, s as createElementIncrSearch, t as createTreeListSearchAdapter, u as keyForElement } from "./incr-search-JIhwB49y.js";
11
+ import { a as LIST_SEARCH_COMMAND_IDS, c as createElementListSearchAdapter, d as createDataListSearchAdapter, f as IncrSearchTreeController, h as createFuzzySearch, i as ListSearchController, l as KeyedElementIndex, m as createAcCompletionProvider, n as listSearchStyles, o as registerListSearchCommands, p as createIncrSearch, r as LIST_SEARCH_MODES, s as createElementIncrSearch, t as createTreeListSearchAdapter, u as keyForElement } from "./incr-search-CgbL-OlS.js";
12
12
  import { AcTokenElement } from "./ac-token.js";
13
13
  import { t as handleStyles } from "./handle-DAXs7kbJ.js";
14
14
  import { TitlebarElement } from "./titlebar.js";
@@ -18,7 +18,9 @@ import { t as prominenceStyles } from "./prominence-DlFShic3.js";
18
18
  import { n as buttonStyles, t as ButtonElement } from "./button-ikByJJPN.js";
19
19
  import { a as toGroupKey, i as resolveGroupKey, n as defaultGroupLabel, o as toggleInSet, r as partitionByGroup, t as EMPTY_GROUP_LABEL } from "./grouping-RYPnHkrS.js";
20
20
  import { a as listItemFadeKeyframes, c as captionForEntry, i as animateListItemOut, l as createDefaultListDragPreview, n as LIST_ITEM_MOTION_MS, o as prefersReducedMotion, r as animateListItemIn, s as startListItemOutAnimation, t as ListNativeDragController } from "./list-native-drag-controller-CcXoS0YX.js";
21
- import { n as isMultiMode, r as ListDragSelectController, t as ListSelectionController } from "./list-selection-controller-BeE70lbB.js";
21
+ import { n as isMultiMode, r as ListDragSelectController, t as ListSelectionController } from "./list-selection-controller-5L0Nj9tR.js";
22
+ import { a as capKeys, c as keysAsFlags, d as partitionKeys, f as withoutFlag, i as capFlags, l as keysToArray, n as EMPTY_KEYS, o as flagsFromRecord, r as MAX_PENDING_KEYS, s as flagsToRecord, t as EMPTY_FLAGS, u as partitionFlags } from "./pending-BGidHppL.js";
23
+ import { n as createScrollHoverGuard, t as SCROLL_HOVER_SUPPRESS_ATTR } from "./scroll-hover-guard-Cjw6179Q.js";
22
24
  import "./interaction-E-uMGHkJ.js";
23
25
  import "./menu-item-BUv8An2k.js";
24
26
  import { Checkbox } from "./checkbox.js";
@@ -26,35 +28,37 @@ import { t as CheckedSelect } from "./checked-select-CBUidG7n.js";
26
28
  import { n as ColourPicker, t as ColourPickerPopup } from "./colour-picker-DwAa6RDh.js";
27
29
  import { LabelledRangeInput } from "./labelled-range-input.js";
28
30
  import { t as collectDataResult } from "./data-provider-35TyWclX.js";
29
- import { a as TreeDataModel, c as pruneDraggedDescendants, i as TreeDropController, n as TreeListElement, o as applyTreeDrop, r as TreeBaseElement, s as dropTargetFor, t as TreeController } from "./tree-D8VxvR9u.js";
31
+ import { a as TreeDataModel, c as pruneDraggedDescendants, i as TreeDropController, n as TreeListElement, o as applyTreeDrop, r as TreeBaseElement, s as dropTargetFor, t as TreeController } from "./tree-DAlgvaHB.js";
32
+ import { a as restoreElementScroll, c as VIEW_STATE_VERSION, i as onUserScrollIntent, n as anchorFromRow, o as scrollToOffset, r as captureElementScroll, s as VIEW_STATE_CHANGE, t as ViewStateController } from "./view-state-controller-BdyXWUUM.js";
30
33
  import { t as scrollFadeStyles } from "./scroll-fade-Xx28D7mH.js";
34
+ import { t as CrumbNavigationElement } from "./crumb-navigation-YnFVFqrx.js";
31
35
  import { NarrowedTextElement } from "./narrowed-text.js";
32
- import { CrumbNavigationElement } from "./crumbs.js";
33
36
  import { DataDisplayComponent } from "./data-display.js";
34
37
  import { t as createDragGhost } from "./drag-ghost-DtSDvjri.js";
35
- import { t as DataGridElement } from "./data-grid-C1sZX6n9.js";
38
+ import { t as DataGridElement } from "./data-grid-Cqxuwgbb.js";
36
39
  import { a as menuFromTreeToElements, t as menu_exports } from "./menu-CU9KAm8s.js";
37
40
  import { t as PanTextElement } from "./pan-text-CqtvjPyK.js";
38
- import { t as DetailListElement } from "./detail-list-q8bs67BM.js";
41
+ import { t as DetailListElement } from "./detail-list-Bo5ze3SN.js";
39
42
  import { ListGroupHeaderElement } from "./list-group-header.js";
40
- import { a as distribute, c as preserveRestoreWeights, d as toSnapshot, f as walk, i as buildModel, l as resolveTracks, n as SplitPanelElement, o as findPanelNode, r as applySnapshot, s as normalise, t as SplitLayoutElement, u as setPanelRestoreWeight } from "./split-layout-Deoxy47d.js";
43
+ import { a as distribute, c as preserveRestoreWeights, d as toSnapshot, f as walk, i as buildModel, l as resolveTracks, n as SplitPanelElement, o as findPanelNode, r as applySnapshot, s as normalise, t as SplitLayoutElement, u as setPanelRestoreWeight } from "./split-layout-DTY4PJRp.js";
41
44
  import { t as TabListElement } from "./tab-list-R_bGbDvO.js";
42
45
  import { EditableLabel } from "./editable-label.js";
43
46
  import { t as resolveAxisStep } from "./keyboard-B5xD7Fz-.js";
44
47
  import { t as WheelNudgeMixin } from "./wheel-nudge-D5rmht3v.js";
45
48
  import { n as EditableNumber, t as LabelledSliderInput } from "./labelled-slider-input-FdCRi6st.js";
46
- import { VerticalListElement } from "./vertical-list.js";
49
+ import { t as VerticalListElement } from "./vertical-list-DyKPKGxK.js";
47
50
  import { FlatListElement } from "./flat-list.js";
48
51
  import { n as formStyles, r as adoptLightDomStyles, t as FormElement } from "./form-CY0nibZ5.js";
49
- import { t as GridListElement } from "./grid-list-y6dWJYu3.js";
52
+ import { t as GridListElement } from "./grid-list-D0dJNVEF.js";
50
53
  import { PanelElement } from "./panel.js";
51
54
  import { GroupedItemListerElement } from "./grouped-item-lister.js";
55
+ import { a as resolveKeyPath, i as reconcileSelection$1, n as reconcileCursor, t as pathFromJson } from "./tree-CftHwtEb.js";
52
56
  import { t as IxfxHexEditorElement } from "./hex-editor-DiYpgIKs.js";
53
57
  import { t as RadialInputElement } from "./radial-input-KMVltQfe.js";
54
58
  import { LabelledRadialInput } from "./labelled-radial-input.js";
55
59
  import { t as SliderInputElement } from "./slider-input-ByHdotzp.js";
56
60
  import { LedElement } from "./led.js";
57
- import { MillerBaseElement, MillerListElement, MillerTreeController } from "./miller.js";
61
+ import { n as MillerListElement, r as MillerBaseElement, t as MillerTreeController } from "./miller-DUqiUAWQ.js";
58
62
  import { NotificationPillElement } from "./notifier.js";
59
63
  import { a as TooltipElement, c as computeColour, i as PlotMultiAxis, l as parseColourScaleAttr, n as PlotSingleAxis, o as PlotDataSeries, r as PlotHistogram, s as applyAgeModification, t as PlotXyAxis, u as readBaseColour } from "./xy-axis-CyMd_Bn1.js";
60
64
  import { PolarPad } from "./polar-pad.js";
@@ -64,7 +68,7 @@ import { RangeElement } from "./range.js";
64
68
  import { SelectHorizElement } from "./select-horiz.js";
65
69
  import { SnackbarElement } from "./snackbar.js";
66
70
  import { SnapContainer } from "./snap-container.js";
67
- import { a as findSunburstId, c as sunburstChildId, d as sunburstParentId, f as sunburstPathToId, i as defaultSunburstValueAccessor, l as sunburstHasChildren, n as computeSunburstLayout, o as findSunburstNodeById, p as SUNBURST_PATH_SEPARATOR, r as createValueResolver, s as isSunburstAncestorId, t as SunburstTreeListElement, u as sunburstIdToPath } from "./sunbursttree-list-yXwS-uEI.js";
71
+ import { a as findSunburstId, c as sunburstChildId, d as sunburstParentId, f as sunburstPathToId, i as defaultSunburstValueAccessor, l as sunburstHasChildren, n as computeSunburstLayout, o as findSunburstNodeById, p as SUNBURST_PATH_SEPARATOR, r as createValueResolver, s as isSunburstAncestorId, t as SunburstTreeListElement, u as sunburstIdToPath } from "./sunbursttree-list-B_M0YTEA.js";
68
72
  import { SwipeElement } from "./swipe.js";
69
73
  import { a as resolveCssVars, i as attachTimelineInteractions, n as TimelineTrackTabElement, o as resolveGrid, r as timelineStyles, s as TimelineController, t as IxfxTimelineElement } from "./timeline-BP1Ux2EV.js";
70
74
  import { TransitoryLabelElement } from "./transitory-label.js";
@@ -1408,6 +1412,46 @@ __decorate([state()], SplitButton.prototype, "_loadingItems", void 0);
1408
1412
  __decorate([state()], SplitButton.prototype, "_menuHasItems", void 0);
1409
1413
  SplitButton = __decorate([safeCustomElement(`ixfx-split-button`)], SplitButton);
1410
1414
  //#endregion
1415
+ //#region src/common/view-state/bind.ts
1416
+ /** Whether `el` implements the view-state API. */
1417
+ function isViewStateful(el) {
1418
+ return typeof el === `object` && el !== null && typeof el.getViewState === `function` && typeof el.setViewState === `function`;
1419
+ }
1420
+ /**
1421
+ * Wire a component's view state to storage the host controls. Loads and
1422
+ * applies once, then saves on every `view-state-change` and when the page is
1423
+ * hidden. Returns a function that stops saving.
1424
+ *
1425
+ * ```ts
1426
+ * const stop = bindViewState(tree, {
1427
+ * load: () => JSON.parse(localStorage.getItem(`files`) ?? `null`),
1428
+ * save: s => localStorage.setItem(`files`, JSON.stringify(s)),
1429
+ * });
1430
+ * ```
1431
+ */
1432
+ function bindViewState(el, binding) {
1433
+ let loaded;
1434
+ try {
1435
+ loaded = binding.load();
1436
+ } catch (error) {
1437
+ console.warn(`bindViewState: load failed`, error);
1438
+ }
1439
+ if (loaded) el.setViewState(loaded);
1440
+ const onChange = (event) => {
1441
+ const { detail } = event;
1442
+ if (event.target !== el) return;
1443
+ if (detail.reason === `restore` && !binding.saveOnRestore) return;
1444
+ binding.save(detail.state);
1445
+ };
1446
+ const onHide = () => binding.save(el.getViewState());
1447
+ el.addEventListener(VIEW_STATE_CHANGE, onChange);
1448
+ globalThis.addEventListener?.(`pagehide`, onHide);
1449
+ return () => {
1450
+ el.removeEventListener(VIEW_STATE_CHANGE, onChange);
1451
+ globalThis.removeEventListener?.(`pagehide`, onHide);
1452
+ };
1453
+ }
1454
+ //#endregion
1411
1455
  //#region src/crumbs/crumb-path-controller.ts
1412
1456
  var CrumbPathController = class {
1413
1457
  #element = null;
@@ -3515,23 +3559,71 @@ function findEmptiedStacks(before, after) {
3515
3559
  return emptied;
3516
3560
  }
3517
3561
  //#endregion
3562
+ //#region src/dock/dock-view-state.ts
3563
+ /**
3564
+ * Pure helpers for `ixfx-dock-host` view state. The dock's layout is close to
3565
+ * structure, so the saved state is the layout tree itself; these helpers
3566
+ * validate what comes back from storage and filter out panels the host
3567
+ * doesn't hold.
3568
+ */
3569
+ /** Loose structural check of a parsed {@link DockLayout} (from storage, so untrusted). */
3570
+ function isDockLayout(value) {
3571
+ if (value === null || typeof value !== `object`) return false;
3572
+ return isDockNode(value.root);
3573
+ }
3574
+ function isDockNode(value) {
3575
+ if (value === null || typeof value !== `object`) return false;
3576
+ const node = value;
3577
+ if (node.kind === `stack`) return Array.isArray(node.panels) && node.panels.every((p) => typeof p === `string`) && typeof node.active === `string`;
3578
+ if (node.kind === `split`) return (node.direction === `row` || node.direction === `column`) && Array.isArray(node.sizes) && node.sizes.every((n) => typeof n === `number`) && Array.isArray(node.children) && node.children.every(isDockNode);
3579
+ return false;
3580
+ }
3581
+ /**
3582
+ * `layout` with every panel `isPresent` rejects removed (stacks that empty
3583
+ * out are dropped unless named, splits collapse as usual). Used for
3584
+ * `getViewState({ includePending: false })`.
3585
+ */
3586
+ function withoutAbsentPanels(layout, isPresent) {
3587
+ const root = serializeLayout(layout.root).root;
3588
+ const visit = (node) => {
3589
+ if (isStack(node)) {
3590
+ node.panels = node.panels.filter(isPresent);
3591
+ return;
3592
+ }
3593
+ node.children.forEach(visit);
3594
+ };
3595
+ visit(root);
3596
+ return {
3597
+ version: 1,
3598
+ root: normalize(root)
3599
+ };
3600
+ }
3601
+ //#endregion
3518
3602
  //#region src/tabs/tab-list-item.ts
3519
3603
  const resizeHandlers = /* @__PURE__ */ new WeakMap();
3520
- const sharedResizeObserver = new ResizeObserver((entries) => {
3521
- for (const entry of entries) resizeHandlers.get(entry.target)?.(entry.contentRect.width);
3522
- });
3604
+ let resizeObserver;
3605
+ function sharedResizeObserver() {
3606
+ resizeObserver ??= new ResizeObserver((entries) => {
3607
+ for (const entry of entries) resizeHandlers.get(entry.target)?.(entry.contentRect.width);
3608
+ });
3609
+ return resizeObserver;
3610
+ }
3523
3611
  const mutationHandlers = /* @__PURE__ */ new Map();
3524
- const sharedMutationObserver = new MutationObserver((records) => {
3525
- const touched = /* @__PURE__ */ new Set();
3526
- for (const record of records) for (let node = record.target; node; node = node.parentNode) {
3527
- const handler = mutationHandlers.get(node);
3528
- if (handler) {
3529
- touched.add(handler);
3530
- break;
3612
+ let mutationObserver;
3613
+ function sharedMutationObserver() {
3614
+ mutationObserver ??= new MutationObserver((records) => {
3615
+ const touched = /* @__PURE__ */ new Set();
3616
+ for (const record of records) for (let node = record.target; node; node = node.parentNode) {
3617
+ const handler = mutationHandlers.get(node);
3618
+ if (handler) {
3619
+ touched.add(handler);
3620
+ break;
3621
+ }
3531
3622
  }
3532
- }
3533
- for (const handler of touched) handler();
3534
- });
3623
+ for (const handler of touched) handler();
3624
+ });
3625
+ return mutationObserver;
3626
+ }
3535
3627
  let TabListItemElement = class TabListItemElement extends LitElement {
3536
3628
  constructor(..._args) {
3537
3629
  super(..._args);
@@ -3565,10 +3657,10 @@ let TabListItemElement = class TabListItemElement extends LitElement {
3565
3657
  resizeHandlers.set(this, (width) => {
3566
3658
  this._compact = width < 64;
3567
3659
  });
3568
- sharedResizeObserver.observe(this);
3660
+ sharedResizeObserver().observe(this);
3569
3661
  this._wireSlotListeners();
3570
3662
  mutationHandlers.set(this, this._onLabelMutation);
3571
- sharedMutationObserver.observe(this, {
3663
+ sharedMutationObserver().observe(this, {
3572
3664
  childList: true,
3573
3665
  characterData: true,
3574
3666
  subtree: true,
@@ -3578,7 +3670,7 @@ let TabListItemElement = class TabListItemElement extends LitElement {
3578
3670
  }
3579
3671
  disconnectedCallback() {
3580
3672
  super.disconnectedCallback();
3581
- sharedResizeObserver.unobserve(this);
3673
+ sharedResizeObserver().unobserve(this);
3582
3674
  resizeHandlers.delete(this);
3583
3675
  this._commandCleanup?.();
3584
3676
  this._commandCleanup = void 0;
@@ -4159,6 +4251,11 @@ let DockHostElement = class DockHostElement extends LitElement {
4159
4251
  this._toolbarSlots = /* @__PURE__ */ new Set();
4160
4252
  this._toolbarObserver = null;
4161
4253
  this._panelObservers = /* @__PURE__ */ new Map();
4254
+ this.#viewState = new ViewStateController(this, {
4255
+ kind: `dock-host`,
4256
+ capture: () => this.getViewState()
4257
+ });
4258
+ this.#layoutRestored = false;
4162
4259
  this._onDragStart = (event) => {
4163
4260
  if (event.composedPath().find((n) => n instanceof HTMLElement && n.tagName === `IXFX-DOCK-HOST`) !== this) return;
4164
4261
  const { panelId, pointerId, clientX, clientY } = event.detail;
@@ -4191,6 +4288,10 @@ let DockHostElement = class DockHostElement extends LitElement {
4191
4288
  this._onSplitWeightsChanged = () => {
4192
4289
  this._foldLiveWeights();
4193
4290
  this._scheduleJunctions();
4291
+ this.#viewState.notify();
4292
+ };
4293
+ this._onInnerViewState = (event) => {
4294
+ if (event.target === event.currentTarget) event.stopPropagation();
4194
4295
  };
4195
4296
  this._onSplitCollapsed = (event, collapsed) => {
4196
4297
  const detail = event.detail;
@@ -4201,6 +4302,7 @@ let DockHostElement = class DockHostElement extends LitElement {
4201
4302
  if (node === null || !isStack(node)) return;
4202
4303
  if (collapsed) node.collapsed = true;
4203
4304
  else delete node.collapsed;
4305
+ this.#viewState.notify();
4204
4306
  };
4205
4307
  this._onSplitClose = (event) => {
4206
4308
  event.preventDefault();
@@ -4223,6 +4325,7 @@ let DockHostElement = class DockHostElement extends LitElement {
4223
4325
  if (stack === null || !isStack(stack)) return;
4224
4326
  if (!setActive(stack, panelId)) return;
4225
4327
  this.requestUpdate();
4328
+ this.#viewState.notify();
4226
4329
  this.dispatchEvent(new CustomEvent(`dock-panel-activated`, {
4227
4330
  detail: { panelId },
4228
4331
  bubbles: true,
@@ -4273,6 +4376,9 @@ let DockHostElement = class DockHostElement extends LitElement {
4273
4376
  this._teardownTabDragListeners();
4274
4377
  };
4275
4378
  }
4379
+ #viewState;
4380
+ /** A layout was restored via `setViewState()`; it wins over the initial `layout` attribute. */
4381
+ #layoutRestored;
4276
4382
  connectedCallback() {
4277
4383
  super.connectedCallback();
4278
4384
  this._buildInitialModel();
@@ -4310,7 +4416,8 @@ let DockHostElement = class DockHostElement extends LitElement {
4310
4416
  }
4311
4417
  willUpdate(changed) {
4312
4418
  super.willUpdate(changed);
4313
- if (changed.has(`layout`) && this.layout !== null) {
4419
+ const initial = !this.hasUpdated && this.#layoutRestored;
4420
+ if (changed.has(`layout`) && this.layout !== null && !initial) {
4314
4421
  const parsed = parseLayout(this.layout);
4315
4422
  if (parsed) {
4316
4423
  this._model = parsed.root;
@@ -4342,6 +4449,10 @@ let DockHostElement = class DockHostElement extends LitElement {
4342
4449
  this._scheduleJunctions();
4343
4450
  }
4344
4451
  _buildInitialModel() {
4452
+ if (this.#layoutRestored) {
4453
+ this._mergePoolIntoModel();
4454
+ return;
4455
+ }
4345
4456
  if (this.layout !== null) {
4346
4457
  const parsed = parseLayout(this.layout);
4347
4458
  if (parsed) {
@@ -4476,21 +4587,69 @@ let DockHostElement = class DockHostElement extends LitElement {
4476
4587
  }
4477
4588
  this._pool = current;
4478
4589
  this.requestUpdate();
4479
- if (structuralChange) this._emitChange();
4590
+ if (structuralChange) this._emitChange(`data`);
4591
+ }
4592
+ /**
4593
+ * Snapshot the layout (live split weights folded in from the DOM). With
4594
+ * `includePending` (default), panels the restored layout names but the
4595
+ * host doesn't hold yet are kept in place; without it they are left out.
4596
+ */
4597
+ getViewState(options) {
4598
+ const layout = this._captureLayout();
4599
+ const withPending = options?.includePending ?? true;
4600
+ return this.#viewState.shape({ layout: withPending ? layout : withoutAbsentPanels(layout, (id) => this._findPanelEl(id) !== void 0) }, options);
4601
+ }
4602
+ /**
4603
+ * Restore a layout. Panels it names that aren't in the host yet keep their
4604
+ * place (shown as a placeholder tab) and slot in when they arrive — as a
4605
+ * light-DOM child, or via `addPanel()` without a target. Pooled panels it
4606
+ * doesn't name are appended to the first stack admitting their kind.
4607
+ */
4608
+ setViewState(state) {
4609
+ if (!this.#viewState.accepts(state) || state.layout === void 0) return;
4610
+ if (!isDockLayout(state.layout)) {
4611
+ console.warn(`[dock-host] ignoring view state with a malformed layout`);
4612
+ return;
4613
+ }
4614
+ const layout = state.layout;
4615
+ this.#viewState.restoring(() => {
4616
+ this.#layoutRestored = true;
4617
+ this._returnAllToolbars();
4618
+ this._loadGeneration++;
4619
+ this._model = normalize(cloneNode(layout.root));
4620
+ this._mergePoolIntoModel();
4621
+ this._liveStacks = snapshotNonEmptyStacks(this._model);
4622
+ this.requestUpdate();
4623
+ this._emitChange();
4624
+ });
4625
+ }
4626
+ /**
4627
+ * Drop every placeholder: panels the restored layout names that the host
4628
+ * doesn't hold. Call it once the app has added all the panels it has, so
4629
+ * ids for panels that no longer exist don't linger (and get saved again).
4630
+ */
4631
+ clearPendingViewState() {
4632
+ const placeholders = collectPanelIds(this._model).filter((id) => this._isPlaceholder(id));
4633
+ if (placeholders.length === 0) return;
4634
+ for (const id of placeholders) this._removeFromModel(id);
4635
+ this._commit();
4480
4636
  }
4481
- /** Serialize the current layout, folding live split weights from the DOM. */
4637
+ /** @deprecated Since 0.16; use `getViewState().layout`. */
4482
4638
  saveLayout() {
4639
+ return this._captureLayout();
4640
+ }
4641
+ /** @deprecated Since 0.16; use `setViewState({ layout })`. */
4642
+ loadLayout(layout) {
4643
+ this.setViewState({ layout });
4644
+ }
4645
+ /** The current layout, folding live split weights from the DOM. */
4646
+ _captureLayout() {
4483
4647
  this._foldLiveWeights();
4484
4648
  return serializeLayout(this._model);
4485
4649
  }
4486
- loadLayout(layout) {
4487
- this._returnAllToolbars();
4488
- this._loadGeneration++;
4489
- this._model = normalize(cloneNode(layout.root));
4490
- this._mergePoolIntoModel();
4491
- this._liveStacks = snapshotNonEmptyStacks(this._model);
4492
- this.requestUpdate();
4493
- this._emitChange();
4650
+ /** Whether `panelId` is in the model only as a placeholder from a restored layout. */
4651
+ _isPlaceholder(panelId) {
4652
+ return !this._pool.some((p) => p.panelId === panelId) && collectPanelIds(this._model).includes(panelId);
4494
4653
  }
4495
4654
  /**
4496
4655
  * Dock a panel. Returns false (without touching the DOM) when the
@@ -4537,11 +4696,12 @@ let DockHostElement = class DockHostElement extends LitElement {
4537
4696
  if (panel.collapsible !== void 0) el.collapsible = panel.collapsible;
4538
4697
  if (panel.kind !== void 0) el.kind = panel.kind;
4539
4698
  }
4699
+ const takesSavedPlace = target === void 0 && this._isPlaceholder(el.panelId);
4540
4700
  if (el.parentElement !== this) this.appendChild(el);
4541
4701
  if (!this._pool.some((p) => p.panelId === el.panelId)) this._pool = [...this._pool, el];
4542
4702
  this._returnAllToolbars();
4543
- if (collectPanelIds(this._model).includes(el.panelId)) this._removeFromModel(el.panelId);
4544
- if (splitTarget !== null) insertBeside(this._model, splitTarget, opToDirection(op), createStack([el.panelId]), `after`);
4703
+ if (!takesSavedPlace && collectPanelIds(this._model).includes(el.panelId)) this._removeFromModel(el.panelId);
4704
+ if (takesSavedPlace) {} else if (splitTarget !== null) insertBeside(this._model, splitTarget, opToDirection(op), createStack([el.panelId]), `after`);
4545
4705
  else if (stackPath !== null) {
4546
4706
  const stack = findNode(this._model, stackPath);
4547
4707
  if (stack !== null && isStack(stack)) addPanelToStack(stack, el.panelId, target?.index);
@@ -4582,6 +4742,7 @@ let DockHostElement = class DockHostElement extends LitElement {
4582
4742
  if (stack === null || !isStack(stack)) return false;
4583
4743
  if (!setActive(stack, panelId)) return false;
4584
4744
  this.requestUpdate();
4745
+ this.#viewState.notify();
4585
4746
  this.dispatchEvent(new CustomEvent(`dock-panel-activated`, {
4586
4747
  detail: { panelId },
4587
4748
  bubbles: true,
@@ -4641,7 +4802,7 @@ let DockHostElement = class DockHostElement extends LitElement {
4641
4802
  * {@link setLeafPlacementPolicy}'s `allowEdgeSplit` (default `true`).
4642
4803
  * Pass `[]` to seal the leaf (nothing admitted). Warns when no stack
4643
4804
  * currently carries `name`, but the rule is still stored and applies once
4644
- * one appears. Rules are host policy: kept across `loadLayout()`, never
4805
+ * one appears. Rules are host policy: kept across `setViewState()`, never
4645
4806
  * serialized.
4646
4807
  */
4647
4808
  setLeafExclusiveContents(name, kinds) {
@@ -4688,7 +4849,7 @@ let DockHostElement = class DockHostElement extends LitElement {
4688
4849
  * unconstrained* leaf — the policy is not propagated to it.
4689
4850
  *
4690
4851
  * Warns when no stack currently carries `name`; the policy is stored and
4691
- * applies once one appears. Host policy: kept across `loadLayout()`, never
4852
+ * applies once one appears. Host policy: kept across `setViewState()`, never
4692
4853
  * serialized.
4693
4854
  */
4694
4855
  setLeafPlacementPolicy(name, policy) {
@@ -4837,7 +4998,8 @@ let DockHostElement = class DockHostElement extends LitElement {
4837
4998
  * already docked here or its kind is refused by `acceptsKinds`.
4838
4999
  */
4839
5000
  acceptExternalPanel(snapshot, at) {
4840
- if (collectPanelIds(this._model).includes(snapshot.panelId)) return null;
5001
+ const placeholder = this._isPlaceholder(snapshot.panelId);
5002
+ if (!placeholder && collectPanelIds(this._model).includes(snapshot.panelId)) return null;
4841
5003
  if (this.acceptsKinds !== null && !this.acceptsKinds.includes(normalizeKind(snapshot.kind))) return null;
4842
5004
  const el = document.createElement(`ixfx-dock-panel`);
4843
5005
  el.panelId = snapshot.panelId;
@@ -4850,7 +5012,7 @@ let DockHostElement = class DockHostElement extends LitElement {
4850
5012
  if (`contentHTML` in snapshot && snapshot.contentHTML) el.innerHTML = snapshot.contentHTML;
4851
5013
  this._returnAllToolbars();
4852
5014
  this._pool = [...this._pool, el];
4853
- this._appendPanelToFirstStack(el.panelId);
5015
+ if (!placeholder) this._appendPanelToFirstStack(el.panelId);
4854
5016
  this.appendChild(el);
4855
5017
  normalize(this._model);
4856
5018
  this.endExternalDrop();
@@ -5346,10 +5508,11 @@ let DockHostElement = class DockHostElement extends LitElement {
5346
5508
  hostEmpty: now.size === 0
5347
5509
  });
5348
5510
  }
5349
- _emitChange() {
5350
- const layout = this.saveLayout();
5511
+ _emitChange(reason = `user`) {
5512
+ const layout = this._captureLayout();
5351
5513
  this._announceEmptiedStacks();
5352
5514
  this._emit(`dock-change`, { layout });
5515
+ this.#viewState.notify(reason);
5353
5516
  }
5354
5517
  render() {
5355
5518
  return html`
@@ -5385,6 +5548,7 @@ let DockHostElement = class DockHostElement extends LitElement {
5385
5548
  return html`
5386
5549
  <ixfx-split-layout
5387
5550
  class="split"
5551
+ forget-absent
5388
5552
  direction=${node.direction}
5389
5553
  size=${size === void 0 ? nothing : live(String(size))}
5390
5554
  style=${`--split-gutter-size: var(--dock-split-bar-size, 2px); --split-collapsed-size: var(--dock-collapsed-size, 40px)`}
@@ -5395,6 +5559,7 @@ let DockHostElement = class DockHostElement extends LitElement {
5395
5559
  @ixfx-split-collapse=${(e) => this._onSplitCollapsed(e, true)}
5396
5560
  @ixfx-split-expand=${(e) => this._onSplitCollapsed(e, false)}
5397
5561
  @ixfx-split-close=${this._onSplitClose}
5562
+ @view-state-change=${this._onInnerViewState}
5398
5563
  >
5399
5564
  ${node.children.map((child, i) => this._renderNode(child, [...path, i], node.sizes[i]))}
5400
5565
  </ixfx-split-layout>
@@ -5483,7 +5648,7 @@ let DockHostElement = class DockHostElement extends LitElement {
5483
5648
  .iconName=${el?.iconName ?? ``}
5484
5649
  .closeable=${el?.closable ?? false}
5485
5650
  @close=${() => this.removePanel(panelId)}
5486
- >${el?.title ?? panelId}</ixfx-tab-list-item>
5651
+ >${el?.title ?? this.placeholderLabel?.(panelId) ?? panelId}</ixfx-tab-list-item>
5487
5652
  `;
5488
5653
  }
5489
5654
  _stripCaretRect(strip, gapIndex, hostRect) {
@@ -5702,6 +5867,7 @@ __decorate([property({
5702
5867
  converter: { fromAttribute: (value) => value === null ? null : value.split(/\s+/).filter((k) => k.length > 0) }
5703
5868
  })], DockHostElement.prototype, "acceptsKinds", void 0);
5704
5869
  __decorate([property({ attribute: false })], DockHostElement.prototype, "canAccept", void 0);
5870
+ __decorate([property({ attribute: false })], DockHostElement.prototype, "placeholderLabel", void 0);
5705
5871
  __decorate([state()], DockHostElement.prototype, "_model", void 0);
5706
5872
  __decorate([state()], DockHostElement.prototype, "_pool", void 0);
5707
5873
  __decorate([state()], DockHostElement.prototype, "_dropZone", void 0);
@@ -6768,269 +6934,6 @@ function createGestureHandlers(host) {
6768
6934
  };
6769
6935
  }
6770
6936
  //#endregion
6771
- //#region src/hyperbolic-tree/hyperbolictree-styles.ts
6772
- /** Shadow-DOM styles for `ixfx-hyperbolictree-list`. */
6773
- const hyperbolicTreeStyles = css`
6774
- :host {
6775
- --hyperbolictree-node-color: var(--surface-4);
6776
- --hyperbolictree-node-text: var(--surface-3-text);
6777
- --hyperbolictree-edge-color: var(--surface-6);
6778
- --hyperbolictree-boundary-color: var(--border-subtle);
6779
- /* Real screen px — converted to SVG units from the element's rendered size. */
6780
- --hyperbolictree-font-size: 13px;
6781
- --hyperbolictree-box-color: var(--hyperbolictree-node-color);
6782
- --hyperbolictree-box-text: var(--surface-4-text);
6783
- --hyperbolictree-box-border: var(--surface-2);
6784
- --hyperbolictree-branch-hue-start: 250;
6785
- --hyperbolictree-branch-chroma: 0.13;
6786
- --hyperbolictree-branch-lightness: 62%;
6787
- --hyperbolictree-branch-mix: 55%;
6788
-
6789
- display: block;
6790
- position: relative;
6791
- overflow: hidden;
6792
- outline: none;
6793
- overscroll-behavior: contain;
6794
- }
6795
-
6796
- /*
6797
- * delegatesFocus means the host itself reports as focused whenever
6798
- * the shadow DOM's focused descendant (the svg) is — without this,
6799
- * the host also picks up the browser's own default focus ring/fill
6800
- * sized to the whole box.
6801
- */
6802
- :host(:focus),
6803
- :host(:focus-visible) {
6804
- outline: none;
6805
- }
6806
-
6807
- .hyperbolictree {
6808
- display: block;
6809
- width: 100%;
6810
- height: 100%;
6811
- outline: none;
6812
- touch-action: none;
6813
- overscroll-behavior: contain;
6814
- }
6815
-
6816
- .disk-boundary {
6817
- fill: none;
6818
- stroke: var(--hyperbolictree-boundary-color);
6819
- stroke-width: 1;
6820
- }
6821
-
6822
- .edge {
6823
- fill: none;
6824
- stroke: var(--hyperbolictree-edge-color);
6825
- /* --edge-weight (0..1, average of the endpoint nodes' --node-weight) thickens heavier edges. */
6826
- stroke-width: calc(1px + var(--edge-weight, 0.5) * 3px);
6827
- /* Constant on-screen width, however far the disk is zoomed. */
6828
- vector-effect: non-scaling-stroke;
6829
- }
6830
-
6831
- .node {
6832
- cursor: pointer;
6833
- }
6834
-
6835
- /*
6836
- * color-by-branch: each top-level branch gets an evenly-spaced hue,
6837
- * mixed into the normal node/box/edge colour (rather than replacing it)
6838
- * so it stays in keeping with — and legible in — the current theme.
6839
- * Only sets the base colour vars, so selected/tickled states still win.
6840
- */
6841
- :host([color-by-branch]) .node,
6842
- :host([color-by-branch]) .edge {
6843
- --_branch-color: oklch(
6844
- var(--hyperbolictree-branch-lightness)
6845
- var(--hyperbolictree-branch-chroma)
6846
- calc(var(--hyperbolictree-branch-hue-start) + var(--branch-index) * 360 / var(--branch-count))
6847
- );
6848
- }
6849
-
6850
- :host([color-by-branch]) .node {
6851
- --_node-base: color-mix(in oklab, var(--hyperbolictree-node-color), var(--_branch-color) var(--hyperbolictree-branch-mix));
6852
- --_box-base: color-mix(in oklab, var(--hyperbolictree-box-color), var(--_branch-color) var(--hyperbolictree-branch-mix));
6853
- }
6854
-
6855
- :host([color-by-branch]) .edge {
6856
- stroke: color-mix(in oklab, var(--hyperbolictree-edge-color), var(--_branch-color) 50%);
6857
- }
6858
-
6859
- .node-hit {
6860
- fill: transparent;
6861
- pointer-events: all;
6862
- }
6863
-
6864
- .node-dot {
6865
- /* --node-weight (0..1, normalized across the visible node set) tints toward --accent for heavier nodes. */
6866
- fill: color-mix(in oklab, var(--_node-base, var(--hyperbolictree-node-color)), var(--accent) calc(var(--node-weight, 0.5) * 50%));
6867
- stroke: var(--surface-2);
6868
- stroke-width: 1;
6869
- pointer-events: none;
6870
- }
6871
-
6872
- .node-box {
6873
- fill: color-mix(in oklab, var(--_box-base, var(--hyperbolictree-box-color)), var(--accent) calc(var(--node-weight, 0.5) * 25%));
6874
- stroke: var(--hyperbolictree-box-border);
6875
- stroke-width: 1.5;
6876
- /* Keep the border crisp however far the box has been scaled down. */
6877
- vector-effect: non-scaling-stroke;
6878
- }
6879
-
6880
- /* Text-less stand-in for a box too small, or too crowded, to label. */
6881
- .node-box.marker {
6882
- stroke-width: 1;
6883
- }
6884
-
6885
- /* Pure user-space font-size; set per render from --hyperbolictree-font-size. */
6886
- .hyperbolictree-label {
6887
- fill: var(--hyperbolictree-node-text);
6888
- cursor: pointer;
6889
- user-select: none;
6890
- }
6891
-
6892
- .box-label {
6893
- fill: var(--hyperbolictree-box-text);
6894
- pointer-events: none;
6895
- }
6896
-
6897
- /*
6898
- * State fills come paired with their theme text tokens: a box label sits
6899
- * on the state fill itself, so e.g. --item-bg-selected needs
6900
- * --item-text-selected on top, not the resting box text colour.
6901
- */
6902
- .node.tickled .node-dot,
6903
- .node.tickled .node-box {
6904
- fill: var(--item-bg-tickled);
6905
- }
6906
-
6907
- .node.tickled .box-label {
6908
- fill: var(--item-text-tickled);
6909
- }
6910
-
6911
- .node.selected .node-dot,
6912
- .node.selected .node-box {
6913
- fill: var(--item-bg-selected);
6914
- }
6915
-
6916
- .node.selected .box-label {
6917
- fill: var(--item-text-selected);
6918
- }
6919
-
6920
- .node.selected.tickled .node-dot,
6921
- .node.selected.tickled .node-box {
6922
- fill: var(--item-bg-selected-tickled);
6923
- }
6924
-
6925
- .node.focused .node-dot,
6926
- .node.focused .node-box {
6927
- stroke: var(--accent);
6928
- stroke-width: 3;
6929
- }
6930
-
6931
- /* A hovered label may sit over busy neighbours — give it a halo so it always reads. */
6932
- .node.tickled .hyperbolictree-label:not(.box-label) {
6933
- paint-order: stroke;
6934
- stroke: var(--surface-2);
6935
- stroke-width: 4px;
6936
- stroke-linejoin: round;
6937
- }
6938
-
6939
- .node.filtered {
6940
- opacity: 0.3;
6941
- }
6942
-
6943
- .node.loading .node-dot,
6944
- .node.loading .node-box {
6945
- opacity: 0.6;
6946
- }
6947
-
6948
-
6949
- /* Child preview: a clickable list of a crowded node's children. */
6950
- .child-preview {
6951
- position: absolute;
6952
- z-index: 1;
6953
- min-width: 8rem;
6954
- max-width: min(18rem, 60%);
6955
- max-height: 45%;
6956
- overflow: auto;
6957
- padding: var(--space-xs, 4px);
6958
- background: var(--surface-3);
6959
- color: var(--surface-3-text);
6960
- border: var(--border-subtle);
6961
- border-radius: var(--radius-m, 6px);
6962
- box-shadow: var(--shadow-m);
6963
- font-size: var(--hyperbolictree-font-size);
6964
- /* Beside the node, never over it, with the dot level with the popover's middle. */
6965
- transform: translate(14px, -50%);
6966
- }
6967
-
6968
- .child-preview.left {
6969
- transform: translate(calc(-100% - 14px), -50%);
6970
- }
6971
-
6972
- .child-preview.below {
6973
- transform: translate(-50%, 14px);
6974
- }
6975
-
6976
- .child-preview.above {
6977
- transform: translate(-50%, calc(-100% - 14px));
6978
- }
6979
-
6980
- .child-preview-title {
6981
- display: flex;
6982
- align-items: center;
6983
- gap: 4px;
6984
- padding: 2px 6px 4px;
6985
- font-weight: 600;
6986
- }
6987
-
6988
- .child-preview-title span {
6989
- overflow: hidden;
6990
- text-overflow: ellipsis;
6991
- white-space: nowrap;
6992
- }
6993
-
6994
- .child-preview button.child-preview-up {
6995
- display: inline-flex;
6996
- flex: none;
6997
- width: auto;
6998
- padding: 2px;
6999
- }
7000
-
7001
- .child-preview ul {
7002
- margin: 0;
7003
- padding: 0;
7004
- list-style: none;
7005
- }
7006
-
7007
- .child-preview button {
7008
- display: block;
7009
- width: 100%;
7010
- padding: 2px 6px;
7011
- border: none;
7012
- border-radius: var(--radius-s, 4px);
7013
- background: none;
7014
- color: inherit;
7015
- font: inherit;
7016
- text-align: start;
7017
- overflow: hidden;
7018
- text-overflow: ellipsis;
7019
- white-space: nowrap;
7020
- cursor: pointer;
7021
- }
7022
-
7023
- .child-preview button:hover {
7024
- background: var(--item-bg-tickled);
7025
- color: var(--item-text-tickled);
7026
- }
7027
-
7028
- .child-preview-more {
7029
- padding: 2px 6px;
7030
- color: var(--surface-muted-text);
7031
- }
7032
- `;
7033
- //#endregion
7034
6937
  //#region src/hyperbolic-tree/layout.ts
7035
6938
  const ID_SEPARATOR = `\u001F`;
7036
6939
  /**
@@ -7445,6 +7348,564 @@ function computeHyperbolicLayout(root, options = {}) {
7445
7348
  };
7446
7349
  }
7447
7350
  //#endregion
7351
+ //#region src/hyperbolic-tree/hyperbolic-view-state.ts
7352
+ function indexHyperbolicKeys(root) {
7353
+ const byKey = /* @__PURE__ */ new Map();
7354
+ const nodes = /* @__PURE__ */ new Set();
7355
+ const walk = (node) => {
7356
+ nodes.add(node);
7357
+ if (!byKey.has(node.item.key)) byKey.set(node.item.key, node);
7358
+ for (const child of node.children ?? []) walk(child);
7359
+ };
7360
+ if (root) walk(root);
7361
+ return {
7362
+ byKey,
7363
+ nodes
7364
+ };
7365
+ }
7366
+ /** Key path from the root's children to the node with layout id `id`. */
7367
+ function centerPathOf(root, id) {
7368
+ if (id === void 0) return [];
7369
+ return findHyperbolicPathById(root, id)?.slice(1).map((n) => n.item.key) ?? [];
7370
+ }
7371
+ /** Walk `path` from the root, matching each step by key among the loaded children. */
7372
+ function resolveCenterPath(root, path) {
7373
+ if (!root) return { kind: `missing` };
7374
+ let node = root;
7375
+ let id = root.item.key;
7376
+ for (const [depth, key] of path.entries()) {
7377
+ const children = node.children;
7378
+ if (children === void 0) return node.item.isLeaf === true ? { kind: `missing` } : {
7379
+ kind: `load`,
7380
+ node,
7381
+ depth
7382
+ };
7383
+ const index = children.findIndex((c) => c.item.key === key);
7384
+ if (index < 0) return { kind: `missing` };
7385
+ id = hyperbolicChildIds(id, children)[index];
7386
+ node = children[index];
7387
+ }
7388
+ return {
7389
+ kind: `found`,
7390
+ id: path.length === 0 ? void 0 : id
7391
+ };
7392
+ }
7393
+ /**
7394
+ * As `reconcileSelection` in `tree/tree-view-state.ts`, but a selected node
7395
+ * still in the tree is kept by identity — keys may repeat here, so re-keying
7396
+ * it would jump the selection to the first node sharing its key on every
7397
+ * lazy load. Only nodes that left the tree are re-keyed (or go pending).
7398
+ */
7399
+ function reconcileHyperbolicSelection(selected, pending, index, canSelect, single) {
7400
+ if (selected.size === 0 && pending.size === 0) return {
7401
+ selected,
7402
+ pending,
7403
+ restored: false,
7404
+ changed: false
7405
+ };
7406
+ const next = /* @__PURE__ */ new Set();
7407
+ const nextPending = /* @__PURE__ */ new Set();
7408
+ let changed = false;
7409
+ for (const node of selected) {
7410
+ if (index.nodes.has(node)) {
7411
+ next.add(node);
7412
+ continue;
7413
+ }
7414
+ changed = true;
7415
+ const current = index.byKey.get(node.item.key);
7416
+ if (current) next.add(current);
7417
+ else nextPending.add(node.item.key);
7418
+ }
7419
+ let restored;
7420
+ for (const key of pending) {
7421
+ const node = index.byKey.get(key);
7422
+ if (node && canSelect(node)) {
7423
+ next.add(node);
7424
+ restored = node;
7425
+ changed = true;
7426
+ } else nextPending.add(key);
7427
+ }
7428
+ if (!changed) return {
7429
+ selected,
7430
+ pending,
7431
+ restored: false,
7432
+ changed: false
7433
+ };
7434
+ if (single && next.size > 0) return {
7435
+ selected: /* @__PURE__ */ new Set([restored ?? [...next].at(-1)]),
7436
+ pending: EMPTY_KEYS,
7437
+ restored: restored !== void 0,
7438
+ changed: true
7439
+ };
7440
+ return {
7441
+ selected: next,
7442
+ pending: capKeys(nextPending),
7443
+ restored: restored !== void 0,
7444
+ changed: true
7445
+ };
7446
+ }
7447
+ /**
7448
+ * Centre: a live centre whose path no longer resolves goes pending (the view
7449
+ * falls back to the root); one that resolves to a new id is re-seated; a
7450
+ * pending path that now resolves is applied.
7451
+ */
7452
+ function reconcileHyperbolicViewState(input) {
7453
+ const { root } = input;
7454
+ let center;
7455
+ let pendingCenter = input.pendingCenter;
7456
+ if (input.focusId !== void 0) {
7457
+ const live = resolveCenterPath(root, input.centerPath);
7458
+ if (live.kind !== `found` || live.id === void 0) {
7459
+ pendingCenter ??= input.centerPath;
7460
+ center = {
7461
+ id: void 0,
7462
+ path: [],
7463
+ restored: false
7464
+ };
7465
+ } else if (live.id !== input.focusId) center = {
7466
+ id: live.id,
7467
+ path: input.centerPath,
7468
+ restored: false
7469
+ };
7470
+ }
7471
+ if (pendingCenter) {
7472
+ const resolved = resolveCenterPath(root, pendingCenter);
7473
+ if (resolved.kind === `found`) {
7474
+ center = {
7475
+ id: resolved.id,
7476
+ path: pendingCenter,
7477
+ restored: true
7478
+ };
7479
+ pendingCenter = void 0;
7480
+ }
7481
+ }
7482
+ const selection = reconcileHyperbolicSelection(input.selected, input.pendingSelected, input.index, input.canSelect, input.singleSelect);
7483
+ return {
7484
+ center,
7485
+ pendingCenter,
7486
+ selection
7487
+ };
7488
+ }
7489
+ /** Sanitise a JSON zoom: a finite positive number, else `undefined`. */
7490
+ function zoomFromJson(value) {
7491
+ return typeof value === `number` && Number.isFinite(value) && value > 0 ? value : void 0;
7492
+ }
7493
+ //#endregion
7494
+ //#region src/hyperbolic-tree/hyperbolic-view-state-binding.ts
7495
+ function createHyperbolicViewState(host) {
7496
+ const { controller } = host;
7497
+ /** Key path of the centre as last seen — what goes pending if it leaves the tree. */
7498
+ let centerPath = [];
7499
+ let pendingCenter;
7500
+ let pendingSelected = EMPTY_KEYS;
7501
+ let pendingFocus;
7502
+ /** Where keyboard / search navigation last put the cursor. */
7503
+ let keyboardCursor;
7504
+ let structureVersion = 0;
7505
+ let reconciledVersion = -1;
7506
+ let cache;
7507
+ const index = () => {
7508
+ if (cache?.version !== structureVersion) cache = {
7509
+ version: structureVersion,
7510
+ index: indexHyperbolicKeys(host.root())
7511
+ };
7512
+ return cache.index;
7513
+ };
7514
+ /** Key of the keyboard cursor, while the cursor is still where the keyboard put it. */
7515
+ const cursorKey = () => keyboardCursor && keyboardCursor.id === host.tickled() ? keyboardCursor.key : void 0;
7516
+ const applyCenter = (id, path, restored) => {
7517
+ centerPath = path;
7518
+ if (host.focusId() === id) return;
7519
+ host.setCenter(id, restored);
7520
+ controller.notify();
7521
+ };
7522
+ const reconcileTickled = (idx) => {
7523
+ const tickled = host.tickled();
7524
+ const alive = tickled !== void 0 && findHyperbolicPathById(host.root(), tickled) !== void 0;
7525
+ const cursor = reconcileCursor(cursorKey(), pendingFocus, (key) => alive && idx.byKey.has(key), (key) => host.visibleIdOf(key) !== void 0);
7526
+ pendingFocus = cursor.pending;
7527
+ if (cursor.clear || tickled !== void 0 && !alive) host.setTickled(void 0);
7528
+ const id = cursor.apply === void 0 ? void 0 : host.visibleIdOf(cursor.apply);
7529
+ if (cursor.apply !== void 0 && id !== void 0) {
7530
+ host.setTickled(id);
7531
+ keyboardCursor = {
7532
+ id,
7533
+ key: cursor.apply
7534
+ };
7535
+ controller.notify();
7536
+ }
7537
+ };
7538
+ /** Move state for missing keys to pending; apply pending state for keys now loaded. */
7539
+ const reconcile = () => {
7540
+ reconciledVersion = structureVersion;
7541
+ const root = host.root();
7542
+ if (!root) return;
7543
+ const idx = index();
7544
+ controller.restoring(() => {
7545
+ const r = reconcileHyperbolicViewState({
7546
+ root,
7547
+ index: idx,
7548
+ focusId: host.focusId(),
7549
+ centerPath,
7550
+ pendingCenter,
7551
+ selected: host.selected(),
7552
+ pendingSelected,
7553
+ canSelect: host.canSelect,
7554
+ singleSelect: host.singleSelect()
7555
+ });
7556
+ pendingCenter = r.pendingCenter;
7557
+ if (r.center) applyCenter(r.center.id, r.center.path, r.center.restored);
7558
+ const sel = r.selection;
7559
+ if (sel.changed) {
7560
+ pendingSelected = sel.pending;
7561
+ const primary = host.selectedNode();
7562
+ host.setSelection(sel.selected, primary && sel.selected.has(primary) ? primary : [...sel.selected].at(-1), sel.restored);
7563
+ }
7564
+ reconcileTickled(idx);
7565
+ });
7566
+ };
7567
+ return {
7568
+ /** The structure changed (new root / model, invalidation, lazy load). */
7569
+ invalidate() {
7570
+ structureVersion++;
7571
+ },
7572
+ /** Run in `willUpdate`. `centerGone` — the centre vanished through in-place mutation. */
7573
+ reconcileIfNeeded(centerGone) {
7574
+ if (centerGone) structureVersion++;
7575
+ if (reconciledVersion !== structureVersion) reconcile();
7576
+ },
7577
+ /** An explicit centre change: supersedes a saved one still waiting. */
7578
+ centred(id) {
7579
+ pendingCenter = void 0;
7580
+ centerPath = centerPathOf(host.root(), id);
7581
+ controller.notify();
7582
+ },
7583
+ /** Selection changed — explicit changes supersede a saved selection still waiting. */
7584
+ selectionChanged() {
7585
+ if (!controller.isRestoring) pendingSelected = EMPTY_KEYS;
7586
+ controller.notify();
7587
+ },
7588
+ clearPendingSelection() {
7589
+ pendingSelected = EMPTY_KEYS;
7590
+ },
7591
+ /** The cursor moved by keyboard / search navigation. */
7592
+ keyboardCursorMoved(id, key) {
7593
+ keyboardCursor = {
7594
+ id,
7595
+ key
7596
+ };
7597
+ pendingFocus = void 0;
7598
+ controller.notify();
7599
+ },
7600
+ /** A user-visible change with no pending facet (e.g. a zoom gesture). */
7601
+ notify() {
7602
+ controller.notify();
7603
+ },
7604
+ get(options) {
7605
+ const withPending = options?.includePending ?? true;
7606
+ return controller.shape({
7607
+ selected: keysToArray([...host.selected()].map((n) => n.item.key), withPending ? pendingSelected : []),
7608
+ focus: cursorKey() ?? (withPending ? pendingFocus : void 0),
7609
+ center: [...(withPending ? pendingCenter : void 0) ?? centerPath],
7610
+ zoom: host.zoom()
7611
+ }, options);
7612
+ },
7613
+ set(state) {
7614
+ if (!controller.accepts(state)) return;
7615
+ controller.restoring(() => {
7616
+ if (state.selected) {
7617
+ pendingSelected = capKeys(new Set(state.selected));
7618
+ if (host.selected().size > 0) host.setSelection(/* @__PURE__ */ new Set(), void 0, true);
7619
+ }
7620
+ if (state.focus !== void 0) pendingFocus = state.focus;
7621
+ const zoom = zoomFromJson(state.zoom);
7622
+ if (zoom !== void 0 && zoom !== host.zoom()) {
7623
+ host.setZoom(zoom);
7624
+ controller.notify();
7625
+ }
7626
+ const center = pathFromJson(state.center);
7627
+ if (center) pendingCenter = center;
7628
+ reconcile();
7629
+ if (center && pendingCenter) applyCenter(void 0, [], true);
7630
+ });
7631
+ },
7632
+ clearPending() {
7633
+ pendingSelected = EMPTY_KEYS;
7634
+ pendingFocus = void 0;
7635
+ pendingCenter = void 0;
7636
+ },
7637
+ /** Load the next unloaded ancestor on a pending centre path (one load at a time). */
7638
+ continueRestoreLoads() {
7639
+ if (!pendingCenter || !host.restoreLoads()) return;
7640
+ const resolved = resolveCenterPath(host.root(), pendingCenter);
7641
+ if (resolved.kind === `load`) host.load(resolved.node, resolved.depth);
7642
+ }
7643
+ };
7644
+ }
7645
+ //#endregion
7646
+ //#region src/hyperbolic-tree/hyperbolictree-styles.ts
7647
+ /** Shadow-DOM styles for `ixfx-hyperbolictree-list`. */
7648
+ const hyperbolicTreeStyles = css`
7649
+ :host {
7650
+ --hyperbolictree-node-color: var(--surface-4);
7651
+ --hyperbolictree-node-text: var(--surface-3-text);
7652
+ --hyperbolictree-edge-color: var(--surface-6);
7653
+ --hyperbolictree-boundary-color: var(--border-subtle);
7654
+ /* Real screen px — converted to SVG units from the element's rendered size. */
7655
+ --hyperbolictree-font-size: 13px;
7656
+ --hyperbolictree-box-color: var(--hyperbolictree-node-color);
7657
+ --hyperbolictree-box-text: var(--surface-4-text);
7658
+ --hyperbolictree-box-border: var(--surface-2);
7659
+ --hyperbolictree-branch-hue-start: 250;
7660
+ --hyperbolictree-branch-chroma: 0.13;
7661
+ --hyperbolictree-branch-lightness: 62%;
7662
+ --hyperbolictree-branch-mix: 55%;
7663
+
7664
+ display: block;
7665
+ position: relative;
7666
+ overflow: hidden;
7667
+ outline: none;
7668
+ overscroll-behavior: contain;
7669
+ }
7670
+
7671
+ /*
7672
+ * delegatesFocus means the host itself reports as focused whenever
7673
+ * the shadow DOM's focused descendant (the svg) is — without this,
7674
+ * the host also picks up the browser's own default focus ring/fill
7675
+ * sized to the whole box.
7676
+ */
7677
+ :host(:focus),
7678
+ :host(:focus-visible) {
7679
+ outline: none;
7680
+ }
7681
+
7682
+ .hyperbolictree {
7683
+ display: block;
7684
+ width: 100%;
7685
+ height: 100%;
7686
+ outline: none;
7687
+ touch-action: none;
7688
+ overscroll-behavior: contain;
7689
+ }
7690
+
7691
+ .disk-boundary {
7692
+ fill: none;
7693
+ stroke: var(--hyperbolictree-boundary-color);
7694
+ stroke-width: 1;
7695
+ }
7696
+
7697
+ .edge {
7698
+ fill: none;
7699
+ stroke: var(--hyperbolictree-edge-color);
7700
+ /* --edge-weight (0..1, average of the endpoint nodes' --node-weight) thickens heavier edges. */
7701
+ stroke-width: calc(1px + var(--edge-weight, 0.5) * 3px);
7702
+ /* Constant on-screen width, however far the disk is zoomed. */
7703
+ vector-effect: non-scaling-stroke;
7704
+ }
7705
+
7706
+ .node {
7707
+ cursor: pointer;
7708
+ }
7709
+
7710
+ /*
7711
+ * color-by-branch: each top-level branch gets an evenly-spaced hue,
7712
+ * mixed into the normal node/box/edge colour (rather than replacing it)
7713
+ * so it stays in keeping with — and legible in — the current theme.
7714
+ * Only sets the base colour vars, so selected/tickled states still win.
7715
+ */
7716
+ :host([color-by-branch]) .node,
7717
+ :host([color-by-branch]) .edge {
7718
+ --_branch-color: oklch(
7719
+ var(--hyperbolictree-branch-lightness)
7720
+ var(--hyperbolictree-branch-chroma)
7721
+ calc(var(--hyperbolictree-branch-hue-start) + var(--branch-index) * 360 / var(--branch-count))
7722
+ );
7723
+ }
7724
+
7725
+ :host([color-by-branch]) .node {
7726
+ --_node-base: color-mix(in oklab, var(--hyperbolictree-node-color), var(--_branch-color) var(--hyperbolictree-branch-mix));
7727
+ --_box-base: color-mix(in oklab, var(--hyperbolictree-box-color), var(--_branch-color) var(--hyperbolictree-branch-mix));
7728
+ }
7729
+
7730
+ :host([color-by-branch]) .edge {
7731
+ stroke: color-mix(in oklab, var(--hyperbolictree-edge-color), var(--_branch-color) 50%);
7732
+ }
7733
+
7734
+ .node-hit {
7735
+ fill: transparent;
7736
+ pointer-events: all;
7737
+ }
7738
+
7739
+ .node-dot {
7740
+ /* --node-weight (0..1, normalized across the visible node set) tints toward --accent for heavier nodes. */
7741
+ fill: color-mix(in oklab, var(--_node-base, var(--hyperbolictree-node-color)), var(--accent) calc(var(--node-weight, 0.5) * 50%));
7742
+ stroke: var(--surface-2);
7743
+ stroke-width: 1;
7744
+ pointer-events: none;
7745
+ }
7746
+
7747
+ .node-box {
7748
+ fill: color-mix(in oklab, var(--_box-base, var(--hyperbolictree-box-color)), var(--accent) calc(var(--node-weight, 0.5) * 25%));
7749
+ stroke: var(--hyperbolictree-box-border);
7750
+ stroke-width: 1.5;
7751
+ /* Keep the border crisp however far the box has been scaled down. */
7752
+ vector-effect: non-scaling-stroke;
7753
+ }
7754
+
7755
+ /* Text-less stand-in for a box too small, or too crowded, to label. */
7756
+ .node-box.marker {
7757
+ stroke-width: 1;
7758
+ }
7759
+
7760
+ /* Pure user-space font-size; set per render from --hyperbolictree-font-size. */
7761
+ .hyperbolictree-label {
7762
+ fill: var(--hyperbolictree-node-text);
7763
+ cursor: pointer;
7764
+ user-select: none;
7765
+ }
7766
+
7767
+ .box-label {
7768
+ fill: var(--hyperbolictree-box-text);
7769
+ pointer-events: none;
7770
+ }
7771
+
7772
+ /*
7773
+ * State fills come paired with their theme text tokens: a box label sits
7774
+ * on the state fill itself, so e.g. --item-bg-selected needs
7775
+ * --item-text-selected on top, not the resting box text colour.
7776
+ */
7777
+ .node.tickled .node-dot,
7778
+ .node.tickled .node-box {
7779
+ fill: var(--item-bg-tickled);
7780
+ }
7781
+
7782
+ .node.tickled .box-label {
7783
+ fill: var(--item-text-tickled);
7784
+ }
7785
+
7786
+ .node.selected .node-dot,
7787
+ .node.selected .node-box {
7788
+ fill: var(--item-bg-selected);
7789
+ }
7790
+
7791
+ .node.selected .box-label {
7792
+ fill: var(--item-text-selected);
7793
+ }
7794
+
7795
+ .node.selected.tickled .node-dot,
7796
+ .node.selected.tickled .node-box {
7797
+ fill: var(--item-bg-selected-tickled);
7798
+ }
7799
+
7800
+ .node.focused .node-dot,
7801
+ .node.focused .node-box {
7802
+ stroke: var(--accent);
7803
+ stroke-width: 3;
7804
+ }
7805
+
7806
+ /* A hovered label may sit over busy neighbours — give it a halo so it always reads. */
7807
+ .node.tickled .hyperbolictree-label:not(.box-label) {
7808
+ paint-order: stroke;
7809
+ stroke: var(--surface-2);
7810
+ stroke-width: 4px;
7811
+ stroke-linejoin: round;
7812
+ }
7813
+
7814
+ .node.filtered {
7815
+ opacity: 0.3;
7816
+ }
7817
+
7818
+ .node.loading .node-dot,
7819
+ .node.loading .node-box {
7820
+ opacity: 0.6;
7821
+ }
7822
+
7823
+
7824
+ /* Child preview: a clickable list of a crowded node's children. */
7825
+ .child-preview {
7826
+ position: absolute;
7827
+ z-index: 1;
7828
+ min-width: 8rem;
7829
+ max-width: min(18rem, 60%);
7830
+ max-height: 45%;
7831
+ overflow: auto;
7832
+ padding: var(--space-xs, 4px);
7833
+ background: var(--surface-3);
7834
+ color: var(--surface-3-text);
7835
+ border: var(--border-subtle);
7836
+ border-radius: var(--radius-m, 6px);
7837
+ box-shadow: var(--shadow-m);
7838
+ font-size: var(--hyperbolictree-font-size);
7839
+ /* Beside the node, never over it, with the dot level with the popover's middle. */
7840
+ transform: translate(14px, -50%);
7841
+ }
7842
+
7843
+ .child-preview.left {
7844
+ transform: translate(calc(-100% - 14px), -50%);
7845
+ }
7846
+
7847
+ .child-preview.below {
7848
+ transform: translate(-50%, 14px);
7849
+ }
7850
+
7851
+ .child-preview.above {
7852
+ transform: translate(-50%, calc(-100% - 14px));
7853
+ }
7854
+
7855
+ .child-preview-title {
7856
+ display: flex;
7857
+ align-items: center;
7858
+ gap: 4px;
7859
+ padding: 2px 6px 4px;
7860
+ font-weight: 600;
7861
+ }
7862
+
7863
+ .child-preview-title span {
7864
+ overflow: hidden;
7865
+ text-overflow: ellipsis;
7866
+ white-space: nowrap;
7867
+ }
7868
+
7869
+ .child-preview button.child-preview-up {
7870
+ display: inline-flex;
7871
+ flex: none;
7872
+ width: auto;
7873
+ padding: 2px;
7874
+ }
7875
+
7876
+ .child-preview ul {
7877
+ margin: 0;
7878
+ padding: 0;
7879
+ list-style: none;
7880
+ }
7881
+
7882
+ .child-preview button {
7883
+ display: block;
7884
+ width: 100%;
7885
+ padding: 2px 6px;
7886
+ border: none;
7887
+ border-radius: var(--radius-s, 4px);
7888
+ background: none;
7889
+ color: inherit;
7890
+ font: inherit;
7891
+ text-align: start;
7892
+ overflow: hidden;
7893
+ text-overflow: ellipsis;
7894
+ white-space: nowrap;
7895
+ cursor: pointer;
7896
+ }
7897
+
7898
+ .child-preview button:hover {
7899
+ background: var(--item-bg-tickled);
7900
+ color: var(--item-text-tickled);
7901
+ }
7902
+
7903
+ .child-preview-more {
7904
+ padding: 2px 6px;
7905
+ color: var(--surface-muted-text);
7906
+ }
7907
+ `;
7908
+ //#endregion
7448
7909
  //#region src/hyperbolic-tree/keyboard.ts
7449
7910
  const unhandled = {
7450
7911
  action: { kind: `none` },
@@ -7543,6 +8004,11 @@ function createLazyChildLoader(source, onChange) {
7543
8004
  if (!model && !loadChildren) return;
7544
8005
  for (const { node, depth } of visible) if (node.children === void 0 && !pending.has(node)) load(node, depth);
7545
8006
  },
8007
+ /** Start loading `node`'s children unless loaded or already loading (view-state restore). */
8008
+ loadOne(node, depth) {
8009
+ const { model, loadChildren } = source();
8010
+ if ((model || loadChildren) && node.children === void 0 && !pending.has(node)) load(node, depth);
8011
+ },
7546
8012
  isPending: (node) => pending.has(node),
7547
8013
  abortAll() {
7548
8014
  for (const aborter of aborters.values()) aborter.abort();
@@ -8063,6 +8529,35 @@ let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitEleme
8063
8529
  this.disableKeyboardNavigation = false;
8064
8530
  this.registry = void 0;
8065
8531
  this._focusId = void 0;
8532
+ this.restoreLoads = true;
8533
+ this.#viewState = createHyperbolicViewState({
8534
+ controller: new ViewStateController(this, {
8535
+ kind: `hyperbolictree-list`,
8536
+ capture: () => this.getViewState()
8537
+ }),
8538
+ root: () => this.#effectiveRoot,
8539
+ focusId: () => this._focusId,
8540
+ setCenter: (id, restored) => this.#restoreCenter(id, restored),
8541
+ selected: () => this._selectedNodes,
8542
+ selectedNode: () => this.selectedNode,
8543
+ canSelect: (node) => this.#canSelect(node),
8544
+ singleSelect: () => this.interactionMode === `implicit`,
8545
+ setSelection: (next, primary, fire) => {
8546
+ const prev = this._selectedNodes;
8547
+ this._selectedNodes = new Set(next);
8548
+ this.selectedNode = primary;
8549
+ if (fire) this.#dispatchSelect(prev);
8550
+ },
8551
+ tickled: () => this.tickled.tickledId,
8552
+ setTickled: (id) => id === void 0 ? this.tickled.clearTickled() : this.tickled.setTickled(id),
8553
+ visibleIdOf: (key) => this.#layout().nodes.find((n) => n.key === key)?.id,
8554
+ zoom: () => this.zoom,
8555
+ setZoom: (zoom) => {
8556
+ this.zoom = zoom;
8557
+ },
8558
+ restoreLoads: () => this.restoreLoads,
8559
+ load: (node, depth) => this.#lazy.loadOne(node, depth)
8560
+ });
8066
8561
  this._viewOverride = void 0;
8067
8562
  this._focusRotation = 0;
8068
8563
  this._zoomOffset = {
@@ -8084,7 +8579,10 @@ let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitEleme
8084
8579
  this.#lazy = createLazyChildLoader(() => ({
8085
8580
  model: this.model,
8086
8581
  loadChildren: this.loadChildren
8087
- }), () => this.requestUpdate());
8582
+ }), () => {
8583
+ this.#viewState.invalidate();
8584
+ this.requestUpdate();
8585
+ });
8088
8586
  this.#lastLabelIds = /* @__PURE__ */ new Set();
8089
8587
  this.search = new ListSearchController(this, { adapter: createTreeListSearchAdapter(this, {
8090
8588
  labelSelector: `.hyperbolictree-label`,
@@ -8131,6 +8629,7 @@ let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitEleme
8131
8629
  },
8132
8630
  zoomState: () => this.#zoomState(),
8133
8631
  setZoomState: (state) => {
8632
+ if (state.zoom !== this.zoom) this.#viewState.notify();
8134
8633
  this.zoom = state.zoom;
8135
8634
  this._zoomOffset = state.offset;
8136
8635
  },
@@ -8153,6 +8652,7 @@ let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitEleme
8153
8652
  if (this._focusId === void 0) return this.#effectiveRoot;
8154
8653
  return findHyperbolicPathById(this.#effectiveRoot, this._focusId)?.at(-1) ?? this.#effectiveRoot;
8155
8654
  }
8655
+ #viewState;
8156
8656
  #preview;
8157
8657
  #lazy;
8158
8658
  /** Labels shown in the last render — what the child preview judges legibility by. */
@@ -8260,46 +8760,30 @@ let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitEleme
8260
8760
  this.#unregisterSearchCommands?.();
8261
8761
  this.#unregisterSearchCommands = registerListSearchCommands(this.#ownedRegistry, this.search);
8262
8762
  }
8763
+ willUpdate(changedProps) {
8764
+ if (changedProps.has(`root`) && this.root || changedProps.has(`model`)) this.#onDataReset();
8765
+ this.#viewState.reconcileIfNeeded(this._focusId !== void 0 && !findHyperbolicPathById(this.#effectiveRoot, this._focusId));
8766
+ }
8263
8767
  updated(changedProps) {
8264
- if (changedProps.has(`root`) && this.root) this.#resetViewState();
8265
8768
  if (changedProps.has(`model`)) {
8266
8769
  this.#modelUnsubscribe?.();
8267
8770
  this.#modelUnsubscribe = void 0;
8268
- if (this.model) {
8269
- this.#modelUnsubscribe = this.model._subscribe((event) => {
8270
- if (event.type === `root`) {
8271
- this.#resetViewState();
8272
- this.requestUpdate();
8273
- } else if (event.type === `invalidate`) this.requestUpdate();
8274
- });
8275
- this.#resetViewState();
8276
- }
8771
+ if (this.model) this.#modelUnsubscribe = this.model._subscribe((event) => {
8772
+ if (event.type === `root`) this.#onDataReset();
8773
+ else if (event.type === `invalidate`) this.#viewState.invalidate();
8774
+ this.requestUpdate();
8775
+ });
8277
8776
  }
8278
- if (changedProps.has(`interactionMode`)) this.clearSelection();
8777
+ if (changedProps.has(`interactionMode`) && changedProps.get(`interactionMode`) !== void 0) this.clearSelection();
8279
8778
  if (changedProps.has(`searchModes`)) this.#syncSearchCommands();
8280
- if (this._focusId !== void 0 && !findHyperbolicPathById(this.#effectiveRoot, this._focusId)) {
8281
- this._focusId = void 0;
8282
- this._viewOverride = void 0;
8283
- this._focusRotation = 0;
8284
- }
8285
8779
  this.#lazy.ensure(this.#layout().nodes);
8780
+ this.#viewState.continueRestoreLoads();
8286
8781
  }
8287
- #resetViewState() {
8288
- this.#clearSelectionInternal();
8289
- this.tickled.clearTickled();
8290
- this._focusId = void 0;
8291
- this._viewOverride = void 0;
8292
- this._focusRotation = 0;
8293
- this._zoomOffset = {
8294
- x: 0,
8295
- y: 0
8296
- };
8782
+ /** New `root` / `model`: drop transient state; keyed view state is reconciled, not reset. */
8783
+ #onDataReset() {
8297
8784
  this._previewId = void 0;
8298
8785
  this.#gestures.cancel();
8299
- if (this.#animFrame !== void 0) {
8300
- cancelAnimationFrame(this.#animFrame);
8301
- this.#animFrame = void 0;
8302
- }
8786
+ this.#viewState.invalidate();
8303
8787
  }
8304
8788
  #canSelect(node) {
8305
8789
  if (this.selectionFilter === `none`) return false;
@@ -8312,6 +8796,7 @@ let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitEleme
8312
8796
  this.selectedNode = void 0;
8313
8797
  }
8314
8798
  #dispatchSelect(previous) {
8799
+ this.#viewState.selectionChanged();
8315
8800
  this.dispatchEvent(new CustomEvent(`select`, {
8316
8801
  detail: {
8317
8802
  selected: new Set(this._selectedNodes),
@@ -8339,6 +8824,7 @@ let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitEleme
8339
8824
  this.requestUpdate();
8340
8825
  }
8341
8826
  clearSelection() {
8827
+ this.#viewState.clearPendingSelection();
8342
8828
  if (this._selectedNodes.size === 0) return;
8343
8829
  const prev = new Set(this._selectedNodes);
8344
8830
  this.#clearSelectionInternal();
@@ -8384,6 +8870,7 @@ let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitEleme
8384
8870
  const previousNode = currentId === void 0 ? void 0 : findHyperbolicPathById(root, currentId)?.at(-1);
8385
8871
  const fromView = this.#viewFromFrameOf(targetId, currentId);
8386
8872
  this._focusId = targetId;
8873
+ this.#viewState.centred(targetId);
8387
8874
  this.#gestures.cancel();
8388
8875
  this._previewId = void 0;
8389
8876
  this.#animateTo(fromView);
@@ -8452,7 +8939,7 @@ let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitEleme
8452
8939
  #focusAndMaybeSelect(node) {
8453
8940
  this.focusOn(node);
8454
8941
  const id = this.#idOf(node);
8455
- if (id !== void 0) this.tickled.setTickled(id);
8942
+ if (id !== void 0) this.#setKeyboardCursor(id, node);
8456
8943
  if (this.interactionMode === `implicit`) this.select(node);
8457
8944
  }
8458
8945
  /** Focus + select the node at `keys` (path from the root's children). Returns `true` if found. */
@@ -8490,10 +8977,15 @@ let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitEleme
8490
8977
  const id = node ? this.#idOf(node) : void 0;
8491
8978
  if (!node || id === void 0) return false;
8492
8979
  if (!this.#layout().nodes.some((n) => n.id === id)) this.focusOn(node);
8493
- this.tickled.setTickled(id);
8980
+ this.#setKeyboardCursor(id, node);
8494
8981
  this.requestUpdate();
8495
8982
  return true;
8496
8983
  }
8984
+ /** Move the cursor as keyboard / search navigation does — recorded as `focus` in the view state. */
8985
+ #setKeyboardCursor(id, node) {
8986
+ this.tickled.setTickled(id);
8987
+ this.#viewState.keyboardCursorMoved(id, node.item.key);
8988
+ }
8497
8989
  /**
8498
8990
  * All nodes currently laid out on the disk (within `maxRadius` of the
8499
8991
  * focus), in render (ring-major) order. Unlike `ixfx-icicletree-list`, the
@@ -8506,6 +8998,49 @@ let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitEleme
8506
8998
  depth: n.depth
8507
8999
  }));
8508
9000
  }
9001
+ /**
9002
+ * Snapshot selection, centred node, keyboard cursor and zoom as plain JSON,
9003
+ * keyed by `item.key`. Includes pending state for keys not currently loaded.
9004
+ */
9005
+ getViewState(options) {
9006
+ return this.#viewState.get(options);
9007
+ }
9008
+ /**
9009
+ * Apply saved view state. Fields present replace the current ones; keys
9010
+ * that aren't loaded yet are applied as they appear — a deep `center` loads
9011
+ * its ancestors via `loadChildren` / `model` when `restoreLoads`.
9012
+ */
9013
+ setViewState(state) {
9014
+ this.#viewState.set(state);
9015
+ this.requestUpdate();
9016
+ }
9017
+ clearPendingViewState() {
9018
+ this.#viewState.clearPending();
9019
+ }
9020
+ /** Centre set by view-state reconcile: snaps (no animation); `restored` fires `focus`. */
9021
+ #restoreCenter(id, restored) {
9022
+ const previous = this.focusedNode;
9023
+ this.#cancelAnimation();
9024
+ this._focusId = id;
9025
+ this._viewOverride = void 0;
9026
+ this._zoomOffset = {
9027
+ x: 0,
9028
+ y: 0
9029
+ };
9030
+ if (!restored) {
9031
+ this._focusRotation = 0;
9032
+ return;
9033
+ }
9034
+ const node = this.focusedNode;
9035
+ if (node) this.dispatchEvent(new CustomEvent(`focus`, {
9036
+ detail: {
9037
+ node,
9038
+ previous
9039
+ },
9040
+ bubbles: true,
9041
+ composed: true
9042
+ }));
9043
+ }
8509
9044
  #matchesFilter(node, depth) {
8510
9045
  if (!this.filterPredicate) return true;
8511
9046
  if (this.filterPredicate(node, depth)) return true;
@@ -8791,6 +9326,7 @@ __decorate([property({
8791
9326
  reflect: true
8792
9327
  })], HyperbolicTreeListElement.prototype, "searchModes", void 0);
8793
9328
  __decorate([state()], HyperbolicTreeListElement.prototype, "_focusId", void 0);
9329
+ __decorate([property({ attribute: false })], HyperbolicTreeListElement.prototype, "restoreLoads", void 0);
8794
9330
  __decorate([state()], HyperbolicTreeListElement.prototype, "_viewOverride", void 0);
8795
9331
  __decorate([state()], HyperbolicTreeListElement.prototype, "_focusRotation", void 0);
8796
9332
  __decorate([state()], HyperbolicTreeListElement.prototype, "_zoomOffset", void 0);
@@ -8825,6 +9361,30 @@ function findIciclePath(root, key) {
8825
9361
  }
8826
9362
  }
8827
9363
  /**
9364
+ * Follow `keys` from `root`; a leading key equal to the root's own key
9365
+ * matches the root itself. Returns `undefined` when a key isn't found.
9366
+ */
9367
+ function resolveIcicleKeyPath(root, keys) {
9368
+ let current = root;
9369
+ for (const key of keys) {
9370
+ const found = key === current?.item.key ? current : current?.children?.find((n) => n.item.key === key);
9371
+ if (!found) return void 0;
9372
+ current = found;
9373
+ }
9374
+ return current;
9375
+ }
9376
+ /** Key path (depth-first) to the first node among `nodes` matching `predicate`. */
9377
+ function findIcicleKeyPath(nodes, predicate, prefix = []) {
9378
+ for (const node of nodes) {
9379
+ const path = [...prefix, node.item.key];
9380
+ if (predicate(node)) return path;
9381
+ if (node.children?.length) {
9382
+ const hit = findIcicleKeyPath(node.children, predicate, path);
9383
+ if (hit) return hit;
9384
+ }
9385
+ }
9386
+ }
9387
+ /**
8828
9388
  * Compute the visible rectangles for an icicle view.
8829
9389
  *
8830
9390
  * Bands start at the zoom node's **children** (the zoom node itself is shown in
@@ -8878,6 +9438,259 @@ function computeIcicleLayout(root, options = {}) {
8878
9438
  return rects;
8879
9439
  }
8880
9440
  //#endregion
9441
+ //#region src/icicle-tree/icicle-view-state.ts
9442
+ /**
9443
+ * - `found` — every key matched: `nodes[i]` is the node for `keys[i]`.
9444
+ * - `load` — `node` (at `depth`; root's children are `0`) must load its
9445
+ * children before the path can continue.
9446
+ * - `missing` — a key isn't there (yet), or the next step can't load.
9447
+ */
9448
+ /** First node (depth-first) for each key in the loaded tree. */
9449
+ function indexNodesByKey(root) {
9450
+ const out = /* @__PURE__ */ new Map();
9451
+ const visit = (node) => {
9452
+ if (!out.has(node.item.key)) out.set(node.item.key, node);
9453
+ for (const child of node.children ?? []) visit(child);
9454
+ };
9455
+ if (root) visit(root);
9456
+ return out;
9457
+ }
9458
+ /**
9459
+ * Nodes along a pending zoom path (`[]` = full tree), or `undefined` while it
9460
+ * can't resolve. Keys are unique tree-wide, so a zoom node that has moved is
9461
+ * still found by its key.
9462
+ */
9463
+ function resolveZoomPath$1(root, path, nodeByKey) {
9464
+ const found = resolveKeyPath(root, path, () => false);
9465
+ if (found.status === `found`) return found.nodes;
9466
+ const key = path.at(-1);
9467
+ return key !== void 0 && nodeByKey(key) ? findIciclePath(root, key)?.slice(1) : void 0;
9468
+ }
9469
+ /** Pixel scroll of a scrolling host. Rects aren't a linear list, so there's no anchor. */
9470
+ function captureHostScroll(el) {
9471
+ return el.scrollLeft === 0 ? { top: el.scrollTop } : {
9472
+ top: el.scrollTop,
9473
+ left: el.scrollLeft
9474
+ };
9475
+ }
9476
+ /** Returns `true` once the saved position is fully reached (else retry after the next render). */
9477
+ function applyHostScroll(el, scroll) {
9478
+ const top = scrollToOffset(el, scroll.top);
9479
+ const left = scroll.left === void 0 || scrollToOffset(el, scroll.left, `horizontal`);
9480
+ return top && left;
9481
+ }
9482
+ //#endregion
9483
+ //#region src/icicle-tree/keyboard.ts
9484
+ /**
9485
+ * Rect the cursor moves to for `key`, or `undefined` when the key isn't a
9486
+ * navigation key or there's nowhere to go. With no current rect, any
9487
+ * navigation key lands on the first rect.
9488
+ */
9489
+ function icicleKeyTarget(rects, current, key) {
9490
+ if (rects.length === 0) return void 0;
9491
+ if (key === `Home`) return rects[0];
9492
+ if (key === `End`) return rects.at(-1);
9493
+ if (![
9494
+ `ArrowLeft`,
9495
+ `ArrowRight`,
9496
+ `ArrowUp`,
9497
+ `ArrowDown`
9498
+ ].includes(key)) return void 0;
9499
+ if (!current) return rects[0];
9500
+ switch (key) {
9501
+ case `ArrowLeft`:
9502
+ case `ArrowRight`: {
9503
+ const dir = key === `ArrowRight` ? 1 : -1;
9504
+ const siblings = rects.filter((r) => r.relativeDepth === current.relativeDepth && r.parentKey === current.parentKey);
9505
+ return siblings[siblings.findIndex((r) => r.node.item.key === current.node.item.key) + dir] ?? (dir > 0 ? siblings[0] : siblings.at(-1));
9506
+ }
9507
+ case `ArrowUp`: return (current.parentKey === void 0 ? void 0 : rects.find((r) => r.node.item.key === current.parentKey)) ?? rects[0];
9508
+ default: return rects.find((r) => r.parentKey === current.node.item.key);
9509
+ }
9510
+ }
9511
+ //#endregion
9512
+ //#region src/icicle-tree/styles.ts
9513
+ /** Shadow-DOM styles for `ixfx-icicletree-list`. */
9514
+ const icicleTreeStyles = css`
9515
+ :host {
9516
+ --icicletree-band-height: 44px;
9517
+ --icicletree-band-gap: 2px;
9518
+ --icicletree-rect-gap: 2px;
9519
+ --icicletree-rect-bg: var(--surface-4);
9520
+ --icicletree-rect-text: var(--surface-4-text);
9521
+ --icicletree-font-size: var(--text-s);
9522
+
9523
+ display: block;
9524
+ overflow: auto;
9525
+ }
9526
+
9527
+ .icicle {
9528
+ display: flex;
9529
+ gap: var(--icicletree-band-gap);
9530
+ outline: none;
9531
+ }
9532
+
9533
+ .icicle.horizontal {
9534
+ flex-direction: column;
9535
+ }
9536
+
9537
+ .icicle.vertical {
9538
+ flex-direction: row;
9539
+ min-height: 200px;
9540
+ }
9541
+
9542
+ .breadcrumb {
9543
+ display: flex;
9544
+ align-items: center;
9545
+ padding: var(--space-xs) var(--space-s);
9546
+ background: var(--surface-3);
9547
+ color: var(--surface-3-text);
9548
+ border: var(--border-subtle);
9549
+ border-radius: var(--radius-m);
9550
+ font-size: var(--text-s);
9551
+ flex-shrink: 0;
9552
+ }
9553
+
9554
+ .breadcrumb ixfx-crumb-navigation {
9555
+ flex: 1;
9556
+ min-width: 0;
9557
+ }
9558
+
9559
+ .band {
9560
+ position: relative;
9561
+ }
9562
+
9563
+ .icicle.horizontal .band {
9564
+ height: var(--icicletree-band-height);
9565
+ }
9566
+
9567
+ .icicle.vertical .band {
9568
+ flex: 1 1 0;
9569
+ min-width: 0;
9570
+ height: 100%;
9571
+ }
9572
+
9573
+ .rect {
9574
+ box-sizing: border-box;
9575
+ position: absolute;
9576
+ overflow: hidden;
9577
+ display: flex;
9578
+ align-items: center;
9579
+ padding: 0 var(--space-s);
9580
+ background: var(--icicletree-rect-bg);
9581
+ color: var(--icicletree-rect-text);
9582
+ font-size: var(--icicletree-font-size);
9583
+ cursor: pointer;
9584
+ transition:
9585
+ width var(--transition),
9586
+ height var(--transition),
9587
+ left var(--transition),
9588
+ top var(--transition),
9589
+ opacity var(--transition),
9590
+ background-color var(--transition);
9591
+ }
9592
+
9593
+ .icicle.horizontal .rect {
9594
+ top: 0;
9595
+ height: 100%;
9596
+ }
9597
+
9598
+ /*
9599
+ * A rotated label needs its full allotted width to be usable — the
9600
+ * default left/right padding (--space-s, ~4px a side) can consume
9601
+ * most or all of a rect sized down to minVerticalLabelWidth, leaving
9602
+ * the rotated text clipped to invisible. #labelMode's width estimate
9603
+ * assumes the whole rect width is usable, so drop the padding here to
9604
+ * match that assumption instead of also budgeting for it in the
9605
+ * threshold (which would make already-narrow rects need even more).
9606
+ */
9607
+ .icicle.horizontal .rect.rotated {
9608
+ padding: 0;
9609
+ }
9610
+
9611
+ .icicle.vertical .rect {
9612
+ left: 0;
9613
+ width: 100%;
9614
+ padding: var(--space-s) 0;
9615
+ justify-content: center;
9616
+ writing-mode: horizontal-tb;
9617
+ }
9618
+
9619
+ .rect.filtered {
9620
+ opacity: 0.3;
9621
+ filter: saturate(0.4);
9622
+ }
9623
+
9624
+ .rect.empty {
9625
+ padding: 0;
9626
+ border: none;
9627
+ }
9628
+
9629
+ .rect.loading {
9630
+ cursor: progress;
9631
+ opacity: 0.6;
9632
+ }
9633
+
9634
+ .icicle-label {
9635
+ overflow: hidden;
9636
+ text-overflow: ellipsis;
9637
+ white-space: nowrap;
9638
+ flex: 1;
9639
+ min-width: 0;
9640
+ }
9641
+
9642
+ .icicle-label.hide-label {
9643
+ visibility: hidden;
9644
+ }
9645
+
9646
+ .icicle-label.label-vertical {
9647
+ writing-mode: vertical-rl;
9648
+ text-orientation: sideways;
9649
+ flex: none;
9650
+ width: 100%;
9651
+ height: 100%;
9652
+ padding: var(--space-2xs, 2px) 0;
9653
+ box-sizing: border-box;
9654
+ display: flex;
9655
+ align-items: center;
9656
+ justify-content: center;
9657
+ }
9658
+
9659
+ /*
9660
+ * Instant tooltip for rects whose label is rotated or hidden — anchored
9661
+ * via CSS anchor positioning (anchor-name set on the tickled rect in
9662
+ * #renderRect) rather than a JS-computed position, and shown/hidden via
9663
+ * the Popover API rather than the browser's own (slow, un-stylable)
9664
+ * title tooltip.
9665
+ */
9666
+ .icicle-tooltip {
9667
+ position: fixed;
9668
+ position-anchor: --icicle-rect-anchor;
9669
+ position-area: bottom center;
9670
+ position-try-fallbacks: top center;
9671
+ margin: var(--space-xs, 4px) 0 0;
9672
+ padding: var(--space-2xs, 2px) var(--space-xs, 6px);
9673
+ background: var(--surface-5);
9674
+ color: var(--surface-5-text);
9675
+ border: var(--border-subtle);
9676
+ border-radius: var(--radius-s, 4px);
9677
+ font-size: var(--text-s);
9678
+ white-space: nowrap;
9679
+ pointer-events: none;
9680
+ inset: auto;
9681
+ }
9682
+
9683
+ .icicle-tooltip:popover-open {
9684
+ display: block;
9685
+ }
9686
+
9687
+ .empty-state {
9688
+ padding: var(--space-l);
9689
+ color: var(--surface-muted-text);
9690
+ font-size: var(--text-s);
9691
+ }
9692
+ `;
9693
+ //#endregion
8881
9694
  //#region src/icicle-tree/icicletree-list.ts
8882
9695
  let IcicleTreeListElement = class IcicleTreeListElement extends LitElement {
8883
9696
  constructor(..._args) {
@@ -8895,6 +9708,14 @@ let IcicleTreeListElement = class IcicleTreeListElement extends LitElement {
8895
9708
  this.disableKeyboardNavigation = false;
8896
9709
  this.registry = void 0;
8897
9710
  this._zoomKey = void 0;
9711
+ this.restoreLoads = true;
9712
+ this.#viewState = new ViewStateController(this, {
9713
+ kind: `icicletree-list`,
9714
+ capture: () => this.getViewState()
9715
+ });
9716
+ this.#pendingSelected = EMPTY_KEYS;
9717
+ this.#keyboardCursor = false;
9718
+ this.#zoomPath = [];
8898
9719
  this.search = new ListSearchController(this, { adapter: createTreeListSearchAdapter(this, {
8899
9720
  labelSelector: `.icicle-label`,
8900
9721
  highlightKey: `incr-search-highlight`
@@ -8923,6 +9744,9 @@ let IcicleTreeListElement = class IcicleTreeListElement extends LitElement {
8923
9744
  });
8924
9745
  this.#pendingKeys = /* @__PURE__ */ new Set();
8925
9746
  this.#aborters = /* @__PURE__ */ new Map();
9747
+ this.#onScroll = () => {
9748
+ if (!this.#viewState.pendingScroll) this.#viewState.notify();
9749
+ };
8926
9750
  }
8927
9751
  static {
8928
9752
  this.shadowRootOptions = {
@@ -8938,6 +9762,16 @@ let IcicleTreeListElement = class IcicleTreeListElement extends LitElement {
8938
9762
  if (this._zoomKey === void 0) return void 0;
8939
9763
  return findIcicleNode(this.#effectiveRoot, this._zoomKey);
8940
9764
  }
9765
+ #viewState;
9766
+ /** Saved selection for keys not (yet) loaded. */
9767
+ #pendingSelected;
9768
+ /** Saved zoom path not (yet) resolvable. */
9769
+ #pendingZoom;
9770
+ #pendingFocus;
9771
+ /** Whether the cursor was last placed by the keyboard (only then is it `focus`). */
9772
+ #keyboardCursor;
9773
+ /** Last known key path of the zoom node, so it can go pending when the node leaves. */
9774
+ #zoomPath;
8941
9775
  #modelUnsubscribe;
8942
9776
  #pendingKeys;
8943
9777
  #aborters;
@@ -8974,6 +9808,8 @@ let IcicleTreeListElement = class IcicleTreeListElement extends LitElement {
8974
9808
  });
8975
9809
  this.registry = registry;
8976
9810
  this.#syncSearchCommands();
9811
+ this.#viewState.observeScrollTarget(this);
9812
+ this.addEventListener(`scroll`, this.#onScroll, { passive: true });
8977
9813
  if (typeof ResizeObserver !== `undefined`) {
8978
9814
  this.#resizeObserver = new ResizeObserver(() => this.requestUpdate());
8979
9815
  this.#resizeObserver.observe(this);
@@ -8986,6 +9822,7 @@ let IcicleTreeListElement = class IcicleTreeListElement extends LitElement {
8986
9822
  for (const aborter of this.#aborters.values()) aborter.abort();
8987
9823
  this.#aborters.clear();
8988
9824
  this.#pendingKeys.clear();
9825
+ this.removeEventListener(`scroll`, this.#onScroll);
8989
9826
  this.#resizeObserver?.disconnect();
8990
9827
  this.#resizeObserver = void 0;
8991
9828
  if (this.#ownedRegistry) {
@@ -9007,28 +9844,23 @@ let IcicleTreeListElement = class IcicleTreeListElement extends LitElement {
9007
9844
  this.#unregisterSearchCommands?.();
9008
9845
  this.#unregisterSearchCommands = registerListSearchCommands(this.#ownedRegistry, this.search);
9009
9846
  }
9010
- updated(changedProps) {
9011
- if (changedProps.has(`root`) && this.root) this.#resetViewState();
9012
- if (changedProps.has(`model`)) {
9013
- this.#modelUnsubscribe?.();
9014
- this.#modelUnsubscribe = void 0;
9015
- if (this.model) {
9016
- this.#modelUnsubscribe = this.model._subscribe((event) => {
9017
- if (event.type === `root`) {
9018
- this.#resetViewState();
9019
- this.requestUpdate();
9020
- } else if (event.type === `invalidate`) this.requestUpdate();
9021
- });
9022
- this.#resetViewState();
9023
- }
9024
- }
9025
- if (changedProps.has(`interactionMode`)) {
9847
+ willUpdate(changedProps) {
9848
+ if (changedProps.has(`root`) || changedProps.has(`model`)) this.#anchorIndex = void 0;
9849
+ if (changedProps.has(`interactionMode`) && changedProps.get(`interactionMode`) !== void 0) {
9026
9850
  this.#anchorIndex = void 0;
9027
9851
  this.clearSelection();
9028
9852
  }
9853
+ this.#reconcileViewState();
9854
+ }
9855
+ updated(changedProps) {
9856
+ if (changedProps.has(`model`)) {
9857
+ this.#modelUnsubscribe?.();
9858
+ this.#modelUnsubscribe = this.model?._subscribe(() => this.requestUpdate());
9859
+ }
9029
9860
  if (changedProps.has(`searchModes`)) this.#syncSearchCommands();
9030
- if (this._zoomKey !== void 0 && !findIcicleNode(this.#effectiveRoot, this._zoomKey)) this._zoomKey = void 0;
9031
9861
  this.#ensureVisibleChildren();
9862
+ this.#continueRestoreLoads();
9863
+ this.#viewState.applyPendingScroll((scroll) => applyHostScroll(this, scroll));
9032
9864
  this.#syncTooltip();
9033
9865
  }
9034
9866
  /** The host's own (viewport) extent — width in horizontal orientation, height in vertical. */
@@ -9078,12 +9910,103 @@ let IcicleTreeListElement = class IcicleTreeListElement extends LitElement {
9078
9910
  popover.hidePopover();
9079
9911
  } catch {}
9080
9912
  }
9081
- #resetViewState() {
9082
- this.#clearSelectionInternal();
9083
- this.tickled.clearTickled();
9084
- this._zoomKey = void 0;
9085
- this.#anchorIndex = void 0;
9913
+ /**
9914
+ * Snapshot selection, zoom path, keyboard cursor and scroll as plain JSON,
9915
+ * keyed by `item.key`. Includes pending state for keys not currently loaded.
9916
+ */
9917
+ getViewState(options) {
9918
+ const withPending = options?.includePending ?? true;
9919
+ const focus = this.#keyboardCursor ? this.tickled.tickledId : void 0;
9920
+ return this.#viewState.shape({
9921
+ selected: keysToArray([...this._selectedNodes].map((n) => n.item.key), withPending ? this.#pendingSelected : []),
9922
+ zoom: (withPending ? this.#pendingZoom : void 0) ?? (this._zoomKey === void 0 ? [] : [...this.#zoomPath]),
9923
+ focus: focus ?? (withPending ? this.#pendingFocus : void 0),
9924
+ scroll: (withPending ? this.#viewState.pendingScroll : void 0) ?? (this.isConnected ? captureHostScroll(this) : void 0)
9925
+ }, options);
9926
+ }
9927
+ /**
9928
+ * Apply saved view state. Fields present replace the current ones; keys
9929
+ * that aren't loaded yet are applied as they appear — including via
9930
+ * `loadChildren` / `model` down a saved zoom path when `restoreLoads`.
9931
+ */
9932
+ setViewState(state) {
9933
+ if (!this.#viewState.accepts(state)) return;
9934
+ this.#viewState.restoring(() => {
9935
+ if (state.selected) {
9936
+ this.#pendingSelected = capKeys(new Set(state.selected));
9937
+ if (this._selectedNodes.size > 0) {
9938
+ const prev = new Set(this._selectedNodes);
9939
+ this.#clearSelectionInternal();
9940
+ this.#dispatchSelect(prev);
9941
+ }
9942
+ }
9943
+ if (state.zoom) this.#pendingZoom = [...state.zoom];
9944
+ if (state.focus !== void 0) this.#pendingFocus = state.focus;
9945
+ if (state.scroll) this.#viewState.pendingScroll = state.scroll;
9946
+ this.#reconcileViewState();
9947
+ if (this.#pendingZoom !== void 0) this.zoomOut();
9948
+ });
9949
+ this.requestUpdate();
9950
+ }
9951
+ clearPendingViewState() {
9952
+ this.#pendingSelected = EMPTY_KEYS;
9953
+ this.#pendingZoom = void 0;
9954
+ this.#pendingFocus = void 0;
9955
+ this.#viewState.pendingScroll = void 0;
9956
+ }
9957
+ /** Move state for missing keys to pending; apply pending state for keys now loaded. */
9958
+ #reconcileViewState() {
9959
+ if (this._selectedNodes.size === 0 && this.#pendingSelected.size === 0 && this._zoomKey === void 0 && this.#pendingZoom === void 0 && this.tickled.tickledId === void 0 && this.#pendingFocus === void 0) return;
9960
+ const root = this.#effectiveRoot;
9961
+ let index;
9962
+ const nodeByKey = (key) => (index ??= indexNodesByKey(root)).get(key);
9963
+ this.#viewState.restoring(() => {
9964
+ this.#reconcileZoom(root, nodeByKey);
9965
+ const sel = reconcileSelection$1(this._selectedNodes, this.#pendingSelected, nodeByKey, (node) => this.#canSelect(node), this.interactionMode === `implicit`);
9966
+ if (sel.changed) {
9967
+ const prev = this._selectedNodes;
9968
+ const primary = this.selectedNode?.item.key;
9969
+ this._selectedNodes = new Set(sel.selected);
9970
+ this.#pendingSelected = sel.pending;
9971
+ this.selectedNode = [...sel.selected].find((n) => n.item.key === primary) ?? [...sel.selected].at(-1);
9972
+ if (sel.restored) this.#dispatchSelect(prev);
9973
+ else this.#viewState.notify(`data`);
9974
+ }
9975
+ this.#reconcileFocus(nodeByKey);
9976
+ });
9977
+ }
9978
+ #reconcileZoom(root, nodeByKey) {
9979
+ if (this._zoomKey !== void 0 && !nodeByKey(this._zoomKey)) {
9980
+ this.#pendingZoom ??= this.#zoomPath;
9981
+ this._zoomKey = void 0;
9982
+ this.#viewState.notify(`data`);
9983
+ }
9984
+ const nodes = this.#pendingZoom && resolveZoomPath$1(root, this.#pendingZoom, nodeByKey);
9985
+ if (!nodes) return;
9986
+ this.#pendingZoom = void 0;
9987
+ if (nodes.length === 0) this.zoomOut();
9988
+ else this.zoomTo(nodes.at(-1));
9989
+ }
9990
+ #reconcileFocus(nodeByKey) {
9991
+ const tickled = this.tickled.tickledId;
9992
+ if (tickled !== void 0 && !nodeByKey(tickled)) {
9993
+ if (this.#keyboardCursor) this.#pendingFocus ??= tickled;
9994
+ this.tickled.clearTickled();
9995
+ }
9996
+ const focus = this.#pendingFocus;
9997
+ if (focus !== void 0 && this.#rects().some((r) => r.node.item.key === focus)) {
9998
+ this.#pendingFocus = void 0;
9999
+ this.#keyboardCursor = true;
10000
+ this.tickled.setTickled(focus);
10001
+ }
9086
10002
  }
10003
+ /** Load the next unloaded node along a pending zoom path (one load at a time). */
10004
+ #continueRestoreLoads() {
10005
+ if (!this.restoreLoads || this.#pendingZoom === void 0 || !this.model && !this.loadChildren) return;
10006
+ const next = resolveKeyPath(this.#effectiveRoot, this.#pendingZoom, (node) => node.item.isLeaf !== true);
10007
+ if (next.status === `load` && !this.#pendingKeys.has(next.node.item.key)) this.#loadChildren(next.node, next.depth);
10008
+ }
10009
+ #onScroll;
9087
10010
  #ensureVisibleChildren() {
9088
10011
  if (!this.model && !this.loadChildren) return;
9089
10012
  for (const rect of this.#rects()) {
@@ -9127,6 +10050,8 @@ let IcicleTreeListElement = class IcicleTreeListElement extends LitElement {
9127
10050
  this.selectedNode = void 0;
9128
10051
  }
9129
10052
  #dispatchSelect(previous) {
10053
+ if (!this.#viewState.isRestoring) this.#pendingSelected = EMPTY_KEYS;
10054
+ this.#viewState.notify();
9130
10055
  this.dispatchEvent(new CustomEvent(`select`, {
9131
10056
  detail: {
9132
10057
  selected: new Set(this._selectedNodes),
@@ -9154,6 +10079,7 @@ let IcicleTreeListElement = class IcicleTreeListElement extends LitElement {
9154
10079
  this.requestUpdate();
9155
10080
  }
9156
10081
  clearSelection() {
10082
+ this.#pendingSelected = EMPTY_KEYS;
9157
10083
  if (this._selectedNodes.size === 0) return;
9158
10084
  const prev = new Set(this._selectedNodes);
9159
10085
  this.#clearSelectionInternal();
@@ -9190,11 +10116,13 @@ let IcicleTreeListElement = class IcicleTreeListElement extends LitElement {
9190
10116
  * Fires `zoom` and the semantic `expand` event.
9191
10117
  */
9192
10118
  zoomTo(node) {
10119
+ this.#zoomChanged();
9193
10120
  if (this._zoomKey === node.item.key) return;
9194
10121
  this._zoomKey = node.item.key;
9195
10122
  this.tickled.clearTickled();
9196
- const depth = findIciclePath(this.#effectiveRoot, node.item.key)?.length;
9197
- const absoluteDepth = depth === void 0 ? -1 : depth - 2;
10123
+ const path = findIciclePath(this.#effectiveRoot, node.item.key);
10124
+ this.#zoomPath = path?.slice(1).map((n) => n.item.key) ?? [node.item.key];
10125
+ const absoluteDepth = path === void 0 ? -1 : path.length - 2;
9198
10126
  this.dispatchEvent(new CustomEvent(`zoom`, {
9199
10127
  detail: {
9200
10128
  node,
@@ -9218,11 +10146,13 @@ let IcicleTreeListElement = class IcicleTreeListElement extends LitElement {
9218
10146
  * the semantic `collapse` event for the previous zoom root.
9219
10147
  */
9220
10148
  zoomOut() {
10149
+ this.#zoomChanged();
9221
10150
  if (this._zoomKey === void 0) return;
9222
10151
  const previous = this.focusedNode;
9223
10152
  const path = this._zoomKey === void 0 ? void 0 : findIciclePath(this.#effectiveRoot, this._zoomKey);
9224
10153
  const absoluteDepth = path === void 0 ? -1 : path.length - 2;
9225
10154
  this._zoomKey = void 0;
10155
+ this.#zoomPath = [];
9226
10156
  this.tickled.clearTickled();
9227
10157
  this.dispatchEvent(new CustomEvent(`zoom`, {
9228
10158
  detail: {
@@ -9242,6 +10172,11 @@ let IcicleTreeListElement = class IcicleTreeListElement extends LitElement {
9242
10172
  }));
9243
10173
  this.requestUpdate();
9244
10174
  }
10175
+ /** An explicit zoom supersedes a saved one still waiting. */
10176
+ #zoomChanged() {
10177
+ if (!this.#viewState.isRestoring) this.#pendingZoom = void 0;
10178
+ this.#viewState.notify();
10179
+ }
9245
10180
  #canZoom(node) {
9246
10181
  if (node.item.isLeaf === true) return false;
9247
10182
  if (node.children !== void 0) return node.children.length > 0;
@@ -9277,7 +10212,7 @@ let IcicleTreeListElement = class IcicleTreeListElement extends LitElement {
9277
10212
  navigateToNode(predicate) {
9278
10213
  const root = this.#effectiveRoot;
9279
10214
  if (!root?.children) return false;
9280
- const keyPath = this.#findKeyPath(root.children, predicate);
10215
+ const keyPath = findIcicleKeyPath(root.children, predicate);
9281
10216
  if (!keyPath) return false;
9282
10217
  return this.navigateTo(keyPath);
9283
10218
  }
@@ -9288,35 +10223,16 @@ let IcicleTreeListElement = class IcicleTreeListElement extends LitElement {
9288
10223
  revealKey(key) {
9289
10224
  const root = this.#effectiveRoot;
9290
10225
  if (!root?.children) return false;
9291
- const path = this.#findKeyPath(root.children, (n) => n.item.key === key);
10226
+ const path = findIcicleKeyPath(root.children, (n) => n.item.key === key);
9292
10227
  if (!path) return false;
9293
10228
  if (path.length > this.maxDepth) {
9294
- const ancestor = this.#resolveKeyPath(path.slice(0, path.length - this.maxDepth));
10229
+ const ancestor = resolveIcicleKeyPath(this.#effectiveRoot, path.slice(0, path.length - this.maxDepth));
9295
10230
  if (ancestor) this.zoomTo(ancestor);
9296
10231
  }
9297
10232
  this.tickled.setTickled(key);
9298
10233
  this.requestUpdate();
9299
10234
  return true;
9300
10235
  }
9301
- #resolveKeyPath(keys) {
9302
- let current = this.#effectiveRoot;
9303
- for (const key of keys) {
9304
- const found = key === current?.item.key ? current : current?.children?.find((n) => n.item.key === key);
9305
- if (!found) return void 0;
9306
- current = found;
9307
- }
9308
- return current;
9309
- }
9310
- #findKeyPath(nodes, predicate, prefix = []) {
9311
- for (const node of nodes) {
9312
- const path = [...prefix, node.item.key];
9313
- if (predicate(node)) return path;
9314
- if (node.children?.length) {
9315
- const hit = this.#findKeyPath(node.children, predicate, path);
9316
- if (hit) return hit;
9317
- }
9318
- }
9319
- }
9320
10236
  /**
9321
10237
  * All nodes currently visible as rectangles, in render (band-major) order,
9322
10238
  * with absolute depth (root's children are depth 0).
@@ -9393,44 +10309,20 @@ let IcicleTreeListElement = class IcicleTreeListElement extends LitElement {
9393
10309
  const currentIndex = tickledKey === void 0 ? -1 : rects.findIndex((r) => r.node.item.key === tickledKey);
9394
10310
  const current = currentIndex >= 0 ? rects[currentIndex] : void 0;
9395
10311
  const moveTo = (rect) => {
9396
- if (!rect) return;
9397
10312
  event.preventDefault();
9398
10313
  this.tickled.setTickled(rect.node.item.key);
10314
+ this.#keyboardCursor = true;
10315
+ this.#pendingFocus = void 0;
10316
+ this.#viewState.notify();
9399
10317
  if (this.interactionMode === `implicit`) this.select(rect.node);
9400
10318
  };
10319
+ if (event.key === `ArrowLeft` || event.key === `ArrowRight`) event.preventDefault();
10320
+ const target = icicleKeyTarget(rects, current, event.key);
10321
+ if (target) {
10322
+ moveTo(target);
10323
+ return;
10324
+ }
9401
10325
  switch (event.key) {
9402
- case `ArrowLeft`:
9403
- case `ArrowRight`: {
9404
- event.preventDefault();
9405
- const dir = event.key === `ArrowRight` ? 1 : -1;
9406
- if (!current) {
9407
- moveTo(rects[0]);
9408
- return;
9409
- }
9410
- const siblings = rects.filter((r) => r.relativeDepth === current.relativeDepth && r.parentKey === current.parentKey);
9411
- moveTo(siblings[siblings.findIndex((r) => r.node.item.key === current.node.item.key) + dir] ?? (dir > 0 ? siblings[0] : siblings.at(-1)));
9412
- return;
9413
- }
9414
- case `ArrowUp`:
9415
- if (!current) {
9416
- moveTo(rects[0]);
9417
- return;
9418
- }
9419
- moveTo((current.parentKey === void 0 ? void 0 : rects.find((r) => r.node.item.key === current.parentKey)) ?? rects[0]);
9420
- return;
9421
- case `ArrowDown`:
9422
- if (!current) {
9423
- moveTo(rects[0]);
9424
- return;
9425
- }
9426
- moveTo(rects.find((r) => r.parentKey === current.node.item.key));
9427
- return;
9428
- case `Home`:
9429
- moveTo(rects[0]);
9430
- return;
9431
- case `End`:
9432
- moveTo(rects.at(-1));
9433
- return;
9434
10326
  case `Enter`:
9435
10327
  if (!current) return;
9436
10328
  event.preventDefault();
@@ -9480,6 +10372,10 @@ let IcicleTreeListElement = class IcicleTreeListElement extends LitElement {
9480
10372
  if (node.item.key === this.#effectiveRoot?.item.key) this.zoomOut();
9481
10373
  else this.zoomTo(node);
9482
10374
  }
10375
+ #onRectPointerEnter(key) {
10376
+ this.tickled.handlePointerEnter(key);
10377
+ if (this.tickled.tickledId === key) this.#keyboardCursor = false;
10378
+ }
9483
10379
  #renderRect(rect) {
9484
10380
  const { node } = rect;
9485
10381
  const selected = this._selectedNodes.has(node);
@@ -9508,7 +10404,7 @@ let IcicleTreeListElement = class IcicleTreeListElement extends LitElement {
9508
10404
  style=${sizeStyle}
9509
10405
  @click=${(e) => this.#handleItemClick(rect, this.#rects().findIndex((r) => r.node.item.key === node.item.key), e)}
9510
10406
  @dblclick=${() => this.#handleItemDblClick(rect)}
9511
- @pointerenter=${() => this.tickled.handlePointerEnter(node.item.key)}
10407
+ @pointerenter=${() => this.#onRectPointerEnter(node.item.key)}
9512
10408
  >
9513
10409
  <span class="icicle-label ${hideLabel ? `hide-label` : ``} ${rotated ? `label-vertical` : ``}" part="label">${node.item.label}</span>
9514
10410
  </div>
@@ -9553,185 +10449,7 @@ let IcicleTreeListElement = class IcicleTreeListElement extends LitElement {
9553
10449
  tickledStyles,
9554
10450
  tickledItemStyles,
9555
10451
  listSearchStyles,
9556
- css`
9557
- :host {
9558
- --icicletree-band-height: 44px;
9559
- --icicletree-band-gap: 2px;
9560
- --icicletree-rect-gap: 2px;
9561
- --icicletree-rect-bg: var(--surface-4);
9562
- --icicletree-rect-text: var(--surface-4-text);
9563
- --icicletree-font-size: var(--text-s);
9564
-
9565
- display: block;
9566
- overflow: auto;
9567
- }
9568
-
9569
- .icicle {
9570
- display: flex;
9571
- gap: var(--icicletree-band-gap);
9572
- outline: none;
9573
- }
9574
-
9575
- .icicle.horizontal {
9576
- flex-direction: column;
9577
- }
9578
-
9579
- .icicle.vertical {
9580
- flex-direction: row;
9581
- min-height: 200px;
9582
- }
9583
-
9584
- .breadcrumb {
9585
- display: flex;
9586
- align-items: center;
9587
- padding: var(--space-xs) var(--space-s);
9588
- background: var(--surface-3);
9589
- color: var(--surface-3-text);
9590
- border: var(--border-subtle);
9591
- border-radius: var(--radius-m);
9592
- font-size: var(--text-s);
9593
- flex-shrink: 0;
9594
- }
9595
-
9596
- .breadcrumb ixfx-crumb-navigation {
9597
- flex: 1;
9598
- min-width: 0;
9599
- }
9600
-
9601
- .band {
9602
- position: relative;
9603
- }
9604
-
9605
- .icicle.horizontal .band {
9606
- height: var(--icicletree-band-height);
9607
- }
9608
-
9609
- .icicle.vertical .band {
9610
- flex: 1 1 0;
9611
- min-width: 0;
9612
- height: 100%;
9613
- }
9614
-
9615
- .rect {
9616
- box-sizing: border-box;
9617
- position: absolute;
9618
- overflow: hidden;
9619
- display: flex;
9620
- align-items: center;
9621
- padding: 0 var(--space-s);
9622
- background: var(--icicletree-rect-bg);
9623
- color: var(--icicletree-rect-text);
9624
- font-size: var(--icicletree-font-size);
9625
- cursor: pointer;
9626
- transition:
9627
- width var(--transition),
9628
- height var(--transition),
9629
- left var(--transition),
9630
- top var(--transition),
9631
- opacity var(--transition),
9632
- background-color var(--transition);
9633
- }
9634
-
9635
- .icicle.horizontal .rect {
9636
- top: 0;
9637
- height: 100%;
9638
- }
9639
-
9640
- /*
9641
- * A rotated label needs its full allotted width to be usable — the
9642
- * default left/right padding (--space-s, ~4px a side) can consume
9643
- * most or all of a rect sized down to minVerticalLabelWidth, leaving
9644
- * the rotated text clipped to invisible. #labelMode's width estimate
9645
- * assumes the whole rect width is usable, so drop the padding here to
9646
- * match that assumption instead of also budgeting for it in the
9647
- * threshold (which would make already-narrow rects need even more).
9648
- */
9649
- .icicle.horizontal .rect.rotated {
9650
- padding: 0;
9651
- }
9652
-
9653
- .icicle.vertical .rect {
9654
- left: 0;
9655
- width: 100%;
9656
- padding: var(--space-s) 0;
9657
- justify-content: center;
9658
- writing-mode: horizontal-tb;
9659
- }
9660
-
9661
- .rect.filtered {
9662
- opacity: 0.3;
9663
- filter: saturate(0.4);
9664
- }
9665
-
9666
- .rect.empty {
9667
- padding: 0;
9668
- border: none;
9669
- }
9670
-
9671
- .rect.loading {
9672
- cursor: progress;
9673
- opacity: 0.6;
9674
- }
9675
-
9676
- .icicle-label {
9677
- overflow: hidden;
9678
- text-overflow: ellipsis;
9679
- white-space: nowrap;
9680
- flex: 1;
9681
- min-width: 0;
9682
- }
9683
-
9684
- .icicle-label.hide-label {
9685
- visibility: hidden;
9686
- }
9687
-
9688
- .icicle-label.label-vertical {
9689
- writing-mode: vertical-rl;
9690
- text-orientation: sideways;
9691
- flex: none;
9692
- width: 100%;
9693
- height: 100%;
9694
- padding: var(--space-2xs, 2px) 0;
9695
- box-sizing: border-box;
9696
- display: flex;
9697
- align-items: center;
9698
- justify-content: center;
9699
- }
9700
-
9701
- /*
9702
- * Instant tooltip for rects whose label is rotated or hidden — anchored
9703
- * via CSS anchor positioning (anchor-name set on the tickled rect in
9704
- * #renderRect) rather than a JS-computed position, and shown/hidden via
9705
- * the Popover API rather than the browser's own (slow, un-stylable)
9706
- * title tooltip.
9707
- */
9708
- .icicle-tooltip {
9709
- position: fixed;
9710
- position-anchor: --icicle-rect-anchor;
9711
- position-area: bottom center;
9712
- position-try-fallbacks: top center;
9713
- margin: var(--space-xs, 4px) 0 0;
9714
- padding: var(--space-2xs, 2px) var(--space-xs, 6px);
9715
- background: var(--surface-5);
9716
- color: var(--surface-5-text);
9717
- border: var(--border-subtle);
9718
- border-radius: var(--radius-s, 4px);
9719
- font-size: var(--text-s);
9720
- white-space: nowrap;
9721
- pointer-events: none;
9722
- inset: auto;
9723
- }
9724
-
9725
- .icicle-tooltip:popover-open {
9726
- display: block;
9727
- }
9728
-
9729
- .empty-state {
9730
- padding: var(--space-l);
9731
- color: var(--surface-muted-text);
9732
- font-size: var(--text-s);
9733
- }
9734
- `
10452
+ icicleTreeStyles
9735
10453
  ];
9736
10454
  }
9737
10455
  };
@@ -9786,6 +10504,7 @@ __decorate([property({
9786
10504
  reflect: true
9787
10505
  })], IcicleTreeListElement.prototype, "searchModes", void 0);
9788
10506
  __decorate([state()], IcicleTreeListElement.prototype, "_zoomKey", void 0);
10507
+ __decorate([property({ attribute: false })], IcicleTreeListElement.prototype, "restoreLoads", void 0);
9789
10508
  __decorate([query(`#icicle-tooltip`)], IcicleTreeListElement.prototype, "_tooltipEl", void 0);
9790
10509
  IcicleTreeListElement = __decorate([safeCustomElement(`ixfx-icicletree-list`)], IcicleTreeListElement);
9791
10510
  //#endregion
@@ -9811,6 +10530,136 @@ function findLeafCategory(categories, key) {
9811
10530
  return flattenCategories(categories).find((c) => c.key === key);
9812
10531
  }
9813
10532
  //#endregion
10533
+ //#region src/multicol-list/multicol-view-state.ts
10534
+ /**
10535
+ * Pure helpers for `ixfx-multicol-list` view state. The element runs these
10536
+ * when a state is restored, when `categories` change (so saved columns whose
10537
+ * category wasn't known yet can appear) and when a column's items arrive (so
10538
+ * saved selections apply once their keys exist).
10539
+ */
10540
+ const MULTICOL_VIEW_STATE_KIND = `multicol-list`;
10541
+ const isSort = (v) => v === `asc` || v === `desc`;
10542
+ /**
10543
+ * Validate saved columns (they come from storage, so may be malformed).
10544
+ * Entries without a string `id` / `categoryKey` are dropped, as are repeat
10545
+ * ids; bad optional fields fall back to defaults.
10546
+ */
10547
+ function sanitiseColumns(raw) {
10548
+ if (!Array.isArray(raw)) return [];
10549
+ const seen = /* @__PURE__ */ new Set();
10550
+ const out = [];
10551
+ for (const entry of raw) {
10552
+ if (entry === null || typeof entry !== `object`) continue;
10553
+ const e = entry;
10554
+ if (typeof e.id !== `string` || typeof e.categoryKey !== `string` || seen.has(e.id)) continue;
10555
+ seen.add(e.id);
10556
+ out.push({
10557
+ id: e.id,
10558
+ categoryKey: e.categoryKey,
10559
+ sort: isSort(e.sort) ? e.sort : `asc`,
10560
+ grouped: e.grouped === true,
10561
+ ...typeof e.menu === `boolean` ? { menu: e.menu } : {},
10562
+ ...typeof e.width === `number` && Number.isFinite(e.width) ? { width: e.width } : {}
10563
+ });
10564
+ }
10565
+ return out;
10566
+ }
10567
+ /** Split saved columns into those whose category resolves now and those to keep pending. */
10568
+ function partitionColumns(columns, hasCategory) {
10569
+ const ready = [];
10570
+ const pending = [];
10571
+ columns.forEach((state, index) => {
10572
+ if (hasCategory(state.categoryKey)) ready.push(state);
10573
+ else pending.push({
10574
+ state,
10575
+ index
10576
+ });
10577
+ });
10578
+ return {
10579
+ ready,
10580
+ pending
10581
+ };
10582
+ }
10583
+ /** Split pending columns into those whose category now resolves and those still waiting. */
10584
+ function resolvePendingColumns(pending, hasCategory) {
10585
+ if (pending.length === 0) return {
10586
+ ready: [],
10587
+ pending
10588
+ };
10589
+ const ready = pending.filter((p) => hasCategory(p.state.categoryKey));
10590
+ return ready.length === 0 ? {
10591
+ ready,
10592
+ pending
10593
+ } : {
10594
+ ready,
10595
+ pending: pending.filter((p) => !hasCategory(p.state.categoryKey))
10596
+ };
10597
+ }
10598
+ /** Insert pending columns back at their saved positions (clamped), for a snapshot. */
10599
+ function mergeColumns(live, pending) {
10600
+ const out = [...live];
10601
+ for (const p of [...pending].sort((a, b) => a.index - b.index)) out.splice(Math.min(p.index, out.length), 0, p.state);
10602
+ return out;
10603
+ }
10604
+ /**
10605
+ * - selected keys no longer in the data → pending
10606
+ * - pending keys now in the data → selected (appended, in pending order)
10607
+ */
10608
+ function reconcileSelection(selected, pending, has) {
10609
+ const kept = selected.filter(has);
10610
+ const dropped = kept.length === selected.length ? [] : selected.filter((k) => !has(k));
10611
+ const { present, missing } = partitionKeys(pending, has);
10612
+ if (dropped.length === 0 && present.length === 0) return {
10613
+ selected,
10614
+ pending,
10615
+ restored: false,
10616
+ changed: false
10617
+ };
10618
+ const nextPending = dropped.length === 0 ? missing : capKeys(/* @__PURE__ */ new Set([...missing, ...dropped]));
10619
+ const added = present.filter((k) => !kept.includes(k));
10620
+ return {
10621
+ selected: [...kept, ...added],
10622
+ pending: nextPending,
10623
+ restored: added.length > 0,
10624
+ changed: dropped.length > 0 || added.length > 0
10625
+ };
10626
+ }
10627
+ /** JSON array of string keys → capped pending set; non-strings are ignored. */
10628
+ function keysFromJson(raw) {
10629
+ if (!Array.isArray(raw)) return EMPTY_KEYS;
10630
+ return capKeys(new Set(raw.filter((k) => typeof k === `string`)));
10631
+ }
10632
+ /** Whether `raw` looks like a {@link ScrollState}. */
10633
+ function isScrollState(raw) {
10634
+ return raw !== null && typeof raw === `object` && typeof raw.top === `number`;
10635
+ }
10636
+ /** `record` entries as a map, keeping only values `accept` allows. */
10637
+ function mapFromRecord(record, accept) {
10638
+ const out = /* @__PURE__ */ new Map();
10639
+ if (record === null || typeof record !== `object`) return out;
10640
+ for (const [key, value] of Object.entries(record)) {
10641
+ const v = accept(value);
10642
+ if (v !== void 0) out.set(key, v);
10643
+ }
10644
+ return out;
10645
+ }
10646
+ const keyOf = (el) => el.dataset.key;
10647
+ /** Rendered, keyed item rows inside a column's scroll host, in visual order. */
10648
+ function itemRows(host) {
10649
+ return [...host.querySelectorAll(`li[data-key]`)];
10650
+ }
10651
+ function captureColumnScroll(host) {
10652
+ return captureElementScroll(host, itemRows(host), keyOf);
10653
+ }
10654
+ /** Apply `scroll` to a column's host; `true` once the position was fully reached. */
10655
+ function restoreColumnScroll(host, scroll) {
10656
+ const anchor = scroll.anchor;
10657
+ if (!anchor) return scrollToOffset(host, scroll.top);
10658
+ const row = itemRows(host).find((el) => keyOf(el) === anchor.key);
10659
+ if (!row) return false;
10660
+ return restoreElementScroll(host, row, anchor.offset);
10661
+ }
10662
+ //#endregion
9814
10663
  //#region src/multicol-list/types.ts
9815
10664
  const ALL_KEY = `__all__`;
9816
10665
  //#endregion
@@ -9862,6 +10711,7 @@ var MulticolColumnController = class {
9862
10711
  #dataProvider;
9863
10712
  #onSelectionChange;
9864
10713
  #onRequestData;
10714
+ #onScroll;
9865
10715
  #abortController = void 0;
9866
10716
  #host;
9867
10717
  #allRow;
@@ -9869,7 +10719,19 @@ var MulticolColumnController = class {
9869
10719
  #selectedValues = [];
9870
10720
  #allSelected = true;
9871
10721
  #lastItems = [];
10722
+ /** Keys of `#lastItems`; empty until data for the current category arrived. */
10723
+ #itemKeys = EMPTY_KEYS;
10724
+ /** Saved selection keys not in the data (yet). */
10725
+ #pendingSelection = EMPTY_KEYS;
10726
+ /** Saved scroll waiting for its anchor row / enough content. */
10727
+ #pendingScroll;
10728
+ #disposeScrollIntent;
9872
10729
  #syncingSelection = false;
10730
+ /**
10731
+ * Pending `#restoreAfterSync` passes. The lister re-renders new items and
10732
+ * may fire `select` before the sync runs; that isn't the user's doing.
10733
+ */
10734
+ #syncsInFlight = 0;
9873
10735
  #state;
9874
10736
  constructor(options) {
9875
10737
  this.id = options.id;
@@ -9879,6 +10741,7 @@ var MulticolColumnController = class {
9879
10741
  this.#dataProvider = options.dataProvider;
9880
10742
  this.#onSelectionChange = options.onSelectionChange;
9881
10743
  this.#onRequestData = options.onRequestData;
10744
+ this.#onScroll = options.onScroll;
9882
10745
  this.#state = {
9883
10746
  id: options.id,
9884
10747
  categoryKey: options.category.key,
@@ -9954,7 +10817,10 @@ var MulticolColumnController = class {
9954
10817
  grouped: category.defaults?.grouped ?? false,
9955
10818
  menu: category.defaults?.menu ?? true
9956
10819
  };
9957
- this.clearSelection();
10820
+ this.#pendingSelection = EMPTY_KEYS;
10821
+ this.#pendingScroll = void 0;
10822
+ this.#itemKeys = EMPTY_KEYS;
10823
+ this.#setAllSelection(`data`);
9958
10824
  this.#syncGrouping();
9959
10825
  }
9960
10826
  get listerHost() {
@@ -9979,7 +10845,7 @@ var MulticolColumnController = class {
9979
10845
  allRow.appendChild(allLabel);
9980
10846
  const applyAll = () => {
9981
10847
  if (this.#allSelected) return;
9982
- this.#setAllSelection();
10848
+ this.#setAllSelection(`user`);
9983
10849
  };
9984
10850
  allRow.addEventListener(`click`, applyAll);
9985
10851
  allRow.addEventListener(`keydown`, (e) => {
@@ -10000,6 +10866,10 @@ var MulticolColumnController = class {
10000
10866
  this.#syncGrouping();
10001
10867
  this.#syncAllRow();
10002
10868
  host.append(allRow, lister);
10869
+ host.addEventListener(`scroll`, this.#handleScroll, { passive: true });
10870
+ this.#disposeScrollIntent = onUserScrollIntent(host, () => {
10871
+ this.#pendingScroll = void 0;
10872
+ });
10003
10873
  if (this.#lastItems.length > 0) {
10004
10874
  this.#pushItems();
10005
10875
  this.#restoreAfterSync();
@@ -10007,6 +10877,9 @@ var MulticolColumnController = class {
10007
10877
  }
10008
10878
  unmount() {
10009
10879
  this.#abortPending();
10880
+ this.#host?.removeEventListener(`scroll`, this.#handleScroll);
10881
+ this.#disposeScrollIntent?.();
10882
+ this.#disposeScrollIntent = void 0;
10010
10883
  this.#allRow?.remove();
10011
10884
  this.#listerEl?.remove();
10012
10885
  this.#allRow = void 0;
@@ -10029,17 +10902,17 @@ var MulticolColumnController = class {
10029
10902
  }
10030
10903
  #applyItems(items) {
10031
10904
  this.#lastItems = items;
10032
- const valid = this.#selectedValues.filter((v) => items.some((i) => i.key === v));
10033
- const changed = valid.length !== this.#selectedValues.length;
10034
- this.#selectedValues = valid;
10035
- if (valid.length === 0) this.#allSelected = true;
10036
- if (!this.#listerEl) {
10037
- if (changed) this.#onSelectionChange(this.id, valid);
10038
- return;
10905
+ const keys = new Set(items.map((i) => i.key));
10906
+ this.#itemKeys = keys;
10907
+ const r = reconcileSelection(this.#selectedValues, this.#pendingSelection, (k) => keys.has(k));
10908
+ this.#pendingSelection = r.pending;
10909
+ this.#selectedValues = r.selected;
10910
+ this.#allSelected = r.selected.length === 0;
10911
+ if (this.#listerEl) {
10912
+ this.#pushItems();
10913
+ this.#restoreAfterSync();
10039
10914
  }
10040
- this.#pushItems();
10041
- this.#restoreAfterSync();
10042
- if (changed) this.#onSelectionChange(this.id, valid);
10915
+ if (r.changed) this.#onSelectionChange(this.id, r.selected, r.restored ? `restore` : `data`);
10043
10916
  }
10044
10917
  #pushItems() {
10045
10918
  if (!this.#listerEl) return;
@@ -10064,7 +10937,7 @@ var MulticolColumnController = class {
10064
10937
  return this.#listerEl.querySelectorAll(`:scope > .ungrouped-host > *, :scope > ixfx-panel > *`);
10065
10938
  }
10066
10939
  #handleSelect = () => {
10067
- if (this.#syncingSelection) return;
10940
+ if (this.#syncingSelection || this.#syncsInFlight > 0) return;
10068
10941
  const values = [...this.#listerEl?.selectedItems ?? []].map((i) => i.key).filter((k) => k !== ALL_KEY);
10069
10942
  if (values.length === this.#selectedValues.length && values.every((v) => this.#selectedValues.includes(v))) {
10070
10943
  this.#syncAllRow();
@@ -10073,9 +10946,9 @@ var MulticolColumnController = class {
10073
10946
  this.#selectedValues = values;
10074
10947
  this.#allSelected = values.length === 0;
10075
10948
  this.#syncAllRow();
10076
- this.#onSelectionChange(this.id, values);
10949
+ this.#onSelectionChange(this.id, values, `user`);
10077
10950
  };
10078
- #setAllSelection() {
10951
+ #setAllSelection(origin) {
10079
10952
  const previous = this.#selectedValues;
10080
10953
  this.#syncingSelection = true;
10081
10954
  try {
@@ -10086,17 +10959,64 @@ var MulticolColumnController = class {
10086
10959
  this.#syncingSelection = false;
10087
10960
  }
10088
10961
  this.#syncAllRow();
10089
- if (previous.length > 0) this.#onSelectionChange(this.id, []);
10962
+ if (previous.length > 0) this.#onSelectionChange(this.id, [], origin);
10090
10963
  }
10964
+ /** Back to 'All' — an explicit selection change. */
10091
10965
  clearSelection() {
10092
- this.#setAllSelection();
10966
+ this.#setAllSelection(`user`);
10093
10967
  }
10094
10968
  get selectedValues() {
10095
10969
  return this.#selectedValues;
10096
10970
  }
10971
+ get pendingSelection() {
10972
+ return this.#pendingSelection;
10973
+ }
10974
+ /**
10975
+ * Replace the selection with `keys`. Keys in the current data are selected
10976
+ * now; the rest stay pending until items carrying them arrive.
10977
+ */
10978
+ restoreSelection(keys) {
10979
+ const previous = this.#selectedValues;
10980
+ const r = reconcileSelection([], capKeys(keys), (k) => this.#itemKeys.has(k));
10981
+ this.#pendingSelection = r.pending;
10982
+ this.#selectedValues = r.selected;
10983
+ this.#allSelected = r.selected.length === 0;
10984
+ this.#restoreAfterSync();
10985
+ this.#syncAllRow();
10986
+ if (!(previous.length === r.selected.length && previous.every((k) => r.selected.includes(k)))) this.#onSelectionChange(this.id, r.selected, `restore`);
10987
+ }
10988
+ clearPendingSelection() {
10989
+ this.#pendingSelection = EMPTY_KEYS;
10990
+ }
10991
+ get pendingScroll() {
10992
+ return this.#pendingScroll;
10993
+ }
10994
+ /** Current scroll of the column's list; `undefined` while unmounted. */
10995
+ captureScroll() {
10996
+ return this.#host ? captureColumnScroll(this.#host) : void 0;
10997
+ }
10998
+ /** Scroll to `scroll` now if possible, else when enough items have rendered. */
10999
+ restoreScroll(scroll) {
11000
+ this.#pendingScroll = scroll;
11001
+ this.#tryPendingScroll();
11002
+ }
11003
+ clearPendingScroll() {
11004
+ this.#pendingScroll = void 0;
11005
+ }
11006
+ #tryPendingScroll() {
11007
+ const scroll = this.#pendingScroll;
11008
+ if (!scroll || !this.#host) return;
11009
+ if (restoreColumnScroll(this.#host, scroll)) this.#pendingScroll = void 0;
11010
+ }
11011
+ #handleScroll = () => {
11012
+ if (!this.#pendingScroll) this.#onScroll?.(this.id);
11013
+ };
10097
11014
  #restoreAfterSync() {
10098
11015
  if (!this.#listerEl) return;
10099
- this.#listerEl.updateComplete.then(() => {
11016
+ this.#syncsInFlight++;
11017
+ this.#listerEl.updateComplete.finally(() => {
11018
+ this.#syncsInFlight--;
11019
+ }).then(() => {
10100
11020
  if (!this.#listerEl || !this.#host) return;
10101
11021
  this.#syncingSelection = true;
10102
11022
  try {
@@ -10109,6 +11029,7 @@ var MulticolColumnController = class {
10109
11029
  this.#syncingSelection = false;
10110
11030
  }
10111
11031
  this.#syncAllRow();
11032
+ this.#tryPendingScroll();
10112
11033
  });
10113
11034
  }
10114
11035
  #syncAllRow() {
@@ -10140,12 +11061,201 @@ function renderItem(item) {
10140
11061
  }
10141
11062
  return li;
10142
11063
  }
11064
+ /** Shadow styles for `ixfx-multicol-list` (theme fallbacks / handle styles are added by the element). */
11065
+ const multicolListStyles = css`
11066
+ :host {
11067
+ --multicol-list-bg: var(--surface-3);
11068
+ --multicol-list-header-bg: var(--surface-2);
11069
+ --multicol-list-header-text: var(--surface-3-text);
11070
+ --multicol-list-border: var(--border);
11071
+ --multicol-list-min-column-width: ${80}px;
11072
+ --multicol-list-max-column-width: ${480}px;
11073
+ --multicol-list-header-font-size: unset;
11074
+
11075
+ display: block;
11076
+ overflow: hidden;
11077
+ /* A sideways trackpad swipe over the component must never pan it
11078
+ (or the page behind it, or trigger history navigation) */
11079
+ overscroll-behavior-x: none;
11080
+ background: var(--multicol-list-bg);
11081
+ color: var(--surface-3-text);
11082
+ font-family: var(--font-family);
11083
+ }
11084
+ #container {
11085
+ display: flex;
11086
+ flex-direction: row;
11087
+ align-items: stretch;
11088
+ overflow: hidden;
11089
+ overscroll-behavior-x: none;
11090
+ position: relative;
11091
+ height: 100%;
11092
+ }
11093
+ .column {
11094
+ flex-shrink: 0;
11095
+ display: flex;
11096
+ flex-direction: column;
11097
+ border-right: var(--multicol-list-border);
11098
+ min-width: var(--multicol-list-min-column-width);
11099
+ max-width: var(--multicol-list-max-column-width);
11100
+ background: var(--surface-3);
11101
+ }
11102
+ .column-header {
11103
+ display: flex;
11104
+ align-items: center;
11105
+ gap: var(--space-xs);
11106
+ height: 32px;
11107
+ background: var(--multicol-list-header-bg);
11108
+ color: var(--multicol-list-header-text);
11109
+ border-bottom: var(--border);
11110
+ position: relative;
11111
+ user-select: none;
11112
+ min-width: var(--multicol-list-min-column-width);
11113
+ box-sizing: border-box;
11114
+ }
11115
+ .column-header[data-dragging] {
11116
+ opacity: 0.4;
11117
+ }
11118
+ .reorder-handle {
11119
+ cursor: grab;
11120
+ opacity: 0;
11121
+ display: inline-flex;
11122
+ align-items: center;
11123
+ flex-shrink: 0;
11124
+ padding-left: var(--space-xs);
11125
+ touch-action: none;
11126
+ transition: opacity var(--transition, 0.15s ease-out);
11127
+ }
11128
+ .column-header:hover .reorder-handle, .column-header:focus-within .reorder-handle, .column-header[data-dragging] .reorder-handle { opacity: var(--ixfx-handle-opacity, 0.5); }
11129
+ .reorder-handle:active {
11130
+ cursor: grabbing;
11131
+ }
11132
+ .reorder-handle .ixfx-handle {
11133
+ --ixfx-handle-length: 14px;
11134
+ --ixfx-handle-thickness: var(--multicol-list-reorder-handle-width, 6px);
11135
+ --ixfx-handle-dot-size: 3px;
11136
+ }
11137
+ .column-label {
11138
+ flex: 1;
11139
+ overflow: hidden;
11140
+ text-overflow: ellipsis;
11141
+ white-space: nowrap;
11142
+ padding: 0 var(--space-s);
11143
+ font-size: var(--multicol-list-header-font-size);
11144
+ font-weight: 600;
11145
+ background: transparent;
11146
+ border: none;
11147
+ color: inherit;
11148
+ cursor: pointer;
11149
+ text-align: left;
11150
+ min-width: 0;
11151
+ }
11152
+ .column-label:hover {
11153
+ text-decoration: underline;
11154
+ }
11155
+ .burger-btn {
11156
+ flex-shrink: 0;
11157
+ width: 28px;
11158
+ height: 28px;
11159
+ display: flex;
11160
+ align-items: center;
11161
+ justify-content: center;
11162
+ background: transparent;
11163
+ border: none;
11164
+ cursor: pointer;
11165
+ color: var(--surface-3-text);
11166
+ opacity: 0;
11167
+ transition: opacity var(--transition);
11168
+ border-radius: var(--radius-s);
11169
+ }
11170
+ .column:hover .burger-btn,
11171
+ .column-header:focus-within .burger-btn {
11172
+ opacity: 1;
11173
+ }
11174
+ .burger-btn:hover {
11175
+ background: var(--surface-4);
11176
+ }
11177
+ .resize-handle {
11178
+ position: absolute;
11179
+ right: -4px;
11180
+ top: 0;
11181
+ bottom: 0;
11182
+ width: 8px;
11183
+ cursor: col-resize;
11184
+ z-index: 1;
11185
+ display: flex;
11186
+ align-items: stretch;
11187
+ justify-content: center;
11188
+ touch-action: none;
11189
+ }
11190
+ .resize-handle::before {
11191
+ content: '';
11192
+ width: 2px;
11193
+ margin: 2px 0;
11194
+ border-radius: 1px;
11195
+ background: transparent;
11196
+ transition: background-color var(--transition);
11197
+ }
11198
+ .resize-handle:hover::before,
11199
+ .resize-handle[data-active]::before {
11200
+ background: var(--accent);
11201
+ }
11202
+ .lister-host {
11203
+ flex: 1;
11204
+ overflow-y: auto;
11205
+ overflow-x: hidden;
11206
+ /* Vertical scrolling only: horizontal deltas are swallowed here
11207
+ instead of chaining up and panning the whole component */
11208
+ overscroll-behavior-x: none;
11209
+ touch-action: pan-y;
11210
+ position: relative;
11211
+ display: flex;
11212
+ flex-direction: column;
11213
+ --list-gutter: 0;
11214
+ }
11215
+ .all-row {
11216
+ display: flex;
11217
+ align-items: center;
11218
+ padding: var(--space-xs) var(--space-s);
11219
+ cursor: pointer;
11220
+ user-select: none;
11221
+ min-height: 28px;
11222
+ box-sizing: border-box;
11223
+ outline: 1px solid transparent;
11224
+ background: var(--surface-3);
11225
+ }
11226
+ .all-row:hover,
11227
+ .all-row[data-selected] {
11228
+ background: var(--surface-5);
11229
+ }
11230
+ .all-row[data-selected] {
11231
+ outline-color: var(--accent);
11232
+ font-weight: 600;
11233
+ }
11234
+ ixfx-grouped-item-lister {
11235
+ display: block;
11236
+ }
11237
+ .drop-caret {
11238
+ position: absolute;
11239
+ top: 0;
11240
+ bottom: 0;
11241
+ width: 2px;
11242
+ background: var(--accent);
11243
+ border-radius: 1px;
11244
+ pointer-events: none;
11245
+ z-index: 10;
11246
+ }
11247
+ .item-label {
11248
+ flex: 1;
11249
+ }
11250
+ .count {
11251
+ opacity: 0.6;
11252
+ margin-left: var(--space-s);
11253
+ font-size: var(--text-s);
11254
+ }
11255
+ `;
10143
11256
  //#endregion
10144
11257
  //#region src/multicol-list/multicol-list.ts
10145
11258
  registerIcon(`menu`, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/></svg>`);
10146
- const DEFAULT_COLUMN_WIDTH = 220;
10147
- const MIN_COLUMN_WIDTH = 80;
10148
- const MAX_COLUMN_WIDTH = 480;
10149
11259
  const DRAG_THRESHOLD = 5;
10150
11260
  let MulticolListElement = class MulticolListElement extends LitElement {
10151
11261
  constructor(..._args) {
@@ -10170,240 +11280,33 @@ let MulticolListElement = class MulticolListElement extends LitElement {
10170
11280
  ghost: null,
10171
11281
  insert: -1
10172
11282
  };
11283
+ this.#viewState = new ViewStateController(this, {
11284
+ kind: MULTICOL_VIEW_STATE_KIND,
11285
+ capture: () => this.getViewState()
11286
+ });
11287
+ this.#pendingColumns = [];
11288
+ this.#pendingSelected = /* @__PURE__ */ new Map();
11289
+ this.#pendingScroll = /* @__PURE__ */ new Map();
10173
11290
  }
10174
11291
  static {
10175
11292
  this.styles = [
10176
11293
  themeFallbacks,
10177
11294
  handleStyles,
10178
- css`
10179
- :host {
10180
- --multicol-list-bg: var(--surface-3);
10181
- --multicol-list-header-bg: var(--surface-2);
10182
- --multicol-list-header-text: var(--surface-3-text);
10183
- --multicol-list-border: var(--border);
10184
- --multicol-list-min-column-width: ${MIN_COLUMN_WIDTH}px;
10185
- --multicol-list-max-column-width: ${MAX_COLUMN_WIDTH}px;
10186
- --multicol-list-header-font-size: unset;
10187
-
10188
- display: block;
10189
- overflow: hidden;
10190
- /* A sideways trackpad swipe over the component must never pan it
10191
- (or the page behind it, or trigger history navigation) */
10192
- overscroll-behavior-x: none;
10193
- background: var(--multicol-list-bg);
10194
- color: var(--surface-3-text);
10195
- font-family: var(--font-family);
10196
- }
10197
- #container {
10198
- display: flex;
10199
- flex-direction: row;
10200
- align-items: stretch;
10201
- overflow: hidden;
10202
- overscroll-behavior-x: none;
10203
- position: relative;
10204
- height: 100%;
10205
- }
10206
- .column {
10207
- flex-shrink: 0;
10208
- display: flex;
10209
- flex-direction: column;
10210
- border-right: var(--multicol-list-border);
10211
- min-width: var(--multicol-list-min-column-width);
10212
- max-width: var(--multicol-list-max-column-width);
10213
- background: var(--surface-3);
10214
- }
10215
- .column-header {
10216
- display: flex;
10217
- align-items: center;
10218
- gap: var(--space-xs);
10219
- height: 32px;
10220
- background: var(--multicol-list-header-bg);
10221
- color: var(--multicol-list-header-text);
10222
- border-bottom: var(--border);
10223
- position: relative;
10224
- user-select: none;
10225
- min-width: var(--multicol-list-min-column-width);
10226
- box-sizing: border-box;
10227
- }
10228
- .column-header[data-dragging] {
10229
- opacity: 0.4;
10230
- }
10231
- .reorder-handle {
10232
- cursor: grab;
10233
- opacity: 0;
10234
- display: inline-flex;
10235
- align-items: center;
10236
- flex-shrink: 0;
10237
- padding-left: var(--space-xs);
10238
- touch-action: none;
10239
- transition: opacity var(--transition, 0.15s ease-out);
10240
- }
10241
- .column-header:hover .reorder-handle, .column-header:focus-within .reorder-handle, .column-header[data-dragging] .reorder-handle { opacity: var(--ixfx-handle-opacity, 0.5); }
10242
- .reorder-handle:active {
10243
- cursor: grabbing;
10244
- }
10245
- .reorder-handle .ixfx-handle {
10246
- --ixfx-handle-length: 14px;
10247
- --ixfx-handle-thickness: var(--multicol-list-reorder-handle-width, 6px);
10248
- --ixfx-handle-dot-size: 3px;
10249
- }
10250
- .column-label {
10251
- flex: 1;
10252
- overflow: hidden;
10253
- text-overflow: ellipsis;
10254
- white-space: nowrap;
10255
- padding: 0 var(--space-s);
10256
- font-size: var(--multicol-list-header-font-size);
10257
- font-weight: 600;
10258
- background: transparent;
10259
- border: none;
10260
- color: inherit;
10261
- cursor: pointer;
10262
- text-align: left;
10263
- min-width: 0;
10264
- }
10265
- .column-label:hover {
10266
- text-decoration: underline;
10267
- }
10268
- .burger-btn {
10269
- flex-shrink: 0;
10270
- width: 28px;
10271
- height: 28px;
10272
- display: flex;
10273
- align-items: center;
10274
- justify-content: center;
10275
- background: transparent;
10276
- border: none;
10277
- cursor: pointer;
10278
- color: var(--surface-3-text);
10279
- opacity: 0;
10280
- transition: opacity var(--transition);
10281
- border-radius: var(--radius-s);
10282
- }
10283
- .column:hover .burger-btn,
10284
- .column-header:focus-within .burger-btn {
10285
- opacity: 1;
10286
- }
10287
- .burger-btn:hover {
10288
- background: var(--surface-4);
10289
- }
10290
- .resize-handle {
10291
- position: absolute;
10292
- right: -4px;
10293
- top: 0;
10294
- bottom: 0;
10295
- width: 8px;
10296
- cursor: col-resize;
10297
- z-index: 1;
10298
- display: flex;
10299
- align-items: stretch;
10300
- justify-content: center;
10301
- touch-action: none;
10302
- }
10303
- .resize-handle::before {
10304
- content: '';
10305
- width: 2px;
10306
- margin: 2px 0;
10307
- border-radius: 1px;
10308
- background: transparent;
10309
- transition: background-color var(--transition);
10310
- }
10311
- .resize-handle:hover::before,
10312
- .resize-handle[data-active]::before {
10313
- background: var(--accent);
10314
- }
10315
- .lister-host {
10316
- flex: 1;
10317
- overflow-y: auto;
10318
- overflow-x: hidden;
10319
- /* Vertical scrolling only: horizontal deltas are swallowed here
10320
- instead of chaining up and panning the whole component */
10321
- overscroll-behavior-x: none;
10322
- touch-action: pan-y;
10323
- position: relative;
10324
- display: flex;
10325
- flex-direction: column;
10326
- --list-gutter: 0;
10327
- }
10328
- .all-row {
10329
- display: flex;
10330
- align-items: center;
10331
- padding: var(--space-xs) var(--space-s);
10332
- cursor: pointer;
10333
- user-select: none;
10334
- min-height: 28px;
10335
- box-sizing: border-box;
10336
- outline: 1px solid transparent;
10337
- background: var(--surface-3);
10338
- }
10339
- .all-row:hover,
10340
- .all-row[data-selected] {
10341
- background: var(--surface-5);
10342
- }
10343
- .all-row[data-selected] {
10344
- outline-color: var(--accent);
10345
- font-weight: 600;
10346
- }
10347
- ixfx-grouped-item-lister {
10348
- display: block;
10349
- }
10350
- .drop-caret {
10351
- position: absolute;
10352
- top: 0;
10353
- bottom: 0;
10354
- width: 2px;
10355
- background: var(--accent);
10356
- border-radius: 1px;
10357
- pointer-events: none;
10358
- z-index: 10;
10359
- }
10360
- .item-label {
10361
- flex: 1;
10362
- }
10363
- .count {
10364
- opacity: 0.6;
10365
- margin-left: var(--space-s);
10366
- font-size: var(--text-s);
10367
- }
10368
- `
11295
+ multicolListStyles
10369
11296
  ];
10370
11297
  }
10371
- get columns() {
10372
- return this._columns.map((c) => c.getState());
10373
- }
10374
- setColumns(states) {
10375
- const existingMap = new Map(this._columns.map((c) => [c.id, c]));
10376
- const newColumns = [];
10377
- for (const colState of states) {
10378
- const existing = existingMap.get(colState.id);
10379
- if (existing) {
10380
- if (existing.categoryKey !== colState.categoryKey) {
10381
- const newCategory = findLeafCategory(this.categories, colState.categoryKey);
10382
- if (newCategory) existing.setCategory(newCategory);
10383
- }
10384
- existing.updateState(colState);
10385
- newColumns.push(existing);
10386
- existingMap.delete(colState.id);
10387
- continue;
10388
- }
10389
- const category = findLeafCategory(this.categories, colState.categoryKey);
10390
- if (!category) continue;
10391
- const controller = this.#createController(colState.id, category);
10392
- controller.updateState(colState);
10393
- newColumns.push(controller);
10394
- }
10395
- for (const col of existingMap.values()) col.unmount();
10396
- this._columns = newColumns;
10397
- this._updateColOffsets();
10398
- this._emitColumnsChange(`add`);
10399
- this._requeryAllColumns();
10400
- this.requestUpdate();
10401
- }
11298
+ #viewState;
11299
+ /** Saved columns whose category isn't in `categories` (yet). */
11300
+ #pendingColumns;
11301
+ /** Saved selection / scroll for column ids that don't exist (yet). */
11302
+ #pendingSelected;
11303
+ #pendingScroll;
10402
11304
  addColumn(categoryKey, position) {
10403
11305
  if (this.maxColumns > 0 && this._columns.length >= this.maxColumns) return;
10404
11306
  const category = findLeafCategory(this.categories, categoryKey);
10405
11307
  if (!category) return;
10406
- const id = `col-${++this._idCounter}`;
11308
+ let id = `col-${++this._idCounter}`;
11309
+ while (this._columns.some((c) => c.id === id) || this.#pendingColumns.some((p) => p.state.id === id)) id = `col-${++this._idCounter}`;
10407
11310
  const controller = this.#createController(id, category);
10408
11311
  if (position !== void 0 && position >= 0 && position < this._columns.length) this._columns.splice(position, 0, controller);
10409
11312
  else this._columns.push(controller);
@@ -10431,9 +11334,15 @@ let MulticolListElement = class MulticolListElement extends LitElement {
10431
11334
  }));
10432
11335
  }
10433
11336
  clearSelection() {
11337
+ this.#dropPendingSelection();
10434
11338
  for (const col of this._columns) col.clearSelection();
10435
11339
  this.requestUpdate();
10436
11340
  }
11341
+ createRenderRoot() {
11342
+ const root = super.createRenderRoot();
11343
+ root.addEventListener(VIEW_STATE_CHANGE, (e) => e.stopPropagation());
11344
+ return root;
11345
+ }
10437
11346
  render() {
10438
11347
  return html`
10439
11348
  <div id="container">
@@ -10442,7 +11351,136 @@ let MulticolListElement = class MulticolListElement extends LitElement {
10442
11351
  </div>
10443
11352
  `;
10444
11353
  }
11354
+ /**
11355
+ * Snapshot the column layout (in display order) and each column's
11356
+ * selection and scroll, keyed by column id. Includes pending state —
11357
+ * saved columns whose category isn't known and keys not yet in the data.
11358
+ */
11359
+ getViewState(options) {
11360
+ const withPending = options?.includePending ?? true;
11361
+ const selectedByColumn = {};
11362
+ const scrollByColumn = {};
11363
+ if (withPending) {
11364
+ for (const [id, keys] of this.#pendingSelected) selectedByColumn[id] = [...keys];
11365
+ for (const [id, scroll] of this.#pendingScroll) scrollByColumn[id] = scroll;
11366
+ }
11367
+ for (const col of this._columns) {
11368
+ const keys = keysToArray(col.selectedValues, withPending ? col.pendingSelection : []);
11369
+ if (keys.length > 0) selectedByColumn[col.id] = keys;
11370
+ const scroll = (withPending ? col.pendingScroll : void 0) ?? col.captureScroll();
11371
+ if (scroll) scrollByColumn[col.id] = scroll;
11372
+ }
11373
+ return this.#viewState.shape({
11374
+ columns: mergeColumns(this._columns.map((c) => c.getState()), withPending ? this.#pendingColumns : []),
11375
+ selectedByColumn,
11376
+ scrollByColumn
11377
+ }, options);
11378
+ }
11379
+ /**
11380
+ * Apply saved view state; fields present replace the current ones.
11381
+ * `columns` creates, reorders, re-points and removes columns to match
11382
+ * (columns whose category isn't in `categories` wait until it is).
11383
+ * Selection keys not in a column's data are applied when they arrive.
11384
+ */
11385
+ setViewState(state) {
11386
+ if (!this.#viewState.accepts(state)) return;
11387
+ this.#viewState.restoring(() => {
11388
+ if (state.columns) this.#applyColumns(sanitiseColumns(state.columns));
11389
+ if (state.selectedByColumn) {
11390
+ const wanted = mapFromRecord(state.selectedByColumn, (v) => Array.isArray(v) ? keysFromJson(v) : void 0);
11391
+ for (const col of this._columns) {
11392
+ col.restoreSelection(wanted.get(col.id) ?? EMPTY_KEYS);
11393
+ wanted.delete(col.id);
11394
+ }
11395
+ this.#pendingSelected = wanted;
11396
+ }
11397
+ if (state.scrollByColumn) {
11398
+ const wanted = mapFromRecord(state.scrollByColumn, (v) => isScrollState(v) ? v : void 0);
11399
+ for (const col of this._columns) {
11400
+ const scroll = wanted.get(col.id);
11401
+ if (scroll) col.restoreScroll(scroll);
11402
+ else col.clearPendingScroll();
11403
+ wanted.delete(col.id);
11404
+ }
11405
+ this.#pendingScroll = wanted;
11406
+ }
11407
+ this.#viewState.notify();
11408
+ });
11409
+ this.requestUpdate();
11410
+ }
11411
+ clearPendingViewState() {
11412
+ this.#pendingColumns = [];
11413
+ this.#pendingSelected = /* @__PURE__ */ new Map();
11414
+ this.#pendingScroll = /* @__PURE__ */ new Map();
11415
+ for (const col of this._columns) {
11416
+ col.clearPendingSelection();
11417
+ col.clearPendingScroll();
11418
+ }
11419
+ }
11420
+ /** Reuse live columns by id (re-pointing their category), create the rest, drop the others. */
11421
+ #applyColumns(saved) {
11422
+ const hasCategory = (key) => findLeafCategory(this.categories, key) !== void 0;
11423
+ const { ready, pending } = partitionColumns(saved, hasCategory);
11424
+ const existing = new Map(this._columns.map((c) => [c.id, c]));
11425
+ const next = ready.map((colState) => this.#columnFromState(colState, existing));
11426
+ for (const col of existing.values()) col.unmount();
11427
+ if (saved.length > 0) this._autoSeeded = true;
11428
+ this.#pendingColumns = pending;
11429
+ this._columns = next;
11430
+ this._updateColOffsets();
11431
+ this._emitColumnsChange(`add`);
11432
+ this._requeryAllColumns();
11433
+ }
11434
+ /** Live column for `colState`: the existing one with that id (taken from `existing`), or a new one. */
11435
+ #columnFromState(colState, existing) {
11436
+ const category = findLeafCategory(this.categories, colState.categoryKey);
11437
+ const live = existing.get(colState.id);
11438
+ existing.delete(colState.id);
11439
+ if (live) {
11440
+ if (live.categoryKey !== colState.categoryKey) live.setCategory(category);
11441
+ live.updateState(colState);
11442
+ return live;
11443
+ }
11444
+ const controller = this.#createController(colState.id, category);
11445
+ controller.updateState(colState);
11446
+ const keys = this.#pendingSelected.get(colState.id);
11447
+ if (keys) {
11448
+ controller.restoreSelection(keys);
11449
+ this.#pendingSelected = withoutKey(this.#pendingSelected, colState.id);
11450
+ }
11451
+ const scroll = this.#pendingScroll.get(colState.id);
11452
+ if (scroll) {
11453
+ controller.restoreScroll(scroll);
11454
+ this.#pendingScroll = withoutKey(this.#pendingScroll, colState.id);
11455
+ }
11456
+ return controller;
11457
+ }
11458
+ /** Insert pending columns whose category has now appeared, at their saved positions. */
11459
+ #resolvePendingColumns() {
11460
+ const hasCategory = (key) => findLeafCategory(this.categories, key) !== void 0;
11461
+ const { ready, pending } = resolvePendingColumns(this.#pendingColumns, hasCategory);
11462
+ if (ready.length === 0) return;
11463
+ this.#viewState.restoring(() => {
11464
+ this.#pendingColumns = pending;
11465
+ const columns = [...this._columns];
11466
+ const none = /* @__PURE__ */ new Map();
11467
+ for (const p of [...ready].sort((a, b) => a.index - b.index)) {
11468
+ if (columns.some((c) => c.id === p.state.id)) continue;
11469
+ columns.splice(Math.min(p.index, columns.length), 0, this.#columnFromState(p.state, none));
11470
+ }
11471
+ this._columns = columns;
11472
+ this._updateColOffsets();
11473
+ this._emitColumnsChange(`add`);
11474
+ this._requeryAllColumns();
11475
+ });
11476
+ }
11477
+ /** An explicit selection change supersedes any saved selection still waiting. */
11478
+ #dropPendingSelection() {
11479
+ this.#pendingSelected = /* @__PURE__ */ new Map();
11480
+ for (const col of this._columns) col.clearPendingSelection();
11481
+ }
10445
11482
  updated(changedProperties) {
11483
+ if (changedProperties.has(`categories`)) this.#resolvePendingColumns();
10446
11484
  const seedCategory = flattenCategories(this.categories)[0];
10447
11485
  if (!this._autoSeeded && this._columns.length === 0 && seedCategory) {
10448
11486
  this._autoSeeded = true;
@@ -10459,8 +11497,8 @@ let MulticolListElement = class MulticolListElement extends LitElement {
10459
11497
  this._updateColOffsets();
10460
11498
  }
10461
11499
  _renderColumn(col, idx) {
10462
- const rawWidth = col.getState().width ?? DEFAULT_COLUMN_WIDTH;
10463
- const width = Number.isFinite(rawWidth) ? rawWidth : DEFAULT_COLUMN_WIDTH;
11500
+ const rawWidth = col.getState().width ?? 220;
11501
+ const width = Number.isFinite(rawWidth) ? rawWidth : 220;
10464
11502
  return html`
10465
11503
  <div
10466
11504
  class="column"
@@ -10522,7 +11560,8 @@ let MulticolListElement = class MulticolListElement extends LitElement {
10522
11560
  selectionMode: this.selectionMode,
10523
11561
  dataProvider: this.dataProvider,
10524
11562
  onSelectionChange: this._onColumnSelectionChange.bind(this),
10525
- onRequestData: this._onColumnRequestData.bind(this)
11563
+ onRequestData: this._onColumnRequestData.bind(this),
11564
+ onScroll: () => this.#viewState.notify()
10526
11565
  });
10527
11566
  }
10528
11567
  _onHeaderPointerDown(e, col, idx) {
@@ -10601,11 +11640,11 @@ let MulticolListElement = class MulticolListElement extends LitElement {
10601
11640
  e.preventDefault();
10602
11641
  e.stopPropagation();
10603
11642
  const startX = e.clientX;
10604
- const rawStartW = col.getState().width ?? DEFAULT_COLUMN_WIDTH;
10605
- const startW = Number.isFinite(rawStartW) ? rawStartW : DEFAULT_COLUMN_WIDTH;
11643
+ const rawStartW = col.getState().width ?? 220;
11644
+ const startW = Number.isFinite(rawStartW) ? rawStartW : 220;
10606
11645
  this._resizingColumnId = col.id;
10607
11646
  const applyWidth = (clientX) => {
10608
- const w = Math.max(MIN_COLUMN_WIDTH, Math.min(MAX_COLUMN_WIDTH, startW + clientX - startX));
11647
+ const w = Math.max(80, Math.min(480, startW + clientX - startX));
10609
11648
  col.setWidth(w);
10610
11649
  return w;
10611
11650
  };
@@ -10734,8 +11773,10 @@ let MulticolListElement = class MulticolListElement extends LitElement {
10734
11773
  this._requeryAllColumns();
10735
11774
  this.requestUpdate();
10736
11775
  }
10737
- _onColumnSelectionChange(columnId, _values) {
11776
+ _onColumnSelectionChange(columnId, _values, origin) {
10738
11777
  if (!this._columns.find((c) => c.id === columnId)) return;
11778
+ if (origin === `user`) this.#dropPendingSelection();
11779
+ this.#viewState.notify(origin);
10739
11780
  for (const otherCol of this._columns) if (otherCol.id !== columnId) otherCol.requestData(this._buildQuery(otherCol));
10740
11781
  const detail = {
10741
11782
  columnId,
@@ -10770,22 +11811,23 @@ let MulticolListElement = class MulticolListElement extends LitElement {
10770
11811
  col.setCategory(leaves[0]);
10771
11812
  changed = true;
10772
11813
  }
10773
- if (changed) this._emitColumnsChange(`category`);
11814
+ if (changed) this._emitColumnsChange(`category`, `data`);
10774
11815
  }
10775
11816
  _updateColOffsets() {
10776
11817
  const offsets = [0];
10777
11818
  let sum = 0;
10778
11819
  for (const col of this._columns) {
10779
- const rawWidth = col.getState().width ?? DEFAULT_COLUMN_WIDTH;
10780
- sum += Number.isFinite(rawWidth) ? rawWidth : DEFAULT_COLUMN_WIDTH;
11820
+ const rawWidth = col.getState().width ?? 220;
11821
+ sum += Number.isFinite(rawWidth) ? rawWidth : 220;
10781
11822
  offsets.push(sum);
10782
11823
  }
10783
11824
  if (!(offsets.length === this._colOffsets.length && offsets.every((v, i) => v === this._colOffsets[i] || Number.isNaN(v) && Number.isNaN(this._colOffsets[i])))) this._colOffsets = offsets;
10784
11825
  }
10785
- _emitColumnsChange(reason) {
11826
+ _emitColumnsChange(reason, viewStateReason = `user`) {
11827
+ this.#viewState.notify(viewStateReason);
10786
11828
  this.dispatchEvent(new CustomEvent(`multicol-columns-change`, {
10787
11829
  detail: {
10788
- columns: this.columns,
11830
+ columns: this._columns.map((c) => c.getState()),
10789
11831
  reason
10790
11832
  },
10791
11833
  bubbles: true,
@@ -10795,8 +11837,8 @@ let MulticolListElement = class MulticolListElement extends LitElement {
10795
11837
  async autoSizeColumns(options) {
10796
11838
  return new Promise((resolve) => {
10797
11839
  setTimeout(() => {
10798
- const minWidth = options?.minWidth ?? MIN_COLUMN_WIDTH;
10799
- const maxWidth = options?.maxWidth ?? MAX_COLUMN_WIDTH;
11840
+ const minWidth = options?.minWidth ?? 80;
11841
+ const maxWidth = options?.maxWidth ?? 480;
10800
11842
  const measure = (text) => {
10801
11843
  const span = document.createElement(`span`);
10802
11844
  span.style.position = `absolute`;
@@ -10874,6 +11916,11 @@ __decorate([state()], MulticolListElement.prototype, "_draggingColumnId", void 0
10874
11916
  __decorate([state()], MulticolListElement.prototype, "_dropCaretX", void 0);
10875
11917
  __decorate([query(`#container`)], MulticolListElement.prototype, "_containerEl", void 0);
10876
11918
  MulticolListElement = __decorate([safeCustomElement(`ixfx-multicol-list`)], MulticolListElement);
11919
+ function withoutKey(map, key) {
11920
+ const next = new Map(map);
11921
+ next.delete(key);
11922
+ return next;
11923
+ }
10877
11924
  //#endregion
10878
11925
  //#region src/notifier/pill-renderer.ts
10879
11926
  const ROW_SPACING = 8;
@@ -14536,6 +15583,242 @@ function computeTreemapLayout(root, options = {}) {
14536
15583
  return rects;
14537
15584
  }
14538
15585
  //#endregion
15586
+ //#region src/treemap/treemap-view-state.ts
15587
+ function indexTreemapKeys(root) {
15588
+ const out = /* @__PURE__ */ new Map();
15589
+ if (!root) return out;
15590
+ const walk = (node, parentKey, depth) => {
15591
+ if (!out.has(node.item.key)) out.set(node.item.key, {
15592
+ node,
15593
+ parentKey,
15594
+ depth
15595
+ });
15596
+ for (const child of node.children ?? []) walk(child, node.item.key, depth + 1);
15597
+ };
15598
+ walk(root, void 0, -1);
15599
+ return out;
15600
+ }
15601
+ /** Key path from the root's children down to `key` (`[]` for the root itself). */
15602
+ function treemapKeyPath(index, key) {
15603
+ const path = [];
15604
+ for (let entry = index.get(key); entry !== void 0; entry = entry.parentKey === void 0 ? void 0 : index.get(entry.parentKey)) {
15605
+ if (entry.parentKey === void 0) return path;
15606
+ path.unshift(entry.node.item.key);
15607
+ }
15608
+ }
15609
+ /**
15610
+ * Where a saved zoom path stands against the loaded tree. The target is
15611
+ * matched by key anywhere (it may have moved); failing that, the deepest
15612
+ * loaded ancestor on the path is reported for loading when its children
15613
+ * haven't been fetched.
15614
+ */
15615
+ function resolveZoomPath(index, path) {
15616
+ const target = path.at(-1);
15617
+ if (target === void 0) return {
15618
+ kind: `found`,
15619
+ key: void 0
15620
+ };
15621
+ if (index.has(target)) return {
15622
+ kind: `found`,
15623
+ key: target
15624
+ };
15625
+ for (let i = path.length - 2; i >= 0; i--) {
15626
+ const entry = index.get(path[i]);
15627
+ if (!entry) continue;
15628
+ const { node, depth } = entry;
15629
+ return node.children === void 0 && node.item.isLeaf !== true ? {
15630
+ kind: `load`,
15631
+ node,
15632
+ depth
15633
+ } : { kind: `missing` };
15634
+ }
15635
+ return { kind: `missing` };
15636
+ }
15637
+ /**
15638
+ * Zoom: a live zoom root that left the tree goes pending (view falls back to
15639
+ * the full tree); a pending path whose target is now loaded is applied.
15640
+ * Selection: see `reconcileSelection` in `common/view-state/tree.ts`.
15641
+ */
15642
+ function reconcileTreemapViewState(input) {
15643
+ const { index } = input;
15644
+ let zoom;
15645
+ let pendingZoom = input.pendingZoom;
15646
+ if (input.zoomKey !== void 0 && !index.has(input.zoomKey)) {
15647
+ pendingZoom ??= input.zoomPath;
15648
+ zoom = {
15649
+ key: void 0,
15650
+ path: [],
15651
+ restored: false
15652
+ };
15653
+ } else if (input.zoomKey !== void 0) {
15654
+ const path = treemapKeyPath(index, input.zoomKey) ?? input.zoomPath;
15655
+ if (!samePath(path, input.zoomPath)) zoom = {
15656
+ key: input.zoomKey,
15657
+ path,
15658
+ restored: false
15659
+ };
15660
+ }
15661
+ if (pendingZoom) {
15662
+ const resolved = resolveZoomPath(index, pendingZoom);
15663
+ if (resolved.kind === `found`) {
15664
+ const key = resolved.key;
15665
+ zoom = {
15666
+ key,
15667
+ path: key === void 0 ? [] : treemapKeyPath(index, key) ?? pendingZoom,
15668
+ restored: true
15669
+ };
15670
+ pendingZoom = void 0;
15671
+ }
15672
+ }
15673
+ const selection = reconcileSelection$1(input.selected, input.pendingSelected, (key) => index.get(key)?.node, input.canSelect, input.singleSelect);
15674
+ return {
15675
+ zoom,
15676
+ pendingZoom,
15677
+ selection
15678
+ };
15679
+ }
15680
+ function samePath(a, b) {
15681
+ return a.length === b.length && a.every((k, i) => k === b[i]);
15682
+ }
15683
+ //#endregion
15684
+ //#region src/treemap/treemap-view-state-binding.ts
15685
+ function createTreemapViewState(host) {
15686
+ const { controller } = host;
15687
+ /** Key path of the zoom root as last seen — what goes pending if it leaves the tree. */
15688
+ let zoomPath = [];
15689
+ let pendingZoom;
15690
+ let pendingSelected = EMPTY_KEYS;
15691
+ let pendingFocus;
15692
+ /** The cursor was last moved by keyboard / search (only then is it saved as `focus`). */
15693
+ let keyboardCursor = false;
15694
+ let structureVersion = 0;
15695
+ let reconciledVersion = -1;
15696
+ let cache;
15697
+ const index = () => {
15698
+ if (cache?.version !== structureVersion) cache = {
15699
+ version: structureVersion,
15700
+ index: indexTreemapKeys(host.root())
15701
+ };
15702
+ return cache.index;
15703
+ };
15704
+ const cursorKey = () => keyboardCursor ? host.tickled() : void 0;
15705
+ const applyZoom = (key, path, restored) => {
15706
+ zoomPath = path;
15707
+ if (host.zoomKey() === key) return;
15708
+ host.setZoom(key, restored);
15709
+ controller.notify();
15710
+ };
15711
+ const reconcileCursor$1 = (idx) => {
15712
+ const cursor = reconcileCursor(cursorKey(), pendingFocus, (key) => idx.has(key), host.isVisible);
15713
+ pendingFocus = cursor.pending;
15714
+ const tickled = host.tickled();
15715
+ if (cursor.clear || tickled !== void 0 && !idx.has(tickled)) host.setTickled(void 0);
15716
+ if (cursor.apply !== void 0) {
15717
+ host.setTickled(cursor.apply);
15718
+ keyboardCursor = true;
15719
+ controller.notify();
15720
+ }
15721
+ };
15722
+ /** Move state for missing keys to pending; apply pending state for keys now loaded. */
15723
+ const reconcile = () => {
15724
+ reconciledVersion = structureVersion;
15725
+ if (!host.root()) return;
15726
+ const idx = index();
15727
+ controller.restoring(() => {
15728
+ const r = reconcileTreemapViewState({
15729
+ index: idx,
15730
+ zoomKey: host.zoomKey(),
15731
+ zoomPath,
15732
+ pendingZoom,
15733
+ selected: host.selected(),
15734
+ pendingSelected,
15735
+ canSelect: host.canSelect,
15736
+ singleSelect: host.singleSelect()
15737
+ });
15738
+ pendingZoom = r.pendingZoom;
15739
+ if (r.zoom) applyZoom(r.zoom.key, r.zoom.path, r.zoom.restored);
15740
+ const sel = r.selection;
15741
+ if (sel.changed) {
15742
+ pendingSelected = sel.pending;
15743
+ const primaryKey = host.selectedNode()?.item.key;
15744
+ const primary = [...sel.selected].find((n) => n.item.key === primaryKey) ?? [...sel.selected].at(-1);
15745
+ host.setSelection(sel.selected, primary, sel.restored);
15746
+ }
15747
+ reconcileCursor$1(idx);
15748
+ });
15749
+ };
15750
+ return {
15751
+ /** The structure changed (new root / model, invalidation, lazy load). */
15752
+ invalidate() {
15753
+ structureVersion++;
15754
+ },
15755
+ /** Run in `willUpdate`. `zoomGone` — the zoom root vanished through in-place mutation. */
15756
+ reconcileIfNeeded(zoomGone) {
15757
+ if (zoomGone) structureVersion++;
15758
+ if (reconciledVersion !== structureVersion) reconcile();
15759
+ },
15760
+ /** An explicit zoom: supersedes a saved one still waiting. */
15761
+ zoomed(key) {
15762
+ pendingZoom = void 0;
15763
+ zoomPath = key === void 0 ? [] : treemapKeyPath(index(), key) ?? [key];
15764
+ controller.notify();
15765
+ },
15766
+ /** Selection changed — explicit changes supersede a saved selection still waiting. */
15767
+ selectionChanged() {
15768
+ if (!controller.isRestoring) pendingSelected = EMPTY_KEYS;
15769
+ controller.notify();
15770
+ },
15771
+ clearPendingSelection() {
15772
+ pendingSelected = EMPTY_KEYS;
15773
+ },
15774
+ /** The cursor moved by keyboard / search navigation. */
15775
+ keyboardCursorMoved() {
15776
+ keyboardCursor = true;
15777
+ pendingFocus = void 0;
15778
+ controller.notify();
15779
+ },
15780
+ /** The cursor moved by pointer. */
15781
+ pointerCursorMoved() {
15782
+ if (!keyboardCursor) return;
15783
+ keyboardCursor = false;
15784
+ controller.notify();
15785
+ },
15786
+ get(options) {
15787
+ const withPending = options?.includePending ?? true;
15788
+ return controller.shape({
15789
+ selected: keysToArray([...host.selected()].map((n) => n.item.key), withPending ? pendingSelected : []),
15790
+ focus: cursorKey() ?? (withPending ? pendingFocus : void 0),
15791
+ zoom: [...(withPending ? pendingZoom : void 0) ?? zoomPath]
15792
+ }, options);
15793
+ },
15794
+ set(state) {
15795
+ if (!controller.accepts(state)) return;
15796
+ controller.restoring(() => {
15797
+ if (state.selected) {
15798
+ pendingSelected = capKeys(new Set(state.selected));
15799
+ if (host.selected().size > 0) host.setSelection(/* @__PURE__ */ new Set(), void 0, true);
15800
+ }
15801
+ if (state.focus !== void 0) pendingFocus = state.focus;
15802
+ const zoom = pathFromJson(state.zoom);
15803
+ if (zoom) pendingZoom = zoom;
15804
+ reconcile();
15805
+ if (zoom && pendingZoom) applyZoom(void 0, [], true);
15806
+ });
15807
+ },
15808
+ clearPending() {
15809
+ pendingSelected = EMPTY_KEYS;
15810
+ pendingFocus = void 0;
15811
+ pendingZoom = void 0;
15812
+ },
15813
+ /** Load the next unloaded ancestor on a pending zoom path (one load at a time). */
15814
+ continueRestoreLoads() {
15815
+ if (!pendingZoom || !host.restoreLoads() || !host.canLoad()) return;
15816
+ const resolved = resolveZoomPath(index(), pendingZoom);
15817
+ if (resolved.kind === `load` && !host.isLoading(resolved.node.item.key)) host.load(resolved.node, resolved.depth);
15818
+ }
15819
+ };
15820
+ }
15821
+ //#endregion
14539
15822
  //#region src/treemap/treemap-list.ts
14540
15823
  let TreemapListElement = class TreemapListElement extends LitElement {
14541
15824
  constructor(..._args) {
@@ -14557,6 +15840,33 @@ let TreemapListElement = class TreemapListElement extends LitElement {
14557
15840
  this.disableKeyboardNavigation = false;
14558
15841
  this.registry = void 0;
14559
15842
  this._zoomKey = void 0;
15843
+ this.restoreLoads = true;
15844
+ this.#viewState = createTreemapViewState({
15845
+ controller: new ViewStateController(this, {
15846
+ kind: `treemap-list`,
15847
+ capture: () => this.getViewState()
15848
+ }),
15849
+ root: () => this.#effectiveRoot,
15850
+ zoomKey: () => this._zoomKey,
15851
+ setZoom: (key, restored) => this.#restoreZoom(key, restored),
15852
+ selected: () => this._selectedNodes,
15853
+ selectedNode: () => this.selectedNode,
15854
+ canSelect: (node) => this.#canSelect(node),
15855
+ singleSelect: () => this.interactionMode === `implicit`,
15856
+ setSelection: (next, primary, fire) => {
15857
+ const prev = this._selectedNodes;
15858
+ this._selectedNodes = new Set(next);
15859
+ this.selectedNode = primary;
15860
+ if (fire) this.#dispatchSelect(prev);
15861
+ },
15862
+ tickled: () => this.tickled.tickledId,
15863
+ setTickled: (key) => key === void 0 ? this.tickled.clearTickled() : this.tickled.setTickled(key),
15864
+ isVisible: (key) => this.#rects().some((r) => r.node.item.key === key),
15865
+ restoreLoads: () => this.restoreLoads,
15866
+ canLoad: () => this.model !== void 0 || this.loadChildren !== void 0,
15867
+ isLoading: (key) => this.#pendingKeys.has(key),
15868
+ load: (node, depth) => void this.#loadChildren(node, depth)
15869
+ });
14560
15870
  this.search = new ListSearchController(this, { adapter: createTreeListSearchAdapter(this, {
14561
15871
  labelSelector: `.treemap-label`,
14562
15872
  highlightKey: `incr-search-highlight`
@@ -14595,6 +15905,7 @@ let TreemapListElement = class TreemapListElement extends LitElement {
14595
15905
  if (this._zoomKey === void 0) return void 0;
14596
15906
  return findTreemapNode(this.#effectiveRoot, this._zoomKey);
14597
15907
  }
15908
+ #viewState;
14598
15909
  #modelUnsubscribe;
14599
15910
  #pendingKeys;
14600
15911
  #aborters;
@@ -14690,40 +16001,38 @@ let TreemapListElement = class TreemapListElement extends LitElement {
14690
16001
  this.#unregisterSearchCommands?.();
14691
16002
  this.#unregisterSearchCommands = registerListSearchCommands(this.#ownedRegistry, this.search);
14692
16003
  }
16004
+ willUpdate(changedProps) {
16005
+ if (changedProps.has(`root`) && this.root || changedProps.has(`model`)) this.#onDataReset();
16006
+ this.#viewState.reconcileIfNeeded(this._zoomKey !== void 0 && !findTreemapNode(this.#effectiveRoot, this._zoomKey));
16007
+ }
14693
16008
  updated(changedProps) {
14694
- if (changedProps.has(`root`) && this.root) this.#resetViewState();
14695
16009
  if (changedProps.has(`model`)) {
14696
16010
  this.#modelUnsubscribe?.();
14697
16011
  this.#modelUnsubscribe = void 0;
14698
- if (this.model) {
14699
- this.#modelUnsubscribe = this.model._subscribe((event) => {
14700
- if (event.type === `root`) {
14701
- this.#resetViewState();
14702
- this.requestUpdate();
14703
- } else if (event.type === `invalidate`) this.requestUpdate();
14704
- });
14705
- this.#resetViewState();
14706
- }
16012
+ if (this.model) this.#modelUnsubscribe = this.model._subscribe((event) => {
16013
+ if (event.type === `root`) this.#onDataReset();
16014
+ else if (event.type === `invalidate`) this.#viewState.invalidate();
16015
+ this.requestUpdate();
16016
+ });
14707
16017
  }
14708
- if (changedProps.has(`interactionMode`)) {
16018
+ if (changedProps.has(`interactionMode`) && changedProps.get(`interactionMode`) !== void 0) {
14709
16019
  this.#anchorIndex = void 0;
14710
16020
  this.clearSelection();
14711
16021
  }
14712
16022
  if (changedProps.has(`searchModes`)) this.#syncSearchCommands();
14713
- if (this._zoomKey !== void 0 && !findTreemapNode(this.#effectiveRoot, this._zoomKey)) this._zoomKey = void 0;
14714
16023
  this.#ensureVisibleChildren();
16024
+ this.#viewState.continueRestoreLoads();
14715
16025
  if (this.#pendingFlip) {
14716
16026
  const flip = this.#pendingFlip;
14717
16027
  this.#pendingFlip = void 0;
14718
16028
  this.#applyFlip(flip);
14719
16029
  }
14720
16030
  }
14721
- #resetViewState() {
16031
+ /** New `root` / `model`: drop transient state; keyed view state is reconciled, not reset. */
16032
+ #onDataReset() {
14722
16033
  this.#pendingFlip = void 0;
14723
- this.#clearSelectionInternal();
14724
- this.tickled.clearTickled();
14725
- this._zoomKey = void 0;
14726
16034
  this.#anchorIndex = void 0;
16035
+ this.#viewState.invalidate();
14727
16036
  }
14728
16037
  #ensureVisibleChildren() {
14729
16038
  if (!this.model && !this.loadChildren) return;
@@ -14754,7 +16063,10 @@ let TreemapListElement = class TreemapListElement extends LitElement {
14754
16063
  } finally {
14755
16064
  this.#pendingKeys.delete(key);
14756
16065
  this.#aborters.delete(key);
14757
- if (!controller.signal.aborted) this.requestUpdate();
16066
+ if (!controller.signal.aborted) {
16067
+ this.#viewState.invalidate();
16068
+ this.requestUpdate();
16069
+ }
14758
16070
  }
14759
16071
  }
14760
16072
  #canSelect(node) {
@@ -14768,6 +16080,7 @@ let TreemapListElement = class TreemapListElement extends LitElement {
14768
16080
  this.selectedNode = void 0;
14769
16081
  }
14770
16082
  #dispatchSelect(previous) {
16083
+ this.#viewState.selectionChanged();
14771
16084
  this.dispatchEvent(new CustomEvent(`select`, {
14772
16085
  detail: {
14773
16086
  selected: new Set(this._selectedNodes),
@@ -14795,6 +16108,7 @@ let TreemapListElement = class TreemapListElement extends LitElement {
14795
16108
  this.requestUpdate();
14796
16109
  }
14797
16110
  clearSelection() {
16111
+ this.#viewState.clearPendingSelection();
14798
16112
  if (this._selectedNodes.size === 0) return;
14799
16113
  const prev = new Set(this._selectedNodes);
14800
16114
  this.#clearSelectionInternal();
@@ -14833,7 +16147,7 @@ let TreemapListElement = class TreemapListElement extends LitElement {
14833
16147
  zoomTo(node) {
14834
16148
  if (this._zoomKey === node.item.key) return;
14835
16149
  this.#pendingFlip = this.#planFlip(node.item.key);
14836
- this._zoomKey = node.item.key;
16150
+ this.#setZoom(node.item.key);
14837
16151
  this.tickled.clearTickled();
14838
16152
  const depth = findTreemapPath(this.#effectiveRoot, node.item.key)?.length;
14839
16153
  const absoluteDepth = depth === void 0 ? -1 : depth - 2;
@@ -14865,7 +16179,7 @@ let TreemapListElement = class TreemapListElement extends LitElement {
14865
16179
  const previous = this.focusedNode;
14866
16180
  const path = this._zoomKey === void 0 ? void 0 : findTreemapPath(this.#effectiveRoot, this._zoomKey);
14867
16181
  const absoluteDepth = path === void 0 ? -1 : path.length - 2;
14868
- this._zoomKey = void 0;
16182
+ this.#setZoom(void 0);
14869
16183
  this.tickled.clearTickled();
14870
16184
  this.dispatchEvent(new CustomEvent(`zoom`, {
14871
16185
  detail: {
@@ -14885,6 +16199,25 @@ let TreemapListElement = class TreemapListElement extends LitElement {
14885
16199
  }));
14886
16200
  this.requestUpdate();
14887
16201
  }
16202
+ /** An explicit zoom change: supersedes any saved zoom still waiting. */
16203
+ #setZoom(key) {
16204
+ this._zoomKey = key;
16205
+ this.#viewState.zoomed(key);
16206
+ }
16207
+ /** Zoom set by view-state reconcile. `restored` fires `zoom` like an explicit zoom, without animating. */
16208
+ #restoreZoom(key, restored) {
16209
+ this._zoomKey = key;
16210
+ if (!restored) return;
16211
+ const path = key === void 0 ? void 0 : findTreemapPath(this.#effectiveRoot, key);
16212
+ this.dispatchEvent(new CustomEvent(`zoom`, {
16213
+ detail: {
16214
+ node: path?.at(-1),
16215
+ depth: path === void 0 ? -1 : path.length - 2
16216
+ },
16217
+ bubbles: true,
16218
+ composed: true
16219
+ }));
16220
+ }
14888
16221
  /**
14889
16222
  * Work out (before `_zoomKey` changes) whether the upcoming zoom should
14890
16223
  * animate, and how — see `FlipPlan`. Returns `undefined` to skip the
@@ -14994,10 +16327,20 @@ let TreemapListElement = class TreemapListElement extends LitElement {
14994
16327
  const ancestor = this.#resolveKeyPath(path.slice(0, path.length - this.maxDepth));
14995
16328
  if (ancestor) this.zoomTo(ancestor);
14996
16329
  }
14997
- this.tickled.setTickled(key);
16330
+ this.#setKeyboardCursor(key);
14998
16331
  this.requestUpdate();
14999
16332
  return true;
15000
16333
  }
16334
+ /** Move the cursor as keyboard / search navigation does — recorded as `focus` in the view state. */
16335
+ #setKeyboardCursor(key) {
16336
+ this.tickled.setTickled(key);
16337
+ this.#viewState.keyboardCursorMoved();
16338
+ }
16339
+ #onTilePointerEnter(key) {
16340
+ const before = this.tickled.tickledId;
16341
+ this.tickled.handlePointerEnter(key);
16342
+ if (this.tickled.tickledId !== before) this.#viewState.pointerCursorMoved();
16343
+ }
15001
16344
  #resolveKeyPath(keys) {
15002
16345
  let current = this.#effectiveRoot;
15003
16346
  for (const key of keys) {
@@ -15027,6 +16370,25 @@ let TreemapListElement = class TreemapListElement extends LitElement {
15027
16370
  depth: r.depth
15028
16371
  }));
15029
16372
  }
16373
+ /**
16374
+ * Snapshot selection, zoom path and keyboard cursor as plain JSON, keyed by
16375
+ * `item.key`. Includes pending state for keys not currently loaded.
16376
+ */
16377
+ getViewState(options) {
16378
+ return this.#viewState.get(options);
16379
+ }
16380
+ /**
16381
+ * Apply saved view state. Fields present replace the current ones; keys
16382
+ * that aren't loaded yet are applied as they appear — a deep `zoom` loads
16383
+ * its ancestors via `loadChildren` / `model` when `restoreLoads`.
16384
+ */
16385
+ setViewState(state) {
16386
+ this.#viewState.set(state);
16387
+ this.requestUpdate();
16388
+ }
16389
+ clearPendingViewState() {
16390
+ this.#viewState.clearPending();
16391
+ }
15030
16392
  #matchesFilter(node, depth) {
15031
16393
  if (!this.filterPredicate) return true;
15032
16394
  if (this.filterPredicate(node, depth)) return true;
@@ -15128,7 +16490,7 @@ let TreemapListElement = class TreemapListElement extends LitElement {
15128
16490
  const moveTo = (rect) => {
15129
16491
  if (!rect) return;
15130
16492
  event.preventDefault();
15131
- this.tickled.setTickled(rect.node.item.key);
16493
+ this.#setKeyboardCursor(rect.node.item.key);
15132
16494
  if (this.interactionMode === `implicit`) this.select(rect.node);
15133
16495
  };
15134
16496
  switch (event.key) {
@@ -15309,7 +16671,7 @@ let TreemapListElement = class TreemapListElement extends LitElement {
15309
16671
  e.stopPropagation();
15310
16672
  this.#handleItemDblClick(rect);
15311
16673
  }}
15312
- @pointerenter=${() => this.tickled.handlePointerEnter(node.item.key)}
16674
+ @pointerenter=${() => this.#onTilePointerEnter(node.item.key)}
15313
16675
  >
15314
16676
  ${showHeader ? html`<div class="tile-header" part="tile-header"><span class="treemap-label" part="label">${node.item.label}</span></div>` : html`<span class="treemap-label ${hideLabel ? `hide-label` : ``}" part="label">${node.item.label}</span>`}
15315
16677
  ${childrenContent}
@@ -15623,6 +16985,7 @@ __decorate([property({
15623
16985
  reflect: true
15624
16986
  })], TreemapListElement.prototype, "searchModes", void 0);
15625
16987
  __decorate([state()], TreemapListElement.prototype, "_zoomKey", void 0);
16988
+ __decorate([property({ attribute: false })], TreemapListElement.prototype, "restoreLoads", void 0);
15626
16989
  TreemapListElement = __decorate([safeCustomElement(`ixfx-treemap-list`)], TreemapListElement);
15627
16990
  //#endregion
15628
16991
  //#region src/twosplit-layout/split-layout.ts
@@ -15649,6 +17012,10 @@ let TwoSplitLayoutElement = class TwoSplitLayoutElement extends LitElement {
15649
17012
  this.#nestedSplitInitialSizeA = ``;
15650
17013
  this.#pendingNestedUpdate = null;
15651
17014
  this.#hysteresisThreshold = .1;
17015
+ this.#viewState = new ViewStateController(this, {
17016
+ kind: `twosplit-layout`,
17017
+ capture: () => this.getViewState()
17018
+ });
15652
17019
  this.#onResizeHandleDown = (event) => {
15653
17020
  event.preventDefault();
15654
17021
  const zoneEl = this.getZone();
@@ -15665,6 +17032,7 @@ let TwoSplitLayoutElement = class TwoSplitLayoutElement extends LitElement {
15665
17032
  if (zoneEl) zoneEl.classList.remove(`user-resizing`);
15666
17033
  window.removeEventListener(`pointerup`, this.#onResizeHandleUp);
15667
17034
  window.removeEventListener(`pointermove`, this.#onResizeHandleMove);
17035
+ if (this.sizeA !== this.#startSizeA || this.sizeB !== this.#startSizeB) this.#viewState.notify();
15668
17036
  };
15669
17037
  this.#onResizeHandleMove = (event) => {
15670
17038
  const bounds = this.#elementBounds;
@@ -15784,9 +17152,11 @@ let TwoSplitLayoutElement = class TwoSplitLayoutElement extends LitElement {
15784
17152
  if (nestedSplit) {
15785
17153
  const nestedZoneEl = nestedSplit.getZone();
15786
17154
  if (nestedZoneEl) nestedZoneEl.classList.remove(`user-resizing`);
17155
+ if (#viewState in nestedSplit && nestedSplit.sizeA !== this.#nestedSplitInitialSizeA) nestedSplit.#viewState.notify();
15787
17156
  }
15788
17157
  window.removeEventListener(`pointerup`, this.#onCornerResizeUp);
15789
17158
  window.removeEventListener(`pointermove`, this.#onCornerResizeMove);
17159
+ if (this.sizeA !== this.#startSizeA || this.sizeB !== this.#startSizeB) this.#viewState.notify();
15790
17160
  };
15791
17161
  this.#onCornerResizeMove = (event) => {
15792
17162
  const outerBounds = this.#elementBounds;
@@ -15854,6 +17224,7 @@ let TwoSplitLayoutElement = class TwoSplitLayoutElement extends LitElement {
15854
17224
  #collapsedAPointerAtCollapse;
15855
17225
  #collapsedBPointerAtCollapse;
15856
17226
  #hysteresisThreshold;
17227
+ #viewState;
15857
17228
  disconnectedCallback() {
15858
17229
  super.disconnectedCallback();
15859
17230
  this.#observer?.disconnect();
@@ -15863,7 +17234,7 @@ let TwoSplitLayoutElement = class TwoSplitLayoutElement extends LitElement {
15863
17234
  }
15864
17235
  updated(changedProperties) {
15865
17236
  if (changedProperties.has(`sizeA`) || changedProperties.has(`sizeB`) || changedProperties.has(`collapsedA`) || changedProperties.has(`collapsedB`)) this.#updateCornerPosition();
15866
- if (changedProperties.has(`sizeA`) || changedProperties.has(`sizeB`)) this.#updateDataCollapsed();
17237
+ if (changedProperties.has(`sizeA`) || changedProperties.has(`sizeB`) || changedProperties.has(`collapsedA`) || changedProperties.has(`collapsedB`)) this.#updateDataCollapsed();
15867
17238
  }
15868
17239
  #updateDataCollapsed() {
15869
17240
  if (this.collapsedA) this.setAttribute(`data-collapsed-a`, ``);
@@ -16022,6 +17393,31 @@ let TwoSplitLayoutElement = class TwoSplitLayoutElement extends LitElement {
16022
17393
  else if (this.layout === `horizontal` && nestedSplit.layout === `vertical`) cornerZone.style.cursor = `nesw-resize`;
16023
17394
  }
16024
17395
  }
17396
+ /** Snapshot both sides' size and collapse. */
17397
+ getViewState(options) {
17398
+ return this.#viewState.shape({ layout: {
17399
+ a: sideState(this.sizeA, this.collapsedA),
17400
+ b: sideState(this.sizeB, this.collapsedB)
17401
+ } }, options);
17402
+ }
17403
+ /** Restore sizes / collapse. A side missing from `layout` is left as it is. */
17404
+ setViewState(state) {
17405
+ if (!this.#viewState.accepts(state) || !state.layout) return;
17406
+ const { a, b } = state.layout;
17407
+ this.#viewState.restoring(() => {
17408
+ if (typeof a?.size === `string`) {
17409
+ this.sizeA = a.size;
17410
+ this.collapsedA = a.collapsed === true;
17411
+ }
17412
+ if (typeof b?.size === `string`) {
17413
+ this.sizeB = b.size;
17414
+ this.collapsedB = b.collapsed === true;
17415
+ }
17416
+ this.#viewState.notify();
17417
+ });
17418
+ }
17419
+ /** Both slots always exist, so nothing is ever pending. */
17420
+ clearPendingViewState() {}
16025
17421
  getZone() {
16026
17422
  if (!this.shadowRoot) return;
16027
17423
  const el = this.shadowRoot.querySelector(`#zone`);
@@ -16209,6 +17605,12 @@ __decorate([property({ type: String })], TwoSplitLayoutElement.prototype, "minSi
16209
17605
  __decorate([property({ type: String })], TwoSplitLayoutElement.prototype, "maxSizeB", void 0);
16210
17606
  __decorate([state()], TwoSplitLayoutElement.prototype, "_hasCorner", void 0);
16211
17607
  TwoSplitLayoutElement = __decorate([safeCustomElement(`ixfx-twosplit-layout`)], TwoSplitLayoutElement);
17608
+ function sideState(size, collapsed) {
17609
+ return collapsed ? {
17610
+ size,
17611
+ collapsed
17612
+ } : { size };
17613
+ }
16212
17614
  //#endregion
16213
17615
  //#region src/util/checkbox.ts
16214
17616
  function isCheckboxLike(el) {
@@ -16267,4 +17669,4 @@ function init() {
16267
17669
  console.log(`Init`);
16268
17670
  }
16269
17671
  //#endregion
16270
- export { AcTextElement, AcTokenElement, BottomDrawerElement, ButtonElement, Checkbox, CheckedSelect, ColourPicker, ColourPickerPopup, CrumbNavigationElement, CrumbPathController, CrumbPathElement, DEFAULT_SIBLING_SPACING, DataController, DataDisplayComponent, DataGridElement, DataTableElement, DetailListElement, DockHostElement, DockPanelElement, EMPTY_GROUP_LABEL, EditableLabel, EditableNumber, FlatListElement, FormElement, FormGroupElement, FormOptionsElement, FormPartElement, FormRowElement, GridListElement, GroupedItemListerElement, HyperbolicTreeListElement, ICON_CARET_RIGHT, ICON_CHECK, ICON_CHEVRON_DOWN, ICON_CLOSE, IcicleTreeListElement, IncrSearchTreeController as IncrSearchMillerController, IncrSearchTreeController, IxfxHexEditorElement, IxfxIconElement, IxfxTimelineElement, KeyedElementIndex, LIST_ITEM_MOTION_MS, LIST_SEARCH_COMMAND_IDS, LIST_SEARCH_MODES, LabelledRadialInput, LabelledRangeInput, LabelledSliderInput, LedElement, ListDragSelectController, ListGroupHeaderElement, ListNativeDragController, ListSearchController, ListSelectionController, menu_exports as Menu, MillerBaseElement, MillerListElement, MillerTreeController, MulticolListElement, NarrowedTextElement, NotificationManager, NotificationPillElement, PanTextElement, PanelElement, PanelGroupElement, PlotDataSeries, PlotHistogram, PlotMultiAxis, PlotSingleAxis, PlotXyAxis, PolarPad, RadialInputElement, RadioGroup, RangeElement, RangeInput, RangeMultiElement, RatingElement, SUNBURST_PATH_SEPARATOR, SelectHorizElement, ShapefillValueElement, SliderInputElement, SlidingNumberElement, SnackbarElement, SnapContainer, SplitButton, SplitLayoutElement, SplitPanelElement, SunburstTreeListElement, SwipeElement, TabController, TabListElement, TabListItemElement, TabPanelElement, TabPanelsElement, TextShimmerElement, TickledController, TimelineController, TimelineTrackTabElement, TitlebarElement, ToolbarElement, ToolbarGroupElement, TooltipElement, TransitoryLabelElement, TreeBaseElement, TreeController, TreeDataModel, TreeDropController, TreeKeyboardNavController, TreeListElement, TreemapListElement, TwoSplitLayoutElement, VerticalListElement, XyPad, addPanelToStack, animateListItemIn, animateListItemOut, applyAgeModification, applyHighlights, applyIsometry, applySnapshot, applyTreeDrop, attachTimelineInteractions, bottomDrawerCommandId, buildHighlightTargets, buildModel, captionForEntry, clampToDisk, clearHighlights, collectContainerNames, collectDataResult, collectPanelIds, collectStackPaths, composeIsometry, computeCanonicalPositions, computeColour, computeHyperbolicLayout, computeIcicleLayout, computeLeafGeoms, computeSunburstLayout, computeTreemapLayout, createAcCompletionProvider, createDataListSearchAdapter, createDefaultListDragPreview, createElementIncrSearch, createElementListSearchAdapter, createEmptyLayout, createEmptyStack, createFuzzySearch, createIncrSearch, createPillRenderer, createSplit, createStack, createTreeListAdapter, createTreeListSearchAdapter, createValueResolver, createVerticalListAdapter, decomposeViewIsometry, defaultGroupLabel, defaultSunburstValueAccessor, distribute, dropTargetFor, euclideanRadiusForHyperbolicDistance, extractStack, findEmptiedStacks, findHyperbolicIdOf, findHyperbolicKeyPath, findHyperbolicNode, findHyperbolicPath, findHyperbolicPathById, findIcicleNode, findIciclePath, findNode, findPanelNode, findParentOf, findStackOf, findStackPathByName, findSunburstId, findSunburstNodeById, findTreemapNode, findTreemapPath, geodesicInterpolate, geodesicPath, geodesicSegment, getActiveDragPanelId, getIcon, getIconEntry, getIconFont, getLeafNeighbours, hasIcon, hyperbolicBranchId, hyperbolicChildIds, hyperbolicDistanceForEuclideanRadius, hyperbolicIdDepth, hyperbolicRelativeFrame, iconBus, identityIsometry, init, insertBeside, invertIsometry, isDragging, isHyperbolicDescendantId, isMultiMode, isSplit, isStack, isSunburstAncestorId, keyForElement, listItemFadeKeyframes, listSearchStyles, mobiusAdd, mobiusFromOrigin, mobiusToOrigin, monitorIndeterminate, moveLeafNodeToEdge, moveLeafStep, norm, normalise, normaliseValue, normalize, panelGapIndex, parseColourScaleAttr, parseLayout, partitionByGroup, poincareDistance, pointerOverAnyHost, prefersReducedMotion, preserveRestoreWeights, pruneDraggedDescendants, readBaseColour, registerFontIcon, registerIcon, registerListSearchCommands, removePanelFromStack, resolveCssVars, resolveGrid, resolveGroupKey, resolveLeafPath, resolveTracks, rotationIsometry, serializeLayout, setActive, setIconFont, setPanelRestoreWeight, snapshotNonEmptyStacks, startListItemOutAnimation, sunburstChildId, sunburstHasChildren, sunburstIdToPath, sunburstParentId, sunburstPathToId, svgToDataUri, swapLeaves, tickledItemStyles, tickledStyles, timelineStyles, toGroupKey, toSnapshot, toggleInSet, toolbarGapIndex, translationFromOrigin, viewIsometry, walk, wrapRoot };
17672
+ export { AcTextElement, AcTokenElement, BottomDrawerElement, ButtonElement, Checkbox, CheckedSelect, ColourPicker, ColourPickerPopup, CrumbNavigationElement, CrumbPathController, CrumbPathElement, DEFAULT_SIBLING_SPACING, DataController, DataDisplayComponent, DataGridElement, DataTableElement, DetailListElement, DockHostElement, DockPanelElement, EMPTY_FLAGS, EMPTY_GROUP_LABEL, EMPTY_KEYS, EditableLabel, EditableNumber, FlatListElement, FormElement, FormGroupElement, FormOptionsElement, FormPartElement, FormRowElement, GridListElement, GroupedItemListerElement, HyperbolicTreeListElement, ICON_CARET_RIGHT, ICON_CHECK, ICON_CHEVRON_DOWN, ICON_CLOSE, IcicleTreeListElement, IncrSearchTreeController as IncrSearchMillerController, IncrSearchTreeController, IxfxHexEditorElement, IxfxIconElement, IxfxTimelineElement, KeyedElementIndex, LIST_ITEM_MOTION_MS, LIST_SEARCH_COMMAND_IDS, LIST_SEARCH_MODES, LabelledRadialInput, LabelledRangeInput, LabelledSliderInput, LedElement, ListDragSelectController, ListGroupHeaderElement, ListNativeDragController, ListSearchController, ListSelectionController, MAX_PENDING_KEYS, menu_exports as Menu, MillerBaseElement, MillerListElement, MillerTreeController, MulticolListElement, NarrowedTextElement, NotificationManager, NotificationPillElement, PanTextElement, PanelElement, PanelGroupElement, PlotDataSeries, PlotHistogram, PlotMultiAxis, PlotSingleAxis, PlotXyAxis, PolarPad, RadialInputElement, RadioGroup, RangeElement, RangeInput, RangeMultiElement, RatingElement, SCROLL_HOVER_SUPPRESS_ATTR, SUNBURST_PATH_SEPARATOR, SelectHorizElement, ShapefillValueElement, SliderInputElement, SlidingNumberElement, SnackbarElement, SnapContainer, SplitButton, SplitLayoutElement, SplitPanelElement, SunburstTreeListElement, SwipeElement, TabController, TabListElement, TabListItemElement, TabPanelElement, TabPanelsElement, TextShimmerElement, TickledController, TimelineController, TimelineTrackTabElement, TitlebarElement, ToolbarElement, ToolbarGroupElement, TooltipElement, TransitoryLabelElement, TreeBaseElement, TreeController, TreeDataModel, TreeDropController, TreeKeyboardNavController, TreeListElement, TreemapListElement, TwoSplitLayoutElement, VIEW_STATE_CHANGE, VIEW_STATE_VERSION, VerticalListElement, ViewStateController, XyPad, addPanelToStack, anchorFromRow, animateListItemIn, animateListItemOut, applyAgeModification, applyHighlights, applyIsometry, applySnapshot, applyTreeDrop, attachTimelineInteractions, bindViewState, bottomDrawerCommandId, buildHighlightTargets, buildModel, capFlags, capKeys, captionForEntry, captureElementScroll, clampToDisk, clearHighlights, collectContainerNames, collectDataResult, collectPanelIds, collectStackPaths, composeIsometry, computeCanonicalPositions, computeColour, computeHyperbolicLayout, computeIcicleLayout, computeLeafGeoms, computeSunburstLayout, computeTreemapLayout, createAcCompletionProvider, createDataListSearchAdapter, createDefaultListDragPreview, createElementIncrSearch, createElementListSearchAdapter, createEmptyLayout, createEmptyStack, createFuzzySearch, createIncrSearch, createPillRenderer, createScrollHoverGuard, createSplit, createStack, createTreeListAdapter, createTreeListSearchAdapter, createValueResolver, createVerticalListAdapter, decomposeViewIsometry, defaultGroupLabel, defaultSunburstValueAccessor, distribute, dropTargetFor, euclideanRadiusForHyperbolicDistance, extractStack, findEmptiedStacks, findHyperbolicIdOf, findHyperbolicKeyPath, findHyperbolicNode, findHyperbolicPath, findHyperbolicPathById, findIcicleKeyPath, findIcicleNode, findIciclePath, findNode, findPanelNode, findParentOf, findStackOf, findStackPathByName, findSunburstId, findSunburstNodeById, findTreemapNode, findTreemapPath, flagsFromRecord, flagsToRecord, geodesicInterpolate, geodesicPath, geodesicSegment, getActiveDragPanelId, getIcon, getIconEntry, getIconFont, getLeafNeighbours, hasIcon, hyperbolicBranchId, hyperbolicChildIds, hyperbolicDistanceForEuclideanRadius, hyperbolicIdDepth, hyperbolicRelativeFrame, iconBus, identityIsometry, init, insertBeside, invertIsometry, isDragging, isHyperbolicDescendantId, isMultiMode, isSplit, isStack, isSunburstAncestorId, isViewStateful, keyForElement, keysAsFlags, keysToArray, listItemFadeKeyframes, listSearchStyles, mobiusAdd, mobiusFromOrigin, mobiusToOrigin, monitorIndeterminate, moveLeafNodeToEdge, moveLeafStep, norm, normalise, normaliseValue, normalize, onUserScrollIntent, panelGapIndex, parseColourScaleAttr, parseLayout, partitionByGroup, partitionFlags, partitionKeys, poincareDistance, pointerOverAnyHost, prefersReducedMotion, preserveRestoreWeights, pruneDraggedDescendants, readBaseColour, registerFontIcon, registerIcon, registerListSearchCommands, removePanelFromStack, resolveCssVars, resolveGrid, resolveGroupKey, resolveIcicleKeyPath, resolveLeafPath, resolveTracks, restoreElementScroll, rotationIsometry, scrollToOffset, serializeLayout, setActive, setIconFont, setPanelRestoreWeight, snapshotNonEmptyStacks, startListItemOutAnimation, sunburstChildId, sunburstHasChildren, sunburstIdToPath, sunburstParentId, sunburstPathToId, svgToDataUri, swapLeaves, tickledItemStyles, tickledStyles, timelineStyles, toGroupKey, toSnapshot, toggleInSet, toolbarGapIndex, translationFromOrigin, viewIsometry, walk, withoutFlag, wrapRoot };