@ixfx/components 0.15.4 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/bundle/index.d.ts +1223 -48
  2. package/bundle/index.js +875 -700
  3. package/dist/ac-text.d.ts +1 -1
  4. package/dist/{ac-token-Cq7lsYf-.d.ts → ac-token-qEs3FsQv.d.ts} +1 -1
  5. package/dist/ac-token.d.ts +1 -1
  6. package/dist/colour-picker.d.ts +1 -1
  7. package/dist/crumb-navigation-BbqfmkYs.d.ts +147 -0
  8. package/dist/crumb-navigation-YnFVFqrx.js +1290 -0
  9. package/dist/crumbs.d.ts +2 -115
  10. package/dist/crumbs.js +1 -1179
  11. package/dist/{data-grid-CbiubdG2.d.ts → data-grid-CIG2DVip.d.ts} +58 -9
  12. package/dist/{data-grid-Dx3l3dxw.js → data-grid-rMkLX2qo.js} +630 -215
  13. package/dist/data-grid.d.ts +3 -3
  14. package/dist/data-grid.js +1 -1
  15. package/dist/{detail-list-ClkMV0SN.d.ts → detail-list-6iMTNP4N.d.ts} +35 -5
  16. package/dist/{detail-list-q8bs67BM.js → detail-list-C4pDSoI8.js} +218 -7
  17. package/dist/detail-list.d.ts +4 -4
  18. package/dist/detail-list.js +1 -1
  19. package/dist/{flat-list-0xTZdXzh.d.ts → flat-list-JPgQSmLW.d.ts} +28 -5
  20. package/dist/flat-list.d.ts +2 -2
  21. package/dist/flat-list.js +123 -4
  22. package/dist/{grid-list-y6dWJYu3.js → grid-list-CdEkScn-.js} +276 -75
  23. package/dist/{grid-list-BnWJUIBC.d.ts → grid-list-D6n7WH8l.d.ts} +128 -22
  24. package/dist/grid-list.d.ts +3 -3
  25. package/dist/grid-list.js +1 -1
  26. package/dist/{grouped-item-lister-Bhyo-dkH.d.ts → grouped-item-lister-DETte0Dh.d.ts} +1 -1
  27. package/dist/grouped-item-lister.d.ts +1 -1
  28. package/dist/{icon-DR_3rSyZ.d.ts → icon-CDGprSdE.d.ts} +1 -1
  29. package/dist/icons.d.ts +2 -2
  30. package/dist/incr-search.d.ts +1 -1
  31. package/dist/index-9ttcdwlL.d.ts +71 -0
  32. package/dist/{index-BAuqR9El.d.ts → index-Bx0BXPxI.d.ts} +52 -6
  33. package/dist/index-CYRc6Dah.d.ts +475 -0
  34. package/dist/{index-garxxqgV.d.ts → index-CYndvTWH.d.ts} +1 -1
  35. package/dist/{index-BUB5SICW.d.ts → index-CoqtMSd9.d.ts} +1 -1
  36. package/dist/{index-Bz3loIAf.d.ts → index-DN2BWctP.d.ts} +1 -1
  37. package/dist/{index-BPTuCRGb.d.ts → index-Dr80Zdl6.d.ts} +2 -2
  38. package/dist/{index-cnaRPQPp.d.ts → index-DxyyVqzA.d.ts} +2 -2
  39. package/dist/{index-CJ5fshD3.d.ts → index-UvnoZUAo.d.ts} +1 -1
  40. package/dist/index.d.ts +380 -33
  41. package/dist/index.js +2247 -866
  42. package/dist/{labelled-input-base-Dp_9KP0G.d.ts → labelled-input-base-DiTme1JO.d.ts} +1 -1
  43. package/dist/labelled-radial-input.d.ts +2 -2
  44. package/dist/labelled-range-input.d.ts +1 -1
  45. package/dist/labelled-slider-input.d.ts +1 -1
  46. package/dist/list-group-header.d.ts +1 -1
  47. package/dist/{list-native-drag-controller-O2Nruqpi.d.ts → list-native-drag-controller-D_UrXipS.d.ts} +1 -1
  48. package/dist/{list-selection-controller-BeE70lbB.js → list-selection-controller-5L0Nj9tR.js} +87 -2
  49. package/dist/{menu-item-B0zqw2xp.d.ts → menu-item-bF6narIz.d.ts} +1 -1
  50. package/dist/menu.d.ts +2 -2
  51. package/dist/miller-BFRfn0ve.js +2237 -0
  52. package/dist/miller.d.ts +4 -437
  53. package/dist/miller.js +1 -1815
  54. package/dist/panel.d.ts +1 -1
  55. package/dist/pending-BGidHppL.js +91 -0
  56. package/dist/scroll-hover-guard-Cjw6179Q.js +35 -0
  57. package/dist/{split-layout-C5SYbDrq.d.ts → split-layout-CophkNeU.d.ts} +42 -4
  58. package/dist/{split-layout-Deoxy47d.js → split-layout-DTY4PJRp.js} +251 -19
  59. package/dist/split-layout.d.ts +1 -1
  60. package/dist/split-layout.js +1 -1
  61. package/dist/sunburst-tree.d.ts +2 -0
  62. package/dist/sunburst-tree.js +2 -0
  63. package/dist/sunbursttree-list-B_VxOHlo.js +1556 -0
  64. package/dist/sunbursttree-list-D6j5BxVQ.d.ts +303 -0
  65. package/dist/{tab-list-wC0xNz1b.d.ts → tab-list-Df371rqw.d.ts} +1 -1
  66. package/dist/tabs.d.ts +1 -1
  67. package/dist/{titlebar-B5zSTZnF.d.ts → titlebar-Tqu-VvMU.d.ts} +1 -1
  68. package/dist/titlebar.d.ts +1 -1
  69. package/dist/tree-CftHwtEb.js +146 -0
  70. package/dist/{tree-D8VxvR9u.js → tree-Crdpl4nc.js} +275 -47
  71. package/dist/{tree-component-C6UHO-w9.d.ts → tree-component-DgfkxmVY.d.ts} +19 -8
  72. package/dist/tree.d.ts +3 -3
  73. package/dist/tree.js +1 -1
  74. package/dist/types-LU1u0CB8.d.ts +87 -0
  75. package/dist/vertical-list-1V1Vg2O5.js +1993 -0
  76. package/dist/{vertical-list-B8FoU-pU.d.ts → vertical-list-BPDmZx6T.d.ts} +31 -3
  77. package/dist/vertical-list.d.ts +3 -3
  78. package/dist/vertical-list.js +1 -1789
  79. package/dist/view-state-controller-BdyXWUUM.js +305 -0
  80. package/docs-user/README.md +3 -0
  81. package/docs-user/crumbs.md +27 -0
  82. package/docs-user/data-grid.md +77 -5
  83. package/docs-user/detail-list.md +54 -0
  84. package/docs-user/dock.md +54 -12
  85. package/docs-user/flat-list.md +27 -0
  86. package/docs-user/grid-list.md +64 -11
  87. package/docs-user/hyperbolic-tree.md +31 -4
  88. package/docs-user/icicle-tree.md +36 -0
  89. package/docs-user/index.json +17 -2
  90. package/docs-user/llms.txt +3 -0
  91. package/docs-user/miller.md +52 -21
  92. package/docs-user/multicol-list.md +50 -8
  93. package/docs-user/split-layout.md +45 -17
  94. package/docs-user/sunburst-tree.md +223 -0
  95. package/docs-user/tree.md +46 -7
  96. package/docs-user/treemap.md +32 -0
  97. package/docs-user/twosplit-layout.md +22 -0
  98. package/docs-user/user-catalog.md +1 -1
  99. package/docs-user/vertical-list.md +55 -7
  100. package/llms.txt +3 -0
  101. package/package.json +5 -1
package/dist/index.js CHANGED
@@ -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-Crdpl4nc.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-Dx3l3dxw.js";
38
+ import { t as DataGridElement } from "./data-grid-rMkLX2qo.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-C4pDSoI8.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-1V1Vg2O5.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-CdEkScn-.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-BFRfn0ve.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,6 +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";
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_VxOHlo.js";
67
72
  import { SwipeElement } from "./swipe.js";
68
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";
69
74
  import { TransitoryLabelElement } from "./transitory-label.js";
@@ -1407,6 +1412,46 @@ __decorate([state()], SplitButton.prototype, "_loadingItems", void 0);
1407
1412
  __decorate([state()], SplitButton.prototype, "_menuHasItems", void 0);
1408
1413
  SplitButton = __decorate([safeCustomElement(`ixfx-split-button`)], SplitButton);
1409
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
1410
1455
  //#region src/crumbs/crumb-path-controller.ts
