@ixfx/components 0.13.1 → 0.13.3

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 (78) hide show
  1. package/bundle/index.d.ts +100 -14
  2. package/bundle/index.d.ts.map +1 -1
  3. package/bundle/index.js +2211 -636
  4. package/bundle/index.js.map +1 -1
  5. package/dist/ac-token.js +1 -1
  6. package/dist/{data-grid-Ck1Qx2H1.d.ts → data-grid-DHWJN66w.d.ts} +7 -5
  7. package/dist/{data-grid-Ck1Qx2H1.d.ts.map → data-grid-DHWJN66w.d.ts.map} +1 -1
  8. package/dist/data-grid.d.ts +1 -1
  9. package/dist/data-grid.js +210 -120
  10. package/dist/data-grid.js.map +1 -1
  11. package/dist/detail-list-C96B5Txz.js +2001 -0
  12. package/dist/detail-list-C96B5Txz.js.map +1 -0
  13. package/dist/{detail-list-C1xzr1z7.d.ts → detail-list-dX7jLO6b.d.ts} +12 -3
  14. package/dist/{detail-list-C1xzr1z7.d.ts.map → detail-list-dX7jLO6b.d.ts.map} +1 -1
  15. package/dist/detail-list.d.ts +2 -2
  16. package/dist/detail-list.js +1 -1680
  17. package/dist/{flat-list-DEDG6epz.d.ts → flat-list-Y98IqH00.d.ts} +5 -2
  18. package/dist/flat-list-Y98IqH00.d.ts.map +1 -0
  19. package/dist/flat-list.d.ts +1 -1
  20. package/dist/flat-list.js +33 -1
  21. package/dist/flat-list.js.map +1 -1
  22. package/dist/{grid-list-pefLORIr.d.ts → grid-list-CoT9e1jB.d.ts} +9 -2
  23. package/dist/{grid-list-pefLORIr.d.ts.map → grid-list-CoT9e1jB.d.ts.map} +1 -1
  24. package/dist/{grid-list-DjIl910A.js → grid-list-DeqAd9-h.js} +189 -40
  25. package/dist/grid-list-DeqAd9-h.js.map +1 -0
  26. package/dist/grid-list.d.ts +1 -1
  27. package/dist/grid-list.js +1 -1
  28. package/dist/{incr-search-D91M4Ozq.js → incr-search-JIhwB49y.js} +27 -5
  29. package/dist/incr-search-JIhwB49y.js.map +1 -0
  30. package/dist/incr-search.d.ts +1 -1
  31. package/dist/incr-search.js +1 -1
  32. package/dist/index-CnQPCywE.d.ts +325 -0
  33. package/dist/index-CnQPCywE.d.ts.map +1 -0
  34. package/dist/{index-CxZ61DO1.d.ts → index-Dlah7Zfi.d.ts} +8 -1
  35. package/dist/{index-CxZ61DO1.d.ts.map → index-Dlah7Zfi.d.ts.map} +1 -1
  36. package/dist/index.d.ts +9 -9
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +46 -25
  39. package/dist/index.js.map +1 -1
  40. package/dist/miller.d.ts +1 -1
  41. package/dist/miller.d.ts.map +1 -1
  42. package/dist/miller.js +146 -100
  43. package/dist/miller.js.map +1 -1
  44. package/dist/narrowed-text.js +1 -1
  45. package/dist/split-layout-C5SYbDrq.d.ts.map +1 -1
  46. package/dist/{split-layout-CXotkdbx.js → split-layout-Deoxy47d.js} +6 -1
  47. package/dist/{split-layout-CXotkdbx.js.map → split-layout-Deoxy47d.js.map} +1 -1
  48. package/dist/split-layout.js +1 -1
  49. package/dist/{tab-list-v8mKX7Fu.d.ts → tab-list-D_dmImJM.d.ts} +2 -3
  50. package/dist/tab-list-D_dmImJM.d.ts.map +1 -0
  51. package/dist/tabs.d.ts +1 -1
  52. package/dist/tabs.js +22 -0
  53. package/dist/tabs.js.map +1 -1
  54. package/dist/{tree-DGveys4K.js → tree-DD8OaqsK.js} +1010 -230
  55. package/dist/tree-DD8OaqsK.js.map +1 -0
  56. package/dist/tree.d.ts +4 -267
  57. package/dist/tree.js +1 -1
  58. package/dist/{vertical-list-CqBc-t8w.d.ts → vertical-list-lj_19DCx.d.ts} +3 -2
  59. package/dist/{vertical-list-CqBc-t8w.d.ts.map → vertical-list-lj_19DCx.d.ts.map} +1 -1
  60. package/dist/vertical-list.d.ts +1 -1
  61. package/dist/vertical-list.js +113 -24
  62. package/dist/vertical-list.js.map +1 -1
  63. package/docs-user/data-grid.md +74 -4
  64. package/docs-user/detail-list.md +65 -0
  65. package/docs-user/flat-list.md +14 -2
  66. package/docs-user/grid-list.md +81 -4
  67. package/docs-user/index.json +2 -2
  68. package/docs-user/miller.md +59 -3
  69. package/docs-user/tree.md +63 -2
  70. package/docs-user/vertical-list.md +9 -1
  71. package/package.json +6 -1
  72. package/dist/detail-list.js.map +0 -1
  73. package/dist/flat-list-DEDG6epz.d.ts.map +0 -1
  74. package/dist/grid-list-DjIl910A.js.map +0 -1
  75. package/dist/incr-search-D91M4Ozq.js.map +0 -1
  76. package/dist/tab-list-v8mKX7Fu.d.ts.map +0 -1
  77. package/dist/tree-DGveys4K.js.map +0 -1
  78. package/dist/tree.d.ts.map +0 -1
package/bundle/index.js CHANGED
@@ -10,6 +10,7 @@ import { Colour } from "@ixfx/visual";
10
10
  import { Trees } from "@ixfx/collections";
11
11
  import { clamp, clamper, rangeStream, round, scaler } from "@ixfx/numbers";
12
12
  import { classMap } from "lit/directives/class-map.js";
13
+ import { ifDefined } from "lit/directives/if-defined.js";
13
14
  import { createRef, ref } from "lit/directives/ref.js";
14
15
  import { Easings } from "@ixfx/modulation";
15
16
  import "@clinth/vcanvas";
@@ -2073,7 +2074,11 @@ function createElementListSearchAdapter(host, options = {}) {
2073
2074
  const getLabel = options.getLabel ?? defaultLabel;
2074
2075
  const getKey = options.getKey ?? ((el) => keyForElement(el) ?? defaultLabel(el));
2075
2076
  const hidden = /* @__PURE__ */ new Set();
2076
- const items = () => Array.from(host.querySelectorAll(itemSelector));
2077
+ const observeContainer = options.observeContainer ?? true;
2078
+ const items = () => {
2079
+ if (options.getAllItems) return options.getAllItems();
2080
+ return Array.from(host.querySelectorAll(itemSelector));
2081
+ };
2077
2082
  return {
2078
2083
  getItems: items,
2079
2084
  getLabel,
@@ -2082,7 +2087,9 @@ function createElementListSearchAdapter(host, options = {}) {
2082
2087
  if (el) host.focusKey(getKey(el));
2083
2088
  },
2084
2089
  activateItem: (el) => {
2085
- el.dispatchEvent(new MouseEvent(`click`, {
2090
+ const key = getKey(el);
2091
+ if (host.activateKey) host.activateKey(key);
2092
+ else el.dispatchEvent(new MouseEvent(`click`, {
2086
2093
  bubbles: true,
2087
2094
  composed: true
2088
2095
  }));
@@ -2108,7 +2115,7 @@ function createElementListSearchAdapter(host, options = {}) {
2108
2115
  highlightKeyOf: (el) => getKey(el),
2109
2116
  highlightText: getLabel,
2110
2117
  highlightKey: options.highlightKey,
2111
- container: () => host
2118
+ container: observeContainer ? () => host : void 0
2112
2119
  };
2113
2120
  }
2114
2121
  //#endregion
@@ -2426,6 +2433,10 @@ var ListSearchController = class {
2426
2433
  }
2427
2434
  this.#search.setItems(items);
2428
2435
  }
2436
+ /** Re-paint highlights without rebuilding the item set. Use when the rendered window changes. */
2437
+ repaintHighlights() {
2438
+ this.#applyHighlights();
2439
+ }
2429
2440
  /** Popover markup. Pair with `listSearchStyles` in the host's `static styles`. */
2430
2441
  renderField() {
2431
2442
  if (!this.enabled) return nothing;
@@ -2547,9 +2558,10 @@ var ListSearchController = class {
2547
2558
  return;
2548
2559
  }
2549
2560
  const state = this.#search.state;
2561
+ const itemByKey = new Map(state.results.map((r) => [r.item.key, r.item]));
2550
2562
  const targets = buildHighlightTargets(root, selector, state.positionsByItem, (el) => {
2551
2563
  const key = keyOf(el);
2552
- return key === void 0 ? void 0 : state.results.find((r) => r.item.key === key)?.item;
2564
+ return key === void 0 ? void 0 : itemByKey.get(key);
2553
2565
  }, this.#adapter.highlightText ?? ((el) => el.textContent ?? ``));
2554
2566
  try {
2555
2567
  applyHighlights(targets, { key: this.#highlightKey });
@@ -2673,6 +2685,16 @@ const listSearchStyles = css`
2673
2685
  //#endregion
2674
2686
  //#region src/incr-search/tree-list-search-adapter.ts
2675
2687
  /**
2688
+ * The label's own text. Highlights are painted on one text node matched by its
2689
+ * exact content, but a Lit-rendered label also holds template whitespace and
2690
+ * sibling bindings (tooltips), so `textContent` is padded and never equals the
2691
+ * node's content. Take the first non-blank text node instead.
2692
+ */
2693
+ function labelText(el) {
2694
+ for (const node of el.childNodes) if (node.nodeType === Node.TEXT_NODE && (node.textContent ?? ``).trim().length > 0) return node.textContent ?? ``;
2695
+ return el.textContent ?? ``;
2696
+ }
2697
+ /**
2676
2698
  * A {@link ListSearchAdapter} over any `TreeSearchHost` (`ixfx-tree-list`,
2677
2699
  * `ixfx-miller-list`, `ixfx-crumb-navigation`). Navigate reveals the match
2678
2700
  * (expands its ancestors, moves the cursor); select drives `selectMany`; filter
@@ -2700,6 +2722,7 @@ function createTreeListSearchAdapter(host, options = {}) {
2700
2722
  },
2701
2723
  highlightRoot: () => host.shadowRoot,
2702
2724
  highlightSelector: options.labelSelector ?? `.tree-label`,
2725
+ highlightText: labelText,
2703
2726
  highlightKeyOf: (el) => el.closest(`[data-key]`)?.dataset.key,
2704
2727
  highlightKey: options.highlightKey
2705
2728
  };
@@ -15220,6 +15243,10 @@ function sameKeys$4(a, b) {
15220
15243
  for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
15221
15244
  return true;
15222
15245
  }
15246
+ /** Element-wise reference equality, treating `undefined` (never computed) as different. */
15247
+ function sameRefs(a, b) {
15248
+ return a !== void 0 && a.length === b.length && a.every((v, i) => v === b[i]);
15249
+ }
15223
15250
  let DataGridElement = class DataGridElement extends LitElement {
15224
15251
  constructor(..._args) {
15225
15252
  super(..._args);
@@ -15237,7 +15264,7 @@ let DataGridElement = class DataGridElement extends LitElement {
15237
15264
  this.groupBy = null;
15238
15265
  this.allowGrouping = false;
15239
15266
  this.crossGroupSelection = false;
15240
- this.columnMenu = `auto`;
15267
+ this.columnMenu = `none`;
15241
15268
  this.textColorColumn = ``;
15242
15269
  this.iconColumn = ``;
15243
15270
  this.badgeColumn = ``;
@@ -15258,9 +15285,9 @@ let DataGridElement = class DataGridElement extends LitElement {
15258
15285
  this._lastClickedCell = null;
15259
15286
  this._editValue = ``;
15260
15287
  this._tickled = new TickledController(this, {
15261
- getItems: () => this._flatKeys.map((k) => ({
15262
- id: k,
15263
- enabled: true
15288
+ getItems: () => this.#tickledItems ??= this._flat.map((f) => ({
15289
+ id: f.key,
15290
+ enabled: f.type === `row`
15264
15291
  })),
15265
15292
  onStateChange: () => this.requestUpdate(),
15266
15293
  hoverDisabled: () => this.#nativeDrag.isDragging
@@ -15307,6 +15334,9 @@ let DataGridElement = class DataGridElement extends LitElement {
15307
15334
  this._sortedRows = [];
15308
15335
  this._flat = [];
15309
15336
  this._flatKeys = [];
15337
+ this.#flatIndexByKey = /* @__PURE__ */ new Map();
15338
+ this.#flatVersion = 0;
15339
+ this.#notedFlatVersion = -1;
15310
15340
  this.#searchKeyByRow = /* @__PURE__ */ new Map();
15311
15341
  this.search = new ListSearchController(this, { adapter: this.#createSearchAdapter() });
15312
15342
  this.#prevFlatKeys = [];
@@ -15318,7 +15348,6 @@ let DataGridElement = class DataGridElement extends LitElement {
15318
15348
  this._colWidthSum = 0;
15319
15349
  this._raf = 0;
15320
15350
  this.#viewportChangeRaf = 0;
15321
- this.#appendInProgress = false;
15322
15351
  this.#resortRaf = 0;
15323
15352
  this._drag = null;
15324
15353
  this._onFocusOut = (e) => {
@@ -15335,10 +15364,12 @@ let DataGridElement = class DataGridElement extends LitElement {
15335
15364
  const el = e.target;
15336
15365
  cancelAnimationFrame(this._raf);
15337
15366
  this._raf = requestAnimationFrame(() => {
15367
+ const before = this.#windowRows(2);
15338
15368
  this._scrollTop = el.scrollTop;
15339
15369
  this._scrollLeft = el.scrollLeft;
15340
15370
  if (this._headerScrollEl) this._headerScrollEl.scrollLeft = el.scrollLeft;
15341
- this.requestUpdate();
15371
+ const after = this.#windowRows(2);
15372
+ if (before.start !== after.start || before.end !== after.end) this.requestUpdate();
15342
15373
  cancelAnimationFrame(this.#viewportChangeRaf);
15343
15374
  this.#viewportChangeRaf = 0;
15344
15375
  this.#emitViewportChangeIfChanged();
@@ -15377,7 +15408,7 @@ let DataGridElement = class DataGridElement extends LitElement {
15377
15408
  let nextIdx = idx + dir;
15378
15409
  while (nextIdx >= 0 && nextIdx < rowKeys.length) {
15379
15410
  const nk = rowKeys[nextIdx];
15380
- const row = this._sortedRows.find((r, i) => this.getRowKey(r, i) === nk);
15411
+ const row = this.#rowEntry(nk)?.row;
15381
15412
  const col = this._visibleColumns.find((c) => c.key === colKey);
15382
15413
  if (row && col && this._isEditable(row, col)) {
15383
15414
  this._startEdit(nk, colKey);
@@ -15426,9 +15457,9 @@ let DataGridElement = class DataGridElement extends LitElement {
15426
15457
  if (!key) break;
15427
15458
  e.preventDefault();
15428
15459
  if (!this._editing && this.editability === `user`) {
15429
- const idx = this._sortedRows.findIndex((r, i) => this.getRowKey(r, i) === key);
15430
- if (idx >= 0) {
15431
- const row = this._sortedRows[idx];
15460
+ const entry = this.#rowEntry(key);
15461
+ if (entry) {
15462
+ const row = entry.row;
15432
15463
  if (this._lastClickedCell?.rowKey === key) {
15433
15464
  const col = this._visibleColumns.find((c) => c.key === this._lastClickedCell.colKey);
15434
15465
  if (col && this._isEditable(row, col)) {
@@ -15444,9 +15475,10 @@ let DataGridElement = class DataGridElement extends LitElement {
15444
15475
  }
15445
15476
  }
15446
15477
  if (!this._editing) {
15447
- const idx = this._sortedRows.findIndex((r, i) => this.getRowKey(r, i) === key);
15478
+ const entry = this.#rowEntry(key);
15479
+ const idx = entry?.idx ?? -1;
15448
15480
  if (this._selectedKeys.has(key)) {
15449
- this._activateRow(key, idx >= 0 ? this._sortedRows[idx] : void 0, idx);
15481
+ this._activateRow(key, entry?.row, idx);
15450
15482
  break;
15451
15483
  }
15452
15484
  if (this.interactionMode !== `manual` && this.interactionMode !== `checked`) this._replaceSelection(key);
@@ -15476,7 +15508,7 @@ let DataGridElement = class DataGridElement extends LitElement {
15476
15508
  next = !active.checked;
15477
15509
  active.checked = next;
15478
15510
  } else {
15479
- const row = this._sortedRows.find((r, i) => this.getRowKey(r, i) === rowKey);
15511
+ const row = this.#rowEntry(rowKey)?.row;
15480
15512
  next = !(this._editValue !== `` ? Boolean(this._editValue) : Boolean(row?.[colKey]));
15481
15513
  }
15482
15514
  if (isImmediate) this._commitImmediate(rowKey, colKey, next);
@@ -15745,6 +15777,15 @@ let DataGridElement = class DataGridElement extends LitElement {
15745
15777
  this.#postDragSuppressTimer = 0;
15746
15778
  }, 0);
15747
15779
  }
15780
+ #flatIndexByKey;
15781
+ /** Bumped whenever `_flat` changes; drives `#noteFlatMutation`. */
15782
+ #flatVersion;
15783
+ #notedFlatVersion;
15784
+ /** Inputs `_sortedRows` / `_flat` were last derived from — see `#syncDerivedRows`. */
15785
+ #sortInputs;
15786
+ #flatInputs;
15787
+ /** `_flat` projected for the tickled controller, cached until `_flat` changes. */
15788
+ #tickledItems;
15748
15789
  #searchRegistry;
15749
15790
  #unregisterSearchCommands;
15750
15791
  #searchKeyByRow;
@@ -15803,7 +15844,6 @@ let DataGridElement = class DataGridElement extends LitElement {
15803
15844
  /** Key sets as of the last `datagrid-viewport-change` dispatch, for dedupe. */
15804
15845
  #lastViewportRenderedKeys;
15805
15846
  #lastViewportVisibleKeys;
15806
- #appendInProgress;
15807
15847
  #resortRaf;
15808
15848
  connectedCallback() {
15809
15849
  super.connectedCallback();
@@ -15876,7 +15916,7 @@ let DataGridElement = class DataGridElement extends LitElement {
15876
15916
  updated(changed) {
15877
15917
  super.updated(changed);
15878
15918
  if (changed.has(`rows`) || changed.has(`sortState`) || changed.has(`columns`) || changed.has(`groupBy`) || changed.has(`groupLabel`) || changed.has(`filterPredicate`) || changed.has(`rowHeight`)) this.#scheduleViewportChangeEmit();
15879
- if (changed.has(`_editing`) || changed.has(`_editingRow`)) requestAnimationFrame(() => {
15919
+ if (changed.has(`_editing`) || changed.has(`_editingRow`)) this.updateComplete.then(() => {
15880
15920
  let sel = null;
15881
15921
  if (this._editing) sel = `.row[data-key="${this._editing.rowKey}"] .cell[data-col="${this._editing.colKey}"] input, .row[data-key="${this._editing.rowKey}"] .cell[data-col="${this._editing.colKey}"] select`;
15882
15922
  else if (this._editingRow) sel = `.row[data-key="${this._editingRow}"] .cell input, .row[data-key="${this._editingRow}"] .cell select`;
@@ -15890,10 +15930,7 @@ let DataGridElement = class DataGridElement extends LitElement {
15890
15930
  super.willUpdate(changed);
15891
15931
  this.setAttribute(`data-input-mode`, this._tickled.inputMode);
15892
15932
  if (changed.has(`searchModes`)) this.#syncSearchCommands();
15893
- if (!this.#appendInProgress) {
15894
- if (changed.has(`rows`) || changed.has(`sortState`) || changed.has(`columns`) || changed.has(`groupBy`) || changed.has(`groupLabel`)) this._sortedRows = this._applySort([...this.rows]);
15895
- this._rebuildFlat();
15896
- }
15933
+ this.#syncDerivedRows();
15897
15934
  this._visibleColumns = this.columns.filter((c) => c.visible !== false);
15898
15935
  let off = 0;
15899
15936
  this._colOffsets = this._visibleColumns.map((c) => {
@@ -15903,7 +15940,53 @@ let DataGridElement = class DataGridElement extends LitElement {
15903
15940
  });
15904
15941
  this._colWidthSum = off;
15905
15942
  if (this.interactionMode === `checked`) this._colWidthSum += 28;
15906
- this.#noteFlatMutation();
15943
+ if (this.#flatVersion !== this.#notedFlatVersion) {
15944
+ this.#notedFlatVersion = this.#flatVersion;
15945
+ this.#noteFlatMutation();
15946
+ }
15947
+ }
15948
+ /**
15949
+ * Re-derive `_sortedRows` / `_flat` only when one of their inputs changed
15950
+ * identity. Scroll, hover, selection and edit-state updates touch none of
15951
+ * them, so those frames cost O(window) instead of O(rows).
15952
+ */
15953
+ #syncDerivedRows() {
15954
+ const sortInputs = [
15955
+ this.rows,
15956
+ this.sortState,
15957
+ this.columns
15958
+ ];
15959
+ const flatInputs = [
15960
+ this.filterPredicate,
15961
+ this.groupBy,
15962
+ this.groupLabel,
15963
+ this._collapsedGroups,
15964
+ this.getRowKey
15965
+ ];
15966
+ const resort = !sameRefs(this.#sortInputs, sortInputs);
15967
+ if (resort) {
15968
+ this.#sortInputs = sortInputs;
15969
+ this._sortedRows = this._applySort([...this.rows]);
15970
+ }
15971
+ if (resort || !sameRefs(this.#flatInputs, flatInputs)) {
15972
+ this.#flatInputs = flatInputs;
15973
+ this._rebuildFlat();
15974
+ }
15975
+ }
15976
+ /** Mark the derived rows as matching the current inputs (after an incremental update). */
15977
+ #markDerivedRowsSynced() {
15978
+ this.#sortInputs = [
15979
+ this.rows,
15980
+ this.sortState,
15981
+ this.columns
15982
+ ];
15983
+ this.#flatInputs = [
15984
+ this.filterPredicate,
15985
+ this.groupBy,
15986
+ this.groupLabel,
15987
+ this._collapsedGroups,
15988
+ this.getRowKey
15989
+ ];
15907
15990
  }
15908
15991
  /**
15909
15992
  * Diff the row-key list against the previous update. Rows that appeared get an
@@ -15924,10 +16007,9 @@ let DataGridElement = class DataGridElement extends LitElement {
15924
16007
  return;
15925
16008
  }
15926
16009
  const before = new Set(prev);
15927
- const after = new Set(now);
15928
16010
  const added = now.filter((k) => !before.has(k));
15929
- const removed = prev.filter((k) => !after.has(k));
15930
- if (added.length === now.length || added.length === 0 && removed.length === 0) return;
16011
+ const removedCount = prev.length - (now.length - added.length);
16012
+ if (added.length === now.length || added.length === 0 && removedCount === 0) return;
15931
16013
  this.#enteringRowKeys = new Set(added);
15932
16014
  this.toggleAttribute(`reflowing`, true);
15933
16015
  if (this.#rowReflowTimer) clearTimeout(this.#rowReflowTimer);
@@ -15944,55 +16026,64 @@ let DataGridElement = class DataGridElement extends LitElement {
15944
16026
  return String(this.groupBy(row));
15945
16027
  }
15946
16028
  _rebuildFlat() {
15947
- const filtered = this.filterPredicate !== void 0;
15948
- const source = filtered ? this._sortedRows.filter((r) => this.filterPredicate(r)) : this._sortedRows;
15949
- if (!this.groupBy) {
15950
- this._flat = source.map((r, i) => {
15951
- const idx = filtered ? this._sortedRows.indexOf(r) : i;
15952
- return {
15953
- type: `row`,
15954
- key: this.getRowKey(r, idx),
15955
- row: r,
15956
- idx
15957
- };
15958
- });
15959
- this._flatKeys = this._flat.map((f) => f.key);
15960
- return;
15961
- }
15962
- const groups = /* @__PURE__ */ new Map();
15963
- for (const r of source) {
15964
- const k = this._getGroupKey(r) ?? ``;
15965
- const arr = groups.get(k);
15966
- if (arr) arr.push(r);
15967
- else groups.set(k, [r]);
15968
- }
16029
+ const pred = this.filterPredicate;
16030
+ const sourceIdx = [];
16031
+ for (let i = 0; i < this._sortedRows.length; i++) if (!pred || pred(this._sortedRows[i])) sourceIdx.push(i);
15969
16032
  const flat = [];
15970
16033
  const keys = [];
15971
- for (const [gKey, rows] of groups) {
15972
- const label = this.groupLabel ? this.groupLabel(gKey, rows) : gKey;
15973
- const gId = `__group__${gKey}`;
16034
+ const pushRow = (idx) => {
16035
+ const row = this._sortedRows[idx];
16036
+ const key = this.getRowKey(row, idx);
15974
16037
  flat.push({
15975
- type: `group`,
15976
- key: gId,
15977
- label,
15978
- count: rows.length
16038
+ type: `row`,
16039
+ key,
16040
+ row,
16041
+ idx
15979
16042
  });
15980
- keys.push(gId);
15981
- if (this._collapsedGroups.has(gKey)) continue;
15982
- for (const r of rows) {
15983
- const idx = this._sortedRows.indexOf(r);
15984
- const rk = this.getRowKey(r, idx);
16043
+ keys.push(key);
16044
+ };
16045
+ if (!this.groupBy) sourceIdx.forEach(pushRow);
16046
+ else {
16047
+ const groups = /* @__PURE__ */ new Map();
16048
+ for (const idx of sourceIdx) {
16049
+ const k = this._getGroupKey(this._sortedRows[idx]) ?? ``;
16050
+ const arr = groups.get(k);
16051
+ if (arr) arr.push(idx);
16052
+ else groups.set(k, [idx]);
16053
+ }
16054
+ for (const [gKey, idxs] of groups) {
16055
+ const label = this.groupLabel ? this.groupLabel(gKey, idxs.map((i) => this._sortedRows[i])) : gKey;
16056
+ const gId = `__group__${gKey}`;
15985
16057
  flat.push({
15986
- type: `row`,
15987
- key: rk,
15988
- row: r,
15989
- idx
16058
+ type: `group`,
16059
+ key: gId,
16060
+ label,
16061
+ count: idxs.length
15990
16062
  });
15991
- keys.push(rk);
16063
+ keys.push(gId);
16064
+ if (!this._collapsedGroups.has(gKey)) idxs.forEach(pushRow);
15992
16065
  }
15993
16066
  }
15994
16067
  this._flat = flat;
15995
16068
  this._flatKeys = keys;
16069
+ this.#rebuildFlatIndex();
16070
+ }
16071
+ #rebuildFlatIndex() {
16072
+ this.#flatIndexByKey.clear();
16073
+ this._flat.forEach((f, i) => this.#flatIndexByKey.set(f.key, i));
16074
+ this.#flatVersion++;
16075
+ this.#tickledItems = void 0;
16076
+ }
16077
+ /** Flat entry for a row key, or `undefined` for unknown keys and group headers. */
16078
+ #rowEntry(key) {
16079
+ const i = this.#flatIndexByKey.get(key);
16080
+ const entry = i === void 0 ? void 0 : this._flat[i];
16081
+ if (entry?.type === `row`) return entry;
16082
+ const idx = this._sortedRows.findIndex((r, j) => this.getRowKey(r, j) === key);
16083
+ return idx < 0 ? void 0 : {
16084
+ row: this._sortedRows[idx],
16085
+ idx
16086
+ };
15996
16087
  }
15997
16088
  _toggleGroup(gKey) {
15998
16089
  const next = new Set(this._collapsedGroups);
@@ -16222,17 +16313,26 @@ let DataGridElement = class DataGridElement extends LitElement {
16222
16313
  }));
16223
16314
  }
16224
16315
  _scrollRowIntoView(key) {
16225
- const idx = this._flat.findIndex((f) => f.key === key);
16226
- if (idx < 0 || !this._viewportEl) return;
16227
- const top = idx * this.rowHeight;
16228
- const bottom = top + this.rowHeight;
16316
+ const idx = this.#flatIndexByKey.get(key);
16317
+ if (idx === void 0 || !this._viewportEl) return;
16318
+ const rh = this.rowHeight;
16319
+ const top = (idx > 0 && this._flat[idx - 1].type === `group` ? idx - 1 : idx) * rh;
16320
+ const bottom = (idx + 1) * rh;
16229
16321
  const vt = this._viewportEl.scrollTop;
16230
- const vb = vt + this._viewportEl.clientHeight;
16231
- if (top < vt) this._viewportEl.scrollTop = top;
16232
- else if (bottom > vb) this._viewportEl.scrollTop = bottom - this._viewportEl.clientHeight;
16322
+ const vh = this._viewportEl.clientHeight;
16323
+ let next = vt;
16324
+ if (top < vt) next = top;
16325
+ else if (bottom > vt + vh) next = bottom - vh;
16326
+ if (next !== vt) {
16327
+ this._viewportEl.scrollTop = next;
16328
+ this._scrollTop = next;
16329
+ this.requestUpdate();
16330
+ }
16233
16331
  }
16234
16332
  _getRowForKey(key) {
16235
- return this._flat.find((f) => f.key === key)?.row;
16333
+ const i = this.#flatIndexByKey.get(key);
16334
+ const entry = i === void 0 ? void 0 : this._flat[i];
16335
+ return entry?.type === `row` ? entry.row : void 0;
16236
16336
  }
16237
16337
  /** The selected row keys (read-only). */
16238
16338
  get selectedKeys() {
@@ -16249,8 +16349,7 @@ let DataGridElement = class DataGridElement extends LitElement {
16249
16349
  * set when the selection actually changes.
16250
16350
  */
16251
16351
  selectKeys(keys) {
16252
- const known = new Set(this._flat.filter((f) => f.type === `row`).map((f) => f.key));
16253
- let next = new Set([...keys].filter((k) => known.has(k)));
16352
+ let next = new Set([...keys].filter((k) => this._getRowForKey(k) !== void 0));
16254
16353
  const multi = this.selectionMode === `multiple` || this.interactionMode === `checked` || this.interactionMode === `sticky` || this.interactionMode === `vscode`;
16255
16354
  if (this.selectionMode === `none`) next = /* @__PURE__ */ new Set();
16256
16355
  else if (!multi && next.size > 1) {
@@ -16276,7 +16375,7 @@ let DataGridElement = class DataGridElement extends LitElement {
16276
16375
  }
16277
16376
  /** Move the cursor to this row key and scroll it into view. */
16278
16377
  tickleRow(key) {
16279
- if (!this._flatKeys.includes(key)) return;
16378
+ if (!this.#flatIndexByKey.has(key) || key.startsWith(`__group__`)) return;
16280
16379
  this._tickled.focus(key);
16281
16380
  this._scrollRowIntoView(key);
16282
16381
  }
@@ -16290,25 +16389,21 @@ let DataGridElement = class DataGridElement extends LitElement {
16290
16389
  appendRows(rows) {
16291
16390
  if (rows.length === 0) return;
16292
16391
  const startIdx = this.rows.length;
16293
- this.#appendInProgress = true;
16392
+ const inSync = this._sortedRows.length === startIdx && sameRefs(this.#sortInputs, [
16393
+ this.rows,
16394
+ this.sortState,
16395
+ this.columns
16396
+ ]);
16294
16397
  this.rows = [...this.rows, ...rows];
16398
+ if (!inSync) return;
16295
16399
  const appended = this.rows.slice(startIdx);
16296
- if (this._sortedRows.length === startIdx) this._sortedRows = [...this._sortedRows, ...appended];
16297
- else {
16298
- this._sortedRows = this._applySort([...this.rows]);
16299
- this._rebuildFlat();
16300
- this.#appendInProgress = false;
16301
- this.requestUpdate();
16302
- return;
16303
- }
16400
+ this._sortedRows = [...this._sortedRows, ...appended];
16304
16401
  this.#appendToFlat(appended, startIdx);
16305
- this.#appendInProgress = false;
16306
- this.requestUpdate();
16402
+ this.#markDerivedRowsSynced();
16307
16403
  if (this.sortState != null) {
16308
16404
  cancelAnimationFrame(this.#resortRaf);
16309
16405
  this.#resortRaf = requestAnimationFrame(() => {
16310
- this._sortedRows = this._applySort([...this.rows]);
16311
- this._rebuildFlat();
16406
+ this.#sortInputs = void 0;
16312
16407
  this.requestUpdate();
16313
16408
  });
16314
16409
  }
@@ -16320,6 +16415,7 @@ let DataGridElement = class DataGridElement extends LitElement {
16320
16415
  }
16321
16416
  for (let j = 0; j < appended.length; j++) {
16322
16417
  const idx = startIdx + j;
16418
+ if (this.filterPredicate && !this.filterPredicate(appended[j])) continue;
16323
16419
  const key = this.getRowKey(appended[j], idx);
16324
16420
  this._flat.push({
16325
16421
  type: `row`,
@@ -16328,7 +16424,10 @@ let DataGridElement = class DataGridElement extends LitElement {
16328
16424
  idx
16329
16425
  });
16330
16426
  this._flatKeys.push(key);
16427
+ this.#flatIndexByKey.set(key, this._flat.length - 1);
16331
16428
  }
16429
+ this.#flatVersion++;
16430
+ this.#tickledItems = void 0;
16332
16431
  }
16333
16432
  _replaceSelection(key) {
16334
16433
  if (this.selectionMode === `none`) return;
@@ -16434,8 +16533,8 @@ let DataGridElement = class DataGridElement extends LitElement {
16434
16533
  return !e || e === `auto`;
16435
16534
  }
16436
16535
  _isCellEditing(rowKey, colKey) {
16437
- if (this.editability === `immediate` && this._isEditable(this._sortedRows.find((r, i) => this.getRowKey(r, i) === rowKey) ?? {}, this.columns.find((c) => c.key === colKey))) return true;
16438
- if (this._editingRow === rowKey) return this._isEditable(this._sortedRows.find((r, i) => this.getRowKey(r, i) === rowKey) ?? {}, this.columns.find((c) => c.key === colKey));
16536
+ if (this.editability === `immediate` && this._isEditable(this.#rowEntry(rowKey)?.row ?? {}, this.columns.find((c) => c.key === colKey))) return true;
16537
+ if (this._editingRow === rowKey) return this._isEditable(this.#rowEntry(rowKey)?.row ?? {}, this.columns.find((c) => c.key === colKey));
16439
16538
  return this._editing?.rowKey === rowKey && this._editing?.colKey === colKey;
16440
16539
  }
16441
16540
  _activateRow(rowKey, row, rowIndex) {
@@ -16451,7 +16550,7 @@ let DataGridElement = class DataGridElement extends LitElement {
16451
16550
  }
16452
16551
  _startEdit(rowKey, colKey) {
16453
16552
  if (this.editability === `disabled`) return;
16454
- const row = this._sortedRows.find((r, i) => this.getRowKey(r, i) === rowKey);
16553
+ const row = this.#rowEntry(rowKey)?.row;
16455
16554
  if (!row) return;
16456
16555
  const col = this.columns.find((c) => c.key === colKey);
16457
16556
  if (!col || !this._isEditable(row, col)) return;
@@ -16469,7 +16568,7 @@ let DataGridElement = class DataGridElement extends LitElement {
16469
16568
  /** Programmatic: put all editable cells of a row in edit mode. */
16470
16569
  startRowEdit(rowKey) {
16471
16570
  if (this.editability === `disabled`) return;
16472
- if (!this._sortedRows.find((r, i) => this.getRowKey(r, i) === rowKey)) return;
16571
+ if (!this.#rowEntry(rowKey)?.row) return;
16473
16572
  this._editing = null;
16474
16573
  this._editingRow = rowKey;
16475
16574
  this.requestUpdate();
@@ -16537,7 +16636,7 @@ let DataGridElement = class DataGridElement extends LitElement {
16537
16636
  const target = this._editing;
16538
16637
  if (!target) return;
16539
16638
  const { rowKey, colKey } = target;
16540
- const row = this._sortedRows.find((r, i) => this.getRowKey(r, i) === rowKey);
16639
+ const row = this.#rowEntry(rowKey)?.row;
16541
16640
  if (!row) {
16542
16641
  this._editing = null;
16543
16642
  return;
@@ -16562,7 +16661,7 @@ let DataGridElement = class DataGridElement extends LitElement {
16562
16661
  }));
16563
16662
  }
16564
16663
  _tabToNext(rowKey, curColKey, dir) {
16565
- const row = this._sortedRows.find((r, i) => this.getRowKey(r, i) === rowKey);
16664
+ const row = this.#rowEntry(rowKey)?.row;
16566
16665
  if (!row) return;
16567
16666
  const cols = this._visibleColumns;
16568
16667
  let next = cols.findIndex((c) => c.key === curColKey) + dir;
@@ -16583,7 +16682,7 @@ let DataGridElement = class DataGridElement extends LitElement {
16583
16682
  this.requestUpdate();
16584
16683
  }
16585
16684
  _commitImmediate(rowKey, colKey, value) {
16586
- const row = this._sortedRows.find((r, i) => this.getRowKey(r, i) === rowKey);
16685
+ const row = this.#rowEntry(rowKey)?.row;
16587
16686
  if (!row) return;
16588
16687
  const oldValue = row[colKey];
16589
16688
  if (oldValue === value) return;
@@ -16706,11 +16805,19 @@ let DataGridElement = class DataGridElement extends LitElement {
16706
16805
  window.addEventListener(`pointermove`, onMove);
16707
16806
  window.addEventListener(`pointerup`, onUp);
16708
16807
  }
16709
- getVisibleRange() {
16808
+ /** Flat-index window `[start, end)` intersecting the viewport, widened by `overscan` rows each side. */
16809
+ #windowRows(overscan) {
16710
16810
  const rh = this.rowHeight;
16711
16811
  return {
16712
- startRow: Math.max(0, Math.floor(this._scrollTop / rh) - 2),
16713
- endRow: Math.min(this._flat.length, Math.ceil((this._scrollTop + this._viewportH) / rh) + 2),
16812
+ start: Math.max(0, Math.floor(this._scrollTop / rh) - overscan),
16813
+ end: Math.min(this._flat.length, Math.ceil((this._scrollTop + this._viewportH) / rh) + overscan)
16814
+ };
16815
+ }
16816
+ getVisibleRange() {
16817
+ const { start: startRow, end: endRow } = this.#windowRows(2);
16818
+ return {
16819
+ startRow,
16820
+ endRow,
16714
16821
  startCol: this._findFirstVisibleCol(this._scrollLeft),
16715
16822
  endCol: this._findLastVisibleCol(this._scrollLeft + this._viewportW)
16716
16823
  };
@@ -16725,10 +16832,8 @@ let DataGridElement = class DataGridElement extends LitElement {
16725
16832
  * `ixfx-grid-list.visibleKeys()` for the `datagrid-viewport-change` detail.
16726
16833
  */
16727
16834
  #visibleKeys() {
16728
- const rh = this.rowHeight;
16729
- const startRow = Math.max(0, Math.floor(this._scrollTop / rh));
16730
- const endRow = Math.min(this._flat.length, Math.ceil((this._scrollTop + this._viewportH) / rh));
16731
- return this._flat.slice(startRow, endRow).filter((f) => f.type === `row`).map((f) => f.key);
16835
+ const { start, end } = this.#windowRows(0);
16836
+ return this._flat.slice(start, end).filter((f) => f.type === `row`).map((f) => f.key);
16732
16837
  }
16733
16838
  /** Coalesce viewport-change dispatches to at most one per animation frame. */
16734
16839
  #scheduleViewportChangeEmit() {
@@ -16777,17 +16882,28 @@ let DataGridElement = class DataGridElement extends LitElement {
16777
16882
  }
16778
16883
  render() {
16779
16884
  const rh = this.rowHeight;
16780
- const startRow = Math.max(0, Math.floor(this._scrollTop / rh) - 2);
16781
- const endRow = Math.min(this._flat.length, Math.ceil((this._scrollTop + this._viewportH) / rh) + 2);
16782
- const visibleFlat = this._flat.slice(startRow, endRow);
16885
+ const { start: startRow, end: endRow } = this.#windowRows(2);
16886
+ const visibleFlat = this._flat.slice(startRow, endRow).map((entry, i) => ({
16887
+ entry,
16888
+ absIdx: startRow + i
16889
+ }));
16890
+ const pinnedKey = this._editing?.rowKey ?? this._editingRow;
16891
+ const pinnedIdx = pinnedKey ? this.#flatIndexByKey.get(pinnedKey) : void 0;
16892
+ if (pinnedIdx !== void 0) {
16893
+ if (pinnedIdx >= startRow && pinnedIdx < endRow) visibleFlat.splice(pinnedIdx - startRow, 1);
16894
+ visibleFlat.unshift({
16895
+ entry: this._flat[pinnedIdx],
16896
+ absIdx: pinnedIdx
16897
+ });
16898
+ }
16783
16899
  const showCheckbox = this.interactionMode === `checked`;
16784
16900
  return html`
16785
- <div class="grid">
16901
+ <div class="grid" role="grid" aria-rowcount=${this._flat.length + 1} aria-colcount=${this._visibleColumns.length} aria-multiselectable=${this.selectionMode === `multiple` ? `true` : `false`}>
16786
16902
  <div class="header-bar">
16787
- <div class="header-scroll scroll-fade-x">
16788
- ${showCheckbox ? html`<div class="header-cell checkbox-col"></div>` : nothing}
16903
+ <div class="header-scroll scroll-fade-x" role="row" aria-rowindex="1">
16904
+ ${showCheckbox ? html`<div class="header-cell checkbox-col" role="columnheader"></div>` : nothing}
16789
16905
  ${repeat(this._visibleColumns, (c) => c.key, (col, idx) => html`
16790
- <div class="header-cell ${this._animatingColKeys.has(col.key) ? `width-animating` : ``}" style="width:${col.width ?? 150}px" ?drag-source=${this._drag?.active && this._drag.sourceCol.key === col.key}
16906
+ <div class="header-cell ${this._animatingColKeys.has(col.key) ? `width-animating` : ``}" role="columnheader" aria-sort=${this.sortState?.key === col.key ? this.sortState.direction === `asc` ? `ascending` : `descending` : `none`} style="width:${col.width ?? 150}px" ?drag-source=${this._drag?.active && this._drag.sourceCol.key === col.key}
16791
16907
  @click=${(e) => this._onHeaderClick(col, e)}
16792
16908
  @pointerenter=${() => this._tickled.handlePointerEnter(col.key)}>
16793
16909
  ${col.reorderable !== false ? html`<span class="reorder-handle" draggable="false"
@@ -16810,7 +16926,7 @@ let DataGridElement = class DataGridElement extends LitElement {
16810
16926
  return last >= 0 ? this._colOffsets[last] + (this._visibleColumns[last].width ?? 150) : 0;
16811
16927
  })()}px"></div>` : nothing}
16812
16928
  </div>
16813
- ${this.columnMenu === `none` ? nothing : html`
16929
+ ${this.columnMenu !== `auto` ? nothing : html`
16814
16930
  <ixfx-menu-trigger label="⚙" placement="bottom-end" @menu-command=${(e) => {
16815
16931
  const cmd = e.detail.command;
16816
16932
  const checked = e.detail.checked;
@@ -16854,12 +16970,11 @@ let DataGridElement = class DataGridElement extends LitElement {
16854
16970
  </div>
16855
16971
  <div class="viewport scroll-fade-y" @scroll=${this._onScroll}>
16856
16972
  <div class="spacer" style="height:${this._flat.length * rh}px; width:${this._colWidthSum}px">
16857
- ${repeat(visibleFlat, (f) => f.key, (entry, i) => {
16858
- const absIdx = startRow + i;
16973
+ ${repeat(visibleFlat, (f) => f.entry.key, ({ entry, absIdx }) => {
16859
16974
  if (entry.type === `group`) {
16860
16975
  const gKey = entry.key.replace(/^__group__/, ``);
16861
16976
  const collapsed = this._collapsedGroups.has(gKey);
16862
- return html`<div class="group-row ${collapsed ? `collapsed` : ``}" style="top:${absIdx * rh}px; height:${rh}px" @click=${() => this._toggleGroup(gKey)}><span class="caret"><ixfx-icon name="chevron-down" size="12"></ixfx-icon></span><span>${entry.label}</span><span class="count">(${entry.count})</span><span class="divider"></span></div>`;
16977
+ return html`<div class="group-row ${collapsed ? `collapsed` : ``}" role="row" aria-rowindex=${absIdx + 2} style="top:${absIdx * rh}px; height:${rh}px" @click=${() => this._toggleGroup(gKey)}><span class="caret"><ixfx-icon name="chevron-down" size="12"></ixfx-icon></span><span role="rowheader" aria-expanded=${collapsed ? `false` : `true`}>${entry.label}</span><span class="count">(${entry.count})</span><span class="divider"></span></div>`;
16863
16978
  }
16864
16979
  const row = entry.row;
16865
16980
  const absIdx2 = entry.idx;
@@ -16867,7 +16982,7 @@ let DataGridElement = class DataGridElement extends LitElement {
16867
16982
  const selected = this._selectedKeys.has(rowKey);
16868
16983
  const tickled = this._tickled.isTickled(rowKey);
16869
16984
  return html`
16870
- <div class="row item ${selected ? `selected` : ``} ${tickled ? `tickled` : ``} ${this.zebraStripes && absIdx2 % 2 === 1 ? `zebra-odd` : ``} ${this.#enteringRowKeys.has(rowKey) ? `entering` : ``} ${this.#nativeDragging && rowKey === this.#dragOriginKey ? `dragging-origin` : ``}" style="top:${absIdx * rh}px; height:${rh}px"
16985
+ <div class="row item ${selected ? `selected` : ``} ${tickled ? `tickled` : ``} ${this.zebraStripes && absIdx2 % 2 === 1 ? `zebra-odd` : ``} ${this.#enteringRowKeys.has(rowKey) ? `entering` : ``} ${this.#nativeDragging && rowKey === this.#dragOriginKey ? `dragging-origin` : ``}" role="row" aria-rowindex=${absIdx + 2} aria-selected=${selected ? `true` : `false`} style="top:${absIdx * rh}px; height:${rh}px"
16871
16986
  data-key=${rowKey}
16872
16987
  draggable=${this.#nativeDrag.draggableAttr}
16873
16988
  @click=${(e) => {
@@ -16896,7 +17011,7 @@ let DataGridElement = class DataGridElement extends LitElement {
16896
17011
  const immediate = this.editability === `immediate` || this._editingRow === rowKey;
16897
17012
  const curVal = immediate ? val : this._editValue ?? val;
16898
17013
  const effectiveDataType = col.dataType ?? `text`;
16899
- if (effectiveDataType === `boolean` && (col.editor === `auto` || col.editor === `select` || !col.editor)) return html`<div class="cell" data-col=${col.key} style="width:${col.width ?? 150}px;justify-content:center;align-items:center"><input type="checkbox" .checked=${Boolean(curVal)} @change=${(e) => {
17014
+ if (effectiveDataType === `boolean` && (col.editor === `auto` || col.editor === `select` || !col.editor)) return html`<div class="cell" role="gridcell" data-col=${col.key} style="width:${col.width ?? 150}px;justify-content:center;align-items:center"><input type="checkbox" .checked=${Boolean(curVal)} @change=${(e) => {
16900
17015
  const v = e.target.checked;
16901
17016
  if (immediate) this._commitImmediate(rowKey, col.key, v);
16902
17017
  else {
@@ -16906,7 +17021,7 @@ let DataGridElement = class DataGridElement extends LitElement {
16906
17021
  }} /></div>`;
16907
17022
  if (col.editor === `select`) {
16908
17023
  const opts = col.editorOptions ?? [];
16909
- return html`<div class="cell" data-col=${col.key} style="width:${col.width ?? 150}px"><select @change=${(e) => {
17024
+ return html`<div class="cell" role="gridcell" data-col=${col.key} style="width:${col.width ?? 150}px"><select @change=${(e) => {
16910
17025
  const v = e.target.value;
16911
17026
  if (immediate) this._commitImmediate(rowKey, col.key, v);
16912
17027
  else {
@@ -16952,11 +17067,11 @@ let DataGridElement = class DataGridElement extends LitElement {
16952
17067
  this._editingRow = null;
16953
17068
  }
16954
17069
  };
16955
- return html`<div class="cell" data-col=${col.key} style="width:${col.width ?? 150}px">${factory(ctx)}</div>`;
17070
+ return html`<div class="cell" role="gridcell" data-col=${col.key} style="width:${col.width ?? 150}px">${factory(ctx)}</div>`;
16956
17071
  }
16957
17072
  const inputType = effectiveDataType === `number` ? `number` : `text`;
16958
17073
  const inputValue = effectiveDataType === `number` ? String(curVal ?? ``) : String(curVal ?? ``);
16959
- return html`<div class="cell" data-col=${col.key} style="width:${col.width ?? 150}px"><input type=${inputType} .value=${inputValue} @input=${(e) => this._editValue = e.target.value} @keydown=${(e) => {
17074
+ return html`<div class="cell" role="gridcell" data-col=${col.key} style="width:${col.width ?? 150}px"><input type=${inputType} .value=${inputValue} @input=${(e) => this._editValue = e.target.value} @keydown=${(e) => {
16960
17075
  if (e.key === `Enter`) this._commitEdit();
16961
17076
  if (e.key === `Escape`) {
16962
17077
  this._editing = null;
@@ -16980,7 +17095,7 @@ let DataGridElement = class DataGridElement extends LitElement {
16980
17095
  const canPan = this.cellOverflow === `pan-text` && !col.renderer && (selected || tickled);
16981
17096
  const look = this._cellLook(row, col.key);
16982
17097
  const cellStyle = look.textColor === void 0 ? `` : `color:${look.textColor};`;
16983
- return html`<div class="cell ${textEditable ? `editable-text` : ``} ${this._animatingColKeys.has(col.key) ? `width-animating` : ``}" data-col=${col.key} style="width:${col.width ?? 150}px; justify-content:${col.align === `right` ? `flex-end` : col.align === `center` ? `center` : `flex-start`}; ${indent} ${cellStyle}"
17098
+ return html`<div class="cell ${textEditable ? `editable-text` : ``} ${this._animatingColKeys.has(col.key) ? `width-animating` : ``}" role="gridcell" data-col=${col.key} style="width:${col.width ?? 150}px; justify-content:${col.align === `right` ? `flex-end` : col.align === `center` ? `center` : `flex-start`}; ${indent} ${cellStyle}"
16984
17099
  @click=${(e) => {
16985
17100
  this._lastClickedCell = {
16986
17101
  rowKey,
@@ -17095,8 +17210,6 @@ __decorate([property({
17095
17210
  __decorate([state()], DataGridElement.prototype, "_resizingColKey", void 0);
17096
17211
  __decorate([state()], DataGridElement.prototype, "_animatingColKeys", void 0);
17097
17212
  __decorate([state()], DataGridElement.prototype, "_selectedKeys", void 0);
17098
- __decorate([state()], DataGridElement.prototype, "_scrollTop", void 0);
17099
- __decorate([state()], DataGridElement.prototype, "_scrollLeft", void 0);
17100
17213
  __decorate([state()], DataGridElement.prototype, "_viewportH", void 0);
17101
17214
  __decorate([state()], DataGridElement.prototype, "_viewportW", void 0);
17102
17215
  __decorate([state()], DataGridElement.prototype, "_collapsedGroups", void 0);
@@ -17374,6 +17487,123 @@ __decorate([state()], DataTableElement.prototype, "_detailIndex", void 0);
17374
17487
  __decorate([state()], DataTableElement.prototype, "_stacked", void 0);
17375
17488
  DataTableElement = __decorate([safeCustomElement(`ixfx-data-table`)], DataTableElement);
17376
17489
  //#endregion
17490
+ //#region src/detail-list/detail-list-geometry.ts
17491
+ const EMPTY_GEOMETRY = {
17492
+ columns: [],
17493
+ positions: /* @__PURE__ */ new Map(),
17494
+ tops: /* @__PURE__ */ new Map(),
17495
+ lefts: /* @__PURE__ */ new Map()
17496
+ };
17497
+ function contentOrigin(scroller) {
17498
+ const r = scroller.getBoundingClientRect();
17499
+ return {
17500
+ left: r.left - scroller.scrollLeft,
17501
+ top: r.top - scroller.scrollTop
17502
+ };
17503
+ }
17504
+ /** Items closer than this (px) horizontally share a column (sub-pixel layout). */
17505
+ const COLUMN_TOLERANCE_PX = 1;
17506
+ /**
17507
+ * Measure `items` (one rect read each — a single layout flush) and bucket them
17508
+ * into columns. Zero-sized items (e.g. `display:none`) are skipped.
17509
+ */
17510
+ function measureGeometry(items, origin) {
17511
+ const measured = [];
17512
+ for (const el of items) {
17513
+ const r = el.getBoundingClientRect();
17514
+ if (r.width === 0 && r.height === 0) continue;
17515
+ measured.push({
17516
+ el,
17517
+ left: r.left - origin.left,
17518
+ top: r.top - origin.top,
17519
+ width: r.width
17520
+ });
17521
+ }
17522
+ measured.sort((a, b) => a.left - b.left || a.top - b.top);
17523
+ const columns = [];
17524
+ const positions = /* @__PURE__ */ new Map();
17525
+ const tops = /* @__PURE__ */ new Map();
17526
+ const lefts = /* @__PURE__ */ new Map();
17527
+ for (const m of measured) {
17528
+ let col = columns.at(-1);
17529
+ if (!col || m.left - col.left > COLUMN_TOLERANCE_PX) {
17530
+ col = {
17531
+ left: m.left,
17532
+ width: m.width,
17533
+ items: []
17534
+ };
17535
+ columns.push(col);
17536
+ }
17537
+ positions.set(m.el, {
17538
+ col: columns.length - 1,
17539
+ row: col.items.length
17540
+ });
17541
+ col.items.push(m.el);
17542
+ col.width = Math.max(col.width, m.width);
17543
+ tops.set(m.el, m.top);
17544
+ lefts.set(m.el, m.left);
17545
+ }
17546
+ return {
17547
+ columns,
17548
+ positions,
17549
+ tops,
17550
+ lefts
17551
+ };
17552
+ }
17553
+ /**
17554
+ * Whether `geometry` still matches layout, judged by re-reading a few probe
17555
+ * elements. Layout shifts that matter (column width, row height, reflow after
17556
+ * an insert) move at least one of first / cursor / last.
17557
+ */
17558
+ function geometryMatches(geometry, probes, origin) {
17559
+ for (const el of probes) {
17560
+ if (!el) continue;
17561
+ const left = geometry.lefts.get(el);
17562
+ const top = geometry.tops.get(el);
17563
+ if (left === void 0 || top === void 0) return false;
17564
+ const r = el.getBoundingClientRect();
17565
+ if (Math.abs(r.left - origin.left - left) > .5 || Math.abs(r.top - origin.top - top) > .5) return false;
17566
+ }
17567
+ return true;
17568
+ }
17569
+ /** First index in `[lo, hi)` for which `pred` holds, given `pred` is monotonic (false… then true…). */
17570
+ function partitionPoint(lo, hi, pred) {
17571
+ while (lo < hi) {
17572
+ const mid = lo + hi >> 1;
17573
+ if (pred(mid)) hi = mid;
17574
+ else lo = mid + 1;
17575
+ }
17576
+ return lo;
17577
+ }
17578
+ /**
17579
+ * Items (in DOM order) whose box intersects the content-space span
17580
+ * `[start, end)`, found with O(log n) layout reads — no full measure.
17581
+ * Relies on column flow: DOM order is column order, so `left` only moves in
17582
+ * one direction along the list (increasing for LTR, decreasing for RTL).
17583
+ */
17584
+ function itemsInSpanByOrder(items, origin, start, end) {
17585
+ if (items.length === 0) return [];
17586
+ const box = (i) => {
17587
+ const r = items[i].getBoundingClientRect();
17588
+ return {
17589
+ left: r.left - origin.left,
17590
+ right: r.right - origin.left
17591
+ };
17592
+ };
17593
+ const first = box(0);
17594
+ const last = box(items.length - 1);
17595
+ if (first.left === last.left) return first.right > start && first.left < end ? items : [];
17596
+ const n = items.length;
17597
+ if (last.left > first.left) {
17598
+ const from = partitionPoint(0, n, (i) => box(i).right > start);
17599
+ const to = partitionPoint(from, n, (i) => box(i).left >= end);
17600
+ return items.slice(from, to);
17601
+ }
17602
+ const from = partitionPoint(0, n, (i) => box(i).left < end);
17603
+ const to = partitionPoint(from, n, (i) => box(i).right <= start);
17604
+ return items.slice(from, to);
17605
+ }
17606
+ //#endregion
17377
17607
  //#region src/list-group-header/list-group-header.ts
17378
17608
  let ListGroupHeaderElement = class ListGroupHeaderElement extends LitElement {
17379
17609
  constructor(..._args) {
@@ -17534,6 +17764,50 @@ function sameKeys$3(a, b) {
17534
17764
  for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
17535
17765
  return true;
17536
17766
  }
17767
+ function sameMembers(a, b) {
17768
+ if (a.size !== b.size) return false;
17769
+ for (const el of a) if (!b.has(el)) return false;
17770
+ return true;
17771
+ }
17772
+ /** Remove `attr` from elements leaving the set, add it to elements entering it. */
17773
+ function applySetDiff(prev, next, attr) {
17774
+ for (const el of prev) if (!next.has(el)) el.removeAttribute(attr);
17775
+ for (const el of next) if (!prev.has(el)) el.setAttribute(attr, ``);
17776
+ }
17777
+ /** Attributes on an item that change list membership, visibility, keys or selectability. */
17778
+ const ITEM_CACHE_ATTRS = [
17779
+ `hidden`,
17780
+ GROUP_COLLAPSED_ATTR,
17781
+ `not-checked`,
17782
+ `data-key`,
17783
+ `data-value`
17784
+ ];
17785
+ /** Whether a mutation record can change the item caches (direct children / their cache attributes). */
17786
+ function affectsItemCaches(host, record) {
17787
+ if (record.type === `childList`) return record.target === host;
17788
+ return record.target.parentElement === host;
17789
+ }
17790
+ /**
17791
+ * Make `parent`'s managed children equal `nodes`, in order, with the fewest
17792
+ * DOM moves — a single insert stays a single move instead of re-appending
17793
+ * every row. `nodes` must contain every managed child; unmanaged children
17794
+ * (text, foreign elements) are left where they are.
17795
+ */
17796
+ function syncChildOrder(parent, nodes, managed) {
17797
+ const nextManaged = (from) => {
17798
+ let el = from;
17799
+ while (el && !managed(el)) el = el.nextElementSibling;
17800
+ return el;
17801
+ };
17802
+ let cursor = nextManaged(parent.firstElementChild);
17803
+ for (const node of nodes) {
17804
+ if (node === cursor) {
17805
+ cursor = nextManaged(cursor.nextElementSibling);
17806
+ continue;
17807
+ }
17808
+ parent.insertBefore(node, cursor);
17809
+ }
17810
+ }
17537
17811
  let DetailListElement = class DetailListElement extends LitElement {
17538
17812
  constructor(..._args) {
17539
17813
  super(..._args);
@@ -17554,10 +17828,15 @@ let DetailListElement = class DetailListElement extends LitElement {
17554
17828
  this.crossGroupSelection = false;
17555
17829
  this._collapsedGroups = /* @__PURE__ */ new Set();
17556
17830
  this.#itemData = /* @__PURE__ */ new WeakMap();
17557
- this.#grid = [];
17558
- this.#positions = /* @__PURE__ */ new Map();
17559
- this.#tops = /* @__PURE__ */ new Map();
17831
+ this.#itemIndex = /* @__PURE__ */ new WeakMap();
17832
+ this.#geometry = EMPTY_GEOMETRY;
17833
+ this.#geometryDirty = true;
17560
17834
  this.#geometryScheduled = false;
17835
+ this.#appliedSelected = /* @__PURE__ */ new Set();
17836
+ this.#appliedChecked = /* @__PURE__ */ new Set();
17837
+ this.#itemStatesFullSync = true;
17838
+ this.#hasCheckboxes = false;
17839
+ this.#draggableApplied = false;
17561
17840
  this.#sortRaf = 0;
17562
17841
  this.#viewportScrollRaf = 0;
17563
17842
  this.#animatedItems = /* @__PURE__ */ new WeakSet();
@@ -17596,10 +17875,14 @@ let DetailListElement = class DetailListElement extends LitElement {
17596
17875
  }
17597
17876
  });
17598
17877
  this.tickled = new TickledController(this, {
17599
- getItems: () => this._getVisibleItems().map((el) => ({
17600
- id: el,
17601
- enabled: !el.hasAttribute(`not-checked`)
17602
- })),
17878
+ getItems: () => {
17879
+ const visible = this._getVisibleItems();
17880
+ this.#tickledItemsCache ??= visible.map((el) => ({
17881
+ id: el,
17882
+ enabled: !el.hasAttribute(`not-checked`)
17883
+ }));
17884
+ return this.#tickledItemsCache;
17885
+ },
17603
17886
  onTickle: (id) => {
17604
17887
  if (id !== void 0) this._dispatch(`list-tickle`, { item: id });
17605
17888
  },
@@ -17703,7 +17986,7 @@ let DetailListElement = class DetailListElement extends LitElement {
17703
17986
  }
17704
17987
  return;
17705
17988
  }
17706
- if (NAV_KEYS.has(e.key)) this.setAttribute(`data-keyboard-nav`, ``);
17989
+ if (NAV_KEYS.has(e.key) && !this.hasAttribute(`data-keyboard-nav`)) this.setAttribute(`data-keyboard-nav`, ``);
17707
17990
  switch (e.key) {
17708
17991
  case `ArrowDown`:
17709
17992
  e.preventDefault();
@@ -17782,11 +18065,32 @@ let DetailListElement = class DetailListElement extends LitElement {
17782
18065
  };
17783
18066
  }
17784
18067
  #itemData;
17785
- #grid;
17786
- #positions;
17787
- /** Viewport-relative `top` per item at the last geometry pass — lines rows up across columns. */
17788
- #tops;
18068
+ /** The `index` each object item was formatted with — reuse is only safe at the same index. */
18069
+ #itemIndex;
18070
+ /** Measured column grid (content coordinates). See `#ensureGeometry`. */
18071
+ #geometry;
18072
+ /** Layout may have changed since `#geometry` was measured. */
18073
+ #geometryDirty;
17789
18074
  #geometryScheduled;
18075
+ /**
18076
+ * Watches child / visibility / key changes so the item caches below can be
18077
+ * validated synchronously (via `takeRecords()`) on every read — they are
18078
+ * never stale, even within the same task as the mutation.
18079
+ */
18080
+ #cacheObserver;
18081
+ #allItemsCache;
18082
+ #visibleCache;
18083
+ #visibleCachePredicate;
18084
+ #tickledItemsCache;
18085
+ #renderedKeysCache;
18086
+ #keyIndexCache;
18087
+ /** Item states as last written to the DOM, so `_syncItemStates` can apply just the diff. */
18088
+ #appliedTickled;
18089
+ #appliedSelected;
18090
+ #appliedChecked;
18091
+ #itemStatesFullSync;
18092
+ #hasCheckboxes;
18093
+ #draggableApplied;
17790
18094
  #sortRaf;
17791
18095
  #viewportScrollRaf;
17792
18096
  /** Key sets as of the last viewport-change dispatch, for dedupe. */
@@ -17840,6 +18144,8 @@ let DetailListElement = class DetailListElement extends LitElement {
17840
18144
  }
17841
18145
  _syncNativeDraggable() {
17842
18146
  if (!this.isConnected) return;
18147
+ if (!this.itemsDraggable && !this.#draggableApplied) return;
18148
+ this.#draggableApplied = this.itemsDraggable;
17843
18149
  for (const el of this.querySelectorAll(`li`)) if (el instanceof HTMLElement) el.draggable = this.itemsDraggable && this._isCheckable(el);
17844
18150
  }
17845
18151
  #startPostDragSuppressTimer() {
@@ -17921,7 +18227,7 @@ let DetailListElement = class DetailListElement extends LitElement {
17921
18227
  this.#resizeObserver = new ResizeObserver(() => this.#scheduleGeometry());
17922
18228
  this.#resizeObserver.observe(this);
17923
18229
  this.#mutationObserver = new MutationObserver((records) => {
17924
- this.#processChildren();
18230
+ this.#processAddedChildren(records);
17925
18231
  this.#animateChildMutations(records);
17926
18232
  this.#scheduleGeometry();
17927
18233
  this.#scheduleGroups();
@@ -17932,6 +18238,16 @@ let DetailListElement = class DetailListElement extends LitElement {
17932
18238
  attributeFilter: [`hidden`]
17933
18239
  });
17934
18240
  for (const el of this.querySelectorAll(ITEM_SELECTOR$3)) this.#animatedItems.add(el);
18241
+ this.#cacheObserver = new MutationObserver((records) => {
18242
+ if (records.some((r) => affectsItemCaches(this, r))) this.#dropItemCaches();
18243
+ });
18244
+ this.#cacheObserver.observe(this, {
18245
+ childList: true,
18246
+ subtree: true,
18247
+ attributes: true,
18248
+ attributeFilter: ITEM_CACHE_ATTRS
18249
+ });
18250
+ this.#dropItemCaches();
17935
18251
  this.#searchRegistry ??= createCommandRegistry();
17936
18252
  this.#syncSearchCommands();
17937
18253
  this.#scheduleGroups();
@@ -17951,7 +18267,7 @@ let DetailListElement = class DetailListElement extends LitElement {
17951
18267
  fresh.push(node);
17952
18268
  }
17953
18269
  if (fresh.length === 0) return;
17954
- const total = this.querySelectorAll(ITEM_SELECTOR$3).length;
18270
+ const total = this.#allItems().length;
17955
18271
  if (fresh.length >= total) return;
17956
18272
  for (const el of fresh) animateListItemIn(el);
17957
18273
  window.setTimeout(() => this.#scheduleGeometry(), 230);
@@ -17978,6 +18294,9 @@ let DetailListElement = class DetailListElement extends LitElement {
17978
18294
  this.#mutationObserver = void 0;
17979
18295
  this.#groupObserver?.disconnect();
17980
18296
  this.#groupObserver = void 0;
18297
+ this.#cacheObserver?.disconnect();
18298
+ this.#cacheObserver = void 0;
18299
+ this.#dropItemCaches();
17981
18300
  this.#unregisterSearchCommands?.();
17982
18301
  this.#unregisterSearchCommands = void 0;
17983
18302
  this.#searchRegistry = void 0;
@@ -17990,6 +18309,7 @@ let DetailListElement = class DetailListElement extends LitElement {
17990
18309
  this.#processChildren();
17991
18310
  if (this.filterPredicate) this.#applyFilterPredicate();
17992
18311
  this.#recomputeGeometry();
18312
+ this.#scheduleViewportChangeEmit();
17993
18313
  this._syncNativeDraggable();
17994
18314
  }
17995
18315
  willUpdate(changed) {
@@ -18018,7 +18338,8 @@ let DetailListElement = class DetailListElement extends LitElement {
18018
18338
  });
18019
18339
  return;
18020
18340
  }
18021
- if ((changed.has(`items`) || changed.has(`displayProperty`) || changed.has(`formatter`) || changed.has(`iconProperty`) || changed.has(`iconColorProperty`) || changed.has(`textColorProperty`) || changed.has(`badgeProperty`) || changed.has(`keyProperty`)) && this.provider === void 0 && this.items.length > 0) this.#rebuildFromObjects(this.items);
18341
+ const shapeChanged = changed.has(`displayProperty`) || changed.has(`formatter`) || changed.has(`iconProperty`) || changed.has(`iconColorProperty`) || changed.has(`textColorProperty`) || changed.has(`badgeProperty`) || changed.has(`keyProperty`);
18342
+ if ((changed.has(`items`) || shapeChanged) && this.provider === void 0 && this.items.length > 0) this.#rebuildFromObjects(this.items, !shapeChanged);
18022
18343
  this.#syncFromProvider();
18023
18344
  if (changed.has(`sort`) || changed.has(`sortBy`) || changed.has(`sortComparator`)) this.#applySort();
18024
18345
  if (changed.has(`filterPredicate`)) {
@@ -18028,6 +18349,7 @@ let DetailListElement = class DetailListElement extends LitElement {
18028
18349
  }
18029
18350
  if (changed.has(`interactionMode`)) {
18030
18351
  this.selection.clearAnchor();
18352
+ this.#itemStatesFullSync = true;
18031
18353
  this._syncItemStates();
18032
18354
  this._syncNativeDraggable();
18033
18355
  } else if (changed.has(`checkboxRenderer`) && this.interactionMode === `checked`) this._syncCheckboxes();
@@ -18051,6 +18373,7 @@ let DetailListElement = class DetailListElement extends LitElement {
18051
18373
  */
18052
18374
  refreshLayout() {
18053
18375
  this.#recomputeGeometry();
18376
+ this.#scheduleViewportChangeEmit();
18054
18377
  }
18055
18378
  addItem(item, options) {
18056
18379
  let el;
@@ -18076,7 +18399,7 @@ let DetailListElement = class DetailListElement extends LitElement {
18076
18399
  item.remove();
18077
18400
  this.#scheduleGroups();
18078
18401
  if (this.interactionMode === `checked`) this._syncCheckboxes();
18079
- this.#recomputeGeometry();
18402
+ this.#scheduleGeometry();
18080
18403
  });
18081
18404
  }
18082
18405
  /** {@link removeItem} for several items at once. */
@@ -18125,13 +18448,12 @@ let DetailListElement = class DetailListElement extends LitElement {
18125
18448
  visibleKeys() {
18126
18449
  const container = this._listEl;
18127
18450
  if (!container) return [];
18128
- const box = container.getBoundingClientRect();
18451
+ const start = container.scrollLeft;
18452
+ const end = start + container.getBoundingClientRect().width;
18129
18453
  const out = [];
18130
- for (const el of this._getVisibleItems()) {
18454
+ for (const el of itemsInSpanByOrder(this._getVisibleItems(), contentOrigin(container), start, end)) {
18131
18455
  const key = keyForElement(el);
18132
- if (key === void 0) continue;
18133
- const r = el.getBoundingClientRect();
18134
- if (r.right > box.left && r.left < box.right) out.push(key);
18456
+ if (key !== void 0) out.push(key);
18135
18457
  }
18136
18458
  return out;
18137
18459
  }
@@ -18190,7 +18512,15 @@ let DetailListElement = class DetailListElement extends LitElement {
18190
18512
  });
18191
18513
  }
18192
18514
  #itemForKey(key) {
18193
- for (const el of this.querySelectorAll(ITEM_SELECTOR$3)) if (keyForElement(el) === key) return el;
18515
+ if (!this.#keyIndexCache) {
18516
+ const index = /* @__PURE__ */ new Map();
18517
+ for (const el of this.#allItems()) {
18518
+ const k = keyForElement(el);
18519
+ if (k !== void 0 && !index.has(k)) index.set(k, el);
18520
+ }
18521
+ this.#keyIndexCache = index;
18522
+ }
18523
+ return this.#keyIndexCache.get(key);
18194
18524
  }
18195
18525
  *selectedFilter(kind) {
18196
18526
  const wanted = (el) => {
@@ -18205,11 +18535,12 @@ let DetailListElement = class DetailListElement extends LitElement {
18205
18535
  if (wanted(el)) yield el;
18206
18536
  }
18207
18537
  }
18208
- #rebuildFromObjects(items) {
18538
+ #rebuildFromObjects(items, reuseRows = false) {
18209
18539
  const keyOf = (el) => el.getAttribute(`data-key`) ?? el.getAttribute(`data-search-label`) ?? null;
18210
18540
  const previousKeys = new Set([...this.selection.selected].map(keyOf).filter((k) => k !== null));
18211
18541
  const anchorKey = this.selection.anchor ? keyOf(this.selection.anchor) : null;
18212
- this.replaceChildren(...items.map((item, index) => this.#createItemEl(item, index)));
18542
+ if (reuseRows) this.#reconcileObjectRows(items);
18543
+ else this.replaceChildren(...items.map((item, index) => this.#createItemEl(item, index)));
18213
18544
  if (previousKeys.size > 0 || anchorKey !== null) {
18214
18545
  const next = [];
18215
18546
  let anchor;
@@ -18226,9 +18557,38 @@ let DetailListElement = class DetailListElement extends LitElement {
18226
18557
  this.#scheduleGeometry();
18227
18558
  this.#scheduleGroups();
18228
18559
  }
18560
+ /**
18561
+ * Update the rows to match `items`, keeping the `<li>` of every object that
18562
+ * is still present (by identity) instead of rebuilding all of them — so
18563
+ * `items = [...items, next]` creates one row, not thousands. A row built by
18564
+ * a `formatter` is only kept at the same index, since the formatter receives
18565
+ * the index.
18566
+ */
18567
+ #reconcileObjectRows(items) {
18568
+ const reusable = /* @__PURE__ */ new Map();
18569
+ for (const li of this.#allItems()) if (this.#itemData.has(li) && !reusable.has(this.#itemData.get(li))) reusable.set(this.#itemData.get(li), li);
18570
+ const next = items.map((item, index) => {
18571
+ const li = reusable.get(item);
18572
+ if (li && (!this.formatter || this.#itemIndex.get(li) === index)) {
18573
+ reusable.delete(item);
18574
+ return li;
18575
+ }
18576
+ return this.#createItemEl(item, index);
18577
+ });
18578
+ const keep = new Set(next);
18579
+ for (const li of this.#allItems()) if (!keep.has(li)) li.remove();
18580
+ if (this.#groupingActive()) {
18581
+ for (const li of next) this.#seq.set(li, this.#seqNext++);
18582
+ this.append(...next.filter((li) => li.parentElement !== this));
18583
+ this.#scheduleGroups();
18584
+ return;
18585
+ }
18586
+ syncChildOrder(this, next, (el) => el.localName === `li`);
18587
+ }
18229
18588
  #createItemEl(item, index) {
18230
18589
  const li = document.createElement(`li`);
18231
18590
  this.#itemData.set(li, item);
18591
+ this.#itemIndex.set(li, index);
18232
18592
  const content = this.formatter ? this.formatter(item, index) : this.displayProperty ? this.#propertyValue(item, this.displayProperty) : item;
18233
18593
  const textColor = this.#colorValue(item, this.textColorProperty);
18234
18594
  const iconColor = this.#colorValue(item, this.iconColorProperty);
@@ -18299,42 +18659,40 @@ let DetailListElement = class DetailListElement extends LitElement {
18299
18659
  this.#scheduleGroups();
18300
18660
  return;
18301
18661
  }
18302
- for (const el of this.#sortRows(rows)) this.appendChild(el);
18662
+ syncChildOrder(this, this.#sortRows(rows), (el) => el.localName === `li`);
18303
18663
  this.#scheduleGeometry();
18304
18664
  this.#scheduleGroups();
18305
18665
  }
18306
18666
  /** Stable sort of `rows` by the current `sort` / `sortBy` / `sortComparator`. */
18307
18667
  #sortRows(rows) {
18308
18668
  const dir = this.sort === `descending` ? -1 : 1;
18309
- const meta = (el) => ({
18669
+ const metas = rows.map((el) => ({
18310
18670
  element: el,
18311
18671
  data: this.getItemData(el),
18312
18672
  label: el.getAttribute(`data-search-label`) ?? el.textContent?.trim() ?? ``
18313
- });
18673
+ }));
18674
+ const collator = new Intl.Collator(void 0, { numeric: true });
18314
18675
  let cmp;
18315
18676
  if (this.sortComparator) {
18316
18677
  const user = this.sortComparator;
18317
- cmp = (a, b) => user(meta(a), meta(b));
18678
+ cmp = (a, b) => user(metas[a], metas[b]);
18318
18679
  } else if (this.sortBy) {
18319
18680
  const prop = this.sortBy;
18320
- const key = (el) => {
18321
- const v = this.#propertyValue(this.getItemData(el), prop);
18681
+ const keys = metas.map((m) => {
18682
+ const v = this.#propertyValue(m.data, prop);
18322
18683
  if (typeof v === `number`) return v;
18323
18684
  return v === void 0 || v === null ? `` : String(v);
18324
- };
18685
+ });
18325
18686
  cmp = (a, b) => {
18326
- const ka = key(a);
18327
- const kb = key(b);
18687
+ const ka = keys[a];
18688
+ const kb = keys[b];
18328
18689
  if (typeof ka === `number` && typeof kb === `number`) return ka - kb;
18329
- return String(ka).localeCompare(String(kb), void 0, { numeric: true });
18690
+ return collator.compare(String(ka), String(kb));
18330
18691
  };
18331
- } else cmp = (a, b) => meta(a).label.localeCompare(meta(b).label, void 0, { numeric: true });
18332
- const indexed = rows.map((el, i) => ({
18333
- el,
18334
- i
18335
- }));
18336
- indexed.sort((a, b) => cmp(a.el, b.el) * dir || a.i - b.i);
18337
- return indexed.map(({ el }) => el);
18692
+ } else cmp = (a, b) => collator.compare(metas[a].label, metas[b].label);
18693
+ const order = rows.map((_, i) => i);
18694
+ order.sort((a, b) => cmp(a, b) * dir || a - b);
18695
+ return order.map((i) => rows[i]);
18338
18696
  }
18339
18697
  #groupingActive() {
18340
18698
  return this.groupBy !== null && this.groupBy !== void 0 && this.groupBy !== ``;
@@ -18378,7 +18736,7 @@ let DetailListElement = class DetailListElement extends LitElement {
18378
18736
  #syncGroups() {
18379
18737
  const active = this.#groupingActive();
18380
18738
  if (!active && !this.#wasGrouped) return;
18381
- const items = [...this.querySelectorAll(ITEM_SELECTOR$3)];
18739
+ const items = this.#allItems();
18382
18740
  for (const el of items) if (!this.#seq.has(el)) this.#seq.set(el, this.#seqNext++);
18383
18741
  let ordered = items.toSorted((a, b) => this.#seq.get(a) - this.#seq.get(b));
18384
18742
  if (this.sort !== `none` && ordered.length > 1) ordered = this.#sortRows(ordered);
@@ -18423,8 +18781,7 @@ let DetailListElement = class DetailListElement extends LitElement {
18423
18781
  this.#groupIndex = index;
18424
18782
  this.#groupList = info;
18425
18783
  this.#wasGrouped = active;
18426
- const current = [...this.children].filter((c) => c.matches(`li`) || c.hasAttribute(GROUP_HEADER_ATTR));
18427
- if (current.length !== nodes.length || current.some((c, i) => c !== nodes[i])) this.append(...nodes);
18784
+ syncChildOrder(this, nodes, (c) => c.localName === `li` || c.hasAttribute(GROUP_HEADER_ATTR));
18428
18785
  if (this.tickled.tickledId?.hasAttribute(GROUP_COLLAPSED_ATTR)) this.tickled.focus(void 0);
18429
18786
  this.#syncGroupObserver(active);
18430
18787
  this.#scheduleGeometry();
@@ -18475,59 +18832,65 @@ let DetailListElement = class DetailListElement extends LitElement {
18475
18832
  if (target instanceof HTMLElement && target.parentElement === this && target.hasAttribute(GROUP_HEADER_ATTR)) return target;
18476
18833
  }
18477
18834
  }
18835
+ /**
18836
+ * Mark the layout dirty and, next frame, check for a viewport change.
18837
+ * Geometry moves (resize, filter, sort, DOM changes) can change the visible
18838
+ * set without a scroll, so every layout-changing path comes through here.
18839
+ * The grid itself is re-measured lazily, by whoever needs it next.
18840
+ */
18478
18841
  #scheduleGeometry() {
18842
+ this.#geometryDirty = true;
18479
18843
  if (this.#geometryScheduled) return;
18480
18844
  this.#geometryScheduled = true;
18481
18845
  requestAnimationFrame(() => {
18482
18846
  this.#geometryScheduled = false;
18483
- this.#recomputeGeometry();
18847
+ this.#emitViewportChangeIfChanged();
18484
18848
  });
18485
18849
  }
18850
+ /** Re-measure the column grid now (one layout read per visible item). */
18486
18851
  #recomputeGeometry() {
18487
- this.#grid = [];
18488
- this.#positions.clear();
18489
- this.#tops.clear();
18490
- if (!this.isConnected) return;
18491
- const columns = [];
18492
- for (const el of this._getVisibleItems()) {
18493
- const rect = el.getBoundingClientRect();
18494
- if (rect.width === 0 && rect.height === 0) continue;
18495
- this.#tops.set(el, rect.top);
18496
- let ci = columns.findIndex((c) => Math.abs(c.left - rect.left) <= 1);
18497
- if (ci === -1) {
18498
- columns.push({
18499
- left: rect.left,
18500
- items: []
18501
- });
18502
- ci = columns.length - 1;
18503
- }
18504
- columns[ci].items.push(el);
18852
+ this.#geometryDirty = false;
18853
+ this.#geometry = this.isConnected && this._listEl ? measureGeometry(this._getVisibleItems(), contentOrigin(this._listEl)) : EMPTY_GEOMETRY;
18854
+ }
18855
+ /**
18856
+ * The column grid, re-measured only if it's known dirty or a few probe items
18857
+ * have moved (catches layout changes nothing told us about, e.g. a CSS
18858
+ * variable change). Cheap enough to call per keypress / scroll frame.
18859
+ */
18860
+ #ensureGeometry() {
18861
+ const visible = this._getVisibleItems();
18862
+ if (this.#geometryDirty || !this._listEl) {
18863
+ this.#recomputeGeometry();
18864
+ return this.#geometry;
18505
18865
  }
18506
- columns.sort((a, b) => a.left - b.left);
18507
- for (const col of columns) col.items.sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top);
18508
- this.#grid = columns.map((c) => c.items);
18509
- this.#grid.forEach((col, colIdx) => col.forEach((el, rowIdx) => this.#positions.set(el, {
18510
- col: colIdx,
18511
- row: rowIdx
18512
- })));
18513
- this.#scheduleViewportChangeEmit();
18866
+ const cursor = this.tickled.tickledId;
18867
+ const probes = [
18868
+ visible[0],
18869
+ cursor && this.#geometry.positions.has(cursor) ? cursor : void 0,
18870
+ visible.at(-1)
18871
+ ];
18872
+ if (!geometryMatches(this.#geometry, probes, contentOrigin(this._listEl))) this.#recomputeGeometry();
18873
+ return this.#geometry;
18514
18874
  }
18515
18875
  #positionOf(el) {
18516
- let pos = this.#positions.get(el);
18876
+ let pos = this.#geometry.positions.get(el);
18517
18877
  if (pos === void 0) {
18518
18878
  this.#recomputeGeometry();
18519
- pos = this.#positions.get(el);
18879
+ pos = this.#geometry.positions.get(el);
18520
18880
  }
18521
18881
  return pos;
18522
18882
  }
18883
+ #column(col) {
18884
+ return this.#geometry.columns[col]?.items;
18885
+ }
18523
18886
  #itemAt(col, row) {
18524
- return this.#grid[col]?.[row];
18887
+ return this.#column(col)?.[row];
18525
18888
  }
18526
18889
  #gridSize() {
18527
- if (this.#grid.length === 0) this.#recomputeGeometry();
18890
+ const { columns } = this.#ensureGeometry();
18528
18891
  return {
18529
- cols: this.#grid.length,
18530
- rows: Math.max(0, ...this.#grid.map((c) => c.length))
18892
+ cols: columns.length,
18893
+ rows: Math.max(0, ...columns.map((c) => c.items.length))
18531
18894
  };
18532
18895
  }
18533
18896
  #navigateTo(target) {
@@ -18545,7 +18908,7 @@ let DetailListElement = class DetailListElement extends LitElement {
18545
18908
  let row = deltaRow !== 0 ? fromRow + deltaRow : fromRow;
18546
18909
  let wrappedColumns = false;
18547
18910
  for (let iterations = 0; iterations < maxIterations; iterations++) {
18548
- const colLength = this.#grid[col]?.length ?? 0;
18911
+ const colLength = this.#column(col)?.length ?? 0;
18549
18912
  if (row < 0 || row >= colLength) {
18550
18913
  if (deltaCol !== 0 || wrappedColumns) {
18551
18914
  if (this.itemWrap === `wrap`) {
@@ -18567,15 +18930,15 @@ let DetailListElement = class DetailListElement extends LitElement {
18567
18930
  } else if (deltaRow < 0) {
18568
18931
  if (col - 1 >= 0) {
18569
18932
  col = col - 1;
18570
- row = (this.#grid[col]?.length ?? 1) - 1;
18933
+ row = (this.#column(col)?.length ?? 1) - 1;
18571
18934
  wrappedColumns = true;
18572
18935
  } else if (this.itemWrap === `wrap`) {
18573
18936
  col = cols - 1;
18574
- row = (this.#grid[col]?.length ?? 1) - 1;
18937
+ row = (this.#column(col)?.length ?? 1) - 1;
18575
18938
  wrappedColumns = true;
18576
18939
  } else return;
18577
18940
  } else if (deltaCol !== 0) {
18578
- const targetColLength = this.#grid[col]?.length ?? 0;
18941
+ const targetColLength = this.#column(col)?.length ?? 0;
18579
18942
  if (row >= targetColLength && targetColLength > 0) row = targetColLength - 1;
18580
18943
  }
18581
18944
  if (col === fromCol && row === fromRow + deltaRow) return;
@@ -18594,7 +18957,7 @@ let DetailListElement = class DetailListElement extends LitElement {
18594
18957
  for (let i = visibleItems.length - 1; i >= 0; i--) if (this._isCheckable(visibleItems[i])) return visibleItems[i];
18595
18958
  }
18596
18959
  #navigateDown() {
18597
- this.#recomputeGeometry();
18960
+ this.#ensureGeometry();
18598
18961
  const cur = this.tickled.tickledId ? this.#positionOf(this.tickled.tickledId) : void 0;
18599
18962
  if (!cur) {
18600
18963
  this.#navigateTo(this.#findFirstCheckable());
@@ -18604,7 +18967,7 @@ let DetailListElement = class DetailListElement extends LitElement {
18604
18967
  if (next) this.#navigateTo(next);
18605
18968
  }
18606
18969
  #navigateUp() {
18607
- this.#recomputeGeometry();
18970
+ this.#ensureGeometry();
18608
18971
  const cur = this.tickled.tickledId ? this.#positionOf(this.tickled.tickledId) : void 0;
18609
18972
  if (!cur) {
18610
18973
  this.#navigateTo(this.#findLastCheckable());
@@ -18621,15 +18984,15 @@ let DetailListElement = class DetailListElement extends LitElement {
18621
18984
  */
18622
18985
  #horizontalTarget(from, dir) {
18623
18986
  if (this.#groupList.length === 0) return this.#findNextCheckable(from.col, from.row, dir, 0);
18624
- const column = this.#grid[from.col + dir];
18987
+ const column = this.#column(from.col + dir);
18625
18988
  const current = this.tickled.tickledId;
18626
- const top = current ? this.#tops.get(current) : void 0;
18989
+ const top = current ? this.#geometry.tops.get(current) : void 0;
18627
18990
  if (!column || top === void 0) return void 0;
18628
18991
  let best;
18629
18992
  let bestDelta = Number.POSITIVE_INFINITY;
18630
18993
  for (const el of column) {
18631
18994
  if (!this._isCheckable(el)) continue;
18632
- const delta = Math.abs((this.#tops.get(el) ?? Number.POSITIVE_INFINITY) - top);
18995
+ const delta = Math.abs((this.#geometry.tops.get(el) ?? Number.POSITIVE_INFINITY) - top);
18633
18996
  if (delta < bestDelta) {
18634
18997
  best = el;
18635
18998
  bestDelta = delta;
@@ -18638,7 +19001,7 @@ let DetailListElement = class DetailListElement extends LitElement {
18638
19001
  return best;
18639
19002
  }
18640
19003
  #navigateRight() {
18641
- this.#recomputeGeometry();
19004
+ this.#ensureGeometry();
18642
19005
  const cur = this.tickled.tickledId ? this.#positionOf(this.tickled.tickledId) : void 0;
18643
19006
  if (!cur) {
18644
19007
  this.#navigateTo(this.#findFirstCheckable());
@@ -18649,7 +19012,7 @@ let DetailListElement = class DetailListElement extends LitElement {
18649
19012
  else if (this.itemWrap === `wrap`) this.#navigateTo(this.#findFirstCheckable());
18650
19013
  }
18651
19014
  #navigateLeft() {
18652
- this.#recomputeGeometry();
19015
+ this.#ensureGeometry();
18653
19016
  const cur = this.tickled.tickledId ? this.#positionOf(this.tickled.tickledId) : void 0;
18654
19017
  if (!cur) {
18655
19018
  this.#navigateTo(this.#findFirstCheckable());
@@ -18659,13 +19022,49 @@ let DetailListElement = class DetailListElement extends LitElement {
18659
19022
  if (next) this.#navigateTo(next);
18660
19023
  else if (this.itemWrap === `wrap`) this.#navigateTo(this.#findLastCheckable());
18661
19024
  }
19025
+ /**
19026
+ * Visible items in DOM order. Cached — every per-hover / per-frame path
19027
+ * reads it — and validated on each call, so it is never stale.
19028
+ */
18662
19029
  _getVisibleItems() {
18663
- return Array.from(this.querySelectorAll(ITEM_SELECTOR$3)).filter((el) => {
19030
+ this.#validateItemCaches();
19031
+ if (this.#visibleCachePredicate !== this.filterPredicate) {
19032
+ this.#dropVisibleCaches();
19033
+ this.#visibleCachePredicate = this.filterPredicate;
19034
+ }
19035
+ this.#visibleCache ??= this.#allItems().filter((el) => {
18664
19036
  if (el.hidden) return false;
18665
19037
  if (el.hasAttribute(GROUP_COLLAPSED_ATTR)) return false;
18666
19038
  if (this.filterPredicate && !this.filterPredicate(el)) return false;
18667
19039
  return true;
18668
19040
  });
19041
+ return this.#visibleCache;
19042
+ }
19043
+ /** Every item (`<li>` child), in DOM order. Cached like `_getVisibleItems`. */
19044
+ #allItems() {
19045
+ this.#validateItemCaches();
19046
+ this.#allItemsCache ??= [...this.querySelectorAll(ITEM_SELECTOR$3)];
19047
+ return this.#allItemsCache;
19048
+ }
19049
+ /** Drop the item caches if anything relevant changed since they were built. */
19050
+ #validateItemCaches() {
19051
+ if (!this.#cacheObserver) {
19052
+ this.#dropItemCaches();
19053
+ return;
19054
+ }
19055
+ if (this.#cacheObserver.takeRecords().some((r) => affectsItemCaches(this, r))) this.#dropItemCaches();
19056
+ }
19057
+ #dropItemCaches() {
19058
+ this.#allItemsCache = void 0;
19059
+ this.#keyIndexCache = void 0;
19060
+ this.#itemStatesFullSync = true;
19061
+ this.#dropVisibleCaches();
19062
+ }
19063
+ #dropVisibleCaches() {
19064
+ this.#visibleCache = void 0;
19065
+ this.#tickledItemsCache = void 0;
19066
+ this.#renderedKeysCache = void 0;
19067
+ this.#geometryDirty = true;
18669
19068
  }
18670
19069
  /**
18671
19070
  * Set the column width to fit the widest visible item.
@@ -18738,21 +19137,48 @@ let DetailListElement = class DetailListElement extends LitElement {
18738
19137
  }
18739
19138
  /** Scan light-DOM children for unprocessed `data-icon` items. */
18740
19139
  #processChildren() {
18741
- for (const el of this.querySelectorAll(ITEM_SELECTOR$3)) this.#ensureIcon(el);
19140
+ for (const el of this.#allItems()) this.#ensureIcon(el);
18742
19141
  }
19142
+ /** `#processChildren` for just the items a batch of mutations added. */
19143
+ #processAddedChildren(records) {
19144
+ for (const record of records) for (const node of record.addedNodes) if (node instanceof HTMLLIElement && node.parentElement === this) this.#ensureIcon(node);
19145
+ }
19146
+ /**
19147
+ * Reflect cursor / selection / checked state onto the items. After the item
19148
+ * set changes (or the interaction mode does) every item is visited; otherwise
19149
+ * only the items whose state changed are touched, so hovering is O(1).
19150
+ */
18743
19151
  _syncItemStates() {
18744
- for (const el of this.querySelectorAll(ITEM_SELECTOR$3)) {
18745
- el.toggleAttribute(`data-tickled`, this.tickled.isTickled(el));
18746
- el.toggleAttribute(`data-selected`, this.selection.selected.has(el));
18747
- if (this.interactionMode === `checked`) el.toggleAttribute(`data-checked`, !el.hasAttribute(`not-checked`) && this.selection.selected.has(el));
18748
- else el.removeAttribute(`data-checked`);
19152
+ const selected = this.selection.selected;
19153
+ const checked = this.interactionMode === `checked` ? new Set([...selected].filter((el) => !el.hasAttribute(`not-checked`))) : /* @__PURE__ */ new Set();
19154
+ const tickled = this.tickled.tickledId;
19155
+ const selectionChanged = this.#itemStatesFullSync || !sameMembers(this.#appliedSelected, selected) || !sameMembers(this.#appliedChecked, checked);
19156
+ if (this.#itemStatesFullSync) {
19157
+ for (const el of this.#allItems()) {
19158
+ el.toggleAttribute(`data-tickled`, el === tickled);
19159
+ el.toggleAttribute(`data-selected`, selected.has(el));
19160
+ el.toggleAttribute(`data-checked`, checked.has(el));
19161
+ }
19162
+ this.#itemStatesFullSync = false;
19163
+ } else {
19164
+ if (tickled !== this.#appliedTickled) {
19165
+ this.#appliedTickled?.removeAttribute(`data-tickled`);
19166
+ tickled?.setAttribute(`data-tickled`, ``);
19167
+ }
19168
+ applySetDiff(this.#appliedSelected, selected, `data-selected`);
19169
+ applySetDiff(this.#appliedChecked, checked, `data-checked`);
18749
19170
  }
18750
- this.dataset.inputMode = this.tickled.inputMode;
18751
- this._syncCheckboxes();
19171
+ this.#appliedTickled = tickled;
19172
+ this.#appliedSelected = new Set(selected);
19173
+ this.#appliedChecked = checked;
19174
+ if (this.dataset.inputMode !== this.tickled.inputMode) this.dataset.inputMode = this.tickled.inputMode;
19175
+ if (selectionChanged) this._syncCheckboxes();
18752
19176
  }
18753
19177
  _syncCheckboxes() {
18754
19178
  const inCheckedMode = this.interactionMode === `checked`;
18755
19179
  const wantsCustom = typeof this.checkboxRenderer === `function`;
19180
+ if (!inCheckedMode && !this.#hasCheckboxes) return;
19181
+ this.#hasCheckboxes = inCheckedMode;
18756
19182
  for (const el of this.querySelectorAll(`li`)) {
18757
19183
  const existing = el.querySelector(`.ixfx-list-checkbox`);
18758
19184
  if (!inCheckedMode) {
@@ -18865,12 +19291,12 @@ let DetailListElement = class DetailListElement extends LitElement {
18865
19291
  * `ixfx-vertical-list.renderedKeys()` in its non-virtualized mode.
18866
19292
  */
18867
19293
  #renderedKeys() {
18868
- const out = [];
18869
- for (const el of this._getVisibleItems()) {
19294
+ const visible = this._getVisibleItems();
19295
+ this.#renderedKeysCache ??= visible.flatMap((el) => {
18870
19296
  const key = keyForElement(el);
18871
- if (key !== void 0) out.push(key);
18872
- }
18873
- return out;
19297
+ return key === void 0 ? [] : [key];
19298
+ });
19299
+ return this.#renderedKeysCache;
18874
19300
  }
18875
19301
  /**
18876
19302
  * Dispatch `detail-list-viewport-change` with the current key sets — unless
@@ -18970,6 +19396,12 @@ let DetailListElement = class DetailListElement extends LitElement {
18970
19396
  display: flex;
18971
19397
  align-items: center;
18972
19398
  flex: none;
19399
+ /* Paint-isolates each row, so restyling one row (hover, selection)
19400
+ doesn't repaint the whole list: ~3.5x less render work per hover at
19401
+ 10k rows, for ~0.6ms more compositor commit per scroll frame. Paint
19402
+ only: layout/style containment add nothing here, and no size
19403
+ containment — fitColumnWidth() measures rows at max-content. */
19404
+ contain: paint;
18973
19405
  width: var(--detail-list-column-width, 220px);
18974
19406
  height: var(--detail-list-item-height, 28px);
18975
19407
  padding: var(--_detail-list-item-padding);
@@ -20850,6 +21282,11 @@ let SplitLayoutElement = class SplitLayoutElement extends LitElement {
20850
21282
  height: 100%;
20851
21283
  }
20852
21284
 
21285
+ /* ::part() doesn't match inside our own shadow root, so target directly. */
21286
+ [part="panel"] {
21287
+ contain: strict;
21288
+ }
21289
+
20853
21290
  .gutter {
20854
21291
  z-index: 2;
20855
21292
  background: var(--split-gutter-color);
@@ -21045,9 +21482,20 @@ let TabListElement = class TabListElement extends LitElement {
21045
21482
  window.addEventListener(`pointerup`, this.#onPointerUp);
21046
21483
  window.addEventListener(`pointercancel`, this.#onPointerCancel);
21047
21484
  };
21485
+ this.#pendingMove = null;
21486
+ this.#moveRaf = null;
21048
21487
  this.#onPointerMove = (event) => {
21049
21488
  const state = this.#dragState;
21050
21489
  if (!state || event.pointerId !== state.pointerId) return;
21490
+ this.#pendingMove = event;
21491
+ this.#moveRaf ??= requestAnimationFrame(this.#flushMove);
21492
+ };
21493
+ this.#flushMove = () => {
21494
+ const event = this.#pendingMove;
21495
+ this.#pendingMove = null;
21496
+ this.#moveRaf = null;
21497
+ const state = this.#dragState;
21498
+ if (!state || !event) return;
21051
21499
  const dx = event.clientX - state.startX;
21052
21500
  const dy = event.clientY - state.startY;
21053
21501
  const distance = Math.hypot(dx, dy);
@@ -21232,7 +21680,17 @@ let TabListElement = class TabListElement extends LitElement {
21232
21680
  return item;
21233
21681
  }
21234
21682
  #onPointerDown;
21683
+ #pendingMove;
21684
+ #moveRaf;
21235
21685
  #onPointerMove;
21686
+ #cancelPendingMove() {
21687
+ if (this.#moveRaf !== null) {
21688
+ cancelAnimationFrame(this.#moveRaf);
21689
+ this.#moveRaf = null;
21690
+ }
21691
+ this.#pendingMove = null;
21692
+ }
21693
+ #flushMove;
21236
21694
  #onPointerUp;
21237
21695
  #onPointerCancel;
21238
21696
  #beginDrag(event) {
@@ -21258,6 +21716,7 @@ let TabListElement = class TabListElement extends LitElement {
21258
21716
  #endDrag(pointerId) {
21259
21717
  const state = this.#dragState;
21260
21718
  if (!state) return;
21719
+ this.#cancelPendingMove();
21261
21720
  state.ghost?.destroy();
21262
21721
  state.ghost = null;
21263
21722
  this.#dragState = null;
@@ -21580,6 +22039,22 @@ __decorate([property({
21580
22039
  TabListElement = __decorate([safeCustomElement(`ixfx-tab-list`)], TabListElement);
21581
22040
  //#endregion
21582
22041
  //#region src/tabs/tab-list-item.ts
22042
+ const resizeHandlers = /* @__PURE__ */ new WeakMap();
22043
+ const sharedResizeObserver = new ResizeObserver((entries) => {
22044
+ for (const entry of entries) resizeHandlers.get(entry.target)?.(entry.contentRect.width);
22045
+ });
22046
+ const mutationHandlers = /* @__PURE__ */ new Map();
22047
+ const sharedMutationObserver = new MutationObserver((records) => {
22048
+ const touched = /* @__PURE__ */ new Set();
22049
+ for (const record of records) for (let node = record.target; node; node = node.parentNode) {
22050
+ const handler = mutationHandlers.get(node);
22051
+ if (handler) {
22052
+ touched.add(handler);
22053
+ break;
22054
+ }
22055
+ }
22056
+ for (const handler of touched) handler();
22057
+ });
21583
22058
  let TabListItemElement = class TabListItemElement extends LitElement {
21584
22059
  constructor(..._args) {
21585
22060
  super(..._args);
@@ -21593,7 +22068,11 @@ let TabListItemElement = class TabListItemElement extends LitElement {
21593
22068
  this._compact = false;
21594
22069
  this._hasToolbarContent = false;
21595
22070
  this._hasStatusContent = false;
21596
- this._labelObserver = null;
22071
+ this._onLabelMutation = () => {
22072
+ this._syncLabelSizer();
22073
+ this._updateToolbarContent();
22074
+ this._updateStatusContent();
22075
+ };
21597
22076
  this._onToolbarSlotChange = () => {
21598
22077
  this._updateToolbarContent();
21599
22078
  };
@@ -21606,17 +22085,13 @@ let TabListItemElement = class TabListItemElement extends LitElement {
21606
22085
  }
21607
22086
  connectedCallback() {
21608
22087
  super.connectedCallback();
21609
- this._resizeObserver = new ResizeObserver(([entry]) => {
21610
- this._compact = entry.contentRect.width < 64;
22088
+ resizeHandlers.set(this, (width) => {
22089
+ this._compact = width < 64;
21611
22090
  });
21612
- this._resizeObserver.observe(this);
22091
+ sharedResizeObserver.observe(this);
21613
22092
  this._wireSlotListeners();
21614
- this._labelObserver = new MutationObserver(() => {
21615
- this._syncLabelSizer();
21616
- this._updateToolbarContent();
21617
- this._updateStatusContent();
21618
- });
21619
- this._labelObserver.observe(this, {
22093
+ mutationHandlers.set(this, this._onLabelMutation);
22094
+ sharedMutationObserver.observe(this, {
21620
22095
  childList: true,
21621
22096
  characterData: true,
21622
22097
  subtree: true,
@@ -21626,12 +22101,11 @@ let TabListItemElement = class TabListItemElement extends LitElement {
21626
22101
  }
21627
22102
  disconnectedCallback() {
21628
22103
  super.disconnectedCallback();
21629
- this._resizeObserver?.disconnect();
21630
- this._resizeObserver = void 0;
22104
+ sharedResizeObserver.unobserve(this);
22105
+ resizeHandlers.delete(this);
21631
22106
  this._commandCleanup?.();
21632
22107
  this._commandCleanup = void 0;
21633
- this._labelObserver?.disconnect();
21634
- this._labelObserver = null;
22108
+ mutationHandlers.delete(this);
21635
22109
  this._unwireSlotListeners();
21636
22110
  }
21637
22111
  updated(changed) {
@@ -24726,8 +25200,8 @@ let VerticalListElement = class VerticalListElement extends LitElement {
24726
25200
  });
24727
25201
  }
24728
25202
  });
24729
- this.tickled = new TickledController(this, {
24730
- getItems: () => this._getVisibleItems().map((el) => ({
25203
+ this.tickled = ((self) => new TickledController(self, {
25204
+ getItems: () => self.#selectionOrder().map((el) => ({
24731
25205
  id: el,
24732
25206
  enabled: !el.hasAttribute(`not-checked`)
24733
25207
  })),
@@ -24735,9 +25209,11 @@ let VerticalListElement = class VerticalListElement extends LitElement {
24735
25209
  if (id !== void 0) this._dispatch(`list-tickle`, { item: id });
24736
25210
  },
24737
25211
  onStateChange: () => this._syncItemStates(),
24738
- itemWrap: this.itemWrap,
25212
+ get itemWrap() {
25213
+ return self.itemWrap;
25214
+ },
24739
25215
  hoverDisabled: () => this.#dragSelect.isDragging || this.#nativeDrag.isDragging
24740
- });
25216
+ }))(this);
24741
25217
  this.#nativeDragging = false;
24742
25218
  this.#postDragSuppressTimer = 0;
24743
25219
  this.#nativeDrag = new ListNativeDragController(this, {
@@ -24769,6 +25245,7 @@ let VerticalListElement = class VerticalListElement extends LitElement {
24769
25245
  this.toggleAttribute(`data-native-dragging`, false);
24770
25246
  this.removeAttribute(`aria-label`);
24771
25247
  for (const el of this._getVisibleItems()) el.removeAttribute(`data-drag-origin`);
25248
+ if (this.#virtualActive()) for (const el of this.#allItems) el.removeAttribute(`data-drag-origin`);
24772
25249
  this.#startPostDragSuppressTimer();
24773
25250
  },
24774
25251
  syncLightDomDraggable: () => this._syncNativeDraggable()
@@ -24797,7 +25274,9 @@ let VerticalListElement = class VerticalListElement extends LitElement {
24797
25274
  this.search = new ListSearchController(this, { adapter: createElementListSearchAdapter(this, {
24798
25275
  itemSelector: ITEM_SELECTOR$2,
24799
25276
  getLabel: (el) => el.getAttribute(`data-search-label`) ?? el.textContent?.trim() ?? ``,
24800
- highlightKey: `vertical-list-search`
25277
+ highlightKey: `vertical-list-search`,
25278
+ getAllItems: () => this.#selectionOrder(),
25279
+ observeContainer: false
24801
25280
  }) });
24802
25281
  this.#onScroll = () => {
24803
25282
  if (this.#scrollRaf !== 0) return;
@@ -24836,10 +25315,7 @@ let VerticalListElement = class VerticalListElement extends LitElement {
24836
25315
  case `ArrowDown`:
24837
25316
  e.preventDefault();
24838
25317
  this.tickled.handleKeyboardNavigation(`next`);
24839
- this.tickled.tickledId?.scrollIntoView({
24840
- block: `nearest`,
24841
- inline: `nearest`
24842
- });
25318
+ this.#revealCursor();
24843
25319
  if (e.shiftKey && this.interactionMode === `vscode`) {
24844
25320
  const cur = this.tickled.tickledId;
24845
25321
  if (cur) this.selection.rangeTo(cur);
@@ -24848,15 +25324,30 @@ let VerticalListElement = class VerticalListElement extends LitElement {
24848
25324
  case `ArrowUp`:
24849
25325
  e.preventDefault();
24850
25326
  this.tickled.handleKeyboardNavigation(`previous`);
24851
- this.tickled.tickledId?.scrollIntoView({
24852
- block: `nearest`,
24853
- inline: `nearest`
24854
- });
25327
+ this.#revealCursor();
24855
25328
  if (e.shiftKey && this.interactionMode === `vscode`) {
24856
25329
  const cur = this.tickled.tickledId;
24857
25330
  if (cur) this.selection.rangeTo(cur);
24858
25331
  }
24859
25332
  break;
25333
+ case `Home`:
25334
+ e.preventDefault();
25335
+ this.tickled.handleKeyboardNavigation(`first`);
25336
+ this.#revealCursor();
25337
+ break;
25338
+ case `End`:
25339
+ e.preventDefault();
25340
+ this.tickled.handleKeyboardNavigation(`last`);
25341
+ this.#revealCursor();
25342
+ break;
25343
+ case `PageDown`:
25344
+ e.preventDefault();
25345
+ this.#handlePage(1);
25346
+ break;
25347
+ case `PageUp`:
25348
+ e.preventDefault();
25349
+ this.#handlePage(-1);
25350
+ break;
24860
25351
  case `Enter`: {
24861
25352
  const id = this.tickled.tickledId;
24862
25353
  if (id) {
@@ -24978,16 +25469,17 @@ let VerticalListElement = class VerticalListElement extends LitElement {
24978
25469
  #postDragSuppressTimer;
24979
25470
  #nativeDrag;
24980
25471
  #prepareNativeDrag(originKey) {
24981
- const visible = this._getVisibleItems();
24982
- const originEl = visible.find((el) => keyForElement(el) === originKey);
25472
+ const originEl = this.#itemForKey(originKey);
24983
25473
  if (!originEl || !this._isCheckable(originEl)) return void 0;
24984
25474
  if (!this.selection.selected.has(originEl)) this.selection.replace(originEl);
25475
+ const ordered = this.#selectionOrder();
24985
25476
  const selected = [];
24986
- visible.forEach((el, index) => {
25477
+ ordered.forEach((el) => {
24987
25478
  if (!this.selection.selected.has(el)) return;
25479
+ const idx = this.#registryIndexOf(el);
24988
25480
  selected.push({
24989
25481
  key: keyForElement(el) ?? originKey,
24990
- index,
25482
+ index: idx,
24991
25483
  item: el,
24992
25484
  element: el
24993
25485
  });
@@ -25214,6 +25706,7 @@ let VerticalListElement = class VerticalListElement extends LitElement {
25214
25706
  return;
25215
25707
  }
25216
25708
  if (this.interactionMode === `checked`) this._syncCheckboxes();
25709
+ this.search.refresh();
25217
25710
  }
25218
25711
  /**
25219
25712
  * Collapse + fade `item` out, then detach it. The rows below slide up to
@@ -25250,6 +25743,7 @@ let VerticalListElement = class VerticalListElement extends LitElement {
25250
25743
  this.replaceChildren();
25251
25744
  this.selection.reset();
25252
25745
  this._syncItemStates();
25746
+ this.search.refresh();
25253
25747
  if (this.#virtualActive()) this.#syncVirtualWindow(true);
25254
25748
  }
25255
25749
  /**
@@ -25265,11 +25759,13 @@ let VerticalListElement = class VerticalListElement extends LitElement {
25265
25759
  this.#allItems = [...items];
25266
25760
  for (const el of this.#allItems) this.#animatedItems.add(el);
25267
25761
  this.selection.reset();
25762
+ this.search.refresh();
25268
25763
  this.#syncVirtualWindow(true);
25269
25764
  return;
25270
25765
  }
25271
25766
  for (const item of items) this.appendChild(item);
25272
25767
  if (this.interactionMode === `checked`) this._syncCheckboxes();
25768
+ this.search.refresh();
25273
25769
  }
25274
25770
  /**
25275
25771
  * Dispatch a command to this list — used by `ixfx-grouped-item-lister` to
@@ -25326,7 +25822,16 @@ let VerticalListElement = class VerticalListElement extends LitElement {
25326
25822
  if (index < 0) return;
25327
25823
  const ul = this.renderRoot.querySelector(`ul`);
25328
25824
  if (ul) {
25329
- ul.scrollTop = index * this.itemHeight;
25825
+ const stride = this.itemHeight;
25826
+ const viewportH = ul.clientHeight;
25827
+ const top = index * stride;
25828
+ const bottom = (index + 1) * stride;
25829
+ const vt = ul.scrollTop;
25830
+ const vh = viewportH;
25831
+ let next = vt;
25832
+ if (top < vt) next = top;
25833
+ else if (bottom > vt + vh) next = bottom - vh;
25834
+ if (next !== vt) ul.scrollTop = next;
25330
25835
  this.#syncVirtualWindow(true);
25331
25836
  }
25332
25837
  }
@@ -25338,6 +25843,33 @@ let VerticalListElement = class VerticalListElement extends LitElement {
25338
25843
  inline: `nearest`
25339
25844
  });
25340
25845
  }
25846
+ #revealCursor() {
25847
+ const el = this.tickled.tickledId;
25848
+ const key = el ? keyForElement(el) : void 0;
25849
+ if (key !== void 0) this.focusKey(key);
25850
+ }
25851
+ #pageSize() {
25852
+ const ul = this.#scrollList();
25853
+ if (!ul) return 1;
25854
+ const viewportH = ul.clientHeight;
25855
+ if (this.#virtualActive()) return Math.max(1, Math.floor(viewportH / this.itemHeight));
25856
+ const first = this.querySelector(`li[data-key]`);
25857
+ if (!first) return 1;
25858
+ const firstH = first.offsetHeight || this.itemHeight;
25859
+ return Math.max(1, Math.floor(viewportH / firstH));
25860
+ }
25861
+ /** Move the cursor one page up (-1) or down (1) across the whole registry. */
25862
+ #handlePage(direction) {
25863
+ const page = this.#pageSize();
25864
+ const items = this.#selectionOrder().filter((el) => !el.hasAttribute(`not-checked`));
25865
+ if (items.length === 0) return;
25866
+ const currentKey = this.tickled.tickledId ? keyForElement(this.tickled.tickledId) : void 0;
25867
+ const currentIdx = currentKey !== void 0 ? items.findIndex((i) => keyForElement(i) === currentKey) : -1;
25868
+ const fallback = direction === 1 ? page - 1 : 0;
25869
+ const nextIdx = currentIdx < 0 ? fallback : currentIdx + direction * page;
25870
+ this.tickled.handleKeyboardNavigation(items[Math.min(items.length - 1, Math.max(0, nextIdx))]);
25871
+ this.#revealCursor();
25872
+ }
25341
25873
  #itemForKey(key) {
25342
25874
  if (this.#virtualActive()) return this.#allItems.find((el) => keyForElement(el) === key);
25343
25875
  for (const el of this.querySelectorAll(ITEM_SELECTOR$2)) {
@@ -25686,6 +26218,7 @@ let VerticalListElement = class VerticalListElement extends LitElement {
25686
26218
  else this.#pendingAnimate.push(...fresh);
25687
26219
  }
25688
26220
  if (changed && this.interactionMode === `checked`) this._syncCheckboxes();
26221
+ if (changed) this.search.refresh();
25689
26222
  return changed;
25690
26223
  }
25691
26224
  /** Consume queued observer records first so a sync never drops a row. */
@@ -25763,9 +26296,10 @@ let VerticalListElement = class VerticalListElement extends LitElement {
25763
26296
  this.#windowEnd = end;
25764
26297
  if (this.interactionMode === `checked`) this._syncCheckboxes();
25765
26298
  this._syncItemStates();
26299
+ const totalCount = this.#selectionOrder().length;
25766
26300
  for (let i = start; i < end; i++) {
25767
26301
  const el = this.#allItems[i];
25768
- if (el) this.#applyRowState(el);
26302
+ if (el) this.#applyRowState(el, i + 1, totalCount);
25769
26303
  }
25770
26304
  const pending = this.#pendingAnimate;
25771
26305
  this.#pendingAnimate = [];
@@ -25773,12 +26307,25 @@ let VerticalListElement = class VerticalListElement extends LitElement {
25773
26307
  this.#animatedItems.add(el);
25774
26308
  if (el.parentNode === this) animateListItemIn(el);
25775
26309
  }
26310
+ this.search.repaintHighlights();
25776
26311
  }
25777
26312
  /** Per-attach state the attached sweep can't cover (it only sees the window). */
25778
- #applyRowState(el) {
25779
- if (el instanceof HTMLElement) el.draggable = this.itemsDraggable && this._isCheckable(el) && !el.hasAttribute(`data-editing`);
26313
+ #applyRowState(el, posInSet, setSize) {
26314
+ if (el instanceof HTMLElement) {
26315
+ el.draggable = this.itemsDraggable && this._isCheckable(el) && !el.hasAttribute(`data-editing`);
26316
+ if (posInSet !== void 0 && setSize !== void 0) {
26317
+ el.setAttribute(`aria-setsize`, String(setSize));
26318
+ el.setAttribute(`aria-posinset`, String(posInSet));
26319
+ if (!el.hasAttribute(`role`) || el.hasAttribute(`data-aria-role`)) {
26320
+ el.setAttribute(`role`, `option`);
26321
+ el.setAttribute(`data-aria-role`, ``);
26322
+ el.setAttribute(`aria-selected`, String(this.selection.selected.has(el)));
26323
+ }
26324
+ }
26325
+ }
25780
26326
  el.removeAttribute(`data-marquee`);
25781
26327
  el.removeAttribute(`data-drag-origin`);
26328
+ el.removeAttribute(`data-drop-target`);
25782
26329
  }
25783
26330
  /**
25784
26331
  * Leave virtualization: reattach every registry row in order and drop the
@@ -25843,6 +26390,7 @@ let VerticalListElement = class VerticalListElement extends LitElement {
25843
26390
  const item = this.#customWrappers.get(el) ?? el;
25844
26391
  el.toggleAttribute(`data-tickled`, this.tickled.isTickled(el));
25845
26392
  el.toggleAttribute(`data-selected`, this.selection.selected.has(el));
26393
+ if (el.hasAttribute(`data-aria-role`)) el.setAttribute(`aria-selected`, String(this.selection.selected.has(el)));
25846
26394
  if (this.interactionMode === `checked`) el.toggleAttribute(`data-checked`, !item.hasAttribute(`not-checked`) && this.selection.selected.has(el));
25847
26395
  else el.removeAttribute(`data-checked`);
25848
26396
  }
@@ -25986,6 +26534,10 @@ let VerticalListElement = class VerticalListElement extends LitElement {
25986
26534
  if (this.interactionMode !== `manual`) this.selection.replace(item);
25987
26535
  this._dispatch(`list-activate`, { item });
25988
26536
  }
26537
+ activateKey(key) {
26538
+ const item = this.#itemForKey(key);
26539
+ if (item) this._activateItem(item);
26540
+ }
25989
26541
  /** The row (or `undefined` for empty space) the last `list-drag-over` was fired for. */
25990
26542
  #dragOver;
25991
26543
  /** Tell the consumer the drag left its last target and drop the highlight. */
@@ -25995,9 +26547,11 @@ let VerticalListElement = class VerticalListElement extends LitElement {
25995
26547
  this.#highlightDropTarget(void 0, false);
25996
26548
  if (last) this._dispatch(`list-drag-leave`, { key: last.item ? keyForElement(last.item) : void 0 });
25997
26549
  }
26550
+ #dropTargetEl;
25998
26551
  /** Mark one row (or, for empty space, the host) as the drop target. */
25999
26552
  #highlightDropTarget(item, active) {
26000
- for (const el of this.querySelectorAll(`[data-drop-target]`)) el.removeAttribute(`data-drop-target`);
26553
+ this.#dropTargetEl?.removeAttribute(`data-drop-target`);
26554
+ this.#dropTargetEl = active ? item : void 0;
26001
26555
  this.toggleAttribute(`data-drop-target`, active && item === void 0);
26002
26556
  if (active && item) item.setAttribute(`data-drop-target`, ``);
26003
26557
  }
@@ -26282,9 +26836,17 @@ let VerticalListElement = class VerticalListElement extends LitElement {
26282
26836
  ];
26283
26837
  }
26284
26838
  render() {
26839
+ const role = this.getAttribute(`role`) ? void 0 : `listbox`;
26840
+ const ariaMulti = this.selectionMode === `multiple`;
26285
26841
  return html`
26286
26842
  <div class="container">
26287
- <ul part="list" class="scroll-fade-y" @scroll=${this.#onScroll}>
26843
+ <ul
26844
+ part="list"
26845
+ class="scroll-fade-y"
26846
+ role=${ifDefined(role)}
26847
+ aria-multiselectable=${ariaMulti ? `true` : `false`}
26848
+ @scroll=${this.#onScroll}
26849
+ >
26288
26850
  <slot></slot>
26289
26851
  </ul>
26290
26852
  ${this.#marqueeRect ? html`<div class="marquee" part="marquee" style=${this.#marqueeStyle(this.#marqueeRect)}></div>` : ``}
@@ -26411,6 +26973,7 @@ let FlatListElement = class FlatListElement extends LitElement {
26411
26973
  this.hideRoot = false;
26412
26974
  this.emptyText = `Empty`;
26413
26975
  this._focusVisible = false;
26976
+ this._hasToolbar = false;
26414
26977
  this._rows = [];
26415
26978
  this._loading = false;
26416
26979
  this.#lastPath = [];
@@ -26502,6 +27065,9 @@ let FlatListElement = class FlatListElement extends LitElement {
26502
27065
  composed: true
26503
27066
  }));
26504
27067
  };
27068
+ this.#onToolbarSlotChange = (e) => {
27069
+ this._hasToolbar = e.target.assignedNodes({ flatten: true }).some((n) => n.nodeType === Node.ELEMENT_NODE || !!n.textContent?.trim());
27070
+ };
26505
27071
  this.#syncFocusVisible = () => {
26506
27072
  this._focusVisible = this.list?.matches(`:focus-visible`) ?? false;
26507
27073
  };
@@ -26744,16 +27310,21 @@ let FlatListElement = class FlatListElement extends LitElement {
26744
27310
  #onListEditCancel;
26745
27311
  #onListSelect;
26746
27312
  #onListActivate;
27313
+ #onToolbarSlotChange;
26747
27314
  #syncFocusVisible;
26748
27315
  #onKeyDown;
26749
27316
  /** A crumb was chosen (or the Up button used): go to that node, or to its parent and select it if it is a leaf. */
26750
27317
  #onCrumbSelect;
27318
+ #markedEl;
26751
27319
  /** Flag the marked row. Rows are rebuilt with the level, so this runs after each rebuild. */
26752
27320
  async #applyMarked() {
26753
27321
  const list = this.list;
26754
27322
  if (!list) return;
26755
27323
  await list.updateComplete;
26756
- for (const row of list.querySelectorAll(`li[data-key]`)) row.toggleAttribute(`data-marked`, row.getAttribute(`data-key`) === this.markedKey);
27324
+ this.#markedEl?.removeAttribute(`data-marked`);
27325
+ const key = this.markedKey;
27326
+ this.#markedEl = key === void 0 ? void 0 : list.getItemByKey(key);
27327
+ this.#markedEl?.setAttribute(`data-marked`, ``);
26757
27328
  }
26758
27329
  /** After the inner list has rebuilt, place the cursor / selection / focus. */
26759
27330
  async #settleList() {
@@ -26795,6 +27366,9 @@ let FlatListElement = class FlatListElement extends LitElement {
26795
27366
  ?hide-root=${this.hideRoot}
26796
27367
  @select=${this.#onCrumbSelect}
26797
27368
  ></ixfx-crumb-navigation>
27369
+ <div class="toolbar" part="toolbar" ?hidden=${!this._hasToolbar}>
27370
+ <slot @slotchange=${this.#onToolbarSlotChange}></slot>
27371
+ </div>
26798
27372
  </div>
26799
27373
  <div class="body">
26800
27374
  <ixfx-vertical-list
@@ -26845,12 +27419,31 @@ let FlatListElement = class FlatListElement extends LitElement {
26845
27419
  }
26846
27420
 
26847
27421
  .crumbs {
27422
+ display: flex;
27423
+ align-items: center;
27424
+ gap: var(--flat-list-toolbar-gap, var(--space-xs));
26848
27425
  flex: none;
26849
27426
  font-size: var(--flat-list-crumb-font-size, var(--text-s));
26850
27427
  padding: var(--flat-list-crumb-padding, var(--space-xs));
26851
27428
  border-bottom: 1px solid var(--flat-list-border, var(--list-border, var(--border)));
26852
27429
  }
26853
27430
 
27431
+ .crumbs ixfx-crumb-navigation {
27432
+ flex: 1 1 0;
27433
+ min-width: 0;
27434
+ }
27435
+
27436
+ .toolbar {
27437
+ display: flex;
27438
+ flex: none;
27439
+ align-items: center;
27440
+ gap: var(--flat-list-toolbar-gap, var(--space-xs));
27441
+ }
27442
+
27443
+ .toolbar[hidden] {
27444
+ display: none;
27445
+ }
27446
+
26854
27447
  .body {
26855
27448
  position: relative;
26856
27449
  flex: 1 1 auto;
@@ -26984,6 +27577,7 @@ __decorate([property({
26984
27577
  reflect: true,
26985
27578
  attribute: `data-focus-visible`
26986
27579
  })], FlatListElement.prototype, "_focusVisible", void 0);
27580
+ __decorate([state()], FlatListElement.prototype, "_hasToolbar", void 0);
26987
27581
  __decorate([state()], FlatListElement.prototype, "_rows", void 0);
26988
27582
  __decorate([state()], FlatListElement.prototype, "_loading", void 0);
26989
27583
  FlatListElement = __decorate([safeCustomElement(`ixfx-flat-list`)], FlatListElement);
@@ -27498,16 +28092,24 @@ function warnDuplicateKeys(keys) {
27498
28092
  }
27499
28093
  if (dupes.size > 0) console.warn(`ixfx-grid-list: duplicate item key(s) ${[...dupes].map((k) => `"${k}"`).join(`, `)} — the first occurrence wins for key-based operations.`);
27500
28094
  }
28095
+ /** Key → first effective index, built from `keys` (first occurrence wins on duplicates). */
28096
+ function indexByKey(keys) {
28097
+ const index = /* @__PURE__ */ new Map();
28098
+ keys.forEach((k, i) => {
28099
+ if (!index.has(k)) index.set(k, i);
28100
+ });
28101
+ return index;
28102
+ }
27501
28103
  var ElementGridListSource = class {
27502
28104
  #host;
27503
28105
  #items = [];
27504
- #slotNames = /* @__PURE__ */ new WeakMap();
27505
28106
  #syntheticKeys = /* @__PURE__ */ new WeakMap();
27506
28107
  #seq = 0;
27507
28108
  #predicate;
27508
28109
  #searchKeys;
27509
28110
  #effectiveIndices = [];
27510
28111
  #keysCache;
28112
+ #indexCache;
27511
28113
  #grouping;
27512
28114
  #groups = [];
27513
28115
  #groupByKey = /* @__PURE__ */ new Map();
@@ -27526,7 +28128,8 @@ var ElementGridListSource = class {
27526
28128
  return eff === void 0 ? `` : this.#itemKey(this.#items[eff]);
27527
28129
  }
27528
28130
  indexOfKey(key) {
27529
- return this.#effectiveIndices.findIndex((i) => this.#itemKey(this.#items[i]) === key);
28131
+ this.#indexCache ??= indexByKey(this.keys());
28132
+ return this.#indexCache.get(key) ?? -1;
27530
28133
  }
27531
28134
  keys() {
27532
28135
  this.#keysCache ??= this.#effectiveIndices.map((i) => this.#itemKey(this.#items[i]));
@@ -27549,11 +28152,11 @@ var ElementGridListSource = class {
27549
28152
  const eff = this.#effectiveIndices[index];
27550
28153
  return eff !== void 0 && !this.#items[eff].hasAttribute(`not-checked`);
27551
28154
  }
27552
- renderCellContent(index) {
27553
- const eff = this.#effectiveIndices[index];
27554
- if (eff === void 0) return void 0;
27555
- return html`<slot name=${this.slotNameFor(this.#items[eff])}></slot>`;
27556
- }
28155
+ /**
28156
+ * Element mode cells project their item through a per-cell `<slot>` that
28157
+ * `GridListElement` renders and assigns itself (see its `#applyCellSlots`).
28158
+ */
28159
+ renderCellContent(_index) {}
27557
28160
  iconAt(_index) {}
27558
28161
  iconColorAt(_index) {}
27559
28162
  textColorAt(_index) {}
@@ -27575,10 +28178,6 @@ var ElementGridListSource = class {
27575
28178
  }
27576
28179
  refresh() {
27577
28180
  this.#items = Array.from(this.#host.querySelectorAll(ITEM_SELECTOR$1)).filter((el) => !el.hidden);
27578
- for (const el of this.#items) {
27579
- const name = this.slotNameFor(el);
27580
- if (el.getAttribute(`slot`) !== name) el.setAttribute(`slot`, name);
27581
- }
27582
28181
  warnDuplicateKeys(this.#items.map((el) => this.#itemKey(el)));
27583
28182
  this.#recomputeEffective();
27584
28183
  }
@@ -27590,16 +28189,6 @@ var ElementGridListSource = class {
27590
28189
  rawElementAt(index) {
27591
28190
  return this.#items[index];
27592
28191
  }
27593
- /** Stable per-element `<slot>` name. */
27594
- slotNameFor(el) {
27595
- let name = this.#slotNames.get(el);
27596
- if (name === void 0) {
27597
- this.#seq += 1;
27598
- name = `gl-item-${this.#seq}`;
27599
- this.#slotNames.set(el, name);
27600
- }
27601
- return name;
27602
- }
27603
28192
  resolveKeyToElement(key) {
27604
28193
  if (isSyntheticKey(key)) return this.#items.find((el) => this.#syntheticKeys.get(el) === key);
27605
28194
  return this.#items.find((el) => keyForElement(el) === key);
@@ -27617,6 +28206,7 @@ var ElementGridListSource = class {
27617
28206
  }
27618
28207
  this.#applyGroupingTo(effective);
27619
28208
  this.#keysCache = void 0;
28209
+ this.#indexCache = void 0;
27620
28210
  }
27621
28211
  #applyGroupingTo(effective) {
27622
28212
  if (this.#grouping === void 0) {
@@ -27652,6 +28242,7 @@ var DataGridListSource = class {
27652
28242
  #searchKeys;
27653
28243
  #effectiveIndices = [];
27654
28244
  #keysCache;
28245
+ #indexCache;
27655
28246
  #grouping;
27656
28247
  #groups = [];
27657
28248
  #groupByKey = /* @__PURE__ */ new Map();
@@ -27672,7 +28263,8 @@ var DataGridListSource = class {
27672
28263
  return eff === void 0 ? `` : this.#adapter.key(this.#items[eff], eff);
27673
28264
  }
27674
28265
  indexOfKey(key) {
27675
- return this.#effectiveIndices.findIndex((i) => this.#adapter.key(this.#items[i], i) === key);
28266
+ this.#indexCache ??= indexByKey(this.keys());
28267
+ return this.#indexCache.get(key) ?? -1;
27676
28268
  }
27677
28269
  keys() {
27678
28270
  this.#keysCache ??= this.#effectiveIndices.map((i) => this.#adapter.key(this.#items[i], i));
@@ -27765,6 +28357,7 @@ var DataGridListSource = class {
27765
28357
  this.#groupByKey = grouped.groupByKey;
27766
28358
  }
27767
28359
  this.#keysCache = void 0;
28360
+ this.#indexCache = void 0;
27768
28361
  }
27769
28362
  };
27770
28363
  //#endregion
@@ -27791,6 +28384,15 @@ function sameKeys(a, b) {
27791
28384
  for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
27792
28385
  return true;
27793
28386
  }
28387
+ /**
28388
+ * Set `attr` on exactly the elements in `now`, removing it from those in
28389
+ * `previous` that no longer qualify. Returns the new marked set.
28390
+ */
28391
+ function syncMarker(attr, previous, now) {
28392
+ for (const el of previous) if (!now.has(el)) el.removeAttribute(attr);
28393
+ for (const el of now) if (!el.hasAttribute(attr)) el.setAttribute(attr, ``);
28394
+ return now;
28395
+ }
27794
28396
  let GridListElement = class GridListElement extends LitElement {
27795
28397
  constructor(..._args) {
27796
28398
  super(..._args);
@@ -27825,6 +28427,11 @@ let GridListElement = class GridListElement extends LitElement {
27825
28427
  this.#snapTimer = 0;
27826
28428
  this.#items = [];
27827
28429
  this.#itemWrap = `stop`;
28430
+ this.#cellIds = /* @__PURE__ */ new Map();
28431
+ this.#nextCellId = 0;
28432
+ this.#recycledKeys = /* @__PURE__ */ new Set();
28433
+ this.#windowSlots = [];
28434
+ this.#slottedItems = /* @__PURE__ */ new Map();
27828
28435
  this.#entering = /* @__PURE__ */ new Set();
27829
28436
  this.#reflowTimer = 0;
27830
28437
  this.#sourceElement = new ElementGridListSource(this);
@@ -27948,6 +28555,8 @@ let GridListElement = class GridListElement extends LitElement {
27948
28555
  }
27949
28556
  });
27950
28557
  this.#groupByChanged = false;
28558
+ this.#markedTickled = /* @__PURE__ */ new Set();
28559
+ this.#markedSelected = /* @__PURE__ */ new Set();
27951
28560
  this.#selectedSnapshot = /* @__PURE__ */ new Set();
27952
28561
  this.#onScroll = (e) => {
27953
28562
  const el = e.target;
@@ -28437,6 +29046,14 @@ let GridListElement = class GridListElement extends LitElement {
28437
29046
  .cell.entering {
28438
29047
  animation: ixfx-list-item-in var(--grid-list-item-move-duration, 200ms) ease both;
28439
29048
  }
29049
+ /* A recycled cell has just been handed from one item to another (see
29050
+ #assignCellIds); it must not animate from the previous item's state
29051
+ or position. */
29052
+ .cell.recycled,
29053
+ .cell.recycled > .thumb,
29054
+ .cell.recycled > .thumb::after {
29055
+ transition: none !important;
29056
+ }
28440
29057
  @media (prefers-reduced-motion: reduce) {
28441
29058
  :host([reflowing]) .group-header { transition: none; }
28442
29059
  :host([reflowing]) .cell { transition: background var(--transition), border-color var(--transition); }
@@ -28468,11 +29085,39 @@ let GridListElement = class GridListElement extends LitElement {
28468
29085
  #viewportRef;
28469
29086
  #resizeObserver;
28470
29087
  #mutationObserver;
29088
+ /**
29089
+ * Watches item (direct child) attributes that change the item set: `hidden`
29090
+ * drops an item from layout, `data-key` / `data-value` change its identity.
29091
+ * Separate from {@link #mutationObserver} because attribute observation of
29092
+ * children needs `subtree`, which must not apply to the child list.
29093
+ */
29094
+ #itemAttrObserver;
28471
29095
  #scrollRaf;
28472
29096
  /** Debounce handle for {@link snapToItem} — fires once scrolling settles. */
28473
29097
  #snapTimer;
28474
29098
  #items;
28475
29099
  #itemWrap;
29100
+ /**
29101
+ * Cell recycling. Each rendered key holds a stable cell id, and `repeat` is
29102
+ * keyed by that id rather than the item key, so a cell scrolled out of the
29103
+ * window hands its DOM to an item scrolling in instead of being destroyed and
29104
+ * recreated. Item-keyed `repeat` gets steadily slower in Chrome when every key
29105
+ * in the window is new (a scrollbar drag / fling), and recreating ~100 cells a
29106
+ * frame is costly in any case. See {@link #assignCellIds}.
29107
+ */
29108
+ #cellIds;
29109
+ #nextCellId;
29110
+ /** Keys whose cell was taken over from another item in the latest render. */
29111
+ #recycledKeys;
29112
+ /**
29113
+ * Element mode: each cell has a fixed `<slot name="gl-cell-<id>">`, and the
29114
+ * light-DOM item it shows is pointed at it via its `slot` attribute. Moving
29115
+ * that attribute is far cheaper than renaming slots, which makes the browser
29116
+ * re-match every light-DOM child. `#windowSlots` is the assignment the latest
29117
+ * render wants (applied in `updated()`); `#slottedItems` is what's applied.
29118
+ */
29119
+ #windowSlots;
29120
+ #slottedItems;
28476
29121
  /** Items added mid-list that are still playing their enter animation. */
28477
29122
  #entering;
28478
29123
  /** Timer that ends the `[reflowing]` window opened by a mid-list insertion. */
@@ -28660,9 +29305,15 @@ let GridListElement = class GridListElement extends LitElement {
28660
29305
  this.addEventListener(`pointermove`, this.#onHostPointerMove, { passive: true });
28661
29306
  this.addEventListener(`pointerleave`, this.#onHostPointerLeave);
28662
29307
  this.#mutationObserver ??= new MutationObserver((records) => this.#onChildrenChanged(records));
28663
- this.#mutationObserver.observe(this, {
28664
- childList: true,
28665
- attributeFilter: [`hidden`]
29308
+ this.#mutationObserver.observe(this, { childList: true });
29309
+ this.#itemAttrObserver ??= new MutationObserver((records) => this.#onItemAttributesChanged(records));
29310
+ this.#itemAttrObserver.observe(this, {
29311
+ subtree: true,
29312
+ attributeFilter: [
29313
+ `hidden`,
29314
+ `data-key`,
29315
+ `data-value`
29316
+ ]
28666
29317
  });
28667
29318
  this.#searchRegistry ??= createCommandRegistry();
28668
29319
  this.#ensureSearch();
@@ -28678,6 +29329,7 @@ let GridListElement = class GridListElement extends LitElement {
28678
29329
  this.#resizeObserver?.disconnect();
28679
29330
  this.#resizeObserver = void 0;
28680
29331
  this.#mutationObserver?.disconnect();
29332
+ this.#itemAttrObserver?.disconnect();
28681
29333
  this.#cancelScrollRaf();
28682
29334
  this.#cancelSnapTimer();
28683
29335
  this.#unregisterSearchCommands?.();
@@ -28689,6 +29341,10 @@ let GridListElement = class GridListElement extends LitElement {
28689
29341
  }
28690
29342
  if (this.#nativeDragging || this.#nativeDrag.isDragging) this.#clearNativeDragState();
28691
29343
  }
29344
+ /** Only attribute changes on items themselves matter, not on their content. */
29345
+ #onItemAttributesChanged(records) {
29346
+ if (records.some((r) => r.target.parentNode === this)) this.#onChildrenChanged([]);
29347
+ }
28692
29348
  #onChildrenChanged(records) {
28693
29349
  const previousLength = this.#items.length;
28694
29350
  this.#syncItems();
@@ -28705,7 +29361,7 @@ let GridListElement = class GridListElement extends LitElement {
28705
29361
  const tickledId = this.tickled.tickledId;
28706
29362
  if (tickledId && gone.includes(tickledId)) this.tickled.focus(void 0);
28707
29363
  }
28708
- this._syncItemStates();
29364
+ this._syncItemStates(true);
28709
29365
  this.#noteChildMutation(previousLength, records);
28710
29366
  this.requestUpdate();
28711
29367
  this.updateComplete.then(() => this.#emitViewportChange());
@@ -28777,6 +29433,11 @@ let GridListElement = class GridListElement extends LitElement {
28777
29433
  const w = entry.contentRect.width;
28778
29434
  const h = entry.contentRect.height;
28779
29435
  if (w <= 0 || h <= 0) return;
29436
+ if (w === this._viewportWidth && h === this._viewportHeight) continue;
29437
+ const horizontal = this.orientation === `row`;
29438
+ const prevOffset = horizontal ? this._scrollLeft : this._scrollTop;
29439
+ const prevExtent = horizontal ? this._viewportWidth : this._viewportHeight;
29440
+ if (prevExtent > 0 && prevOffset > 1) this.#captureCentreAnchor(prevOffset, prevExtent, this.itemSize, this.gap, this.captionStyle, this.captionHeight, this.orientation);
28780
29441
  this._viewportWidth = w;
28781
29442
  this._viewportHeight = h;
28782
29443
  }
@@ -28938,7 +29599,11 @@ let GridListElement = class GridListElement extends LitElement {
28938
29599
  }
28939
29600
  updated(changed) {
28940
29601
  super.updated(changed);
28941
- this.#syncItems();
29602
+ const pending = this.#mutationObserver?.takeRecords();
29603
+ const pendingAttrs = this.#itemAttrObserver?.takeRecords();
29604
+ if (pending !== void 0 && pending.length > 0) this.#onChildrenChanged(pending);
29605
+ else if (pendingAttrs !== void 0 && pendingAttrs.length > 0) this.#onItemAttributesChanged(pendingAttrs);
29606
+ if (this.#source instanceof ElementGridListSource) this.#applyCellSlots();
28942
29607
  if (changed.has(`selectionMode`) || changed.has(`interactionMode`)) {
28943
29608
  this.selection.clearAnchor();
28944
29609
  this.#syncAriaMultiselectable();
@@ -29290,13 +29955,36 @@ let GridListElement = class GridListElement extends LitElement {
29290
29955
  this._scrollTop = next;
29291
29956
  }
29292
29957
  }
29293
- _syncItemStates() {
29958
+ /** Light-DOM items currently carrying `data-tickled` / `data-selected`. */
29959
+ #markedTickled;
29960
+ #markedSelected;
29961
+ /**
29962
+ * Mirror cursor / selection onto the light-DOM items as `data-tickled` /
29963
+ * `data-selected`. Only the items whose state changed are touched, so a
29964
+ * cursor move costs O(selection), not O(items). `full` re-checks every item —
29965
+ * used when the child list changes, so markers set before the item was
29966
+ * ours (or left on a removed item) are corrected.
29967
+ */
29968
+ _syncItemStates(full = false) {
29294
29969
  const selected = this.selection.selectedKeys;
29295
- for (const el of this.#items) {
29296
- const key = keyForElement(el);
29297
- el.toggleAttribute(`data-tickled`, key !== void 0 && this.tickled.isTickled(key));
29298
- el.toggleAttribute(`data-selected`, key !== void 0 && selected.has(key));
29970
+ const tickledId = this.tickled.tickledId;
29971
+ const elementFor = (key) => {
29972
+ if (isSyntheticKey(key)) return void 0;
29973
+ const index = this.#source.indexOfKey(key);
29974
+ return index < 0 ? void 0 : this.#items[index];
29975
+ };
29976
+ const tickledEl = tickledId === void 0 ? void 0 : elementFor(tickledId);
29977
+ const selectedEls = /* @__PURE__ */ new Set();
29978
+ for (const key of selected) {
29979
+ const el = elementFor(key);
29980
+ if (el) selectedEls.add(el);
29299
29981
  }
29982
+ if (full) for (const el of this.#items) {
29983
+ this.#markedTickled.add(el);
29984
+ this.#markedSelected.add(el);
29985
+ }
29986
+ this.#markedTickled = syncMarker(`data-tickled`, this.#markedTickled, tickledEl ? /* @__PURE__ */ new Set([tickledEl]) : /* @__PURE__ */ new Set());
29987
+ this.#markedSelected = syncMarker(`data-selected`, this.#markedSelected, selectedEls);
29300
29988
  this.setAttribute(`data-input-mode`, this.tickled.inputMode);
29301
29989
  }
29302
29990
  #emitSelect(selected, previous) {
@@ -29366,8 +30054,57 @@ let GridListElement = class GridListElement extends LitElement {
29366
30054
  `;
29367
30055
  }
29368
30056
  #renderDataCells(win) {
30057
+ const slice = this.#windowSlice(win);
30058
+ return repeat(slice, (index) => this.#cellIds.get(this.#source.keyAt(index)), (index) => this.#renderDataCell(index, win));
30059
+ }
30060
+ #cellSlotName(key) {
30061
+ return `gl-cell-${this.#cellIds.get(key)}`;
30062
+ }
30063
+ /**
30064
+ * Element mode: point each rendered item's `slot` at its cell, and clear it on
30065
+ * items that left the window (or the DOM) so they project nowhere.
30066
+ */
30067
+ #applyCellSlots() {
30068
+ const next = /* @__PURE__ */ new Map();
30069
+ for (const [el, name] of this.#windowSlots) {
30070
+ next.set(el, name);
30071
+ if (el.getAttribute(`slot`) !== name) el.setAttribute(`slot`, name);
30072
+ }
30073
+ for (const el of this.#slottedItems.keys()) if (!next.has(el)) el.removeAttribute(`slot`);
30074
+ this.#slottedItems = next;
30075
+ }
30076
+ /** Effective indices in the rendered window, with cell ids assigned to their keys. */
30077
+ #windowSlice(win) {
29369
30078
  const slice = win.ranges.flatMap(([start, end]) => Array.from({ length: end - start }, (_, i) => start + i));
29370
- return repeat(slice, (index) => this.#source.keyAt(index), (index) => this.#renderDataCell(index, win));
30079
+ this.#assignCellIds(slice.map((index) => this.#source.keyAt(index)));
30080
+ return slice;
30081
+ }
30082
+ /**
30083
+ * Give every key in the window a cell id: keys already rendered keep theirs;
30084
+ * new keys take the ids freed by keys that left the window (recycling that
30085
+ * cell's DOM), and only mint a fresh id when none is free.
30086
+ */
30087
+ #assignCellIds(keys) {
30088
+ const inWindow = new Set(keys);
30089
+ const free = [];
30090
+ for (const [key, id] of this.#cellIds) if (!inWindow.has(key)) free.push(id);
30091
+ const next = /* @__PURE__ */ new Map();
30092
+ for (const key of keys) {
30093
+ const id = this.#cellIds.get(key);
30094
+ if (id !== void 0) next.set(key, id);
30095
+ }
30096
+ const recycled = /* @__PURE__ */ new Set();
30097
+ for (const key of keys) {
30098
+ if (next.has(key)) continue;
30099
+ const id = free.pop();
30100
+ if (id === void 0) next.set(key, this.#nextCellId++);
30101
+ else {
30102
+ next.set(key, id);
30103
+ recycled.add(key);
30104
+ }
30105
+ }
30106
+ this.#cellIds = next;
30107
+ this.#recycledKeys = recycled;
29371
30108
  }
29372
30109
  #renderDataCell(index, win) {
29373
30110
  const key = this.#source.keyAt(index);
@@ -29387,10 +30124,12 @@ let GridListElement = class GridListElement extends LitElement {
29387
30124
  const icon = appearanceAbsent(rawIcon) ? void 0 : rawIcon;
29388
30125
  return html`
29389
30126
  <div
29390
- class="cell${selected ? ` selected` : ``}${tickled ? ` tickled` : ``}${marquee ? ` marquee` : ``}${this.#nativeDragging && key === this.#dragOriginKey ? ` dragging-origin` : ``}"
30127
+ class="cell${selected ? ` selected` : ``}${tickled ? ` tickled` : ``}${marquee ? ` marquee` : ``}${this.#recycledKeys.has(key) ? ` recycled` : ``}${this.#nativeDragging && key === this.#dragOriginKey ? ` dragging-origin` : ``}"
29391
30128
  part="item${selected ? ` selected` : ``}${tickled ? ` tickled` : ``}"
29392
30129
  role="option"
29393
30130
  aria-selected=${selected ? `true` : `false`}
30131
+ aria-setsize=${this.#source.count}
30132
+ aria-posinset=${index + 1}
29394
30133
  style="width:${this.itemSize}px;height:${cellHeight}px;transform:translate(${x}px,${y}px)"
29395
30134
  data-index=${index}
29396
30135
  data-key=${key}
@@ -29467,8 +30206,9 @@ let GridListElement = class GridListElement extends LitElement {
29467
30206
  current.startLoop?.();
29468
30207
  }
29469
30208
  #renderElementCells(win) {
29470
- const slice = win.ranges.flatMap(([start, end]) => Array.from({ length: end - start }, (_, i) => start + i));
29471
- return repeat(slice, (index) => this.#source.keyAt(index), (index) => this.#renderCell(this.#items[index], index, win));
30209
+ const slice = this.#windowSlice(win);
30210
+ this.#windowSlots = slice.map((index) => [this.#items[index], this.#cellSlotName(this.#source.keyAt(index))]);
30211
+ return repeat(slice, (index) => this.#cellIds.get(this.#source.keyAt(index)), (index) => this.#renderCell(this.#items[index], index, win));
29472
30212
  }
29473
30213
  #renderHeaders(win) {
29474
30214
  if (win.headers.length === 0) return nothing;
@@ -29477,6 +30217,7 @@ let GridListElement = class GridListElement extends LitElement {
29477
30217
  <ixfx-list-group-header
29478
30218
  class="group-header"
29479
30219
  part="group-header"
30220
+ role="presentation"
29480
30221
  data-group-key=${h.key}
29481
30222
  label=${h.label}
29482
30223
  count=${h.count}
@@ -29505,10 +30246,12 @@ let GridListElement = class GridListElement extends LitElement {
29505
30246
  const captionCss = separate ? `height:${Math.max(0, this.captionHeight - 4)}px` : style === `under` ? `height:${this.captionHeight}px` : ``;
29506
30247
  return html`
29507
30248
  <div
29508
- class="cell${selected ? ` selected` : ``}${tickled ? ` tickled` : ``}${entering ? ` entering` : ``}${marquee ? ` marquee` : ``}${this.#nativeDragging && key === this.#dragOriginKey ? ` dragging-origin` : ``}"
30249
+ class="cell${selected ? ` selected` : ``}${tickled ? ` tickled` : ``}${entering ? ` entering` : ``}${marquee ? ` marquee` : ``}${this.#recycledKeys.has(key) ? ` recycled` : ``}${this.#nativeDragging && key === this.#dragOriginKey ? ` dragging-origin` : ``}"
29509
30250
  part="item${selected ? ` selected` : ``}${tickled ? ` tickled` : ``}"
29510
30251
  role="option"
29511
30252
  aria-selected=${selected ? `true` : `false`}
30253
+ aria-setsize=${this.#source.count}
30254
+ aria-posinset=${index + 1}
29512
30255
  style="width:${this.itemSize}px;height:${cellHeight}px;transform:translate(${x}px,${y}px)"
29513
30256
  data-index=${index}
29514
30257
  data-key=${key}
@@ -29521,7 +30264,7 @@ let GridListElement = class GridListElement extends LitElement {
29521
30264
  @pointerenter=${() => this.#handleCellPointerEnter(key)}
29522
30265
  >
29523
30266
  <div class="thumb" part="thumb" style=${thumbStyle}>
29524
- ${this.#source.renderCellContent(index)}
30267
+ <slot name=${this.#cellSlotName(key)}></slot>
29525
30268
  </div>
29526
30269
  ${showCaption ? this.#renderCaption(caption, captionCss, tickled) : ``}
29527
30270
  </div>
@@ -30373,15 +31116,10 @@ var TreeDropController = class {
30373
31116
  if (clientY > rows[rows.length - 1].rect.bottom) return rows[rows.length - 1];
30374
31117
  return rows.find((r) => clientY >= r.rect.top && clientY <= r.rect.bottom) ?? rows[rows.length - 1];
30375
31118
  }
30376
- #parentRow(rows, index) {
30377
- const target = rows[index];
30378
- for (let i = index - 1; i >= 0; i--) if (rows[i].depth === target.depth - 1) return rows[i];
30379
- }
30380
31119
  #resolveIntent(event, rows, source) {
30381
31120
  if (rows.length === 0) return void 0;
30382
31121
  const row = this.#rowAtPointer(rows, event.clientY);
30383
31122
  if (!row) return void 0;
30384
- const rowIndex = rows.indexOf(row);
30385
31123
  const f = clamp$4((event.clientY - row.rect.top) / row.rect.height, 0, 1);
30386
31124
  let position = f < .25 ? `before` : f > .75 ? `after` : `inside`;
30387
31125
  const isLeaf = row.node.item.isLeaf === true;
@@ -30404,18 +31142,21 @@ var TreeDropController = class {
30404
31142
  const indent = this.#config.getIndentPx() || 1;
30405
31143
  const treeLeft = rows[0].rect.left;
30406
31144
  const effDepth = clamp$4(Math.round((event.clientX - treeLeft) / indent), 0, row.depth);
30407
- let anchorIndex = rowIndex;
30408
- for (let up = row.depth - effDepth; up > 0; up--) {
30409
- const parent = this.#parentRow(rows, anchorIndex);
30410
- if (!parent) break;
30411
- anchorIndex = rows.indexOf(parent);
30412
- }
30413
- const anchor = rows[anchorIndex];
30414
- referenceKey = anchor.key;
31145
+ let anchor = {
31146
+ node: row.node,
31147
+ depth: row.depth,
31148
+ parentKey: this.#config.getRowInfo(row.key)?.parentKey
31149
+ };
31150
+ referenceKey = row.key;
31151
+ for (let up = row.depth - effDepth; up > 0 && anchor.parentKey !== void 0; up--) {
31152
+ const parentInfo = this.#config.getRowInfo(anchor.parentKey);
31153
+ if (!parentInfo) break;
31154
+ referenceKey = anchor.parentKey;
31155
+ anchor = parentInfo;
31156
+ }
30415
31157
  depth = anchor.depth;
30416
- const parent = this.#parentRow(rows, anchorIndex);
30417
- parentKey = parent?.key;
30418
- const refIdx = (parent ? parent.node.children ?? [] : rows.filter((r) => r.depth === 0).map((r) => r.node)).findIndex((n) => n.item.key === anchor.key);
31158
+ parentKey = anchor.parentKey;
31159
+ const refIdx = this.#config.getChildren(parentKey).findIndex((n) => n.item.key === referenceKey);
30419
31160
  index = (refIdx < 0 ? 0 : refIdx) + (position === `after` ? 1 : 0);
30420
31161
  }
30421
31162
  if (source === `internal`) {
@@ -30490,9 +31231,333 @@ var TreeDropController = class {
30490
31231
  }
30491
31232
  };
30492
31233
  //#endregion
31234
+ //#region src/tree/tree-flat.ts
31235
+ /**
31236
+ * Flatten the visible rows under `root` (root itself excluded).
31237
+ *
31238
+ * Iterative implementation using an explicit stack to avoid recursion depth
31239
+ * limits with deep trees. `posInSet` / `setSize` count only shown siblings.
31240
+ *
31241
+ * @param root - The root node (never included in output; its children are depth 0)
31242
+ * @param options - Callbacks determining expansion and visibility
31243
+ * @returns FlatTree with rows in display order and a key→index map
31244
+ */
31245
+ function flattenTree(root, options) {
31246
+ const rows = [];
31247
+ const indexByKey = /* @__PURE__ */ new Map();
31248
+ const stack = [];
31249
+ const pushChildren = (children, depth, parentKey) => {
31250
+ if (!children || children.length === 0) return;
31251
+ const shown = children.filter((c) => options.isShown(c, depth));
31252
+ for (let i = shown.length - 1; i >= 0; i--) stack.push({
31253
+ node: shown[i],
31254
+ depth,
31255
+ parentKey,
31256
+ posInSet: i + 1,
31257
+ setSize: shown.length
31258
+ });
31259
+ };
31260
+ pushChildren(root.children, 0, void 0);
31261
+ while (stack.length > 0) {
31262
+ const { node, depth, parentKey, posInSet, setSize } = stack.pop();
31263
+ indexByKey.set(node.item.key, rows.length);
31264
+ rows.push({
31265
+ node,
31266
+ depth,
31267
+ parentKey,
31268
+ posInSet,
31269
+ setSize
31270
+ });
31271
+ if (node.children && node.children.length > 0 && options.isExpanded(node, depth)) pushChildren(node.children, depth + 1, node.item.key);
31272
+ }
31273
+ return {
31274
+ rows,
31275
+ indexByKey
31276
+ };
31277
+ }
31278
+ /**
31279
+ * Build a whole-tree index over the currently loaded nodes.
31280
+ * This walks the entire loaded tree once and is cached on structure version.
31281
+ *
31282
+ * @param root - The root node
31283
+ * @returns TreeIndex with parent map and icon depth set
31284
+ */
31285
+ function indexTree(root) {
31286
+ const parentByKey = /* @__PURE__ */ new Map();
31287
+ const depthsWithIcons = /* @__PURE__ */ new Set();
31288
+ const stack = [];
31289
+ if (root.children) for (const child of root.children) {
31290
+ parentByKey.set(child.item.key, root.item.key);
31291
+ stack.push({
31292
+ node: child,
31293
+ depth: 0,
31294
+ parentKey: root.item.key
31295
+ });
31296
+ }
31297
+ while (stack.length > 0) {
31298
+ const { node, depth, parentKey } = stack.pop();
31299
+ parentByKey.set(node.item.key, parentKey);
31300
+ if (node.item.icon) depthsWithIcons.add(depth);
31301
+ if (node.children) for (let i = node.children.length - 1; i >= 0; i--) {
31302
+ const child = node.children[i];
31303
+ stack.push({
31304
+ node: child,
31305
+ depth: depth + 1,
31306
+ parentKey: node.item.key
31307
+ });
31308
+ }
31309
+ }
31310
+ return {
31311
+ parentByKey,
31312
+ depthsWithIcons
31313
+ };
31314
+ }
31315
+ /**
31316
+ * Compute which nodes match a filter predicate.
31317
+ * Returns the set of keys for nodes that match OR have a matching descendant.
31318
+ *
31319
+ * One iterative post-order pass; every node is visited (no short-circuiting).
31320
+ */
31321
+ function computeFilterMatches(root, predicate) {
31322
+ const matches = /* @__PURE__ */ new Set();
31323
+ if (!root.children) return matches;
31324
+ const stack = [];
31325
+ const enter = (node, depth) => {
31326
+ stack.push({
31327
+ node,
31328
+ depth,
31329
+ next: 0,
31330
+ matched: predicate(node, depth)
31331
+ });
31332
+ };
31333
+ for (const child of root.children) {
31334
+ enter(child, 0);
31335
+ while (stack.length > 0) {
31336
+ const frame = stack[stack.length - 1];
31337
+ const children = frame.node.children;
31338
+ if (children && frame.next < children.length) {
31339
+ enter(children[frame.next++], frame.depth + 1);
31340
+ continue;
31341
+ }
31342
+ stack.pop();
31343
+ if (frame.matched) {
31344
+ matches.add(frame.node.item.key);
31345
+ if (stack.length > 0) stack[stack.length - 1].matched = true;
31346
+ }
31347
+ }
31348
+ }
31349
+ return matches;
31350
+ }
31351
+ /**
31352
+ * Merge ghost rows back near their previous position.
31353
+ * Each ghost goes immediately before the first row that followed it last render
31354
+ * and still exists; otherwise at the end. Ghosts sharing an anchor keep their
31355
+ * previous relative order. O(rows + prevKeys).
31356
+ *
31357
+ * @param rows - Current flat rows (with node and depth)
31358
+ * @param prevKeys - Previous render's row keys in order
31359
+ * @param ghosts - Map of ghost keys to their row data
31360
+ * @returns Rows with ghosts woven in at their original relative positions
31361
+ */
31362
+ function weaveGhosts(rows, prevKeys, ghosts) {
31363
+ if (ghosts.size === 0) return [...rows];
31364
+ const present = /* @__PURE__ */ new Set();
31365
+ for (const r of rows) present.add(r.node.item.key);
31366
+ const END = Symbol(`end`);
31367
+ const before = /* @__PURE__ */ new Map();
31368
+ let nextSurvivor = END;
31369
+ for (let i = prevKeys.length - 1; i >= 0; i--) {
31370
+ const key = prevKeys[i];
31371
+ if (present.has(key)) {
31372
+ nextSurvivor = key;
31373
+ continue;
31374
+ }
31375
+ const ghost = ghosts.get(key);
31376
+ if (!ghost) continue;
31377
+ const list = before.get(nextSurvivor);
31378
+ if (list) list.push(ghost);
31379
+ else before.set(nextSurvivor, [ghost]);
31380
+ }
31381
+ const result = [];
31382
+ for (const row of rows) {
31383
+ const list = before.get(row.node.item.key);
31384
+ if (list) for (let i = list.length - 1; i >= 0; i--) result.push(list[i]);
31385
+ result.push(row);
31386
+ }
31387
+ const tail = before.get(END);
31388
+ if (tail) for (let i = tail.length - 1; i >= 0; i--) result.push(tail[i]);
31389
+ return result;
31390
+ }
31391
+ /**
31392
+ * Compute checkbox states for all nodes in a single iterative post-order pass.
31393
+ * A leaf (or a branch with no children) uses `selected.has(node)`; otherwise a
31394
+ * branch is checked if all children are checked, unchecked if all are
31395
+ * unchecked, else indeterminate.
31396
+ *
31397
+ * @param root - The root node
31398
+ * @param selected - Set of selected nodes
31399
+ * @returns Map from node key to checkbox state
31400
+ */
31401
+ function computeCheckboxStates(root, selected) {
31402
+ const states = /* @__PURE__ */ new Map();
31403
+ if (!root.children) return states;
31404
+ const stack = [];
31405
+ for (const top of root.children) {
31406
+ stack.push({
31407
+ node: top,
31408
+ next: 0,
31409
+ checked: 0,
31410
+ unchecked: 0
31411
+ });
31412
+ while (stack.length > 0) {
31413
+ const frame = stack[stack.length - 1];
31414
+ const { node } = frame;
31415
+ const children = node.children;
31416
+ const isLeafLike = node.item.isLeaf || !children?.length;
31417
+ if (!isLeafLike && frame.next < children.length) {
31418
+ stack.push({
31419
+ node: children[frame.next++],
31420
+ next: 0,
31421
+ checked: 0,
31422
+ unchecked: 0
31423
+ });
31424
+ continue;
31425
+ }
31426
+ stack.pop();
31427
+ let state;
31428
+ if (isLeafLike) state = selected.has(node) ? `checked` : `unchecked`;
31429
+ else if (frame.checked === children.length) state = `checked`;
31430
+ else if (frame.unchecked === children.length) state = `unchecked`;
31431
+ else state = `indeterminate`;
31432
+ states.set(node.item.key, state);
31433
+ if (stack.length > 0) {
31434
+ const parent = stack[stack.length - 1];
31435
+ if (state === `checked`) parent.checked++;
31436
+ else if (state === `unchecked`) parent.unchecked++;
31437
+ }
31438
+ }
31439
+ }
31440
+ return states;
31441
+ }
31442
+ function withKey(set, key) {
31443
+ if (set.has(key)) return set;
31444
+ const next = new Set(set);
31445
+ next.add(key);
31446
+ return next;
31447
+ }
31448
+ function withoutKey(set, key) {
31449
+ if (!set.has(key)) return set;
31450
+ const next = new Set(set);
31451
+ next.delete(key);
31452
+ return next;
31453
+ }
31454
+ /** Remove every key for which `pred` returns true. */
31455
+ function withoutKeys(set, pred) {
31456
+ let next;
31457
+ for (const key of set) if (pred(key)) {
31458
+ next ??= new Set(set);
31459
+ next.delete(key);
31460
+ }
31461
+ return next ?? set;
31462
+ }
31463
+ function treeStride(params) {
31464
+ return Math.max(1, params.rowHeight + params.gap);
31465
+ }
31466
+ /** Content-space top of the row box at `index`. */
31467
+ function treeRowTop(params, index) {
31468
+ return params.listOffset + index * treeStride(params) + params.gap;
31469
+ }
31470
+ function treeRowBottom(params, index) {
31471
+ return treeRowTop(params, index) + params.rowHeight;
31472
+ }
31473
+ /** The window of rows to render for a given scroll offset. Always `0 <= start <= end <= count`. */
31474
+ function computeTreeWindow(params, scrollTop, overscan = 5) {
31475
+ const { count } = params;
31476
+ if (count <= 0) return {
31477
+ start: 0,
31478
+ end: 0
31479
+ };
31480
+ const stride = treeStride(params);
31481
+ const top = Math.max(0, scrollTop - params.listOffset);
31482
+ const firstVisible = Math.floor(top / stride);
31483
+ const lastVisible = Math.ceil((top + params.viewportHeight) / stride);
31484
+ const start = Math.min(count, Math.max(0, firstVisible - overscan));
31485
+ return {
31486
+ start,
31487
+ end: Math.min(count, Math.max(start, lastVisible + overscan))
31488
+ };
31489
+ }
31490
+ /**
31491
+ * The scroll offset that brings row `index` fully into view with the least
31492
+ * movement, leaving `topInset` px at the top clear (the sticky-header overlay).
31493
+ * Returns `scrollTop` unchanged when the row is already visible.
31494
+ */
31495
+ function treeScrollTopToReveal(params, index, scrollTop, topInset = 0) {
31496
+ if (index < 0 || index >= params.count) return scrollTop;
31497
+ const top = treeRowTop(params, index);
31498
+ const bottom = top + params.rowHeight;
31499
+ if (top < scrollTop + topInset) return index === 0 ? 0 : Math.max(0, top - topInset);
31500
+ if (bottom > scrollTop + params.viewportHeight) return Math.max(0, bottom - params.viewportHeight);
31501
+ return scrollTop;
31502
+ }
31503
+ /** Rows that fit one viewport (PageUp / PageDown step), at least 1. */
31504
+ function treePageSize(params) {
31505
+ return Math.max(1, Math.floor(params.viewportHeight / treeStride(params)) - 1);
31506
+ }
31507
+ /** Arithmetic `RowGeometryReader` over flat-row indices (the windowed counterpart of reading `offsetTop`). */
31508
+ function treeRowReader(params) {
31509
+ return {
31510
+ count: params.count,
31511
+ topOf: (i) => treeRowTop(params, i),
31512
+ bottomOf: (i) => treeRowBottom(params, i)
31513
+ };
31514
+ }
31515
+ //#endregion
31516
+ //#region src/tree/tree-row-geometry.ts
31517
+ /**
31518
+ * First index whose bottom is greater than `y` (binary search; rows are
31519
+ * monotonic). Returns `reader.count` when no row qualifies.
31520
+ */
31521
+ function firstRowBelow(reader, y) {
31522
+ let lo = 0;
31523
+ let hi = reader.count;
31524
+ while (lo < hi) {
31525
+ const mid = lo + hi >>> 1;
31526
+ if (reader.bottomOf(mid) > y) hi = mid;
31527
+ else lo = mid + 1;
31528
+ }
31529
+ return lo;
31530
+ }
31531
+ /**
31532
+ * Inclusive index range of rows that overlap `[top, bottom]`, widened by
31533
+ * `margin` rows on each side. Returns `undefined` when there are no rows.
31534
+ */
31535
+ function rowRangeOverlapping(reader, top, bottom, margin = 0) {
31536
+ if (reader.count === 0) return void 0;
31537
+ const firstHit = Math.min(firstRowBelow(reader, top), reader.count - 1);
31538
+ let lo = firstHit;
31539
+ let hi = reader.count;
31540
+ while (lo < hi) {
31541
+ const mid = lo + hi >>> 1;
31542
+ if (reader.topOf(mid) >= bottom) hi = mid;
31543
+ else lo = mid + 1;
31544
+ }
31545
+ const lastHit = Math.max(firstHit, lo - 1);
31546
+ return {
31547
+ first: Math.max(0, firstHit - margin),
31548
+ last: Math.min(reader.count - 1, lastHit + margin)
31549
+ };
31550
+ }
31551
+ //#endregion
30493
31552
  //#region src/tree/tree-base.ts
30494
31553
  /** Above this many rows entering / leaving at once, skip the animation entirely. */
30495
31554
  const MAX_ANIMATED_ROWS = 50;
31555
+ const EMPTY_FLAT = {
31556
+ rows: [],
31557
+ indexByKey: /* @__PURE__ */ new Map()
31558
+ };
31559
+ /** Viewport height assumed until the container has been measured (or when it reads 0). */
31560
+ const FALLBACK_VIEWPORT_HEIGHT = 600;
30496
31561
  let TreeBaseElement = class TreeBaseElement extends LitElement {
30497
31562
  constructor(..._args) {
30498
31563
  super(..._args);
@@ -30506,11 +31571,33 @@ let TreeBaseElement = class TreeBaseElement extends LitElement {
30506
31571
  this.dndExpandHoverMs = 500;
30507
31572
  this.reorderable = true;
30508
31573
  this.stickyHeaders = true;
31574
+ this.virtualize = `auto`;
31575
+ this.virtualizeThreshold = 200;
30509
31576
  this.applyInternalDrop = true;
30510
31577
  this._expandedKeys = /* @__PURE__ */ new Set();
31578
+ this.#structureVersion = 0;
31579
+ this._selectionVersion = 0;
31580
+ this._scrollTop = 0;
31581
+ this.#metrics = {
31582
+ rowHeight: 30,
31583
+ gap: 1,
31584
+ listOffset: 0
31585
+ };
31586
+ this.#viewportH = 0;
31587
+ this.#virtualScrollRaf = 0;
31588
+ this.#winStart = 0;
31589
+ this.#winEnd = 0;
31590
+ this.#scrollOnlyUpdate = false;
31591
+ this.#onVirtualScroll = () => {
31592
+ if (this.#virtualScrollRaf !== 0) return;
31593
+ this.#virtualScrollRaf = requestAnimationFrame(() => {
31594
+ this.#virtualScrollRaf = 0;
31595
+ this.#syncWindowToScroll();
31596
+ });
31597
+ };
30511
31598
  this.tickled = new TickledController(this, {
30512
31599
  getItems: () => {
30513
- return this.#getAllNodes().map(({ node }) => ({
31600
+ return this.#getFlat().rows.map(({ node }) => ({
30514
31601
  id: node.item.key,
30515
31602
  enabled: true
30516
31603
  }));
@@ -30527,6 +31614,8 @@ let TreeBaseElement = class TreeBaseElement extends LitElement {
30527
31614
  }) });
30528
31615
  this.#config = { indent: 20 };
30529
31616
  this.#renderedKeys = [];
31617
+ this.#renderedRowKeys = [];
31618
+ this.#renderedRowIndex = /* @__PURE__ */ new Map();
30530
31619
  this.#lastNodeByKey = /* @__PURE__ */ new Map();
30531
31620
  this.#enteringKeys = /* @__PURE__ */ new Set();
30532
31621
  this.#leaving = /* @__PURE__ */ new Map();
@@ -30550,9 +31639,10 @@ let TreeBaseElement = class TreeBaseElement extends LitElement {
30550
31639
  this.#stickyDirty = true;
30551
31640
  this.#stickyRaf = 0;
30552
31641
  this.#stickyScrollBound = false;
31642
+ this.#revealToken = 0;
30553
31643
  this.#onKeyDown = (event) => {
30554
31644
  if (this.search.isOpen) return;
30555
- const nodes = this.#getAllNodes();
31645
+ const nodes = this.#getFlat().rows;
30556
31646
  if (nodes.length === 0) return;
30557
31647
  if ((event.ctrlKey || event.metaKey) && event.key === `a`) {
30558
31648
  event.preventDefault();
@@ -30564,29 +31654,41 @@ let TreeBaseElement = class TreeBaseElement extends LitElement {
30564
31654
  `ArrowUp`,
30565
31655
  `ArrowLeft`,
30566
31656
  `ArrowRight`,
31657
+ `Home`,
31658
+ `End`,
31659
+ `PageUp`,
31660
+ `PageDown`,
30567
31661
  `Enter`,
30568
31662
  ` `
30569
31663
  ].includes(event.key)) this.tickled.setInputMode(`keyboard`);
30570
31664
  const currentIdx = this.#getFocusedIndex();
30571
31665
  switch (event.key) {
30572
- case `ArrowDown`: {
31666
+ case `ArrowDown`:
30573
31667
  event.preventDefault();
30574
- const nextIdx = Math.min(currentIdx + 1, nodes.length - 1);
30575
- if (this.interactionMode === `implicit`) this.select(nodes[nextIdx].node);
30576
- this.#setFocusedIndex(nextIdx);
30577
- if (event.shiftKey && (this.interactionMode === `standard` || this.interactionMode === `vscode`)) this.#extendRangeSelection(nodes, currentIdx, nextIdx);
30578
- else if (this.interactionMode === `vscode`) this.#anchor = void 0;
30579
- this.requestUpdate();
31668
+ this.#navigateToIndex(nodes, currentIdx, Math.min(currentIdx + 1, nodes.length - 1), event.shiftKey);
31669
+ break;
31670
+ case `ArrowUp`:
31671
+ event.preventDefault();
31672
+ this.#navigateToIndex(nodes, currentIdx, Math.max(currentIdx - 1, 0), event.shiftKey);
31673
+ break;
31674
+ case `Home`:
31675
+ event.preventDefault();
31676
+ this.#navigateToIndex(nodes, currentIdx, 0, event.shiftKey);
31677
+ break;
31678
+ case `End`:
31679
+ event.preventDefault();
31680
+ this.#navigateToIndex(nodes, currentIdx, nodes.length - 1, event.shiftKey);
31681
+ break;
31682
+ case `PageDown`: {
31683
+ event.preventDefault();
31684
+ const page = this.#pageSize();
31685
+ this.#navigateToIndex(nodes, currentIdx, Math.min(currentIdx + page, nodes.length - 1), event.shiftKey);
30580
31686
  break;
30581
31687
  }
30582
- case `ArrowUp`: {
31688
+ case `PageUp`: {
30583
31689
  event.preventDefault();
30584
- const prevIdx = Math.max(currentIdx - 1, 0);
30585
- if (this.interactionMode === `implicit`) this.select(nodes[prevIdx].node);
30586
- this.#setFocusedIndex(prevIdx);
30587
- if (event.shiftKey && (this.interactionMode === `standard` || this.interactionMode === `vscode`)) this.#extendRangeSelection(nodes, currentIdx, prevIdx);
30588
- else if (this.interactionMode === `vscode`) this.#anchor = void 0;
30589
- this.requestUpdate();
31690
+ const page = this.#pageSize();
31691
+ this.#navigateToIndex(nodes, currentIdx, Math.max(currentIdx - page, 0), event.shiftKey);
30590
31692
  break;
30591
31693
  }
30592
31694
  case `ArrowRight`: {
@@ -30603,7 +31705,7 @@ let TreeBaseElement = class TreeBaseElement extends LitElement {
30603
31705
  event.preventDefault();
30604
31706
  const current = nodes[currentIdx];
30605
31707
  if (current && this._expandedKeys.has(current.node.item.key)) {
30606
- this._expandedKeys.delete(current.node.item.key);
31708
+ this.#setExpanded(withoutKey(this._expandedKeys, current.node.item.key));
30607
31709
  this.dispatchEvent(new CustomEvent(`collapse`, {
30608
31710
  detail: {
30609
31711
  node: current.node,
@@ -30657,7 +31759,9 @@ let TreeBaseElement = class TreeBaseElement extends LitElement {
30657
31759
  if (!li) return;
30658
31760
  const key = li.dataset.key;
30659
31761
  if (!key) return;
30660
- const node = this.#getAllNodes().find((n) => n.node.item.key === key);
31762
+ const flat = this.#getFlat();
31763
+ const idx = flat.indexByKey.get(key);
31764
+ const node = idx === void 0 ? void 0 : flat.rows[idx];
30661
31765
  if (!node) return;
30662
31766
  const detail = {
30663
31767
  node: node.node,
@@ -30678,6 +31782,206 @@ let TreeBaseElement = class TreeBaseElement extends LitElement {
30678
31782
  get selectedNodes() {
30679
31783
  return this._selectedNodes;
30680
31784
  }
31785
+ #setExpanded(next) {
31786
+ if (next === this._expandedKeys) return;
31787
+ this._expandedKeys = next;
31788
+ this.#flat = void 0;
31789
+ this.#stickyDirty = true;
31790
+ }
31791
+ #flat;
31792
+ #flatRoot;
31793
+ #flatFilter;
31794
+ #structureVersion;
31795
+ #treeIndexCache;
31796
+ #filterCache;
31797
+ /**
31798
+ * Call after editing `node.children` / `node.item` in place (outside
31799
+ * `TreeDataModel`). Drops the cached whole-tree indexes and re-renders.
31800
+ */
31801
+ invalidateStructure() {
31802
+ this.#structureVersion++;
31803
+ this.#flat = void 0;
31804
+ this.requestUpdate();
31805
+ }
31806
+ /** Visible rows in display order (cached for the current update). */
31807
+ #getFlat() {
31808
+ if (!this.root) return EMPTY_FLAT;
31809
+ if (this.#flat && this.#flatRoot === this.root && this.#flatFilter === this.filterPredicate) return this.#flat;
31810
+ const filter = this.#getFilter();
31811
+ this.#flat = flattenTree(this.root, {
31812
+ isExpanded: (node, depth) => this._isExpanded(node, depth),
31813
+ isShown: filter ? (node) => filter.matches.has(node.item.key) : () => true
31814
+ });
31815
+ this.#flatRoot = this.root;
31816
+ this.#flatFilter = this.filterPredicate;
31817
+ return this.#flat;
31818
+ }
31819
+ /** Whole-loaded-tree index (parents, icon depths); cached on `(root, structureVersion)`. */
31820
+ #getTreeIndex() {
31821
+ const root = this.root;
31822
+ if (!root) return {
31823
+ parentByKey: /* @__PURE__ */ new Map(),
31824
+ depthsWithIcons: /* @__PURE__ */ new Set()
31825
+ };
31826
+ const cached = this.#treeIndexCache;
31827
+ if (cached && cached.root === root && cached.version === this.#structureVersion) return cached.index;
31828
+ const index = indexTree(root);
31829
+ this.#treeIndexCache = {
31830
+ root,
31831
+ version: this.#structureVersion,
31832
+ index
31833
+ };
31834
+ return index;
31835
+ }
31836
+ /** Row height / gap / list offset as last measured from CSS + layout. */
31837
+ #metrics;
31838
+ /** Last non-zero container height (a hidden dock tab reads 0). */
31839
+ #viewportH;
31840
+ #virtualScrollRaf;
31841
+ #virtualScrollContainer;
31842
+ #resizeObserver;
31843
+ #winStart;
31844
+ #winEnd;
31845
+ /** Set in `willUpdate` when only the scroll offset changed; consumed in `updated`. */
31846
+ #scrollOnlyUpdate;
31847
+ /** True when the current visible row count should be windowed. */
31848
+ #virtualActive() {
31849
+ if (this.virtualize === `off`) return false;
31850
+ if (this.virtualize === `on`) return true;
31851
+ return this.#getFlat().rows.length > this.virtualizeThreshold;
31852
+ }
31853
+ #layoutParams(count) {
31854
+ return {
31855
+ count,
31856
+ rowHeight: this.#metrics.rowHeight,
31857
+ gap: this.#metrics.gap,
31858
+ listOffset: this.#metrics.listOffset,
31859
+ viewportHeight: this.#viewportH > 0 ? this.#viewportH : FALLBACK_VIEWPORT_HEIGHT
31860
+ };
31861
+ }
31862
+ /**
31863
+ * Re-read the CSS metrics and viewport height. Returns true when anything
31864
+ * that changes row positions moved (the caller re-renders).
31865
+ */
31866
+ #measureMetrics() {
31867
+ const list = this.renderRoot.querySelector(`ul.tree-list`);
31868
+ const sample = list?.querySelector(`li.item:not(.leaving)`);
31869
+ const measuredHeight = sample?.getBoundingClientRect().height ?? 0;
31870
+ const rowHeight = measuredHeight > 0 ? measuredHeight : this.#getItemHeight();
31871
+ const measuredGap = sample ? Number.parseFloat(getComputedStyle(sample).marginTop) : NaN;
31872
+ const parsedGap = Number.isNaN(measuredGap) ? Number.parseFloat(getComputedStyle(this).getPropertyValue(`--tree-item-gap`)) : measuredGap;
31873
+ const gap = Number.isNaN(parsedGap) ? 1 : parsedGap;
31874
+ let listOffset = this.#metrics.listOffset;
31875
+ if (list) {
31876
+ const padTop = Number.parseFloat(getComputedStyle(list).paddingTop);
31877
+ listOffset = list.offsetTop + (Number.isNaN(padTop) ? 0 : padTop);
31878
+ }
31879
+ const height = this.#container()?.clientHeight ?? 0;
31880
+ const heightChanged = height > 0 && height !== this.#viewportH;
31881
+ if (height > 0) this.#viewportH = height;
31882
+ const prev = this.#metrics;
31883
+ const changed = prev.rowHeight !== rowHeight || prev.gap !== gap || prev.listOffset !== listOffset;
31884
+ this.#metrics = {
31885
+ rowHeight,
31886
+ gap,
31887
+ listOffset
31888
+ };
31889
+ return changed || heightChanged;
31890
+ }
31891
+ /** The window for the current scroll offset. */
31892
+ #currentWindow(count) {
31893
+ return computeTreeWindow(this.#layoutParams(count), this._scrollTop);
31894
+ }
31895
+ /** Pull the container's real scroll offset into state (programmatic and clamped scrolls included). */
31896
+ #syncScrollTopFromContainer() {
31897
+ const top = this.#container()?.scrollTop;
31898
+ if (top !== void 0 && top !== this._scrollTop) this._scrollTop = top;
31899
+ }
31900
+ #onVirtualScroll;
31901
+ /** Re-render only when the scroll has moved the window; scrolling inside it is free. */
31902
+ #syncWindowToScroll() {
31903
+ const container = this.#container();
31904
+ if (!container || !this.root || !this.#virtualActive()) return;
31905
+ const top = container.scrollTop;
31906
+ if (top === this._scrollTop) return;
31907
+ const next = computeTreeWindow(this.#layoutParams(this.#getFlat().rows.length), top);
31908
+ if (next.start !== this.#winStart || next.end !== this.#winEnd) this._scrollTop = top;
31909
+ }
31910
+ /** Bind the window's scroll listener and resize observer to the container once it exists. */
31911
+ #syncVirtualObservers() {
31912
+ const container = this.#container();
31913
+ if (container === this.#virtualScrollContainer) return;
31914
+ this.#virtualScrollContainer?.removeEventListener(`scroll`, this.#onVirtualScroll);
31915
+ this.#resizeObserver?.disconnect();
31916
+ this.#resizeObserver = void 0;
31917
+ this.#virtualScrollContainer = container;
31918
+ if (!container) return;
31919
+ container.addEventListener(`scroll`, this.#onVirtualScroll, { passive: true });
31920
+ if (typeof ResizeObserver !== `undefined`) {
31921
+ this.#resizeObserver = new ResizeObserver((entries) => {
31922
+ const height = entries.at(-1)?.contentRect.height ?? 0;
31923
+ if (height > 0 && height !== this.#viewportH && this.#virtualActive()) this.requestUpdate();
31924
+ });
31925
+ this.#resizeObserver.observe(container);
31926
+ }
31927
+ }
31928
+ #teardownVirtualObservers() {
31929
+ this.#virtualScrollContainer?.removeEventListener(`scroll`, this.#onVirtualScroll);
31930
+ this.#virtualScrollContainer = void 0;
31931
+ this.#resizeObserver?.disconnect();
31932
+ this.#resizeObserver = void 0;
31933
+ if (this.#virtualScrollRaf !== 0) {
31934
+ cancelAnimationFrame(this.#virtualScrollRaf);
31935
+ this.#virtualScrollRaf = 0;
31936
+ }
31937
+ }
31938
+ /**
31939
+ * Scroll flat row `index` into view (below the sticky overlay). Writes the
31940
+ * container's `scrollTop` and `_scrollTop` together, so the very next render
31941
+ * already contains the target row — no frame where it is missing.
31942
+ */
31943
+ #revealIndex(index) {
31944
+ const container = this.#container();
31945
+ if (!container) return;
31946
+ const params = this.#layoutParams(this.#getFlat().rows.length);
31947
+ const inset = this.renderRoot.querySelector(`.sticky-headers`)?.getBoundingClientRect().height ?? 0;
31948
+ const next = treeScrollTopToReveal(params, index, container.scrollTop, inset);
31949
+ if (next === container.scrollTop && next === this._scrollTop) return;
31950
+ container.scrollTop = next;
31951
+ this._scrollTop = next;
31952
+ }
31953
+ /** Filter match sets, or `undefined` when no filter is active. */
31954
+ #getFilter() {
31955
+ const predicate = this.filterPredicate;
31956
+ const root = this.root;
31957
+ if (!predicate || !root) return void 0;
31958
+ const cached = this.#filterCache;
31959
+ if (cached && cached.predicate === predicate && cached.root === root && cached.version === this.#structureVersion) return cached;
31960
+ const matches = computeFilterMatches(root, predicate);
31961
+ const { parentByKey } = this.#getTreeIndex();
31962
+ const autoExpanded = /* @__PURE__ */ new Set();
31963
+ for (const key of matches) {
31964
+ const parent = parentByKey.get(key);
31965
+ if (parent !== void 0) autoExpanded.add(parent);
31966
+ }
31967
+ const next = {
31968
+ predicate,
31969
+ root,
31970
+ version: this.#structureVersion,
31971
+ matches,
31972
+ autoExpanded
31973
+ };
31974
+ this.#filterCache = next;
31975
+ return next;
31976
+ }
31977
+ /** Structure version for cache keys in subclasses (bumped on root / model change and lazy loads). */
31978
+ get _structureVersion() {
31979
+ return this.#structureVersion;
31980
+ }
31981
+ /** Depths at which any loaded node carries an icon (stable across expansion). */
31982
+ get _depthsWithIcons() {
31983
+ return this.#getTreeIndex().depthsWithIcons;
31984
+ }
30681
31985
  #unregisterSearchCommands;
30682
31986
  #effectiveSearchModes() {
30683
31987
  const raw = this.searchModes?.trim();
@@ -30698,6 +32002,9 @@ let TreeBaseElement = class TreeBaseElement extends LitElement {
30698
32002
  #ownedRegistry;
30699
32003
  #config;
30700
32004
  #renderedKeys;
32005
+ /** Keys in the exact DOM order of the last render (ghost rows woven in). */
32006
+ #renderedRowKeys;
32007
+ #renderedRowIndex;
30701
32008
  #lastNodeByKey;
30702
32009
  #enteringKeys;
30703
32010
  #leaving;
@@ -30732,73 +32039,65 @@ let TreeBaseElement = class TreeBaseElement extends LitElement {
30732
32039
  }
30733
32040
  }
30734
32041
  #onStickyScroll;
30735
- /** Real (non-sticky) row element for `key`, or `null`. */
30736
- #stickyRowEl(key) {
30737
- return this.renderRoot.querySelector(`ul.tree-list li[data-key="${CSS.escape(key)}"]`);
32042
+ /**
32043
+ * Row geometry over the rendered `<li>` elements (ghost rows included), in
32044
+ * container content coordinates. `#container` is `position: relative`, so
32045
+ * `offsetTop` is scroll-independent. Reads only — no layout writes.
32046
+ */
32047
+ #rowReader(rows) {
32048
+ return {
32049
+ count: rows.length,
32050
+ topOf: (i) => rows[i].offsetTop,
32051
+ bottomOf: (i) => rows[i].offsetTop + rows[i].offsetHeight
32052
+ };
30738
32053
  }
30739
32054
  /**
30740
32055
  * Recompute which ancestors stick to the top: the ancestors of the first
30741
32056
  * row whose bottom clears the container top, minus any ancestor that is
30742
32057
  * itself still visible below the overlay. Synchronous — reads current
30743
- * geometry, renders nothing, expands nothing.
32058
+ * geometry, renders nothing, expands nothing. O(log N) offset reads + O(depth).
30744
32059
  */
30745
32060
  #updateStickyHeaders() {
30746
32061
  this.#stickyDirty = false;
30747
32062
  const empty = /* @__PURE__ */ new Map();
30748
- if (!this.stickyHeaders || !this.root) {
30749
- this.#setStickyKeys([], empty);
30750
- return;
30751
- }
32063
+ const clear = () => this.#setStickyKeys([], empty);
32064
+ if (!this.stickyHeaders || !this.root) return clear();
30752
32065
  const container = this.#container();
30753
- if (!container) {
30754
- this.#setStickyKeys([], empty);
30755
- return;
30756
- }
30757
- const nodes = this.#getAllNodes();
30758
- if (nodes.length === 0) {
30759
- this.#setStickyKeys([], empty);
30760
- return;
30761
- }
30762
- const parent = /* @__PURE__ */ new Map();
30763
- const walk = (n) => {
30764
- for (const child of n.children ?? []) {
30765
- parent.set(child.item.key, n.item.key);
30766
- walk(child);
30767
- }
30768
- };
30769
- walk(this.root);
30770
- const containerTop = container.getBoundingClientRect().top;
30771
- const visibleThreshold = containerTop + (this.renderRoot.querySelector(`.sticky-headers`)?.getBoundingClientRect().height ?? 0);
30772
- let topKey;
30773
- for (const { node } of nodes) {
30774
- const li = this.#stickyRowEl(node.item.key);
30775
- if (!li || li.classList.contains(`leaving`)) continue;
30776
- if (li.getBoundingClientRect().bottom > containerTop) {
30777
- topKey = node.item.key;
30778
- break;
30779
- }
30780
- }
30781
- if (topKey === void 0) {
30782
- this.#setStickyKeys([], empty);
30783
- return;
30784
- }
30785
- const rootKey = this.root.item.key;
32066
+ const list = this.renderRoot.querySelector(`ul.tree-list`);
32067
+ if (!container || !list) return clear();
32068
+ const flat = this.#getFlat();
32069
+ const virtual = this.#virtualActive();
32070
+ const rows = list.children;
32071
+ const keys = this.#renderedRowKeys;
32072
+ if (flat.rows.length === 0) return clear();
32073
+ if (!virtual && (rows.length === 0 || rows.length !== keys.length)) return clear();
32074
+ const reader = virtual ? treeRowReader(this.#layoutParams(flat.rows.length)) : this.#rowReader(rows);
32075
+ const scrollTop = container.scrollTop;
32076
+ const visibleThreshold = scrollTop + (this.renderRoot.querySelector(`.sticky-headers`)?.getBoundingClientRect().height ?? 0);
32077
+ let topIdx = firstRowBelow(reader, scrollTop);
32078
+ if (!virtual) while (topIdx < rows.length && rows[topIdx].classList.contains(`leaving`)) topIdx++;
32079
+ if (topIdx >= reader.count) return clear();
32080
+ const topFlat = virtual ? topIdx : flat.indexByKey.get(keys[topIdx]);
32081
+ if (topFlat === void 0) return clear();
30786
32082
  const chain = [];
30787
- let cursor = parent.get(topKey);
30788
- while (cursor !== void 0 && cursor !== rootKey) {
30789
- chain.unshift(cursor);
30790
- cursor = parent.get(cursor);
32083
+ let cursor = flat.rows[topFlat].parentKey;
32084
+ while (cursor !== void 0) {
32085
+ const idx = flat.indexByKey.get(cursor);
32086
+ if (idx === void 0) break;
32087
+ chain.unshift(flat.rows[idx]);
32088
+ cursor = flat.rows[idx].parentKey;
30791
32089
  }
30792
- const byKey = new Map(nodes.map((entry) => [entry.node.item.key, entry]));
30793
32090
  const entries = /* @__PURE__ */ new Map();
30794
32091
  const stuck = [];
30795
- for (const key of chain) {
30796
- const li = this.#stickyRowEl(key);
30797
- if (!li || li.getBoundingClientRect().bottom > visibleThreshold) continue;
30798
- const entry = byKey.get(key);
30799
- if (!entry) continue;
32092
+ for (const row of chain) {
32093
+ const key = row.node.item.key;
32094
+ const domIdx = virtual ? flat.indexByKey.get(key) : this.#renderedRowIndex.get(key);
32095
+ if (domIdx === void 0 || reader.bottomOf(domIdx) > visibleThreshold) continue;
30800
32096
  stuck.push(key);
30801
- entries.set(key, entry);
32097
+ entries.set(key, {
32098
+ node: row.node,
32099
+ depth: row.depth
32100
+ });
30802
32101
  }
30803
32102
  this.#setStickyKeys(stuck, entries);
30804
32103
  }
@@ -30811,12 +32110,14 @@ let TreeBaseElement = class TreeBaseElement extends LitElement {
30811
32110
  #renderStickyHeaders() {
30812
32111
  if (!this.stickyHeaders || this._stickyKeys.length === 0) return nothing;
30813
32112
  return html`
30814
- <div class="sticky-headers" part="sticky-headers">
30815
- ${this._stickyKeys.map((key) => {
32113
+ <div class="sticky-anchor">
32114
+ <div class="sticky-headers" part="sticky-headers">
32115
+ ${this._stickyKeys.map((key) => {
30816
32116
  const entry = this.#stickyEntries.get(key);
30817
32117
  if (!entry) return nothing;
30818
32118
  return this.#renderStickyHeader(entry.node, entry.depth);
30819
32119
  })}
32120
+ </div>
30820
32121
  </div>
30821
32122
  `;
30822
32123
  }
@@ -30888,15 +32189,44 @@ let TreeBaseElement = class TreeBaseElement extends LitElement {
30888
32189
  }
30889
32190
  willUpdate(changedProps) {
30890
32191
  super.willUpdate(changedProps);
30891
- this.#diffVisibleNodes();
32192
+ const scrollOnly = this.#flat !== void 0 && changedProps.size === 1 && changedProps.has(`_scrollTop`) && this.#virtualActive();
32193
+ this.#scrollOnlyUpdate = scrollOnly;
32194
+ if (scrollOnly) {
32195
+ this.#syncScrollTopFromContainer();
32196
+ return;
32197
+ }
32198
+ this.#flat = void 0;
32199
+ if (changedProps.has(`root`) || changedProps.has(`model`)) this.#structureVersion++;
32200
+ this.#diffVisibleNodes(changedProps.has(`filterPredicate`));
32201
+ const virtual = this.#virtualActive();
32202
+ this.toggleAttribute(`data-virtual`, virtual);
32203
+ if (virtual) {
32204
+ this.#measureMetrics();
32205
+ this.#syncScrollTopFromContainer();
32206
+ }
30892
32207
  if (changedProps.has(`root`) || changedProps.has(`model`) || changedProps.has(`filterPredicate`) || changedProps.has(`stickyHeaders`)) this.#stickyDirty = true;
30893
32208
  if (this.#stickyDirty) this.#updateStickyHeaders();
30894
32209
  }
30895
- #diffVisibleNodes() {
30896
- const nodes = this.root ? this.#getAllNodes() : [];
32210
+ /**
32211
+ * `filterChanged`: typing in a filter adds / removes whole rows from the DOM.
32212
+ * That is not an expand / collapse, so skip the enter / leave animations
32213
+ * (and any ghosts) for this update; `#renderedKeys` is still refreshed.
32214
+ */
32215
+ #diffVisibleNodes(filterChanged) {
32216
+ const nodes = this.#getFlat().rows;
32217
+ if (this.#virtualActive()) {
32218
+ this.#enteringKeys.clear();
32219
+ this.#cancelAllLeavingAnimations();
32220
+ this.#leaving.clear();
32221
+ this.#renderedKeys = [];
32222
+ return;
32223
+ }
30897
32224
  const nowKeys = new Set(nodes.map((n) => n.node.item.key));
30898
32225
  this.#enteringKeys.clear();
30899
- if (this.#treeAnimReady && this.#renderedKeys.length > 0) {
32226
+ if (filterChanged) {
32227
+ this.#cancelAllLeavingAnimations();
32228
+ this.#leaving.clear();
32229
+ } else if (this.#treeAnimReady && this.#renderedKeys.length > 0) {
30900
32230
  const prevSet = new Set(this.#renderedKeys);
30901
32231
  for (const { node } of nodes) if (!prevSet.has(node.item.key)) this.#enteringKeys.add(node.item.key);
30902
32232
  if (this.#enteringKeys.size > MAX_ANIMATED_ROWS) this.#enteringKeys.clear();
@@ -30919,30 +32249,12 @@ let TreeBaseElement = class TreeBaseElement extends LitElement {
30919
32249
  return this.#leaving.has(node.item.key);
30920
32250
  }
30921
32251
  /**
30922
- * True when `filterPredicate` is set and neither `node` nor any descendant
30923
- * matches — the row should carry the `filtered` class (`display: none`).
30924
- */
30925
- _isFilteredOut(node, depth) {
30926
- return this.filterPredicate !== void 0 && !this.#matchesFilter(node, depth);
30927
- }
30928
- /** Splice the fading ghost rows back into the visible list near where they were. */
30929
- #weaveLeaving(nodes) {
30930
- if (this.#leaving.size === 0) return nodes;
30931
- const result = [...nodes];
30932
- const indexOfKey = (key) => result.findIndex((e) => e.node.item.key === key);
30933
- for (const [key, entry] of this.#leaving) {
30934
- const prevIdx = this.#renderedKeys.indexOf(key);
30935
- let insertAt = result.length;
30936
- for (let j = prevIdx + 1; j < this.#renderedKeys.length; j++) {
30937
- const at = indexOfKey(this.#renderedKeys[j]);
30938
- if (at !== -1) {
30939
- insertAt = at;
30940
- break;
30941
- }
30942
- }
30943
- result.splice(insertAt, 0, entry);
30944
- }
30945
- return result;
32252
+ * Kept for subclass compatibility. Filtered-out rows are no longer rendered
32253
+ * at all (they are omitted from the flat list), so this is `false` for every
32254
+ * rendered row.
32255
+ */
32256
+ _isFilteredOut(_node, _depth) {
32257
+ return false;
30946
32258
  }
30947
32259
  #runItemAnimations() {
30948
32260
  if (this.#enteringKeys.size > 0) {
@@ -30982,7 +32294,7 @@ let TreeBaseElement = class TreeBaseElement extends LitElement {
30982
32294
  });
30983
32295
  });
30984
32296
  }
30985
- const visible = this.root ? this.#getAllNodes() : [];
32297
+ const visible = this.#getFlat().rows;
30986
32298
  this.#renderedKeys = visible.map((n) => n.node.item.key);
30987
32299
  this.#lastNodeByKey.clear();
30988
32300
  for (const entry of visible) this.#lastNodeByKey.set(entry.node.item.key, entry);
@@ -30990,10 +32302,16 @@ let TreeBaseElement = class TreeBaseElement extends LitElement {
30990
32302
  }
30991
32303
  updated(changedProps) {
30992
32304
  this.#rowsCache = void 0;
32305
+ this.#syncVirtualObservers();
32306
+ if (this.#scrollOnlyUpdate) {
32307
+ this.#scrollOnlyUpdate = false;
32308
+ this.#repaintSearchHighlights();
32309
+ return;
32310
+ }
30993
32311
  this.#syncStickyScroll();
30994
32312
  if (changedProps.has(`root`) && this.root) {
30995
32313
  this.#abortPending();
30996
- this._expandedKeys.clear();
32314
+ this.#setExpanded(/* @__PURE__ */ new Set());
30997
32315
  this.#clearSelectionInternal();
30998
32316
  this.tickled.clearTickled();
30999
32317
  this._focusedDepth = 0;
@@ -31013,8 +32331,7 @@ let TreeBaseElement = class TreeBaseElement extends LitElement {
31013
32331
  this.requestUpdate();
31014
32332
  } else if (event.type === `invalidate`) {
31015
32333
  if (this._expandedKeys.has(event.key)) {
31016
- this._expandedKeys.delete(event.key);
31017
- this.#stickyDirty = true;
32334
+ this.#setExpanded(withoutKey(this._expandedKeys, event.key));
31018
32335
  this.requestUpdate();
31019
32336
  }
31020
32337
  }
@@ -31028,7 +32345,16 @@ let TreeBaseElement = class TreeBaseElement extends LitElement {
31028
32345
  if (changedProps.has(`selectedNode`) && this.selectedNode) {
31029
32346
  if (changedProps.get(`selectedNode`) !== this.selectedNode) this.tickled.setTickled(this.selectedNode.item.key);
31030
32347
  }
31031
- this.#runItemAnimations();
32348
+ if (this.#virtualActive()) {
32349
+ this.#renderedKeys = [];
32350
+ this.#lastNodeByKey.clear();
32351
+ if (this.#measureMetrics()) queueMicrotask(() => this.requestUpdate());
32352
+ this.#repaintSearchHighlights();
32353
+ } else this.#runItemAnimations();
32354
+ }
32355
+ /** Highlights are painted on rendered labels, so they move with the window. */
32356
+ #repaintSearchHighlights() {
32357
+ if (this.search.isOpen) this.search.repaintHighlights();
31032
32358
  }
31033
32359
  connectedCallback() {
31034
32360
  super.connectedCallback();
@@ -31060,8 +32386,10 @@ let TreeBaseElement = class TreeBaseElement extends LitElement {
31060
32386
  return target.closest(`ul.tree-list li[data-key]`)?.getAttribute(`data-key`) ?? void 0;
31061
32387
  },
31062
32388
  prepareSelection: (originKey) => {
31063
- const allNodes = this.#getAllNodes();
31064
- const originEntry = allNodes.find((n) => n.node.item.key === originKey);
32389
+ const flat = this.#getFlat();
32390
+ const allNodes = flat.rows;
32391
+ const originIdx = flat.indexByKey.get(originKey);
32392
+ const originEntry = originIdx === void 0 ? void 0 : allNodes[originIdx];
31065
32393
  if (!originEntry) return void 0;
31066
32394
  const selectedNodes = new Set(this._selectedNodes);
31067
32395
  if (!selectedNodes.has(originEntry.node)) selectedNodes.clear();
@@ -31150,6 +32478,18 @@ let TreeBaseElement = class TreeBaseElement extends LitElement {
31150
32478
  getDndMode: () => this.itemDnd,
31151
32479
  getExpandHoverMs: () => this.dndExpandHoverMs,
31152
32480
  getVisibleRows: () => this.#getVisibleRows(),
32481
+ getRowInfo: (key) => {
32482
+ const flat = this.#getFlat();
32483
+ const idx = flat.indexByKey.get(key);
32484
+ if (idx === void 0) return void 0;
32485
+ const { node, depth, parentKey } = flat.rows[idx];
32486
+ return {
32487
+ node,
32488
+ depth,
32489
+ parentKey
32490
+ };
32491
+ },
32492
+ getChildren: (parentKey) => this.#childrenOfKey(parentKey),
31153
32493
  getIndentPx: () => this.#getIndentPx(),
31154
32494
  getScrollContainer: () => this.#container(),
31155
32495
  isSelfOrDescendant: (ancestorKey, key) => this.#isSelfOrDescendant(ancestorKey, key),
@@ -31222,44 +32562,121 @@ let TreeBaseElement = class TreeBaseElement extends LitElement {
31222
32562
  const parsed = Number.parseInt(getComputedStyle(this).getPropertyValue(`--tree-item-height`), 10);
31223
32563
  return Number.isNaN(parsed) ? 30 : parsed;
31224
32564
  }
31225
- /** Measured rows, cached for the duration of a drag and invalidated on scroll / update. */
31226
- #getVisibleRows() {
31227
- if (this.#rowsCache) return this.#rowsCache;
31228
- const rows = [];
31229
- for (const { node, depth } of this.#getAllNodes()) {
31230
- const li = this.shadowRoot?.querySelector(`ul.tree-list li[data-key="${CSS.escape(node.item.key)}"]`);
31231
- if (li) rows.push({
32565
+ /** Children of `parentKey` (the root's children when undefined), straight from the tree. */
32566
+ #childrenOfKey(parentKey) {
32567
+ if (parentKey === void 0) return this.root?.children ?? [];
32568
+ const flat = this.#getFlat();
32569
+ const idx = flat.indexByKey.get(parentKey);
32570
+ return idx === void 0 ? [] : flat.rows[idx].node.children ?? [];
32571
+ }
32572
+ /**
32573
+ * Windowed counterpart of the measured rows: rects come from the layout
32574
+ * arithmetic and one rendered row's horizontal extent, so nothing is looked
32575
+ * up per row and rows outside the window still resolve.
32576
+ */
32577
+ #collectVirtualDropRows(container, list, out) {
32578
+ const flat = this.#getFlat();
32579
+ const params = this.#layoutParams(flat.rows.length);
32580
+ const range = rowRangeOverlapping(treeRowReader(params), container.scrollTop, container.scrollTop + container.clientHeight, 1);
32581
+ if (!range) return;
32582
+ const containerRect = container.getBoundingClientRect();
32583
+ const sample = list.querySelector(`li.item`)?.getBoundingClientRect() ?? containerRect;
32584
+ for (let i = range.first; i <= range.last; i++) {
32585
+ const { node, depth } = flat.rows[i];
32586
+ const top = containerRect.top - container.scrollTop + treeRowTop(params, i);
32587
+ const rect = {
32588
+ x: sample.left,
32589
+ y: top,
32590
+ left: sample.left,
32591
+ right: sample.left + sample.width,
32592
+ top,
32593
+ bottom: top + params.rowHeight,
32594
+ width: sample.width,
32595
+ height: params.rowHeight,
32596
+ toJSON: () => ({})
32597
+ };
32598
+ out.push({
31232
32599
  key: node.item.key,
31233
32600
  node,
31234
32601
  depth,
31235
- rect: li.getBoundingClientRect()
32602
+ index: i,
32603
+ rect
31236
32604
  });
31237
32605
  }
32606
+ }
32607
+ /**
32608
+ * Measured rows for the part of the list that intersects the scroll
32609
+ * container (plus one row of margin each side), cached for the duration of a
32610
+ * drag and invalidated on scroll / update. Each row carries its flat index;
32611
+ * structural lookups (parents, siblings) come from `getRowInfo` / `getChildren`.
32612
+ */
32613
+ #getVisibleRows() {
32614
+ if (this.#rowsCache) return this.#rowsCache;
32615
+ const rows = [];
32616
+ const container = this.#container();
32617
+ const list = this.renderRoot.querySelector(`ul.tree-list`);
32618
+ const keys = this.#renderedRowKeys;
32619
+ if (container && list && this.#virtualActive()) this.#collectVirtualDropRows(container, list, rows);
32620
+ else if (container && list && list.children.length === keys.length) {
32621
+ const flat = this.#getFlat();
32622
+ const items = list.children;
32623
+ const range = rowRangeOverlapping(this.#rowReader(items), container.scrollTop, container.scrollTop + container.clientHeight, 1);
32624
+ if (range) for (let i = range.first; i <= range.last; i++) {
32625
+ const li = items[i];
32626
+ if (li.classList.contains(`leaving`)) continue;
32627
+ const flatIdx = flat.indexByKey.get(keys[i]);
32628
+ if (flatIdx === void 0) continue;
32629
+ const { node, depth } = flat.rows[flatIdx];
32630
+ rows.push({
32631
+ key: node.item.key,
32632
+ node,
32633
+ depth,
32634
+ index: flatIdx,
32635
+ rect: li.getBoundingClientRect()
32636
+ });
32637
+ }
32638
+ }
31238
32639
  this.#rowsCache = rows;
31239
32640
  return rows;
31240
32641
  }
31241
32642
  /** Route spring-load through the real expand path so lazy children load and `expand` fires with the right depth. */
31242
32643
  #springLoadExpand(key) {
31243
32644
  if (this._expandedKeys.has(key)) return;
31244
- const entry = this.#getAllNodes().find((n) => n.node.item.key === key);
32645
+ const flat = this.#getFlat();
32646
+ const idx = flat.indexByKey.get(key);
32647
+ const entry = idx === void 0 ? void 0 : flat.rows[idx];
31245
32648
  if (!entry || entry.node.item.isLeaf === true) return;
31246
32649
  this.#toggleExpand(entry.node, entry.depth);
31247
32650
  this.#rowsCache = void 0;
31248
32651
  this.requestUpdate();
31249
32652
  }
32653
+ /** O(depth): walk up the whole-tree parent map. */
31250
32654
  #isSelfOrDescendant(ancestorKey, key) {
31251
32655
  if (ancestorKey === key) return true;
31252
- let node = this.#getAllNodes().find((n) => n.node.item.key === key)?.node;
31253
- let parent = node ? this.#findParentByKey(node.item.key) : void 0;
31254
- while (parent) {
31255
- if (parent.item.key === ancestorKey) return true;
31256
- node = parent;
31257
- parent = this.#findParentByKey(node.item.key);
32656
+ const { parentByKey } = this.#getTreeIndex();
32657
+ let cursor = parentByKey.get(key);
32658
+ while (cursor !== void 0) {
32659
+ if (cursor === ancestorKey) return true;
32660
+ cursor = parentByKey.get(cursor);
31258
32661
  }
31259
32662
  return false;
31260
32663
  }
31261
32664
  #findParentByKey(key) {
31262
- for (const { node } of this.#getAllNodes()) if (node.children?.some((c) => c.item.key === key)) return node;
32665
+ const parentKey = this.#getTreeIndex().parentByKey.get(key);
32666
+ if (parentKey === void 0 || parentKey === this.root?.item.key) return void 0;
32667
+ return this.#nodeByKey(parentKey);
32668
+ }
32669
+ /** Node for a key anywhere in the loaded tree (via the flat list when visible). */
32670
+ #nodeByKey(key) {
32671
+ const flat = this.#getFlat();
32672
+ const idx = flat.indexByKey.get(key);
32673
+ if (idx !== void 0) return flat.rows[idx].node;
32674
+ const { parentByKey } = this.#getTreeIndex();
32675
+ const path = [];
32676
+ for (let cursor = key; cursor !== void 0 && cursor !== this.root?.item.key; cursor = parentByKey.get(cursor)) path.unshift(cursor);
32677
+ let node = this.root;
32678
+ for (const k of path) node = node?.children?.find((c) => c.item.key === k);
32679
+ return node;
31263
32680
  }
31264
32681
  /**
31265
32682
  * Decide whether a DataTransfer is acceptable and resolve the dragged set.
@@ -31283,7 +32700,7 @@ let TreeBaseElement = class TreeBaseElement extends LitElement {
31283
32700
  return {
31284
32701
  source: `internal`,
31285
32702
  keys,
31286
- nodes: keys.map((k) => this.#getAllNodes().find((n) => n.node.item.key === k)?.node).filter((n) => n !== void 0)
32703
+ nodes: keys.map((k) => this.#nodeByKey(k)).filter((n) => n !== void 0)
31287
32704
  };
31288
32705
  }
31289
32706
  if (phase === `drop`) try {
@@ -31327,7 +32744,7 @@ let TreeBaseElement = class TreeBaseElement extends LitElement {
31327
32744
  }
31328
32745
  #anyRowDraggable() {
31329
32746
  if (this.itemDnd === `none`) return false;
31330
- const allNodes = this.#getAllNodes();
32747
+ const allNodes = this.#getFlat().rows;
31331
32748
  if (allNodes.length === 0) return false;
31332
32749
  if (!this.canDrag) return true;
31333
32750
  return allNodes.some(({ node }) => this.canDrag({
@@ -31349,6 +32766,7 @@ let TreeBaseElement = class TreeBaseElement extends LitElement {
31349
32766
  this.#postDragSuppressTimer = void 0;
31350
32767
  }
31351
32768
  this.#container()?.removeEventListener(`scroll`, this.#onContainerScroll);
32769
+ this.#teardownVirtualObservers();
31352
32770
  if (this.#stickyScrollBound) {
31353
32771
  this.#stickyScrollContainer?.removeEventListener(`scroll`, this.#onStickyScroll);
31354
32772
  this.#stickyScrollContainer = void 0;
@@ -31402,12 +32820,12 @@ let TreeBaseElement = class TreeBaseElement extends LitElement {
31402
32820
  }
31403
32821
  selectAll() {
31404
32822
  const prev = new Set(this._selectedNodes);
31405
- if (this.interactionMode === `checked`) for (const { node } of this.#getAllNodes()) {
32823
+ if (this.interactionMode === `checked`) for (const { node } of this.#getFlat().rows) {
31406
32824
  if (!this.#canSelect(node)) continue;
31407
32825
  this.#applyCheckedToggle(node, true);
31408
32826
  }
31409
32827
  else {
31410
- for (const { node } of this.#getAllNodes()) {
32828
+ for (const { node } of this.#getFlat().rows) {
31411
32829
  if (!this.#canSelect(node)) continue;
31412
32830
  this._selectedNodes.add(node);
31413
32831
  }
@@ -31418,7 +32836,7 @@ let TreeBaseElement = class TreeBaseElement extends LitElement {
31418
32836
  }
31419
32837
  deselectAll() {
31420
32838
  const prev = new Set(this._selectedNodes);
31421
- if (this.interactionMode === `checked`) for (const { node } of this.#getAllNodes()) {
32839
+ if (this.interactionMode === `checked`) for (const { node } of this.#getFlat().rows) {
31422
32840
  if (!this.#canSelect(node)) continue;
31423
32841
  this.#applyCheckedToggle(node, false);
31424
32842
  }
@@ -31439,7 +32857,7 @@ let TreeBaseElement = class TreeBaseElement extends LitElement {
31439
32857
  const found = children.find((n) => n.item.key === key);
31440
32858
  if (!found) return false;
31441
32859
  this.#applyExclusivity(found.item.key);
31442
- this._expandedKeys.add(found.item.key);
32860
+ this.#setExpanded(withKey(this._expandedKeys, found.item.key));
31443
32861
  if (key === keys.at(-1)) {
31444
32862
  this.select(found);
31445
32863
  this.tickled.setTickled(found.item.key);
@@ -31464,7 +32882,7 @@ let TreeBaseElement = class TreeBaseElement extends LitElement {
31464
32882
  if (!path) return false;
31465
32883
  for (let i = 0; i < path.length - 1; i++) {
31466
32884
  this.#applyExclusivity(path[i]);
31467
- this._expandedKeys.add(path[i]);
32885
+ this.#setExpanded(withKey(this._expandedKeys, path[i]));
31468
32886
  }
31469
32887
  this.tickled.setTickled(key);
31470
32888
  this.#stickyDirty = true;
@@ -31500,10 +32918,12 @@ let TreeBaseElement = class TreeBaseElement extends LitElement {
31500
32918
  return true;
31501
32919
  }
31502
32920
  #clearSelectionInternal() {
32921
+ this._selectionVersion++;
31503
32922
  this._selectedNodes.clear();
31504
32923
  this.selectedNode = void 0;
31505
32924
  }
31506
32925
  #dispatchSelect(previous) {
32926
+ this._selectionVersion++;
31507
32927
  const detail = {
31508
32928
  selected: new Set(this._selectedNodes),
31509
32929
  previous
@@ -31609,9 +33029,10 @@ let TreeBaseElement = class TreeBaseElement extends LitElement {
31609
33029
  return;
31610
33030
  }
31611
33031
  if (isShift && this.#anchor) {
31612
- const visible = this.#getAllNodes();
31613
- const anchorIdx = visible.findIndex((n) => n.node === this.#anchor.node);
31614
- const targetIdx = visible.findIndex((n) => n.node === node);
33032
+ const flat = this.#getFlat();
33033
+ const visible = flat.rows;
33034
+ const anchorIdx = flat.indexByKey.get(this.#anchor.node.item.key) ?? -1;
33035
+ const targetIdx = flat.indexByKey.get(node.item.key) ?? -1;
31615
33036
  if (anchorIdx >= 0 && targetIdx >= 0) {
31616
33037
  const prev = new Set(this._selectedNodes);
31617
33038
  const from = Math.min(anchorIdx, targetIdx);
@@ -31661,9 +33082,10 @@ let TreeBaseElement = class TreeBaseElement extends LitElement {
31661
33082
  return;
31662
33083
  }
31663
33084
  if (isShift && this.#anchor) {
31664
- const visible = this.#getAllNodes();
31665
- const anchorIdx = visible.findIndex((n) => n.node === this.#anchor.node);
31666
- const targetIdx = visible.findIndex((n) => n.node === node);
33085
+ const flat = this.#getFlat();
33086
+ const visible = flat.rows;
33087
+ const anchorIdx = flat.indexByKey.get(this.#anchor.node.item.key) ?? -1;
33088
+ const targetIdx = flat.indexByKey.get(node.item.key) ?? -1;
31667
33089
  if (anchorIdx >= 0 && targetIdx >= 0) {
31668
33090
  const prev = new Set(this._selectedNodes);
31669
33091
  const from = Math.min(anchorIdx, targetIdx);
@@ -31706,7 +33128,7 @@ let TreeBaseElement = class TreeBaseElement extends LitElement {
31706
33128
  const isExpanded = this._expandedKeys.has(node.item.key);
31707
33129
  this.#stickyDirty = true;
31708
33130
  if (isExpanded) {
31709
- this._expandedKeys.delete(node.item.key);
33131
+ this.#setExpanded(withoutKey(this._expandedKeys, node.item.key));
31710
33132
  this.dispatchEvent(new CustomEvent(`collapse`, {
31711
33133
  detail: {
31712
33134
  node,
@@ -31725,7 +33147,7 @@ let TreeBaseElement = class TreeBaseElement extends LitElement {
31725
33147
  }
31726
33148
  } else {
31727
33149
  this.#applyExclusivity(node.item.key);
31728
- this._expandedKeys.add(node.item.key);
33150
+ this.#setExpanded(withKey(this._expandedKeys, node.item.key));
31729
33151
  this.dispatchEvent(new CustomEvent(`expand`, {
31730
33152
  detail: {
31731
33153
  node,
@@ -31749,7 +33171,7 @@ let TreeBaseElement = class TreeBaseElement extends LitElement {
31749
33171
  if (node.children) for (const child of node.children) this.#applyCheckedToggle(child, selected);
31750
33172
  }
31751
33173
  #getFlatIndexOf(node) {
31752
- return this.#getAllNodes().findIndex((n) => n.node === node);
33174
+ return this.#getFlat().indexByKey.get(node.item.key) ?? -1;
31753
33175
  }
31754
33176
  #extendRangeSelection(nodes, fromIdx, toIdx) {
31755
33177
  if (!this.#anchor) {
@@ -31760,7 +33182,7 @@ let TreeBaseElement = class TreeBaseElement extends LitElement {
31760
33182
  };
31761
33183
  }
31762
33184
  if (!this.#anchor) return;
31763
- const anchorIdx = nodes.findIndex((n) => n.node === this.#anchor.node);
33185
+ const anchorIdx = this.#getFlat().indexByKey.get(this.#anchor.node.item.key) ?? -1;
31764
33186
  if (anchorIdx < 0) return;
31765
33187
  const from = Math.min(anchorIdx, toIdx);
31766
33188
  const to = Math.max(anchorIdx, toIdx);
@@ -31796,8 +33218,10 @@ let TreeBaseElement = class TreeBaseElement extends LitElement {
31796
33218
  }, controller.signal), controller.signal)];
31797
33219
  if (controller.signal.aborted) return;
31798
33220
  node.children = children;
33221
+ this.#structureVersion++;
31799
33222
  this.#applyExclusivity(node.item.key);
31800
- this._expandedKeys.add(node.item.key);
33223
+ this.#setExpanded(withKey(this._expandedKeys, node.item.key));
33224
+ this.#flat = void 0;
31801
33225
  this.#stickyDirty = true;
31802
33226
  this.#pendingNode = void 0;
31803
33227
  this.#pendingController = void 0;
@@ -31813,6 +33237,8 @@ let TreeBaseElement = class TreeBaseElement extends LitElement {
31813
33237
  } catch {
31814
33238
  if (controller.signal.aborted) return;
31815
33239
  node.children = [];
33240
+ this.#structureVersion++;
33241
+ this.#flat = void 0;
31816
33242
  this.#pendingNode = void 0;
31817
33243
  this.#pendingController = void 0;
31818
33244
  this.requestUpdate();
@@ -31824,33 +33250,44 @@ let TreeBaseElement = class TreeBaseElement extends LitElement {
31824
33250
  _isFocused(node) {
31825
33251
  return this.tickled.isTickled(node.item.key);
31826
33252
  }
31827
- _isExpanded(node, depth = 0) {
33253
+ _isExpanded(node, _depth = 0) {
31828
33254
  if (this._expandedKeys.has(node.item.key)) return true;
31829
- if (this.filterPredicate && node.children) return node.children.some((child) => this.#matchesFilter(child, depth + 1));
31830
- return false;
31831
- }
31832
- #matchesFilter(node, depth) {
31833
- if (!this.filterPredicate) return true;
31834
- if (this.filterPredicate(node, depth)) return true;
31835
- return node.children?.some((child) => this.#matchesFilter(child, depth + 1)) ?? false;
33255
+ return this.#getFilter()?.autoExpanded.has(node.item.key) ?? false;
31836
33256
  }
31837
33257
  get _pendingNode() {
31838
33258
  return this.#pendingNode;
31839
33259
  }
31840
33260
  #scrollKeyIntoView(key) {
33261
+ if (this.#virtualActive()) {
33262
+ this.updateComplete.then(() => {
33263
+ const idx = this.#getFlat().indexByKey.get(key);
33264
+ if (idx !== void 0) this.#revealIndex(idx);
33265
+ });
33266
+ return;
33267
+ }
33268
+ const token = ++this.#revealToken;
31841
33269
  requestAnimationFrame(() => {
31842
- const container = this.#container();
31843
- const el = this.shadowRoot?.querySelector(`ul.tree-list [data-key="${CSS.escape(key)}"]`);
31844
- if (!el || !container) return;
31845
- const elRect = el.getBoundingClientRect();
31846
- const containerRect = container.getBoundingClientRect();
31847
- const relativeTop = elRect.top - containerRect.top;
31848
- const relativeBottom = relativeTop + elRect.height;
31849
- const overlayHeight = this.renderRoot.querySelector(`.sticky-headers`)?.getBoundingClientRect().height ?? 0;
31850
- if (relativeTop < overlayHeight) container.scrollTop += relativeTop - overlayHeight;
31851
- else if (relativeBottom > container.clientHeight) container.scrollTop += relativeBottom - container.clientHeight;
33270
+ this.#revealRowElement(key);
33271
+ const running = this.#container()?.getAnimations?.({ subtree: true }) ?? [];
33272
+ if (running.length > 0) Promise.allSettled(running.map((a) => a.finished)).then(() => {
33273
+ if (token === this.#revealToken) this.#revealRowElement(key);
33274
+ });
31852
33275
  });
31853
33276
  }
33277
+ #revealToken;
33278
+ /** Scroll the rendered row for `key` into the container, below the sticky overlay. No-op if it isn't rendered or is already visible. */
33279
+ #revealRowElement(key) {
33280
+ const container = this.#container();
33281
+ const el = this.shadowRoot?.querySelector(`ul.tree-list [data-key="${CSS.escape(key)}"]`);
33282
+ if (!el || !container) return;
33283
+ const elRect = el.getBoundingClientRect();
33284
+ const containerRect = container.getBoundingClientRect();
33285
+ const relativeTop = elRect.top - containerRect.top;
33286
+ const relativeBottom = relativeTop + elRect.height;
33287
+ const overlayHeight = this.renderRoot.querySelector(`.sticky-headers`)?.getBoundingClientRect().height ?? 0;
33288
+ if (relativeTop < overlayHeight) container.scrollTop += relativeTop - overlayHeight;
33289
+ else if (relativeBottom > container.clientHeight) container.scrollTop += relativeBottom - container.clientHeight;
33290
+ }
31854
33291
  #abortPending() {
31855
33292
  if (this.#pendingController) {
31856
33293
  this.#pendingController.abort();
@@ -31864,14 +33301,15 @@ let TreeBaseElement = class TreeBaseElement extends LitElement {
31864
33301
  if (this.exclusivity === `depth`) this.#collapseSiblingsAtDepth(expandKey);
31865
33302
  else if (this.exclusivity === `global`) {
31866
33303
  const ancestorKeys = this.#findAncestorKeys(expandKey);
31867
- for (const key of [...this._expandedKeys]) if (!ancestorKeys.has(key) && key !== expandKey) this._expandedKeys.delete(key);
33304
+ this.#setExpanded(withoutKeys(this._expandedKeys, (key) => !ancestorKeys.has(key) && key !== expandKey));
31868
33305
  }
31869
33306
  }
31870
33307
  #collapseSiblingsAtDepth(expandKey) {
31871
33308
  if (!this.root) return;
31872
33309
  const parent = this.#findParent(this.root, expandKey);
31873
33310
  if (!parent?.children) return;
31874
- for (const child of parent.children) if (child.item.key !== expandKey && this._expandedKeys.has(child.item.key)) this._expandedKeys.delete(child.item.key);
33311
+ const siblingKeys = new Set(parent.children.map((c) => c.item.key));
33312
+ this.#setExpanded(withoutKeys(this._expandedKeys, (key) => key !== expandKey && siblingKeys.has(key)));
31875
33313
  }
31876
33314
  #findAncestorKeys(targetKey) {
31877
33315
  const keys = /* @__PURE__ */ new Set();
@@ -31895,30 +33333,13 @@ let TreeBaseElement = class TreeBaseElement extends LitElement {
31895
33333
  if (found) return found;
31896
33334
  }
31897
33335
  }
31898
- #getVisibleNodes(node, depth) {
31899
- const result = [];
31900
- const visit = (n, d) => {
31901
- result.push({
31902
- node: n,
31903
- depth: d
31904
- });
31905
- if (n.children && this._isExpanded(n, d)) for (const child of n.children) visit(child, d + 1);
31906
- };
31907
- if (node.children) for (const child of node.children) visit(child, depth + 1);
31908
- return result;
31909
- }
31910
- #getAllNodes() {
31911
- if (!this.root) return [];
31912
- return this.#getVisibleNodes(this.root, -1);
31913
- }
31914
33336
  #getFocusedIndex() {
31915
- const nodes = this.#getAllNodes();
31916
33337
  const tickledKey = this.tickled.tickledId;
31917
33338
  if (!tickledKey) return 0;
31918
- return nodes.findIndex((n) => n.node.item.key === tickledKey);
33339
+ return this.#getFlat().indexByKey.get(tickledKey) ?? 0;
31919
33340
  }
31920
33341
  #setFocusedIndex(index) {
31921
- const nodes = this.#getAllNodes();
33342
+ const nodes = this.#getFlat().rows;
31922
33343
  if (index < 0 || index >= nodes.length) return;
31923
33344
  const { node, depth } = nodes[index];
31924
33345
  this.tickled.setTickled(node.item.key);
@@ -31932,6 +33353,10 @@ let TreeBaseElement = class TreeBaseElement extends LitElement {
31932
33353
  composed: true
31933
33354
  }));
31934
33355
  this.requestUpdate();
33356
+ if (this.#virtualActive()) {
33357
+ this.#revealIndex(index);
33358
+ return;
33359
+ }
31935
33360
  this.updateComplete.then(() => this.#scrollTickledIntoView());
31936
33361
  }
31937
33362
  #scrollTickledIntoView() {
@@ -31942,12 +33367,25 @@ let TreeBaseElement = class TreeBaseElement extends LitElement {
31942
33367
  behavior: `smooth`
31943
33368
  });
31944
33369
  }
33370
+ /** Rows per PageUp / PageDown: one viewport, from the current CSS metrics. */
33371
+ #pageSize() {
33372
+ this.#measureMetrics();
33373
+ return treePageSize(this.#layoutParams(this.#getFlat().rows.length));
33374
+ }
33375
+ /** Move the cursor to `nextIdx`, applying the interaction mode's selection side effects. */
33376
+ #navigateToIndex(nodes, currentIdx, nextIdx, shiftKey) {
33377
+ if (this.interactionMode === `implicit`) this.select(nodes[nextIdx].node);
33378
+ this.#setFocusedIndex(nextIdx);
33379
+ if (shiftKey && (this.interactionMode === `standard` || this.interactionMode === `vscode`)) this.#extendRangeSelection(nodes, currentIdx, nextIdx);
33380
+ else if (this.interactionMode === `vscode`) this.#anchor = void 0;
33381
+ this.requestUpdate();
33382
+ }
31945
33383
  #onKeyDown;
31946
33384
  renderLoadingIndicator() {
31947
33385
  return html`<span class="loading-spinner"></span>`;
31948
33386
  }
31949
33387
  render() {
31950
- const nodes = this.#getAllNodes();
33388
+ const nodes = this.#getFlat().rows;
31951
33389
  return html`
31952
33390
  <div
31953
33391
  id="container"
@@ -31970,12 +33408,51 @@ let TreeBaseElement = class TreeBaseElement extends LitElement {
31970
33408
  ${this.renderLoadingIndicator()}
31971
33409
  </div>
31972
33410
  `;
33411
+ if (this.#virtualActive()) return this.#renderWindow(nodes);
33412
+ const woven = weaveGhosts(nodes, this.#renderedRowKeys, this.#leaving);
33413
+ this.#renderedRowKeys = woven.map((e) => e.node.item.key);
33414
+ this.#renderedRowIndex = new Map(this.#renderedRowKeys.map((key, i) => [key, i]));
31973
33415
  return html`
31974
33416
  <ul class="tree-list" role="tree">
31975
- ${repeat(this.#weaveLeaving(nodes), ({ node }) => node.item.key, ({ node, depth }) => this._renderNode(node, depth))}
33417
+ ${repeat(woven, ({ node }) => node.item.key, ({ node, depth }) => this._renderNode(node, depth))}
31976
33418
  </ul>
31977
33419
  `;
31978
33420
  }
33421
+ /**
33422
+ * Windowed render: the rows near the viewport between two spacers that hold
33423
+ * the rest of the scroll height. Keyed, so a row that stays in the window
33424
+ * keeps its element while others enter and leave at the edges.
33425
+ */
33426
+ #renderWindow(nodes) {
33427
+ const params = this.#layoutParams(nodes.length);
33428
+ const { start, end } = this.#currentWindow(nodes.length);
33429
+ this.#winStart = start;
33430
+ this.#winEnd = end;
33431
+ this.#renderedRowKeys = [];
33432
+ this.#renderedRowIndex = /* @__PURE__ */ new Map();
33433
+ const stride = treeStride(params);
33434
+ return html`
33435
+ <ul class="tree-list virtual" role="tree" aria-rowcount=${nodes.length}>
33436
+ <li class="virtual-spacer" role="presentation" aria-hidden="true" style=${`height: ${start * stride}px`}></li>
33437
+ ${repeat(nodes.slice(start, end), ({ node }) => node.item.key, ({ node, depth }) => this._renderNode(node, depth))}
33438
+ <li class="virtual-spacer" role="presentation" aria-hidden="true" style=${`height: ${(nodes.length - end) * stride}px`}></li>
33439
+ </ul>
33440
+ `;
33441
+ }
33442
+ /**
33443
+ * `{ posinset, setsize }` for `aria-posinset` / `aria-setsize`. A windowed
33444
+ * list has most siblings out of the DOM, so assistive tech cannot count them.
33445
+ */
33446
+ _ariaSetInfo(node) {
33447
+ const flat = this.#getFlat();
33448
+ const idx = flat.indexByKey.get(node.item.key);
33449
+ if (idx === void 0) return void 0;
33450
+ const { posInSet, setSize } = flat.rows[idx];
33451
+ return {
33452
+ posInSet,
33453
+ setSize
33454
+ };
33455
+ }
31979
33456
  /** Shared drag-and-drop render state for a row, used by every `_renderNode` variant. */
31980
33457
  _dndRowState(node) {
31981
33458
  const draggable = this.itemDnd !== `none` && (this.root?.children?.length ?? 0) > 0 && (this.itemDnd === `internal` || this.itemDnd === `both` || this.itemDnd === `external`) && (this.canDrag?.({
@@ -31999,17 +33476,18 @@ let TreeBaseElement = class TreeBaseElement extends LitElement {
31999
33476
  const isBranch = node.item.isLeaf !== true;
32000
33477
  const isExpanded = this._isExpanded(node, depth);
32001
33478
  const isPending = this.#pendingNode === node;
32002
- const isFiltered = this._isFilteredOut(node, depth);
32003
33479
  const isLeaving = this._isLeaving(node);
32004
33480
  const dnd = this._dndRowState(node);
32005
33481
  return html`
32006
33482
  <li
32007
- class="item ${selected ? `selected` : ``} ${tickled ? `tickled` : ``} ${isBranch ? `branch` : `leaf`} ${isFiltered ? `filtered` : ``} ${isLeaving ? `leaving` : ``} ${dnd.dragging ? `dragging` : ``} ${dnd.dropTarget && dnd.dropPosition === `inside` ? `drop-into` : ``}"
33483
+ class="item ${selected ? `selected` : ``} ${tickled ? `tickled` : ``} ${isBranch ? `branch` : `leaf`} ${isLeaving ? `leaving` : ``} ${dnd.dragging ? `dragging` : ``} ${dnd.dropTarget && dnd.dropPosition === `inside` ? `drop-into` : ``}"
32008
33484
  role="treeitem"
32009
33485
  aria-selected=${selected}
32010
33486
  aria-expanded=${isBranch ? isExpanded : nothing}
32011
33487
  aria-hidden=${isLeaving ? `true` : nothing}
32012
33488
  aria-level=${depth + 1}
33489
+ aria-setsize=${this._ariaSetInfo(node)?.setSize ?? nothing}
33490
+ aria-posinset=${this._ariaSetInfo(node)?.posInSet ?? nothing}
32013
33491
  data-key=${node.item.key}
32014
33492
  style=${`--depth: ${depth}`}
32015
33493
  draggable=${dnd.draggable ? `true` : nothing}
@@ -32050,6 +33528,8 @@ let TreeBaseElement = class TreeBaseElement extends LitElement {
32050
33528
  --tree-muted-text: var(--surface-muted-text);
32051
33529
  --tree-indent: 1ch;
32052
33530
  --tree-item-height: 30px;
33531
+ /* Margin above each row. A windowed list is positioned by (height + gap), so keep it in px. */
33532
+ --tree-item-gap: 1px;
32053
33533
  --tree-caret-size: 30px;
32054
33534
  --tree-radius: var(--radius-s);
32055
33535
  --tree-drop-line: var(--accent);
@@ -32073,6 +33553,8 @@ let TreeBaseElement = class TreeBaseElement extends LitElement {
32073
33553
  The scroll lives here, not on :host, so the search popover (a sibling of
32074
33554
  #container, positioned against :host) stays put while the list scrolls. */
32075
33555
  #container {
33556
+ /* offsetParent for rows: row offsets are scroll-independent (sticky / DnD geometry). */
33557
+ position: relative;
32076
33558
  outline: none;
32077
33559
  min-height: 100%;
32078
33560
  max-height: 100%;
@@ -32119,7 +33601,40 @@ let TreeBaseElement = class TreeBaseElement extends LitElement {
32119
33601
  font-size: var(--text-m);
32120
33602
  border-radius: var(--tree-radius);
32121
33603
  transition: background var(--transition, 0.15s ease-out);
32122
- margin: 1px 0;
33604
+ margin: var(--tree-item-gap) 0;
33605
+ }
33606
+
33607
+ /* Windowed list: rows are placed purely by stride arithmetic — a margin above
33608
+ each row only (no collapsing), spacers hold the offscreen height. */
33609
+ .tree-list.virtual .item {
33610
+ margin: var(--tree-item-gap) 0 0;
33611
+ }
33612
+
33613
+ .virtual-spacer {
33614
+ display: block;
33615
+ margin: 0;
33616
+ padding: 0;
33617
+ list-style: none;
33618
+ pointer-events: none;
33619
+ }
33620
+
33621
+ .sticky-anchor {
33622
+ display: contents;
33623
+ }
33624
+
33625
+ :host([data-virtual]) .sticky-anchor {
33626
+ position: sticky;
33627
+ top: 0;
33628
+ z-index: 5;
33629
+ display: block;
33630
+ height: 0;
33631
+ }
33632
+
33633
+ :host([data-virtual]) .sticky-headers {
33634
+ position: absolute;
33635
+ top: 0;
33636
+ left: 0;
33637
+ right: 0;
32123
33638
  }
32124
33639
 
32125
33640
  .tree-caret {
@@ -32174,10 +33689,6 @@ let TreeBaseElement = class TreeBaseElement extends LitElement {
32174
33689
  to { transform: rotate(360deg); }
32175
33690
  }
32176
33691
 
32177
- .item.filtered {
32178
- display: none;
32179
- }
32180
-
32181
33692
  /* Fading-out ghost row (see #runItemAnimations) — inert while it collapses. */
32182
33693
  .item.leaving {
32183
33694
  pointer-events: none;
@@ -32288,6 +33799,14 @@ __decorate([property({
32288
33799
  toAttribute: (value) => value ? null : `false`
32289
33800
  }
32290
33801
  })], TreeBaseElement.prototype, "stickyHeaders", void 0);
33802
+ __decorate([property({
33803
+ reflect: true,
33804
+ attribute: `virtualize`
33805
+ })], TreeBaseElement.prototype, "virtualize", void 0);
33806
+ __decorate([property({
33807
+ type: Number,
33808
+ attribute: `virtualize-threshold`
33809
+ })], TreeBaseElement.prototype, "virtualizeThreshold", void 0);
32291
33810
  __decorate([property({ attribute: false })], TreeBaseElement.prototype, "dragPreviewRenderer", void 0);
32292
33811
  __decorate([property({ attribute: false })], TreeBaseElement.prototype, "dragPreviewSource", void 0);
32293
33812
  __decorate([property({ attribute: false })], TreeBaseElement.prototype, "canDrag", void 0);
@@ -32295,33 +33814,33 @@ __decorate([property({ attribute: false })], TreeBaseElement.prototype, "canDrop
32295
33814
  __decorate([property({ attribute: false })], TreeBaseElement.prototype, "parseExternalDrop", void 0);
32296
33815
  __decorate([property({ attribute: false })], TreeBaseElement.prototype, "applyInternalDrop", void 0);
32297
33816
  __decorate([state()], TreeBaseElement.prototype, "_expandedKeys", void 0);
33817
+ __decorate([state()], TreeBaseElement.prototype, "_scrollTop", void 0);
32298
33818
  __decorate([state()], TreeBaseElement.prototype, "_focusedDepth", void 0);
32299
33819
  __decorate([state()], TreeBaseElement.prototype, "_stickyKeys", void 0);
32300
33820
  TreeBaseElement = __decorate([safeCustomElement(`ixfx-tree-base`)], TreeBaseElement);
32301
33821
  //#endregion
32302
33822
  //#region src/tree/tree-list.ts
32303
- function computeCheckboxState$1(node, selectedNodes) {
32304
- if (node.item.isLeaf || !node.children?.length) return selectedNodes.has(node) ? `checked` : `unchecked`;
32305
- const childStates = node.children.map((c) => computeCheckboxState$1(c, selectedNodes));
32306
- const allChecked = childStates.every((s) => s === `checked`);
32307
- const allUnchecked = childStates.every((s) => s === `unchecked`);
32308
- if (allChecked) return `checked`;
32309
- if (allUnchecked) return `unchecked`;
32310
- return `indeterminate`;
32311
- }
32312
33823
  let TreeListElement = class TreeListElement extends TreeBaseElement {
32313
- #depthsWithIcons = /* @__PURE__ */ new Set();
32314
- render() {
32315
- this.#detectIcons();
32316
- return super.render();
33824
+ /** Checkbox tri-states for the whole loaded tree, rebuilt only when the structure or selection changes. */
33825
+ #checkboxCache;
33826
+ willUpdate(changedProps) {
33827
+ super.willUpdate(changedProps);
33828
+ this.#syncCheckboxStates();
32317
33829
  }
32318
- #detectIcons() {
32319
- this.#depthsWithIcons.clear();
32320
- const visit = (node, depth) => {
32321
- if (node.item.icon) this.#depthsWithIcons.add(depth);
32322
- if (node.children) for (const child of node.children) visit(child, depth + 1);
33830
+ #syncCheckboxStates() {
33831
+ const root = this.root;
33832
+ if (this.interactionMode !== `checked` || !root) {
33833
+ this.#checkboxCache = void 0;
33834
+ return;
33835
+ }
33836
+ const c = this.#checkboxCache;
33837
+ if (c && c.root === root && c.structureVersion === this._structureVersion && c.selectionVersion === this._selectionVersion) return;
33838
+ this.#checkboxCache = {
33839
+ root,
33840
+ structureVersion: this._structureVersion,
33841
+ selectionVersion: this._selectionVersion,
33842
+ states: computeCheckboxStates(root, this._selectedNodes)
32323
33843
  };
32324
- if (this.root) visit(this.root, -1);
32325
33844
  }
32326
33845
  renderNodeContent(node, depth) {
32327
33846
  const isBranch = node.item.isLeaf !== true;
@@ -32339,7 +33858,7 @@ let TreeListElement = class TreeListElement extends TreeBaseElement {
32339
33858
  </span>
32340
33859
  `;
32341
33860
  if (this.interactionMode === `checked`) {
32342
- const state = computeCheckboxState$1(node, this._selectedNodes);
33861
+ const state = this.#checkboxCache?.states.get(node.item.key) ?? `unchecked`;
32343
33862
  return html`${this.#renderCheckbox(node, depth, state)}${caretAndLabel}`;
32344
33863
  }
32345
33864
  return caretAndLabel;
@@ -32373,19 +33892,20 @@ let TreeListElement = class TreeListElement extends TreeBaseElement {
32373
33892
  const isBranch = node.item.isLeaf !== true;
32374
33893
  const isExpanded = this._isExpanded(node, depth);
32375
33894
  const isPending = this._pendingNode === node;
32376
- const hasIconAtDepth = this.#depthsWithIcons.has(depth);
33895
+ const hasIconAtDepth = this._depthsWithIcons.has(depth);
32377
33896
  const isLeaving = this._isLeaving(node);
32378
- const isFiltered = this._isFilteredOut(node, depth);
32379
33897
  const dnd = this._dndRowState(node);
32380
33898
  return html`
32381
33899
  <li
32382
- class="item ${selected ? `selected` : ``} ${tickled ? `tickled` : ``} ${isBranch ? `branch` : `leaf`} ${hasIconAtDepth ? `has-icon-at-depth` : ``} ${isLeaving ? `leaving` : ``} ${isFiltered ? `filtered` : ``} ${dnd.dragging ? `dragging` : ``} ${dnd.dropTarget && dnd.dropPosition === `inside` ? `drop-into` : ``}"
33900
+ class="item ${selected ? `selected` : ``} ${tickled ? `tickled` : ``} ${isBranch ? `branch` : `leaf`} ${hasIconAtDepth ? `has-icon-at-depth` : ``} ${isLeaving ? `leaving` : ``} ${dnd.dragging ? `dragging` : ``} ${dnd.dropTarget && dnd.dropPosition === `inside` ? `drop-into` : ``}"
32383
33901
  data-key=${node.item.key}
32384
33902
  role="treeitem"
32385
33903
  aria-selected=${selected}
32386
33904
  aria-expanded=${isBranch ? isExpanded : nothing}
32387
33905
  aria-hidden=${isLeaving ? `true` : nothing}
32388
33906
  aria-level=${depth + 1}
33907
+ aria-setsize=${this._ariaSetInfo(node)?.setSize ?? nothing}
33908
+ aria-posinset=${this._ariaSetInfo(node)?.posInSet ?? nothing}
32389
33909
  style=${`--depth: ${depth}`}
32390
33910
  draggable=${dnd.draggable ? `true` : nothing}
32391
33911
  part=${dnd.dropTarget ? `drop-target` : nothing}
@@ -40891,86 +42411,30 @@ let MillerBaseElement = class MillerBaseElement extends LitElement {
40891
42411
  return;
40892
42412
  }
40893
42413
  switch (event.key) {
40894
- case `ArrowDown`: {
42414
+ case `ArrowDown`:
40895
42415
  event.preventDefault();
40896
- const current = this.#getTickledNodeAndDepth();
40897
- let targetIndex = 0;
40898
- if (current && current.depth === currentDepth) targetIndex = Math.min(current.index + 1, col.length - 1);
40899
- const targetNode = col[targetIndex];
40900
- if (!targetNode) break;
40901
- this.#setTickledNode(targetNode, currentDepth);
40902
- if (this.interactionMode === `standard` || this.interactionMode === `vscode`) {
40903
- const prevIndex = current?.index ?? 0;
40904
- if (event.shiftKey) {
40905
- if (!this.#anchor || this.#anchor.depth !== currentDepth) {
40906
- const anchorNode = col[prevIndex];
40907
- if (anchorNode) this.#anchor = {
40908
- key: anchorNode.item.key,
40909
- depth: currentDepth,
40910
- index: prevIndex,
40911
- selectionSnapshot: this.interactionMode === `vscode` ? new Set(this._selectedKeys) : void 0
40912
- };
40913
- }
40914
- if (this.#anchor) {
40915
- const from = Math.min(this.#anchor.index, targetIndex);
40916
- const to = Math.max(this.#anchor.index, targetIndex);
40917
- const rangeKeys = col.slice(from, to + 1).map((n) => n.item.key);
40918
- if (this.interactionMode === `vscode`) {
40919
- const snap = this.#anchor.selectionSnapshot;
40920
- for (const k of rangeKeys) if (snap.has(k)) this._selectedKeys.delete(k);
40921
- else this._selectedKeys.add(k);
40922
- } else this._selectedKeys = new Set(rangeKeys);
40923
- }
40924
- } else if (this.interactionMode === `standard`) this.#anchor = {
40925
- key: targetNode.item.key,
40926
- depth: currentDepth,
40927
- index: targetIndex
40928
- };
40929
- else this.#anchor = void 0;
40930
- }
40931
- this.requestUpdate();
42416
+ this.#moveCursor(col, currentDepth, event, (current) => Math.min(current + 1, col.length - 1));
40932
42417
  break;
40933
- }
40934
- case `ArrowUp`: {
42418
+ case `ArrowUp`:
40935
42419
  event.preventDefault();
40936
- const current = this.#getTickledNodeAndDepth();
40937
- let targetIndex = 0;
40938
- if (current && current.depth === currentDepth) targetIndex = Math.max(current.index - 1, 0);
40939
- const targetNode = col[targetIndex];
40940
- if (!targetNode) break;
40941
- this.#setTickledNode(targetNode, currentDepth);
40942
- if (this.interactionMode === `standard` || this.interactionMode === `vscode`) {
40943
- const prevIndex = current?.index ?? 0;
40944
- if (event.shiftKey) {
40945
- if (!this.#anchor || this.#anchor.depth !== currentDepth) {
40946
- const anchorNode = col[prevIndex];
40947
- if (anchorNode) this.#anchor = {
40948
- key: anchorNode.item.key,
40949
- depth: currentDepth,
40950
- index: prevIndex,
40951
- selectionSnapshot: this.interactionMode === `vscode` ? new Set(this._selectedKeys) : void 0
40952
- };
40953
- }
40954
- if (this.#anchor) {
40955
- const from = Math.min(this.#anchor.index, targetIndex);
40956
- const to = Math.max(this.#anchor.index, targetIndex);
40957
- const rangeKeys = col.slice(from, to + 1).map((n) => n.item.key);
40958
- if (this.interactionMode === `vscode`) {
40959
- const snap = this.#anchor.selectionSnapshot;
40960
- for (const k of rangeKeys) if (snap.has(k)) this._selectedKeys.delete(k);
40961
- else this._selectedKeys.add(k);
40962
- } else this._selectedKeys = new Set(rangeKeys);
40963
- }
40964
- } else if (this.interactionMode === `standard`) this.#anchor = {
40965
- key: targetNode.item.key,
40966
- depth: currentDepth,
40967
- index: targetIndex
40968
- };
40969
- else this.#anchor = void 0;
40970
- }
40971
- this.requestUpdate();
42420
+ this.#moveCursor(col, currentDepth, event, (current) => Math.max(current - 1, 0));
42421
+ break;
42422
+ case `Home`:
42423
+ event.preventDefault();
42424
+ this.#moveCursor(col, currentDepth, event, () => 0, true);
42425
+ break;
42426
+ case `End`:
42427
+ event.preventDefault();
42428
+ this.#moveCursor(col, currentDepth, event, () => col.length - 1, true);
42429
+ break;
42430
+ case `PageUp`:
42431
+ event.preventDefault();
42432
+ this.#moveCursor(col, currentDepth, event, (current) => Math.max(current - this.#pageSize(currentDepth), 0), true);
42433
+ break;
42434
+ case `PageDown`:
42435
+ event.preventDefault();
42436
+ this.#moveCursor(col, currentDepth, event, (current) => Math.min(current + this.#pageSize(currentDepth), col.length - 1), true);
40972
42437
  break;
40973
- }
40974
42438
  case `ArrowRight`: {
40975
42439
  event.preventDefault();
40976
42440
  const tickled = this.#getTickledNodeAndDepth();
@@ -41063,14 +42527,28 @@ let MillerBaseElement = class MillerBaseElement extends LitElement {
41063
42527
  window.addEventListener(`pointermove`, this.#onResizeMove);
41064
42528
  window.addEventListener(`pointerup`, this.#onResizeEnd);
41065
42529
  };
42530
+ this.#resizeRaf = null;
42531
+ this.#resizePendingWidth = 0;
41066
42532
  this.#onResizeMove = (event) => {
41067
42533
  if (this.#resizingDepth < 0) return;
41068
- const delta = event.clientX - this.#resizeStartX;
41069
- const newWidth = Math.max(32, this.#resizeStartWidth + delta);
41070
- this.#columnWidths.set(this.#resizingDepth, newWidth);
41071
- this.requestUpdate();
42534
+ this.#resizePendingWidth = Math.max(32, this.#resizeStartWidth + (event.clientX - this.#resizeStartX));
42535
+ this.#resizeRaf ??= requestAnimationFrame(this.#applyResizeWidth);
42536
+ };
42537
+ this.#applyResizeWidth = () => {
42538
+ this.#resizeRaf = null;
42539
+ if (this.#resizingDepth < 0) return;
42540
+ (this.shadowRoot?.querySelectorAll(`.column`)[this.#resizingDepth])?.setAttribute(`style`, this.#explicitWidthStyle(this.#resizePendingWidth));
41072
42541
  };
41073
42542
  this.#onResizeEnd = () => {
42543
+ if (this.#resizeRaf !== null) {
42544
+ cancelAnimationFrame(this.#resizeRaf);
42545
+ this.#resizeRaf = null;
42546
+ }
42547
+ if (this.#resizingDepth >= 0 && this.#resizePendingWidth > 0) {
42548
+ this.#columnWidths.set(this.#resizingDepth, this.#resizePendingWidth);
42549
+ this.requestUpdate();
42550
+ }
42551
+ this.#resizePendingWidth = 0;
41074
42552
  this.#resizingDepth = -1;
41075
42553
  window.removeEventListener(`pointermove`, this.#onResizeMove);
41076
42554
  window.removeEventListener(`pointerup`, this.#onResizeEnd);
@@ -41677,7 +43155,7 @@ let MillerBaseElement = class MillerBaseElement extends LitElement {
41677
43155
  this.#handleStandardClick(node, depth, event);
41678
43156
  break;
41679
43157
  case `manual`: break;
41680
- case `checked`: this.#expandNode(node, depth);
43158
+ case `checked`: this.#expandNode(node, depth, true);
41681
43159
  }
41682
43160
  }
41683
43161
  #handleImplicitClick(node, depth) {
@@ -41687,7 +43165,7 @@ let MillerBaseElement = class MillerBaseElement extends LitElement {
41687
43165
  this._selectedKeys = /* @__PURE__ */ new Set([node.item.key]);
41688
43166
  this._selectedNodes.clear();
41689
43167
  this._selectedNodes.add(node);
41690
- this.#expandNode(node, depth);
43168
+ this.#expandNode(node, depth, true);
41691
43169
  if (!previous.has(node)) this.#dispatchSelectEvent(previous);
41692
43170
  }
41693
43171
  #handleStandardClick(node, depth, event) {
@@ -41730,16 +43208,22 @@ let MillerBaseElement = class MillerBaseElement extends LitElement {
41730
43208
  } : void 0;
41731
43209
  this._selectedByDepth.set(depth, node);
41732
43210
  this.#lastSelectedNode = node;
41733
- this.#expandNode(node, depth);
43211
+ this.#expandNode(node, depth, true);
41734
43212
  if (!previous.has(node) || previous.size !== 1) this.#dispatchSelectEvent(previous);
41735
43213
  }
41736
- #expandNode(node, depth) {
43214
+ /**
43215
+ * Open the column for `node`. `immediate` skips the lazy-load debounce: use
43216
+ * it for deliberate clicks, where the user has committed to this node. The
43217
+ * debounce is kept for keyboard navigation, which can sweep over many
43218
+ * branches in quick succession.
43219
+ */
43220
+ #expandNode(node, depth, immediate = false) {
41737
43221
  if (node.item.isLeaf) {
41738
43222
  this._columns = this._columns.slice(0, depth + 1);
41739
43223
  this.requestUpdate();
41740
43224
  return;
41741
43225
  }
41742
- if (node.children === void 0) this.#loadUnloadedChildren(node, depth, true);
43226
+ if (node.children === void 0) this.#loadUnloadedChildren(node, depth, true, immediate);
41743
43227
  else {
41744
43228
  const newColumns = this._columns.slice(0, depth + 1);
41745
43229
  newColumns.push(node.children);
@@ -41764,10 +43248,11 @@ let MillerBaseElement = class MillerBaseElement extends LitElement {
41764
43248
  * own depth: its column for expansions, -1 for a view root (it sits above
41765
43249
  * column 0, so loaders computing child depth as `depth + 1` stay correct).
41766
43250
  */
41767
- #loadUnloadedChildren(node, parentDepth, fireExpandEvent) {
43251
+ #loadUnloadedChildren(node, parentDepth, fireExpandEvent, immediate = false) {
41768
43252
  const nodeDepth = parentDepth + 1;
41769
43253
  this._columns = this._columns.slice(0, nodeDepth);
41770
43254
  this._loadingDepth = nodeDepth;
43255
+ this.#pendingController?.abort();
41771
43256
  this.#pendingNode = node;
41772
43257
  const controller = new AbortController();
41773
43258
  this.#pendingController = controller;
@@ -41798,15 +43283,20 @@ let MillerBaseElement = class MillerBaseElement extends LitElement {
41798
43283
  this.requestUpdate();
41799
43284
  };
41800
43285
  if (this.model || this.loadChildren) {
41801
- if (this.#expandTimer !== void 0) clearTimeout(this.#expandTimer);
41802
- this.#expandTimer = setTimeout(() => {
43286
+ if (this.#expandTimer !== void 0) {
43287
+ clearTimeout(this.#expandTimer);
43288
+ this.#expandTimer = void 0;
43289
+ }
43290
+ const load = () => {
41803
43291
  this.#expandTimer = void 0;
41804
43292
  if (this.#pendingNode !== node) return;
41805
43293
  (this.model ? this.model.getChildren(node, nodeDepth - 1, controller.signal) : collectDataResult(this.loadChildren({
41806
43294
  node,
41807
43295
  depth: nodeDepth - 1
41808
43296
  }, controller.signal), controller.signal)).then((children) => applyChildren([...children])).catch(handleError);
41809
- }, EXPAND_DEBOUNCE_MS);
43297
+ };
43298
+ if (immediate) load();
43299
+ else this.#expandTimer = setTimeout(load, EXPAND_DEBOUNCE_MS);
41810
43300
  } else {
41811
43301
  this._loadingDepth = void 0;
41812
43302
  this.#pendingNode = void 0;
@@ -41817,10 +43307,12 @@ let MillerBaseElement = class MillerBaseElement extends LitElement {
41817
43307
  }
41818
43308
  }
41819
43309
  #onCheckboxClick(node) {
43310
+ const previous = new Set(this._selectedNodes);
41820
43311
  const currentlySelected = this._selectedKeys.has(node.item.key);
41821
43312
  if (!!node.children?.length ? this.#allDescendantsSelected(node) : currentlySelected) this.#applyCheckedToggle(node, false);
41822
43313
  else this.#applyCheckedToggle(node, true);
41823
43314
  this.requestUpdate();
43315
+ this.#dispatchSelectEvent(previous);
41824
43316
  }
41825
43317
  #allDescendantsSelected(node) {
41826
43318
  if (!this._selectedKeys.has(node.item.key)) return false;
@@ -41857,7 +43349,7 @@ let MillerBaseElement = class MillerBaseElement extends LitElement {
41857
43349
  index
41858
43350
  };
41859
43351
  }
41860
- #scrollNavigatedItemIntoView() {
43352
+ #scrollNavigatedItemIntoView(behavior = `smooth`) {
41861
43353
  requestAnimationFrame(() => {
41862
43354
  const tickled = this.#getTickledNodeAndDepth();
41863
43355
  if (!tickled) return;
@@ -41865,7 +43357,7 @@ let MillerBaseElement = class MillerBaseElement extends LitElement {
41865
43357
  if (!container) return;
41866
43358
  container.querySelectorAll(`.item`)[tickled.index]?.scrollIntoView({
41867
43359
  block: `nearest`,
41868
- behavior: `smooth`
43360
+ behavior
41869
43361
  });
41870
43362
  });
41871
43363
  }
@@ -41880,9 +43372,67 @@ let MillerBaseElement = class MillerBaseElement extends LitElement {
41880
43372
  composed: true
41881
43373
  }));
41882
43374
  }
43375
+ /**
43376
+ * Number of items a PageUp/PageDown moves: how many rows fit in the column's
43377
+ * visible area, less one so the previous page's last row stays in view.
43378
+ */
43379
+ #pageSize(depth) {
43380
+ const content = this.shadowRoot?.querySelectorAll(`.column`)[depth]?.querySelector(`.column-content`);
43381
+ const item = content?.querySelector(`.item`);
43382
+ if (!content || !item || item.offsetHeight === 0) return 10;
43383
+ return Math.max(1, Math.floor(content.clientHeight / item.offsetHeight) - 1);
43384
+ }
43385
+ /**
43386
+ * Move the cursor within column `col` to the index returned by `target`
43387
+ * (given the current cursor index, or -1 when the cursor isn't in this
43388
+ * column, which behaves as "before the first item"). Handles Shift range
43389
+ * extension and anchor seating for `standard` / `vscode` modes.
43390
+ * `scroll` brings the new item into view (for jumps larger than one row).
43391
+ */
43392
+ #moveCursor(col, currentDepth, event, target, scroll = false) {
43393
+ const current = this.#getTickledNodeAndDepth();
43394
+ const targetIndex = target(current !== void 0 && current.depth === currentDepth ? current.index : -1);
43395
+ const targetNode = col[targetIndex];
43396
+ if (!targetNode) return;
43397
+ this.#setTickledNode(targetNode, currentDepth);
43398
+ if (this.interactionMode === `standard` || this.interactionMode === `vscode`) {
43399
+ const prevIndex = current?.index ?? 0;
43400
+ if (event.shiftKey) {
43401
+ if (!this.#anchor || this.#anchor.depth !== currentDepth) {
43402
+ const anchorNode = col[prevIndex];
43403
+ if (anchorNode) this.#anchor = {
43404
+ key: anchorNode.item.key,
43405
+ depth: currentDepth,
43406
+ index: prevIndex,
43407
+ selectionSnapshot: this.interactionMode === `vscode` ? new Set(this._selectedKeys) : void 0
43408
+ };
43409
+ }
43410
+ if (this.#anchor) {
43411
+ const from = Math.min(this.#anchor.index, targetIndex);
43412
+ const to = Math.max(this.#anchor.index, targetIndex);
43413
+ const rangeKeys = col.slice(from, to + 1).map((n) => n.item.key);
43414
+ if (this.interactionMode === `vscode`) {
43415
+ const snap = this.#anchor.selectionSnapshot;
43416
+ for (const k of rangeKeys) if (snap.has(k)) this._selectedKeys.delete(k);
43417
+ else this._selectedKeys.add(k);
43418
+ } else this._selectedKeys = new Set(rangeKeys);
43419
+ }
43420
+ } else if (this.interactionMode === `standard`) this.#anchor = {
43421
+ key: targetNode.item.key,
43422
+ depth: currentDepth,
43423
+ index: targetIndex
43424
+ };
43425
+ else this.#anchor = void 0;
43426
+ }
43427
+ this.requestUpdate();
43428
+ if (scroll) this.#scrollNavigatedItemIntoView(`auto`);
43429
+ }
41883
43430
  #onKeyDown;
41884
43431
  #onResizeStart;
43432
+ #resizeRaf;
43433
+ #resizePendingWidth;
41885
43434
  #onResizeMove;
43435
+ #applyResizeWidth;
41886
43436
  #onResizeEnd;
41887
43437
  #getColumnHeader(depth) {
41888
43438
  if (depth === 0) return this.#displayRoot?.item.label ?? ``;
@@ -41896,9 +43446,12 @@ let MillerBaseElement = class MillerBaseElement extends LitElement {
41896
43446
  }
41897
43447
  return ``;
41898
43448
  }
43449
+ #explicitWidthStyle(width) {
43450
+ return `width:${width}px;flex-grow:0;flex-shrink:0;flex-basis:auto;max-width:none`;
43451
+ }
41899
43452
  #columnStyle(depth, isLast) {
41900
43453
  const w = this.#columnWidths.get(depth);
41901
- if (w !== void 0) return `width:${w}px;flex-grow:0;flex-shrink:0;flex-basis:auto;max-width:none`;
43454
+ if (w !== void 0) return this.#explicitWidthStyle(w);
41902
43455
  if (this.stretchLastColumn && isLast) return `width:max-content;flex-grow:1;flex-shrink:0;flex-basis:auto;min-width:0`;
41903
43456
  return `width:max-content;flex-grow:0;flex-shrink:0;flex-basis:auto`;
41904
43457
  }
@@ -42148,8 +43701,24 @@ __decorate([state()], MillerBaseElement.prototype, "_selectedNodes", void 0);
42148
43701
  MillerBaseElement = __decorate([safeCustomElement(`ixfx-miller-base`)], MillerBaseElement);
42149
43702
  //#endregion
42150
43703
  //#region src/miller/miller-list.ts
43704
+ const HAS_CHECKED = 1;
43705
+ const HAS_UNCHECKED = 2;
42151
43706
  /**
42152
- * Recursively compute the checkbox state for a node based on `selectedKeys`.
43707
+ * Walk the loaded subtree under `node`, returning which kinds of leaf were
43708
+ * seen (`HAS_CHECKED` | `HAS_UNCHECKED`). Stops as soon as both have been
43709
+ * seen, since the result can no longer change.
43710
+ */
43711
+ function scanCheckState(node, selectedKeys) {
43712
+ if (node.item.isLeaf || !node.children?.length) return selectedKeys.has(node.item.key) ? HAS_CHECKED : HAS_UNCHECKED;
43713
+ let seen = 0;
43714
+ for (const child of node.children) {
43715
+ seen |= scanCheckState(child, selectedKeys);
43716
+ if (seen === 3) break;
43717
+ }
43718
+ return seen;
43719
+ }
43720
+ /**
43721
+ * Compute the checkbox state for a node based on `selectedKeys`.
42153
43722
  *
42154
43723
  * - Unloaded branch (`children === undefined`): treated as a leaf — we can only
42155
43724
  * report on what is known.
@@ -42158,12 +43727,9 @@ MillerBaseElement = __decorate([safeCustomElement(`ixfx-miller-base`)], MillerBa
42158
43727
  * indeterminate otherwise.
42159
43728
  */
42160
43729
  function computeCheckboxState(node, selectedKeys) {
42161
- if (node.item.isLeaf || !node.children?.length) return selectedKeys.has(node.item.key) ? `checked` : `unchecked`;
42162
- const childStates = node.children.map((c) => computeCheckboxState(c, selectedKeys));
42163
- const allChecked = childStates.every((s) => s === `checked`);
42164
- const allUnchecked = childStates.every((s) => s === `unchecked`);
42165
- if (allChecked) return `checked`;
42166
- if (allUnchecked) return `unchecked`;
43730
+ const seen = scanCheckState(node, selectedKeys);
43731
+ if (seen === HAS_CHECKED) return `checked`;
43732
+ if (seen === HAS_UNCHECKED) return `unchecked`;
42167
43733
  return `indeterminate`;
42168
43734
  }
42169
43735
  let MillerListElement = class MillerListElement extends MillerBaseElement {
@@ -50378,9 +51944,12 @@ var TabController = class {
50378
51944
  }
50379
51945
  /** The tab's visible label text, excluding any `slot="toolbar"` content. */
50380
51946
  #labelOf(item) {
50381
- const clone = item.cloneNode(true);
50382
- clone.querySelectorAll(`[slot="toolbar"]`).forEach((el) => el.remove());
50383
- return (clone.textContent ?? ``).trim();
51947
+ let text = ``;
51948
+ for (const node of item.childNodes) {
51949
+ if (node instanceof Element && node.getAttribute(`slot`) === `toolbar`) continue;
51950
+ text += node.textContent ?? ``;
51951
+ }
51952
+ return text.trim();
50384
51953
  }
50385
51954
  #registerCommand(tab) {
50386
51955
  if (!this.#commands) return;
@@ -50497,9 +52066,12 @@ let TabPanelsElement = class TabPanelsElement extends LitElement {
50497
52066
  }
50498
52067
  /** The tab's visible label text, excluding any `slot="toolbar"` content. */
50499
52068
  #labelOf(item) {
50500
- const clone = item.cloneNode(true);
50501
- clone.querySelectorAll(`[slot="toolbar"]`).forEach((el) => el.remove());
50502
- return (clone.textContent ?? ``).trim();
52069
+ let text = ``;
52070
+ for (const node of item.childNodes) {
52071
+ if (node instanceof Element && node.getAttribute(`slot`) === `toolbar`) continue;
52072
+ text += node.textContent ?? ``;
52073
+ }
52074
+ return text.trim();
50503
52075
  }
50504
52076
  static {
50505
52077
  this.styles = css`
@@ -53368,7 +54940,7 @@ var TreeController = class {
53368
54940
  const treeNode = this.#nodeToTree.get(node);
53369
54941
  if (!treeNode) return;
53370
54942
  node.children = this.#childrenOf(treeNode);
53371
- this.#element.requestUpdate();
54943
+ this.#element.invalidateStructure();
53372
54944
  }, { signal });
53373
54945
  this.#element.addEventListener(`collapse`, (_e) => {}, { signal });
53374
54946
  }
@@ -53390,7 +54962,10 @@ var TreeController = class {
53390
54962
  if (!node) return false;
53391
54963
  if (node.children === void 0) {
53392
54964
  const mapped = this.#nodeToTree.get(node);
53393
- if (mapped) node.children = this.#childrenOf(mapped);
54965
+ if (mapped) {
54966
+ node.children = this.#childrenOf(mapped);
54967
+ this.#element.invalidateStructure();
54968
+ }
53394
54969
  }
53395
54970
  currentColumn = node.children ?? [];
53396
54971
  }