1411
1456
  var CrumbPathController = class {
1412
1457
  #element = null;
@@ -3514,6 +3559,46 @@ function findEmptiedStacks(before, after) {
3514
3559
  return emptied;
3515
3560
  }
3516
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
3517
3602
  //#region src/tabs/tab-list-item.ts
3518
3603
  const resizeHandlers = /* @__PURE__ */ new WeakMap();
3519
3604
  const sharedResizeObserver = new ResizeObserver((entries) => {
@@ -4158,6 +4243,11 @@ let DockHostElement = class DockHostElement extends LitElement {
4158
4243
  this._toolbarSlots = /* @__PURE__ */ new Set();
4159
4244
  this._toolbarObserver = null;
4160
4245
  this._panelObservers = /* @__PURE__ */ new Map();
4246
+ this.#viewState = new ViewStateController(this, {
4247
+ kind: `dock-host`,
4248
+ capture: () => this.getViewState()
4249
+ });
4250
+ this.#layoutRestored = false;
4161
4251
  this._onDragStart = (event) => {
4162
4252
  if (event.composedPath().find((n) => n instanceof HTMLElement && n.tagName === `IXFX-DOCK-HOST`) !== this) return;
4163
4253
  const { panelId, pointerId, clientX, clientY } = event.detail;
@@ -4190,6 +4280,10 @@ let DockHostElement = class DockHostElement extends LitElement {
4190
4280
  this._onSplitWeightsChanged = () => {
4191
4281
  this._foldLiveWeights();
4192
4282
  this._scheduleJunctions();
4283
+ this.#viewState.notify();
4284
+ };
4285
+ this._onInnerViewState = (event) => {
4286
+ if (event.target === event.currentTarget) event.stopPropagation();
4193
4287
  };
4194
4288
  this._onSplitCollapsed = (event, collapsed) => {
4195
4289
  const detail = event.detail;
@@ -4200,6 +4294,7 @@ let DockHostElement = class DockHostElement extends LitElement {
4200
4294
  if (node === null || !isStack(node)) return;
4201
4295
  if (collapsed) node.collapsed = true;
4202
4296
  else delete node.collapsed;
4297
+ this.#viewState.notify();
4203
4298
  };
4204
4299
  this._onSplitClose = (event) => {
4205
4300
  event.preventDefault();
@@ -4222,6 +4317,7 @@ let DockHostElement = class DockHostElement extends LitElement {
4222
4317
  if (stack === null || !isStack(stack)) return;
4223
4318
  if (!setActive(stack, panelId)) return;
4224
4319
  this.requestUpdate();
4320
+ this.#viewState.notify();
4225
4321
  this.dispatchEvent(new CustomEvent(`dock-panel-activated`, {
4226
4322
  detail: { panelId },
4227
4323
  bubbles: true,
@@ -4272,6 +4368,9 @@ let DockHostElement = class DockHostElement extends LitElement {
4272
4368
  this._teardownTabDragListeners();
4273
4369
  };
4274
4370
  }
4371
+ #viewState;
4372
+ /** A layout was restored via `setViewState()`; it wins over the initial `layout` attribute. */
4373
+ #layoutRestored;
4275
4374
  connectedCallback() {
4276
4375
  super.connectedCallback();
4277
4376
  this._buildInitialModel();
@@ -4309,7 +4408,8 @@ let DockHostElement = class DockHostElement extends LitElement {
4309
4408
  }
4310
4409
  willUpdate(changed) {
4311
4410
  super.willUpdate(changed);
4312
- if (changed.has(`layout`) && this.layout !== null) {
4411
+ const initial = !this.hasUpdated && this.#layoutRestored;
4412
+ if (changed.has(`layout`) && this.layout !== null && !initial) {
4313
4413
  const parsed = parseLayout(this.layout);
4314
4414
  if (parsed) {
4315
4415
  this._model = parsed.root;
@@ -4341,6 +4441,10 @@ let DockHostElement = class DockHostElement extends LitElement {
4341
4441
  this._scheduleJunctions();
4342
4442
  }
4343
4443
  _buildInitialModel() {
4444
+ if (this.#layoutRestored) {
4445
+ this._mergePoolIntoModel();
4446
+ return;
4447
+ }
4344
4448
  if (this.layout !== null) {
4345
4449
  const parsed = parseLayout(this.layout);
4346
4450
  if (parsed) {
@@ -4475,21 +4579,56 @@ let DockHostElement = class DockHostElement extends LitElement {
4475
4579
  }
4476
4580
  this._pool = current;
4477
4581
  this.requestUpdate();
4478
- if (structuralChange) this._emitChange();
4582
+ if (structuralChange) this._emitChange(`data`);
4583
+ }
4584
+ /**
4585
+ * Snapshot the layout (live split weights folded in from the DOM). With
4586
+ * `includePending` (default), panels the restored layout names but the
4587
+ * host doesn't hold yet are kept in place; without it they are left out.
4588
+ */
4589
+ getViewState(options) {
4590
+ const layout = this._captureLayout();
4591
+ const withPending = options?.includePending ?? true;
4592
+ return this.#viewState.shape({ layout: withPending ? layout : withoutAbsentPanels(layout, (id) => this._findPanelEl(id) !== void 0) }, options);
4593
+ }
4594
+ /**
4595
+ * Restore a layout. Panels it names that aren't in the host yet keep their
4596
+ * place (shown as a placeholder tab) and slot in when they arrive — as a
4597
+ * light-DOM child, or via `addPanel()` without a target. Pooled panels it
4598
+ * doesn't name are appended to the first stack admitting their kind.
4599
+ */
4600
+ setViewState(state) {
4601
+ if (!this.#viewState.accepts(state) || state.layout === void 0) return;
4602
+ if (!isDockLayout(state.layout)) {
4603
+ console.warn(`[dock-host] ignoring view state with a malformed layout`);
4604
+ return;
4605
+ }
4606
+ const layout = state.layout;
4607
+ this.#viewState.restoring(() => {
4608
+ this.#layoutRestored = true;
4609
+ this._returnAllToolbars();
4610
+ this._loadGeneration++;
4611
+ this._model = normalize(cloneNode(layout.root));
4612
+ this._mergePoolIntoModel();
4613
+ this._liveStacks = snapshotNonEmptyStacks(this._model);
4614
+ this.requestUpdate();
4615
+ this._emitChange();
4616
+ });
4479
4617
  }
4480
- /** Serialize the current layout, folding live split weights from the DOM. */
4481
- saveLayout() {
4618
+ /**
4619
+ * Placeholders are part of the layout tree rather than a separate pending
4620
+ * set, so there is nothing to clear here: remove a placeholder with
4621
+ * `removePanel(id)`.
4622
+ */
4623
+ clearPendingViewState() {}
4624
+ /** The current layout, folding live split weights from the DOM. */
4625
+ _captureLayout() {
4482
4626
  this._foldLiveWeights();
4483
4627
  return serializeLayout(this._model);
4484
4628
  }
4485
- loadLayout(layout) {
4486
- this._returnAllToolbars();
4487
- this._loadGeneration++;
4488
- this._model = normalize(cloneNode(layout.root));
4489
- this._mergePoolIntoModel();
4490
- this._liveStacks = snapshotNonEmptyStacks(this._model);
4491
- this.requestUpdate();
4492
- this._emitChange();
4629
+ /** Whether `panelId` is in the model only as a placeholder from a restored layout. */
4630
+ _isPlaceholder(panelId) {
4631
+ return !this._pool.some((p) => p.panelId === panelId) && collectPanelIds(this._model).includes(panelId);
4493
4632
  }
4494
4633
  /**
4495
4634
  * Dock a panel. Returns false (without touching the DOM) when the
@@ -4536,11 +4675,12 @@ let DockHostElement = class DockHostElement extends LitElement {
4536
4675
  if (panel.collapsible !== void 0) el.collapsible = panel.collapsible;
4537
4676
  if (panel.kind !== void 0) el.kind = panel.kind;
4538
4677
  }
4678
+ const takesSavedPlace = target === void 0 && this._isPlaceholder(el.panelId);
4539
4679
  if (el.parentElement !== this) this.appendChild(el);
4540
4680
  if (!this._pool.some((p) => p.panelId === el.panelId)) this._pool = [...this._pool, el];
4541
4681
  this._returnAllToolbars();
4542
- if (collectPanelIds(this._model).includes(el.panelId)) this._removeFromModel(el.panelId);
4543
- if (splitTarget !== null) insertBeside(this._model, splitTarget, opToDirection(op), createStack([el.panelId]), `after`);
4682
+ if (!takesSavedPlace && collectPanelIds(this._model).includes(el.panelId)) this._removeFromModel(el.panelId);
4683
+ if (takesSavedPlace) {} else if (splitTarget !== null) insertBeside(this._model, splitTarget, opToDirection(op), createStack([el.panelId]), `after`);
4544
4684
  else if (stackPath !== null) {
4545
4685
  const stack = findNode(this._model, stackPath);
4546
4686
  if (stack !== null && isStack(stack)) addPanelToStack(stack, el.panelId, target?.index);
@@ -4581,6 +4721,7 @@ let DockHostElement = class DockHostElement extends LitElement {
4581
4721
  if (stack === null || !isStack(stack)) return false;
4582
4722
  if (!setActive(stack, panelId)) return false;
4583
4723
  this.requestUpdate();
4724
+ this.#viewState.notify();
4584
4725
  this.dispatchEvent(new CustomEvent(`dock-panel-activated`, {
4585
4726
  detail: { panelId },
4586
4727
  bubbles: true,
@@ -4640,7 +4781,7 @@ let DockHostElement = class DockHostElement extends LitElement {
4640
4781
  * {@link setLeafPlacementPolicy}'s `allowEdgeSplit` (default `true`).
4641
4782
  * Pass `[]` to seal the leaf (nothing admitted). Warns when no stack
4642
4783
  * currently carries `name`, but the rule is still stored and applies once
4643
- * one appears. Rules are host policy: kept across `loadLayout()`, never
4784
+ * one appears. Rules are host policy: kept across `setViewState()`, never
4644
4785
  * serialized.
4645
4786
  */
4646
4787
  setLeafExclusiveContents(name, kinds) {
@@ -4687,7 +4828,7 @@ let DockHostElement = class DockHostElement extends LitElement {
4687
4828
  * unconstrained* leaf — the policy is not propagated to it.
4688
4829
  *
4689
4830
  * Warns when no stack currently carries `name`; the policy is stored and
4690
- * applies once one appears. Host policy: kept across `loadLayout()`, never
4831
+ * applies once one appears. Host policy: kept across `setViewState()`, never
4691
4832
  * serialized.
4692
4833
  */
4693
4834
  setLeafPlacementPolicy(name, policy) {
@@ -4836,7 +4977,8 @@ let DockHostElement = class DockHostElement extends LitElement {
4836
4977
  * already docked here or its kind is refused by `acceptsKinds`.
4837
4978
  */
4838
4979
  acceptExternalPanel(snapshot, at) {
4839
- if (collectPanelIds(this._model).includes(snapshot.panelId)) return null;
4980
+ const placeholder = this._isPlaceholder(snapshot.panelId);
4981
+ if (!placeholder && collectPanelIds(this._model).includes(snapshot.panelId)) return null;
4840
4982
  if (this.acceptsKinds !== null && !this.acceptsKinds.includes(normalizeKind(snapshot.kind))) return null;
4841
4983
  const el = document.createElement(`ixfx-dock-panel`);
4842
4984
  el.panelId = snapshot.panelId;
@@ -4849,7 +4991,7 @@ let DockHostElement = class DockHostElement extends LitElement {
4849
4991
  if (`contentHTML` in snapshot && snapshot.contentHTML) el.innerHTML = snapshot.contentHTML;
4850
4992
  this._returnAllToolbars();
4851
4993
  this._pool = [...this._pool, el];
4852
- this._appendPanelToFirstStack(el.panelId);
4994
+ if (!placeholder) this._appendPanelToFirstStack(el.panelId);
4853
4995
  this.appendChild(el);
4854
4996
  normalize(this._model);
4855
4997
  this.endExternalDrop();
@@ -5345,10 +5487,11 @@ let DockHostElement = class DockHostElement extends LitElement {
5345
5487
  hostEmpty: now.size === 0
5346
5488
  });
5347
5489
  }
5348
- _emitChange() {
5349
- const layout = this.saveLayout();
5490
+ _emitChange(reason = `user`) {
5491
+ const layout = this._captureLayout();
5350
5492
  this._announceEmptiedStacks();
5351
5493
  this._emit(`dock-change`, { layout });
5494
+ this.#viewState.notify(reason);
5352
5495
  }
5353
5496
  render() {
5354
5497
  return html`
@@ -5384,6 +5527,7 @@ let DockHostElement = class DockHostElement extends LitElement {
5384
5527
  return html`
5385
5528
  <ixfx-split-layout
5386
5529
  class="split"
5530
+ forget-absent
5387
5531
  direction=${node.direction}
5388
5532
  size=${size === void 0 ? nothing : live(String(size))}
5389
5533
  style=${`--split-gutter-size: var(--dock-split-bar-size, 2px); --split-collapsed-size: var(--dock-collapsed-size, 40px)`}
@@ -5394,6 +5538,7 @@ let DockHostElement = class DockHostElement extends LitElement {
5394
5538
  @ixfx-split-collapse=${(e) => this._onSplitCollapsed(e, true)}
5395
5539
  @ixfx-split-expand=${(e) => this._onSplitCollapsed(e, false)}
5396
5540
  @ixfx-split-close=${this._onSplitClose}
5541
+ @view-state-change=${this._onInnerViewState}
5397
5542
  >
5398
5543
  ${node.children.map((child, i) => this._renderNode(child, [...path, i], node.sizes[i]))}
5399
5544
  </ixfx-split-layout>
@@ -6767,269 +6912,6 @@ function createGestureHandlers(host) {
6767
6912
  };
6768
6913
  }
6769
6914
  //#endregion
6770
- //#region src/hyperbolic-tree/hyperbolictree-styles.ts
6771
- /** Shadow-DOM styles for `ixfx-hyperbolictree-list`. */
6772
- const hyperbolicTreeStyles = css`
6773
- :host {
6774
- --hyperbolictree-node-color: var(--surface-4);
6775
- --hyperbolictree-node-text: var(--surface-3-text);
6776
- --hyperbolictree-edge-color: var(--surface-6);
6777
- --hyperbolictree-boundary-color: var(--border-subtle);
6778
- /* Real screen px — converted to SVG units from the element's rendered size. */
6779
- --hyperbolictree-font-size: 13px;
6780
- --hyperbolictree-box-color: var(--hyperbolictree-node-color);
6781
- --hyperbolictree-box-text: var(--surface-4-text);
6782
- --hyperbolictree-box-border: var(--surface-2);
6783
- --hyperbolictree-branch-hue-start: 250;
6784
- --hyperbolictree-branch-chroma: 0.13;
6785
- --hyperbolictree-branch-lightness: 62%;
6786
- --hyperbolictree-branch-mix: 55%;
6787
-
6788
- display: block;
6789
- position: relative;
6790
- overflow: hidden;
6791
- outline: none;
6792
- overscroll-behavior: contain;
6793
- }
6794
-
6795
- /*
6796
- * delegatesFocus means the host itself reports as focused whenever
6797
- * the shadow DOM's focused descendant (the svg) is — without this,
6798
- * the host also picks up the browser's own default focus ring/fill
6799
- * sized to the whole box.
6800
- */
6801
- :host(:focus),
6802
- :host(:focus-visible) {
6803
- outline: none;
6804
- }
6805
-
6806
- .hyperbolictree {
6807
- display: block;
6808
- width: 100%;
6809
- height: 100%;
6810
- outline: none;
6811
- touch-action: none;
6812
- overscroll-behavior: contain;
6813
- }
6814
-
6815
- .disk-boundary {
6816
- fill: none;
6817
- stroke: var(--hyperbolictree-boundary-color);
6818
- stroke-width: 1;
6819
- }
6820
-
6821
- .edge {
6822
- fill: none;
6823
- stroke: var(--hyperbolictree-edge-color);
6824
- /* --edge-weight (0..1, average of the endpoint nodes' --node-weight) thickens heavier edges. */
6825
- stroke-width: calc(1px + var(--edge-weight, 0.5) * 3px);
6826
- /* Constant on-screen width, however far the disk is zoomed. */
6827
- vector-effect: non-scaling-stroke;
6828
- }
6829
-
6830
- .node {
6831
- cursor: pointer;
6832
- }
6833
-
6834
- /*
6835
- * color-by-branch: each top-level branch gets an evenly-spaced hue,
6836
- * mixed into the normal node/box/edge colour (rather than replacing it)
6837
- * so it stays in keeping with — and legible in — the current theme.
6838
- * Only sets the base colour vars, so selected/tickled states still win.
6839
- */
6840
- :host([color-by-branch]) .node,
6841
- :host([color-by-branch]) .edge {
6842
- --_branch-color: oklch(
6843
- var(--hyperbolictree-branch-lightness)
6844
- var(--hyperbolictree-branch-chroma)
6845
- calc(var(--hyperbolictree-branch-hue-start) + var(--branch-index) * 360 / var(--branch-count))
6846
- );
6847
- }
6848
-
6849
- :host([color-by-branch]) .node {
6850
- --_node-base: color-mix(in oklab, var(--hyperbolictree-node-color), var(--_branch-color) var(--hyperbolictree-branch-mix));
6851
- --_box-base: color-mix(in oklab, var(--hyperbolictree-box-color), var(--_branch-color) var(--hyperbolictree-branch-mix));
6852
- }
6853
-
6854
- :host([color-by-branch]) .edge {
6855
- stroke: color-mix(in oklab, var(--hyperbolictree-edge-color), var(--_branch-color) 50%);
6856
- }
6857
-
6858
- .node-hit {
6859
- fill: transparent;
6860
- pointer-events: all;
6861
- }
6862
-
6863
- .node-dot {
6864
- /* --node-weight (0..1, normalized across the visible node set) tints toward --accent for heavier nodes. */
6865
- fill: color-mix(in oklab, var(--_node-base, var(--hyperbolictree-node-color)), var(--accent) calc(var(--node-weight, 0.5) * 50%));
6866
- stroke: var(--surface-2);
6867
- stroke-width: 1;
6868
- pointer-events: none;
6869
- }
6870
-
6871
- .node-box {
6872
- fill: color-mix(in oklab, var(--_box-base, var(--hyperbolictree-box-color)), var(--accent) calc(var(--node-weight, 0.5) * 25%));
6873
- stroke: var(--hyperbolictree-box-border);
6874
- stroke-width: 1.5;
6875
- /* Keep the border crisp however far the box has been scaled down. */
6876
- vector-effect: non-scaling-stroke;
6877
- }
6878
-
6879
- /* Text-less stand-in for a box too small, or too crowded, to label. */
6880
- .node-box.marker {
6881
- stroke-width: 1;
6882
- }
6883
-
6884
- /* Pure user-space font-size; set per render from --hyperbolictree-font-size. */
6885
- .hyperbolictree-label {
6886
- fill: var(--hyperbolictree-node-text);
6887
- cursor: pointer;
6888
- user-select: none;
6889
- }
6890
-
6891
- .box-label {
6892
- fill: var(--hyperbolictree-box-text);
6893
- pointer-events: none;
6894
- }
6895
-
6896
- /*
6897
- * State fills come paired with their theme text tokens: a box label sits
6898
- * on the state fill itself, so e.g. --item-bg-selected needs
6899
- * --item-text-selected on top, not the resting box text colour.
6900
- */
6901
- .node.tickled .node-dot,
6902
- .node.tickled .node-box {
6903
- fill: var(--item-bg-tickled);
6904
- }
6905
-
6906
- .node.tickled .box-label {
6907
- fill: var(--item-text-tickled);
6908
- }
6909
-
6910
- .node.selected .node-dot,
6911
- .node.selected .node-box {
6912
- fill: var(--item-bg-selected);
6913
- }
6914
-
6915
- .node.selected .box-label {
6916
- fill: var(--item-text-selected);
6917
- }
6918
-
6919
- .node.selected.tickled .node-dot,
6920
- .node.selected.tickled .node-box {
6921
- fill: var(--item-bg-selected-tickled);
6922
- }
6923
-
6924
- .node.focused .node-dot,
6925
- .node.focused .node-box {
6926
- stroke: var(--accent);
6927
- stroke-width: 3;
6928
- }
6929
-
6930
- /* A hovered label may sit over busy neighbours — give it a halo so it always reads. */
6931
- .node.tickled .hyperbolictree-label:not(.box-label) {
6932
- paint-order: stroke;
6933
- stroke: var(--surface-2);
6934
- stroke-width: 4px;
6935
- stroke-linejoin: round;
6936
- }
6937
-
6938
- .node.filtered {
6939
- opacity: 0.3;
6940
- }
6941
-
6942
- .node.loading .node-dot,
6943
- .node.loading .node-box {
6944
- opacity: 0.6;
6945
- }
6946
-
6947
-
6948
- /* Child preview: a clickable list of a crowded node's children. */
6949
- .child-preview {
6950
- position: absolute;
6951
- z-index: 1;
6952
- min-width: 8rem;
6953
- max-width: min(18rem, 60%);
6954
- max-height: 45%;
6955
- overflow: auto;
6956
- padding: var(--space-xs, 4px);
6957
- background: var(--surface-3);
6958
- color: var(--surface-3-text);
6959
- border: var(--border-subtle);
6960
- border-radius: var(--radius-m, 6px);
6961
- box-shadow: var(--shadow-m);
6962
- font-size: var(--hyperbolictree-font-size);
6963
- /* Beside the node, never over it, with the dot level with the popover's middle. */
6964
- transform: translate(14px, -50%);
6965
- }
6966
-
6967
- .child-preview.left {
6968
- transform: translate(calc(-100% - 14px), -50%);
6969
- }
6970
-
6971
- .child-preview.below {
6972
- transform: translate(-50%, 14px);
6973
- }
6974
-
6975
- .child-preview.above {
6976
- transform: translate(-50%, calc(-100% - 14px));
6977
- }
6978
-
6979
- .child-preview-title {
6980
- display: flex;
6981
- align-items: center;
6982
- gap: 4px;
6983
- padding: 2px 6px 4px;
6984
- font-weight: 600;
6985
- }
6986
-
6987
- .child-preview-title span {
6988
- overflow: hidden;
6989
- text-overflow: ellipsis;
6990
- white-space: nowrap;
6991
- }
6992
-
6993
- .child-preview button.child-preview-up {
6994
- display: inline-flex;
6995
- flex: none;
6996
- width: auto;
6997
- padding: 2px;
6998
- }
6999
-
7000
- .child-preview ul {
7001
- margin: 0;
7002
- padding: 0;
7003
- list-style: none;
7004
- }
7005
-
7006
- .child-preview button {
7007
- display: block;
7008
- width: 100%;
7009
- padding: 2px 6px;
7010
- border: none;
7011
- border-radius: var(--radius-s, 4px);
7012
- background: none;
7013
- color: inherit;
7014
- font: inherit;
7015
- text-align: start;
7016
- overflow: hidden;
7017
- text-overflow: ellipsis;
7018
- white-space: nowrap;
7019
- cursor: pointer;
7020
- }
7021
-
7022
- .child-preview button:hover {
7023
- background: var(--item-bg-tickled);
7024
- color: var(--item-text-tickled);
7025
- }
7026
-
7027
- .child-preview-more {
7028
- padding: 2px 6px;
7029
- color: var(--surface-muted-text);
7030
- }
7031
- `;
7032
- //#endregion
7033
6915
  //#region src/hyperbolic-tree/layout.ts
7034
6916
  const ID_SEPARATOR = `\u001F`;
7035
6917
  /**
@@ -7444,6 +7326,564 @@ function computeHyperbolicLayout(root, options = {}) {
7444
7326
  };
7445
7327
  }
7446
7328
  //#endregion
7329
+ //#region src/hyperbolic-tree/hyperbolic-view-state.ts
7330
+ function indexHyperbolicKeys(root) {
7331
+ const byKey = /* @__PURE__ */ new Map();
7332
+ const nodes = /* @__PURE__ */ new Set();
7333
+ const walk = (node) => {
7334
+ nodes.add(node);
7335
+ if (!byKey.has(node.item.key)) byKey.set(node.item.key, node);
7336
+ for (const child of node.children ?? []) walk(child);
7337
+ };
7338
+ if (root) walk(root);
7339
+ return {
7340
+ byKey,
7341
+ nodes
7342
+ };
7343
+ }
7344
+ /** Key path from the root's children to the node with layout id `id`. */
7345
+ function centerPathOf(root, id) {
7346
+ if (id === void 0) return [];
7347
+ return findHyperbolicPathById(root, id)?.slice(1).map((n) => n.item.key) ?? [];
7348
+ }
7349
+ /** Walk `path` from the root, matching each step by key among the loaded children. */
7350
+ function resolveCenterPath(root, path) {
7351
+ if (!root) return { kind: `missing` };
7352
+ let node = root;
7353
+ let id = root.item.key;
7354
+ for (const [depth, key] of path.entries()) {
7355
+ const children = node.children;
7356
+ if (children === void 0) return node.item.isLeaf === true ? { kind: `missing` } : {
7357
+ kind: `load`,
7358
+ node,
7359
+ depth
7360
+ };
7361
+ const index = children.findIndex((c) => c.item.key === key);
7362
+ if (index < 0) return { kind: `missing` };
7363
+ id = hyperbolicChildIds(id, children)[index];
7364
+ node = children[index];
7365
+ }
7366
+ return {
7367
+ kind: `found`,
7368
+ id: path.length === 0 ? void 0 : id
7369
+ };
7370
+ }
7371
+ /**
7372
+ * As `reconcileSelection` in `tree/tree-view-state.ts`, but a selected node
7373
+ * still in the tree is kept by identity — keys may repeat here, so re-keying
7374
+ * it would jump the selection to the first node sharing its key on every
7375
+ * lazy load. Only nodes that left the tree are re-keyed (or go pending).
7376
+ */
7377
+ function reconcileHyperbolicSelection(selected, pending, index, canSelect, single) {
7378
+ if (selected.size === 0 && pending.size === 0) return {
7379
+ selected,
7380
+ pending,
7381
+ restored: false,
7382
+ changed: false
7383
+ };
7384
+ const next = /* @__PURE__ */ new Set();
7385
+ const nextPending = /* @__PURE__ */ new Set();
7386
+ let changed = false;
7387
+ for (const node of selected) {
7388
+ if (index.nodes.has(node)) {
7389
+ next.add(node);
7390
+ continue;
7391
+ }
7392
+ changed = true;
7393
+ const current = index.byKey.get(node.item.key);
7394
+ if (current) next.add(current);
7395
+ else nextPending.add(node.item.key);
7396
+ }
7397
+ let restored;
7398
+ for (const key of pending) {
7399
+ const node = index.byKey.get(key);
7400
+ if (node && canSelect(node)) {
7401
+ next.add(node);
7402
+ restored = node;
7403
+ changed = true;
7404
+ } else nextPending.add(key);
7405
+ }
7406
+ if (!changed) return {
7407
+ selected,
7408
+ pending,
7409
+ restored: false,
7410
+ changed: false
7411
+ };
7412
+ if (single && next.size > 0) return {
7413
+ selected: /* @__PURE__ */ new Set([restored ?? [...next].at(-1)]),
7414
+ pending: EMPTY_KEYS,
7415
+ restored: restored !== void 0,
7416
+ changed: true
7417
+ };
7418
+ return {
7419
+ selected: next,
7420
+ pending: capKeys(nextPending),
7421
+ restored: restored !== void 0,
7422
+ changed: true
7423
+ };
7424
+ }
7425
+ /**
7426
+ * Centre: a live centre whose path no longer resolves goes pending (the view
7427
+ * falls back to the root); one that resolves to a new id is re-seated; a
7428
+ * pending path that now resolves is applied.
7429
+ */
7430
+ function reconcileHyperbolicViewState(input) {
7431
+ const { root } = input;
7432
+ let center;
7433
+ let pendingCenter = input.pendingCenter;
7434
+ if (input.focusId !== void 0) {
7435
+ const live = resolveCenterPath(root, input.centerPath);
7436
+ if (live.kind !== `found` || live.id === void 0) {
7437
+ pendingCenter ??= input.centerPath;
7438
+ center = {
7439
+ id: void 0,
7440
+ path: [],
7441
+ restored: false
7442
+ };
7443
+ } else if (live.id !== input.focusId) center = {
7444
+ id: live.id,
7445
+ path: input.centerPath,
7446
+ restored: false
7447
+ };
7448
+ }
7449
+ if (pendingCenter) {
7450
+ const resolved = resolveCenterPath(root, pendingCenter);
7451
+ if (resolved.kind === `found`) {
7452
+ center = {
7453
+ id: resolved.id,
7454
+ path: pendingCenter,
7455
+ restored: true
7456
+ };
7457
+ pendingCenter = void 0;
7458
+ }
7459
+ }
7460
+ const selection = reconcileHyperbolicSelection(input.selected, input.pendingSelected, input.index, input.canSelect, input.singleSelect);
7461
+ return {
7462
+ center,
7463
+ pendingCenter,
7464
+ selection
7465
+ };
7466
+ }
7467
+ /** Sanitise a JSON zoom: a finite positive number, else `undefined`. */
7468
+ function zoomFromJson(value) {
7469
+ return typeof value === `number` && Number.isFinite(value) && value > 0 ? value : void 0;
7470
+ }
7471
+ //#endregion
7472
+ //#region src/hyperbolic-tree/hyperbolic-view-state-binding.ts
7473
+ function createHyperbolicViewState(host) {
7474
+ const { controller } = host;
7475
+ /** Key path of the centre as last seen — what goes pending if it leaves the tree. */
7476
+ let centerPath = [];
7477
+ let pendingCenter;
7478
+ let pendingSelected = EMPTY_KEYS;
7479
+ let pendingFocus;
7480
+ /** Where keyboard / search navigation last put the cursor. */
7481
+ let keyboardCursor;
7482
+ let structureVersion = 0;
7483
+ let reconciledVersion = -1;
7484
+ let cache;
7485
+ const index = () => {
7486
+ if (cache?.version !== structureVersion) cache = {
7487
+ version: structureVersion,
7488
+ index: indexHyperbolicKeys(host.root())
7489
+ };
7490
+ return cache.index;
7491
+ };
7492
+ /** Key of the keyboard cursor, while the cursor is still where the keyboard put it. */
7493
+ const cursorKey = () => keyboardCursor && keyboardCursor.id === host.tickled() ? keyboardCursor.key : void 0;
7494
+ const applyCenter = (id, path, restored) => {
7495
+ centerPath = path;
7496
+ if (host.focusId() === id) return;
7497
+ host.setCenter(id, restored);
7498
+ controller.notify();
7499
+ };
7500
+ const reconcileTickled = (idx) => {
7501
+ const tickled = host.tickled();
7502
+ const alive = tickled !== void 0 && findHyperbolicPathById(host.root(), tickled) !== void 0;
7503
+ const cursor = reconcileCursor(cursorKey(), pendingFocus, (key) => alive && idx.byKey.has(key), (key) => host.visibleIdOf(key) !== void 0);
7504
+ pendingFocus = cursor.pending;
7505
+ if (cursor.clear || tickled !== void 0 && !alive) host.setTickled(void 0);
7506
+ const id = cursor.apply === void 0 ? void 0 : host.visibleIdOf(cursor.apply);
7507
+ if (cursor.apply !== void 0 && id !== void 0) {
7508
+ host.setTickled(id);
7509
+ keyboardCursor = {
7510
+ id,
7511
+ key: cursor.apply
7512
+ };
7513
+ controller.notify();
7514
+ }
7515
+ };
7516
+ /** Move state for missing keys to pending; apply pending state for keys now loaded. */
7517
+ const reconcile = () => {
7518
+ reconciledVersion = structureVersion;
7519
+ const root = host.root();
7520
+ if (!root) return;
7521
+ const idx = index();
7522
+ controller.restoring(() => {
7523
+ const r = reconcileHyperbolicViewState({
7524
+ root,
7525
+ index: idx,
7526
+ focusId: host.focusId(),
7527
+ centerPath,
7528
+ pendingCenter,
7529
+ selected: host.selected(),
7530
+ pendingSelected,
7531
+ canSelect: host.canSelect,
7532
+ singleSelect: host.singleSelect()
7533
+ });
7534
+ pendingCenter = r.pendingCenter;
7535
+ if (r.center) applyCenter(r.center.id, r.center.path, r.center.restored);
7536
+ const sel = r.selection;
7537
+ if (sel.changed) {
7538
+ pendingSelected = sel.pending;
7539
+ const primary = host.selectedNode();
7540
+ host.setSelection(sel.selected, primary && sel.selected.has(primary) ? primary : [...sel.selected].at(-1), sel.restored);
7541
+ }
7542
+ reconcileTickled(idx);
7543
+ });
7544
+ };
7545
+ return {
7546
+ /** The structure changed (new root / model, invalidation, lazy load). */
7547
+ invalidate() {
7548
+ structureVersion++;
7549
+ },
7550
+ /** Run in `willUpdate`. `centerGone` — the centre vanished through in-place mutation. */
7551
+ reconcileIfNeeded(centerGone) {
7552
+ if (centerGone) structureVersion++;
7553
+ if (reconciledVersion !== structureVersion) reconcile();
7554
+ },
7555
+ /** An explicit centre change: supersedes a saved one still waiting. */
7556
+ centred(id) {
7557
+ pendingCenter = void 0;
7558
+ centerPath = centerPathOf(host.root(), id);
7559
+ controller.notify();
7560
+ },
7561
+ /** Selection changed — explicit changes supersede a saved selection still waiting. */
7562
+ selectionChanged() {
7563
+ if (!controller.isRestoring) pendingSelected = EMPTY_KEYS;
7564
+ controller.notify();
7565
+ },
7566
+ clearPendingSelection() {
7567
+ pendingSelected = EMPTY_KEYS;
7568
+ },
7569
+ /** The cursor moved by keyboard / search navigation. */
7570
+ keyboardCursorMoved(id, key) {
7571
+ keyboardCursor = {
7572
+ id,
7573
+ key
7574
+ };
7575
+ pendingFocus = void 0;
7576
+ controller.notify();
7577
+ },
7578
+ /** A user-visible change with no pending facet (e.g. a zoom gesture). */
7579
+ notify() {
7580
+ controller.notify();
7581
+ },
7582
+ get(options) {
7583
+ const withPending = options?.includePending ?? true;
7584
+ return controller.shape({
7585
+ selected: keysToArray([...host.selected()].map((n) => n.item.key), withPending ? pendingSelected : []),
7586
+ focus: cursorKey() ?? (withPending ? pendingFocus : void 0),
7587
+ center: [...(withPending ? pendingCenter : void 0) ?? centerPath],
7588
+ zoom: host.zoom()
7589
+ }, options);
7590
+ },
7591
+ set(state) {
7592
+ if (!controller.accepts(state)) return;
7593
+ controller.restoring(() => {
7594
+ if (state.selected) {
7595
+ pendingSelected = capKeys(new Set(state.selected));
7596
+ if (host.selected().size > 0) host.setSelection(/* @__PURE__ */ new Set(), void 0, true);
7597
+ }
7598
+ if (state.focus !== void 0) pendingFocus = state.focus;
7599
+ const zoom = zoomFromJson(state.zoom);
7600
+ if (zoom !== void 0 && zoom !== host.zoom()) {
7601
+ host.setZoom(zoom);
7602
+ controller.notify();
7603
+ }
7604
+ const center = pathFromJson(state.center);
7605
+ if (center) pendingCenter = center;
7606
+ reconcile();
7607
+ if (center && pendingCenter) applyCenter(void 0, [], true);
7608
+ });
7609
+ },
7610
+ clearPending() {
7611
+ pendingSelected = EMPTY_KEYS;
7612
+ pendingFocus = void 0;
7613
+ pendingCenter = void 0;
7614
+ },
7615
+ /** Load the next unloaded ancestor on a pending centre path (one load at a time). */
7616
+ continueRestoreLoads() {
7617
+ if (!pendingCenter || !host.restoreLoads()) return;
7618
+ const resolved = resolveCenterPath(host.root(), pendingCenter);
7619
+ if (resolved.kind === `load`) host.load(resolved.node, resolved.depth);
7620
+ }
7621
+ };
7622
+ }
7623
+ //#endregion
7624
+ //#region src/hyperbolic-tree/hyperbolictree-styles.ts
7625
+ /** Shadow-DOM styles for `ixfx-hyperbolictree-list`. */
7626
+ const hyperbolicTreeStyles = css`
7627
+ :host {
7628
+ --hyperbolictree-node-color: var(--surface-4);
7629
+ --hyperbolictree-node-text: var(--surface-3-text);
7630
+ --hyperbolictree-edge-color: var(--surface-6);
7631
+ --hyperbolictree-boundary-color: var(--border-subtle);
7632
+ /* Real screen px — converted to SVG units from the element's rendered size. */
7633
+ --hyperbolictree-font-size: 13px;
7634
+ --hyperbolictree-box-color: var(--hyperbolictree-node-color);
7635
+ --hyperbolictree-box-text: var(--surface-4-text);
7636
+ --hyperbolictree-box-border: var(--surface-2);
7637
+ --hyperbolictree-branch-hue-start: 250;
7638
+ --hyperbolictree-branch-chroma: 0.13;
7639
+ --hyperbolictree-branch-lightness: 62%;
7640
+ --hyperbolictree-branch-mix: 55%;
7641
+
7642
+ display: block;
7643
+ position: relative;
7644
+ overflow: hidden;
7645
+ outline: none;
7646
+ overscroll-behavior: contain;
7647
+ }
7648
+
7649
+ /*
7650
+ * delegatesFocus means the host itself reports as focused whenever
7651
+ * the shadow DOM's focused descendant (the svg) is — without this,
7652
+ * the host also picks up the browser's own default focus ring/fill
7653
+ * sized to the whole box.
7654
+ */
7655
+ :host(:focus),
7656
+ :host(:focus-visible) {
7657
+ outline: none;
7658
+ }
7659
+
7660
+ .hyperbolictree {
7661
+ display: block;
7662
+ width: 100%;
7663
+ height: 100%;
7664
+ outline: none;
7665
+ touch-action: none;
7666
+ overscroll-behavior: contain;
7667
+ }
7668
+
7669
+ .disk-boundary {
7670
+ fill: none;
7671
+ stroke: var(--hyperbolictree-boundary-color);
7672
+ stroke-width: 1;
7673
+ }
7674
+
7675
+ .edge {
7676
+ fill: none;
7677
+ stroke: var(--hyperbolictree-edge-color);
7678
+ /* --edge-weight (0..1, average of the endpoint nodes' --node-weight) thickens heavier edges. */
7679
+ stroke-width: calc(1px + var(--edge-weight, 0.5) * 3px);
7680
+ /* Constant on-screen width, however far the disk is zoomed. */
7681
+ vector-effect: non-scaling-stroke;
7682
+ }
7683
+
7684
+ .node {
7685
+ cursor: pointer;
7686
+ }
7687
+
7688
+ /*
7689
+ * color-by-branch: each top-level branch gets an evenly-spaced hue,
7690
+ * mixed into the normal node/box/edge colour (rather than replacing it)
7691
+ * so it stays in keeping with — and legible in — the current theme.
7692
+ * Only sets the base colour vars, so selected/tickled states still win.
7693
+ */
7694
+ :host([color-by-branch]) .node,
7695
+ :host([color-by-branch]) .edge {
7696
+ --_branch-color: oklch(
7697
+ var(--hyperbolictree-branch-lightness)
7698
+ var(--hyperbolictree-branch-chroma)
7699
+ calc(var(--hyperbolictree-branch-hue-start) + var(--branch-index) * 360 / var(--branch-count))
7700
+ );
7701
+ }
7702
+
7703
+ :host([color-by-branch]) .node {
7704
+ --_node-base: color-mix(in oklab, var(--hyperbolictree-node-color), var(--_branch-color) var(--hyperbolictree-branch-mix));
7705
+ --_box-base: color-mix(in oklab, var(--hyperbolictree-box-color), var(--_branch-color) var(--hyperbolictree-branch-mix));
7706
+ }
7707
+
7708
+ :host([color-by-branch]) .edge {
7709
+ stroke: color-mix(in oklab, var(--hyperbolictree-edge-color), var(--_branch-color) 50%);
7710
+ }
7711
+
7712
+ .node-hit {
7713
+ fill: transparent;
7714
+ pointer-events: all;
7715
+ }
7716
+
7717
+ .node-dot {
7718
+ /* --node-weight (0..1, normalized across the visible node set) tints toward --accent for heavier nodes. */
7719
+ fill: color-mix(in oklab, var(--_node-base, var(--hyperbolictree-node-color)), var(--accent) calc(var(--node-weight, 0.5) * 50%));
7720
+ stroke: var(--surface-2);
7721
+ stroke-width: 1;
7722
+ pointer-events: none;
7723
+ }
7724
+
7725
+ .node-box {
7726
+ fill: color-mix(in oklab, var(--_box-base, var(--hyperbolictree-box-color)), var(--accent) calc(var(--node-weight, 0.5) * 25%));
7727
+ stroke: var(--hyperbolictree-box-border);
7728
+ stroke-width: 1.5;
7729
+ /* Keep the border crisp however far the box has been scaled down. */
7730
+ vector-effect: non-scaling-stroke;
7731
+ }
7732
+
7733
+ /* Text-less stand-in for a box too small, or too crowded, to label. */
7734
+ .node-box.marker {
7735
+ stroke-width: 1;
7736
+ }
7737
+
7738
+ /* Pure user-space font-size; set per render from --hyperbolictree-font-size. */
7739
+ .hyperbolictree-label {
7740
+ fill: var(--hyperbolictree-node-text);
7741
+ cursor: pointer;
7742
+ user-select: none;
7743
+ }
7744
+
7745
+ .box-label {
7746
+ fill: var(--hyperbolictree-box-text);
7747
+ pointer-events: none;
7748
+ }
7749
+
7750
+ /*
7751
+ * State fills come paired with their theme text tokens: a box label sits
7752
+ * on the state fill itself, so e.g. --item-bg-selected needs
7753
+ * --item-text-selected on top, not the resting box text colour.
7754
+ */
7755
+ .node.tickled .node-dot,
7756
+ .node.tickled .node-box {
7757
+ fill: var(--item-bg-tickled);
7758
+ }
7759
+
7760
+ .node.tickled .box-label {
7761
+ fill: var(--item-text-tickled);
7762
+ }
7763
+
7764
+ .node.selected .node-dot,
7765
+ .node.selected .node-box {
7766
+ fill: var(--item-bg-selected);
7767
+ }
7768
+
7769
+ .node.selected .box-label {
7770
+ fill: var(--item-text-selected);
7771
+ }
7772
+
7773
+ .node.selected.tickled .node-dot,
7774
+ .node.selected.tickled .node-box {
7775
+ fill: var(--item-bg-selected-tickled);
7776
+ }
7777
+
7778
+ .node.focused .node-dot,
7779
+ .node.focused .node-box {
7780
+ stroke: var(--accent);
7781
+ stroke-width: 3;
7782
+ }
7783
+
7784
+ /* A hovered label may sit over busy neighbours — give it a halo so it always reads. */
7785
+ .node.tickled .hyperbolictree-label:not(.box-label) {
7786
+ paint-order: stroke;
7787
+ stroke: var(--surface-2);
7788
+ stroke-width: 4px;
7789
+ stroke-linejoin: round;
7790
+ }
7791
+
7792
+ .node.filtered {
7793
+ opacity: 0.3;
7794
+ }
7795
+
7796
+ .node.loading .node-dot,
7797
+ .node.loading .node-box {
7798
+ opacity: 0.6;
7799
+ }
7800
+
7801
+
7802
+ /* Child preview: a clickable list of a crowded node's children. */
7803
+ .child-preview {
7804
+ position: absolute;
7805
+ z-index: 1;
7806
+ min-width: 8rem;
7807
+ max-width: min(18rem, 60%);
7808
+ max-height: 45%;
7809
+ overflow: auto;
7810
+ padding: var(--space-xs, 4px);
7811
+ background: var(--surface-3);
7812
+ color: var(--surface-3-text);
7813
+ border: var(--border-subtle);
7814
+ border-radius: var(--radius-m, 6px);
7815
+ box-shadow: var(--shadow-m);
7816
+ font-size: var(--hyperbolictree-font-size);
7817
+ /* Beside the node, never over it, with the dot level with the popover's middle. */
7818
+ transform: translate(14px, -50%);
7819
+ }
7820
+
7821
+ .child-preview.left {
7822
+ transform: translate(calc(-100% - 14px), -50%);
7823
+ }
7824
+
7825
+ .child-preview.below {
7826
+ transform: translate(-50%, 14px);
7827
+ }
7828
+
7829
+ .child-preview.above {
7830
+ transform: translate(-50%, calc(-100% - 14px));
7831
+ }
7832
+
7833
+ .child-preview-title {
7834
+ display: flex;
7835
+ align-items: center;
7836
+ gap: 4px;
7837
+ padding: 2px 6px 4px;
7838
+ font-weight: 600;
7839
+ }
7840
+
7841
+ .child-preview-title span {
7842
+ overflow: hidden;
7843
+ text-overflow: ellipsis;
7844
+ white-space: nowrap;
7845
+ }
7846
+
7847
+ .child-preview button.child-preview-up {
7848
+ display: inline-flex;
7849
+ flex: none;
7850
+ width: auto;
7851
+ padding: 2px;
7852
+ }
7853
+
7854
+ .child-preview ul {
7855
+ margin: 0;
7856
+ padding: 0;
7857
+ list-style: none;
7858
+ }
7859
+
7860
+ .child-preview button {
7861
+ display: block;
7862
+ width: 100%;
7863
+ padding: 2px 6px;
7864
+ border: none;
7865
+ border-radius: var(--radius-s, 4px);
7866
+ background: none;
7867
+ color: inherit;
7868
+ font: inherit;
7869
+ text-align: start;
7870
+ overflow: hidden;
7871
+ text-overflow: ellipsis;
7872
+ white-space: nowrap;
7873
+ cursor: pointer;
7874
+ }
7875
+
7876
+ .child-preview button:hover {
7877
+ background: var(--item-bg-tickled);
7878
+ color: var(--item-text-tickled);
7879
+ }
7880
+
7881
+ .child-preview-more {
7882
+ padding: 2px 6px;
7883
+ color: var(--surface-muted-text);
7884
+ }
7885
+ `;
7886
+ //#endregion
7447
7887
  //#region src/hyperbolic-tree/keyboard.ts
7448
7888
  const unhandled = {
7449
7889
  action: { kind: `none` },
@@ -7542,6 +7982,11 @@ function createLazyChildLoader(source, onChange) {
7542
7982
  if (!model && !loadChildren) return;
7543
7983
  for (const { node, depth } of visible) if (node.children === void 0 && !pending.has(node)) load(node, depth);
7544
7984
  },
7985
+ /** Start loading `node`'s children unless loaded or already loading (view-state restore). */
7986
+ loadOne(node, depth) {
7987
+ const { model, loadChildren } = source();
7988
+ if ((model || loadChildren) && node.children === void 0 && !pending.has(node)) load(node, depth);
7989
+ },
7545
7990
  isPending: (node) => pending.has(node),
7546
7991
  abortAll() {
7547
7992
  for (const aborter of aborters.values()) aborter.abort();
@@ -8062,6 +8507,35 @@ let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitEleme
8062
8507
  this.disableKeyboardNavigation = false;
8063
8508
  this.registry = void 0;
8064
8509
  this._focusId = void 0;
8510
+ this.restoreLoads = true;
8511
+ this.#viewState = createHyperbolicViewState({
8512
+ controller: new ViewStateController(this, {
8513
+ kind: `hyperbolictree-list`,
8514
+ capture: () => this.getViewState()
8515
+ }),
8516
+ root: () => this.#effectiveRoot,
8517
+ focusId: () => this._focusId,
8518
+ setCenter: (id, restored) => this.#restoreCenter(id, restored),
8519
+ selected: () => this._selectedNodes,
8520
+ selectedNode: () => this.selectedNode,
8521
+ canSelect: (node) => this.#canSelect(node),
8522
+ singleSelect: () => this.interactionMode === `implicit`,
8523
+ setSelection: (next, primary, fire) => {
8524
+ const prev = this._selectedNodes;
8525
+ this._selectedNodes = new Set(next);
8526
+ this.selectedNode = primary;
8527
+ if (fire) this.#dispatchSelect(prev);
8528
+ },
8529
+ tickled: () => this.tickled.tickledId,
8530
+ setTickled: (id) => id === void 0 ? this.tickled.clearTickled() : this.tickled.setTickled(id),
8531
+ visibleIdOf: (key) => this.#layout().nodes.find((n) => n.key === key)?.id,
8532
+ zoom: () => this.zoom,
8533
+ setZoom: (zoom) => {
8534
+ this.zoom = zoom;
8535
+ },
8536
+ restoreLoads: () => this.restoreLoads,
8537
+ load: (node, depth) => this.#lazy.loadOne(node, depth)
8538
+ });
8065
8539
  this._viewOverride = void 0;
8066
8540
  this._focusRotation = 0;
8067
8541
  this._zoomOffset = {
@@ -8083,7 +8557,10 @@ let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitEleme
8083
8557
  this.#lazy = createLazyChildLoader(() => ({
8084
8558
  model: this.model,
8085
8559
  loadChildren: this.loadChildren
8086
- }), () => this.requestUpdate());
8560
+ }), () => {
8561
+ this.#viewState.invalidate();
8562
+ this.requestUpdate();
8563
+ });
8087
8564
  this.#lastLabelIds = /* @__PURE__ */ new Set();
8088
8565
  this.search = new ListSearchController(this, { adapter: createTreeListSearchAdapter(this, {
8089
8566
  labelSelector: `.hyperbolictree-label`,
@@ -8130,6 +8607,7 @@ let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitEleme
8130
8607
  },
8131
8608
  zoomState: () => this.#zoomState(),
8132
8609
  setZoomState: (state) => {
8610
+ if (state.zoom !== this.zoom) this.#viewState.notify();
8133
8611
  this.zoom = state.zoom;
8134
8612
  this._zoomOffset = state.offset;
8135
8613
  },
@@ -8152,6 +8630,7 @@ let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitEleme
8152
8630
  if (this._focusId === void 0) return this.#effectiveRoot;
8153
8631
  return findHyperbolicPathById(this.#effectiveRoot, this._focusId)?.at(-1) ?? this.#effectiveRoot;
8154
8632
  }
8633
+ #viewState;
8155
8634
  #preview;
8156
8635
  #lazy;
8157
8636
  /** Labels shown in the last render — what the child preview judges legibility by. */
@@ -8259,46 +8738,30 @@ let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitEleme
8259
8738
  this.#unregisterSearchCommands?.();
8260
8739
  this.#unregisterSearchCommands = registerListSearchCommands(this.#ownedRegistry, this.search);
8261
8740
  }
8741
+ willUpdate(changedProps) {
8742
+ if (changedProps.has(`root`) && this.root || changedProps.has(`model`)) this.#onDataReset();
8743
+ this.#viewState.reconcileIfNeeded(this._focusId !== void 0 && !findHyperbolicPathById(this.#effectiveRoot, this._focusId));
8744
+ }
8262
8745
  updated(changedProps) {
8263
- if (changedProps.has(`root`) && this.root) this.#resetViewState();
8264
8746
  if (changedProps.has(`model`)) {
8265
8747
  this.#modelUnsubscribe?.();
8266
8748
  this.#modelUnsubscribe = void 0;
8267
- if (this.model) {
8268
- this.#modelUnsubscribe = this.model._subscribe((event) => {
8269
- if (event.type === `root`) {
8270
- this.#resetViewState();
8271
- this.requestUpdate();
8272
- } else if (event.type === `invalidate`) this.requestUpdate();
8273
- });
8274
- this.#resetViewState();
8275
- }
8749
+ if (this.model) this.#modelUnsubscribe = this.model._subscribe((event) => {
8750
+ if (event.type === `root`) this.#onDataReset();
8751
+ else if (event.type === `invalidate`) this.#viewState.invalidate();
8752
+ this.requestUpdate();
8753
+ });
8276
8754
  }
8277
- if (changedProps.has(`interactionMode`)) this.clearSelection();
8755
+ if (changedProps.has(`interactionMode`) && changedProps.get(`interactionMode`) !== void 0) this.clearSelection();
8278
8756
  if (changedProps.has(`searchModes`)) this.#syncSearchCommands();
8279
- if (this._focusId !== void 0 && !findHyperbolicPathById(this.#effectiveRoot, this._focusId)) {
8280
- this._focusId = void 0;
8281
- this._viewOverride = void 0;
8282
- this._focusRotation = 0;
8283
- }
8284
8757
  this.#lazy.ensure(this.#layout().nodes);
8758
+ this.#viewState.continueRestoreLoads();
8285
8759
  }
8286
- #resetViewState() {
8287
- this.#clearSelectionInternal();
8288
- this.tickled.clearTickled();
8289
- this._focusId = void 0;
8290
- this._viewOverride = void 0;
8291
- this._focusRotation = 0;
8292
- this._zoomOffset = {
8293
- x: 0,
8294
- y: 0
8295
- };
8760
+ /** New `root` / `model`: drop transient state; keyed view state is reconciled, not reset. */
8761
+ #onDataReset() {
8296
8762
  this._previewId = void 0;
8297
8763
  this.#gestures.cancel();
8298
- if (this.#animFrame !== void 0) {
8299
- cancelAnimationFrame(this.#animFrame);
8300
- this.#animFrame = void 0;
8301
- }
8764
+ this.#viewState.invalidate();
8302
8765
  }
8303
8766
  #canSelect(node) {
8304
8767
  if (this.selectionFilter === `none`) return false;
@@ -8311,6 +8774,7 @@ let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitEleme
8311
8774
  this.selectedNode = void 0;
8312
8775
  }
8313
8776
  #dispatchSelect(previous) {
8777
+ this.#viewState.selectionChanged();
8314
8778
  this.dispatchEvent(new CustomEvent(`select`, {
8315
8779
  detail: {
8316
8780
  selected: new Set(this._selectedNodes),
@@ -8338,6 +8802,7 @@ let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitEleme
8338
8802
  this.requestUpdate();
8339
8803
  }
8340
8804
  clearSelection() {
8805
+ this.#viewState.clearPendingSelection();
8341
8806
  if (this._selectedNodes.size === 0) return;
8342
8807
  const prev = new Set(this._selectedNodes);
8343
8808
  this.#clearSelectionInternal();
@@ -8383,6 +8848,7 @@ let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitEleme
8383
8848
  const previousNode = currentId === void 0 ? void 0 : findHyperbolicPathById(root, currentId)?.at(-1);
8384
8849
  const fromView = this.#viewFromFrameOf(targetId, currentId);
8385
8850
  this._focusId = targetId;
8851
+ this.#viewState.centred(targetId);
8386
8852
  this.#gestures.cancel();
8387
8853
  this._previewId = void 0;
8388
8854
  this.#animateTo(fromView);
@@ -8451,7 +8917,7 @@ let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitEleme
8451
8917
  #focusAndMaybeSelect(node) {
8452
8918
  this.focusOn(node);
8453
8919
  const id = this.#idOf(node);
8454
- if (id !== void 0) this.tickled.setTickled(id);
8920
+ if (id !== void 0) this.#setKeyboardCursor(id, node);
8455
8921
  if (this.interactionMode === `implicit`) this.select(node);
8456
8922
  }
8457
8923
  /** Focus + select the node at `keys` (path from the root's children). Returns `true` if found. */
@@ -8489,10 +8955,15 @@ let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitEleme
8489
8955
  const id = node ? this.#idOf(node) : void 0;
8490
8956
  if (!node || id === void 0) return false;
8491
8957
  if (!this.#layout().nodes.some((n) => n.id === id)) this.focusOn(node);
8492
- this.tickled.setTickled(id);
8958
+ this.#setKeyboardCursor(id, node);
8493
8959
  this.requestUpdate();
8494
8960
  return true;
8495
8961
  }
8962
+ /** Move the cursor as keyboard / search navigation does — recorded as `focus` in the view state. */
8963
+ #setKeyboardCursor(id, node) {
8964
+ this.tickled.setTickled(id);
8965
+ this.#viewState.keyboardCursorMoved(id, node.item.key);
8966
+ }
8496
8967
  /**
8497
8968
  * All nodes currently laid out on the disk (within `maxRadius` of the
8498
8969
  * focus), in render (ring-major) order. Unlike `ixfx-icicletree-list`, the
@@ -8505,6 +8976,49 @@ let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitEleme
8505
8976
  depth: n.depth
8506
8977
  }));
8507
8978
  }
8979
+ /**
8980
+ * Snapshot selection, centred node, keyboard cursor and zoom as plain JSON,
8981
+ * keyed by `item.key`. Includes pending state for keys not currently loaded.
8982
+ */
8983
+ getViewState(options) {
8984
+ return this.#viewState.get(options);
8985
+ }
8986
+ /**
8987
+ * Apply saved view state. Fields present replace the current ones; keys
8988
+ * that aren't loaded yet are applied as they appear — a deep `center` loads
8989
+ * its ancestors via `loadChildren` / `model` when `restoreLoads`.
8990
+ */
8991
+ setViewState(state) {
8992
+ this.#viewState.set(state);
8993
+ this.requestUpdate();
8994
+ }
8995
+ clearPendingViewState() {
8996
+ this.#viewState.clearPending();
8997
+ }
8998
+ /** Centre set by view-state reconcile: snaps (no animation); `restored` fires `focus`. */
8999
+ #restoreCenter(id, restored) {
9000
+ const previous = this.focusedNode;
9001
+ this.#cancelAnimation();
9002
+ this._focusId = id;
9003
+ this._viewOverride = void 0;
9004
+ this._zoomOffset = {
9005
+ x: 0,
9006
+ y: 0
9007
+ };
9008
+ if (!restored) {
9009
+ this._focusRotation = 0;
9010
+ return;
9011
+ }
9012
+ const node = this.focusedNode;
9013
+ if (node) this.dispatchEvent(new CustomEvent(`focus`, {
9014
+ detail: {
9015
+ node,
9016
+ previous
9017
+ },
9018
+ bubbles: true,
9019
+ composed: true
9020
+ }));
9021
+ }
8508
9022
  #matchesFilter(node, depth) {
8509
9023
  if (!this.filterPredicate) return true;
8510
9024
  if (this.filterPredicate(node, depth)) return true;
@@ -8790,6 +9304,7 @@ __decorate([property({
8790
9304
  reflect: true
8791
9305
  })], HyperbolicTreeListElement.prototype, "searchModes", void 0);
8792
9306
  __decorate([state()], HyperbolicTreeListElement.prototype, "_focusId", void 0);
9307
+ __decorate([property({ attribute: false })], HyperbolicTreeListElement.prototype, "restoreLoads", void 0);
8793
9308
  __decorate([state()], HyperbolicTreeListElement.prototype, "_viewOverride", void 0);
8794
9309
  __decorate([state()], HyperbolicTreeListElement.prototype, "_focusRotation", void 0);
8795
9310
  __decorate([state()], HyperbolicTreeListElement.prototype, "_zoomOffset", void 0);
@@ -8824,6 +9339,30 @@ function findIciclePath(root, key) {
8824
9339
  }
8825
9340
  }
8826
9341
  /**
9342
+ * Follow `keys` from `root`; a leading key equal to the root's own key
9343
+ * matches the root itself. Returns `undefined` when a key isn't found.
9344
+ */
9345
+ function resolveIcicleKeyPath(root, keys) {
9346
+ let current = root;
9347
+ for (const key of keys) {
9348
+ const found = key === current?.item.key ? current : current?.children?.find((n) => n.item.key === key);
9349
+ if (!found) return void 0;
9350
+ current = found;
9351
+ }
9352
+ return current;
9353
+ }
9354
+ /** Key path (depth-first) to the first node among `nodes` matching `predicate`. */
9355
+ function findIcicleKeyPath(nodes, predicate, prefix = []) {
9356
+ for (const node of nodes) {
9357
+ const path = [...prefix, node.item.key];
9358
+ if (predicate(node)) return path;
9359
+ if (node.children?.length) {
9360
+ const hit = findIcicleKeyPath(node.children, predicate, path);
9361
+ if (hit) return hit;
9362
+ }
9363
+ }
9364
+ }
9365
+ /**
8827
9366
  * Compute the visible rectangles for an icicle view.
8828
9367
  *
8829
9368
  * Bands start at the zoom node's **children** (the zoom node itself is shown in
@@ -8877,6 +9416,259 @@ function computeIcicleLayout(root, options = {}) {
8877
9416
  return rects;
8878
9417
  }
8879
9418
  //#endregion
9419
+ //#region src/icicle-tree/icicle-view-state.ts
9420
+ /**
9421
+ * - `found` — every key matched: `nodes[i]` is the node for `keys[i]`.
9422
+ * - `load` — `node` (at `depth`; root's children are `0`) must load its
9423
+ * children before the path can continue.
9424
+ * - `missing` — a key isn't there (yet), or the next step can't load.
9425
+ */
9426
+ /** First node (depth-first) for each key in the loaded tree. */
9427
+ function indexNodesByKey(root) {
9428
+ const out = /* @__PURE__ */ new Map();
9429
+ const visit = (node) => {
9430
+ if (!out.has(node.item.key)) out.set(node.item.key, node);
9431
+ for (const child of node.children ?? []) visit(child);
9432
+ };
9433
+ if (root) visit(root);
9434
+ return out;
9435
+ }
9436
+ /**
9437
+ * Nodes along a pending zoom path (`[]` = full tree), or `undefined` while it
9438
+ * can't resolve. Keys are unique tree-wide, so a zoom node that has moved is
9439
+ * still found by its key.
9440
+ */
9441
+ function resolveZoomPath$1(root, path, nodeByKey) {
9442
+ const found = resolveKeyPath(root, path, () => false);
9443
+ if (found.status === `found`) return found.nodes;
9444
+ const key = path.at(-1);
9445
+ return key !== void 0 && nodeByKey(key) ? findIciclePath(root, key)?.slice(1) : void 0;
9446
+ }
9447
+ /** Pixel scroll of a scrolling host. Rects aren't a linear list, so there's no anchor. */
9448
+ function captureHostScroll(el) {
9449
+ return el.scrollLeft === 0 ? { top: el.scrollTop } : {
9450
+ top: el.scrollTop,
9451
+ left: el.scrollLeft
9452
+ };
9453
+ }
9454
+ /** Returns `true` once the saved position is fully reached (else retry after the next render). */
9455
+ function applyHostScroll(el, scroll) {
9456
+ const top = scrollToOffset(el, scroll.top);
9457
+ const left = scroll.left === void 0 || scrollToOffset(el, scroll.left, `horizontal`);
9458
+ return top && left;
9459
+ }
9460
+ //#endregion
9461
+ //#region src/icicle-tree/keyboard.ts
9462
+ /**
9463
+ * Rect the cursor moves to for `key`, or `undefined` when the key isn't a
9464
+ * navigation key or there's nowhere to go. With no current rect, any
9465
+ * navigation key lands on the first rect.
9466
+ */
9467
+ function icicleKeyTarget(rects, current, key) {
9468
+ if (rects.length === 0) return void 0;
9469
+ if (key === `Home`) return rects[0];
9470
+ if (key === `End`) return rects.at(-1);
9471
+ if (![
9472
+ `ArrowLeft`,
9473
+ `ArrowRight`,
9474
+ `ArrowUp`,
9475
+ `ArrowDown`
9476
+ ].includes(key)) return void 0;
9477
+ if (!current) return rects[0];
9478
+ switch (key) {
9479
+ case `ArrowLeft`:
9480
+ case `ArrowRight`: {
9481
+ const dir = key === `ArrowRight` ? 1 : -1;
9482
+ const siblings = rects.filter((r) => r.relativeDepth === current.relativeDepth && r.parentKey === current.parentKey);
9483
+ return siblings[siblings.findIndex((r) => r.node.item.key === current.node.item.key) + dir] ?? (dir > 0 ? siblings[0] : siblings.at(-1));
9484
+ }
9485
+ case `ArrowUp`: return (current.parentKey === void 0 ? void 0 : rects.find((r) => r.node.item.key === current.parentKey)) ?? rects[0];
9486
+ default: return rects.find((r) => r.parentKey === current.node.item.key);
9487
+ }
9488
+ }
9489
+ //#endregion
9490
+ //#region src/icicle-tree/styles.ts
9491
+ /** Shadow-DOM styles for `ixfx-icicletree-list`. */
9492
+ const icicleTreeStyles = css`
9493
+ :host {
9494
+ --icicletree-band-height: 44px;
9495
+ --icicletree-band-gap: 2px;
9496
+ --icicletree-rect-gap: 2px;
9497
+ --icicletree-rect-bg: var(--surface-4);
9498
+ --icicletree-rect-text: var(--surface-4-text);
9499
+ --icicletree-font-size: var(--text-s);
9500
+
9501
+ display: block;
9502
+ overflow: auto;
9503
+ }
9504
+
9505
+ .icicle {
9506
+ display: flex;
9507
+ gap: var(--icicletree-band-gap);
9508
+ outline: none;
9509
+ }
9510
+
9511
+ .icicle.horizontal {
9512
+ flex-direction: column;
9513
+ }
9514
+
9515
+ .icicle.vertical {
9516
+ flex-direction: row;
9517
+ min-height: 200px;
9518
+ }
9519
+
9520
+ .breadcrumb {
9521
+ display: flex;
9522
+ align-items: center;
9523
+ padding: var(--space-xs) var(--space-s);
9524
+ background: var(--surface-3);
9525
+ color: var(--surface-3-text);
9526
+ border: var(--border-subtle);
9527
+ border-radius: var(--radius-m);
9528
+ font-size: var(--text-s);
9529
+ flex-shrink: 0;
9530
+ }
9531
+
9532
+ .breadcrumb ixfx-crumb-navigation {
9533
+ flex: 1;
9534
+ min-width: 0;
9535
+ }
9536
+
9537
+ .band {
9538
+ position: relative;
9539
+ }
9540
+
9541
+ .icicle.horizontal .band {
9542
+ height: var(--icicletree-band-height);
9543
+ }
9544
+
9545
+ .icicle.vertical .band {
9546
+ flex: 1 1 0;
9547
+ min-width: 0;
9548
+ height: 100%;
9549
+ }
9550
+
9551
+ .rect {
9552
+ box-sizing: border-box;
9553
+ position: absolute;
9554
+ overflow: hidden;
9555
+ display: flex;
9556
+ align-items: center;
9557
+ padding: 0 var(--space-s);
9558
+ background: var(--icicletree-rect-bg);
9559
+ color: var(--icicletree-rect-text);
9560
+ font-size: var(--icicletree-font-size);
9561
+ cursor: pointer;
9562
+ transition:
9563
+ width var(--transition),
9564
+ height var(--transition),
9565
+ left var(--transition),
9566
+ top var(--transition),
9567
+ opacity var(--transition),
9568
+ background-color var(--transition);
9569
+ }
9570
+
9571
+ .icicle.horizontal .rect {
9572
+ top: 0;
9573
+ height: 100%;
9574
+ }
9575
+
9576
+ /*
9577
+ * A rotated label needs its full allotted width to be usable — the
9578
+ * default left/right padding (--space-s, ~4px a side) can consume
9579
+ * most or all of a rect sized down to minVerticalLabelWidth, leaving
9580
+ * the rotated text clipped to invisible. #labelMode's width estimate
9581
+ * assumes the whole rect width is usable, so drop the padding here to
9582
+ * match that assumption instead of also budgeting for it in the
9583
+ * threshold (which would make already-narrow rects need even more).
9584
+ */
9585
+ .icicle.horizontal .rect.rotated {
9586
+ padding: 0;
9587
+ }
9588
+
9589
+ .icicle.vertical .rect {
9590
+ left: 0;
9591
+ width: 100%;
9592
+ padding: var(--space-s) 0;
9593
+ justify-content: center;
9594
+ writing-mode: horizontal-tb;
9595
+ }
9596
+
9597
+ .rect.filtered {
9598
+ opacity: 0.3;
9599
+ filter: saturate(0.4);
9600
+ }
9601
+
9602
+ .rect.empty {
9603
+ padding: 0;
9604
+ border: none;
9605
+ }
9606
+
9607
+ .rect.loading {
9608
+ cursor: progress;
9609
+ opacity: 0.6;
9610
+ }
9611
+
9612
+ .icicle-label {
9613
+ overflow: hidden;
9614
+ text-overflow: ellipsis;
9615
+ white-space: nowrap;
9616
+ flex: 1;
9617
+ min-width: 0;
9618
+ }
9619
+
9620
+ .icicle-label.hide-label {
9621
+ visibility: hidden;
9622
+ }
9623
+
9624
+ .icicle-label.label-vertical {
9625
+ writing-mode: vertical-rl;
9626
+ text-orientation: sideways;
9627
+ flex: none;
9628
+ width: 100%;
9629
+ height: 100%;
9630
+ padding: var(--space-2xs, 2px) 0;
9631
+ box-sizing: border-box;
9632
+ display: flex;
9633
+ align-items: center;
9634
+ justify-content: center;
9635
+ }
9636
+
9637
+ /*
9638
+ * Instant tooltip for rects whose label is rotated or hidden — anchored
9639
+ * via CSS anchor positioning (anchor-name set on the tickled rect in
9640
+ * #renderRect) rather than a JS-computed position, and shown/hidden via
9641
+ * the Popover API rather than the browser's own (slow, un-stylable)
9642
+ * title tooltip.
9643
+ */
9644
+ .icicle-tooltip {
9645
+ position: fixed;
9646
+ position-anchor: --icicle-rect-anchor;
9647
+ position-area: bottom center;
9648
+ position-try-fallbacks: top center;
9649
+ margin: var(--space-xs, 4px) 0 0;
9650
+ padding: var(--space-2xs, 2px) var(--space-xs, 6px);
9651
+ background: var(--surface-5);
9652
+ color: var(--surface-5-text);
9653
+ border: var(--border-subtle);
9654
+ border-radius: var(--radius-s, 4px);
9655
+ font-size: var(--text-s);
9656
+ white-space: nowrap;
9657
+ pointer-events: none;
9658
+ inset: auto;
9659
+ }
9660
+
9661
+ .icicle-tooltip:popover-open {
9662
+ display: block;
9663
+ }
9664
+
9665
+ .empty-state {
9666
+ padding: var(--space-l);
9667
+ color: var(--surface-muted-text);
9668
+ font-size: var(--text-s);
9669
+ }
9670
+ `;
9671
+ //#endregion
8880
9672
  //#region src/icicle-tree/icicletree-list.ts
8881
9673
  let IcicleTreeListElement = class IcicleTreeListElement extends LitElement {
8882
9674
  constructor(..._args) {
@@ -8894,6 +9686,14 @@ let IcicleTreeListElement = class IcicleTreeListElement extends LitElement {
8894
9686
  this.disableKeyboardNavigation = false;
8895
9687
  this.registry = void 0;
8896
9688
  this._zoomKey = void 0;
9689
+ this.restoreLoads = true;
9690
+ this.#viewState = new ViewStateController(this, {
9691
+ kind: `icicletree-list`,
9692
+ capture: () => this.getViewState()
9693
+ });
9694
+ this.#pendingSelected = EMPTY_KEYS;
9695
+ this.#keyboardCursor = false;
9696
+ this.#zoomPath = [];
8897
9697
  this.search = new ListSearchController(this, { adapter: createTreeListSearchAdapter(this, {
8898
9698
  labelSelector: `.icicle-label`,
8899
9699
  highlightKey: `incr-search-highlight`
@@ -8922,6 +9722,9 @@ let IcicleTreeListElement = class IcicleTreeListElement extends LitElement {
8922
9722
  });
8923
9723
  this.#pendingKeys = /* @__PURE__ */ new Set();
8924
9724
  this.#aborters = /* @__PURE__ */ new Map();
9725
+ this.#onScroll = () => {
9726
+ if (!this.#viewState.pendingScroll) this.#viewState.notify();
9727
+ };
8925
9728
  }
8926
9729
  static {
8927
9730
  this.shadowRootOptions = {
@@ -8937,6 +9740,16 @@ let IcicleTreeListElement = class IcicleTreeListElement extends LitElement {
8937
9740
  if (this._zoomKey === void 0) return void 0;
8938
9741
  return findIcicleNode(this.#effectiveRoot, this._zoomKey);
8939
9742
  }
9743
+ #viewState;
9744
+ /** Saved selection for keys not (yet) loaded. */
9745
+ #pendingSelected;
9746
+ /** Saved zoom path not (yet) resolvable. */
9747
+ #pendingZoom;
9748
+ #pendingFocus;
9749
+ /** Whether the cursor was last placed by the keyboard (only then is it `focus`). */
9750
+ #keyboardCursor;
9751
+ /** Last known key path of the zoom node, so it can go pending when the node leaves. */
9752
+ #zoomPath;
8940
9753
  #modelUnsubscribe;
8941
9754
  #pendingKeys;
8942
9755
  #aborters;
@@ -8973,6 +9786,8 @@ let IcicleTreeListElement = class IcicleTreeListElement extends LitElement {
8973
9786
  });
8974
9787
  this.registry = registry;
8975
9788
  this.#syncSearchCommands();
9789
+ this.#viewState.observeScrollTarget(this);
9790
+ this.addEventListener(`scroll`, this.#onScroll, { passive: true });
8976
9791
  if (typeof ResizeObserver !== `undefined`) {
8977
9792
  this.#resizeObserver = new ResizeObserver(() => this.requestUpdate());
8978
9793
  this.#resizeObserver.observe(this);
@@ -8985,6 +9800,7 @@ let IcicleTreeListElement = class IcicleTreeListElement extends LitElement {
8985
9800
  for (const aborter of this.#aborters.values()) aborter.abort();
8986
9801
  this.#aborters.clear();
8987
9802
  this.#pendingKeys.clear();
9803
+ this.removeEventListener(`scroll`, this.#onScroll);
8988
9804
  this.#resizeObserver?.disconnect();
8989
9805
  this.#resizeObserver = void 0;
8990
9806
  if (this.#ownedRegistry) {
@@ -9006,28 +9822,23 @@ let IcicleTreeListElement = class IcicleTreeListElement extends LitElement {
9006
9822
  this.#unregisterSearchCommands?.();
9007
9823
  this.#unregisterSearchCommands = registerListSearchCommands(this.#ownedRegistry, this.search);
9008
9824
  }
9009
- updated(changedProps) {
9010
- if (changedProps.has(`root`) && this.root) this.#resetViewState();
9011
- if (changedProps.has(`model`)) {
9012
- this.#modelUnsubscribe?.();
9013
- this.#modelUnsubscribe = void 0;
9014
- if (this.model) {
9015
- this.#modelUnsubscribe = this.model._subscribe((event) => {
9016
- if (event.type === `root`) {
9017
- this.#resetViewState();
9018
- this.requestUpdate();
9019
- } else if (event.type === `invalidate`) this.requestUpdate();
9020
- });
9021
- this.#resetViewState();
9022
- }
9023
- }
9024
- if (changedProps.has(`interactionMode`)) {
9825
+ willUpdate(changedProps) {
9826
+ if (changedProps.has(`root`) || changedProps.has(`model`)) this.#anchorIndex = void 0;
9827
+ if (changedProps.has(`interactionMode`) && changedProps.get(`interactionMode`) !== void 0) {
9025
9828
  this.#anchorIndex = void 0;
9026
9829
  this.clearSelection();
9027
9830
  }
9831
+ this.#reconcileViewState();
9832
+ }
9833
+ updated(changedProps) {
9834
+ if (changedProps.has(`model`)) {
9835
+ this.#modelUnsubscribe?.();
9836
+ this.#modelUnsubscribe = this.model?._subscribe(() => this.requestUpdate());
9837
+ }
9028
9838
  if (changedProps.has(`searchModes`)) this.#syncSearchCommands();
9029
- if (this._zoomKey !== void 0 && !findIcicleNode(this.#effectiveRoot, this._zoomKey)) this._zoomKey = void 0;
9030
9839
  this.#ensureVisibleChildren();
9840
+ this.#continueRestoreLoads();
9841
+ this.#viewState.applyPendingScroll((scroll) => applyHostScroll(this, scroll));
9031
9842
  this.#syncTooltip();
9032
9843
  }
9033
9844
  /** The host's own (viewport) extent — width in horizontal orientation, height in vertical. */
@@ -9077,12 +9888,103 @@ let IcicleTreeListElement = class IcicleTreeListElement extends LitElement {
9077
9888
  popover.hidePopover();
9078
9889
  } catch {}
9079
9890
  }
9080
- #resetViewState() {
9081
- this.#clearSelectionInternal();
9082
- this.tickled.clearTickled();
9083
- this._zoomKey = void 0;
9084
- this.#anchorIndex = void 0;
9891
+ /**
9892
+ * Snapshot selection, zoom path, keyboard cursor and scroll as plain JSON,
9893
+ * keyed by `item.key`. Includes pending state for keys not currently loaded.
9894
+ */
9895
+ getViewState(options) {
9896
+ const withPending = options?.includePending ?? true;
9897
+ const focus = this.#keyboardCursor ? this.tickled.tickledId : void 0;
9898
+ return this.#viewState.shape({
9899
+ selected: keysToArray([...this._selectedNodes].map((n) => n.item.key), withPending ? this.#pendingSelected : []),
9900
+ zoom: (withPending ? this.#pendingZoom : void 0) ?? (this._zoomKey === void 0 ? [] : [...this.#zoomPath]),
9901
+ focus: focus ?? (withPending ? this.#pendingFocus : void 0),
9902
+ scroll: (withPending ? this.#viewState.pendingScroll : void 0) ?? (this.isConnected ? captureHostScroll(this) : void 0)
9903
+ }, options);
9904
+ }
9905
+ /**
9906
+ * Apply saved view state. Fields present replace the current ones; keys
9907
+ * that aren't loaded yet are applied as they appear — including via
9908
+ * `loadChildren` / `model` down a saved zoom path when `restoreLoads`.
9909
+ */
9910
+ setViewState(state) {
9911
+ if (!this.#viewState.accepts(state)) return;
9912
+ this.#viewState.restoring(() => {
9913
+ if (state.selected) {
9914
+ this.#pendingSelected = capKeys(new Set(state.selected));
9915
+ if (this._selectedNodes.size > 0) {
9916
+ const prev = new Set(this._selectedNodes);
9917
+ this.#clearSelectionInternal();
9918
+ this.#dispatchSelect(prev);
9919
+ }
9920
+ }
9921
+ if (state.zoom) this.#pendingZoom = [...state.zoom];
9922
+ if (state.focus !== void 0) this.#pendingFocus = state.focus;
9923
+ if (state.scroll) this.#viewState.pendingScroll = state.scroll;
9924
+ this.#reconcileViewState();
9925
+ if (this.#pendingZoom !== void 0) this.zoomOut();
9926
+ });
9927
+ this.requestUpdate();
9928
+ }
9929
+ clearPendingViewState() {
9930
+ this.#pendingSelected = EMPTY_KEYS;
9931
+ this.#pendingZoom = void 0;
9932
+ this.#pendingFocus = void 0;
9933
+ this.#viewState.pendingScroll = void 0;
9934
+ }
9935
+ /** Move state for missing keys to pending; apply pending state for keys now loaded. */
9936
+ #reconcileViewState() {
9937
+ 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;
9938
+ const root = this.#effectiveRoot;
9939
+ let index;
9940
+ const nodeByKey = (key) => (index ??= indexNodesByKey(root)).get(key);
9941
+ this.#viewState.restoring(() => {
9942
+ this.#reconcileZoom(root, nodeByKey);
9943
+ const sel = reconcileSelection$1(this._selectedNodes, this.#pendingSelected, nodeByKey, (node) => this.#canSelect(node), this.interactionMode === `implicit`);
9944
+ if (sel.changed) {
9945
+ const prev = this._selectedNodes;
9946
+ const primary = this.selectedNode?.item.key;
9947
+ this._selectedNodes = new Set(sel.selected);
9948
+ this.#pendingSelected = sel.pending;
9949
+ this.selectedNode = [...sel.selected].find((n) => n.item.key === primary) ?? [...sel.selected].at(-1);
9950
+ if (sel.restored) this.#dispatchSelect(prev);
9951
+ else this.#viewState.notify(`data`);
9952
+ }
9953
+ this.#reconcileFocus(nodeByKey);
9954
+ });
9955
+ }
9956
+ #reconcileZoom(root, nodeByKey) {
9957
+ if (this._zoomKey !== void 0 && !nodeByKey(this._zoomKey)) {
9958
+ this.#pendingZoom ??= this.#zoomPath;
9959
+ this._zoomKey = void 0;
9960
+ this.#viewState.notify(`data`);
9961
+ }
9962
+ const nodes = this.#pendingZoom && resolveZoomPath$1(root, this.#pendingZoom, nodeByKey);
9963
+ if (!nodes) return;
9964
+ this.#pendingZoom = void 0;
9965
+ if (nodes.length === 0) this.zoomOut();
9966
+ else this.zoomTo(nodes.at(-1));
9967
+ }
9968
+ #reconcileFocus(nodeByKey) {
9969
+ const tickled = this.tickled.tickledId;
9970
+ if (tickled !== void 0 && !nodeByKey(tickled)) {
9971
+ if (this.#keyboardCursor) this.#pendingFocus ??= tickled;
9972
+ this.tickled.clearTickled();
9973
+ }
9974
+ const focus = this.#pendingFocus;
9975
+ if (focus !== void 0 && this.#rects().some((r) => r.node.item.key === focus)) {
9976
+ this.#pendingFocus = void 0;
9977
+ this.#keyboardCursor = true;
9978
+ this.tickled.setTickled(focus);
9979
+ }
9085
9980
  }
9981
+ /** Load the next unloaded node along a pending zoom path (one load at a time). */
9982
+ #continueRestoreLoads() {
9983
+ if (!this.restoreLoads || this.#pendingZoom === void 0 || !this.model && !this.loadChildren) return;
9984
+ const next = resolveKeyPath(this.#effectiveRoot, this.#pendingZoom, (node) => node.item.isLeaf !== true);
9985
+ if (next.status === `load` && !this.#pendingKeys.has(next.node.item.key)) this.#loadChildren(next.node, next.depth);
9986
+ }
9987
+ #onScroll;
9086
9988
  #ensureVisibleChildren() {
9087
9989
  if (!this.model && !this.loadChildren) return;
9088
9990
  for (const rect of this.#rects()) {
@@ -9126,6 +10028,8 @@ let IcicleTreeListElement = class IcicleTreeListElement extends LitElement {
9126
10028
  this.selectedNode = void 0;
9127
10029
  }
9128
10030
  #dispatchSelect(previous) {
10031
+ if (!this.#viewState.isRestoring) this.#pendingSelected = EMPTY_KEYS;
10032
+ this.#viewState.notify();
9129
10033
  this.dispatchEvent(new CustomEvent(`select`, {
9130
10034
  detail: {
9131
10035
  selected: new Set(this._selectedNodes),
@@ -9153,6 +10057,7 @@ let IcicleTreeListElement = class IcicleTreeListElement extends LitElement {
9153
10057
  this.requestUpdate();
9154
10058
  }
9155
10059
  clearSelection() {
10060
+ this.#pendingSelected = EMPTY_KEYS;
9156
10061
  if (this._selectedNodes.size === 0) return;
9157
10062
  const prev = new Set(this._selectedNodes);
9158
10063
  this.#clearSelectionInternal();
@@ -9189,11 +10094,13 @@ let IcicleTreeListElement = class IcicleTreeListElement extends LitElement {
9189
10094
  * Fires `zoom` and the semantic `expand` event.
9190
10095
  */
9191
10096
  zoomTo(node) {
10097
+ this.#zoomChanged();
9192
10098
  if (this._zoomKey === node.item.key) return;
9193
10099
  this._zoomKey = node.item.key;
9194
10100
  this.tickled.clearTickled();
9195
- const depth = findIciclePath(this.#effectiveRoot, node.item.key)?.length;
9196
- const absoluteDepth = depth === void 0 ? -1 : depth - 2;
10101
+ const path = findIciclePath(this.#effectiveRoot, node.item.key);
10102
+ this.#zoomPath = path?.slice(1).map((n) => n.item.key) ?? [node.item.key];
10103
+ const absoluteDepth = path === void 0 ? -1 : path.length - 2;
9197
10104
  this.dispatchEvent(new CustomEvent(`zoom`, {
9198
10105
  detail: {
9199
10106
  node,
@@ -9217,11 +10124,13 @@ let IcicleTreeListElement = class IcicleTreeListElement extends LitElement {
9217
10124
  * the semantic `collapse` event for the previous zoom root.
9218
10125
  */
9219
10126
  zoomOut() {
10127
+ this.#zoomChanged();
9220
10128
  if (this._zoomKey === void 0) return;
9221
10129
  const previous = this.focusedNode;
9222
10130
  const path = this._zoomKey === void 0 ? void 0 : findIciclePath(this.#effectiveRoot, this._zoomKey);
9223
10131
  const absoluteDepth = path === void 0 ? -1 : path.length - 2;
9224
10132
  this._zoomKey = void 0;
10133
+ this.#zoomPath = [];
9225
10134
  this.tickled.clearTickled();
9226
10135
  this.dispatchEvent(new CustomEvent(`zoom`, {
9227
10136
  detail: {
@@ -9241,6 +10150,11 @@ let IcicleTreeListElement = class IcicleTreeListElement extends LitElement {
9241
10150
  }));
9242
10151
  this.requestUpdate();
9243
10152
  }
10153
+ /** An explicit zoom supersedes a saved one still waiting. */
10154
+ #zoomChanged() {
10155
+ if (!this.#viewState.isRestoring) this.#pendingZoom = void 0;
10156
+ this.#viewState.notify();
10157
+ }
9244
10158
  #canZoom(node) {
9245
10159
  if (node.item.isLeaf === true) return false;
9246
10160
  if (node.children !== void 0) return node.children.length > 0;
@@ -9276,7 +10190,7 @@ let IcicleTreeListElement = class IcicleTreeListElement extends LitElement {
9276
10190
  navigateToNode(predicate) {
9277
10191
  const root = this.#effectiveRoot;
9278
10192
  if (!root?.children) return false;
9279
- const keyPath = this.#findKeyPath(root.children, predicate);
10193
+ const keyPath = findIcicleKeyPath(root.children, predicate);
9280
10194
  if (!keyPath) return false;
9281
10195
  return this.navigateTo(keyPath);
9282
10196
  }
@@ -9287,35 +10201,16 @@ let IcicleTreeListElement = class IcicleTreeListElement extends LitElement {
9287
10201
  revealKey(key) {
9288
10202
  const root = this.#effectiveRoot;
9289
10203
  if (!root?.children) return false;
9290
- const path = this.#findKeyPath(root.children, (n) => n.item.key === key);
10204
+ const path = findIcicleKeyPath(root.children, (n) => n.item.key === key);
9291
10205
  if (!path) return false;
9292
10206
  if (path.length > this.maxDepth) {
9293
- const ancestor = this.#resolveKeyPath(path.slice(0, path.length - this.maxDepth));
10207
+ const ancestor = resolveIcicleKeyPath(this.#effectiveRoot, path.slice(0, path.length - this.maxDepth));
9294
10208
  if (ancestor) this.zoomTo(ancestor);
9295
10209
  }
9296
10210
  this.tickled.setTickled(key);
9297
10211
  this.requestUpdate();
9298
10212
  return true;
9299
10213
  }
9300
- #resolveKeyPath(keys) {
9301
- let current = this.#effectiveRoot;
9302
- for (const key of keys) {
9303
- const found = key === current?.item.key ? current : current?.children?.find((n) => n.item.key === key);
9304
- if (!found) return void 0;
9305
- current = found;
9306
- }
9307
- return current;
9308
- }
9309
- #findKeyPath(nodes, predicate, prefix = []) {
9310
- for (const node of nodes) {
9311
- const path = [...prefix, node.item.key];
9312
- if (predicate(node)) return path;
9313
- if (node.children?.length) {
9314
- const hit = this.#findKeyPath(node.children, predicate, path);
9315
- if (hit) return hit;
9316
- }
9317
- }
9318
- }
9319
10214
  /**
9320
10215
  * All nodes currently visible as rectangles, in render (band-major) order,
9321
10216
  * with absolute depth (root's children are depth 0).
@@ -9392,44 +10287,20 @@ let IcicleTreeListElement = class IcicleTreeListElement extends LitElement {
9392
10287
  const currentIndex = tickledKey === void 0 ? -1 : rects.findIndex((r) => r.node.item.key === tickledKey);
9393
10288
  const current = currentIndex >= 0 ? rects[currentIndex] : void 0;
9394
10289
  const moveTo = (rect) => {
9395
- if (!rect) return;
9396
10290
  event.preventDefault();
9397
10291
  this.tickled.setTickled(rect.node.item.key);
10292
+ this.#keyboardCursor = true;
10293
+ this.#pendingFocus = void 0;
10294
+ this.#viewState.notify();
9398
10295
  if (this.interactionMode === `implicit`) this.select(rect.node);
9399
10296
  };
10297
+ if (event.key === `ArrowLeft` || event.key === `ArrowRight`) event.preventDefault();
10298
+ const target = icicleKeyTarget(rects, current, event.key);
10299
+ if (target) {
10300
+ moveTo(target);
10301
+ return;
10302
+ }
9400
10303
  switch (event.key) {
9401
- case `ArrowLeft`:
9402
- case `ArrowRight`: {
9403
- event.preventDefault();
9404
- const dir = event.key === `ArrowRight` ? 1 : -1;
9405
- if (!current) {
9406
- moveTo(rects[0]);
9407
- return;
9408
- }
9409
- const siblings = rects.filter((r) => r.relativeDepth === current.relativeDepth && r.parentKey === current.parentKey);
9410
- moveTo(siblings[siblings.findIndex((r) => r.node.item.key === current.node.item.key) + dir] ?? (dir > 0 ? siblings[0] : siblings.at(-1)));
9411
- return;
9412
- }
9413
- case `ArrowUp`:
9414
- if (!current) {
9415
- moveTo(rects[0]);
9416
- return;
9417
- }
9418
- moveTo((current.parentKey === void 0 ? void 0 : rects.find((r) => r.node.item.key === current.parentKey)) ?? rects[0]);
9419
- return;
9420
- case `ArrowDown`:
9421
- if (!current) {
9422
- moveTo(rects[0]);
9423
- return;
9424
- }
9425
- moveTo(rects.find((r) => r.parentKey === current.node.item.key));
9426
- return;
9427
- case `Home`:
9428
- moveTo(rects[0]);
9429
- return;
9430
- case `End`:
9431
- moveTo(rects.at(-1));
9432
- return;
9433
10304
  case `Enter`:
9434
10305
  if (!current) return;
9435
10306
  event.preventDefault();
@@ -9479,6 +10350,10 @@ let IcicleTreeListElement = class IcicleTreeListElement extends LitElement {
9479
10350
  if (node.item.key === this.#effectiveRoot?.item.key) this.zoomOut();
9480
10351
  else this.zoomTo(node);
9481
10352
  }
10353
+ #onRectPointerEnter(key) {
10354
+ this.tickled.handlePointerEnter(key);
10355
+ if (this.tickled.tickledId === key) this.#keyboardCursor = false;
10356
+ }
9482
10357
  #renderRect(rect) {
9483
10358
  const { node } = rect;
9484
10359
  const selected = this._selectedNodes.has(node);
@@ -9507,7 +10382,7 @@ let IcicleTreeListElement = class IcicleTreeListElement extends LitElement {
9507
10382
  style=${sizeStyle}
9508
10383
  @click=${(e) => this.#handleItemClick(rect, this.#rects().findIndex((r) => r.node.item.key === node.item.key), e)}
9509
10384
  @dblclick=${() => this.#handleItemDblClick(rect)}
9510
- @pointerenter=${() => this.tickled.handlePointerEnter(node.item.key)}
10385
+ @pointerenter=${() => this.#onRectPointerEnter(node.item.key)}
9511
10386
  >
9512
10387
  <span class="icicle-label ${hideLabel ? `hide-label` : ``} ${rotated ? `label-vertical` : ``}" part="label">${node.item.label}</span>
9513
10388
  </div>
@@ -9552,185 +10427,7 @@ let IcicleTreeListElement = class IcicleTreeListElement extends LitElement {
9552
10427
  tickledStyles,
9553
10428
  tickledItemStyles,
9554
10429
  listSearchStyles,
9555
- css`
9556
- :host {
9557
- --icicletree-band-height: 44px;
9558
- --icicletree-band-gap: 2px;
9559
- --icicletree-rect-gap: 2px;
9560
- --icicletree-rect-bg: var(--surface-4);
9561
- --icicletree-rect-text: var(--surface-4-text);
9562
- --icicletree-font-size: var(--text-s);
9563
-
9564
- display: block;
9565
- overflow: auto;
9566
- }
9567
-
9568
- .icicle {
9569
- display: flex;
9570
- gap: var(--icicletree-band-gap);
9571
- outline: none;
9572
- }
9573
-
9574
- .icicle.horizontal {
9575
- flex-direction: column;
9576
- }
9577
-
9578
- .icicle.vertical {
9579
- flex-direction: row;
9580
- min-height: 200px;
9581
- }
9582
-
9583
- .breadcrumb {
9584
- display: flex;
9585
- align-items: center;
9586
- padding: var(--space-xs) var(--space-s);
9587
- background: var(--surface-3);
9588
- color: var(--surface-3-text);
9589
- border: var(--border-subtle);
9590
- border-radius: var(--radius-m);
9591
- font-size: var(--text-s);
9592
- flex-shrink: 0;
9593
- }
9594
-
9595
- .breadcrumb ixfx-crumb-navigation {
9596
- flex: 1;
9597
- min-width: 0;
9598
- }
9599
-
9600
- .band {
9601
- position: relative;
9602
- }
9603
-
9604
- .icicle.horizontal .band {
9605
- height: var(--icicletree-band-height);
9606
- }
9607
-
9608
- .icicle.vertical .band {
9609
- flex: 1 1 0;
9610
- min-width: 0;
9611
- height: 100%;
9612
- }
9613
-
9614
- .rect {
9615
- box-sizing: border-box;
9616
- position: absolute;
9617
- overflow: hidden;
9618
- display: flex;
9619
- align-items: center;
9620
- padding: 0 var(--space-s);
9621
- background: var(--icicletree-rect-bg);
9622
- color: var(--icicletree-rect-text);
9623
- font-size: var(--icicletree-font-size);
9624
- cursor: pointer;
9625
- transition:
9626
- width var(--transition),
9627
- height var(--transition),
9628
- left var(--transition),
9629
- top var(--transition),
9630
- opacity var(--transition),
9631
- background-color var(--transition);
9632
- }
9633
-
9634
- .icicle.horizontal .rect {
9635
- top: 0;
9636
- height: 100%;
9637
- }
9638
-
9639
- /*
9640
- * A rotated label needs its full allotted width to be usable — the
9641
- * default left/right padding (--space-s, ~4px a side) can consume
9642
- * most or all of a rect sized down to minVerticalLabelWidth, leaving
9643
- * the rotated text clipped to invisible. #labelMode's width estimate
9644
- * assumes the whole rect width is usable, so drop the padding here to
9645
- * match that assumption instead of also budgeting for it in the
9646
- * threshold (which would make already-narrow rects need even more).
9647
- */
9648
- .icicle.horizontal .rect.rotated {
9649
- padding: 0;
9650
- }
9651
-
9652
- .icicle.vertical .rect {
9653
- left: 0;
9654
- width: 100%;
9655
- padding: var(--space-s) 0;
9656
- justify-content: center;
9657
- writing-mode: horizontal-tb;
9658
- }
9659
-
9660
- .rect.filtered {
9661
- opacity: 0.3;
9662
- filter: saturate(0.4);
9663
- }
9664
-
9665
- .rect.empty {
9666
- padding: 0;
9667
- border: none;
9668
- }
9669
-
9670
- .rect.loading {
9671
- cursor: progress;
9672
- opacity: 0.6;
9673
- }
9674
-
9675
- .icicle-label {
9676
- overflow: hidden;
9677
- text-overflow: ellipsis;
9678
- white-space: nowrap;
9679
- flex: 1;
9680
- min-width: 0;
9681
- }
9682
-
9683
- .icicle-label.hide-label {
9684
- visibility: hidden;
9685
- }
9686
-
9687
- .icicle-label.label-vertical {
9688
- writing-mode: vertical-rl;
9689
- text-orientation: sideways;
9690
- flex: none;
9691
- width: 100%;
9692
- height: 100%;
9693
- padding: var(--space-2xs, 2px) 0;
9694
- box-sizing: border-box;
9695
- display: flex;
9696
- align-items: center;
9697
- justify-content: center;
9698
- }
9699
-
9700
- /*
9701
- * Instant tooltip for rects whose label is rotated or hidden — anchored
9702
- * via CSS anchor positioning (anchor-name set on the tickled rect in
9703
- * #renderRect) rather than a JS-computed position, and shown/hidden via
9704
- * the Popover API rather than the browser's own (slow, un-stylable)
9705
- * title tooltip.
9706
- */
9707
- .icicle-tooltip {
9708
- position: fixed;
9709
- position-anchor: --icicle-rect-anchor;
9710
- position-area: bottom center;
9711
- position-try-fallbacks: top center;
9712
- margin: var(--space-xs, 4px) 0 0;
9713
- padding: var(--space-2xs, 2px) var(--space-xs, 6px);
9714
- background: var(--surface-5);
9715
- color: var(--surface-5-text);
9716
- border: var(--border-subtle);
9717
- border-radius: var(--radius-s, 4px);
9718
- font-size: var(--text-s);
9719
- white-space: nowrap;
9720
- pointer-events: none;
9721
- inset: auto;
9722
- }
9723
-
9724
- .icicle-tooltip:popover-open {
9725
- display: block;
9726
- }
9727
-
9728
- .empty-state {
9729
- padding: var(--space-l);
9730
- color: var(--surface-muted-text);
9731
- font-size: var(--text-s);
9732
- }
9733
- `
10430
+ icicleTreeStyles
9734
10431
  ];
9735
10432
  }
9736
10433
  };
@@ -9785,6 +10482,7 @@ __decorate([property({
9785
10482
  reflect: true
9786
10483
  })], IcicleTreeListElement.prototype, "searchModes", void 0);
9787
10484
  __decorate([state()], IcicleTreeListElement.prototype, "_zoomKey", void 0);
10485
+ __decorate([property({ attribute: false })], IcicleTreeListElement.prototype, "restoreLoads", void 0);
9788
10486
  __decorate([query(`#icicle-tooltip`)], IcicleTreeListElement.prototype, "_tooltipEl", void 0);
9789
10487
  IcicleTreeListElement = __decorate([safeCustomElement(`ixfx-icicletree-list`)], IcicleTreeListElement);
9790
10488
  //#endregion
@@ -9810,6 +10508,136 @@ function findLeafCategory(categories, key) {
9810
10508
  return flattenCategories(categories).find((c) => c.key === key);
9811
10509
  }
9812
10510
  //#endregion
10511
+ //#region src/multicol-list/multicol-view-state.ts
10512
+ /**
10513
+ * Pure helpers for `ixfx-multicol-list` view state. The element runs these
10514
+ * when a state is restored, when `categories` change (so saved columns whose
10515
+ * category wasn't known yet can appear) and when a column's items arrive (so
10516
+ * saved selections apply once their keys exist).
10517
+ */
10518
+ const MULTICOL_VIEW_STATE_KIND = `multicol-list`;
10519
+ const isSort = (v) => v === `asc` || v === `desc`;
10520
+ /**
10521
+ * Validate saved columns (they come from storage, so may be malformed).
10522
+ * Entries without a string `id` / `categoryKey` are dropped, as are repeat
10523
+ * ids; bad optional fields fall back to defaults.
10524
+ */
10525
+ function sanitiseColumns(raw) {
10526
+ if (!Array.isArray(raw)) return [];
10527
+ const seen = /* @__PURE__ */ new Set();
10528
+ const out = [];
10529
+ for (const entry of raw) {
10530
+ if (entry === null || typeof entry !== `object`) continue;
10531
+ const e = entry;
10532
+ if (typeof e.id !== `string` || typeof e.categoryKey !== `string` || seen.has(e.id)) continue;
10533
+ seen.add(e.id);
10534
+ out.push({
10535
+ id: e.id,
10536
+ categoryKey: e.categoryKey,
10537
+ sort: isSort(e.sort) ? e.sort : `asc`,
10538
+ grouped: e.grouped === true,
10539
+ ...typeof e.menu === `boolean` ? { menu: e.menu } : {},
10540
+ ...typeof e.width === `number` && Number.isFinite(e.width) ? { width: e.width } : {}
10541
+ });
10542
+ }
10543
+ return out;
10544
+ }
10545
+ /** Split saved columns into those whose category resolves now and those to keep pending. */
10546
+ function partitionColumns(columns, hasCategory) {
10547
+ const ready = [];
10548
+ const pending = [];
10549
+ columns.forEach((state, index) => {
10550
+ if (hasCategory(state.categoryKey)) ready.push(state);
10551
+ else pending.push({
10552
+ state,
10553
+ index
10554
+ });
10555
+ });
10556
+ return {
10557
+ ready,
10558
+ pending
10559
+ };
10560
+ }
10561
+ /** Split pending columns into those whose category now resolves and those still waiting. */
10562
+ function resolvePendingColumns(pending, hasCategory) {
10563
+ if (pending.length === 0) return {
10564
+ ready: [],
10565
+ pending
10566
+ };
10567
+ const ready = pending.filter((p) => hasCategory(p.state.categoryKey));
10568
+ return ready.length === 0 ? {
10569
+ ready,
10570
+ pending
10571
+ } : {
10572
+ ready,
10573
+ pending: pending.filter((p) => !hasCategory(p.state.categoryKey))
10574
+ };
10575
+ }
10576
+ /** Insert pending columns back at their saved positions (clamped), for a snapshot. */
10577
+ function mergeColumns(live, pending) {
10578
+ const out = [...live];
10579
+ for (const p of [...pending].sort((a, b) => a.index - b.index)) out.splice(Math.min(p.index, out.length), 0, p.state);
10580
+ return out;
10581
+ }
10582
+ /**
10583
+ * - selected keys no longer in the data → pending
10584
+ * - pending keys now in the data → selected (appended, in pending order)
10585
+ */
10586
+ function reconcileSelection(selected, pending, has) {
10587
+ const kept = selected.filter(has);
10588
+ const dropped = kept.length === selected.length ? [] : selected.filter((k) => !has(k));
10589
+ const { present, missing } = partitionKeys(pending, has);
10590
+ if (dropped.length === 0 && present.length === 0) return {
10591
+ selected,
10592
+ pending,
10593
+ restored: false,
10594
+ changed: false
10595
+ };
10596
+ const nextPending = dropped.length === 0 ? missing : capKeys(/* @__PURE__ */ new Set([...missing, ...dropped]));
10597
+ const added = present.filter((k) => !kept.includes(k));
10598
+ return {
10599
+ selected: [...kept, ...added],
10600
+ pending: nextPending,
10601
+ restored: added.length > 0,
10602
+ changed: dropped.length > 0 || added.length > 0
10603
+ };
10604
+ }
10605
+ /** JSON array of string keys → capped pending set; non-strings are ignored. */
10606
+ function keysFromJson(raw) {
10607
+ if (!Array.isArray(raw)) return EMPTY_KEYS;
10608
+ return capKeys(new Set(raw.filter((k) => typeof k === `string`)));
10609
+ }
10610
+ /** Whether `raw` looks like a {@link ScrollState}. */
10611
+ function isScrollState(raw) {
10612
+ return raw !== null && typeof raw === `object` && typeof raw.top === `number`;
10613
+ }
10614
+ /** `record` entries as a map, keeping only values `accept` allows. */
10615
+ function mapFromRecord(record, accept) {
10616
+ const out = /* @__PURE__ */ new Map();
10617
+ if (record === null || typeof record !== `object`) return out;
10618
+ for (const [key, value] of Object.entries(record)) {
10619
+ const v = accept(value);
10620
+ if (v !== void 0) out.set(key, v);
10621
+ }
10622
+ return out;
10623
+ }
10624
+ const keyOf = (el) => el.dataset.key;
10625
+ /** Rendered, keyed item rows inside a column's scroll host, in visual order. */
10626
+ function itemRows(host) {
10627
+ return [...host.querySelectorAll(`li[data-key]`)];
10628
+ }
10629
+ function captureColumnScroll(host) {
10630
+ return captureElementScroll(host, itemRows(host), keyOf);
10631
+ }
10632
+ /** Apply `scroll` to a column's host; `true` once the position was fully reached. */
10633
+ function restoreColumnScroll(host, scroll) {
10634
+ const anchor = scroll.anchor;
10635
+ if (!anchor) return scrollToOffset(host, scroll.top);
10636
+ const row = itemRows(host).find((el) => keyOf(el) === anchor.key);
10637
+ if (!row) return false;
10638
+ return restoreElementScroll(host, row, anchor.offset);
10639
+ }
10640
+ //#endregion
9813
10641
  //#region src/multicol-list/types.ts
9814
10642
  const ALL_KEY = `__all__`;
9815
10643
  //#endregion
@@ -9861,6 +10689,7 @@ var MulticolColumnController = class {
9861
10689
  #dataProvider;
9862
10690
  #onSelectionChange;
9863
10691
  #onRequestData;
10692
+ #onScroll;
9864
10693
  #abortController = void 0;
9865
10694
  #host;
9866
10695
  #allRow;
@@ -9868,7 +10697,19 @@ var MulticolColumnController = class {
9868
10697
  #selectedValues = [];
9869
10698
  #allSelected = true;
9870
10699
  #lastItems = [];
10700
+ /** Keys of `#lastItems`; empty until data for the current category arrived. */
10701
+ #itemKeys = EMPTY_KEYS;
10702
+ /** Saved selection keys not in the data (yet). */
10703
+ #pendingSelection = EMPTY_KEYS;
10704
+ /** Saved scroll waiting for its anchor row / enough content. */
10705
+ #pendingScroll;
10706
+ #disposeScrollIntent;
9871
10707
  #syncingSelection = false;
10708
+ /**
10709
+ * Pending `#restoreAfterSync` passes. The lister re-renders new items and
10710
+ * may fire `select` before the sync runs; that isn't the user's doing.
10711
+ */
10712
+ #syncsInFlight = 0;
9872
10713
  #state;
9873
10714
  constructor(options) {
9874
10715
  this.id = options.id;
@@ -9878,6 +10719,7 @@ var MulticolColumnController = class {
9878
10719
  this.#dataProvider = options.dataProvider;
9879
10720
  this.#onSelectionChange = options.onSelectionChange;
9880
10721
  this.#onRequestData = options.onRequestData;
10722
+ this.#onScroll = options.onScroll;
9881
10723
  this.#state = {
9882
10724
  id: options.id,
9883
10725
  categoryKey: options.category.key,
@@ -9953,7 +10795,10 @@ var MulticolColumnController = class {
9953
10795
  grouped: category.defaults?.grouped ?? false,
9954
10796
  menu: category.defaults?.menu ?? true
9955
10797
  };
9956
- this.clearSelection();
10798
+ this.#pendingSelection = EMPTY_KEYS;
10799
+ this.#pendingScroll = void 0;
10800
+ this.#itemKeys = EMPTY_KEYS;
10801
+ this.#setAllSelection(`data`);
9957
10802
  this.#syncGrouping();
9958
10803
  }
9959
10804
  get listerHost() {
@@ -9978,7 +10823,7 @@ var MulticolColumnController = class {
9978
10823
  allRow.appendChild(allLabel);
9979
10824
  const applyAll = () => {
9980
10825
  if (this.#allSelected) return;
9981
- this.#setAllSelection();
10826
+ this.#setAllSelection(`user`);
9982
10827
  };
9983
10828
  allRow.addEventListener(`click`, applyAll);
9984
10829
  allRow.addEventListener(`keydown`, (e) => {
@@ -9999,6 +10844,10 @@ var MulticolColumnController = class {
9999
10844
  this.#syncGrouping();
10000
10845
  this.#syncAllRow();
10001
10846
  host.append(allRow, lister);
10847
+ host.addEventListener(`scroll`, this.#handleScroll, { passive: true });
10848
+ this.#disposeScrollIntent = onUserScrollIntent(host, () => {
10849
+ this.#pendingScroll = void 0;
10850
+ });
10002
10851
  if (this.#lastItems.length > 0) {
10003
10852
  this.#pushItems();
10004
10853
  this.#restoreAfterSync();
@@ -10006,6 +10855,9 @@ var MulticolColumnController = class {
10006
10855
  }
10007
10856
  unmount() {
10008
10857
  this.#abortPending();
10858
+ this.#host?.removeEventListener(`scroll`, this.#handleScroll);
10859
+ this.#disposeScrollIntent?.();
10860
+ this.#disposeScrollIntent = void 0;
10009
10861
  this.#allRow?.remove();
10010
10862
  this.#listerEl?.remove();
10011
10863
  this.#allRow = void 0;
@@ -10028,17 +10880,17 @@ var MulticolColumnController = class {
10028
10880
  }
10029
10881
  #applyItems(items) {
10030
10882
  this.#lastItems = items;
10031
- const valid = this.#selectedValues.filter((v) => items.some((i) => i.key === v));
10032
- const changed = valid.length !== this.#selectedValues.length;
10033
- this.#selectedValues = valid;
10034
- if (valid.length === 0) this.#allSelected = true;
10035
- if (!this.#listerEl) {
10036
- if (changed) this.#onSelectionChange(this.id, valid);
10037
- return;
10883
+ const keys = new Set(items.map((i) => i.key));
10884
+ this.#itemKeys = keys;
10885
+ const r = reconcileSelection(this.#selectedValues, this.#pendingSelection, (k) => keys.has(k));
10886
+ this.#pendingSelection = r.pending;
10887
+ this.#selectedValues = r.selected;
10888
+ this.#allSelected = r.selected.length === 0;
10889
+ if (this.#listerEl) {
10890
+ this.#pushItems();
10891
+ this.#restoreAfterSync();
10038
10892
  }
10039
- this.#pushItems();
10040
- this.#restoreAfterSync();
10041
- if (changed) this.#onSelectionChange(this.id, valid);
10893
+ if (r.changed) this.#onSelectionChange(this.id, r.selected, r.restored ? `restore` : `data`);
10042
10894
  }
10043
10895
  #pushItems() {
10044
10896
  if (!this.#listerEl) return;
@@ -10063,7 +10915,7 @@ var MulticolColumnController = class {
10063
10915
  return this.#listerEl.querySelectorAll(`:scope > .ungrouped-host > *, :scope > ixfx-panel > *`);
10064
10916
  }
10065
10917
  #handleSelect = () => {
10066
- if (this.#syncingSelection) return;
10918
+ if (this.#syncingSelection || this.#syncsInFlight > 0) return;
10067
10919
  const values = [...this.#listerEl?.selectedItems ?? []].map((i) => i.key).filter((k) => k !== ALL_KEY);
10068
10920
  if (values.length === this.#selectedValues.length && values.every((v) => this.#selectedValues.includes(v))) {
10069
10921
  this.#syncAllRow();
@@ -10072,9 +10924,9 @@ var MulticolColumnController = class {
10072
10924
  this.#selectedValues = values;
10073
10925
  this.#allSelected = values.length === 0;
10074
10926
  this.#syncAllRow();
10075
- this.#onSelectionChange(this.id, values);
10927
+ this.#onSelectionChange(this.id, values, `user`);
10076
10928
  };
10077
- #setAllSelection() {
10929
+ #setAllSelection(origin) {
10078
10930
  const previous = this.#selectedValues;
10079
10931
  this.#syncingSelection = true;
10080
10932
  try {
@@ -10085,17 +10937,64 @@ var MulticolColumnController = class {
10085
10937
  this.#syncingSelection = false;
10086
10938
  }
10087
10939
  this.#syncAllRow();
10088
- if (previous.length > 0) this.#onSelectionChange(this.id, []);
10940
+ if (previous.length > 0) this.#onSelectionChange(this.id, [], origin);
10089
10941
  }
10942
+ /** Back to 'All' — an explicit selection change. */
10090
10943
  clearSelection() {
10091
- this.#setAllSelection();
10944
+ this.#setAllSelection(`user`);
10092
10945
  }
10093
10946
  get selectedValues() {
10094
10947
  return this.#selectedValues;
10095
10948
  }
10949
+ get pendingSelection() {
10950
+ return this.#pendingSelection;
10951
+ }
10952
+ /**
10953
+ * Replace the selection with `keys`. Keys in the current data are selected
10954
+ * now; the rest stay pending until items carrying them arrive.
10955
+ */
10956
+ restoreSelection(keys) {
10957
+ const previous = this.#selectedValues;
10958
+ const r = reconcileSelection([], capKeys(keys), (k) => this.#itemKeys.has(k));
10959
+ this.#pendingSelection = r.pending;
10960
+ this.#selectedValues = r.selected;
10961
+ this.#allSelected = r.selected.length === 0;
10962
+ this.#restoreAfterSync();
10963
+ this.#syncAllRow();
10964
+ if (!(previous.length === r.selected.length && previous.every((k) => r.selected.includes(k)))) this.#onSelectionChange(this.id, r.selected, `restore`);
10965
+ }
10966
+ clearPendingSelection() {
10967
+ this.#pendingSelection = EMPTY_KEYS;
10968
+ }
10969
+ get pendingScroll() {
10970
+ return this.#pendingScroll;
10971
+ }
10972
+ /** Current scroll of the column's list; `undefined` while unmounted. */
10973
+ captureScroll() {
10974
+ return this.#host ? captureColumnScroll(this.#host) : void 0;
10975
+ }
10976
+ /** Scroll to `scroll` now if possible, else when enough items have rendered. */
10977
+ restoreScroll(scroll) {
10978
+ this.#pendingScroll = scroll;
10979
+ this.#tryPendingScroll();
10980
+ }
10981
+ clearPendingScroll() {
10982
+ this.#pendingScroll = void 0;
10983
+ }
10984
+ #tryPendingScroll() {
10985
+ const scroll = this.#pendingScroll;
10986
+ if (!scroll || !this.#host) return;
10987
+ if (restoreColumnScroll(this.#host, scroll)) this.#pendingScroll = void 0;
10988
+ }
10989
+ #handleScroll = () => {
10990
+ if (!this.#pendingScroll) this.#onScroll?.(this.id);
10991
+ };
10096
10992
  #restoreAfterSync() {
10097
10993
  if (!this.#listerEl) return;
10098
- this.#listerEl.updateComplete.then(() => {
10994
+ this.#syncsInFlight++;
10995
+ this.#listerEl.updateComplete.finally(() => {
10996
+ this.#syncsInFlight--;
10997
+ }).then(() => {
10099
10998
  if (!this.#listerEl || !this.#host) return;
10100
10999
  this.#syncingSelection = true;
10101
11000
  try {
@@ -10108,6 +11007,7 @@ var MulticolColumnController = class {
10108
11007
  this.#syncingSelection = false;
10109
11008
  }
10110
11009
  this.#syncAllRow();
11010
+ this.#tryPendingScroll();
10111
11011
  });
10112
11012
  }
10113
11013
  #syncAllRow() {
@@ -10139,12 +11039,201 @@ function renderItem(item) {
10139
11039
  }
10140
11040
  return li;
10141
11041
  }
11042
+ /** Shadow styles for `ixfx-multicol-list` (theme fallbacks / handle styles are added by the element). */
11043
+ const multicolListStyles = css`
11044
+ :host {
11045
+ --multicol-list-bg: var(--surface-3);
11046
+ --multicol-list-header-bg: var(--surface-2);
11047
+ --multicol-list-header-text: var(--surface-3-text);
11048
+ --multicol-list-border: var(--border);
11049
+ --multicol-list-min-column-width: ${80}px;
11050
+ --multicol-list-max-column-width: ${480}px;
11051
+ --multicol-list-header-font-size: unset;
11052
+
11053
+ display: block;
11054
+ overflow: hidden;
11055
+ /* A sideways trackpad swipe over the component must never pan it
11056
+ (or the page behind it, or trigger history navigation) */
11057
+ overscroll-behavior-x: none;
11058
+ background: var(--multicol-list-bg);
11059
+ color: var(--surface-3-text);
11060
+ font-family: var(--font-family);
11061
+ }
11062
+ #container {
11063
+ display: flex;
11064
+ flex-direction: row;
11065
+ align-items: stretch;
11066
+ overflow: hidden;
11067
+ overscroll-behavior-x: none;
11068
+ position: relative;
11069
+ height: 100%;
11070
+ }
11071
+ .column {
11072
+ flex-shrink: 0;
11073
+ display: flex;
11074
+ flex-direction: column;
11075
+ border-right: var(--multicol-list-border);
11076
+ min-width: var(--multicol-list-min-column-width);
11077
+ max-width: var(--multicol-list-max-column-width);
11078
+ background: var(--surface-3);
11079
+ }
11080
+ .column-header {
11081
+ display: flex;
11082
+ align-items: center;
11083
+ gap: var(--space-xs);
11084
+ height: 32px;
11085
+ background: var(--multicol-list-header-bg);
11086
+ color: var(--multicol-list-header-text);
11087
+ border-bottom: var(--border);
11088
+ position: relative;
11089
+ user-select: none;
11090
+ min-width: var(--multicol-list-min-column-width);
11091
+ box-sizing: border-box;
11092
+ }
11093
+ .column-header[data-dragging] {
11094
+ opacity: 0.4;
11095
+ }
11096
+ .reorder-handle {
11097
+ cursor: grab;
11098
+ opacity: 0;
11099
+ display: inline-flex;
11100
+ align-items: center;
11101
+ flex-shrink: 0;
11102
+ padding-left: var(--space-xs);
11103
+ touch-action: none;
11104
+ transition: opacity var(--transition, 0.15s ease-out);
11105
+ }
11106
+ .column-header:hover .reorder-handle, .column-header:focus-within .reorder-handle, .column-header[data-dragging] .reorder-handle { opacity: var(--ixfx-handle-opacity, 0.5); }
11107
+ .reorder-handle:active {
11108
+ cursor: grabbing;
11109
+ }
11110
+ .reorder-handle .ixfx-handle {
11111
+ --ixfx-handle-length: 14px;
11112
+ --ixfx-handle-thickness: var(--multicol-list-reorder-handle-width, 6px);
11113
+ --ixfx-handle-dot-size: 3px;
11114
+ }
11115
+ .column-label {
11116
+ flex: 1;
11117
+ overflow: hidden;
11118
+ text-overflow: ellipsis;
11119
+ white-space: nowrap;
11120
+ padding: 0 var(--space-s);
11121
+ font-size: var(--multicol-list-header-font-size);
11122
+ font-weight: 600;
11123
+ background: transparent;
11124
+ border: none;
11125
+ color: inherit;
11126
+ cursor: pointer;
11127
+ text-align: left;
11128
+ min-width: 0;
11129
+ }
11130
+ .column-label:hover {
11131
+ text-decoration: underline;
11132
+ }
11133
+ .burger-btn {
11134
+ flex-shrink: 0;
11135
+ width: 28px;
11136
+ height: 28px;
11137
+ display: flex;
11138
+ align-items: center;
11139
+ justify-content: center;
11140
+ background: transparent;
11141
+ border: none;
11142
+ cursor: pointer;
11143
+ color: var(--surface-3-text);
11144
+ opacity: 0;
11145
+ transition: opacity var(--transition);
11146
+ border-radius: var(--radius-s);
11147
+ }
11148
+ .column:hover .burger-btn,
11149
+ .column-header:focus-within .burger-btn {
11150
+ opacity: 1;
11151
+ }
11152
+ .burger-btn:hover {
11153
+ background: var(--surface-4);
11154
+ }
11155
+ .resize-handle {
11156
+ position: absolute;
11157
+ right: -4px;
11158
+ top: 0;
11159
+ bottom: 0;
11160
+ width: 8px;
11161
+ cursor: col-resize;
11162
+ z-index: 1;
11163
+ display: flex;
11164
+ align-items: stretch;
11165
+ justify-content: center;
11166
+ touch-action: none;
11167
+ }
11168
+ .resize-handle::before {
11169
+ content: '';
11170
+ width: 2px;
11171
+ margin: 2px 0;
11172
+ border-radius: 1px;
11173
+ background: transparent;
11174
+ transition: background-color var(--transition);
11175
+ }
11176
+ .resize-handle:hover::before,
11177
+ .resize-handle[data-active]::before {
11178
+ background: var(--accent);
11179
+ }
11180
+ .lister-host {
11181
+ flex: 1;
11182
+ overflow-y: auto;
11183
+ overflow-x: hidden;
11184
+ /* Vertical scrolling only: horizontal deltas are swallowed here
11185
+ instead of chaining up and panning the whole component */
11186
+ overscroll-behavior-x: none;
11187
+ touch-action: pan-y;
11188
+ position: relative;
11189
+ display: flex;
11190
+ flex-direction: column;
11191
+ --list-gutter: 0;
11192
+ }
11193
+ .all-row {
11194
+ display: flex;
11195
+ align-items: center;
11196
+ padding: var(--space-xs) var(--space-s);
11197
+ cursor: pointer;
11198
+ user-select: none;
11199
+ min-height: 28px;
11200
+ box-sizing: border-box;
11201
+ outline: 1px solid transparent;
11202
+ background: var(--surface-3);
11203
+ }
11204
+ .all-row:hover,
11205
+ .all-row[data-selected] {
11206
+ background: var(--surface-5);
11207
+ }
11208
+ .all-row[data-selected] {
11209
+ outline-color: var(--accent);
11210
+ font-weight: 600;
11211
+ }
11212
+ ixfx-grouped-item-lister {
11213
+ display: block;
11214
+ }
11215
+ .drop-caret {
11216
+ position: absolute;
11217
+ top: 0;
11218
+ bottom: 0;
11219
+ width: 2px;
11220
+ background: var(--accent);
11221
+ border-radius: 1px;
11222
+ pointer-events: none;
11223
+ z-index: 10;
11224
+ }
11225
+ .item-label {
11226
+ flex: 1;
11227
+ }
11228
+ .count {
11229
+ opacity: 0.6;
11230
+ margin-left: var(--space-s);
11231
+ font-size: var(--text-s);
11232
+ }
11233
+ `;
10142
11234
  //#endregion
10143
11235
  //#region src/multicol-list/multicol-list.ts
10144
11236
  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>`);
10145
- const DEFAULT_COLUMN_WIDTH = 220;
10146
- const MIN_COLUMN_WIDTH = 80;
10147
- const MAX_COLUMN_WIDTH = 480;
10148
11237
  const DRAG_THRESHOLD = 5;
10149
11238
  let MulticolListElement = class MulticolListElement extends LitElement {
10150
11239
  constructor(..._args) {
@@ -10169,240 +11258,33 @@ let MulticolListElement = class MulticolListElement extends LitElement {
10169
11258
  ghost: null,
10170
11259
  insert: -1
10171
11260
  };
11261
+ this.#viewState = new ViewStateController(this, {
11262
+ kind: MULTICOL_VIEW_STATE_KIND,
11263
+ capture: () => this.getViewState()
11264
+ });
11265
+ this.#pendingColumns = [];
11266
+ this.#pendingSelected = /* @__PURE__ */ new Map();
11267
+ this.#pendingScroll = /* @__PURE__ */ new Map();
10172
11268
  }
10173
11269
  static {
10174
11270
  this.styles = [
10175
11271
  themeFallbacks,
10176
11272
  handleStyles,
10177
- css`
10178
- :host {
10179
- --multicol-list-bg: var(--surface-3);
10180
- --multicol-list-header-bg: var(--surface-2);
10181
- --multicol-list-header-text: var(--surface-3-text);
10182
- --multicol-list-border: var(--border);
10183
- --multicol-list-min-column-width: ${MIN_COLUMN_WIDTH}px;
10184
- --multicol-list-max-column-width: ${MAX_COLUMN_WIDTH}px;
10185
- --multicol-list-header-font-size: unset;
10186
-
10187
- display: block;
10188
- overflow: hidden;
10189
- /* A sideways trackpad swipe over the component must never pan it
10190
- (or the page behind it, or trigger history navigation) */
10191
- overscroll-behavior-x: none;
10192
- background: var(--multicol-list-bg);
10193
- color: var(--surface-3-text);
10194
- font-family: var(--font-family);
10195
- }
10196
- #container {
10197
- display: flex;
10198
- flex-direction: row;
10199
- align-items: stretch;
10200
- overflow: hidden;
10201
- overscroll-behavior-x: none;
10202
- position: relative;
10203
- height: 100%;
10204
- }
10205
- .column {
10206
- flex-shrink: 0;
10207
- display: flex;
10208
- flex-direction: column;
10209
- border-right: var(--multicol-list-border);
10210
- min-width: var(--multicol-list-min-column-width);
10211
- max-width: var(--multicol-list-max-column-width);
10212
- background: var(--surface-3);
10213
- }
10214
- .column-header {
10215
- display: flex;
10216
- align-items: center;
10217
- gap: var(--space-xs);
10218
- height: 32px;
10219
- background: var(--multicol-list-header-bg);
10220
- color: var(--multicol-list-header-text);
10221
- border-bottom: var(--border);
10222
- position: relative;
10223
- user-select: none;
10224
- min-width: var(--multicol-list-min-column-width);
10225
- box-sizing: border-box;
10226
- }
10227
- .column-header[data-dragging] {
10228
- opacity: 0.4;
10229
- }
10230
- .reorder-handle {
10231
- cursor: grab;
10232
- opacity: 0;
10233
- display: inline-flex;
10234
- align-items: center;
10235
- flex-shrink: 0;
10236
- padding-left: var(--space-xs);
10237
- touch-action: none;
10238
- transition: opacity var(--transition, 0.15s ease-out);
10239
- }
10240
- .column-header:hover .reorder-handle, .column-header:focus-within .reorder-handle, .column-header[data-dragging] .reorder-handle { opacity: var(--ixfx-handle-opacity, 0.5); }
10241
- .reorder-handle:active {
10242
- cursor: grabbing;
10243
- }
10244
- .reorder-handle .ixfx-handle {
10245
- --ixfx-handle-length: 14px;
10246
- --ixfx-handle-thickness: var(--multicol-list-reorder-handle-width, 6px);
10247
- --ixfx-handle-dot-size: 3px;
10248
- }
10249
- .column-label {
10250
- flex: 1;
10251
- overflow: hidden;
10252
- text-overflow: ellipsis;
10253
- white-space: nowrap;
10254
- padding: 0 var(--space-s);
10255
- font-size: var(--multicol-list-header-font-size);
10256
- font-weight: 600;
10257
- background: transparent;
10258
- border: none;
10259
- color: inherit;
10260
- cursor: pointer;
10261
- text-align: left;
10262
- min-width: 0;
10263
- }
10264
- .column-label:hover {
10265
- text-decoration: underline;
10266
- }
10267
- .burger-btn {
10268
- flex-shrink: 0;
10269
- width: 28px;
10270
- height: 28px;
10271
- display: flex;
10272
- align-items: center;
10273
- justify-content: center;
10274
- background: transparent;
10275
- border: none;
10276
- cursor: pointer;
10277
- color: var(--surface-3-text);
10278
- opacity: 0;
10279
- transition: opacity var(--transition);
10280
- border-radius: var(--radius-s);
10281
- }
10282
- .column:hover .burger-btn,
10283
- .column-header:focus-within .burger-btn {
10284
- opacity: 1;
10285
- }
10286
- .burger-btn:hover {
10287
- background: var(--surface-4);
10288
- }
10289
- .resize-handle {
10290
- position: absolute;
10291
- right: -4px;
10292
- top: 0;
10293
- bottom: 0;
10294
- width: 8px;
10295
- cursor: col-resize;
10296
- z-index: 1;
10297
- display: flex;
10298
- align-items: stretch;
10299
- justify-content: center;
10300
- touch-action: none;
10301
- }
10302
- .resize-handle::before {
10303
- content: '';
10304
- width: 2px;
10305
- margin: 2px 0;
10306
- border-radius: 1px;
10307
- background: transparent;
10308
- transition: background-color var(--transition);
10309
- }
10310
- .resize-handle:hover::before,
10311
- .resize-handle[data-active]::before {
10312
- background: var(--accent);
10313
- }
10314
- .lister-host {
10315
- flex: 1;
10316
- overflow-y: auto;
10317
- overflow-x: hidden;
10318
- /* Vertical scrolling only: horizontal deltas are swallowed here
10319
- instead of chaining up and panning the whole component */
10320
- overscroll-behavior-x: none;
10321
- touch-action: pan-y;
10322
- position: relative;
10323
- display: flex;
10324
- flex-direction: column;
10325
- --list-gutter: 0;
10326
- }
10327
- .all-row {
10328
- display: flex;
10329
- align-items: center;
10330
- padding: var(--space-xs) var(--space-s);
10331
- cursor: pointer;
10332
- user-select: none;
10333
- min-height: 28px;
10334
- box-sizing: border-box;
10335
- outline: 1px solid transparent;
10336
- background: var(--surface-3);
10337
- }
10338
- .all-row:hover,
10339
- .all-row[data-selected] {
10340
- background: var(--surface-5);
10341
- }
10342
- .all-row[data-selected] {
10343
- outline-color: var(--accent);
10344
- font-weight: 600;
10345
- }
10346
- ixfx-grouped-item-lister {
10347
- display: block;
10348
- }
10349
- .drop-caret {
10350
- position: absolute;
10351
- top: 0;
10352
- bottom: 0;
10353
- width: 2px;
10354
- background: var(--accent);
10355
- border-radius: 1px;
10356
- pointer-events: none;
10357
- z-index: 10;
10358
- }
10359
- .item-label {
10360
- flex: 1;
10361
- }
10362
- .count {
10363
- opacity: 0.6;
10364
- margin-left: var(--space-s);
10365
- font-size: var(--text-s);
10366
- }
10367
- `
11273
+ multicolListStyles
10368
11274
  ];
10369
11275
  }
10370
- get columns() {
10371
- return this._columns.map((c) => c.getState());
10372
- }
10373
- setColumns(states) {
10374
- const existingMap = new Map(this._columns.map((c) => [c.id, c]));
10375
- const newColumns = [];
10376
- for (const colState of states) {
10377
- const existing = existingMap.get(colState.id);
10378
- if (existing) {
10379
- if (existing.categoryKey !== colState.categoryKey) {
10380
- const newCategory = findLeafCategory(this.categories, colState.categoryKey);
10381
- if (newCategory) existing.setCategory(newCategory);
10382
- }
10383
- existing.updateState(colState);
10384
- newColumns.push(existing);
10385
- existingMap.delete(colState.id);
10386
- continue;
10387
- }
10388
- const category = findLeafCategory(this.categories, colState.categoryKey);
10389
- if (!category) continue;
10390
- const controller = this.#createController(colState.id, category);
10391
- controller.updateState(colState);
10392
- newColumns.push(controller);
10393
- }
10394
- for (const col of existingMap.values()) col.unmount();
10395
- this._columns = newColumns;
10396
- this._updateColOffsets();
10397
- this._emitColumnsChange(`add`);
10398
- this._requeryAllColumns();
10399
- this.requestUpdate();
10400
- }
11276
+ #viewState;
11277
+ /** Saved columns whose category isn't in `categories` (yet). */
11278
+ #pendingColumns;
11279
+ /** Saved selection / scroll for column ids that don't exist (yet). */
11280
+ #pendingSelected;
11281
+ #pendingScroll;
10401
11282
  addColumn(categoryKey, position) {
10402
11283
  if (this.maxColumns > 0 && this._columns.length >= this.maxColumns) return;
10403
11284
  const category = findLeafCategory(this.categories, categoryKey);
10404
11285
  if (!category) return;
10405
- const id = `col-${++this._idCounter}`;
11286
+ let id = `col-${++this._idCounter}`;
11287
+ while (this._columns.some((c) => c.id === id) || this.#pendingColumns.some((p) => p.state.id === id)) id = `col-${++this._idCounter}`;
10406
11288
  const controller = this.#createController(id, category);
10407
11289
  if (position !== void 0 && position >= 0 && position < this._columns.length) this._columns.splice(position, 0, controller);
10408
11290
  else this._columns.push(controller);
@@ -10430,9 +11312,15 @@ let MulticolListElement = class MulticolListElement extends LitElement {
10430
11312
  }));
10431
11313
  }
10432
11314
  clearSelection() {
11315
+ this.#dropPendingSelection();
10433
11316
  for (const col of this._columns) col.clearSelection();
10434
11317
  this.requestUpdate();
10435
11318
  }
11319
+ createRenderRoot() {
11320
+ const root = super.createRenderRoot();
11321
+ root.addEventListener(VIEW_STATE_CHANGE, (e) => e.stopPropagation());
11322
+ return root;
11323
+ }
10436
11324
  render() {
10437
11325
  return html`
10438
11326
  <div id="container">
@@ -10441,7 +11329,136 @@ let MulticolListElement = class MulticolListElement extends LitElement {
10441
11329
  </div>
10442
11330
  `;
10443
11331
  }
11332
+ /**
11333
+ * Snapshot the column layout (in display order) and each column's
11334
+ * selection and scroll, keyed by column id. Includes pending state —
11335
+ * saved columns whose category isn't known and keys not yet in the data.
11336
+ */
11337
+ getViewState(options) {
11338
+ const withPending = options?.includePending ?? true;
11339
+ const selectedByColumn = {};
11340
+ const scrollByColumn = {};
11341
+ if (withPending) {
11342
+ for (const [id, keys] of this.#pendingSelected) selectedByColumn[id] = [...keys];
11343
+ for (const [id, scroll] of this.#pendingScroll) scrollByColumn[id] = scroll;
11344
+ }
11345
+ for (const col of this._columns) {
11346
+ const keys = keysToArray(col.selectedValues, withPending ? col.pendingSelection : []);
11347
+ if (keys.length > 0) selectedByColumn[col.id] = keys;
11348
+ const scroll = (withPending ? col.pendingScroll : void 0) ?? col.captureScroll();
11349
+ if (scroll) scrollByColumn[col.id] = scroll;
11350
+ }
11351
+ return this.#viewState.shape({
11352
+ columns: mergeColumns(this._columns.map((c) => c.getState()), withPending ? this.#pendingColumns : []),
11353
+ selectedByColumn,
11354
+ scrollByColumn
11355
+ }, options);
11356
+ }
11357
+ /**
11358
+ * Apply saved view state; fields present replace the current ones.
11359
+ * `columns` creates, reorders, re-points and removes columns to match
11360
+ * (columns whose category isn't in `categories` wait until it is).
11361
+ * Selection keys not in a column's data are applied when they arrive.
11362
+ */
11363
+ setViewState(state) {
11364
+ if (!this.#viewState.accepts(state)) return;
11365
+ this.#viewState.restoring(() => {
11366
+ if (state.columns) this.#applyColumns(sanitiseColumns(state.columns));
11367
+ if (state.selectedByColumn) {
11368
+ const wanted = mapFromRecord(state.selectedByColumn, (v) => Array.isArray(v) ? keysFromJson(v) : void 0);
11369
+ for (const col of this._columns) {
11370
+ col.restoreSelection(wanted.get(col.id) ?? EMPTY_KEYS);
11371
+ wanted.delete(col.id);
11372
+ }
11373
+ this.#pendingSelected = wanted;
11374
+ }
11375
+ if (state.scrollByColumn) {
11376
+ const wanted = mapFromRecord(state.scrollByColumn, (v) => isScrollState(v) ? v : void 0);
11377
+ for (const col of this._columns) {
11378
+ const scroll = wanted.get(col.id);
11379
+ if (scroll) col.restoreScroll(scroll);
11380
+ else col.clearPendingScroll();
11381
+ wanted.delete(col.id);
11382
+ }
11383
+ this.#pendingScroll = wanted;
11384
+ }
11385
+ this.#viewState.notify();
11386
+ });
11387
+ this.requestUpdate();
11388
+ }
11389
+ clearPendingViewState() {
11390
+ this.#pendingColumns = [];
11391
+ this.#pendingSelected = /* @__PURE__ */ new Map();
11392
+ this.#pendingScroll = /* @__PURE__ */ new Map();
11393
+ for (const col of this._columns) {
11394
+ col.clearPendingSelection();
11395
+ col.clearPendingScroll();
11396
+ }
11397
+ }
11398
+ /** Reuse live columns by id (re-pointing their category), create the rest, drop the others. */
11399
+ #applyColumns(saved) {
11400
+ const hasCategory = (key) => findLeafCategory(this.categories, key) !== void 0;
11401
+ const { ready, pending } = partitionColumns(saved, hasCategory);
11402
+ const existing = new Map(this._columns.map((c) => [c.id, c]));
11403
+ const next = ready.map((colState) => this.#columnFromState(colState, existing));
11404
+ for (const col of existing.values()) col.unmount();
11405
+ if (saved.length > 0) this._autoSeeded = true;
11406
+ this.#pendingColumns = pending;
11407
+ this._columns = next;
11408
+ this._updateColOffsets();
11409
+ this._emitColumnsChange(`add`);
11410
+ this._requeryAllColumns();
11411
+ }
11412
+ /** Live column for `colState`: the existing one with that id (taken from `existing`), or a new one. */
11413
+ #columnFromState(colState, existing) {
11414
+ const category = findLeafCategory(this.categories, colState.categoryKey);
11415
+ const live = existing.get(colState.id);
11416
+ existing.delete(colState.id);
11417
+ if (live) {
11418
+ if (live.categoryKey !== colState.categoryKey) live.setCategory(category);
11419
+ live.updateState(colState);
11420
+ return live;
11421
+ }
11422
+ const controller = this.#createController(colState.id, category);
11423
+ controller.updateState(colState);
11424
+ const keys = this.#pendingSelected.get(colState.id);
11425
+ if (keys) {
11426
+ controller.restoreSelection(keys);
11427
+ this.#pendingSelected = withoutKey(this.#pendingSelected, colState.id);
11428
+ }
11429
+ const scroll = this.#pendingScroll.get(colState.id);
11430
+ if (scroll) {
11431
+ controller.restoreScroll(scroll);
11432
+ this.#pendingScroll = withoutKey(this.#pendingScroll, colState.id);
11433
+ }
11434
+ return controller;
11435
+ }
11436
+ /** Insert pending columns whose category has now appeared, at their saved positions. */
11437
+ #resolvePendingColumns() {
11438
+ const hasCategory = (key) => findLeafCategory(this.categories, key) !== void 0;
11439
+ const { ready, pending } = resolvePendingColumns(this.#pendingColumns, hasCategory);
11440
+ if (ready.length === 0) return;
11441
+ this.#viewState.restoring(() => {
11442
+ this.#pendingColumns = pending;
11443
+ const columns = [...this._columns];
11444
+ const none = /* @__PURE__ */ new Map();
11445
+ for (const p of [...ready].sort((a, b) => a.index - b.index)) {
11446
+ if (columns.some((c) => c.id === p.state.id)) continue;
11447
+ columns.splice(Math.min(p.index, columns.length), 0, this.#columnFromState(p.state, none));
11448
+ }
11449
+ this._columns = columns;
11450
+ this._updateColOffsets();
11451
+ this._emitColumnsChange(`add`);
11452
+ this._requeryAllColumns();
11453
+ });
11454
+ }
11455
+ /** An explicit selection change supersedes any saved selection still waiting. */
11456
+ #dropPendingSelection() {
11457
+ this.#pendingSelected = /* @__PURE__ */ new Map();
11458
+ for (const col of this._columns) col.clearPendingSelection();
11459
+ }
10444
11460
  updated(changedProperties) {
11461
+ if (changedProperties.has(`categories`)) this.#resolvePendingColumns();
10445
11462
  const seedCategory = flattenCategories(this.categories)[0];
10446
11463
  if (!this._autoSeeded && this._columns.length === 0 && seedCategory) {
10447
11464
  this._autoSeeded = true;
@@ -10458,8 +11475,8 @@ let MulticolListElement = class MulticolListElement extends LitElement {
10458
11475
  this._updateColOffsets();
10459
11476
  }
10460
11477
  _renderColumn(col, idx) {
10461
- const rawWidth = col.getState().width ?? DEFAULT_COLUMN_WIDTH;
10462
- const width = Number.isFinite(rawWidth) ? rawWidth : DEFAULT_COLUMN_WIDTH;
11478
+ const rawWidth = col.getState().width ?? 220;
11479
+ const width = Number.isFinite(rawWidth) ? rawWidth : 220;
10463
11480
  return html`
10464
11481
  <div
10465
11482
  class="column"
@@ -10521,7 +11538,8 @@ let MulticolListElement = class MulticolListElement extends LitElement {
10521
11538
  selectionMode: this.selectionMode,
10522
11539
  dataProvider: this.dataProvider,
10523
11540
  onSelectionChange: this._onColumnSelectionChange.bind(this),
10524
- onRequestData: this._onColumnRequestData.bind(this)
11541
+ onRequestData: this._onColumnRequestData.bind(this),
11542
+ onScroll: () => this.#viewState.notify()
10525
11543
  });
10526
11544
  }
10527
11545
  _onHeaderPointerDown(e, col, idx) {
@@ -10600,11 +11618,11 @@ let MulticolListElement = class MulticolListElement extends LitElement {
10600
11618
  e.preventDefault();
10601
11619
  e.stopPropagation();
10602
11620
  const startX = e.clientX;
10603
- const rawStartW = col.getState().width ?? DEFAULT_COLUMN_WIDTH;
10604
- const startW = Number.isFinite(rawStartW) ? rawStartW : DEFAULT_COLUMN_WIDTH;
11621
+ const rawStartW = col.getState().width ?? 220;
11622
+ const startW = Number.isFinite(rawStartW) ? rawStartW : 220;
10605
11623
  this._resizingColumnId = col.id;
10606
11624
  const applyWidth = (clientX) => {
10607
- const w = Math.max(MIN_COLUMN_WIDTH, Math.min(MAX_COLUMN_WIDTH, startW + clientX - startX));
11625
+ const w = Math.max(80, Math.min(480, startW + clientX - startX));
10608
11626
  col.setWidth(w);
10609
11627
  return w;
10610
11628
  };
@@ -10733,8 +11751,10 @@ let MulticolListElement = class MulticolListElement extends LitElement {
10733
11751
  this._requeryAllColumns();
10734
11752
  this.requestUpdate();
10735
11753
  }
10736
- _onColumnSelectionChange(columnId, _values) {
11754
+ _onColumnSelectionChange(columnId, _values, origin) {
10737
11755
  if (!this._columns.find((c) => c.id === columnId)) return;
11756
+ if (origin === `user`) this.#dropPendingSelection();
11757
+ this.#viewState.notify(origin);
10738
11758
  for (const otherCol of this._columns) if (otherCol.id !== columnId) otherCol.requestData(this._buildQuery(otherCol));
10739
11759
  const detail = {
10740
11760
  columnId,
@@ -10769,22 +11789,23 @@ let MulticolListElement = class MulticolListElement extends LitElement {
10769
11789
  col.setCategory(leaves[0]);
10770
11790
  changed = true;
10771
11791
  }
10772
- if (changed) this._emitColumnsChange(`category`);
11792
+ if (changed) this._emitColumnsChange(`category`, `data`);
10773
11793
  }
10774
11794
  _updateColOffsets() {
10775
11795
  const offsets = [0];
10776
11796
  let sum = 0;
10777
11797
  for (const col of this._columns) {
10778
- const rawWidth = col.getState().width ?? DEFAULT_COLUMN_WIDTH;
10779
- sum += Number.isFinite(rawWidth) ? rawWidth : DEFAULT_COLUMN_WIDTH;
11798
+ const rawWidth = col.getState().width ?? 220;
11799
+ sum += Number.isFinite(rawWidth) ? rawWidth : 220;
10780
11800
  offsets.push(sum);
10781
11801
  }
10782
11802
  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;
10783
11803
  }
10784
- _emitColumnsChange(reason) {
11804
+ _emitColumnsChange(reason, viewStateReason = `user`) {
11805
+ this.#viewState.notify(viewStateReason);
10785
11806
  this.dispatchEvent(new CustomEvent(`multicol-columns-change`, {
10786
11807
  detail: {
10787
- columns: this.columns,
11808
+ columns: this._columns.map((c) => c.getState()),
10788
11809
  reason
10789
11810
  },
10790
11811
  bubbles: true,
@@ -10794,8 +11815,8 @@ let MulticolListElement = class MulticolListElement extends LitElement {
10794
11815
  async autoSizeColumns(options) {
10795
11816
  return new Promise((resolve) => {
10796
11817
  setTimeout(() => {
10797
- const minWidth = options?.minWidth ?? MIN_COLUMN_WIDTH;
10798
- const maxWidth = options?.maxWidth ?? MAX_COLUMN_WIDTH;
11818
+ const minWidth = options?.minWidth ?? 80;
11819
+ const maxWidth = options?.maxWidth ?? 480;
10799
11820
  const measure = (text) => {
10800
11821
  const span = document.createElement(`span`);
10801
11822
  span.style.position = `absolute`;
@@ -10873,6 +11894,11 @@ __decorate([state()], MulticolListElement.prototype, "_draggingColumnId", void 0
10873
11894
  __decorate([state()], MulticolListElement.prototype, "_dropCaretX", void 0);
10874
11895
  __decorate([query(`#container`)], MulticolListElement.prototype, "_containerEl", void 0);
10875
11896
  MulticolListElement = __decorate([safeCustomElement(`ixfx-multicol-list`)], MulticolListElement);
11897
+ function withoutKey(map, key) {
11898
+ const next = new Map(map);
11899
+ next.delete(key);
11900
+ return next;
11901
+ }
10876
11902
  //#endregion
10877
11903
  //#region src/notifier/pill-renderer.ts
10878
11904
  const ROW_SPACING = 8;
@@ -14535,6 +15561,242 @@ function computeTreemapLayout(root, options = {}) {
14535
15561
  return rects;
14536
15562
  }
14537
15563
  //#endregion
15564
+ //#region src/treemap/treemap-view-state.ts
15565
+ function indexTreemapKeys(root) {
15566
+ const out = /* @__PURE__ */ new Map();
15567
+ if (!root) return out;
15568
+ const walk = (node, parentKey, depth) => {
15569
+ if (!out.has(node.item.key)) out.set(node.item.key, {
15570
+ node,
15571
+ parentKey,
15572
+ depth
15573
+ });
15574
+ for (const child of node.children ?? []) walk(child, node.item.key, depth + 1);
15575
+ };
15576
+ walk(root, void 0, -1);
15577
+ return out;
15578
+ }
15579
+ /** Key path from the root's children down to `key` (`[]` for the root itself). */
15580
+ function treemapKeyPath(index, key) {
15581
+ const path = [];
15582
+ for (let entry = index.get(key); entry !== void 0; entry = entry.parentKey === void 0 ? void 0 : index.get(entry.parentKey)) {
15583
+ if (entry.parentKey === void 0) return path;
15584
+ path.unshift(entry.node.item.key);
15585
+ }
15586
+ }
15587
+ /**
15588
+ * Where a saved zoom path stands against the loaded tree. The target is
15589
+ * matched by key anywhere (it may have moved); failing that, the deepest
15590
+ * loaded ancestor on the path is reported for loading when its children
15591
+ * haven't been fetched.
15592
+ */
15593
+ function resolveZoomPath(index, path) {
15594
+ const target = path.at(-1);
15595
+ if (target === void 0) return {
15596
+ kind: `found`,
15597
+ key: void 0
15598
+ };
15599
+ if (index.has(target)) return {
15600
+ kind: `found`,
15601
+ key: target
15602
+ };
15603
+ for (let i = path.length - 2; i >= 0; i--) {
15604
+ const entry = index.get(path[i]);
15605
+ if (!entry) continue;
15606
+ const { node, depth } = entry;
15607
+ return node.children === void 0 && node.item.isLeaf !== true ? {
15608
+ kind: `load`,
15609
+ node,
15610
+ depth
15611
+ } : { kind: `missing` };
15612
+ }
15613
+ return { kind: `missing` };
15614
+ }
15615
+ /**
15616
+ * Zoom: a live zoom root that left the tree goes pending (view falls back to
15617
+ * the full tree); a pending path whose target is now loaded is applied.
15618
+ * Selection: see `reconcileSelection` in `common/view-state/tree.ts`.
15619
+ */
15620
+ function reconcileTreemapViewState(input) {
15621
+ const { index } = input;
15622
+ let zoom;
15623
+ let pendingZoom = input.pendingZoom;
15624
+ if (input.zoomKey !== void 0 && !index.has(input.zoomKey)) {
15625
+ pendingZoom ??= input.zoomPath;
15626
+ zoom = {
15627
+ key: void 0,
15628
+ path: [],
15629
+ restored: false
15630
+ };
15631
+ } else if (input.zoomKey !== void 0) {
15632
+ const path = treemapKeyPath(index, input.zoomKey) ?? input.zoomPath;
15633
+ if (!samePath(path, input.zoomPath)) zoom = {
15634
+ key: input.zoomKey,
15635
+ path,
15636
+ restored: false
15637
+ };
15638
+ }
15639
+ if (pendingZoom) {
15640
+ const resolved = resolveZoomPath(index, pendingZoom);
15641
+ if (resolved.kind === `found`) {
15642
+ const key = resolved.key;
15643
+ zoom = {
15644
+ key,
15645
+ path: key === void 0 ? [] : treemapKeyPath(index, key) ?? pendingZoom,
15646
+ restored: true
15647
+ };
15648
+ pendingZoom = void 0;
15649
+ }
15650
+ }
15651
+ const selection = reconcileSelection$1(input.selected, input.pendingSelected, (key) => index.get(key)?.node, input.canSelect, input.singleSelect);
15652
+ return {
15653
+ zoom,
15654
+ pendingZoom,
15655
+ selection
15656
+ };
15657
+ }
15658
+ function samePath(a, b) {
15659
+ return a.length === b.length && a.every((k, i) => k === b[i]);
15660
+ }
15661
+ //#endregion
15662
+ //#region src/treemap/treemap-view-state-binding.ts
15663
+ function createTreemapViewState(host) {
15664
+ const { controller } = host;
15665
+ /** Key path of the zoom root as last seen — what goes pending if it leaves the tree. */
15666
+ let zoomPath = [];
15667
+ let pendingZoom;
15668
+ let pendingSelected = EMPTY_KEYS;
15669
+ let pendingFocus;
15670
+ /** The cursor was last moved by keyboard / search (only then is it saved as `focus`). */
15671
+ let keyboardCursor = false;
15672
+ let structureVersion = 0;
15673
+ let reconciledVersion = -1;
15674
+ let cache;
15675
+ const index = () => {
15676
+ if (cache?.version !== structureVersion) cache = {
15677
+ version: structureVersion,
15678
+ index: indexTreemapKeys(host.root())
15679
+ };
15680
+ return cache.index;
15681
+ };
15682
+ const cursorKey = () => keyboardCursor ? host.tickled() : void 0;
15683
+ const applyZoom = (key, path, restored) => {
15684
+ zoomPath = path;
15685
+ if (host.zoomKey() === key) return;
15686
+ host.setZoom(key, restored);
15687
+ controller.notify();
15688
+ };
15689
+ const reconcileCursor$1 = (idx) => {
15690
+ const cursor = reconcileCursor(cursorKey(), pendingFocus, (key) => idx.has(key), host.isVisible);
15691
+ pendingFocus = cursor.pending;
15692
+ const tickled = host.tickled();
15693
+ if (cursor.clear || tickled !== void 0 && !idx.has(tickled)) host.setTickled(void 0);
15694
+ if (cursor.apply !== void 0) {
15695
+ host.setTickled(cursor.apply);
15696
+ keyboardCursor = true;
15697
+ controller.notify();
15698
+ }
15699
+ };
15700
+ /** Move state for missing keys to pending; apply pending state for keys now loaded. */
15701
+ const reconcile = () => {
15702
+ reconciledVersion = structureVersion;
15703
+ if (!host.root()) return;
15704
+ const idx = index();
15705
+ controller.restoring(() => {
15706
+ const r = reconcileTreemapViewState({
15707
+ index: idx,
15708
+ zoomKey: host.zoomKey(),
15709
+ zoomPath,
15710
+ pendingZoom,
15711
+ selected: host.selected(),
15712
+ pendingSelected,
15713
+ canSelect: host.canSelect,
15714
+ singleSelect: host.singleSelect()
15715
+ });
15716
+ pendingZoom = r.pendingZoom;
15717
+ if (r.zoom) applyZoom(r.zoom.key, r.zoom.path, r.zoom.restored);
15718
+ const sel = r.selection;
15719
+ if (sel.changed) {
15720
+ pendingSelected = sel.pending;
15721
+ const primaryKey = host.selectedNode()?.item.key;
15722
+ const primary = [...sel.selected].find((n) => n.item.key === primaryKey) ?? [...sel.selected].at(-1);
15723
+ host.setSelection(sel.selected, primary, sel.restored);
15724
+ }
15725
+ reconcileCursor$1(idx);
15726
+ });
15727
+ };
15728
+ return {
15729
+ /** The structure changed (new root / model, invalidation, lazy load). */
15730
+ invalidate() {
15731
+ structureVersion++;
15732
+ },
15733
+ /** Run in `willUpdate`. `zoomGone` — the zoom root vanished through in-place mutation. */
15734
+ reconcileIfNeeded(zoomGone) {
15735
+ if (zoomGone) structureVersion++;
15736
+ if (reconciledVersion !== structureVersion) reconcile();
15737
+ },
15738
+ /** An explicit zoom: supersedes a saved one still waiting. */
15739
+ zoomed(key) {
15740
+ pendingZoom = void 0;
15741
+ zoomPath = key === void 0 ? [] : treemapKeyPath(index(), key) ?? [key];
15742
+ controller.notify();
15743
+ },
15744
+ /** Selection changed — explicit changes supersede a saved selection still waiting. */
15745
+ selectionChanged() {
15746
+ if (!controller.isRestoring) pendingSelected = EMPTY_KEYS;
15747
+ controller.notify();
15748
+ },
15749
+ clearPendingSelection() {
15750
+ pendingSelected = EMPTY_KEYS;
15751
+ },
15752
+ /** The cursor moved by keyboard / search navigation. */
15753
+ keyboardCursorMoved() {
15754
+ keyboardCursor = true;
15755
+ pendingFocus = void 0;
15756
+ controller.notify();
15757
+ },
15758
+ /** The cursor moved by pointer. */
15759
+ pointerCursorMoved() {
15760
+ if (!keyboardCursor) return;
15761
+ keyboardCursor = false;
15762
+ controller.notify();
15763
+ },
15764
+ get(options) {
15765
+ const withPending = options?.includePending ?? true;
15766
+ return controller.shape({
15767
+ selected: keysToArray([...host.selected()].map((n) => n.item.key), withPending ? pendingSelected : []),
15768
+ focus: cursorKey() ?? (withPending ? pendingFocus : void 0),
15769
+ zoom: [...(withPending ? pendingZoom : void 0) ?? zoomPath]
15770
+ }, options);
15771
+ },
15772
+ set(state) {
15773
+ if (!controller.accepts(state)) return;
15774
+ controller.restoring(() => {
15775
+ if (state.selected) {
15776
+ pendingSelected = capKeys(new Set(state.selected));
15777
+ if (host.selected().size > 0) host.setSelection(/* @__PURE__ */ new Set(), void 0, true);
15778
+ }
15779
+ if (state.focus !== void 0) pendingFocus = state.focus;
15780
+ const zoom = pathFromJson(state.zoom);
15781
+ if (zoom) pendingZoom = zoom;
15782
+ reconcile();
15783
+ if (zoom && pendingZoom) applyZoom(void 0, [], true);
15784
+ });
15785
+ },
15786
+ clearPending() {
15787
+ pendingSelected = EMPTY_KEYS;
15788
+ pendingFocus = void 0;
15789
+ pendingZoom = void 0;
15790
+ },
15791
+ /** Load the next unloaded ancestor on a pending zoom path (one load at a time). */
15792
+ continueRestoreLoads() {
15793
+ if (!pendingZoom || !host.restoreLoads() || !host.canLoad()) return;
15794
+ const resolved = resolveZoomPath(index(), pendingZoom);
15795
+ if (resolved.kind === `load` && !host.isLoading(resolved.node.item.key)) host.load(resolved.node, resolved.depth);
15796
+ }
15797
+ };
15798
+ }
15799
+ //#endregion
14538
15800
  //#region src/treemap/treemap-list.ts
14539
15801
  let TreemapListElement = class TreemapListElement extends LitElement {
14540
15802
  constructor(..._args) {
@@ -14556,6 +15818,33 @@ let TreemapListElement = class TreemapListElement extends LitElement {
14556
15818
  this.disableKeyboardNavigation = false;
14557
15819
  this.registry = void 0;
14558
15820
  this._zoomKey = void 0;
15821
+ this.restoreLoads = true;
15822
+ this.#viewState = createTreemapViewState({
15823
+ controller: new ViewStateController(this, {
15824
+ kind: `treemap-list`,
15825
+ capture: () => this.getViewState()
15826
+ }),
15827
+ root: () => this.#effectiveRoot,
15828
+ zoomKey: () => this._zoomKey,
15829
+ setZoom: (key, restored) => this.#restoreZoom(key, restored),
15830
+ selected: () => this._selectedNodes,
15831
+ selectedNode: () => this.selectedNode,
15832
+ canSelect: (node) => this.#canSelect(node),
15833
+ singleSelect: () => this.interactionMode === `implicit`,
15834
+ setSelection: (next, primary, fire) => {
15835
+ const prev = this._selectedNodes;
15836
+ this._selectedNodes = new Set(next);
15837
+ this.selectedNode = primary;
15838
+ if (fire) this.#dispatchSelect(prev);
15839
+ },
15840
+ tickled: () => this.tickled.tickledId,
15841
+ setTickled: (key) => key === void 0 ? this.tickled.clearTickled() : this.tickled.setTickled(key),
15842
+ isVisible: (key) => this.#rects().some((r) => r.node.item.key === key),
15843
+ restoreLoads: () => this.restoreLoads,
15844
+ canLoad: () => this.model !== void 0 || this.loadChildren !== void 0,
15845
+ isLoading: (key) => this.#pendingKeys.has(key),
15846
+ load: (node, depth) => void this.#loadChildren(node, depth)
15847
+ });
14559
15848
  this.search = new ListSearchController(this, { adapter: createTreeListSearchAdapter(this, {
14560
15849
  labelSelector: `.treemap-label`,
14561
15850
  highlightKey: `incr-search-highlight`
@@ -14594,6 +15883,7 @@ let TreemapListElement = class TreemapListElement extends LitElement {
14594
15883
  if (this._zoomKey === void 0) return void 0;
14595
15884
  return findTreemapNode(this.#effectiveRoot, this._zoomKey);
14596
15885
  }
15886
+ #viewState;
14597
15887
  #modelUnsubscribe;
14598
15888
  #pendingKeys;
14599
15889
  #aborters;
@@ -14689,40 +15979,38 @@ let TreemapListElement = class TreemapListElement extends LitElement {
14689
15979
  this.#unregisterSearchCommands?.();
14690
15980
  this.#unregisterSearchCommands = registerListSearchCommands(this.#ownedRegistry, this.search);
14691
15981
  }
15982
+ willUpdate(changedProps) {
15983
+ if (changedProps.has(`root`) && this.root || changedProps.has(`model`)) this.#onDataReset();
15984
+ this.#viewState.reconcileIfNeeded(this._zoomKey !== void 0 && !findTreemapNode(this.#effectiveRoot, this._zoomKey));
15985
+ }
14692
15986
  updated(changedProps) {
14693
- if (changedProps.has(`root`) && this.root) this.#resetViewState();
14694
15987
  if (changedProps.has(`model`)) {
14695
15988
  this.#modelUnsubscribe?.();
14696
15989
  this.#modelUnsubscribe = void 0;
14697
- if (this.model) {
14698
- this.#modelUnsubscribe = this.model._subscribe((event) => {
14699
- if (event.type === `root`) {
14700
- this.#resetViewState();
14701
- this.requestUpdate();
14702
- } else if (event.type === `invalidate`) this.requestUpdate();
14703
- });
14704
- this.#resetViewState();
14705
- }
15990
+ if (this.model) this.#modelUnsubscribe = this.model._subscribe((event) => {
15991
+ if (event.type === `root`) this.#onDataReset();
15992
+ else if (event.type === `invalidate`) this.#viewState.invalidate();
15993
+ this.requestUpdate();
15994
+ });
14706
15995
  }
14707
- if (changedProps.has(`interactionMode`)) {
15996
+ if (changedProps.has(`interactionMode`) && changedProps.get(`interactionMode`) !== void 0) {
14708
15997
  this.#anchorIndex = void 0;
14709
15998
  this.clearSelection();
14710
15999
  }
14711
16000
  if (changedProps.has(`searchModes`)) this.#syncSearchCommands();
14712
- if (this._zoomKey !== void 0 && !findTreemapNode(this.#effectiveRoot, this._zoomKey)) this._zoomKey = void 0;
14713
16001
  this.#ensureVisibleChildren();
16002
+ this.#viewState.continueRestoreLoads();
14714
16003
  if (this.#pendingFlip) {
14715
16004
  const flip = this.#pendingFlip;
14716
16005
  this.#pendingFlip = void 0;
14717
16006
  this.#applyFlip(flip);
14718
16007
  }
14719
16008
  }
14720
- #resetViewState() {
16009
+ /** New `root` / `model`: drop transient state; keyed view state is reconciled, not reset. */
16010
+ #onDataReset() {
14721
16011
  this.#pendingFlip = void 0;
14722
- this.#clearSelectionInternal();
14723
- this.tickled.clearTickled();
14724
- this._zoomKey = void 0;
14725
16012
  this.#anchorIndex = void 0;
16013
+ this.#viewState.invalidate();
14726
16014
  }
14727
16015
  #ensureVisibleChildren() {
14728
16016
  if (!this.model && !this.loadChildren) return;
@@ -14753,7 +16041,10 @@ let TreemapListElement = class TreemapListElement extends LitElement {
14753
16041
  } finally {
14754
16042
  this.#pendingKeys.delete(key);
14755
16043
  this.#aborters.delete(key);
14756
- if (!controller.signal.aborted) this.requestUpdate();
16044
+ if (!controller.signal.aborted) {
16045
+ this.#viewState.invalidate();
16046
+ this.requestUpdate();
16047
+ }
14757
16048
  }
14758
16049
  }
14759
16050
  #canSelect(node) {
@@ -14767,6 +16058,7 @@ let TreemapListElement = class TreemapListElement extends LitElement {
14767
16058
  this.selectedNode = void 0;
14768
16059
  }
14769
16060
  #dispatchSelect(previous) {
16061
+ this.#viewState.selectionChanged();
14770
16062
  this.dispatchEvent(new CustomEvent(`select`, {
14771
16063
  detail: {
14772
16064
  selected: new Set(this._selectedNodes),
@@ -14794,6 +16086,7 @@ let TreemapListElement = class TreemapListElement extends LitElement {
14794
16086
  this.requestUpdate();
14795
16087
  }
14796
16088
  clearSelection() {
16089
+ this.#viewState.clearPendingSelection();
14797
16090
  if (this._selectedNodes.size === 0) return;
14798
16091
  const prev = new Set(this._selectedNodes);
14799
16092
  this.#clearSelectionInternal();
@@ -14832,7 +16125,7 @@ let TreemapListElement = class TreemapListElement extends LitElement {
14832
16125
  zoomTo(node) {
14833
16126
  if (this._zoomKey === node.item.key) return;
14834
16127
  this.#pendingFlip = this.#planFlip(node.item.key);
14835
- this._zoomKey = node.item.key;
16128
+ this.#setZoom(node.item.key);
14836
16129
  this.tickled.clearTickled();
14837
16130
  const depth = findTreemapPath(this.#effectiveRoot, node.item.key)?.length;
14838
16131
  const absoluteDepth = depth === void 0 ? -1 : depth - 2;
@@ -14864,7 +16157,7 @@ let TreemapListElement = class TreemapListElement extends LitElement {
14864
16157
  const previous = this.focusedNode;
14865
16158
  const path = this._zoomKey === void 0 ? void 0 : findTreemapPath(this.#effectiveRoot, this._zoomKey);
14866
16159
  const absoluteDepth = path === void 0 ? -1 : path.length - 2;
14867
- this._zoomKey = void 0;
16160
+ this.#setZoom(void 0);
14868
16161
  this.tickled.clearTickled();
14869
16162
  this.dispatchEvent(new CustomEvent(`zoom`, {
14870
16163
  detail: {
@@ -14884,6 +16177,25 @@ let TreemapListElement = class TreemapListElement extends LitElement {
14884
16177
  }));
14885
16178
  this.requestUpdate();
14886
16179
  }
16180
+ /** An explicit zoom change: supersedes any saved zoom still waiting. */
16181
+ #setZoom(key) {
16182
+ this._zoomKey = key;
16183
+ this.#viewState.zoomed(key);
16184
+ }
16185
+ /** Zoom set by view-state reconcile. `restored` fires `zoom` like an explicit zoom, without animating. */
16186
+ #restoreZoom(key, restored) {
16187
+ this._zoomKey = key;
16188
+ if (!restored) return;
16189
+ const path = key === void 0 ? void 0 : findTreemapPath(this.#effectiveRoot, key);
16190
+ this.dispatchEvent(new CustomEvent(`zoom`, {
16191
+ detail: {
16192
+ node: path?.at(-1),
16193
+ depth: path === void 0 ? -1 : path.length - 2
16194
+ },
16195
+ bubbles: true,
16196
+ composed: true
16197
+ }));
16198
+ }
14887
16199
  /**
14888
16200
  * Work out (before `_zoomKey` changes) whether the upcoming zoom should
14889
16201
  * animate, and how — see `FlipPlan`. Returns `undefined` to skip the
@@ -14993,10 +16305,20 @@ let TreemapListElement = class TreemapListElement extends LitElement {
14993
16305
  const ancestor = this.#resolveKeyPath(path.slice(0, path.length - this.maxDepth));
14994
16306
  if (ancestor) this.zoomTo(ancestor);
14995
16307
  }
14996
- this.tickled.setTickled(key);
16308
+ this.#setKeyboardCursor(key);
14997
16309
  this.requestUpdate();
14998
16310
  return true;
14999
16311
  }
16312
+ /** Move the cursor as keyboard / search navigation does — recorded as `focus` in the view state. */
16313
+ #setKeyboardCursor(key) {
16314
+ this.tickled.setTickled(key);
16315
+ this.#viewState.keyboardCursorMoved();
16316
+ }
16317
+ #onTilePointerEnter(key) {
16318
+ const before = this.tickled.tickledId;
16319
+ this.tickled.handlePointerEnter(key);
16320
+ if (this.tickled.tickledId !== before) this.#viewState.pointerCursorMoved();
16321
+ }
15000
16322
  #resolveKeyPath(keys) {
15001
16323
  let current = this.#effectiveRoot;
15002
16324
  for (const key of keys) {
@@ -15026,6 +16348,25 @@ let TreemapListElement = class TreemapListElement extends LitElement {
15026
16348
  depth: r.depth
15027
16349
  }));
15028
16350
  }
16351
+ /**
16352
+ * Snapshot selection, zoom path and keyboard cursor as plain JSON, keyed by
16353
+ * `item.key`. Includes pending state for keys not currently loaded.
16354
+ */
16355
+ getViewState(options) {
16356
+ return this.#viewState.get(options);
16357
+ }
16358
+ /**
16359
+ * Apply saved view state. Fields present replace the current ones; keys
16360
+ * that aren't loaded yet are applied as they appear — a deep `zoom` loads
16361
+ * its ancestors via `loadChildren` / `model` when `restoreLoads`.
16362
+ */
16363
+ setViewState(state) {
16364
+ this.#viewState.set(state);
16365
+ this.requestUpdate();
16366
+ }
16367
+ clearPendingViewState() {
16368
+ this.#viewState.clearPending();
16369
+ }
15029
16370
  #matchesFilter(node, depth) {
15030
16371
  if (!this.filterPredicate) return true;
15031
16372
  if (this.filterPredicate(node, depth)) return true;
@@ -15127,7 +16468,7 @@ let TreemapListElement = class TreemapListElement extends LitElement {
15127
16468
  const moveTo = (rect) => {
15128
16469
  if (!rect) return;
15129
16470
  event.preventDefault();
15130
- this.tickled.setTickled(rect.node.item.key);
16471
+ this.#setKeyboardCursor(rect.node.item.key);
15131
16472
  if (this.interactionMode === `implicit`) this.select(rect.node);
15132
16473
  };
15133
16474
  switch (event.key) {
@@ -15308,7 +16649,7 @@ let TreemapListElement = class TreemapListElement extends LitElement {
15308
16649
  e.stopPropagation();
15309
16650
  this.#handleItemDblClick(rect);
15310
16651
  }}
15311
- @pointerenter=${() => this.tickled.handlePointerEnter(node.item.key)}
16652
+ @pointerenter=${() => this.#onTilePointerEnter(node.item.key)}
15312
16653
  >
15313
16654
  ${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>`}
15314
16655
  ${childrenContent}
@@ -15622,6 +16963,7 @@ __decorate([property({
15622
16963
  reflect: true
15623
16964
  })], TreemapListElement.prototype, "searchModes", void 0);
15624
16965
  __decorate([state()], TreemapListElement.prototype, "_zoomKey", void 0);
16966
+ __decorate([property({ attribute: false })], TreemapListElement.prototype, "restoreLoads", void 0);
15625
16967
  TreemapListElement = __decorate([safeCustomElement(`ixfx-treemap-list`)], TreemapListElement);
15626
16968
  //#endregion
15627
16969
  //#region src/twosplit-layout/split-layout.ts
@@ -15648,6 +16990,10 @@ let TwoSplitLayoutElement = class TwoSplitLayoutElement extends LitElement {
15648
16990
  this.#nestedSplitInitialSizeA = ``;
15649
16991
  this.#pendingNestedUpdate = null;
15650
16992
  this.#hysteresisThreshold = .1;
16993
+ this.#viewState = new ViewStateController(this, {
16994
+ kind: `twosplit-layout`,
16995
+ capture: () => this.getViewState()
16996
+ });
15651
16997
  this.#onResizeHandleDown = (event) => {
15652
16998
  event.preventDefault();
15653
16999
  const zoneEl = this.getZone();
@@ -15664,6 +17010,7 @@ let TwoSplitLayoutElement = class TwoSplitLayoutElement extends LitElement {
15664
17010
  if (zoneEl) zoneEl.classList.remove(`user-resizing`);
15665
17011
  window.removeEventListener(`pointerup`, this.#onResizeHandleUp);
15666
17012
  window.removeEventListener(`pointermove`, this.#onResizeHandleMove);
17013
+ if (this.sizeA !== this.#startSizeA || this.sizeB !== this.#startSizeB) this.#viewState.notify();
15667
17014
  };
15668
17015
  this.#onResizeHandleMove = (event) => {
15669
17016
  const bounds = this.#elementBounds;
@@ -15783,9 +17130,11 @@ let TwoSplitLayoutElement = class TwoSplitLayoutElement extends LitElement {
15783
17130
  if (nestedSplit) {
15784
17131
  const nestedZoneEl = nestedSplit.getZone();
15785
17132
  if (nestedZoneEl) nestedZoneEl.classList.remove(`user-resizing`);
17133
+ if (#viewState in nestedSplit && nestedSplit.sizeA !== this.#nestedSplitInitialSizeA) nestedSplit.#viewState.notify();
15786
17134
  }
15787
17135
  window.removeEventListener(`pointerup`, this.#onCornerResizeUp);
15788
17136
  window.removeEventListener(`pointermove`, this.#onCornerResizeMove);
17137
+ if (this.sizeA !== this.#startSizeA || this.sizeB !== this.#startSizeB) this.#viewState.notify();
15789
17138
  };
15790
17139
  this.#onCornerResizeMove = (event) => {
15791
17140
  const outerBounds = this.#elementBounds;
@@ -15853,6 +17202,7 @@ let TwoSplitLayoutElement = class TwoSplitLayoutElement extends LitElement {
15853
17202
  #collapsedAPointerAtCollapse;
15854
17203
  #collapsedBPointerAtCollapse;
15855
17204
  #hysteresisThreshold;
17205
+ #viewState;
15856
17206
  disconnectedCallback() {
15857
17207
  super.disconnectedCallback();
15858
17208
  this.#observer?.disconnect();
@@ -15862,7 +17212,7 @@ let TwoSplitLayoutElement = class TwoSplitLayoutElement extends LitElement {
15862
17212
  }
15863
17213
  updated(changedProperties) {
15864
17214
  if (changedProperties.has(`sizeA`) || changedProperties.has(`sizeB`) || changedProperties.has(`collapsedA`) || changedProperties.has(`collapsedB`)) this.#updateCornerPosition();
15865
- if (changedProperties.has(`sizeA`) || changedProperties.has(`sizeB`)) this.#updateDataCollapsed();
17215
+ if (changedProperties.has(`sizeA`) || changedProperties.has(`sizeB`) || changedProperties.has(`collapsedA`) || changedProperties.has(`collapsedB`)) this.#updateDataCollapsed();
15866
17216
  }
15867
17217
  #updateDataCollapsed() {
15868
17218
  if (this.collapsedA) this.setAttribute(`data-collapsed-a`, ``);
@@ -16021,6 +17371,31 @@ let TwoSplitLayoutElement = class TwoSplitLayoutElement extends LitElement {
16021
17371
  else if (this.layout === `horizontal` && nestedSplit.layout === `vertical`) cornerZone.style.cursor = `nesw-resize`;
16022
17372
  }
16023
17373
  }
17374
+ /** Snapshot both sides' size and collapse. */
17375
+ getViewState(options) {
17376
+ return this.#viewState.shape({ layout: {
17377
+ a: sideState(this.sizeA, this.collapsedA),
17378
+ b: sideState(this.sizeB, this.collapsedB)
17379
+ } }, options);
17380
+ }
17381
+ /** Restore sizes / collapse. A side missing from `layout` is left as it is. */
17382
+ setViewState(state) {
17383
+ if (!this.#viewState.accepts(state) || !state.layout) return;
17384
+ const { a, b } = state.layout;
17385
+ this.#viewState.restoring(() => {
17386
+ if (typeof a?.size === `string`) {
17387
+ this.sizeA = a.size;
17388
+ this.collapsedA = a.collapsed === true;
17389
+ }
17390
+ if (typeof b?.size === `string`) {
17391
+ this.sizeB = b.size;
17392
+ this.collapsedB = b.collapsed === true;
17393
+ }
17394
+ this.#viewState.notify();
17395
+ });
17396
+ }
17397
+ /** Both slots always exist, so nothing is ever pending. */
17398
+ clearPendingViewState() {}
16024
17399
  getZone() {
16025
17400
  if (!this.shadowRoot) return;
16026
17401
  const el = this.shadowRoot.querySelector(`#zone`);
@@ -16208,6 +17583,12 @@ __decorate([property({ type: String })], TwoSplitLayoutElement.prototype, "minSi
16208
17583
  __decorate([property({ type: String })], TwoSplitLayoutElement.prototype, "maxSizeB", void 0);
16209
17584
  __decorate([state()], TwoSplitLayoutElement.prototype, "_hasCorner", void 0);
16210
17585
  TwoSplitLayoutElement = __decorate([safeCustomElement(`ixfx-twosplit-layout`)], TwoSplitLayoutElement);
17586
+ function sideState(size, collapsed) {
17587
+ return collapsed ? {
17588
+ size,
17589
+ collapsed
17590
+ } : { size };
17591
+ }
16211
17592
  //#endregion
16212
17593
  //#region src/util/checkbox.ts
16213
17594
  function isCheckboxLike(el) {
@@ -16266,4 +17647,4 @@ function init() {
16266
17647
  console.log(`Init`);
16267
17648
  }
16268
17649
  //#endregion
16269
- 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, SelectHorizElement, ShapefillValueElement, SliderInputElement, SlidingNumberElement, SnackbarElement, SnapContainer, SplitButton, SplitLayoutElement, SplitPanelElement, 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, computeTreemapLayout, createAcCompletionProvider, createDataListSearchAdapter, createDefaultListDragPreview, createElementIncrSearch, createElementListSearchAdapter, createEmptyLayout, createEmptyStack, createFuzzySearch, createIncrSearch, createPillRenderer, createSplit, createStack, createTreeListAdapter, createTreeListSearchAdapter, createVerticalListAdapter, decomposeViewIsometry, defaultGroupLabel, distribute, dropTargetFor, euclideanRadiusForHyperbolicDistance, extractStack, findEmptiedStacks, findHyperbolicIdOf, findHyperbolicKeyPath, findHyperbolicNode, findHyperbolicPath, findHyperbolicPathById, findIcicleNode, findIciclePath, findNode, findPanelNode, findParentOf, findStackOf, findStackPathByName, 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, 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, svgToDataUri, swapLeaves, tickledItemStyles, tickledStyles, timelineStyles, toGroupKey, toSnapshot, toggleInSet, toolbarGapIndex, translationFromOrigin, viewIsometry, walk, wrapRoot };
17650
+ 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 };