@ixfx/components 0.7.3 → 0.7.5

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 (141) hide show
  1. package/bundle/index.d.ts +1355 -526
  2. package/bundle/index.d.ts.map +1 -1
  3. package/bundle/index.js +3277 -384
  4. package/bundle/index.js.map +1 -1
  5. package/dist/ac-text.d.ts +1 -1
  6. package/dist/ac-text.js +1 -1
  7. package/dist/{ac-token-TCLAZeVg.d.ts → ac-token--_vCHXWw.d.ts} +2 -2
  8. package/dist/{ac-token-TCLAZeVg.d.ts.map → ac-token--_vCHXWw.d.ts.map} +1 -1
  9. package/dist/ac-token.d.ts +1 -1
  10. package/dist/ac-token.js +2 -2
  11. package/dist/button.d.ts +1 -1
  12. package/dist/button.d.ts.map +1 -1
  13. package/dist/colour-picker.d.ts +1 -1
  14. package/dist/crumbs.d.ts +6 -2
  15. package/dist/crumbs.d.ts.map +1 -1
  16. package/dist/crumbs.js +13 -3
  17. package/dist/crumbs.js.map +1 -1
  18. package/dist/{data-grid-Db4QpnII.d.ts → data-grid-CtJ35v8L.d.ts} +59 -2
  19. package/dist/data-grid-CtJ35v8L.d.ts.map +1 -0
  20. package/dist/data-grid.d.ts +1 -1
  21. package/dist/data-grid.js +268 -14
  22. package/dist/data-grid.js.map +1 -1
  23. package/dist/detail-list-BS5dRDda.d.ts +175 -0
  24. package/dist/detail-list-BS5dRDda.d.ts.map +1 -0
  25. package/dist/detail-list.d.ts +3 -0
  26. package/dist/detail-list.js +1031 -0
  27. package/dist/detail-list.js.map +1 -0
  28. package/dist/grid-list-D-GbghSH.d.ts +321 -0
  29. package/dist/grid-list-D-GbghSH.d.ts.map +1 -0
  30. package/dist/grid-list.d.ts +3 -0
  31. package/dist/grid-list.js +994 -0
  32. package/dist/grid-list.js.map +1 -0
  33. package/dist/{grouped-item-lister-BWlC9S4e.d.ts → grouped-item-lister-fdZHVxFM.d.ts} +3 -3
  34. package/dist/{grouped-item-lister-BWlC9S4e.d.ts.map → grouped-item-lister-fdZHVxFM.d.ts.map} +1 -1
  35. package/dist/grouped-item-lister.d.ts +1 -1
  36. package/dist/{icon-DR_3rSyZ.d.ts → icon-CDGprSdE.d.ts} +2 -2
  37. package/dist/icon-CDGprSdE.d.ts.map +1 -0
  38. package/dist/icons.d.ts +2 -2
  39. package/dist/incr-search-DpbSJtgj.js +962 -0
  40. package/dist/incr-search-DpbSJtgj.js.map +1 -0
  41. package/dist/incr-search.d.ts +2 -2
  42. package/dist/incr-search.js +2 -3
  43. package/dist/{index-C59-JEPl.d.ts → index-9AhaHIT2.d.ts} +3 -3
  44. package/dist/{index-C59-JEPl.d.ts.map → index-9AhaHIT2.d.ts.map} +1 -1
  45. package/dist/index-CBS4z1v-.d.ts +365 -0
  46. package/dist/index-CBS4z1v-.d.ts.map +1 -0
  47. package/dist/{index-DsjpDksO.d.ts → index-CS9yM8L-.d.ts} +2 -2
  48. package/dist/{index-DsjpDksO.d.ts.map → index-CS9yM8L-.d.ts.map} +1 -1
  49. package/dist/{index-BUB5SICW.d.ts → index-CoqtMSd9.d.ts} +2 -2
  50. package/dist/{index-BUB5SICW.d.ts.map → index-CoqtMSd9.d.ts.map} +1 -1
  51. package/dist/{index-CJ5fshD3.d.ts → index-UvnoZUAo.d.ts} +2 -2
  52. package/dist/{index-CJ5fshD3.d.ts.map → index-UvnoZUAo.d.ts.map} +1 -1
  53. package/dist/{index-BOUaDIW-.d.ts → index-tq6f8007.d.ts} +2 -2
  54. package/dist/{index-BOUaDIW-.d.ts.map → index-tq6f8007.d.ts.map} +1 -1
  55. package/dist/index.d.ts +188 -243
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js +376 -1004
  58. package/dist/index.js.map +1 -1
  59. package/dist/interaction-Cr13azbM.js +3 -0
  60. package/dist/{labelled-input-base-Dp_9KP0G.d.ts → labelled-input-base-DiTme1JO.d.ts} +2 -2
  61. package/dist/{labelled-input-base-Dp_9KP0G.d.ts.map → labelled-input-base-DiTme1JO.d.ts.map} +1 -1
  62. package/dist/labelled-radial-input.d.ts +2 -2
  63. package/dist/labelled-radial-input.d.ts.map +1 -1
  64. package/dist/labelled-range-input.d.ts +1 -1
  65. package/dist/labelled-slider-input.d.ts +1 -1
  66. package/dist/list-item-animation-DuiwgN3l.js +112 -0
  67. package/dist/list-item-animation-DuiwgN3l.js.map +1 -0
  68. package/dist/list-selection-controller-DTFI40li.js +264 -0
  69. package/dist/list-selection-controller-DTFI40li.js.map +1 -0
  70. package/dist/{list-selection-types-DSuRNWpx.d.ts → list-selection-types-B5ioL-dG.d.ts} +6 -1
  71. package/dist/list-selection-types-B5ioL-dG.d.ts.map +1 -0
  72. package/dist/{menu-DMzjkVnK.js → menu-Bg90Qr0B.js} +3 -3
  73. package/dist/{menu-DMzjkVnK.js.map → menu-Bg90Qr0B.js.map} +1 -1
  74. package/dist/{menu-item-D2S4i8u1.js → menu-item-Bx-Fs7jq.js} +3 -3
  75. package/dist/{menu-item-D2S4i8u1.js.map → menu-item-Bx-Fs7jq.js.map} +1 -1
  76. package/dist/{menu-item-ZmkXNjek.d.ts → menu-item-CZi9vy5O.d.ts} +2 -2
  77. package/dist/{menu-item-ZmkXNjek.d.ts.map → menu-item-CZi9vy5O.d.ts.map} +1 -1
  78. package/dist/menu.d.ts +2 -2
  79. package/dist/menu.js +2 -2
  80. package/dist/miller.d.ts +82 -4
  81. package/dist/miller.d.ts.map +1 -1
  82. package/dist/miller.js +329 -70
  83. package/dist/miller.js.map +1 -1
  84. package/dist/narrowed-text.js +3 -4
  85. package/dist/narrowed-text.js.map +1 -1
  86. package/dist/panel.d.ts +1 -1
  87. package/dist/plots.js +1 -1
  88. package/dist/select-horiz.js +2 -2
  89. package/dist/{split-layout-C5SYbDrq.d.ts → split-layout-JK3xWVX2.d.ts} +2 -2
  90. package/dist/{split-layout-C5SYbDrq.d.ts.map → split-layout-JK3xWVX2.d.ts.map} +1 -1
  91. package/dist/split-layout.d.ts +1 -1
  92. package/dist/{tab-list-DbMIL520.d.ts → tab-list-CGyPt07Z.d.ts} +2 -2
  93. package/dist/{tab-list-DbMIL520.d.ts.map → tab-list-CGyPt07Z.d.ts.map} +1 -1
  94. package/dist/tabs.d.ts +1 -1
  95. package/dist/{tickled-controller-Ds1DMyG7.d.ts → tickled-controller-Bn3YdQsM.d.ts} +12 -1
  96. package/dist/{tickled-controller-Ds1DMyG7.d.ts.map → tickled-controller-Bn3YdQsM.d.ts.map} +1 -1
  97. package/dist/{tickled-controller-h9GmJ_bU.js → tickled-controller-C66DgJHl.js} +16 -1
  98. package/dist/tickled-controller-C66DgJHl.js.map +1 -0
  99. package/dist/{titlebar-B23MrIbI.d.ts → titlebar-Bj5pfdw_.d.ts} +2 -2
  100. package/dist/{titlebar-B23MrIbI.d.ts.map → titlebar-Bj5pfdw_.d.ts.map} +1 -1
  101. package/dist/titlebar.d.ts +1 -1
  102. package/dist/{tree-Ch4W8qXQ.js → tree-DAii2A1g.js} +190 -29
  103. package/dist/tree-DAii2A1g.js.map +1 -0
  104. package/dist/{tree-component-BvV4muxx.d.ts → tree-component-F6qZ0Gdv.d.ts} +9 -1
  105. package/dist/{tree-component-BvV4muxx.d.ts.map → tree-component-F6qZ0Gdv.d.ts.map} +1 -1
  106. package/dist/tree.d.ts +28 -4
  107. package/dist/tree.d.ts.map +1 -1
  108. package/dist/tree.js +1 -1
  109. package/dist/vertical-list-DwtCPy1b.d.ts +143 -0
  110. package/dist/vertical-list-DwtCPy1b.d.ts.map +1 -0
  111. package/dist/vertical-list.d.ts +3 -121
  112. package/dist/vertical-list.js +642 -1
  113. package/dist/vertical-list.js.map +1 -0
  114. package/dist/{xy-axis-DWizs0bt.js → xy-axis-VrX8dmvS.js} +2 -2
  115. package/dist/{xy-axis-DWizs0bt.js.map → xy-axis-VrX8dmvS.js.map} +1 -1
  116. package/docs-user/README.md +1 -0
  117. package/docs-user/data-grid.md +70 -2
  118. package/docs-user/detail-list.md +39 -1
  119. package/docs-user/grid-list.md +298 -0
  120. package/docs-user/incr-search.md +12 -2
  121. package/docs-user/index.json +7 -2
  122. package/docs-user/llms.txt +1 -0
  123. package/docs-user/tree.md +35 -1
  124. package/docs-user/vertical-list.md +58 -2
  125. package/llms.txt +1 -0
  126. package/package.json +17 -2
  127. package/dist/data-grid-Db4QpnII.d.ts.map +0 -1
  128. package/dist/element-search-Y8SRfPGM.js +0 -185
  129. package/dist/element-search-Y8SRfPGM.js.map +0 -1
  130. package/dist/icon-DR_3rSyZ.d.ts.map +0 -1
  131. package/dist/incr-search-vCzuao-2.js +0 -137
  132. package/dist/incr-search-vCzuao-2.js.map +0 -1
  133. package/dist/index-Cu5i8zZY.d.ts +0 -160
  134. package/dist/index-Cu5i8zZY.d.ts.map +0 -1
  135. package/dist/interaction-D5XdKzbR.js +0 -2
  136. package/dist/list-selection-types-DSuRNWpx.d.ts.map +0 -1
  137. package/dist/tickled-controller-h9GmJ_bU.js.map +0 -1
  138. package/dist/tree-Ch4W8qXQ.js.map +0 -1
  139. package/dist/vertical-list-B4w_ksJk.js +0 -814
  140. package/dist/vertical-list-B4w_ksJk.js.map +0 -1
  141. package/dist/vertical-list.d.ts.map +0 -1
package/bundle/index.js CHANGED
@@ -8,8 +8,9 @@ import { createCommandRegistry } from "@clinth/ui-commands";
8
8
  import { Trees } from "@ixfx/collections";
9
9
  import { clamp, clamper, rangeStream, round, scaler } from "@ixfx/numbers";
10
10
  import { repeat } from "lit/directives/repeat.js";
11
- import { Easings } from "@ixfx/modulation";
11
+ import { createRef, ref } from "lit/directives/ref.js";
12
12
  import { classMap } from "lit/directives/class-map.js";
13
+ import { Easings } from "@ixfx/modulation";
13
14
  import "@clinth/vcanvas";
14
15
  import { keyed } from "lit/directives/keyed.js";
15
16
  //#region node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/css-tag.js
@@ -752,6 +753,21 @@ var TickledController = class TickledController {
752
753
  return this._tickledId;
753
754
  }
754
755
  /**
756
+ * The item currently under the cursor, or `undefined`. Alias of `tickledId`
757
+ * for parity with other controllers.
758
+ */
759
+ get current() {
760
+ return this._tickledId;
761
+ }
762
+ /**
763
+ * Move the cursor programmatically (e.g. to reveal a match or re-seat it
764
+ * after the item set changed). Does not change the input mode. Alias of
765
+ * `setTickled`.
766
+ */
767
+ focus(id) {
768
+ this.setTickled(id);
769
+ }
770
+ /**
755
771
  * Check if a specific item is tickled
756
772
  */
757
773
  isTickled(id) {
@@ -2384,14 +2400,15 @@ function createIncrSearch(options = {}) {
2384
2400
  }
2385
2401
  //#endregion
2386
2402
  //#region src/incr-search/controller-tree.ts
2387
- const DEFAULT_HIGHLIGHT_KEY = `incr-search-highlight`;
2403
+ const DEFAULT_HIGHLIGHT_KEY$1 = `incr-search-highlight`;
2388
2404
  /**
2389
- * Incremental-search controller for any component implementing `TreeSearchHost`
2390
- * (i.e. `ixfx-tree-list`, `ixfx-miller-list`, `ixfx-crumb-navigation`).
2405
+ * External-input filter-search controller for `TreeSearchHost` components.
2391
2406
  *
2392
- * Replaces the previous separate `IncrSearchTreeController` / `IncrSearchMillerController`
2393
- * classes the unified `TreeFilterPredicate` (with depth) means one implementation works
2394
- * for all tree-like components.
2407
+ * @deprecated `ixfx-tree-list` / `ixfx-miller-list` now have a built-in
2408
+ * incremental-search popover with navigate / select / filter modes invoke
2409
+ * `el.registry.invoke('list.search.filter')` (see `docs/infra-incremental.md`)
2410
+ * instead of wiring an external `<input>`. This class remains for existing
2411
+ * consumers and covers filter mode only.
2395
2412
  *
2396
2413
  * @example Using with ixfx-tree-list:
2397
2414
  * ```typescript
@@ -2445,7 +2462,7 @@ var IncrSearchTreeController = class {
2445
2462
  this.#opts.input.removeEventListener(`keydown`, this.#onKeydown);
2446
2463
  }
2447
2464
  this.#el.filterPredicate = void 0;
2448
- clearHighlights({ key: this.#opts.highlightKey ?? DEFAULT_HIGHLIGHT_KEY });
2465
+ clearHighlights({ key: this.#opts.highlightKey ?? DEFAULT_HIGHLIGHT_KEY$1 });
2449
2466
  this.#labelMap.clear();
2450
2467
  }
2451
2468
  async setQuery(query) {
@@ -2468,7 +2485,7 @@ var IncrSearchTreeController = class {
2468
2485
  const matched = new Set(state.results.map((r) => r.item.node.item.key));
2469
2486
  const hasQuery = state.query.trim().length > 0;
2470
2487
  this.#el.filterPredicate = hasQuery && matched.size > 0 ? (node, _depth) => matched.has(node.item.key) : void 0;
2471
- const key = this.#opts.highlightKey ?? DEFAULT_HIGHLIGHT_KEY;
2488
+ const key = this.#opts.highlightKey ?? DEFAULT_HIGHLIGHT_KEY$1;
2472
2489
  if (!this.#el.shadowRoot) return;
2473
2490
  if (!state.query.trim()) {
2474
2491
  clearHighlights({ key });
@@ -2482,11 +2499,149 @@ var IncrSearchTreeController = class {
2482
2499
  }
2483
2500
  };
2484
2501
  //#endregion
2502
+ //#region src/interaction/keyed-list.ts
2503
+ /**
2504
+ * Helpers for key-based selection on the Element-flavoured list components
2505
+ * (`ixfx-vertical-list`, `ixfx-detail-list`, `ixfx-grid-list`).
2506
+ *
2507
+ * A "key" is a stable string the host puts on each item element — `data-key`
2508
+ * (preferred) or `data-value`. It survives the host rebuilding the item set
2509
+ * (re-fetch, re-sort, filter) where raw `Element` references do not.
2510
+ */
2511
+ /**
2512
+ * The stable key for an item element: `data-key`, then `data-value`, else
2513
+ * `undefined`.
2514
+ */
2515
+ function keyForElement(el) {
2516
+ return el.getAttribute(`data-key`) ?? el.getAttribute(`data-value`) ?? void 0;
2517
+ }
2518
+ /**
2519
+ * A lazily-built key → element index over a container. Rebuilt on first use
2520
+ * and after any child-list mutation under `root`; `invalidate()` forces a
2521
+ * rebuild on the next `get()`.
2522
+ *
2523
+ * ```ts
2524
+ * #keyIndex = new KeyedElementIndex(this);
2525
+ * // ...
2526
+ * const el = this.#keyIndex.get(`row-42`);
2527
+ * // on disconnect:
2528
+ * this.#keyIndex.dispose();
2529
+ * ```
2530
+ */
2531
+ var KeyedElementIndex = class {
2532
+ #root;
2533
+ #selector;
2534
+ #map = /* @__PURE__ */ new Map();
2535
+ #dirty = true;
2536
+ #observer;
2537
+ /**
2538
+ * @param root Container whose descendants are indexed.
2539
+ * @param itemSelector Which descendants count as items (default: any `[data-key]`).
2540
+ */
2541
+ constructor(root, itemSelector = `[data-key]`) {
2542
+ this.#root = root;
2543
+ this.#selector = itemSelector;
2544
+ this.#observer = new MutationObserver(() => {
2545
+ this.#dirty = true;
2546
+ });
2547
+ this.#observer.observe(root, {
2548
+ childList: true,
2549
+ subtree: true
2550
+ });
2551
+ }
2552
+ /** The element with this key, or `undefined`. First match wins on duplicates. */
2553
+ get(key) {
2554
+ if (this.#dirty) this.#rebuild();
2555
+ return this.#map.get(key);
2556
+ }
2557
+ /** Force a rebuild on the next `get()` — e.g. after a `data-key` attribute change. */
2558
+ invalidate() {
2559
+ this.#dirty = true;
2560
+ }
2561
+ /** Stop observing and drop the cache. */
2562
+ dispose() {
2563
+ this.#observer.disconnect();
2564
+ this.#map.clear();
2565
+ }
2566
+ #rebuild() {
2567
+ this.#map.clear();
2568
+ for (const el of this.#root.querySelectorAll(this.#selector)) {
2569
+ const key = keyForElement(el);
2570
+ if (key !== void 0 && !this.#map.has(key)) this.#map.set(key, el);
2571
+ }
2572
+ this.#dirty = false;
2573
+ }
2574
+ };
2575
+ //#endregion
2576
+ //#region src/incr-search/element-list-search-adapter.ts
2577
+ function defaultLabel(el) {
2578
+ return el.getAttribute(`data-search-label`) ?? el.textContent?.trim() ?? ``;
2579
+ }
2580
+ /**
2581
+ * A {@link ListSearchAdapter} over the light-DOM item elements of a list
2582
+ * component. Filter mode toggles `hidden` (only on elements it hid itself, so a
2583
+ * consumer's own `hidden` items are left alone). Highlights are painted on the
2584
+ * item elements in the light DOM — the consuming page styles
2585
+ * `::highlight(<highlightKey>)`. See `docs/infra-incremental.md`.
2586
+ */
2587
+ function createElementListSearchAdapter(host, options = {}) {
2588
+ const itemSelector = options.itemSelector ?? `:scope > *`;
2589
+ const getLabel = options.getLabel ?? defaultLabel;
2590
+ const getKey = options.getKey ?? ((el) => keyForElement(el) ?? defaultLabel(el));
2591
+ const hidden = /* @__PURE__ */ new Set();
2592
+ const items = () => Array.from(host.querySelectorAll(itemSelector));
2593
+ return {
2594
+ getItems: items,
2595
+ getLabel,
2596
+ getKey,
2597
+ focusItem: (el) => {
2598
+ if (el) host.focusKey(getKey(el));
2599
+ },
2600
+ activateItem: (el) => {
2601
+ el.dispatchEvent(new MouseEvent(`click`, {
2602
+ bubbles: true,
2603
+ composed: true
2604
+ }));
2605
+ },
2606
+ selectItems: (els) => {
2607
+ host.selectKeys(els.map(getKey));
2608
+ },
2609
+ applyFilter: (matched) => {
2610
+ if (matched === void 0) {
2611
+ for (const el of hidden) el.hidden = false;
2612
+ hidden.clear();
2613
+ return;
2614
+ }
2615
+ for (const el of items()) if (matched.has(getKey(el))) {
2616
+ if (hidden.delete(el)) el.hidden = false;
2617
+ } else {
2618
+ el.hidden = true;
2619
+ hidden.add(el);
2620
+ }
2621
+ },
2622
+ highlightRoot: () => host,
2623
+ highlightSelector: itemSelector,
2624
+ highlightKeyOf: (el) => getKey(el),
2625
+ highlightText: getLabel,
2626
+ highlightKey: options.highlightKey,
2627
+ container: () => host
2628
+ };
2629
+ }
2630
+ //#endregion
2485
2631
  //#region src/incr-search/element-search.ts
2486
2632
  const defaultGetLabel = (el) => el.textContent?.trim() ?? ``;
2487
2633
  function defaultGetKey(el) {
2488
2634
  return el.getAttribute(`data-key`) ?? el.getAttribute(`data-value`) ?? el.textContent?.trim() ?? ``;
2489
2635
  }
2636
+ /**
2637
+ * DOM filter-search over a container's item elements (sets `hidden` on
2638
+ * non-matches, applies highlights).
2639
+ *
2640
+ * @deprecated The list components (`ixfx-grid-list`, `ixfx-vertical-list`,
2641
+ * `ixfx-detail-list`, …) now have a built-in navigate / select / filter search
2642
+ * popover — see `docs/infra-incremental.md` and `ListSearchController`. Kept for
2643
+ * standalone / non-component use.
2644
+ */
2490
2645
  function createElementIncrSearch(opts) {
2491
2646
  const { container, itemSelector, getLabel = defaultGetLabel, getKey = defaultGetKey, labelSelector, highlightKey } = opts;
2492
2647
  let elementByKey = /* @__PURE__ */ new Map();
@@ -2560,6 +2715,512 @@ function createElementIncrSearch(opts) {
2560
2715
  };
2561
2716
  }
2562
2717
  //#endregion
2718
+ //#region src/incr-search/list-search-commands.ts
2719
+ /** Command ids registered by {@link registerListSearchCommands}, keyed by mode. */
2720
+ const LIST_SEARCH_COMMAND_IDS = {
2721
+ navigate: `list.search.navigate`,
2722
+ select: `list.search.select`,
2723
+ filter: `list.search.filter`
2724
+ };
2725
+ const LABELS = {
2726
+ navigate: {
2727
+ label: `Find`,
2728
+ description: `Type to move the cursor to the best match`
2729
+ },
2730
+ select: {
2731
+ label: `Find & Select`,
2732
+ description: `Type to select every matching item`
2733
+ },
2734
+ filter: {
2735
+ label: `Filter`,
2736
+ description: `Type to hide items that don't match`
2737
+ }
2738
+ };
2739
+ /**
2740
+ * Register one command per mode the controller offers (see
2741
+ * `controller.registeredModes`). Each command opens the search popover in its
2742
+ * mode. Returns a function that unregisters them again — call it from
2743
+ * `disconnectedCallback`. See `docs/infra-incremental.md`.
2744
+ */
2745
+ function registerListSearchCommands(registry, controller, options = {}) {
2746
+ const id = (mode) => options.idPrefix ? `${options.idPrefix}.search.${mode}` : LIST_SEARCH_COMMAND_IDS[mode];
2747
+ const registered = [];
2748
+ for (const mode of controller.registeredModes) {
2749
+ const commandId = id(mode);
2750
+ if (registry.has(commandId)) continue;
2751
+ registry.register({
2752
+ id: commandId,
2753
+ label: LABELS[mode].label,
2754
+ description: LABELS[mode].description,
2755
+ execute: () => controller.open(mode)
2756
+ });
2757
+ registered.push(commandId);
2758
+ }
2759
+ return () => {
2760
+ for (const commandId of registered) registry.unregister(commandId);
2761
+ };
2762
+ }
2763
+ //#endregion
2764
+ //#region src/incr-search/list-search-controller.ts
2765
+ /** Canonical order — used for the switch-row and to normalise a requested set. */
2766
+ const LIST_SEARCH_MODES = [
2767
+ `navigate`,
2768
+ `select`,
2769
+ `filter`
2770
+ ];
2771
+ const DEFAULT_HIGHLIGHT_KEY = `incr-search-highlight`;
2772
+ const INPUT_CLASS = `list-search-input`;
2773
+ /**
2774
+ * Shared brain for the three-mode incremental-search popover. A Lit
2775
+ * `ReactiveController`: the host adds `listSearchStyles` to `static styles`,
2776
+ * drops `${controller.renderField()}` into `render()`, and registers the
2777
+ * commands with {@link registerListSearchCommands}. See `docs/infra-incremental.md`.
2778
+ */
2779
+ var ListSearchController = class {
2780
+ #host;
2781
+ #adapter;
2782
+ #modes;
2783
+ #placeholder;
2784
+ #search;
2785
+ #unsub;
2786
+ #observer;
2787
+ #itemByKey = /* @__PURE__ */ new Map();
2788
+ #open = false;
2789
+ #mode = `filter`;
2790
+ #query = ``;
2791
+ #matchIndex = 0;
2792
+ #matches = [];
2793
+ /** Whether a filter has been pushed to the adapter and still needs undoing. */
2794
+ #filterActive = false;
2795
+ constructor(host, options) {
2796
+ this.#host = host;
2797
+ host.addController(this);
2798
+ this.#adapter = options.adapter;
2799
+ const requested = new Set(options.modes ?? LIST_SEARCH_MODES);
2800
+ this.#modes = LIST_SEARCH_MODES.filter((m) => requested.has(m));
2801
+ this.#mode = this.#modes[0] ?? `filter`;
2802
+ this.#placeholder = options.placeholder ?? `Search…`;
2803
+ this.#search = createIncrSearch({
2804
+ selector: (i) => i.label,
2805
+ fzf: options.fzf
2806
+ });
2807
+ this.#unsub = this.#search.onChange((state) => this.#onState(state));
2808
+ }
2809
+ /** `false` when no modes are available — the host should register no commands and render no field. */
2810
+ get enabled() {
2811
+ return this.#modes.length > 0;
2812
+ }
2813
+ /** The modes actually offered (component capability ∩ consumer request). */
2814
+ get registeredModes() {
2815
+ return this.#modes;
2816
+ }
2817
+ /**
2818
+ * Replace the offered mode set (normalised to {@link LIST_SEARCH_MODES}
2819
+ * order). If the current mode is dropped the popover closes and falls back to
2820
+ * the first remaining mode. Callers should re-run
2821
+ * {@link registerListSearchCommands} afterwards.
2822
+ */
2823
+ setModes(modes) {
2824
+ const set = new Set(modes);
2825
+ this.#modes = LIST_SEARCH_MODES.filter((m) => set.has(m));
2826
+ if (!this.#modes.includes(this.#mode)) {
2827
+ if (this.#open) this.close();
2828
+ this.#mode = this.#modes[0] ?? `filter`;
2829
+ }
2830
+ this.#host.requestUpdate();
2831
+ }
2832
+ get isOpen() {
2833
+ return this.#open;
2834
+ }
2835
+ get mode() {
2836
+ return this.#mode;
2837
+ }
2838
+ get query() {
2839
+ return this.#query;
2840
+ }
2841
+ get matchCount() {
2842
+ return this.#matches.length;
2843
+ }
2844
+ get matchIndex() {
2845
+ return this.#matchIndex;
2846
+ }
2847
+ hostConnected() {
2848
+ const container = this.#adapter.container?.();
2849
+ if (container) {
2850
+ this.#observer = new MutationObserver(() => this.refresh());
2851
+ this.#observer.observe(container, {
2852
+ childList: true,
2853
+ subtree: false
2854
+ });
2855
+ }
2856
+ this.refresh();
2857
+ }
2858
+ hostDisconnected() {
2859
+ this.#observer?.disconnect();
2860
+ this.#observer = void 0;
2861
+ if (this.#open) this.#teardownEffects();
2862
+ this.#open = false;
2863
+ this.#unsub?.();
2864
+ this.#unsub = void 0;
2865
+ this.#search.dispose();
2866
+ }
2867
+ /** Open the popover in `mode` (clamped to the available set). */
2868
+ open(mode) {
2869
+ if (!this.enabled) return;
2870
+ this.#mode = this.#modes.includes(mode) ? mode : this.#modes[0];
2871
+ this.#open = true;
2872
+ this.#query = ``;
2873
+ this.#matchIndex = 0;
2874
+ this.#matches = [];
2875
+ this.refresh();
2876
+ this.#search.setQuery(``);
2877
+ this.#host.requestUpdate();
2878
+ this.#host.updateComplete.then(() => this.#focusInput());
2879
+ }
2880
+ /** Close the popover, restoring any live filter. Selection / cursor stay put. */
2881
+ close() {
2882
+ if (!this.#open) return;
2883
+ this.#open = false;
2884
+ this.#teardownEffects();
2885
+ this.#query = ``;
2886
+ this.#matches = [];
2887
+ this.#search.clearQuery();
2888
+ this.#host.requestUpdate();
2889
+ this.#host.focus();
2890
+ }
2891
+ setQuery(query) {
2892
+ this.#query = query;
2893
+ this.#matchIndex = 0;
2894
+ this.#search.setQuery(query);
2895
+ }
2896
+ /** Re-interpret the current query under a new mode (undoing the old mode's effect). */
2897
+ switchMode(next) {
2898
+ if (!this.#modes.includes(next) || next === this.#mode) return;
2899
+ if (this.#mode === `filter` && this.#filterActive) {
2900
+ this.#adapter.applyFilter(void 0);
2901
+ this.#filterActive = false;
2902
+ }
2903
+ this.#mode = next;
2904
+ this.#applyMode();
2905
+ this.#host.requestUpdate();
2906
+ this.#host.updateComplete.then(() => this.#focusInput());
2907
+ }
2908
+ /** One-shot: turn the current match set into the selection, staying in the current mode. */
2909
+ selectMatches() {
2910
+ if (!this.#modes.includes(`select`)) return;
2911
+ this.#adapter.selectItems(this.#currentItems());
2912
+ this.#host.requestUpdate();
2913
+ }
2914
+ nextMatch() {
2915
+ this.#cycle(1);
2916
+ }
2917
+ prevMatch() {
2918
+ this.#cycle(-1);
2919
+ }
2920
+ /** Enter: in `navigate` activate the current match; then close. */
2921
+ commit() {
2922
+ if (this.#mode === `navigate`) {
2923
+ const item = this.#itemFor(this.#matches[this.#matchIndex]);
2924
+ if (item) {
2925
+ if (this.#adapter.activateItem) this.#adapter.activateItem(item);
2926
+ else this.#adapter.focusItem(item);
2927
+ }
2928
+ }
2929
+ this.close();
2930
+ }
2931
+ /** Rebuild the searchable item set from the adapter. Called on open and on container mutation. */
2932
+ refresh() {
2933
+ this.#itemByKey.clear();
2934
+ const items = [];
2935
+ for (const it of this.#adapter.getItems()) {
2936
+ const key = this.#adapter.getKey(it);
2937
+ this.#itemByKey.set(key, it);
2938
+ items.push({
2939
+ key,
2940
+ label: this.#adapter.getLabel(it)
2941
+ });
2942
+ }
2943
+ this.#search.setItems(items);
2944
+ }
2945
+ /** Popover markup. Pair with `listSearchStyles` in the host's `static styles`. */
2946
+ renderField() {
2947
+ if (!this.enabled) return A;
2948
+ const hasQuery = this.#query.trim().length > 0;
2949
+ const others = this.#modes.filter((m) => m !== this.#mode);
2950
+ const showSelectMatches = this.#modes.includes(`select`) && this.#mode !== `select`;
2951
+ return b`
2952
+ <div class="list-search" part="search" ?hidden=${!this.#open}>
2953
+ <div class="list-search-row">
2954
+ <span class="list-search-mode">${this.#mode}</span>
2955
+ <input
2956
+ class=${INPUT_CLASS}
2957
+ type="text"
2958
+ part="search-input"
2959
+ placeholder=${this.#placeholder}
2960
+ .value=${this.#query}
2961
+ @input=${this.#onInput}
2962
+ @keydown=${this.#onKeydown}
2963
+ />
2964
+ ${hasQuery ? b`<span class="list-search-count">${this.#matches.length === 0 ? `0` : this.#matchIndex + 1} / ${this.#matches.length}</span>` : A}
2965
+ ${this.#mode === `navigate` && this.#matches.length > 1 ? b`
2966
+ <button class="list-search-icon-btn" title="Previous match (↑)" @click=${() => this.prevMatch()}>‹</button>
2967
+ <button class="list-search-icon-btn" title="Next match (↓)" @click=${() => this.nextMatch()}>›</button>
2968
+ ` : A}
2969
+ <button class="list-search-icon-btn" title="Close (Esc)" @click=${() => this.close()}>✕</button>
2970
+ </div>
2971
+ ${others.length > 0 || showSelectMatches ? b`
2972
+ <div class="list-search-switch">
2973
+ ${others.map((m) => b`<button class="list-search-btn" @click=${() => this.switchMode(m)}>${m}</button>`)}
2974
+ ${showSelectMatches ? b`<button class="list-search-btn" @click=${() => this.selectMatches()}>Select matches</button>` : A}
2975
+ </div>
2976
+ ` : A}
2977
+ </div>
2978
+ `;
2979
+ }
2980
+ #focusInput() {
2981
+ this.#host.renderRoot?.querySelector(`.${INPUT_CLASS}`)?.focus();
2982
+ }
2983
+ #onInput = (e) => {
2984
+ this.setQuery(e.target.value);
2985
+ };
2986
+ #onKeydown = (e) => {
2987
+ switch (e.key) {
2988
+ case `Escape`:
2989
+ e.preventDefault();
2990
+ e.stopPropagation();
2991
+ this.close();
2992
+ break;
2993
+ case `Enter`:
2994
+ e.preventDefault();
2995
+ e.stopPropagation();
2996
+ this.commit();
2997
+ break;
2998
+ case `ArrowDown`:
2999
+ if (this.#mode === `navigate`) {
3000
+ e.preventDefault();
3001
+ this.nextMatch();
3002
+ }
3003
+ break;
3004
+ case `ArrowUp`: if (this.#mode === `navigate`) {
3005
+ e.preventDefault();
3006
+ this.prevMatch();
3007
+ }
3008
+ }
3009
+ };
3010
+ #cycle(delta) {
3011
+ if (this.#matches.length === 0) return;
3012
+ const n = this.#matches.length;
3013
+ this.#matchIndex = (this.#matchIndex + delta + n) % n;
3014
+ if (this.#mode === `navigate`) this.#adapter.focusItem(this.#itemFor(this.#matches[this.#matchIndex]));
3015
+ this.#host.requestUpdate();
3016
+ }
3017
+ #itemFor(match) {
3018
+ return match ? this.#itemByKey.get(match.item.key) : void 0;
3019
+ }
3020
+ #currentItems() {
3021
+ return this.#matches.map((m) => this.#itemFor(m)).filter((x) => x !== void 0);
3022
+ }
3023
+ get #highlightKey() {
3024
+ return this.#adapter.highlightKey ?? DEFAULT_HIGHLIGHT_KEY;
3025
+ }
3026
+ #onState(state) {
3027
+ if (!this.#open) return;
3028
+ const hasQuery = state.query.trim().length > 0;
3029
+ this.#matches = hasQuery ? [...state.results] : [];
3030
+ if (this.#matchIndex >= this.#matches.length) this.#matchIndex = 0;
3031
+ this.#applyMode();
3032
+ this.#host.requestUpdate();
3033
+ }
3034
+ #applyMode() {
3035
+ if (!(this.#query.trim().length > 0)) {
3036
+ if (this.#filterActive) {
3037
+ this.#adapter.applyFilter(void 0);
3038
+ this.#filterActive = false;
3039
+ }
3040
+ this.#applyHighlights();
3041
+ return;
3042
+ }
3043
+ switch (this.#mode) {
3044
+ case `navigate`:
3045
+ this.#adapter.focusItem(this.#itemFor(this.#matches[this.#matchIndex]));
3046
+ break;
3047
+ case `select`:
3048
+ this.#adapter.selectItems(this.#currentItems());
3049
+ break;
3050
+ case `filter`:
3051
+ this.#adapter.applyFilter(new Set(this.#matches.map((m) => m.item.key)));
3052
+ this.#filterActive = true;
3053
+ }
3054
+ this.#applyHighlights();
3055
+ }
3056
+ #applyHighlights() {
3057
+ const root = this.#adapter.highlightRoot?.();
3058
+ const selector = this.#adapter.highlightSelector;
3059
+ const keyOf = this.#adapter.highlightKeyOf;
3060
+ if (!root || !selector || !keyOf) return;
3061
+ if (this.#query.trim().length === 0) {
3062
+ clearHighlights({ key: this.#highlightKey });
3063
+ return;
3064
+ }
3065
+ const state = this.#search.state;
3066
+ const targets = buildHighlightTargets(root, selector, state.positionsByItem, (el) => {
3067
+ const key = keyOf(el);
3068
+ return key === void 0 ? void 0 : state.results.find((r) => r.item.key === key)?.item;
3069
+ }, this.#adapter.highlightText ?? ((el) => el.textContent ?? ``));
3070
+ try {
3071
+ applyHighlights(targets, { key: this.#highlightKey });
3072
+ } catch {
3073
+ clearHighlights({ key: this.#highlightKey });
3074
+ }
3075
+ }
3076
+ #teardownEffects() {
3077
+ if (this.#filterActive) {
3078
+ this.#adapter.applyFilter(void 0);
3079
+ this.#filterActive = false;
3080
+ }
3081
+ clearHighlights({ key: this.#highlightKey });
3082
+ }
3083
+ };
3084
+ //#endregion
3085
+ //#region src/incr-search/list-search-styles.ts
3086
+ /**
3087
+ * Styles for the incremental-search popover rendered by
3088
+ * `ListSearchController.renderField()`. Add to a component's `static styles`;
3089
+ * the class names are the contract between this fragment and `renderField()`.
3090
+ *
3091
+ * The host must give the popover a positioned ancestor (a wrapper with
3092
+ * `position: relative`, or `:host { position: relative }`). Tunables:
3093
+ * `--list-search-inset` (default `8px`) offsets it from the top-right corner.
3094
+ *
3095
+ * See `docs/infra-incremental.md`.
3096
+ */
3097
+ const listSearchStyles = i$3`
3098
+ .list-search {
3099
+ position: absolute;
3100
+ top: var(--list-search-inset, 8px);
3101
+ right: var(--list-search-inset, 8px);
3102
+ z-index: 20;
3103
+ display: flex;
3104
+ flex-direction: column;
3105
+ gap: 4px;
3106
+ padding: 6px;
3107
+ box-sizing: border-box;
3108
+ background: var(--surface-4, #2a2a2a);
3109
+ color: var(--surface-3-text, inherit);
3110
+ border: 1px solid var(--border, #444);
3111
+ border-radius: var(--radius-m, 6px);
3112
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
3113
+ font-size: var(--text-s, 0.85em);
3114
+ }
3115
+
3116
+ .list-search[hidden] {
3117
+ display: none;
3118
+ }
3119
+
3120
+ .list-search-row {
3121
+ display: flex;
3122
+ align-items: center;
3123
+ gap: 4px;
3124
+ }
3125
+
3126
+ .list-search-mode {
3127
+ flex: none;
3128
+ padding: 1px 7px;
3129
+ border-radius: 999px;
3130
+ font-weight: 600;
3131
+ text-transform: capitalize;
3132
+ background: var(--accent, #60a5fa);
3133
+ color: var(--accent-text, #000);
3134
+ }
3135
+
3136
+ .list-search-input {
3137
+ flex: 1;
3138
+ min-width: 140px;
3139
+ background: transparent;
3140
+ border: none;
3141
+ outline: none;
3142
+ color: inherit;
3143
+ font: inherit;
3144
+ }
3145
+
3146
+ .list-search-count {
3147
+ flex: none;
3148
+ opacity: 0.6;
3149
+ font-variant-numeric: tabular-nums;
3150
+ white-space: nowrap;
3151
+ }
3152
+
3153
+ .list-search-switch {
3154
+ display: flex;
3155
+ flex-wrap: wrap;
3156
+ gap: 4px;
3157
+ }
3158
+
3159
+ .list-search-btn {
3160
+ padding: 1px 7px;
3161
+ background: var(--surface-3, #333);
3162
+ color: inherit;
3163
+ border: 1px solid var(--border, #444);
3164
+ border-radius: var(--radius-s, 4px);
3165
+ cursor: pointer;
3166
+ font: inherit;
3167
+ text-transform: capitalize;
3168
+ }
3169
+
3170
+ .list-search-btn:hover {
3171
+ background: var(--item-bg-tickled, #444);
3172
+ }
3173
+
3174
+ .list-search-icon-btn {
3175
+ flex: none;
3176
+ padding: 0 2px;
3177
+ background: transparent;
3178
+ border: none;
3179
+ color: var(--surface-muted-text, #888);
3180
+ cursor: pointer;
3181
+ font-size: 1.1em;
3182
+ line-height: 1;
3183
+ }
3184
+
3185
+ .list-search-icon-btn:hover {
3186
+ color: inherit;
3187
+ }
3188
+ `;
3189
+ //#endregion
3190
+ //#region src/incr-search/tree-list-search-adapter.ts
3191
+ /**
3192
+ * A {@link ListSearchAdapter} over any `TreeSearchHost` (`ixfx-tree-list`,
3193
+ * `ixfx-miller-list`, `ixfx-crumb-navigation`). Navigate reveals the match
3194
+ * (expands its ancestors, moves the cursor); select drives `selectMany`; filter
3195
+ * sets `filterPredicate`. The item set is the whole tree — `getNodes()` returns
3196
+ * every node regardless of expansion or an active filter — so refining a query
3197
+ * can always re-surface a node. See `docs/infra-incremental.md`.
3198
+ */
3199
+ function createTreeListSearchAdapter(host, options = {}) {
3200
+ return {
3201
+ getItems: () => host.getNodes(),
3202
+ getLabel: options.getLabel ?? ((item) => item.node.item.label),
3203
+ getKey: (item) => item.node.item.key,
3204
+ focusItem: (item) => {
3205
+ if (item) host.revealKey(item.node.item.key);
3206
+ },
3207
+ activateItem: (item) => {
3208
+ host.revealKey(item.node.item.key);
3209
+ host.select(item.node);
3210
+ },
3211
+ selectItems: (items) => {
3212
+ host.selectMany(items.map((i) => i.node));
3213
+ },
3214
+ applyFilter: (matched) => {
3215
+ host.filterPredicate = matched === void 0 ? void 0 : (node) => matched.has(node.item.key);
3216
+ },
3217
+ highlightRoot: () => host.shadowRoot,
3218
+ highlightSelector: options.labelSelector ?? `.tree-label`,
3219
+ highlightKeyOf: (el) => el.closest(`[data-key]`)?.dataset.key,
3220
+ highlightKey: options.highlightKey
3221
+ };
3222
+ }
3223
+ //#endregion
2563
3224
  //#region src/ac-token/ac-token.ts
2564
3225
  const DEFAULT_TOKENIZER = (text) => text.split(/[\s,;]+/);
2565
3226
  function makeLruCache(maxSize) {
@@ -3861,6 +4522,114 @@ __decorate([state()], RadioGroup.prototype, "_icons", void 0);
3861
4522
  __decorate([query(`slot`)], RadioGroup.prototype, "_slot", void 0);
3862
4523
  RadioGroup = __decorate([safeCustomElement(`ixfx-radio-group`)], RadioGroup);
3863
4524
  //#endregion
4525
+ //#region src/interaction/list-item-animation.ts
4526
+ /**
4527
+ * Shared insertion / removal animation for the list & tree components.
4528
+ *
4529
+ * Two flavours, because the components split into two layout families:
4530
+ *
4531
+ * - **Flow layouts** (`ixfx-vertical-list`, `ixfx-detail-list`, `ixfx-tree-*`) —
4532
+ * rows sit in normal flow, so a new row can collapse in from zero height and a
4533
+ * leaving row can collapse back to zero, and the rows around it slide to make
4534
+ * or close the gap for free. Use {@link animateListItemIn} /
4535
+ * {@link animateListItemOut} (Web Animations API, exact measured sizes).
4536
+ *
4537
+ * - **Virtualised layouts** (`ixfx-grid-list`, `ixfx-data-grid`) — rows are
4538
+ * absolutely positioned (`transform` / `top`), so collapsing a row's box moves
4539
+ * nothing. Those components instead fade the entering / leaving row with the
4540
+ * {@link listItemFadeKeyframes} keyframes and slide the survivors by
4541
+ * transitioning their position for a short window after the change.
4542
+ *
4543
+ * Everything here is a no-op under `prefers-reduced-motion: reduce`.
4544
+ */
4545
+ /** Default duration (ms) for both flavours; overridable per call / per component. */
4546
+ const LIST_ITEM_MOTION_MS = 200;
4547
+ function prefersReducedMotion() {
4548
+ return typeof window !== `undefined` && typeof window.matchMedia === `function` && window.matchMedia(`(prefers-reduced-motion: reduce)`).matches;
4549
+ }
4550
+ /**
4551
+ * Opacity keyframes for the virtualised components. Each one adds its own
4552
+ * one-line rule binding `.<row>.ixfx-item-entering` / `.ixfx-item-leaving` to
4553
+ * these, plus a `prefers-reduced-motion` guard on its own selectors.
4554
+ */
4555
+ const listItemFadeKeyframes = i$3`
4556
+ @keyframes ixfx-list-item-in {
4557
+ from { opacity: 0; }
4558
+ to { opacity: 1; }
4559
+ }
4560
+ @keyframes ixfx-list-item-out {
4561
+ from { opacity: 1; }
4562
+ to { opacity: 0; }
4563
+ }
4564
+ `;
4565
+ function measure(el, axis) {
4566
+ const rect = el.getBoundingClientRect();
4567
+ return axis === `inline` ? rect.width : rect.height;
4568
+ }
4569
+ function resolveDuration(el, explicit) {
4570
+ if (explicit !== void 0) return explicit;
4571
+ const raw = getComputedStyle(el).getPropertyValue(`--ixfx-list-item-move-duration`).trim();
4572
+ if (raw) {
4573
+ const n = Number.parseFloat(raw);
4574
+ if (!Number.isNaN(n)) return raw.endsWith(`ms`) ? n : raw.endsWith(`s`) ? n * 1e3 : n;
4575
+ }
4576
+ return 200;
4577
+ }
4578
+ function collapsedFrame(axis) {
4579
+ const prop = axis === `inline` ? `width` : `height`;
4580
+ const minProp = axis === `inline` ? `minWidth` : `minHeight`;
4581
+ return {
4582
+ [prop]: `0px`,
4583
+ [minProp]: `0px`,
4584
+ opacity: 0,
4585
+ overflow: `hidden`
4586
+ };
4587
+ }
4588
+ function fullFrame(size, axis) {
4589
+ const prop = axis === `inline` ? `width` : `height`;
4590
+ const minProp = axis === `inline` ? `minWidth` : `minHeight`;
4591
+ return {
4592
+ [prop]: `${size}px`,
4593
+ [minProp]: `0px`,
4594
+ opacity: 1,
4595
+ overflow: `hidden`
4596
+ };
4597
+ }
4598
+ /**
4599
+ * Collapse + fade `el` in from nothing to its natural size. Call it just after
4600
+ * the element has been inserted (it must already be laid out at full size so it
4601
+ * can be measured). No-op under reduced motion.
4602
+ */
4603
+ function animateListItemIn(el, options = {}) {
4604
+ if (prefersReducedMotion()) return void 0;
4605
+ const { axis = `block`, easing = `ease` } = options;
4606
+ const duration = resolveDuration(el, options.duration);
4607
+ const size = measure(el, axis);
4608
+ if (size <= 0) return void 0;
4609
+ return el.animate([collapsedFrame(axis), fullFrame(size, axis)], {
4610
+ duration,
4611
+ easing
4612
+ });
4613
+ }
4614
+ /**
4615
+ * Collapse + fade `el` out. Resolves once the animation finishes (or straight
4616
+ * away under reduced motion); the caller detaches the element after. The final
4617
+ * collapsed state is held (`fill: forwards`) so there's no flash back to full
4618
+ * size in the gap before removal.
4619
+ */
4620
+ function animateListItemOut(el, options = {}) {
4621
+ if (prefersReducedMotion()) return Promise.resolve();
4622
+ const { axis = `block`, easing = `ease` } = options;
4623
+ const duration = resolveDuration(el, options.duration);
4624
+ const size = measure(el, axis);
4625
+ if (size <= 0) return Promise.resolve();
4626
+ return el.animate([fullFrame(size, axis), collapsedFrame(axis)], {
4627
+ duration,
4628
+ easing,
4629
+ fill: `forwards`
4630
+ }).finished.then(() => void 0, () => void 0);
4631
+ }
4632
+ //#endregion
3864
4633
  //#region src/interaction/list-selection-controller.ts
3865
4634
  /** Whether the interaction mode allows building a multi-item selection. */
3866
4635
  function isMultiMode(mode) {
@@ -3901,6 +4670,20 @@ var ListSelectionController = class {
3901
4670
  get selected() {
3902
4671
  return this.#selected;
3903
4672
  }
4673
+ /**
4674
+ * The current selection as stable string keys (read-only). Empty unless a
4675
+ * `getKey` was supplied in config. Items whose `getKey` returns `undefined`
4676
+ * are omitted.
4677
+ */
4678
+ get selectedKeys() {
4679
+ const getKey = this.#config.getKey;
4680
+ const out = /* @__PURE__ */ new Set();
4681
+ if (getKey) for (const item of this.#selected) {
4682
+ const key = getKey(item);
4683
+ if (key !== void 0) out.add(key);
4684
+ }
4685
+ return out;
4686
+ }
3904
4687
  /** Anchor for range (shift) selection — the last plainly-selected item. */
3905
4688
  get anchor() {
3906
4689
  return this.#anchor;
@@ -3984,6 +4767,66 @@ var ListSelectionController = class {
3984
4767
  next.delete(item);
3985
4768
  this.#apply(next);
3986
4769
  }
4770
+ /**
4771
+ * Whether the current modes permit a selection of more than one item.
4772
+ * Stricter than `canSelectMultiple()`: `standard` / `implicit` only count
4773
+ * as multi when `selectionMode="multiple"`.
4774
+ */
4775
+ #effectiveMulti() {
4776
+ const { selectionMode, interactionMode } = this.#config.getModes();
4777
+ return selectionMode === `multiple` || interactionMode === `checked` || interactionMode === `sticky` || interactionMode === `vscode`;
4778
+ }
4779
+ /**
4780
+ * Replace the selection with the items resolved from `keys`. Keys that
4781
+ * `resolve` cannot map, or that resolve to a non-selectable item, are
4782
+ * ignored. `selectionMode="none"` is a no-op. When multi-select is not
4783
+ * available, only the last resolvable key is kept. `onChange` fires once.
4784
+ */
4785
+ selectKeys(keys, resolve) {
4786
+ if (this.#config.getModes().selectionMode === `none`) return;
4787
+ let next = /* @__PURE__ */ new Set();
4788
+ for (const key of new Set(keys)) {
4789
+ const item = resolve(key);
4790
+ if (item !== void 0 && this.#config.isSelectable(item)) next.add(item);
4791
+ }
4792
+ if (!this.#effectiveMulti() && next.size > 1) {
4793
+ const last = [...next].at(-1);
4794
+ next = last === void 0 ? /* @__PURE__ */ new Set() : /* @__PURE__ */ new Set([last]);
4795
+ }
4796
+ this.#anchor = [...next].at(-1);
4797
+ this.#apply(next);
4798
+ }
4799
+ /**
4800
+ * Add the items resolved from `keys` to the selection (multi-select only;
4801
+ * a no-op otherwise). `onChange` fires once if anything changed.
4802
+ */
4803
+ addKeys(keys, resolve) {
4804
+ if (!this.#effectiveMulti()) return;
4805
+ const next = new Set(this.#selected);
4806
+ let changed = false;
4807
+ for (const key of new Set(keys)) {
4808
+ const item = resolve(key);
4809
+ if (item !== void 0 && !next.has(item) && this.#config.isSelectable(item)) {
4810
+ next.add(item);
4811
+ changed = true;
4812
+ }
4813
+ }
4814
+ if (changed) this.#apply(next);
4815
+ }
4816
+ /**
4817
+ * Remove the items resolved from `keys` from the selection. `onChange`
4818
+ * fires once if anything changed.
4819
+ */
4820
+ removeKeys(keys, resolve) {
4821
+ if (this.#selected.size === 0) return;
4822
+ const next = new Set(this.#selected);
4823
+ let changed = false;
4824
+ for (const key of new Set(keys)) {
4825
+ const item = resolve(key);
4826
+ if (item !== void 0 && next.delete(item)) changed = true;
4827
+ }
4828
+ if (changed) this.#apply(next);
4829
+ }
3987
4830
  /** Route a click on `item` according to the current interaction mode. */
3988
4831
  handleClick(item, gesture) {
3989
4832
  const { selectionMode, interactionMode: mode } = this.#config.getModes();
@@ -8285,6 +9128,16 @@ let CrumbNavigationElement = class CrumbNavigationElement extends i {
8285
9128
  if (!keyPath) return false;
8286
9129
  return this.navigateTo(keyPath);
8287
9130
  }
9131
+ /** `TreeComponent` parity — a crumb path always follows the selection, so this just navigates to `key`. */
9132
+ revealKey(key) {
9133
+ return this.navigateToNode((n) => n.item.key === key);
9134
+ }
9135
+ /** `TreeComponent` parity — a crumb path is a single selection, so only the last node is kept. */
9136
+ selectMany(nodes) {
9137
+ const last = [...nodes].at(-1);
9138
+ if (last) this.select(last);
9139
+ else this.clearSelection();
9140
+ }
8288
9141
  #findNodePath(nodes, predicate, prefix = []) {
8289
9142
  for (const node of nodes) {
8290
9143
  const path = [...prefix, node.item.key];
@@ -10772,6 +11625,7 @@ let DataGridElement = class DataGridElement extends i {
10772
11625
  this.groupBy = null;
10773
11626
  this.allowGrouping = false;
10774
11627
  this.crossGroupSelection = false;
11628
+ this.columnMenu = `auto`;
10775
11629
  this._columnFormatters = /* @__PURE__ */ new Map();
10776
11630
  this._selectedKeys = /* @__PURE__ */ new Set();
10777
11631
  this._scrollTop = 0;
@@ -10794,10 +11648,18 @@ let DataGridElement = class DataGridElement extends i {
10794
11648
  this._sortedRows = [];
10795
11649
  this._flat = [];
10796
11650
  this._flatKeys = [];
11651
+ this.#searchKeyByRow = /* @__PURE__ */ new Map();
11652
+ this.search = new ListSearchController(this, { adapter: this.#createSearchAdapter() });
11653
+ this.#prevFlatKeys = [];
11654
+ this.#enteringRowKeys = /* @__PURE__ */ new Set();
11655
+ this.#rowReflowTimer = 0;
11656
+ this.#dataGridRenderedOnce = false;
10797
11657
  this._visibleColumns = [];
10798
11658
  this._colOffsets = [];
10799
11659
  this._colWidthSum = 0;
10800
11660
  this._raf = 0;
11661
+ this.#appendInProgress = false;
11662
+ this.#resortRaf = 0;
10801
11663
  this._drag = null;
10802
11664
  this._onFocusOut = (e) => {
10803
11665
  if (this._suppressBlurCommit) return;
@@ -10992,6 +11854,8 @@ let DataGridElement = class DataGridElement extends i {
10992
11854
  themeFallbacks,
10993
11855
  tickledStyles,
10994
11856
  tickledItemStyles,
11857
+ listItemFadeKeyframes,
11858
+ listSearchStyles,
10995
11859
  i$3`
10996
11860
  :host {
10997
11861
  --data-grid-row-height: 28px;
@@ -11000,6 +11864,7 @@ let DataGridElement = class DataGridElement extends i {
11000
11864
  --data-grid-header-bg: var(--surface-2);
11001
11865
  --data-grid-border: var(--border);
11002
11866
  --data-grid-header-text: var(--surface-3-text);
11867
+ position: relative;
11003
11868
  display: block;
11004
11869
  overflow: hidden;
11005
11870
  border: var(--data-grid-border);
@@ -11048,6 +11913,22 @@ let DataGridElement = class DataGridElement extends i {
11048
11913
  .row.item.tickled { background: var(--item-bg-tickled); border: var(--item-border-tickled); color: var(--item-text-tickled); }
11049
11914
  .row.item.selected { background: var(--item-bg-selected); border: var(--item-border-selected); color: var(--item-text-selected); }
11050
11915
  .row.item.selected.tickled { background: var(--item-bg-selected-tickled); }
11916
+
11917
+ /* Insertion / removal animation. Only while [reflowing] — the ~0.3s after
11918
+ the row set changes — do rows slide (their top transitions) to make or
11919
+ close a gap; a freshly inserted row also fades in. Kept scoped so it
11920
+ never fires while scrolling the virtualised list. */
11921
+ :host([reflowing]) .row.item,
11922
+ :host([reflowing]) .group-row {
11923
+ transition: top var(--data-grid-row-move-duration, 200ms) cubic-bezier(0.2, 0, 0, 1);
11924
+ }
11925
+ .row.item.entering {
11926
+ animation: ixfx-list-item-in var(--data-grid-row-move-duration, 200ms) ease both;
11927
+ }
11928
+ @media (prefers-reduced-motion: reduce) {
11929
+ :host([reflowing]) .row.item, :host([reflowing]) .group-row { transition: none; }
11930
+ .row.item.entering { animation: none; }
11931
+ }
11051
11932
  .cell {
11052
11933
  flex-shrink: 0; display: flex; align-items: center; padding: 0 var(--space-s);
11053
11934
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; border-right: var(--border-subtle);
@@ -11077,17 +11958,89 @@ let DataGridElement = class DataGridElement extends i {
11077
11958
  `
11078
11959
  ];
11079
11960
  }
11961
+ #searchRegistry;
11962
+ #unregisterSearchCommands;
11963
+ #searchKeyByRow;
11964
+ /** Command registry carrying this grid's `list.search.*` commands. */
11965
+ get registry() {
11966
+ return this.#searchRegistry;
11967
+ }
11968
+ #rowSearchText(row) {
11969
+ if (this.rowSearchText) return this.rowSearchText(row);
11970
+ const rec = row;
11971
+ return this._visibleColumns.map((c) => String(rec[c.key] ?? ``)).join(` `);
11972
+ }
11973
+ #createSearchAdapter() {
11974
+ return {
11975
+ getItems: () => {
11976
+ this.#searchKeyByRow.clear();
11977
+ this._sortedRows.forEach((r, i) => this.#searchKeyByRow.set(r, this.getRowKey(r, i)));
11978
+ return this._sortedRows;
11979
+ },
11980
+ getLabel: (row) => this.#rowSearchText(row),
11981
+ getKey: (row) => this.#searchKeyByRow.get(row) ?? String(this._sortedRows.indexOf(row)),
11982
+ focusItem: (row) => {
11983
+ if (row === void 0) return;
11984
+ const key = this.#searchKeyByRow.get(row);
11985
+ if (key !== void 0) this.tickleRow(key);
11986
+ },
11987
+ selectItems: (rows) => {
11988
+ this.selectKeys(rows.map((r) => this.#searchKeyByRow.get(r)).filter((k) => k !== void 0));
11989
+ },
11990
+ applyFilter: (matched) => {
11991
+ this.filterPredicate = matched === void 0 ? void 0 : (row) => {
11992
+ const k = this.#searchKeyByRow.get(row);
11993
+ return k === void 0 || matched.has(k);
11994
+ };
11995
+ }
11996
+ };
11997
+ }
11998
+ #effectiveSearchModes() {
11999
+ const raw = this.searchModes?.trim();
12000
+ if (raw === void 0) return [...LIST_SEARCH_MODES];
12001
+ const requested = new Set(raw.split(/\s+/));
12002
+ return LIST_SEARCH_MODES.filter((m) => requested.has(m));
12003
+ }
12004
+ #syncSearchCommands() {
12005
+ if (!this.#searchRegistry) return;
12006
+ this.search.setModes(this.#effectiveSearchModes());
12007
+ this.#unregisterSearchCommands?.();
12008
+ this.#unregisterSearchCommands = registerListSearchCommands(this.#searchRegistry, this.search);
12009
+ }
12010
+ /** Row-key snapshot from the previous update, for the insertion / removal animation. */
12011
+ #prevFlatKeys;
12012
+ #enteringRowKeys;
12013
+ #rowReflowTimer;
12014
+ #dataGridRenderedOnce;
12015
+ #appendInProgress;
12016
+ #resortRaf;
11080
12017
  connectedCallback() {
11081
12018
  super.connectedCallback();
11082
12019
  if (!this.hasAttribute(`tabindex`)) this.tabIndex = 0;
11083
12020
  this.addEventListener(`keydown`, this._onKeyDown);
11084
12021
  this.addEventListener(`focusout`, this._onFocusOut);
12022
+ this.#searchRegistry ??= createCommandRegistry();
12023
+ this.#syncSearchCommands();
11085
12024
  }
11086
12025
  disconnectedCallback() {
11087
12026
  super.disconnectedCallback();
11088
12027
  this.removeEventListener(`keydown`, this._onKeyDown);
11089
12028
  this.removeEventListener(`focusout`, this._onFocusOut);
11090
12029
  cancelAnimationFrame(this._raf);
12030
+ this.#unregisterSearchCommands?.();
12031
+ this.#unregisterSearchCommands = void 0;
12032
+ this.#searchRegistry = void 0;
12033
+ if (this.#rowReflowTimer) {
12034
+ clearTimeout(this.#rowReflowTimer);
12035
+ this.#rowReflowTimer = 0;
12036
+ }
12037
+ }
12038
+ /**
12039
+ * Dispatch a command to this grid — routes through the command registry so
12040
+ * `list.search.*` (and any other registered commands) work.
12041
+ */
12042
+ dispatch(commandId, args) {
12043
+ this.#searchRegistry?.invoke(commandId, args);
11091
12044
  }
11092
12045
  firstUpdated() {
11093
12046
  new ResizeObserver(() => {
@@ -11114,8 +12067,11 @@ let DataGridElement = class DataGridElement extends i {
11114
12067
  willUpdate(changed) {
11115
12068
  super.willUpdate(changed);
11116
12069
  this.setAttribute(`data-input-mode`, this._tickled.inputMode);
11117
- if (changed.has(`rows`) || changed.has(`sortState`) || changed.has(`columns`) || changed.has(`groupBy`) || changed.has(`groupLabel`)) this._sortedRows = this._applySort([...this.rows]);
11118
- this._rebuildFlat();
12070
+ if (changed.has(`searchModes`)) this.#syncSearchCommands();
12071
+ if (!this.#appendInProgress) {
12072
+ if (changed.has(`rows`) || changed.has(`sortState`) || changed.has(`columns`) || changed.has(`groupBy`) || changed.has(`groupLabel`)) this._sortedRows = this._applySort([...this.rows]);
12073
+ this._rebuildFlat();
12074
+ }
11119
12075
  this._visibleColumns = this.columns.filter((c) => c.visible !== false);
11120
12076
  let off = 0;
11121
12077
  this._colOffsets = this._visibleColumns.map((c) => {
@@ -11125,6 +12081,40 @@ let DataGridElement = class DataGridElement extends i {
11125
12081
  });
11126
12082
  this._colWidthSum = off;
11127
12083
  if (this.interactionMode === `checked`) this._colWidthSum += 28;
12084
+ this.#noteFlatMutation();
12085
+ }
12086
+ /**
12087
+ * Diff the row-key list against the previous update. Rows that appeared get an
12088
+ * enter fade; any add/remove opens a short `[reflowing]` window so the rows
12089
+ * that shift slide (their `top` transitions) instead of snapping. Bounded in
12090
+ * time so it never bleeds into scroll virtualisation.
12091
+ */
12092
+ #noteFlatMutation() {
12093
+ const now = this._flatKeys;
12094
+ const prev = this.#prevFlatKeys;
12095
+ this.#prevFlatKeys = now.slice();
12096
+ if (!this.#dataGridRenderedOnce) {
12097
+ this.#dataGridRenderedOnce = true;
12098
+ return;
12099
+ }
12100
+ if (prev.length === 0 || now.length === 0) {
12101
+ this.#enteringRowKeys.clear();
12102
+ return;
12103
+ }
12104
+ const before = new Set(prev);
12105
+ const after = new Set(now);
12106
+ const added = now.filter((k) => !before.has(k));
12107
+ const removed = prev.filter((k) => !after.has(k));
12108
+ if (added.length === now.length || added.length === 0 && removed.length === 0) return;
12109
+ this.#enteringRowKeys = new Set(added);
12110
+ this.toggleAttribute(`reflowing`, true);
12111
+ if (this.#rowReflowTimer) clearTimeout(this.#rowReflowTimer);
12112
+ this.#rowReflowTimer = window.setTimeout(() => {
12113
+ this.#rowReflowTimer = 0;
12114
+ this.#enteringRowKeys.clear();
12115
+ this.toggleAttribute(`reflowing`, false);
12116
+ this.requestUpdate();
12117
+ }, 320);
11128
12118
  }
11129
12119
  _getGroupKey(row) {
11130
12120
  if (!this.groupBy) return null;
@@ -11132,18 +12122,23 @@ let DataGridElement = class DataGridElement extends i {
11132
12122
  return String(this.groupBy(row));
11133
12123
  }
11134
12124
  _rebuildFlat() {
12125
+ const filtered = this.filterPredicate !== void 0;
12126
+ const source = filtered ? this._sortedRows.filter((r) => this.filterPredicate(r)) : this._sortedRows;
11135
12127
  if (!this.groupBy) {
11136
- this._flat = this._sortedRows.map((r, i) => ({
11137
- type: `row`,
11138
- key: this.getRowKey(r, i),
11139
- row: r,
11140
- idx: i
11141
- }));
12128
+ this._flat = source.map((r, i) => {
12129
+ const idx = filtered ? this._sortedRows.indexOf(r) : i;
12130
+ return {
12131
+ type: `row`,
12132
+ key: this.getRowKey(r, idx),
12133
+ row: r,
12134
+ idx
12135
+ };
12136
+ });
11142
12137
  this._flatKeys = this._flat.map((f) => f.key);
11143
12138
  return;
11144
12139
  }
11145
12140
  const groups = /* @__PURE__ */ new Map();
11146
- for (const r of this._sortedRows) {
12141
+ for (const r of source) {
11147
12142
  const k = this._getGroupKey(r) ?? ``;
11148
12143
  const arr = groups.get(k);
11149
12144
  if (arr) arr.push(r);
@@ -11372,6 +12367,102 @@ let DataGridElement = class DataGridElement extends i {
11372
12367
  _getRowForKey(key) {
11373
12368
  return this._flat.find((f) => f.key === key)?.row;
11374
12369
  }
12370
+ /** The selected row keys (read-only). */
12371
+ get selectedKeys() {
12372
+ return this._selectedKeys;
12373
+ }
12374
+ /** Replace the selection with these row keys (clamped to known rows). */
12375
+ set selectedKeys(keys) {
12376
+ this.selectKeys(keys);
12377
+ }
12378
+ /**
12379
+ * Replace the selection with the given row keys. Unknown keys are dropped;
12380
+ * in a single-select mode only the last surviving key is kept;
12381
+ * `selectionMode="none"` clears. Fires `datagrid-select` with the previous
12382
+ * set when the selection actually changes.
12383
+ */
12384
+ selectKeys(keys) {
12385
+ const known = new Set(this._flat.filter((f) => f.type === `row`).map((f) => f.key));
12386
+ let next = new Set([...keys].filter((k) => known.has(k)));
12387
+ const multi = this.selectionMode === `multiple` || this.interactionMode === `checked` || this.interactionMode === `sticky` || this.interactionMode === `vscode`;
12388
+ if (this.selectionMode === `none`) next = /* @__PURE__ */ new Set();
12389
+ else if (!multi && next.size > 1) {
12390
+ const last = [...next].at(-1);
12391
+ next = last === void 0 ? /* @__PURE__ */ new Set() : /* @__PURE__ */ new Set([last]);
12392
+ }
12393
+ const prev = new Set(this._selectedKeys);
12394
+ if (prev.size === next.size && [...next].every((k) => prev.has(k))) return;
12395
+ this._selectedKeys = next;
12396
+ this._anchorKey = [...next].at(-1);
12397
+ this.dispatchEvent(new CustomEvent(`datagrid-select`, {
12398
+ detail: {
12399
+ selectedKeys: new Set(next),
12400
+ previous: prev
12401
+ },
12402
+ bubbles: true,
12403
+ composed: true
12404
+ }));
12405
+ }
12406
+ /** The row key under the cursor, or `undefined`. */
12407
+ get tickledKey() {
12408
+ return this._tickled.tickledId;
12409
+ }
12410
+ /** Move the cursor to this row key and scroll it into view. */
12411
+ tickleRow(key) {
12412
+ if (!this._flatKeys.includes(key)) return;
12413
+ this._tickled.focus(key);
12414
+ this._scrollRowIntoView(key);
12415
+ }
12416
+ /**
12417
+ * Append rows without the full O(n log n) rebuild that reassigning `rows`
12418
+ * incurs. `rows` still gets a new array reference (reactivity is preserved),
12419
+ * but the derived arrays are extended over the appended slice only. When a
12420
+ * sort is active the new rows show unsorted for one frame, then a single
12421
+ * coalesced re-sort runs. Scroll position, selection and cursor are kept.
12422
+ */
12423
+ appendRows(rows) {
12424
+ if (rows.length === 0) return;
12425
+ const startIdx = this.rows.length;
12426
+ this.#appendInProgress = true;
12427
+ this.rows = [...this.rows, ...rows];
12428
+ const appended = this.rows.slice(startIdx);
12429
+ if (this._sortedRows.length === startIdx) this._sortedRows = [...this._sortedRows, ...appended];
12430
+ else {
12431
+ this._sortedRows = this._applySort([...this.rows]);
12432
+ this._rebuildFlat();
12433
+ this.#appendInProgress = false;
12434
+ this.requestUpdate();
12435
+ return;
12436
+ }
12437
+ this.#appendToFlat(appended, startIdx);
12438
+ this.#appendInProgress = false;
12439
+ this.requestUpdate();
12440
+ if (this.sortState != null) {
12441
+ cancelAnimationFrame(this.#resortRaf);
12442
+ this.#resortRaf = requestAnimationFrame(() => {
12443
+ this._sortedRows = this._applySort([...this.rows]);
12444
+ this._rebuildFlat();
12445
+ this.requestUpdate();
12446
+ });
12447
+ }
12448
+ }
12449
+ #appendToFlat(appended, startIdx) {
12450
+ if (this.groupBy) {
12451
+ this._rebuildFlat();
12452
+ return;
12453
+ }
12454
+ for (let j = 0; j < appended.length; j++) {
12455
+ const idx = startIdx + j;
12456
+ const key = this.getRowKey(appended[j], idx);
12457
+ this._flat.push({
12458
+ type: `row`,
12459
+ key,
12460
+ row: appended[j],
12461
+ idx
12462
+ });
12463
+ this._flatKeys.push(key);
12464
+ }
12465
+ }
11375
12466
  _replaceSelection(key) {
11376
12467
  if (this.selectionMode === `none`) return;
11377
12468
  const prev = new Set(this._selectedKeys);
@@ -11727,6 +12818,7 @@ let DataGridElement = class DataGridElement extends i {
11727
12818
  return last >= 0 ? this._colOffsets[last] + (this._visibleColumns[last].width ?? 150) : 0;
11728
12819
  })()}px"></div>` : A}
11729
12820
  </div>
12821
+ ${this.columnMenu === `none` ? A : b`
11730
12822
  <ixfx-menu-trigger label="⚙" placement="bottom-end" @menu-command=${(e) => {
11731
12823
  const cmd = e.detail.command;
11732
12824
  const checked = e.detail.checked;
@@ -11766,7 +12858,7 @@ let DataGridElement = class DataGridElement extends i {
11766
12858
  </ixfx-menu-radio-group>
11767
12859
  ` : A}
11768
12860
  </ixfx-menu-container>
11769
- </ixfx-menu-trigger>
12861
+ </ixfx-menu-trigger>`}
11770
12862
  </div>
11771
12863
  <div class="viewport" @scroll=${this._onScroll}>
11772
12864
  <div class="spacer" style="height:${this._flat.length * rh}px; width:${this._colWidthSum}px">
@@ -11783,7 +12875,7 @@ let DataGridElement = class DataGridElement extends i {
11783
12875
  const selected = this._selectedKeys.has(rowKey);
11784
12876
  const tickled = this._tickled.isTickled(rowKey);
11785
12877
  return b`
11786
- <div class="row item ${selected ? `selected` : ``} ${tickled ? `tickled` : ``}" style="top:${absIdx * rh}px; height:${rh}px"
12878
+ <div class="row item ${selected ? `selected` : ``} ${tickled ? `tickled` : ``} ${this.#enteringRowKeys.has(rowKey) ? `entering` : ``}" style="top:${absIdx * rh}px; height:${rh}px"
11787
12879
  data-key=${rowKey}
11788
12880
  @click=${(e) => {
11789
12881
  this._onRowClick(rowKey, null, e);
@@ -11916,6 +13008,7 @@ let DataGridElement = class DataGridElement extends i {
11916
13008
  })}
11917
13009
  </div>
11918
13010
  </div>
13011
+ ${this.search.renderField()}
11919
13012
  </div>
11920
13013
  `;
11921
13014
  }
@@ -11954,7 +13047,18 @@ __decorate([property({
11954
13047
  attribute: `cross-group-selection`,
11955
13048
  reflect: true
11956
13049
  })], DataGridElement.prototype, "crossGroupSelection", void 0);
13050
+ __decorate([property({
13051
+ type: String,
13052
+ attribute: `column-menu`
13053
+ })], DataGridElement.prototype, "columnMenu", void 0);
11957
13054
  __decorate([property({ attribute: false })], DataGridElement.prototype, "globalFormatter", void 0);
13055
+ __decorate([property({ attribute: false })], DataGridElement.prototype, "filterPredicate", void 0);
13056
+ __decorate([property({ attribute: false })], DataGridElement.prototype, "rowSearchText", void 0);
13057
+ __decorate([property({
13058
+ type: String,
13059
+ attribute: `search-modes`,
13060
+ reflect: true
13061
+ })], DataGridElement.prototype, "searchModes", void 0);
11958
13062
  __decorate([state()], DataGridElement.prototype, "_selectedKeys", void 0);
11959
13063
  __decorate([state()], DataGridElement.prototype, "_scrollTop", void 0);
11960
13064
  __decorate([state()], DataGridElement.prototype, "_scrollLeft", void 0);
@@ -11970,13 +13074,278 @@ __decorate([query(`.viewport`)], DataGridElement.prototype, "_viewportEl", void
11970
13074
  __decorate([query(`.header-scroll`)], DataGridElement.prototype, "_headerScrollEl", void 0);
11971
13075
  DataGridElement = __decorate([safeCustomElement(`ixfx-data-grid`)], DataGridElement);
11972
13076
  //#endregion
13077
+ //#region src/data-table/data-table.ts
13078
+ let DataTableElement = class DataTableElement extends i {
13079
+ constructor(..._args) {
13080
+ super(..._args);
13081
+ this.rows = [];
13082
+ this.tickleDelay = 400;
13083
+ this.layout = `auto`;
13084
+ this.narrowBreakpoint = 420;
13085
+ this._tickled = new TickledController(this, { getItems: () => this.rows.map((_, i) => ({
13086
+ id: i,
13087
+ enabled: true
13088
+ })) });
13089
+ this._stacked = false;
13090
+ this.#lastWidth = 0;
13091
+ }
13092
+ static {
13093
+ this.styles = [
13094
+ tickledStyles,
13095
+ tickledItemStyles,
13096
+ i$3`
13097
+ :host {
13098
+ display: block;
13099
+ container-type: inline-size;
13100
+ }
13101
+
13102
+ .properties {
13103
+ display: grid;
13104
+ grid-template-columns: var(--data-table-name-width, max-content)
13105
+ var(--data-table-value-width, minmax(0, 1fr));
13106
+ /* No column gap: the tickled highlight reads as one solid block.
13107
+ Separation comes from cell padding, which the highlight covers. */
13108
+ gap: 2px 0;
13109
+ }
13110
+
13111
+ /* Widening: 2 then 3 property-pairs per row on roomy containers.
13112
+ Suppressed when stacked, or when locked to 'columns'. */
13113
+ @container (min-width: 650px) {
13114
+ :host(:not([layout='columns'])) .properties:not(.stacked) {
13115
+ grid-template-columns:
13116
+ var(--data-table-name-width, max-content) var(--data-table-value-width, minmax(0, 1fr))
13117
+ var(--data-table-name-width, max-content) var(--data-table-value-width, minmax(0, 1fr));
13118
+ }
13119
+ }
13120
+
13121
+ @container (min-width: 1000px) {
13122
+ :host(:not([layout='columns'])) .properties:not(.stacked) {
13123
+ grid-template-columns:
13124
+ var(--data-table-name-width, max-content) var(--data-table-value-width, minmax(0, 1fr))
13125
+ var(--data-table-name-width, max-content) var(--data-table-value-width, minmax(0, 1fr))
13126
+ var(--data-table-name-width, max-content) var(--data-table-value-width, minmax(0, 1fr));
13127
+ }
13128
+ }
13129
+
13130
+ /* Stacked (key above value). Threshold is JS-driven via 'narrowBreakpoint'.
13131
+ minmax(0, 1fr) — not 1fr — so a long value's min-content size can't
13132
+ blow the column out past the container; the value then ellipsises. */
13133
+ .properties.stacked {
13134
+ grid-template-columns: minmax(0, 1fr);
13135
+ }
13136
+
13137
+ /* A pair is its own subgrid so the whole row is one element:
13138
+ the tickled highlight can't split, and key/value share a baseline. */
13139
+ .pair {
13140
+ display: grid;
13141
+ grid-column: span 2;
13142
+ grid-template-columns: subgrid;
13143
+ align-items: var(--data-table-cell-align, baseline);
13144
+ cursor: pointer;
13145
+ }
13146
+
13147
+ .pair[data-full] {
13148
+ grid-column: 1 / -1;
13149
+ }
13150
+
13151
+ .pair[data-full] > .value {
13152
+ grid-column: 2 / -1;
13153
+ }
13154
+
13155
+ .properties.stacked .pair {
13156
+ display: block;
13157
+ grid-column: 1 / -1;
13158
+ /* Let the block shrink below its content so .value-text can clip. */
13159
+ min-width: 0;
13160
+ }
13161
+
13162
+ .properties.stacked .pair:not(:last-child) {
13163
+ margin-bottom: var(--space-s);
13164
+ }
13165
+
13166
+ /* Stacked keys get their own line, so a long label must wrap rather than
13167
+ run off the edge (nowrap is only wanted for the max-content column). */
13168
+ .properties.stacked .key {
13169
+ white-space: normal;
13170
+ overflow-wrap: break-word;
13171
+ }
13172
+
13173
+ .key,
13174
+ .value {
13175
+ min-width: 0;
13176
+ /* Row rhythm comes from this padding (tunable), not line-height. */
13177
+ padding: var(--data-table-row-padding, 0.35em) 0;
13178
+ }
13179
+
13180
+ .key {
13181
+ padding-right: var(--space-m);
13182
+ opacity: 0.6;
13183
+ white-space: nowrap;
13184
+ text-align: var(--data-table-name-align, left);
13185
+ font-size: 10px;
13186
+ text-transform: uppercase;
13187
+ letter-spacing: 0.05em;
13188
+ /* Uppercase keys have no descenders, so both edges can be trimmed to
13189
+ the glyphs for tight baseline / centre alignment. */
13190
+ text-box: trim-both cap alphabetic;
13191
+ }
13192
+
13193
+ .value {
13194
+ position: relative;
13195
+ padding-right: var(--space-l);
13196
+ font: 12px ui-monospace, SFMono-Regular, Menlo, monospace;
13197
+ }
13198
+
13199
+ /* The value text is clipped here — never on .value itself — so a long
13200
+ value stays within its cell even while tickled, and the detail
13201
+ popover (a child of .value) can still escape the cell. */
13202
+ .value-text {
13203
+ display: block;
13204
+ overflow: hidden;
13205
+ text-overflow: ellipsis;
13206
+ white-space: nowrap;
13207
+ /* No text-box trim here: the natural line box gives descenders the room
13208
+ overflow:hidden would otherwise clip. Baseline alignment of key and
13209
+ value comes from align-items on .pair, which needs no trimming. */
13210
+ }
13211
+
13212
+ .pair.tickled {
13213
+ background: var(--item-bg-tickled, color-mix(in oklch, var(--surface-2), var(--accent) 10%));
13214
+ }
13215
+
13216
+ /* Lift the row whose detail is open above the rows it slides over. */
13217
+ .pair.show-detail {
13218
+ position: relative;
13219
+ z-index: 1;
13220
+ }
13221
+
13222
+ .detail {
13223
+ position: absolute;
13224
+ z-index: 2;
13225
+ /* Flush with the bottom of the value (no gap) so the pointer can travel
13226
+ from value to panel without crossing a dead zone that would dismiss it.
13227
+ Left-aligned with the value; only as wide as its content, capped at
13228
+ the table width. */
13229
+ top: 100%;
13230
+ left: 0;
13231
+ right: auto;
13232
+ width: max-content;
13233
+ max-width: 100cqi;
13234
+ padding: var(--space-xs);
13235
+ background: var(--surface-2);
13236
+ visibility: hidden;
13237
+ transform: translateY(-4px);
13238
+ pointer-events: none;
13239
+ transition: transform var(--transition);
13240
+ }
13241
+
13242
+ .pair.show-detail .detail {
13243
+ visibility: visible;
13244
+ transform: translateY(0);
13245
+ pointer-events: auto;
13246
+ }
13247
+ `
13248
+ ];
13249
+ }
13250
+ #detailTimer;
13251
+ #resizeObserver;
13252
+ #lastWidth;
13253
+ connectedCallback() {
13254
+ super.connectedCallback();
13255
+ this.#resizeObserver = new ResizeObserver((entries) => {
13256
+ const entry = entries[0];
13257
+ if (!entry) return;
13258
+ this.#lastWidth = entry.borderBoxSize?.[0]?.inlineSize ?? entry.contentRect.width;
13259
+ this.#updateStacked();
13260
+ });
13261
+ this.#resizeObserver.observe(this);
13262
+ }
13263
+ disconnectedCallback() {
13264
+ super.disconnectedCallback();
13265
+ clearTimeout(this.#detailTimer);
13266
+ this.#resizeObserver?.disconnect();
13267
+ this.#resizeObserver = void 0;
13268
+ }
13269
+ willUpdate(changed) {
13270
+ if (changed.has(`layout`) || changed.has(`narrowBreakpoint`)) this.#updateStacked();
13271
+ }
13272
+ #updateStacked() {
13273
+ const stacked = this.layout === `rows` ? true : this.layout === `columns` ? false : this.#lastWidth > 0 && this.#lastWidth <= this.narrowBreakpoint;
13274
+ if (stacked !== this._stacked) this._stacked = stacked;
13275
+ }
13276
+ #handlePointerEnter(i) {
13277
+ this._tickled.handlePointerEnter(i);
13278
+ clearTimeout(this.#detailTimer);
13279
+ if (this._detailIndex !== void 0) this._detailIndex = void 0;
13280
+ this.#detailTimer = setTimeout(() => {
13281
+ if (this._tickled.isTickled(i)) this._detailIndex = i;
13282
+ }, this.tickleDelay);
13283
+ }
13284
+ #handlePointerLeave(e) {
13285
+ clearTimeout(this.#detailTimer);
13286
+ this._detailIndex = void 0;
13287
+ this._tickled.handlePointerLeave(e);
13288
+ }
13289
+ #select(i) {
13290
+ clearTimeout(this.#detailTimer);
13291
+ this.selectedIndex = this.selectedIndex === i ? void 0 : i;
13292
+ this._tickled.setTickled(i);
13293
+ this._detailIndex = i;
13294
+ this.dispatchEvent(new CustomEvent(`data-table-row-click`, {
13295
+ bubbles: true,
13296
+ composed: true,
13297
+ detail: {
13298
+ row: this.rows[i],
13299
+ index: i,
13300
+ selected: this.selectedIndex === i
13301
+ }
13302
+ }));
13303
+ }
13304
+ render() {
13305
+ return b`<div
13306
+ class="properties ${this._stacked ? `stacked` : ``}"
13307
+ @pointerleave=${(e) => this.#handlePointerLeave(e)}
13308
+ >
13309
+ ${this.rows.map((r, i) => b`<div
13310
+ class="pair ${this._tickled.isTickled(i) ? `tickled` : ``} ${this._detailIndex === i ? `show-detail` : ``}"
13311
+ ?data-full=${r.fullWidth}
13312
+ @pointerenter=${() => this.#handlePointerEnter(i)}
13313
+ @click=${() => this.#select(i)}
13314
+ ><div class="key">${r.label}</div><div class="value"><span class="value-text">${typeof r.value === `boolean` ? r.value ? `Yes` : `No` : r.value}</span>${r.detail === void 0 ? A : b`<div class="detail" part="detail">${r.detail}</div>`}</div></div>`)}</div>`;
13315
+ }
13316
+ };
13317
+ __decorate([property({
13318
+ type: Array,
13319
+ attribute: false
13320
+ })], DataTableElement.prototype, "rows", void 0);
13321
+ __decorate([property({
13322
+ type: Number,
13323
+ attribute: false
13324
+ })], DataTableElement.prototype, "selectedIndex", void 0);
13325
+ __decorate([property({
13326
+ type: Number,
13327
+ attribute: `tickle-delay`
13328
+ })], DataTableElement.prototype, "tickleDelay", void 0);
13329
+ __decorate([property({
13330
+ type: String,
13331
+ reflect: true
13332
+ })], DataTableElement.prototype, "layout", void 0);
13333
+ __decorate([property({
13334
+ type: Number,
13335
+ attribute: `narrow-breakpoint`
13336
+ })], DataTableElement.prototype, "narrowBreakpoint", void 0);
13337
+ __decorate([state()], DataTableElement.prototype, "_tickled", void 0);
13338
+ __decorate([state()], DataTableElement.prototype, "_detailIndex", void 0);
13339
+ __decorate([state()], DataTableElement.prototype, "_stacked", void 0);
13340
+ DataTableElement = __decorate([safeCustomElement(`ixfx-data-table`)], DataTableElement);
13341
+ //#endregion
11973
13342
  //#region src/detail-list/detail-list.ts
11974
- const ITEM_SELECTOR$1 = `:scope > li`;
13343
+ const ITEM_SELECTOR$2 = `:scope > li`;
11975
13344
  const SEARCH_HIGHLIGHT_KEY = `detail-list-search`;
11976
13345
  const LABEL_SPAN_CSS = `overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0`;
11977
13346
  let DetailListElement = class DetailListElement extends i {
11978
- constructor(..._args2) {
11979
- super(..._args2);
13347
+ constructor(..._args) {
13348
+ super(..._args);
11980
13349
  this.selectionMode = `single`;
11981
13350
  this.interactionMode = `standard`;
11982
13351
  this.sort = `none`;
@@ -11986,12 +13355,11 @@ let DetailListElement = class DetailListElement extends i {
11986
13355
  this.iconProperty = ``;
11987
13356
  this.keyProperty = ``;
11988
13357
  this.itemWrap = `stop`;
11989
- this._searchVisible = false;
11990
- this._searchQuery = ``;
11991
13358
  this.#itemData = /* @__PURE__ */ new WeakMap();
11992
13359
  this.#grid = [];
11993
13360
  this.#positions = /* @__PURE__ */ new Map();
11994
13361
  this.#geometryScheduled = false;
13362
+ this.#animatedItems = /* @__PURE__ */ new WeakSet();
11995
13363
  this.#builtFromProvider = [];
11996
13364
  this.#providerBuilt = 0;
11997
13365
  this.#data = new DataController(this);
@@ -12002,11 +13370,13 @@ let DetailListElement = class DetailListElement extends i {
12002
13370
  selectionMode: this.selectionMode,
12003
13371
  interactionMode: this.interactionMode
12004
13372
  }),
13373
+ getKey: keyForElement,
12005
13374
  onChange: (selected, previous) => {
12006
13375
  this._syncItemStates();
12007
13376
  this._dispatch(`list-select`, {
12008
13377
  selected,
12009
- previous
13378
+ previous,
13379
+ selectedKeys: this.selection.selectedKeys
12010
13380
  });
12011
13381
  }
12012
13382
  });
@@ -12021,6 +13391,11 @@ let DetailListElement = class DetailListElement extends i {
12021
13391
  onStateChange: () => this._syncItemStates(),
12022
13392
  itemWrap: this.itemWrap
12023
13393
  });
13394
+ this.search = new ListSearchController(this, { adapter: createElementListSearchAdapter(this, {
13395
+ itemSelector: ITEM_SELECTOR$2,
13396
+ getLabel: (el) => el.getAttribute(`data-search-label`) ?? el.textContent?.trim() ?? ``,
13397
+ highlightKey: SEARCH_HIGHLIGHT_KEY
13398
+ }) });
12024
13399
  this._onPointerOver = (e) => {
12025
13400
  const item = this._findListItem(e.composedPath());
12026
13401
  if (item) this.tickled.handlePointerEnter(item);
@@ -12029,11 +13404,7 @@ let DetailListElement = class DetailListElement extends i {
12029
13404
  this.tickled.handlePointerLeave(e);
12030
13405
  };
12031
13406
  this._onKeyDown = (e) => {
12032
- if ((e.ctrlKey || e.metaKey) && e.key === `f`) {
12033
- e.preventDefault();
12034
- this._openSearch();
12035
- return;
12036
- }
13407
+ if (this.search.isOpen) return;
12037
13408
  if ((e.ctrlKey || e.metaKey) && e.key === `a`) {
12038
13409
  if (this.selection.canSelectAll()) {
12039
13410
  e.preventDefault();
@@ -12041,11 +13412,6 @@ let DetailListElement = class DetailListElement extends i {
12041
13412
  }
12042
13413
  return;
12043
13414
  }
12044
- if (this._searchVisible && e.key === `Escape`) {
12045
- e.preventDefault();
12046
- this._closeSearch();
12047
- return;
12048
- }
12049
13415
  switch (e.key) {
12050
13416
  case `ArrowDown`:
12051
13417
  e.preventDefault();
@@ -12114,53 +13480,36 @@ let DetailListElement = class DetailListElement extends i {
12114
13480
  e.preventDefault();
12115
13481
  this._listEl.scrollLeft += e.deltaY;
12116
13482
  };
12117
- this._onSearchInput = (e) => {
12118
- const input = e.target;
12119
- this._searchQuery = input.value;
12120
- this.#search?.setQuery(this._searchQuery).then(() => this.#recomputeGeometry());
12121
- };
12122
- this._onSearchKeydown = (e) => {
12123
- if (e.key === `Escape`) {
12124
- e.stopPropagation();
12125
- this._closeSearch();
12126
- } else if (e.key === `ArrowDown`) {
12127
- e.preventDefault();
12128
- this.focus();
12129
- this.#navigateDown();
12130
- } else if (e.key === `ArrowUp`) {
12131
- e.preventDefault();
12132
- this.focus();
12133
- this.#navigateUp();
12134
- } else if (e.key === `ArrowRight`) e.preventDefault();
12135
- else if (e.key === `ArrowLeft`) e.preventDefault();
12136
- else if (e.key === `Enter`) {
12137
- e.preventDefault();
12138
- e.stopPropagation();
12139
- const id = this.tickled.tickledId ?? this.#findFirstCheckable();
12140
- if (id) {
12141
- this._dispatch(`list-item-click`, { item: id });
12142
- this._activateItem(id);
12143
- this._closeSearch();
12144
- }
12145
- } else if (e.key === ` `) {
12146
- const id = this.tickled.tickledId;
12147
- if (id) {
12148
- e.preventDefault();
12149
- this.selection.handleSpace(id);
12150
- }
12151
- }
12152
- };
12153
13483
  }
12154
- #search;
12155
13484
  #itemData;
12156
13485
  #grid;
12157
13486
  #positions;
12158
13487
  #geometryScheduled;
12159
13488
  #resizeObserver;
12160
13489
  #mutationObserver;
13490
+ /** Items that have already played (or been skipped for) their enter animation. */
13491
+ #animatedItems;
12161
13492
  #builtFromProvider;
12162
13493
  #providerBuilt;
12163
13494
  #data;
13495
+ #searchRegistry;
13496
+ #unregisterSearchCommands;
13497
+ /** Command registry carrying this list's `list.search.*` commands. */
13498
+ get registry() {
13499
+ return this.#searchRegistry;
13500
+ }
13501
+ #effectiveSearchModes() {
13502
+ const raw = this.searchModes?.trim();
13503
+ if (raw === void 0) return [...LIST_SEARCH_MODES];
13504
+ const requested = new Set(raw.split(/\s+/));
13505
+ return LIST_SEARCH_MODES.filter((m) => requested.has(m));
13506
+ }
13507
+ #syncSearchCommands() {
13508
+ if (!this.#searchRegistry) return;
13509
+ this.search.setModes(this.#effectiveSearchModes());
13510
+ this.#unregisterSearchCommands?.();
13511
+ this.#unregisterSearchCommands = registerListSearchCommands(this.#searchRegistry, this.search);
13512
+ }
12164
13513
  connectedCallback() {
12165
13514
  super.connectedCallback();
12166
13515
  if (!this.hasAttribute(`tabindex`)) this.tabIndex = 0;
@@ -12172,8 +13521,9 @@ let DetailListElement = class DetailListElement extends i {
12172
13521
  this.addEventListener(`wheel`, this._onWheel);
12173
13522
  this.#resizeObserver = new ResizeObserver(() => this.#scheduleGeometry());
12174
13523
  this.#resizeObserver.observe(this);
12175
- this.#mutationObserver = new MutationObserver(() => {
13524
+ this.#mutationObserver = new MutationObserver((records) => {
12176
13525
  this.#processChildren();
13526
+ this.#animateChildMutations(records);
12177
13527
  this.#scheduleGeometry();
12178
13528
  });
12179
13529
  this.#mutationObserver.observe(this, {
@@ -12181,6 +13531,29 @@ let DetailListElement = class DetailListElement extends i {
12181
13531
  subtree: false,
12182
13532
  attributeFilter: [`hidden`]
12183
13533
  });
13534
+ for (const el of this.querySelectorAll(ITEM_SELECTOR$2)) this.#animatedItems.add(el);
13535
+ this.#searchRegistry ??= createCommandRegistry();
13536
+ this.#syncSearchCommands();
13537
+ }
13538
+ /**
13539
+ * Collapse-in items appended to the light DOM (however they got there). A bulk
13540
+ * populate is left alone. Geometry is re-read once the animations settle so
13541
+ * keyboard navigation lines back up with the final layout.
13542
+ */
13543
+ #animateChildMutations(records) {
13544
+ if (!this.isConnected) return;
13545
+ const fresh = [];
13546
+ for (const record of records) for (const node of record.addedNodes) {
13547
+ if (!(node instanceof HTMLElement) || node.parentElement !== this || node.localName !== `li`) continue;
13548
+ if (this.#animatedItems.has(node)) continue;
13549
+ this.#animatedItems.add(node);
13550
+ fresh.push(node);
13551
+ }
13552
+ if (fresh.length === 0) return;
13553
+ const total = this.querySelectorAll(ITEM_SELECTOR$2).length;
13554
+ if (fresh.length >= total) return;
13555
+ for (const el of fresh) animateListItemIn(el);
13556
+ window.setTimeout(() => this.#scheduleGeometry(), 230);
12184
13557
  }
12185
13558
  disconnectedCallback() {
12186
13559
  super.disconnectedCallback();
@@ -12194,23 +13567,18 @@ let DetailListElement = class DetailListElement extends i {
12194
13567
  this.#resizeObserver = void 0;
12195
13568
  this.#mutationObserver?.disconnect();
12196
13569
  this.#mutationObserver = void 0;
12197
- this.#search?.dispose();
12198
- this.#search = void 0;
13570
+ this.#unregisterSearchCommands?.();
13571
+ this.#unregisterSearchCommands = void 0;
13572
+ this.#searchRegistry = void 0;
12199
13573
  }
12200
13574
  firstUpdated() {
12201
13575
  this.#processChildren();
12202
13576
  if (this.filterPredicate) this.#applyFilterPredicate();
12203
- this.#search = createElementIncrSearch({
12204
- container: this,
12205
- itemSelector: ITEM_SELECTOR$1,
12206
- getLabel: (el) => el.getAttribute(`data-search-label`) ?? el.textContent?.trim() ?? ``,
12207
- getKey: (el) => el.getAttribute(`data-key`) ?? el.getAttribute(`data-search-label`) ?? el.textContent?.trim() ?? ``,
12208
- highlightKey: SEARCH_HIGHLIGHT_KEY
12209
- });
12210
13577
  this.#recomputeGeometry();
12211
13578
  }
12212
13579
  updated(changed) {
12213
13580
  super.updated(changed);
13581
+ if (changed.has(`searchModes`)) this.#syncSearchCommands();
12214
13582
  if (changed.has(`provider`)) {
12215
13583
  this.#builtFromProvider = [];
12216
13584
  this.#providerBuilt = 0;
@@ -12261,8 +13629,21 @@ let DetailListElement = class DetailListElement extends i {
12261
13629
  if (this.sort !== `none`) this.#applySort();
12262
13630
  if (this.interactionMode === `checked`) this._syncCheckboxes();
12263
13631
  }
13632
+ /**
13633
+ * Collapse + fade `item` out, then detach it; the rows after it slide up to
13634
+ * close the gap. Resolves once removed. Immediate under reduced motion.
13635
+ */
12264
13636
  removeItem(item) {
12265
- item.remove();
13637
+ this.selection.deselect(item);
13638
+ return animateListItemOut(item).then(() => {
13639
+ item.remove();
13640
+ if (this.interactionMode === `checked`) this._syncCheckboxes();
13641
+ this.#recomputeGeometry();
13642
+ });
13643
+ }
13644
+ /** {@link removeItem} for several items at once. */
13645
+ removeItems(items) {
13646
+ return Promise.all([...items].map((item) => this.removeItem(item))).then(() => void 0);
12266
13647
  }
12267
13648
  clearItems() {
12268
13649
  this.replaceChildren();
@@ -12291,8 +13672,14 @@ let DetailListElement = class DetailListElement extends i {
12291
13672
  getItemData(item) {
12292
13673
  return this.#itemData.get(item);
12293
13674
  }
12294
- /** Dispatch a command to this list. No-op by default (grouped-lister parity). */
12295
- dispatch(_commandId, _args) {}
13675
+ /**
13676
+ * Dispatch a command to this list — used by `ixfx-grouped-item-lister` to
13677
+ * forward commands. Routes through the command registry (so `list.search.*`
13678
+ * and any other registered commands work).
13679
+ */
13680
+ dispatch(commandId, args) {
13681
+ this.#searchRegistry?.invoke(commandId, args);
13682
+ }
12296
13683
  select(item) {
12297
13684
  this.selection.replace(item);
12298
13685
  }
@@ -12308,6 +13695,40 @@ let DetailListElement = class DetailListElement extends i {
12308
13695
  selectAll() {
12309
13696
  if (this.selection.canSelectAll()) this.selection.selectAll();
12310
13697
  }
13698
+ /** The selection as stable `data-key` / `data-value` strings. */
13699
+ get selectedKeys() {
13700
+ return this.selection.selectedKeys;
13701
+ }
13702
+ /** The key of the item under the cursor, or `undefined`. */
13703
+ get tickledKey() {
13704
+ const id = this.tickled.tickledId;
13705
+ return id ? keyForElement(id) : void 0;
13706
+ }
13707
+ /** Replace the selection with the items carrying these keys (unknown keys ignored). */
13708
+ selectKeys(keys) {
13709
+ this.selection.selectKeys(keys, (key) => this.#itemForKey(key));
13710
+ }
13711
+ /** Add items by key to the selection (multi-select modes only). */
13712
+ addSelectedKeys(keys) {
13713
+ this.selection.addKeys(keys, (key) => this.#itemForKey(key));
13714
+ }
13715
+ /** Remove items by key from the selection. */
13716
+ removeSelectedKeys(keys) {
13717
+ this.selection.removeKeys(keys, (key) => this.#itemForKey(key));
13718
+ }
13719
+ /** Move the cursor to the item with this key and scroll it into view. */
13720
+ focusKey(key) {
13721
+ const el = this.#itemForKey(key);
13722
+ if (!el) return;
13723
+ this.tickled.focus(el);
13724
+ el.scrollIntoView({
13725
+ block: `nearest`,
13726
+ inline: `nearest`
13727
+ });
13728
+ }
13729
+ #itemForKey(key) {
13730
+ for (const el of this.querySelectorAll(ITEM_SELECTOR$2)) if (keyForElement(el) === key) return el;
13731
+ }
12311
13732
  *selectedFilter(kind) {
12312
13733
  const wanted = (el) => {
12313
13734
  switch (kind) {
@@ -12316,7 +13737,7 @@ let DetailListElement = class DetailListElement extends i {
12316
13737
  case `either`: return el.hasAttribute(`data-selected`) || el.hasAttribute(`data-checked`);
12317
13738
  }
12318
13739
  };
12319
- for (const el of this.querySelectorAll(ITEM_SELECTOR$1)) {
13740
+ for (const el of this.querySelectorAll(ITEM_SELECTOR$2)) {
12320
13741
  if (el.hidden) continue;
12321
13742
  if (wanted(el)) yield el;
12322
13743
  }
@@ -12329,7 +13750,7 @@ let DetailListElement = class DetailListElement extends i {
12329
13750
  if (previousKeys.size > 0 || anchorKey !== null) {
12330
13751
  const next = [];
12331
13752
  let anchor;
12332
- for (const el of this.querySelectorAll(ITEM_SELECTOR$1)) {
13753
+ for (const el of this.querySelectorAll(ITEM_SELECTOR$2)) {
12333
13754
  const key = keyOf(el);
12334
13755
  if (key !== null && previousKeys.has(key)) next.push(el);
12335
13756
  if (key !== null && key === anchorKey) anchor = el;
@@ -12384,15 +13805,15 @@ let DetailListElement = class DetailListElement extends i {
12384
13805
  }
12385
13806
  #applyFilterPredicate() {
12386
13807
  if (!this.filterPredicate) {
12387
- for (const el of this.querySelectorAll(ITEM_SELECTOR$1)) el.removeAttribute(`hidden`);
13808
+ for (const el of this.querySelectorAll(ITEM_SELECTOR$2)) el.removeAttribute(`hidden`);
12388
13809
  return;
12389
13810
  }
12390
- for (const el of this.querySelectorAll(ITEM_SELECTOR$1)) if (this.filterPredicate(el)) el.removeAttribute(`hidden`);
13811
+ for (const el of this.querySelectorAll(ITEM_SELECTOR$2)) if (this.filterPredicate(el)) el.removeAttribute(`hidden`);
12391
13812
  else el.setAttribute(`hidden`, ``);
12392
13813
  }
12393
13814
  #applySort() {
12394
13815
  if (this.sort === `none`) return;
12395
- const rows = [...this.querySelectorAll(ITEM_SELECTOR$1)];
13816
+ const rows = [...this.querySelectorAll(ITEM_SELECTOR$2)];
12396
13817
  if (rows.length < 2) return;
12397
13818
  const dir = this.sort === `descending` ? -1 : 1;
12398
13819
  const meta = (el) => ({
@@ -12584,7 +14005,7 @@ let DetailListElement = class DetailListElement extends i {
12584
14005
  else if (this.itemWrap === `wrap`) this.#navigateTo(this.#findLastCheckable());
12585
14006
  }
12586
14007
  _getVisibleItems() {
12587
- return Array.from(this.querySelectorAll(ITEM_SELECTOR$1)).filter((el) => {
14008
+ return Array.from(this.querySelectorAll(ITEM_SELECTOR$2)).filter((el) => {
12588
14009
  if (el.hidden) return false;
12589
14010
  if (this.filterPredicate && !this.filterPredicate(el)) return false;
12590
14011
  return true;
@@ -12639,10 +14060,10 @@ let DetailListElement = class DetailListElement extends i {
12639
14060
  }
12640
14061
  /** Scan light-DOM children for unprocessed `data-icon` items. */
12641
14062
  #processChildren() {
12642
- for (const el of this.querySelectorAll(ITEM_SELECTOR$1)) this.#ensureIcon(el);
14063
+ for (const el of this.querySelectorAll(ITEM_SELECTOR$2)) this.#ensureIcon(el);
12643
14064
  }
12644
14065
  _syncItemStates() {
12645
- for (const el of this.querySelectorAll(ITEM_SELECTOR$1)) {
14066
+ for (const el of this.querySelectorAll(ITEM_SELECTOR$2)) {
12646
14067
  el.toggleAttribute(`data-tickled`, this.tickled.isTickled(el));
12647
14068
  el.toggleAttribute(`data-selected`, this.selection.selected.has(el));
12648
14069
  if (this.interactionMode === `checked`) el.toggleAttribute(`data-checked`, !el.hasAttribute(`not-checked`) && this.selection.selected.has(el));
@@ -12742,16 +14163,6 @@ let DetailListElement = class DetailListElement extends i {
12742
14163
  composed: true
12743
14164
  }));
12744
14165
  }
12745
- _openSearch() {
12746
- this._searchVisible = true;
12747
- this.updateComplete.then(() => this._searchInputEl?.focus());
12748
- }
12749
- _closeSearch() {
12750
- this._searchVisible = false;
12751
- this._searchQuery = ``;
12752
- this.#search?.clearQuery().then(() => this.#recomputeGeometry());
12753
- this.focus();
12754
- }
12755
14166
  _activateItem(item) {
12756
14167
  if (this.interactionMode !== `manual`) this.selection.replace(item);
12757
14168
  this._dispatch(`list-activate`, { item });
@@ -12760,6 +14171,7 @@ let DetailListElement = class DetailListElement extends i {
12760
14171
  this.styles = [
12761
14172
  themeFallbacks,
12762
14173
  scrollFadeStyles(`x`),
14174
+ listSearchStyles,
12763
14175
  i$3`
12764
14176
  :host {
12765
14177
  display: block;
@@ -12881,48 +14293,6 @@ let DetailListElement = class DetailListElement extends i {
12881
14293
  .loading[hidden] {
12882
14294
  display: none;
12883
14295
  }
12884
-
12885
- .search-overlay {
12886
- position: absolute;
12887
- top: 8px;
12888
- right: 8px;
12889
- z-index: 10;
12890
- display: flex;
12891
- align-items: center;
12892
- gap: 4px;
12893
- background: var(--surface-4, #2a2a2a);
12894
- border: 1px solid var(--border, #444);
12895
- border-radius: var(--radius-m, 4px);
12896
- padding: 4px 6px;
12897
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
12898
- }
12899
-
12900
- .search-overlay[hidden] {
12901
- display: none;
12902
- }
12903
-
12904
- .search-input {
12905
- background: transparent;
12906
- border: none;
12907
- outline: none;
12908
- color: inherit;
12909
- font-size: var(--text-m, 0.875em);
12910
- min-width: 140px;
12911
- }
12912
-
12913
- .search-close {
12914
- background: transparent;
12915
- border: none;
12916
- color: var(--surface-muted-text, #888);
12917
- cursor: pointer;
12918
- font-size: 1.1em;
12919
- line-height: 1;
12920
- padding: 0 2px;
12921
- }
12922
-
12923
- .search-close:hover {
12924
- color: inherit;
12925
- }
12926
14296
  `
12927
14297
  ];
12928
14298
  }
@@ -12937,21 +14307,7 @@ let DetailListElement = class DetailListElement extends i {
12937
14307
  <div class="loading" part="loading" ?hidden=${!loading}>
12938
14308
  ${loading ? progress !== void 0 ? `${Math.round(progress * 100)}%` : `loading…` : A}
12939
14309
  </div>
12940
- <div
12941
- class="search-overlay"
12942
- part="search-overlay"
12943
- ?hidden=${!this._searchVisible}
12944
- >
12945
- <input
12946
- type="text"
12947
- class="search-input"
12948
- placeholder="Search…"
12949
- .value=${this._searchQuery}
12950
- @input=${this._onSearchInput}
12951
- @keydown=${this._onSearchKeydown}
12952
- />
12953
- <button class="search-close" @click=${this._closeSearch} title="Close search (Esc)">×</button>
12954
- </div>
14310
+ ${this.search.renderField()}
12955
14311
  </div>
12956
14312
  `;
12957
14313
  }
@@ -12991,9 +14347,11 @@ __decorate([property({
12991
14347
  attribute: `item-wrap`
12992
14348
  })], DetailListElement.prototype, "itemWrap", void 0);
12993
14349
  __decorate([property({ attribute: false })], DetailListElement.prototype, "checkboxRenderer", void 0);
12994
- __decorate([state()], DetailListElement.prototype, "_searchVisible", void 0);
12995
- __decorate([state()], DetailListElement.prototype, "_searchQuery", void 0);
12996
- __decorate([query(`.search-input`)], DetailListElement.prototype, "_searchInputEl", void 0);
14350
+ __decorate([property({
14351
+ type: String,
14352
+ attribute: `search-modes`,
14353
+ reflect: true
14354
+ })], DetailListElement.prototype, "searchModes", void 0);
12997
14355
  __decorate([query(`ul`)], DetailListElement.prototype, "_listEl", void 0);
12998
14356
  DetailListElement = __decorate([safeCustomElement(`ixfx-detail-list`)], DetailListElement);
12999
14357
  //#endregion
@@ -18701,6 +20059,983 @@ let FormElement = class FormElement extends i {
18701
20059
  };
18702
20060
  FormElement = __decorate([safeCustomElement(`ixfx-form`)], FormElement);
18703
20061
  //#endregion
20062
+ //#region src/grid-list/grid-list.ts
20063
+ const ITEM_SELECTOR$1 = `:scope > *`;
20064
+ const MIN_ITEM_SIZE = 16;
20065
+ /**
20066
+ * How long the host keeps the `[reflowing]` attribute after items are inserted
20067
+ * mid-list — long enough for the cell move-transition and the enter animation
20068
+ * to finish. Kept a touch above the default `--grid-list-item-move-duration`.
20069
+ */
20070
+ const REFLOW_WINDOW_MS = 320;
20071
+ function computeGridWindow(count, itemWidth, rowHeight, gap, viewportWidth, viewportHeight, scrollOffset, orientation, overscan = 2) {
20072
+ const w = Math.max(1, itemWidth);
20073
+ const h = Math.max(1, rowHeight);
20074
+ const g = Math.max(0, gap);
20075
+ const colStride = w + g;
20076
+ const rowStride = h + g;
20077
+ const offset = Math.max(0, scrollOffset);
20078
+ if (orientation === `row`) {
20079
+ const rows = count === 0 ? 0 : 1;
20080
+ const totalWidth = count === 0 ? 0 : (count - 1) * colStride + w;
20081
+ if (count === 0 || viewportWidth <= 0) return {
20082
+ cols: count,
20083
+ rows,
20084
+ totalHeight: 0,
20085
+ totalWidth,
20086
+ startIndex: 0,
20087
+ endIndex: 0,
20088
+ colStride,
20089
+ rowStride,
20090
+ horizontal: true
20091
+ };
20092
+ const firstVisible = Math.max(0, Math.floor(offset / colStride));
20093
+ const lastVisible = Math.ceil((offset + viewportWidth) / colStride);
20094
+ return {
20095
+ cols: count,
20096
+ rows,
20097
+ totalHeight: 0,
20098
+ totalWidth,
20099
+ startIndex: Math.max(0, firstVisible - overscan),
20100
+ endIndex: Math.min(count, lastVisible + overscan),
20101
+ colStride,
20102
+ rowStride,
20103
+ horizontal: true
20104
+ };
20105
+ }
20106
+ const cols = orientation === `column` ? 1 : Math.max(1, Math.floor((Math.max(0, viewportWidth) + g) / colStride));
20107
+ const rows = count === 0 ? 0 : Math.ceil(count / cols);
20108
+ const totalHeight = rows === 0 ? 0 : (rows - 1) * rowStride + h;
20109
+ if (count === 0 || viewportHeight <= 0) return {
20110
+ cols,
20111
+ rows,
20112
+ totalHeight,
20113
+ totalWidth: 0,
20114
+ startIndex: 0,
20115
+ endIndex: 0,
20116
+ colStride,
20117
+ rowStride,
20118
+ horizontal: false
20119
+ };
20120
+ const firstVisibleRow = Math.max(0, Math.floor(offset / rowStride));
20121
+ const lastVisibleRow = Math.ceil((offset + viewportHeight) / rowStride);
20122
+ const rowStart = Math.max(0, firstVisibleRow - overscan);
20123
+ const rowEnd = Math.min(rows, lastVisibleRow + overscan);
20124
+ return {
20125
+ cols,
20126
+ rows,
20127
+ totalHeight,
20128
+ totalWidth: 0,
20129
+ startIndex: rowStart * cols,
20130
+ endIndex: Math.min(count, rowEnd * cols),
20131
+ colStride,
20132
+ rowStride,
20133
+ horizontal: false
20134
+ };
20135
+ }
20136
+ function columnOf(index, cols) {
20137
+ return cols <= 1 ? 0 : index % cols;
20138
+ }
20139
+ function rowOf(index, cols) {
20140
+ return cols <= 1 ? index : Math.floor(index / cols);
20141
+ }
20142
+ let GridListElement = class GridListElement extends i {
20143
+ constructor(..._args) {
20144
+ super(..._args);
20145
+ this.selectionMode = `single`;
20146
+ this.interactionMode = `standard`;
20147
+ this.itemSize = 160;
20148
+ this.gap = 8;
20149
+ this.orientation = `grid`;
20150
+ this.captionStyle = `under`;
20151
+ this.captionAlign = `middle`;
20152
+ this.captionPosition = `bottom`;
20153
+ this.captionBackdrop = `scrim`;
20154
+ this.captionHeight = 24;
20155
+ this.itemWrap = `stop`;
20156
+ this._scrollTop = 0;
20157
+ this._scrollLeft = 0;
20158
+ this._viewportHeight = 0;
20159
+ this._viewportWidth = 0;
20160
+ this.#viewportRef = createRef();
20161
+ this.#scrollRaf = 0;
20162
+ this.#items = [];
20163
+ this.#itemWrap = `stop`;
20164
+ this.#entering = /* @__PURE__ */ new Set();
20165
+ this.#reflowTimer = 0;
20166
+ this.#slotNames = /* @__PURE__ */ new WeakMap();
20167
+ this.#slotSeq = 0;
20168
+ this.selection = new ListSelectionController({
20169
+ getOrderedItems: () => this._getVisibleItems(),
20170
+ isSelectable: () => true,
20171
+ getModes: () => ({
20172
+ selectionMode: this.selectionMode,
20173
+ interactionMode: this.interactionMode
20174
+ }),
20175
+ getKey: keyForElement,
20176
+ onChange: (selected, previous) => {
20177
+ this._syncItemStates();
20178
+ this.requestUpdate();
20179
+ this._dispatch(`list-select`, {
20180
+ selected,
20181
+ previous,
20182
+ selectedKeys: this.selection.selectedKeys
20183
+ });
20184
+ }
20185
+ });
20186
+ this.tickled = new TickledController(this, {
20187
+ getItems: () => this.#items.map((el) => ({
20188
+ id: el,
20189
+ enabled: !el.hasAttribute(`not-checked`)
20190
+ })),
20191
+ onTickle: (id) => {
20192
+ if (id !== void 0) this._dispatch(`list-tickle`, { item: id });
20193
+ },
20194
+ onStateChange: () => this._syncItemStates(),
20195
+ itemWrap: this.#itemWrap
20196
+ });
20197
+ this.search = new ListSearchController(this, { adapter: createElementListSearchAdapter(this, {
20198
+ itemSelector: ITEM_SELECTOR$1,
20199
+ getLabel: (el) => el.getAttribute(`data-label`) ?? el.querySelector(`[data-grid-list-caption]`)?.textContent?.trim() ?? el.getAttribute(`data-search-label`) ?? el.textContent?.trim() ?? ``,
20200
+ highlightKey: `grid-list-search`
20201
+ }) });
20202
+ this.#onScroll = (e) => {
20203
+ const el = e.target;
20204
+ this.#cancelScrollRaf();
20205
+ this.#scrollRaf = requestAnimationFrame(() => {
20206
+ this.#scrollRaf = 0;
20207
+ this._scrollTop = el.scrollTop;
20208
+ this._scrollLeft = el.scrollLeft;
20209
+ this.#emitViewportChange();
20210
+ });
20211
+ };
20212
+ this._onKeyDown = (e) => {
20213
+ if ((e.ctrlKey || e.metaKey) && e.key === `f`) return;
20214
+ if ((e.ctrlKey || e.metaKey) && e.key === `a`) {
20215
+ if (this.selection.canSelectAll()) {
20216
+ e.preventDefault();
20217
+ this.selection.selectAll();
20218
+ }
20219
+ return;
20220
+ }
20221
+ const count = this.#items.length;
20222
+ if (count === 0) return;
20223
+ const win = this.#currentWindow();
20224
+ const move = (delta) => {
20225
+ const from = this.tickled.tickledId ? this.#items.indexOf(this.tickled.tickledId) : -1;
20226
+ const nextIndex = Math.max(0, Math.min(count - 1, from + delta));
20227
+ const next = this.#items[nextIndex];
20228
+ if (next) {
20229
+ this.tickled.handleKeyboardNavigation(next);
20230
+ this.#scrollItemIntoView(next);
20231
+ }
20232
+ };
20233
+ const perLine = this.orientation === `grid` ? win.cols : 1;
20234
+ switch (e.key) {
20235
+ case `ArrowLeft`:
20236
+ move(-1);
20237
+ break;
20238
+ case `ArrowRight`:
20239
+ move(1);
20240
+ break;
20241
+ case `ArrowUp`:
20242
+ move(-perLine);
20243
+ break;
20244
+ case `ArrowDown`:
20245
+ move(perLine);
20246
+ break;
20247
+ case `Home`:
20248
+ this.tickled.handleKeyboardNavigation(`first`);
20249
+ if (this.tickled.tickledId) this.#scrollItemIntoView(this.tickled.tickledId);
20250
+ break;
20251
+ case `End`:
20252
+ this.tickled.handleKeyboardNavigation(`last`);
20253
+ if (this.tickled.tickledId) this.#scrollItemIntoView(this.tickled.tickledId);
20254
+ break;
20255
+ case `PageUp`:
20256
+ case `PageDown`: {
20257
+ const mainExtent = win.horizontal ? this._viewportWidth : this._viewportHeight;
20258
+ const mainStride = win.horizontal ? win.colStride : win.rowStride;
20259
+ const visibleLines = Math.max(1, Math.floor(mainExtent / mainStride));
20260
+ move((e.key === `PageDown` ? 1 : -1) * visibleLines * perLine);
20261
+ break;
20262
+ }
20263
+ case `Enter`: {
20264
+ const id = this.tickled.tickledId;
20265
+ if (id) {
20266
+ e.preventDefault();
20267
+ this._dispatch(`list-item-click`, { item: id });
20268
+ this.#onCellActivate(id);
20269
+ }
20270
+ break;
20271
+ }
20272
+ case ` `: {
20273
+ const id = this.tickled.tickledId;
20274
+ if (id) {
20275
+ e.preventDefault();
20276
+ this.selection.handleSpace(id);
20277
+ }
20278
+ break;
20279
+ }
20280
+ case `Escape`:
20281
+ this.selection.clear();
20282
+ break;
20283
+ default: return;
20284
+ }
20285
+ e.preventDefault();
20286
+ };
20287
+ }
20288
+ static {
20289
+ this.styles = [
20290
+ themeFallbacks,
20291
+ tickledStyles,
20292
+ tickledItemStyles,
20293
+ scrollFadeStyles(`x`),
20294
+ scrollFadeStyles(`y`),
20295
+ listItemFadeKeyframes,
20296
+ listSearchStyles,
20297
+ i$3`
20298
+ :host {
20299
+ --grid-list-item-bg: var(--item-bg);
20300
+ --grid-list-radius: var(--radius-m);
20301
+ --grid-list-gap: var(--space-xs);
20302
+ position: relative;
20303
+ display: block;
20304
+ height: 100%;
20305
+ min-height: 2em;
20306
+ overflow: hidden;
20307
+ user-select: none;
20308
+ background: var(--surface-3);
20309
+ color: var(--surface-3-text);
20310
+ border-radius: var(--radius-s);
20311
+ border: 1px solid var(--border);
20312
+ }
20313
+ :host(:focus-visible) {
20314
+ outline: 2px solid var(--accent);
20315
+ outline-offset: -2px;
20316
+ }
20317
+ .viewport {
20318
+ position: relative;
20319
+ height: 100%;
20320
+ overflow-y: auto;
20321
+ overflow-x: hidden;
20322
+ }
20323
+ :host([orientation="row"]) .viewport {
20324
+ overflow-x: auto;
20325
+ overflow-y: hidden;
20326
+ }
20327
+ .spacer {
20328
+ position: relative;
20329
+ width: 100%;
20330
+ }
20331
+ .cell {
20332
+ position: absolute;
20333
+ inset: 0 auto auto 0;
20334
+ box-sizing: border-box;
20335
+ display: flex;
20336
+ flex-direction: column;
20337
+ align-items: stretch;
20338
+ contain: layout style;
20339
+ border-radius: var(--grid-list-radius);
20340
+ }
20341
+
20342
+ /* The square that holds the slotted content. */
20343
+ .thumb {
20344
+ position: relative;
20345
+ flex: 1;
20346
+ min-height: 0;
20347
+ display: flex;
20348
+ align-items: stretch;
20349
+ box-sizing: border-box;
20350
+ overflow: hidden;
20351
+ border-radius: 0;
20352
+ background: transparent;
20353
+ border: 1px solid transparent;
20354
+ transition: background var(--transition), border-color var(--transition);
20355
+ }
20356
+ .thumb ::slotted(*) {
20357
+ flex: 1;
20358
+ min-width: 0;
20359
+ min-height: 0;
20360
+ width: 100%;
20361
+ display: flex;
20362
+ align-items: center;
20363
+ justify-content: center;
20364
+ overflow: hidden;
20365
+ }
20366
+ .thumb ::slotted(*:empty)::after {
20367
+ content: "Empty";
20368
+ color: var(--surface-muted-text);
20369
+ font-size: var(--text-s);
20370
+ }
20371
+ ::slotted([hidden]) {
20372
+ display: none !important;
20373
+ }
20374
+
20375
+ /* Selection / cursor wash — sits over the slotted content so it reads
20376
+ on photos and arbitrary DOM alike. */
20377
+ .thumb::after {
20378
+ content: "";
20379
+ position: absolute;
20380
+ inset: 0;
20381
+ pointer-events: none;
20382
+ border-radius: inherit;
20383
+ background: var(--accent);
20384
+ opacity: 0;
20385
+ transition: opacity var(--transition, 0.1s ease-out);
20386
+ }
20387
+ .cell.selected .thumb::after {
20388
+ opacity: 0.18;
20389
+ }
20390
+
20391
+ /* ---- Caption (base) ---- */
20392
+ .caption {
20393
+ box-sizing: border-box;
20394
+ flex: none;
20395
+ padding: 3px 6px;
20396
+ font-size: var(--text-s, 0.8rem);
20397
+ line-height: 1.3;
20398
+ text-align: center;
20399
+ white-space: nowrap;
20400
+ overflow: hidden;
20401
+ text-overflow: ellipsis;
20402
+ color: var(--surface-3-text);
20403
+ }
20404
+ :host([caption-align="left"]) .caption { text-align: left; }
20405
+ :host([caption-align="right"]) .caption { text-align: right; }
20406
+
20407
+ /* ---- Box modes (hidden / under / overlaid): the cell is one box with a
20408
+ single selection outline ---- */
20409
+ :host(:not([caption-style="separate"])) .cell {
20410
+ background: var(--grid-list-item-bg);
20411
+ border: 1px solid transparent;
20412
+ overflow: hidden;
20413
+ transition: background var(--transition), border-color var(--transition);
20414
+ }
20415
+ :host(:not([caption-style="separate"])) .cell:hover,
20416
+ :host(:not([caption-style="separate"])) .cell.tickled {
20417
+ background: var(--item-bg-tickled);
20418
+ border: var(--item-border-tickled);
20419
+ }
20420
+ :host(:not([caption-style="separate"])) .cell.selected {
20421
+ outline: 2px solid var(--accent);
20422
+ outline-offset: -2px;
20423
+ }
20424
+ :host(:not([caption-style="separate"])) .cell.selected.tickled {
20425
+ background: var(--item-bg-selected-tickled);
20426
+ outline: 2px dashed var(--surface-3);
20427
+ outline-offset: -2px;
20428
+ }
20429
+
20430
+ /* under: caption is a strip inside the box, below the content */
20431
+ :host([caption-style="under"]) .caption {
20432
+ border-top: 1px solid var(--border, rgba(128, 128, 128, 0.3));
20433
+ background: var(--surface-4, transparent);
20434
+ }
20435
+
20436
+ /* overlaid: caption floats over the content */
20437
+ :host([caption-style="overlaid"]) .caption {
20438
+ position: absolute;
20439
+ left: 0;
20440
+ right: 0;
20441
+ bottom: 0;
20442
+ color: #fff;
20443
+ }
20444
+ :host([caption-style="overlaid"][caption-position="top"]) .caption {
20445
+ top: 0;
20446
+ bottom: auto;
20447
+ }
20448
+ :host([caption-style="overlaid"][caption-position="middle"]) .caption {
20449
+ top: 50%;
20450
+ bottom: auto;
20451
+ transform: translateY(-50%);
20452
+ }
20453
+ /* overlaid backdrop */
20454
+ :host([caption-style="overlaid"][caption-backdrop="scrim"]) .caption {
20455
+ background: rgba(0, 0, 0, 0.55);
20456
+ }
20457
+ :host([caption-style="overlaid"][caption-backdrop="gradient"]) .caption {
20458
+ background: linear-gradient(to top, rgba(0, 0, 0, 0.75), rgba(0, 0, 0, 0));
20459
+ padding-block: 10px 4px;
20460
+ }
20461
+ :host([caption-style="overlaid"][caption-backdrop="gradient"][caption-position="top"]) .caption {
20462
+ background: linear-gradient(to bottom, rgba(0, 0, 0, 0.75), rgba(0, 0, 0, 0));
20463
+ padding-block: 4px 10px;
20464
+ }
20465
+ :host([caption-style="overlaid"][caption-backdrop="gradient"][caption-position="middle"]) .caption {
20466
+ background: rgba(0, 0, 0, 0.5);
20467
+ padding-block: 3px;
20468
+ }
20469
+ :host([caption-style="overlaid"][caption-backdrop="blur"]) .caption {
20470
+ background: rgba(20, 20, 20, 0.3);
20471
+ backdrop-filter: blur(6px);
20472
+ -webkit-backdrop-filter: blur(6px);
20473
+ }
20474
+ :host([caption-style="overlaid"][caption-backdrop="none"]) .caption {
20475
+ background: none;
20476
+ text-shadow: 0 1px 3px rgba(0, 0, 0, 0.95), 0 0 6px rgba(0, 0, 0, 0.7);
20477
+ }
20478
+
20479
+ /* ---- separate mode: square + detached caption, each highlighted ---- */
20480
+ :host([caption-style="separate"]) .thumb {
20481
+ flex: none;
20482
+ border-radius: var(--grid-list-radius);
20483
+ background: var(--grid-list-item-bg);
20484
+ border: 1px solid transparent;
20485
+ }
20486
+ :host([caption-style="separate"]) .cell:hover .thumb,
20487
+ :host([caption-style="separate"]) .cell.tickled .thumb {
20488
+ background: var(--item-bg-tickled);
20489
+ border: var(--item-border-tickled);
20490
+ }
20491
+ :host([caption-style="separate"]) .caption {
20492
+ display: flex;
20493
+ align-items: center;
20494
+ align-self: center;
20495
+ max-width: 100%;
20496
+ margin-top: 4px;
20497
+ border-radius: var(--radius-s, 4px);
20498
+ }
20499
+ :host([caption-style="separate"][caption-align="left"]) .caption { align-self: flex-start; }
20500
+ :host([caption-style="separate"][caption-align="right"]) .caption { align-self: flex-end; }
20501
+ :host([caption-style="separate"]) .cell.selected .thumb {
20502
+ outline: 2px solid var(--accent);
20503
+ outline-offset: 2px;
20504
+ }
20505
+ :host([caption-style="separate"]) .cell.selected.tickled .thumb {
20506
+ background: var(--item-bg-selected-tickled);
20507
+ outline: 2px dashed var(--surface-3);
20508
+ outline-offset: 2px;
20509
+ }
20510
+ :host([caption-style="separate"]) .cell.selected .caption {
20511
+ background: var(--accent);
20512
+ color: var(--accent-text, #000);
20513
+ }
20514
+ :host([caption-style="separate"]) .cell.tickled .caption {
20515
+ background: var(--item-bg-tickled);
20516
+ outline: var(--item-border-tickled);
20517
+ }
20518
+ :host([caption-style="separate"]) .cell.selected.tickled .caption {
20519
+ background: var(--item-bg-selected-tickled);
20520
+ outline: 2px dashed var(--surface-3);
20521
+ outline-offset: -2px;
20522
+ }
20523
+
20524
+ /* ---- Insertion / removal animation ----
20525
+ Active only while the host carries [reflowing] — the ~0.3s window after
20526
+ the child list changes mid-list (see #noteChildMutation). Kept out of
20527
+ the base rules so it never fires during scroll virtualisation, where
20528
+ cells are recycled to wildly different positions every frame. Placed
20529
+ last so it wins over the earlier cell transition rule regardless of
20530
+ specificity. Survivors slide to their new slot; a freshly inserted cell
20531
+ also fades in. (A removed item's element is already gone, so there's
20532
+ nothing left to fade — the gap simply closes as the others slide.) */
20533
+ :host([reflowing]) .cell {
20534
+ transition:
20535
+ transform var(--grid-list-item-move-duration, 200ms) cubic-bezier(0.2, 0, 0, 1),
20536
+ background var(--transition),
20537
+ border-color var(--transition);
20538
+ }
20539
+ .cell.entering {
20540
+ animation: ixfx-list-item-in var(--grid-list-item-move-duration, 200ms) ease both;
20541
+ }
20542
+ @media (prefers-reduced-motion: reduce) {
20543
+ :host([reflowing]) .cell { transition: background var(--transition), border-color var(--transition); }
20544
+ .cell.entering { animation: none; }
20545
+ }
20546
+ `
20547
+ ];
20548
+ }
20549
+ #viewportRef;
20550
+ #resizeObserver;
20551
+ #mutationObserver;
20552
+ #scrollRaf;
20553
+ #items;
20554
+ #itemWrap;
20555
+ /** Items added mid-list that are still playing their enter animation. */
20556
+ #entering;
20557
+ /** Timer that ends the `[reflowing]` window opened by a mid-list insertion. */
20558
+ #reflowTimer;
20559
+ /**
20560
+ * When cell geometry changes (zoom), the item nearest the viewport centre is
20561
+ * captured here in `willUpdate` and the scroll position is corrected in
20562
+ * `updated` so that item stays put — rather than the grid anchoring to its
20563
+ * top-left corner.
20564
+ */
20565
+ #zoomAnchor;
20566
+ /**
20567
+ * Per-item slot name. Each cell renders its own named `<slot>` and the
20568
+ * matching light-DOM child carries `slot="<name>"`, so a child is projected
20569
+ * into exactly one cell. (A single unnamed slot would collapse every child
20570
+ * into the first cell.)
20571
+ */
20572
+ #slotNames;
20573
+ #slotSeq;
20574
+ #searchRegistry;
20575
+ #unregisterSearchCommands;
20576
+ /** Command registry carrying this grid's `list.search.*` commands. */
20577
+ get registry() {
20578
+ return this.#searchRegistry;
20579
+ }
20580
+ /** Effective search modes: this component's capability (all three) ∩ the `search-modes` attribute. */
20581
+ #effectiveSearchModes() {
20582
+ const raw = this.searchModes?.trim();
20583
+ if (raw === void 0) return [...LIST_SEARCH_MODES];
20584
+ const requested = new Set(raw.split(/\s+/));
20585
+ return LIST_SEARCH_MODES.filter((m) => requested.has(m));
20586
+ }
20587
+ #syncSearchCommands() {
20588
+ if (!this.#searchRegistry) return;
20589
+ this.search.setModes(this.#effectiveSearchModes());
20590
+ this.#unregisterSearchCommands?.();
20591
+ this.#unregisterSearchCommands = registerListSearchCommands(this.#searchRegistry, this.search);
20592
+ }
20593
+ connectedCallback() {
20594
+ super.connectedCallback();
20595
+ if (!this.hasAttribute(`tabindex`)) this.tabIndex = 0;
20596
+ for (const [attr, value] of [
20597
+ [`caption-style`, this.captionStyle],
20598
+ [`caption-align`, this.captionAlign],
20599
+ [`caption-position`, this.captionPosition],
20600
+ [`caption-backdrop`, this.captionBackdrop]
20601
+ ]) if (!this.hasAttribute(attr)) this.setAttribute(attr, value);
20602
+ this.addEventListener(`keydown`, this._onKeyDown);
20603
+ this.#mutationObserver ??= new MutationObserver((records) => this.#onChildrenChanged(records));
20604
+ this.#mutationObserver.observe(this, {
20605
+ childList: true,
20606
+ attributeFilter: [`hidden`]
20607
+ });
20608
+ this.#searchRegistry ??= createCommandRegistry();
20609
+ this.#syncSearchCommands();
20610
+ }
20611
+ disconnectedCallback() {
20612
+ super.disconnectedCallback();
20613
+ this.removeEventListener(`keydown`, this._onKeyDown);
20614
+ this.#resizeObserver?.disconnect();
20615
+ this.#resizeObserver = void 0;
20616
+ this.#mutationObserver?.disconnect();
20617
+ this.#cancelScrollRaf();
20618
+ this.#unregisterSearchCommands?.();
20619
+ this.#unregisterSearchCommands = void 0;
20620
+ this.#searchRegistry = void 0;
20621
+ if (this.#reflowTimer) {
20622
+ clearTimeout(this.#reflowTimer);
20623
+ this.#reflowTimer = 0;
20624
+ }
20625
+ }
20626
+ #onChildrenChanged(records) {
20627
+ const previousLength = this.#items.length;
20628
+ this.#syncItems();
20629
+ this._syncItemStates();
20630
+ this.#noteChildMutation(previousLength, records);
20631
+ this.requestUpdate();
20632
+ this.updateComplete.then(() => this.#emitViewportChange());
20633
+ }
20634
+ /**
20635
+ * When the child list changes mid-list — not a first populate or a wholesale
20636
+ * replace — mark any newcomers for an enter animation and open a short
20637
+ * `[reflowing]` window so the cells that shift slide to their new position
20638
+ * instead of snapping. Removals get the same window (the survivors slide to
20639
+ * close the gap). The window is bounded in time so the transform transition
20640
+ * never bleeds into scroll virtualisation.
20641
+ *
20642
+ * Reads the `MutationRecord`s directly rather than diffing `#items`, because a
20643
+ * `requestUpdate()` between the DOM change and this callback (e.g. a caller
20644
+ * that clears the selection before removing) lets `updated()` re-sync `#items`
20645
+ * first, which would hide the change from a naive diff.
20646
+ */
20647
+ #noteChildMutation(previousLength, records) {
20648
+ if (!this.hasUpdated || !records || records.length === 0) return;
20649
+ const currentSet = new Set(this.#items);
20650
+ const added = [];
20651
+ let removedCount = 0;
20652
+ for (const record of records) {
20653
+ for (const node of record.addedNodes) if (node.nodeType === Node.ELEMENT_NODE && currentSet.has(node)) added.push(node);
20654
+ for (const node of record.removedNodes) if (node.nodeType === Node.ELEMENT_NODE) removedCount++;
20655
+ }
20656
+ if (added.length === 0 && removedCount === 0) return;
20657
+ if (previousLength === 0 && removedCount === 0) return;
20658
+ if (added.length > 0 && added.length === this.#items.length) return;
20659
+ for (const el of added) this.#entering.add(el);
20660
+ this.toggleAttribute(`reflowing`, true);
20661
+ if (this.#reflowTimer) clearTimeout(this.#reflowTimer);
20662
+ this.#reflowTimer = window.setTimeout(() => {
20663
+ this.#reflowTimer = 0;
20664
+ this.#entering.clear();
20665
+ this.toggleAttribute(`reflowing`, false);
20666
+ this.requestUpdate();
20667
+ }, REFLOW_WINDOW_MS);
20668
+ }
20669
+ #emitViewportChange() {
20670
+ this._dispatch(`grid-list-viewport-change`, { visibleKeys: this.visibleKeys() });
20671
+ }
20672
+ firstUpdated() {
20673
+ const el = this.#viewportRef.value;
20674
+ if (!el) return;
20675
+ this._viewportWidth = el.clientWidth;
20676
+ this._viewportHeight = el.clientHeight;
20677
+ this.#resizeObserver = new ResizeObserver((entries) => {
20678
+ for (const entry of entries) {
20679
+ this._viewportWidth = entry.contentRect.width;
20680
+ this._viewportHeight = entry.contentRect.height;
20681
+ }
20682
+ });
20683
+ this.#resizeObserver.observe(el);
20684
+ this.#syncItems();
20685
+ this.requestUpdate();
20686
+ }
20687
+ willUpdate(changed) {
20688
+ if (changed.has(`searchModes`)) this.#syncSearchCommands();
20689
+ if (changed.has(`filterPredicate`)) this.#syncItems();
20690
+ if (changed.has(`itemSize`) || changed.has(`gap`)) {
20691
+ this.itemSize = Math.max(MIN_ITEM_SIZE, Math.round(this.itemSize));
20692
+ this.gap = Math.max(0, Math.round(this.gap));
20693
+ }
20694
+ if (changed.has(`captionHeight`)) this.captionHeight = Math.max(0, Math.round(this.captionHeight));
20695
+ if (changed.has(`itemWrap`)) this.#itemWrap = this.itemWrap;
20696
+ if ((changed.has(`itemSize`) || changed.has(`gap`) || changed.has(`captionStyle`) || changed.has(`captionHeight`) || changed.has(`orientation`)) && this.hasUpdated && this.#items.length > 0) {
20697
+ const prevOrientation = changed.get(`orientation`) ?? this.orientation;
20698
+ const horizontal = prevOrientation === `row`;
20699
+ const prevOffset = horizontal ? this._scrollLeft : this._scrollTop;
20700
+ const prevExtent = horizontal ? this._viewportWidth : this._viewportHeight;
20701
+ if (prevExtent > 0 && prevOffset > 1) {
20702
+ const prevItemSize = changed.get(`itemSize`) ?? this.itemSize;
20703
+ const prevGap = changed.get(`gap`) ?? this.gap;
20704
+ const prevCaptionStyle = changed.get(`captionStyle`) ?? this.captionStyle;
20705
+ const prevCaptionHeight = changed.get(`captionHeight`) ?? this.captionHeight;
20706
+ const prevRowHeight = prevItemSize + (prevCaptionStyle === `separate` || prevCaptionStyle === `under` ? prevCaptionHeight : 0);
20707
+ const prev = computeGridWindow(this.#items.length, prevItemSize, prevRowHeight, prevGap, this._viewportWidth, this._viewportHeight, prevOffset, prevOrientation);
20708
+ const mainStride = horizontal ? prev.colStride : prev.rowStride;
20709
+ const centerLine = (prevOffset + prevExtent / 2) / mainStride;
20710
+ const lineFloor = Math.max(0, Math.floor(centerLine));
20711
+ const index = horizontal ? Math.min(this.#items.length - 1, lineFloor) : Math.min(this.#items.length - 1, lineFloor * prev.cols + Math.max(0, Math.floor((prev.cols - 1) / 2)));
20712
+ this.#zoomAnchor = {
20713
+ index,
20714
+ lineFrac: centerLine - lineFloor
20715
+ };
20716
+ }
20717
+ }
20718
+ }
20719
+ /** Full cell height: the square, plus a caption band in `separate` / `under`. */
20720
+ #rowHeight() {
20721
+ const captionBand = this.captionStyle === `separate` || this.captionStyle === `under`;
20722
+ return this.itemSize + (captionBand ? this.captionHeight : 0);
20723
+ }
20724
+ #captionFor(el) {
20725
+ return el.getAttribute(`data-label`) ?? el.querySelector(`[data-grid-list-caption]`)?.textContent?.trim() ?? ``;
20726
+ }
20727
+ updated(changed) {
20728
+ super.updated(changed);
20729
+ this.#syncItems();
20730
+ if (changed.has(`selectionMode`) || changed.has(`interactionMode`)) this.selection.clearAnchor();
20731
+ if (changed.has(`orientation`)) {
20732
+ const vp = this.#viewportRef.value;
20733
+ if (vp) {
20734
+ if (this.orientation === `row`) {
20735
+ vp.scrollTop = 0;
20736
+ this._scrollTop = 0;
20737
+ } else {
20738
+ vp.scrollLeft = 0;
20739
+ this._scrollLeft = 0;
20740
+ }
20741
+ }
20742
+ }
20743
+ this.#applyZoomAnchor();
20744
+ }
20745
+ /** Re-anchor the scroll so the item captured in `willUpdate` stays centred. */
20746
+ #applyZoomAnchor() {
20747
+ if (!this.#zoomAnchor) return;
20748
+ const { index, lineFrac } = this.#zoomAnchor;
20749
+ this.#zoomAnchor = void 0;
20750
+ const vp = this.#viewportRef.value;
20751
+ if (!vp) return;
20752
+ const win = this.#currentWindow();
20753
+ if (win.horizontal) {
20754
+ if (this._viewportWidth <= 0) return;
20755
+ const targetCenter = (index + lineFrac) * win.colStride;
20756
+ const maxScroll = Math.max(0, win.totalWidth - this._viewportWidth);
20757
+ const next = Math.min(maxScroll, Math.max(0, targetCenter - this._viewportWidth / 2));
20758
+ if (Math.abs(vp.scrollLeft - next) > .5) {
20759
+ vp.scrollLeft = next;
20760
+ this._scrollLeft = next;
20761
+ }
20762
+ return;
20763
+ }
20764
+ if (this._viewportHeight <= 0) return;
20765
+ const targetCenter = ((win.cols > 0 ? Math.floor(index / win.cols) : 0) + lineFrac) * win.rowStride;
20766
+ const maxScroll = Math.max(0, win.totalHeight - this._viewportHeight);
20767
+ const next = Math.min(maxScroll, Math.max(0, targetCenter - this._viewportHeight / 2));
20768
+ if (Math.abs(vp.scrollTop - next) > .5) {
20769
+ vp.scrollTop = next;
20770
+ this._scrollTop = next;
20771
+ }
20772
+ }
20773
+ #syncItems() {
20774
+ this.#items = Array.from(this.querySelectorAll(ITEM_SELECTOR$1)).filter((el) => !el.hidden && (this.filterPredicate === void 0 || this.filterPredicate(el)));
20775
+ for (const el of this.#items) {
20776
+ const name = this.#slotNameFor(el);
20777
+ if (el.getAttribute(`slot`) !== name) el.setAttribute(`slot`, name);
20778
+ }
20779
+ }
20780
+ #slotNameFor(el) {
20781
+ let name = this.#slotNames.get(el);
20782
+ if (name === void 0) {
20783
+ this.#slotSeq += 1;
20784
+ name = `gl-item-${this.#slotSeq}`;
20785
+ this.#slotNames.set(el, name);
20786
+ }
20787
+ return name;
20788
+ }
20789
+ _getVisibleItems() {
20790
+ return this.#items.filter((el) => {
20791
+ if (el.hidden) return false;
20792
+ return true;
20793
+ });
20794
+ }
20795
+ get count() {
20796
+ return this.#items.length;
20797
+ }
20798
+ /**
20799
+ * Number of cells currently rendered in the shadow DOM. With virtualization
20800
+ * this is roughly the visible rows plus overscan, regardless of how large
20801
+ * `count` is — useful for demos/diagnostics.
20802
+ */
20803
+ get renderedCount() {
20804
+ return this.renderRoot.querySelectorAll(`.cell`).length;
20805
+ }
20806
+ get selectedItems() {
20807
+ return this.selection.selected;
20808
+ }
20809
+ /** The selection as stable `data-key` / `data-value` strings. */
20810
+ get selectedKeys() {
20811
+ return this.selection.selectedKeys;
20812
+ }
20813
+ select(item) {
20814
+ this.selection.replace(item);
20815
+ }
20816
+ selectMany(items) {
20817
+ this.selection.selectMany(items);
20818
+ }
20819
+ deselect(item) {
20820
+ this.selection.deselect(item);
20821
+ }
20822
+ clearSelection() {
20823
+ this.selection.clear();
20824
+ }
20825
+ selectAll() {
20826
+ if (this.selection.canSelectAll()) this.selection.selectAll();
20827
+ }
20828
+ /**
20829
+ * Replace the selection with the items carrying these keys. Unknown keys are
20830
+ * ignored; survives the host rebuilding the item set. Requires each item to
20831
+ * have a `data-key` (or `data-value`).
20832
+ */
20833
+ selectKeys(keys) {
20834
+ this.selection.selectKeys(keys, (key) => this.getItem(key));
20835
+ }
20836
+ /** Add items by key to the selection (multi-select modes only). */
20837
+ addSelectedKeys(keys) {
20838
+ this.selection.addKeys(keys, (key) => this.getItem(key));
20839
+ }
20840
+ /** Remove items by key from the selection. */
20841
+ removeSelectedKeys(keys) {
20842
+ this.selection.removeKeys(keys, (key) => this.getItem(key));
20843
+ }
20844
+ /** Move the cursor to the item with this key and scroll it into view. */
20845
+ focusKey(key) {
20846
+ const el = this.getItem(key);
20847
+ if (!el) return;
20848
+ this.tickled.focus(el);
20849
+ this.#scrollItemIntoView(el);
20850
+ }
20851
+ /** The key of the item under the cursor, or `undefined`. */
20852
+ get tickledKey() {
20853
+ const id = this.tickled.tickledId;
20854
+ return id ? keyForElement(id) : void 0;
20855
+ }
20856
+ getItem(key) {
20857
+ return this.#items.find((el) => keyForElement(el) === key);
20858
+ }
20859
+ getItemByIndex(index) {
20860
+ return this.#items[index];
20861
+ }
20862
+ getKey(item) {
20863
+ return keyForElement(item);
20864
+ }
20865
+ visibleKeys() {
20866
+ const win = this.#currentWindow();
20867
+ return this.#items.slice(win.startIndex, win.endIndex).map((el) => keyForElement(el)).filter((k) => k !== void 0);
20868
+ }
20869
+ /** Adjust the scroll offset so `el` is fully within the viewport. */
20870
+ #scrollItemIntoView(el) {
20871
+ const index = this.#items.indexOf(el);
20872
+ const vp = this.#viewportRef.value;
20873
+ if (index < 0 || !vp) return;
20874
+ const win = this.#currentWindow();
20875
+ if (win.horizontal) {
20876
+ const left = index * win.colStride;
20877
+ const right = left + this.itemSize;
20878
+ const viewLeft = vp.scrollLeft;
20879
+ let next = viewLeft;
20880
+ if (left < viewLeft) next = left;
20881
+ else if (right > viewLeft + this._viewportWidth) next = right - this._viewportWidth;
20882
+ if (next !== viewLeft) {
20883
+ vp.scrollLeft = next;
20884
+ this._scrollLeft = next;
20885
+ }
20886
+ return;
20887
+ }
20888
+ const top = (win.cols > 0 ? Math.floor(index / win.cols) : 0) * win.rowStride;
20889
+ const bottom = top + this.#rowHeight();
20890
+ const viewTop = vp.scrollTop;
20891
+ let next = viewTop;
20892
+ if (top < viewTop) next = top;
20893
+ else if (bottom > viewTop + this._viewportHeight) next = bottom - this._viewportHeight;
20894
+ if (next !== viewTop) {
20895
+ vp.scrollTop = next;
20896
+ this._scrollTop = next;
20897
+ }
20898
+ }
20899
+ _syncItemStates() {
20900
+ for (const el of this.#items) {
20901
+ el.toggleAttribute(`data-tickled`, this.tickled.isTickled(el));
20902
+ el.toggleAttribute(`data-selected`, this.selection.selected.has(el));
20903
+ }
20904
+ this.setAttribute(`data-input-mode`, this.tickled.inputMode);
20905
+ }
20906
+ _dispatch(name, detail) {
20907
+ this.dispatchEvent(new CustomEvent(name, {
20908
+ detail,
20909
+ bubbles: true,
20910
+ composed: true
20911
+ }));
20912
+ }
20913
+ render() {
20914
+ const win = this.#currentWindow();
20915
+ const slice = this.#items.slice(win.startIndex, win.endIndex);
20916
+ const spacerStyle = win.horizontal ? `width:${win.totalWidth}px;height:100%` : `height:${win.totalHeight}px`;
20917
+ const scrollFade = win.horizontal ? `scroll-fade-x` : `scroll-fade-y`;
20918
+ return b`
20919
+ <div
20920
+ class="viewport ${scrollFade}"
20921
+ part="viewport"
20922
+ ${ref(this.#viewportRef)}
20923
+ data-input-mode=${this.tickled.inputMode}
20924
+ @scroll=${this.#onScroll}
20925
+ >
20926
+ <div class="spacer" style=${spacerStyle}>
20927
+ ${repeat(slice, (el) => this.#slotNameFor(el), (el, i) => this.#renderCell(el, win.startIndex + i, win))}
20928
+ </div>
20929
+ </div>
20930
+ ${this.search.renderField()}
20931
+ `;
20932
+ }
20933
+ #renderCell(el, index, win) {
20934
+ const selected = this.selection.selected.has(el);
20935
+ const tickled = this.tickled.isTickled(el);
20936
+ const entering = this.#entering.has(el);
20937
+ const style = this.captionStyle;
20938
+ const separate = style === `separate`;
20939
+ const caption = style === `hidden` ? `` : this.#captionFor(el);
20940
+ const showCaption = caption !== ``;
20941
+ const cellHeight = win.rowStride - this.gap;
20942
+ const x = win.horizontal ? index * win.colStride : columnOf(index, win.cols) * win.colStride;
20943
+ const y = win.horizontal ? Math.max(0, (this._viewportHeight - cellHeight) / 2) : rowOf(index, win.cols) * win.rowStride;
20944
+ const thumbStyle = separate ? `height:${this.itemSize}px` : ``;
20945
+ const captionCss = separate ? `height:${Math.max(0, this.captionHeight - 4)}px` : style === `under` ? `height:${this.captionHeight}px` : ``;
20946
+ return b`
20947
+ <div
20948
+ class="cell${selected ? ` selected` : ``}${tickled ? ` tickled` : ``}${entering ? ` entering` : ``}"
20949
+ part="item"
20950
+ style="width:${this.itemSize}px;height:${cellHeight}px;transform:translate(${x}px,${y}px)"
20951
+ data-index=${index}
20952
+ @click=${(e) => this.#onCellClick(e, el)}
20953
+ @dblclick=${() => this.#onCellActivate(el)}
20954
+ @pointerenter=${() => this.tickled.handlePointerEnter(el)}
20955
+ >
20956
+ <div class="thumb" part="thumb" style=${thumbStyle}>
20957
+ <slot name=${this.#slotNameFor(el)}></slot>
20958
+ </div>
20959
+ ${showCaption ? b`<div class="caption" part="caption" style=${captionCss}>${caption}</div>` : ``}
20960
+ </div>
20961
+ `;
20962
+ }
20963
+ #onScroll;
20964
+ #cancelScrollRaf() {
20965
+ if (this.#scrollRaf !== 0) {
20966
+ cancelAnimationFrame(this.#scrollRaf);
20967
+ this.#scrollRaf = 0;
20968
+ }
20969
+ }
20970
+ #onCellClick(e, item) {
20971
+ this.selection.handleClick(item, e);
20972
+ this._dispatch(`list-item-click`, { item });
20973
+ }
20974
+ #onCellActivate(item) {
20975
+ if (this.interactionMode !== `manual`) this.selection.replace(item);
20976
+ this._dispatch(`list-activate`, { item });
20977
+ }
20978
+ #currentWindow() {
20979
+ return computeGridWindow(this.#items.length, this.itemSize, this.#rowHeight(), this.gap, this._viewportWidth, this._viewportHeight, this.orientation === `row` ? this._scrollLeft : this._scrollTop, this.orientation);
20980
+ }
20981
+ };
20982
+ __decorate([property({
20983
+ type: String,
20984
+ attribute: `selection-mode`
20985
+ })], GridListElement.prototype, "selectionMode", void 0);
20986
+ __decorate([property({
20987
+ type: String,
20988
+ attribute: `interaction-mode`
20989
+ })], GridListElement.prototype, "interactionMode", void 0);
20990
+ __decorate([property({
20991
+ type: Number,
20992
+ attribute: `item-size`
20993
+ })], GridListElement.prototype, "itemSize", void 0);
20994
+ __decorate([property({ type: Number })], GridListElement.prototype, "gap", void 0);
20995
+ __decorate([property({
20996
+ type: String,
20997
+ reflect: true
20998
+ })], GridListElement.prototype, "orientation", void 0);
20999
+ __decorate([property({
21000
+ type: String,
21001
+ attribute: `caption-style`,
21002
+ reflect: true
21003
+ })], GridListElement.prototype, "captionStyle", void 0);
21004
+ __decorate([property({
21005
+ type: String,
21006
+ attribute: `caption-align`,
21007
+ reflect: true
21008
+ })], GridListElement.prototype, "captionAlign", void 0);
21009
+ __decorate([property({
21010
+ type: String,
21011
+ attribute: `caption-position`,
21012
+ reflect: true
21013
+ })], GridListElement.prototype, "captionPosition", void 0);
21014
+ __decorate([property({
21015
+ type: String,
21016
+ attribute: `caption-backdrop`,
21017
+ reflect: true
21018
+ })], GridListElement.prototype, "captionBackdrop", void 0);
21019
+ __decorate([property({
21020
+ type: Number,
21021
+ attribute: `caption-height`
21022
+ })], GridListElement.prototype, "captionHeight", void 0);
21023
+ __decorate([property({
21024
+ type: String,
21025
+ attribute: `item-wrap`
21026
+ })], GridListElement.prototype, "itemWrap", void 0);
21027
+ __decorate([property({ attribute: false })], GridListElement.prototype, "filterPredicate", void 0);
21028
+ __decorate([property({
21029
+ type: String,
21030
+ attribute: `search-modes`,
21031
+ reflect: true
21032
+ })], GridListElement.prototype, "searchModes", void 0);
21033
+ __decorate([state()], GridListElement.prototype, "_scrollTop", void 0);
21034
+ __decorate([state()], GridListElement.prototype, "_scrollLeft", void 0);
21035
+ __decorate([state()], GridListElement.prototype, "_viewportHeight", void 0);
21036
+ __decorate([state()], GridListElement.prototype, "_viewportWidth", void 0);
21037
+ GridListElement = __decorate([safeCustomElement(`ixfx-grid-list`)], GridListElement);
21038
+ //#endregion
18704
21039
  //#region src/panel/panel.ts
18705
21040
  let PanelElement = class PanelElement extends i {
18706
21041
  constructor(..._args) {
@@ -19053,6 +21388,8 @@ __decorate([property({
19053
21388
  GroupedItemListerElement = __decorate([safeCustomElement(`ixfx-grouped-item-lister`)], GroupedItemListerElement);
19054
21389
  //#endregion
19055
21390
  //#region src/tree/tree-base.ts
21391
+ /** Above this many rows entering / leaving at once, skip the animation entirely. */
21392
+ const MAX_ANIMATED_ROWS = 50;
19056
21393
  let TreeBaseElement = class TreeBaseElement extends i {
19057
21394
  constructor(..._args) {
19058
21395
  super(..._args);
@@ -19075,8 +21412,19 @@ let TreeBaseElement = class TreeBaseElement extends i {
19075
21412
  }
19076
21413
  });
19077
21414
  this._focusedDepth = 0;
21415
+ this.search = new ListSearchController(this, { adapter: createTreeListSearchAdapter(this, {
21416
+ labelSelector: `.tree-label`,
21417
+ highlightKey: `incr-search-highlight`
21418
+ }) });
19078
21419
  this.#config = { indent: 20 };
21420
+ this.#renderedKeys = [];
21421
+ this.#lastNodeByKey = /* @__PURE__ */ new Map();
21422
+ this.#enteringKeys = /* @__PURE__ */ new Set();
21423
+ this.#leaving = /* @__PURE__ */ new Map();
21424
+ this.#leavingAnimating = /* @__PURE__ */ new Set();
21425
+ this.#treeAnimReady = false;
19079
21426
  this.#onKeyDown = (event) => {
21427
+ if (this.search.isOpen) return;
19080
21428
  const nodes = this.#getAllNodes();
19081
21429
  if (nodes.length === 0) return;
19082
21430
  if ((event.ctrlKey || event.metaKey) && event.key === `a`) {
@@ -19137,7 +21485,7 @@ let TreeBaseElement = class TreeBaseElement extends i {
19137
21485
  bubbles: true,
19138
21486
  composed: true
19139
21487
  }));
19140
- } else if (current.depth > 1) {
21488
+ } else if (current.depth > 0) {
19141
21489
  const parentDepth = current.depth - 1;
19142
21490
  for (let i = currentIdx - 1; i >= 0; i--) if (nodes[i].depth === parentDepth) {
19143
21491
  this.#setFocusedIndex(i);
@@ -19197,12 +21545,111 @@ let TreeBaseElement = class TreeBaseElement extends i {
19197
21545
  get selectedNodes() {
19198
21546
  return this._selectedNodes;
19199
21547
  }
21548
+ #unregisterSearchCommands;
21549
+ #effectiveSearchModes() {
21550
+ const raw = this.searchModes?.trim();
21551
+ if (raw === void 0) return [...LIST_SEARCH_MODES];
21552
+ const requested = new Set(raw.split(/\s+/));
21553
+ return LIST_SEARCH_MODES.filter((m) => requested.has(m));
21554
+ }
21555
+ #syncSearchCommands() {
21556
+ if (!this.#ownedRegistry) return;
21557
+ this.search.setModes(this.#effectiveSearchModes());
21558
+ this.#unregisterSearchCommands?.();
21559
+ this.#unregisterSearchCommands = registerListSearchCommands(this.#ownedRegistry, this.search);
21560
+ }
19200
21561
  #pendingNode;
19201
21562
  #pendingController;
19202
21563
  #modelUnsubscribe;
19203
21564
  #anchor;
19204
21565
  #ownedRegistry;
19205
21566
  #config;
21567
+ #renderedKeys;
21568
+ #lastNodeByKey;
21569
+ #enteringKeys;
21570
+ #leaving;
21571
+ #leavingAnimating;
21572
+ #treeAnimReady;
21573
+ willUpdate(changedProps) {
21574
+ super.willUpdate(changedProps);
21575
+ this.#diffVisibleNodes();
21576
+ }
21577
+ #diffVisibleNodes() {
21578
+ const nodes = this.root ? this.#getAllNodes() : [];
21579
+ const nowKeys = new Set(nodes.map((n) => n.node.item.key));
21580
+ this.#enteringKeys.clear();
21581
+ if (this.#treeAnimReady && this.#renderedKeys.length > 0) {
21582
+ const prevSet = new Set(this.#renderedKeys);
21583
+ for (const { node } of nodes) if (!prevSet.has(node.item.key)) this.#enteringKeys.add(node.item.key);
21584
+ if (this.#enteringKeys.size > MAX_ANIMATED_ROWS) this.#enteringKeys.clear();
21585
+ for (const key of this.#renderedKeys) if (!nowKeys.has(key) && !this.#leaving.has(key)) {
21586
+ const last = this.#lastNodeByKey.get(key);
21587
+ if (last) this.#leaving.set(key, last);
21588
+ }
21589
+ }
21590
+ for (const key of [...this.#leaving.keys()]) if (nowKeys.has(key)) {
21591
+ this.#leaving.delete(key);
21592
+ this.#leavingAnimating.delete(key);
21593
+ }
21594
+ if (this.#leaving.size > MAX_ANIMATED_ROWS) {
21595
+ this.#leaving.clear();
21596
+ this.#leavingAnimating.clear();
21597
+ }
21598
+ }
21599
+ /** True while `node` is a fading ghost row (removed / collapsed, still animating out). */
21600
+ _isLeaving(node) {
21601
+ return this.#leaving.has(node.item.key);
21602
+ }
21603
+ /**
21604
+ * True when `filterPredicate` is set and neither `node` nor any descendant
21605
+ * matches — the row should carry the `filtered` class (`display: none`).
21606
+ */
21607
+ _isFilteredOut(node, depth) {
21608
+ return this.filterPredicate !== void 0 && !this.#matchesFilter(node, depth);
21609
+ }
21610
+ /** Splice the fading ghost rows back into the visible list near where they were. */
21611
+ #weaveLeaving(nodes) {
21612
+ if (this.#leaving.size === 0) return nodes;
21613
+ const result = [...nodes];
21614
+ const indexOfKey = (key) => result.findIndex((e) => e.node.item.key === key);
21615
+ for (const [key, entry] of this.#leaving) {
21616
+ const prevIdx = this.#renderedKeys.indexOf(key);
21617
+ let insertAt = result.length;
21618
+ for (let j = prevIdx + 1; j < this.#renderedKeys.length; j++) {
21619
+ const at = indexOfKey(this.#renderedKeys[j]);
21620
+ if (at !== -1) {
21621
+ insertAt = at;
21622
+ break;
21623
+ }
21624
+ }
21625
+ result.splice(insertAt, 0, entry);
21626
+ }
21627
+ return result;
21628
+ }
21629
+ #runItemAnimations() {
21630
+ if (this.#enteringKeys.size > 0) {
21631
+ for (const el of this.renderRoot.querySelectorAll(`.item`)) {
21632
+ const key = el.dataset.key;
21633
+ if (key !== void 0 && this.#enteringKeys.has(key)) animateListItemIn(el);
21634
+ }
21635
+ this.#enteringKeys.clear();
21636
+ }
21637
+ if (this.#leaving.size > 0) for (const el of this.renderRoot.querySelectorAll(`.item.leaving`)) {
21638
+ const key = el.dataset.key;
21639
+ if (key === void 0 || this.#leavingAnimating.has(key)) continue;
21640
+ this.#leavingAnimating.add(key);
21641
+ animateListItemOut(el).then(() => {
21642
+ this.#leaving.delete(key);
21643
+ this.#leavingAnimating.delete(key);
21644
+ this.requestUpdate();
21645
+ });
21646
+ }
21647
+ const visible = this.root ? this.#getAllNodes() : [];
21648
+ this.#renderedKeys = visible.map((n) => n.node.item.key);
21649
+ this.#lastNodeByKey.clear();
21650
+ for (const entry of visible) this.#lastNodeByKey.set(entry.node.item.key, entry);
21651
+ this.#treeAnimReady = true;
21652
+ }
19206
21653
  updated(changedProps) {
19207
21654
  if (changedProps.has(`root`) && this.root) {
19208
21655
  this.#abortPending();
@@ -19237,6 +21684,7 @@ let TreeBaseElement = class TreeBaseElement extends i {
19237
21684
  if (changedProps.has(`selectedNode`) && this.selectedNode) {
19238
21685
  if (changedProps.get(`selectedNode`) !== this.selectedNode) this.tickled.setTickled(this.selectedNode.item.key);
19239
21686
  }
21687
+ this.#runItemAnimations();
19240
21688
  }
19241
21689
  connectedCallback() {
19242
21690
  super.connectedCallback();
@@ -19255,11 +21703,14 @@ let TreeBaseElement = class TreeBaseElement extends i {
19255
21703
  execute: () => this.deselectAll()
19256
21704
  });
19257
21705
  this.registry = registry;
21706
+ this.#syncSearchCommands();
19258
21707
  }
19259
21708
  disconnectedCallback() {
19260
21709
  super.disconnectedCallback();
19261
21710
  this.#modelUnsubscribe?.();
19262
21711
  this.#modelUnsubscribe = void 0;
21712
+ this.#unregisterSearchCommands?.();
21713
+ this.#unregisterSearchCommands = void 0;
19263
21714
  if (this.#ownedRegistry) {
19264
21715
  this.#ownedRegistry.unregister(`select-all`);
19265
21716
  this.#ownedRegistry.unregister(`deselect-all`);
@@ -19357,6 +21808,28 @@ let TreeBaseElement = class TreeBaseElement extends i {
19357
21808
  if (!keyPath) return false;
19358
21809
  return this.navigateTo(keyPath);
19359
21810
  }
21811
+ /** Expand ancestors + move the cursor to `key`, without touching the selection (search "navigate" mode). */
21812
+ revealKey(key) {
21813
+ if (!this.root?.children) return false;
21814
+ const path = this.#findNodePath(this.root.children, (n) => n.item.key === key);
21815
+ if (!path) return false;
21816
+ for (let i = 0; i < path.length - 1; i++) {
21817
+ this.#applyExclusivity(path[i]);
21818
+ this._expandedKeys.add(path[i]);
21819
+ }
21820
+ this.tickled.setTickled(key);
21821
+ this.requestUpdate();
21822
+ this.updateComplete.then(() => this.#scrollKeyIntoView(key));
21823
+ return true;
21824
+ }
21825
+ /** Replace the selection with `nodes` (subject to `selectionFilter`) — search "select" mode. */
21826
+ selectMany(nodes) {
21827
+ const prev = new Set(this._selectedNodes);
21828
+ this._selectedNodes = new Set([...nodes].filter((n) => this.#canSelect(n)));
21829
+ this.selectedNode = [...this._selectedNodes].at(-1);
21830
+ this.#dispatchSelect(prev);
21831
+ this.requestUpdate();
21832
+ }
19360
21833
  getNodes() {
19361
21834
  if (!this.root?.children) return [];
19362
21835
  const result = [];
@@ -19401,9 +21874,9 @@ let TreeBaseElement = class TreeBaseElement extends i {
19401
21874
  }
19402
21875
  }
19403
21876
  }
19404
- renderNodeContent(node, _depth) {
21877
+ renderNodeContent(node, depth) {
19405
21878
  const isBranch = node.item.isLeaf !== true;
19406
- const isExpanded = this._isExpanded(node);
21879
+ const isExpanded = this._isExpanded(node, depth);
19407
21880
  return b`
19408
21881
  <span class="tree-caret">
19409
21882
  ${isBranch ? b`<ixfx-icon class="caret-icon" name="chevron-down" .rotate=${isExpanded ? A : `-90deg`}></ixfx-icon>` : ``}
@@ -19684,9 +22157,9 @@ let TreeBaseElement = class TreeBaseElement extends i {
19684
22157
  _isFocused(node) {
19685
22158
  return this.tickled.isTickled(node.item.key);
19686
22159
  }
19687
- _isExpanded(node) {
22160
+ _isExpanded(node, depth = 0) {
19688
22161
  if (this._expandedKeys.has(node.item.key)) return true;
19689
- if (this.filterPredicate && node.children) return node.children.some((child) => this.#matchesFilter(child, 0));
22162
+ if (this.filterPredicate && node.children) return node.children.some((child) => this.#matchesFilter(child, depth + 1));
19690
22163
  return false;
19691
22164
  }
19692
22165
  #matchesFilter(node, depth) {
@@ -19760,14 +22233,14 @@ let TreeBaseElement = class TreeBaseElement extends i {
19760
22233
  node: n,
19761
22234
  depth: d
19762
22235
  });
19763
- if (this._expandedKeys.has(n.item.key) && n.children) for (const child of n.children) visit(child, d + 1);
22236
+ if (n.children && this._isExpanded(n, d)) for (const child of n.children) visit(child, d + 1);
19764
22237
  };
19765
22238
  if (node.children) for (const child of node.children) visit(child, depth + 1);
19766
22239
  return result;
19767
22240
  }
19768
22241
  #getAllNodes() {
19769
22242
  if (!this.root) return [];
19770
- return this.#getVisibleNodes(this.root, 0);
22243
+ return this.#getVisibleNodes(this.root, -1);
19771
22244
  }
19772
22245
  #getFocusedIndex() {
19773
22246
  const nodes = this.#getAllNodes();
@@ -19817,18 +22290,19 @@ let TreeBaseElement = class TreeBaseElement extends i {
19817
22290
  >
19818
22291
  ${this.root ? this.#renderNodes(nodes) : A}
19819
22292
  </div>
22293
+ ${this.search.renderField()}
19820
22294
  `;
19821
22295
  }
19822
22296
  #onDblClick;
19823
22297
  #renderNodes(nodes) {
19824
- if (nodes.length === 0 && this.#pendingNode) return b`
22298
+ if (nodes.length === 0 && this.#pendingNode && this.#leaving.size === 0) return b`
19825
22299
  <div class="tree-loading">
19826
22300
  ${this.renderLoadingIndicator()}
19827
22301
  </div>
19828
22302
  `;
19829
22303
  return b`
19830
22304
  <ul class="tree-list" role="tree">
19831
- ${nodes.map(({ node, depth }) => this._renderNode(node, depth))}
22305
+ ${repeat(this.#weaveLeaving(nodes), ({ node }) => node.item.key, ({ node, depth }) => this._renderNode(node, depth))}
19832
22306
  </ul>
19833
22307
  `;
19834
22308
  }
@@ -19836,15 +22310,17 @@ let TreeBaseElement = class TreeBaseElement extends i {
19836
22310
  const selected = this._isSelected(node);
19837
22311
  const tickled = this._isFocused(node);
19838
22312
  const isBranch = node.item.isLeaf !== true;
19839
- const isExpanded = this._isExpanded(node);
22313
+ const isExpanded = this._isExpanded(node, depth);
19840
22314
  const isPending = this.#pendingNode === node;
19841
- const isFiltered = this.filterPredicate !== void 0 && !this.#matchesFilter(node, depth);
22315
+ const isFiltered = this._isFilteredOut(node, depth);
22316
+ const isLeaving = this._isLeaving(node);
19842
22317
  return b`
19843
22318
  <li
19844
- class="item ${selected ? `selected` : ``} ${tickled ? `tickled` : ``} ${isBranch ? `branch` : `leaf`} ${isFiltered ? `filtered` : ``}"
22319
+ class="item ${selected ? `selected` : ``} ${tickled ? `tickled` : ``} ${isBranch ? `branch` : `leaf`} ${isFiltered ? `filtered` : ``} ${isLeaving ? `leaving` : ``}"
19845
22320
  role="treeitem"
19846
22321
  aria-selected=${selected}
19847
22322
  aria-expanded=${isBranch ? isExpanded : A}
22323
+ aria-hidden=${isLeaving ? `true` : A}
19848
22324
  aria-level=${depth + 1}
19849
22325
  data-key=${node.item.key}
19850
22326
  style=${`--depth: ${depth}`}
@@ -19871,6 +22347,7 @@ let TreeBaseElement = class TreeBaseElement extends i {
19871
22347
  themeFallbacks,
19872
22348
  tickledStyles,
19873
22349
  tickledItemStyles,
22350
+ listSearchStyles,
19874
22351
  i$3`
19875
22352
  :host {
19876
22353
  --tree-bg: var(--surface-3);
@@ -19881,16 +22358,23 @@ let TreeBaseElement = class TreeBaseElement extends i {
19881
22358
  --tree-caret-size: 10px;
19882
22359
  --tree-radius: var(--radius-s);
19883
22360
 
22361
+ position: relative;
19884
22362
  display: block;
19885
- overflow: auto;
22363
+ overflow: hidden;
19886
22364
  box-sizing: border-box;
19887
22365
  background: var(--tree-bg);
19888
22366
  color: var(--tree-text);
19889
22367
  }
19890
22368
 
22369
+ /* min-height (not height) so the tree still grows to its content when the
22370
+ host has no explicit height; when it does, this fills it and scrolls.
22371
+ The scroll lives here, not on :host, so the search popover (a sibling of
22372
+ #container, positioned against :host) stays put while the list scrolls. */
19891
22373
  #container {
19892
22374
  outline: none;
19893
22375
  min-height: 100%;
22376
+ max-height: 100%;
22377
+ overflow: auto;
19894
22378
  }
19895
22379
 
19896
22380
  .tree-list {
@@ -19965,6 +22449,12 @@ let TreeBaseElement = class TreeBaseElement extends i {
19965
22449
  display: none;
19966
22450
  }
19967
22451
 
22452
+ /* Fading-out ghost row (see #runItemAnimations) — inert while it collapses. */
22453
+ .item.leaving {
22454
+ pointer-events: none;
22455
+ overflow: hidden;
22456
+ }
22457
+
19968
22458
  ::highlight(incr-search-highlight) {
19969
22459
  background: var(--search-highlight-bg, var(--accent));
19970
22460
  color: var(--search-highlight-color, var(--accent-text, #fff));
@@ -19998,6 +22488,11 @@ __decorate([property({
19998
22488
  attribute: `disable-keyboard-navigation`
19999
22489
  })], TreeBaseElement.prototype, "disableKeyboardNavigation", void 0);
20000
22490
  __decorate([property({ attribute: false })], TreeBaseElement.prototype, "registry", void 0);
22491
+ __decorate([property({
22492
+ type: String,
22493
+ attribute: `search-modes`,
22494
+ reflect: true
22495
+ })], TreeBaseElement.prototype, "searchModes", void 0);
20001
22496
  __decorate([state()], TreeBaseElement.prototype, "_expandedKeys", void 0);
20002
22497
  __decorate([state()], TreeBaseElement.prototype, "_focusedDepth", void 0);
20003
22498
  TreeBaseElement = __decorate([safeCustomElement(`ixfx-tree-base`)], TreeBaseElement);
@@ -20024,11 +22519,11 @@ let TreeListElement = class TreeListElement extends TreeBaseElement {
20024
22519
  if (node.item.icon) this.#depthsWithIcons.add(depth);
20025
22520
  if (node.children) for (const child of node.children) visit(child, depth + 1);
20026
22521
  };
20027
- if (this.root) visit(this.root, 0);
22522
+ if (this.root) visit(this.root, -1);
20028
22523
  }
20029
22524
  renderNodeContent(node, depth) {
20030
22525
  const isBranch = node.item.isLeaf !== true;
20031
- const isExpanded = this._isExpanded(node);
22526
+ const isExpanded = this._isExpanded(node, depth);
20032
22527
  const icon = node.item.icon;
20033
22528
  const tooltip = node.item.tooltip;
20034
22529
  const caretAndLabel = b`
@@ -20074,16 +22569,19 @@ let TreeListElement = class TreeListElement extends TreeBaseElement {
20074
22569
  const selected = this._isSelected(node);
20075
22570
  const tickled = this._isFocused(node);
20076
22571
  const isBranch = node.item.isLeaf !== true;
20077
- const isExpanded = this._isExpanded(node);
22572
+ const isExpanded = this._isExpanded(node, depth);
20078
22573
  const isPending = this._pendingNode === node;
20079
22574
  const hasIconAtDepth = this.#depthsWithIcons.has(depth);
22575
+ const isLeaving = this._isLeaving(node);
22576
+ const isFiltered = this._isFilteredOut(node, depth);
20080
22577
  return b`
20081
22578
  <li
20082
- class="item ${selected ? `selected` : ``} ${tickled ? `tickled` : ``} ${isBranch ? `branch` : `leaf`} ${hasIconAtDepth ? `has-icon-at-depth` : ``}"
22579
+ class="item ${selected ? `selected` : ``} ${tickled ? `tickled` : ``} ${isBranch ? `branch` : `leaf`} ${hasIconAtDepth ? `has-icon-at-depth` : ``} ${isLeaving ? `leaving` : ``} ${isFiltered ? `filtered` : ``}"
20083
22580
  data-key=${node.item.key}
20084
22581
  role="treeitem"
20085
22582
  aria-selected=${selected}
20086
22583
  aria-expanded=${isBranch ? isExpanded : A}
22584
+ aria-hidden=${isLeaving ? `true` : A}
20087
22585
  aria-level=${depth + 1}
20088
22586
  style=${`--depth: ${depth}`}
20089
22587
  @click=${(e) => this._handleItemClick(node, depth, e)}
@@ -20347,15 +22845,14 @@ function createTreeListAdapter(toTreeItem, options) {
20347
22845
  //#region src/vertical-list/vertical-list.ts
20348
22846
  const ITEM_SELECTOR = `li, [data-list-item]`;
20349
22847
  let VerticalListElement = class VerticalListElement extends i {
20350
- constructor(..._args2) {
20351
- super(..._args2);
22848
+ constructor(..._args) {
22849
+ super(..._args);
20352
22850
  this.selectionMode = `single`;
20353
22851
  this.interactionMode = `standard`;
20354
22852
  this.itemWrap = `stop`;
20355
- this._searchVisible = false;
20356
- this._searchQuery = ``;
20357
22853
  this.#customWrappers = /* @__PURE__ */ new Map();
20358
22854
  this.#wrappedElements = /* @__PURE__ */ new Set();
22855
+ this.#animatedItems = /* @__PURE__ */ new WeakSet();
20359
22856
  this.selection = new ListSelectionController({
20360
22857
  getOrderedItems: () => this._getVisibleItems(),
20361
22858
  isSelectable: (el) => this._isCheckable(el),
@@ -20363,11 +22860,13 @@ let VerticalListElement = class VerticalListElement extends i {
20363
22860
  selectionMode: this.selectionMode,
20364
22861
  interactionMode: this.interactionMode
20365
22862
  }),
22863
+ getKey: keyForElement,
20366
22864
  onChange: (selected, previous) => {
20367
22865
  this._syncItemStates();
20368
22866
  this._dispatch(`list-select`, {
20369
22867
  selected,
20370
- previous
22868
+ previous,
22869
+ selectedKeys: this.selection.selectedKeys
20371
22870
  });
20372
22871
  }
20373
22872
  });
@@ -20382,6 +22881,11 @@ let VerticalListElement = class VerticalListElement extends i {
20382
22881
  onStateChange: () => this._syncItemStates(),
20383
22882
  itemWrap: this.itemWrap
20384
22883
  });
22884
+ this.search = new ListSearchController(this, { adapter: createElementListSearchAdapter(this, {
22885
+ itemSelector: ITEM_SELECTOR,
22886
+ getLabel: (el) => el.getAttribute(`data-search-label`) ?? el.textContent?.trim() ?? ``,
22887
+ highlightKey: `vertical-list-search`
22888
+ }) });
20385
22889
  this._onPointerOver = (e) => {
20386
22890
  const item = this._findListItem(e.composedPath());
20387
22891
  if (item) this.tickled.handlePointerEnter(item);
@@ -20390,11 +22894,7 @@ let VerticalListElement = class VerticalListElement extends i {
20390
22894
  this.tickled.handlePointerLeave(e);
20391
22895
  };
20392
22896
  this._onKeyDown = (e) => {
20393
- if ((e.ctrlKey || e.metaKey) && e.key === `f`) {
20394
- e.preventDefault();
20395
- this._openSearch();
20396
- return;
20397
- }
22897
+ if (this.search.isOpen) return;
20398
22898
  if ((e.ctrlKey || e.metaKey) && e.key === `a`) {
20399
22899
  if (this.selection.canSelectAll()) {
20400
22900
  e.preventDefault();
@@ -20402,7 +22902,6 @@ let VerticalListElement = class VerticalListElement extends i {
20402
22902
  }
20403
22903
  return;
20404
22904
  }
20405
- if (this._searchVisible) return;
20406
22905
  switch (e.key) {
20407
22906
  case `ArrowDown`:
20408
22907
  e.preventDefault();
@@ -20450,36 +22949,31 @@ let VerticalListElement = class VerticalListElement extends i {
20450
22949
  const item = this._findListItem(e.composedPath());
20451
22950
  if (item) this._activateItem(item);
20452
22951
  };
20453
- this._onSearchInput = (e) => {
20454
- const input = e.target;
20455
- this._searchQuery = input.value;
20456
- this.#search?.setQuery(this._searchQuery);
20457
- };
20458
- this._onSearchKeydown = (e) => {
20459
- if (e.key === `Escape`) {
20460
- e.stopPropagation();
20461
- this._closeSearch();
20462
- } else if (e.key === `ArrowDown`) {
20463
- e.preventDefault();
20464
- this.tickled.handleKeyboardNavigation(`next`);
20465
- } else if (e.key === `ArrowUp`) {
20466
- e.preventDefault();
20467
- this.tickled.handleKeyboardNavigation(`previous`);
20468
- } else if (e.key === `Enter`) {
20469
- e.preventDefault();
20470
- e.stopPropagation();
20471
- const id = this.tickled.tickledId ?? this._getVisibleItems()[0];
20472
- if (id) {
20473
- this._dispatch(`list-item-click`, { item: id });
20474
- this._activateItem(id);
20475
- this._closeSearch();
20476
- }
20477
- }
20478
- };
20479
22952
  }
20480
- #search;
20481
22953
  #customWrappers;
20482
22954
  #wrappedElements;
22955
+ /** Watches for items added to the light DOM so they can be animated in. */
22956
+ #itemObserver;
22957
+ /** Items that have already played (or been skipped for) their enter animation. */
22958
+ #animatedItems;
22959
+ #searchRegistry;
22960
+ #unregisterSearchCommands;
22961
+ /** Command registry carrying this list's `list.search.*` commands. */
22962
+ get registry() {
22963
+ return this.#searchRegistry;
22964
+ }
22965
+ #effectiveSearchModes() {
22966
+ const raw = this.searchModes?.trim();
22967
+ if (raw === void 0) return [...LIST_SEARCH_MODES];
22968
+ const requested = new Set(raw.split(/\s+/));
22969
+ return LIST_SEARCH_MODES.filter((m) => requested.has(m));
22970
+ }
22971
+ #syncSearchCommands() {
22972
+ if (!this.#searchRegistry) return;
22973
+ this.search.setModes(this.#effectiveSearchModes());
22974
+ this.#unregisterSearchCommands?.();
22975
+ this.#unregisterSearchCommands = registerListSearchCommands(this.#searchRegistry, this.search);
22976
+ }
20483
22977
  connectedCallback() {
20484
22978
  super.connectedCallback();
20485
22979
  if (!this.hasAttribute(`tabindex`)) this.tabIndex = 0;
@@ -20488,6 +22982,11 @@ let VerticalListElement = class VerticalListElement extends i {
20488
22982
  this.addEventListener(`keydown`, this._onKeyDown);
20489
22983
  this.addEventListener(`click`, this._onClick);
20490
22984
  this.addEventListener(`dblclick`, this._onDblClick);
22985
+ for (const el of this.querySelectorAll(ITEM_SELECTOR)) this.#animatedItems.add(el);
22986
+ this.#itemObserver ??= new MutationObserver((records) => this.#onItemsMutated(records));
22987
+ this.#itemObserver.observe(this, { childList: true });
22988
+ this.#searchRegistry ??= createCommandRegistry();
22989
+ this.#syncSearchCommands();
20491
22990
  }
20492
22991
  disconnectedCallback() {
20493
22992
  super.disconnectedCallback();
@@ -20496,20 +22995,35 @@ let VerticalListElement = class VerticalListElement extends i {
20496
22995
  this.removeEventListener(`keydown`, this._onKeyDown);
20497
22996
  this.removeEventListener(`click`, this._onClick);
20498
22997
  this.removeEventListener(`dblclick`, this._onDblClick);
20499
- this.#search?.dispose();
20500
- this.#search = void 0;
22998
+ this.#itemObserver?.disconnect();
22999
+ this.#unregisterSearchCommands?.();
23000
+ this.#unregisterSearchCommands = void 0;
23001
+ this.#searchRegistry = void 0;
20501
23002
  }
20502
- firstUpdated() {
20503
- this.#search = createElementIncrSearch({
20504
- container: this,
20505
- itemSelector: ITEM_SELECTOR,
20506
- getLabel: (el) => el.getAttribute(`data-search-label`) ?? el.textContent?.trim() ?? ``,
20507
- getKey: (el) => el.getAttribute(`data-key`) ?? el.getAttribute(`data-search-label`) ?? el.textContent?.trim() ?? ``,
20508
- highlightKey: `vertical-list-search`
20509
- });
23003
+ /**
23004
+ * Animate in items appended to the light DOM (however they got there —
23005
+ * {@link addItem}, direct `appendChild`, a framework binding). A bulk populate
23006
+ * (roughly everything at once) is left alone, as are the wrapper `<li>`s the
23007
+ * checked-mode plumbing shuffles around.
23008
+ */
23009
+ #onItemsMutated(records) {
23010
+ if (!this.isConnected) return;
23011
+ const fresh = [];
23012
+ for (const record of records) for (const node of record.addedNodes) {
23013
+ if (!(node instanceof HTMLElement) || node.parentElement !== this) continue;
23014
+ if (!node.matches(ITEM_SELECTOR)) continue;
23015
+ if (this.#animatedItems.has(node) || this.#customWrappers.has(node) || this.#wrappedElements.has(node)) continue;
23016
+ this.#animatedItems.add(node);
23017
+ fresh.push(node);
23018
+ }
23019
+ if (fresh.length === 0) return;
23020
+ const total = this.querySelectorAll(ITEM_SELECTOR).length;
23021
+ if (fresh.length >= total) return;
23022
+ for (const el of fresh) animateListItemIn(el);
20510
23023
  }
20511
23024
  updated(changed) {
20512
23025
  super.updated(changed);
23026
+ if (changed.has(`searchModes`)) this.#syncSearchCommands();
20513
23027
  if (changed.has(`interactionMode`)) {
20514
23028
  this.selection.clearAnchor();
20515
23029
  const previousSelected = this.selection.selected;
@@ -20544,8 +23058,21 @@ let VerticalListElement = class VerticalListElement extends i {
20544
23058
  this.appendChild(el);
20545
23059
  if (this.interactionMode === `checked`) this._syncCheckboxes();
20546
23060
  }
23061
+ /**
23062
+ * Collapse + fade `item` out, then detach it. The rows below slide up to
23063
+ * close the gap. Resolves once the element has actually been removed.
23064
+ * Under `prefers-reduced-motion: reduce` it's removed immediately.
23065
+ */
20547
23066
  removeItem(item) {
20548
- item.remove();
23067
+ this.selection.deselect(item);
23068
+ return animateListItemOut(item).then(() => {
23069
+ item.remove();
23070
+ if (this.interactionMode === `checked`) this._syncCheckboxes();
23071
+ });
23072
+ }
23073
+ /** {@link removeItem} for several items at once. */
23074
+ removeItems(items) {
23075
+ return Promise.all([...items].map((item) => this.removeItem(item))).then(() => void 0);
20549
23076
  }
20550
23077
  clearItems() {
20551
23078
  this.#customWrappers.clear();
@@ -20564,11 +23091,13 @@ let VerticalListElement = class VerticalListElement extends i {
20564
23091
  if (this.interactionMode === `checked`) this._syncCheckboxes();
20565
23092
  }
20566
23093
  /**
20567
- * Dispatch a command to this list. No-op by default.
20568
- * Intended for use with `ixfx-grouped-item-lister`'s `dispatch()` method,
20569
- * which forwards commands to all sub-components.
23094
+ * Dispatch a command to this list used by `ixfx-grouped-item-lister` to
23095
+ * forward commands to all sub-components. Routes through the command registry
23096
+ * (so `list.search.*` and any other registered commands work).
20570
23097
  */
20571
- dispatch(_commandId, _args) {}
23098
+ dispatch(commandId, args) {
23099
+ this.#searchRegistry?.invoke(commandId, args);
23100
+ }
20572
23101
  select(item) {
20573
23102
  this.selection.replace(item);
20574
23103
  }
@@ -20584,6 +23113,43 @@ let VerticalListElement = class VerticalListElement extends i {
20584
23113
  selectAll() {
20585
23114
  if (this.selection.canSelectAll()) this.selection.selectAll();
20586
23115
  }
23116
+ /** The selection as stable `data-key` / `data-value` strings. */
23117
+ get selectedKeys() {
23118
+ return this.selection.selectedKeys;
23119
+ }
23120
+ /** The key of the item under the cursor, or `undefined`. */
23121
+ get tickledKey() {
23122
+ const id = this.tickled.tickledId;
23123
+ return id ? keyForElement(id) : void 0;
23124
+ }
23125
+ /** Replace the selection with the items carrying these keys (unknown keys ignored). */
23126
+ selectKeys(keys) {
23127
+ this.selection.selectKeys(keys, (key) => this.#itemForKey(key));
23128
+ }
23129
+ /** Add items by key to the selection (multi-select modes only). */
23130
+ addSelectedKeys(keys) {
23131
+ this.selection.addKeys(keys, (key) => this.#itemForKey(key));
23132
+ }
23133
+ /** Remove items by key from the selection. */
23134
+ removeSelectedKeys(keys) {
23135
+ this.selection.removeKeys(keys, (key) => this.#itemForKey(key));
23136
+ }
23137
+ /** Move the cursor to the item with this key and scroll it into view. */
23138
+ focusKey(key) {
23139
+ const el = this.#itemForKey(key);
23140
+ if (!el) return;
23141
+ this.tickled.focus(el);
23142
+ el.scrollIntoView({
23143
+ block: `nearest`,
23144
+ inline: `nearest`
23145
+ });
23146
+ }
23147
+ #itemForKey(key) {
23148
+ for (const el of this.querySelectorAll(ITEM_SELECTOR)) {
23149
+ if (this.#wrappedElements.has(el)) continue;
23150
+ if (keyForElement(el) === key) return el;
23151
+ }
23152
+ }
20587
23153
  *selectedFilter(kind) {
20588
23154
  const wanted = (el) => {
20589
23155
  switch (kind) {
@@ -20741,22 +23307,15 @@ let VerticalListElement = class VerticalListElement extends i {
20741
23307
  composed: true
20742
23308
  }));
20743
23309
  }
20744
- _openSearch() {
20745
- this._searchVisible = true;
20746
- this.updateComplete.then(() => this._searchInputEl?.focus());
20747
- }
20748
- _closeSearch() {
20749
- this._searchVisible = false;
20750
- this._searchQuery = ``;
20751
- this.#search?.clearQuery();
20752
- this.focus();
20753
- }
20754
23310
  _activateItem(item) {
20755
23311
  if (this.interactionMode !== `manual`) this.selection.replace(item);
20756
23312
  this._dispatch(`list-activate`, { item });
20757
23313
  }
20758
23314
  static {
20759
- this.styles = [themeFallbacks, i$3`
23315
+ this.styles = [
23316
+ themeFallbacks,
23317
+ listSearchStyles,
23318
+ i$3`
20760
23319
  :host {
20761
23320
  display: block;
20762
23321
  position: relative;
@@ -20807,6 +23366,10 @@ let VerticalListElement = class VerticalListElement extends i {
20807
23366
  display: flex;
20808
23367
  align-items: center;
20809
23368
  padding: var(--ixfx-list-item-padding);
23369
+ /* Inset each row from the scroll container's edges (margin, not padding:
23370
+ padding is inside the border box, so the tickle/selection outlines
23371
+ would still sit flush against the edge and be clipped). */
23372
+ margin: 0 var(--list-gutter, 4px);
20810
23373
  min-height: var(--list-item-height, 28px);
20811
23374
  cursor: pointer;
20812
23375
  user-select: none;
@@ -20819,6 +23382,7 @@ let VerticalListElement = class VerticalListElement extends i {
20819
23382
  /* Custom shadow-DOM components handle their own internal layout/padding */
20820
23383
  ::slotted([data-list-item]) {
20821
23384
  display: block;
23385
+ margin: 0 var(--list-gutter, 4px);
20822
23386
  cursor: pointer;
20823
23387
  user-select: none;
20824
23388
  transition: background var(--transition, 0.1s ease-out),
@@ -20874,49 +23438,8 @@ let VerticalListElement = class VerticalListElement extends i {
20874
23438
  color: inherit;
20875
23439
  outline: var(--item-border-tickled);
20876
23440
  }
20877
-
20878
- .search-overlay {
20879
- position: absolute;
20880
- top: 8px;
20881
- right: 8px;
20882
- z-index: 10;
20883
- display: flex;
20884
- align-items: center;
20885
- gap: 4px;
20886
- background: var(--surface-4, #2a2a2a);
20887
- border: 1px solid var(--border, #444);
20888
- border-radius: var(--radius-m, 4px);
20889
- padding: 4px 6px;
20890
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
20891
- }
20892
-
20893
- .search-overlay[hidden] {
20894
- display: none;
20895
- }
20896
-
20897
- .search-input {
20898
- background: transparent;
20899
- border: none;
20900
- outline: none;
20901
- color: inherit;
20902
- font-size: var(--text-m, 0.875em);
20903
- min-width: 140px;
20904
- }
20905
-
20906
- .search-close {
20907
- background: transparent;
20908
- border: none;
20909
- color: var(--surface-muted-text, #888);
20910
- cursor: pointer;
20911
- font-size: 1.1em;
20912
- line-height: 1;
20913
- padding: 0 2px;
20914
- }
20915
-
20916
- .search-close:hover {
20917
- color: inherit;
20918
- }
20919
- `];
23441
+ `
23442
+ ];
20920
23443
  }
20921
23444
  render() {
20922
23445
  return b`
@@ -20924,21 +23447,7 @@ let VerticalListElement = class VerticalListElement extends i {
20924
23447
  <ul part="list">
20925
23448
  <slot></slot>
20926
23449
  </ul>
20927
- <div
20928
- class="search-overlay"
20929
- part="search-overlay"
20930
- ?hidden=${!this._searchVisible}
20931
- >
20932
- <input
20933
- type="text"
20934
- class="search-input"
20935
- placeholder="Search…"
20936
- .value=${this._searchQuery}
20937
- @input=${this._onSearchInput}
20938
- @keydown=${this._onSearchKeydown}
20939
- />
20940
- <button class="search-close" @click=${this._closeSearch} title="Close search (Esc)">×</button>
20941
- </div>
23450
+ ${this.search.renderField()}
20942
23451
  </div>
20943
23452
  `;
20944
23453
  }
@@ -20957,9 +23466,11 @@ __decorate([property({
20957
23466
  attribute: `item-wrap`
20958
23467
  })], VerticalListElement.prototype, "itemWrap", void 0);
20959
23468
  __decorate([property({ attribute: false })], VerticalListElement.prototype, "checkboxRenderer", void 0);
20960
- __decorate([state()], VerticalListElement.prototype, "_searchVisible", void 0);
20961
- __decorate([state()], VerticalListElement.prototype, "_searchQuery", void 0);
20962
- __decorate([query(`.search-input`)], VerticalListElement.prototype, "_searchInputEl", void 0);
23469
+ __decorate([property({
23470
+ type: String,
23471
+ attribute: `search-modes`,
23472
+ reflect: true
23473
+ })], VerticalListElement.prototype, "searchModes", void 0);
20963
23474
  VerticalListElement = __decorate([safeCustomElement(`ixfx-vertical-list`)], VerticalListElement);
20964
23475
  //#endregion
20965
23476
  //#region src/grouped-item-lister/vertical-list-adapter.ts
@@ -25457,6 +27968,7 @@ let MillerBaseElement = class MillerBaseElement extends i {
25457
27968
  constructor(..._args) {
25458
27969
  super(..._args);
25459
27970
  this.headers = false;
27971
+ this.stretchLastColumn = false;
25460
27972
  this.interactionMode = `standard`;
25461
27973
  this.selectionFilter = `leaf`;
25462
27974
  this.exclusivity = `none`;
@@ -25468,6 +27980,10 @@ let MillerBaseElement = class MillerBaseElement extends i {
25468
27980
  this.#resizingDepth = -1;
25469
27981
  this.#resizeStartX = 0;
25470
27982
  this.#resizeStartWidth = 0;
27983
+ this.search = new ListSearchController(this, { adapter: createTreeListSearchAdapter(this, {
27984
+ labelSelector: `.label`,
27985
+ highlightKey: `incr-search-highlight`
27986
+ }) });
25471
27987
  this.tickled = new TickledController(this, { getItems: () => {
25472
27988
  const items = [];
25473
27989
  for (let depth = 0; depth < this._columns.length; depth++) {
@@ -25477,6 +27993,7 @@ let MillerBaseElement = class MillerBaseElement extends i {
25477
27993
  return items;
25478
27994
  } });
25479
27995
  this.#onKeyDown = (event) => {
27996
+ if (this.search.isOpen) return;
25480
27997
  const currentDepth = this.#getTickledDepthOrDefault();
25481
27998
  const col = this._columns[currentDepth];
25482
27999
  if (!col || col.length === 0) {
@@ -25683,9 +28200,31 @@ let MillerBaseElement = class MillerBaseElement extends i {
25683
28200
  #resizeStartX;
25684
28201
  #resizeStartWidth;
25685
28202
  #modelUnsubscribe;
28203
+ #searchRegistry;
28204
+ #unregisterSearchCommands;
28205
+ /** Command registry carrying this component's `list.search.*` commands. */
28206
+ get registry() {
28207
+ return this.#searchRegistry;
28208
+ }
28209
+ #effectiveSearchModes() {
28210
+ const raw = this.searchModes?.trim();
28211
+ if (raw === void 0) return [...LIST_SEARCH_MODES];
28212
+ const requested = new Set(raw.split(/\s+/));
28213
+ return LIST_SEARCH_MODES.filter((m) => requested.has(m));
28214
+ }
28215
+ #syncSearchCommands() {
28216
+ if (!this.#searchRegistry) return;
28217
+ this.search.setModes(this.#effectiveSearchModes());
28218
+ this.#unregisterSearchCommands?.();
28219
+ this.#unregisterSearchCommands = registerListSearchCommands(this.#searchRegistry, this.search);
28220
+ }
25686
28221
  #buildTickledId(node, depth) {
25687
28222
  return `${depth}:${node.item.key}`;
25688
28223
  }
28224
+ /** The node column 0 renders the children of: `viewRoot` when rebased, else `root`. */
28225
+ get #displayRoot() {
28226
+ return this.viewRoot ?? this.root;
28227
+ }
25689
28228
  #getTickledDepth() {
25690
28229
  const id = this.tickled.tickledId;
25691
28230
  if (!id) return void 0;
@@ -25694,16 +28233,20 @@ let MillerBaseElement = class MillerBaseElement extends i {
25694
28233
  return Number(id.substring(0, colonIndex));
25695
28234
  }
25696
28235
  updated(changedProps) {
28236
+ if (changedProps.has(`searchModes`)) this.#syncSearchCommands();
25697
28237
  if (changedProps.has(`root`) && this.root) {
25698
28238
  this.#abortPending();
25699
- this._columns = this.root.children ? [this.root.children] : [];
28239
+ if (this.viewRoot !== void 0) this.viewRoot = void 0;
25700
28240
  this._selectedByDepth.clear();
25701
28241
  this._selectedKeys = /* @__PURE__ */ new Set();
25702
28242
  this._selectedNodes.clear();
25703
28243
  this.#lastSelectedNode = void 0;
25704
28244
  this.#anchor = void 0;
25705
28245
  this.tickled.clearTickled();
28246
+ const display = this.#displayRoot;
28247
+ this._columns = display?.children ? [display.children] : [];
25706
28248
  }
28249
+ if (changedProps.has(`viewRoot`)) this.#applyViewRoot();
25707
28250
  if (changedProps.has(`model`)) {
25708
28251
  this.#modelUnsubscribe?.();
25709
28252
  this.#modelUnsubscribe = void 0;
@@ -25745,10 +28288,18 @@ let MillerBaseElement = class MillerBaseElement extends i {
25745
28288
  });
25746
28289
  }
25747
28290
  }
28291
+ connectedCallback() {
28292
+ super.connectedCallback();
28293
+ this.#searchRegistry ??= createCommandRegistry();
28294
+ this.#syncSearchCommands();
28295
+ }
25748
28296
  disconnectedCallback() {
25749
28297
  super.disconnectedCallback();
25750
28298
  this.#modelUnsubscribe?.();
25751
28299
  this.#modelUnsubscribe = void 0;
28300
+ this.#unregisterSearchCommands?.();
28301
+ this.#unregisterSearchCommands = void 0;
28302
+ this.#searchRegistry = void 0;
25752
28303
  }
25753
28304
  getConfig() {
25754
28305
  const columnWidths = {};
@@ -25844,15 +28395,17 @@ let MillerBaseElement = class MillerBaseElement extends i {
25844
28395
  return results;
25845
28396
  }
25846
28397
  /**
25847
- * Programmatically navigate to a node by key path.
28398
+ * Programmatically navigate to a node by key path, relative to the current
28399
+ * view root (rebased or full tree).
25848
28400
  */
25849
28401
  navigateTo(keys) {
25850
- if (!this.root?.children) return false;
28402
+ const display = this.#displayRoot;
28403
+ if (!display?.children) return false;
25851
28404
  this.#abortPending();
25852
28405
  this._selectedByDepth.clear();
25853
28406
  this.tickled.clearTickled();
25854
- const newColumns = [this.root.children];
25855
- let currentColumn = this.root.children;
28407
+ const newColumns = [display.children];
28408
+ let currentColumn = display.children;
25856
28409
  let success = true;
25857
28410
  let lastNode;
25858
28411
  let lastDepth = 0;
@@ -25887,13 +28440,51 @@ let MillerBaseElement = class MillerBaseElement extends i {
25887
28440
  * Programmatically navigate to the first node (breadth-first) matching a predicate.
25888
28441
  */
25889
28442
  navigateToNode(predicate) {
25890
- if (!this.root?.children) return false;
25891
- const keyPath = this.#findNodePath(this.root.children, predicate);
28443
+ const display = this.#displayRoot;
28444
+ if (!display?.children) return false;
28445
+ const keyPath = this.#findNodePath(display.children, predicate);
25892
28446
  if (!keyPath) return false;
25893
28447
  return this.navigateTo(keyPath);
25894
28448
  }
28449
+ /** Open the columns down to `key` and move the cursor to it, without changing the selection (search "navigate" mode). */
28450
+ revealKey(key) {
28451
+ const display = this.#displayRoot;
28452
+ if (!display?.children) return false;
28453
+ const path = this.#findNodePath(display.children, (n) => n.item.key === key);
28454
+ if (!path) return false;
28455
+ const columns = [display.children];
28456
+ let current = display.children;
28457
+ let lastNode;
28458
+ let lastDepth = 0;
28459
+ for (let depth = 0; depth < path.length; depth++) {
28460
+ const node = current.find((n) => n.item.key === path[depth]);
28461
+ if (!node) return false;
28462
+ lastNode = node;
28463
+ lastDepth = depth;
28464
+ if (node.children && node.children.length > 0) {
28465
+ columns.push(node.children);
28466
+ current = node.children;
28467
+ } else break;
28468
+ }
28469
+ this._columns = columns;
28470
+ if (lastNode) this.tickled.setTickled(this.#buildTickledId(lastNode, lastDepth));
28471
+ this.requestUpdate();
28472
+ this.#scrollNavigatedItemIntoView();
28473
+ return true;
28474
+ }
28475
+ /** Replace the selection with `nodes` (search "select" mode). */
28476
+ selectMany(nodes) {
28477
+ const arr = this.selectionFilter === `none` ? [] : [...nodes];
28478
+ const prev = new Set(this._selectedNodes);
28479
+ this._selectedNodes = new Set(arr);
28480
+ this._selectedKeys = new Set(arr.map((n) => n.item.key));
28481
+ this.#lastSelectedNode = arr.at(-1);
28482
+ this.#dispatchSelectEvent(prev);
28483
+ this.requestUpdate();
28484
+ }
25895
28485
  getNodes() {
25896
- if (!this.root?.children) return [];
28486
+ const display = this.#displayRoot;
28487
+ if (!display?.children) return [];
25897
28488
  const result = [];
25898
28489
  const visit = (node, depth) => {
25899
28490
  result.push({
@@ -25902,7 +28493,7 @@ let MillerBaseElement = class MillerBaseElement extends i {
25902
28493
  });
25903
28494
  if (node.children) for (const child of node.children) visit(child, depth + 1);
25904
28495
  };
25905
- for (const child of this.root.children) visit(child, 0);
28496
+ for (const child of display.children) visit(child, 0);
25906
28497
  return result;
25907
28498
  }
25908
28499
  #findNodePath(nodes, predicate, prefix = []) {
@@ -25915,6 +28506,124 @@ let MillerBaseElement = class MillerBaseElement extends i {
25915
28506
  }
25916
28507
  }
25917
28508
  }
28509
+ /**
28510
+ * Re-render the columns for the current `#displayRoot`, clearing selection.
28511
+ * Runs on every `viewRoot` change.
28512
+ */
28513
+ #applyViewRoot() {
28514
+ this.#abortPending();
28515
+ const prev = new Set(this._selectedNodes);
28516
+ this._selectedByDepth.clear();
28517
+ this._selectedKeys = /* @__PURE__ */ new Set();
28518
+ this._selectedNodes.clear();
28519
+ this.#lastSelectedNode = void 0;
28520
+ this.#anchor = void 0;
28521
+ this.tickled.clearTickled();
28522
+ const display = this.#displayRoot;
28523
+ if (display?.children !== void 0) {
28524
+ this._columns = display.children ? [display.children] : [];
28525
+ this._loadingDepth = void 0;
28526
+ } else if (display) {
28527
+ this._columns = [];
28528
+ this.#loadUnloadedChildren(display, -1, false);
28529
+ } else {
28530
+ this._columns = [];
28531
+ this._loadingDepth = void 0;
28532
+ }
28533
+ if (prev.size > 0) this.#dispatchSelectEvent(prev);
28534
+ }
28535
+ /**
28536
+ * Rebase the view to show `target`'s children in column 0 (Finder column-view
28537
+ * behavior on double-click), keeping the full tree in `root`.
28538
+ *
28539
+ * - Pass a `MillerNode` (used as-is) or a key (looked up in the full tree).
28540
+ * - Pass `undefined` to restore the full-tree view.
28541
+ * - Rebasing to the true root is equivalent to restoring the full view.
28542
+ * - Leaves are refused (returns `false`).
28543
+ *
28544
+ * Selection is cleared; all depths, headers, and key paths are relative to
28545
+ * the new view root. Fires a `rebase` event when the view actually changes.
28546
+ * Returns `false` when nothing changed (unknown key, leaf, or same view).
28547
+ */
28548
+ rebaseTo(target) {
28549
+ if (target === void 0) return this.#clearViewRoot();
28550
+ const node = typeof target === `string` ? this.#findNodeByKey(target) : target;
28551
+ if (!node || node.item.isLeaf === true) return false;
28552
+ if (node === this.viewRoot || node.item.key === this.viewRoot?.item.key) return false;
28553
+ if (node === this.root || !this.viewRoot && node.item.key === this.root?.item.key) return this.#clearViewRoot();
28554
+ this.viewRoot = node;
28555
+ this.#dispatchRebaseEvent();
28556
+ return true;
28557
+ }
28558
+ /**
28559
+ * Rebase one level up from the current view root (no-op at the full tree).
28560
+ * Fires a `rebase` event when the view actually changes.
28561
+ */
28562
+ rebaseUp() {
28563
+ if (!this.viewRoot) return false;
28564
+ const parent = this.#findParentOf(this.viewRoot);
28565
+ if (!parent) return false;
28566
+ if (parent === this.root) return this.#clearViewRoot();
28567
+ this.viewRoot = parent;
28568
+ this.#dispatchRebaseEvent();
28569
+ return true;
28570
+ }
28571
+ #clearViewRoot() {
28572
+ if (this.viewRoot === void 0) return false;
28573
+ this.viewRoot = void 0;
28574
+ this.#dispatchRebaseEvent();
28575
+ return true;
28576
+ }
28577
+ #dispatchRebaseEvent() {
28578
+ const viewRoot = this.viewRoot;
28579
+ const detail = {
28580
+ viewRoot,
28581
+ path: viewRoot ? this.#pathToRoot(viewRoot) : []
28582
+ };
28583
+ this.dispatchEvent(new CustomEvent(`rebase`, {
28584
+ detail,
28585
+ bubbles: true,
28586
+ composed: true
28587
+ }));
28588
+ }
28589
+ #findNodeByKey(key) {
28590
+ if (!this.root) return void 0;
28591
+ if (this.root.item.key === key) return this.root;
28592
+ return this.#findNodeIn(this.root.children ?? [], key);
28593
+ }
28594
+ #findNodeIn(nodes, key) {
28595
+ for (const node of nodes) {
28596
+ if (node.item.key === key) return node;
28597
+ if (node.children) {
28598
+ const found = this.#findNodeIn(node.children, key);
28599
+ if (found) return found;
28600
+ }
28601
+ }
28602
+ }
28603
+ /** Find the parent of `target` (by reference, falling back to key) in the full tree. */
28604
+ #findParentOf(target) {
28605
+ if (!this.root || target === this.root) return void 0;
28606
+ return this.#findParentIn(this.root, target);
28607
+ }
28608
+ #findParentIn(node, target) {
28609
+ if (!node.children) return void 0;
28610
+ for (const child of node.children) {
28611
+ if (child === target || child.item.key === target.item.key) return node;
28612
+ const found = this.#findParentIn(child, target);
28613
+ if (found) return found;
28614
+ }
28615
+ }
28616
+ /** Ancestry chain from the true root down to and including `target`. */
28617
+ #pathToRoot(target) {
28618
+ const chain = [target];
28619
+ let current = this.#findParentOf(target);
28620
+ while (current) {
28621
+ chain.unshift(current);
28622
+ if (current === this.root) break;
28623
+ current = this.#findParentOf(current);
28624
+ }
28625
+ return chain;
28626
+ }
25918
28627
  renderColumnContent(nodes, depth) {
25919
28628
  console.warn(`ixfx-miller-base: renderColumnContent not overridden. depth=${depth}, nodes=${nodes.length}`);
25920
28629
  return A;
@@ -26041,7 +28750,7 @@ let MillerBaseElement = class MillerBaseElement extends i {
26041
28750
  */
26042
28751
  #buildPath(depth) {
26043
28752
  const path = [];
26044
- if (this.root) path.push(this.root);
28753
+ if (this.#displayRoot) path.push(this.#displayRoot);
26045
28754
  for (let d = 0; d < depth; d++) {
26046
28755
  const sel = this._selectedByDepth.get(d);
26047
28756
  if (sel) path.push(sel);
@@ -26144,57 +28853,8 @@ let MillerBaseElement = class MillerBaseElement extends i {
26144
28853
  this.requestUpdate();
26145
28854
  return;
26146
28855
  }
26147
- if (node.children === void 0) {
26148
- this._columns = this._columns.slice(0, depth + 1);
26149
- this._loadingDepth = depth + 1;
26150
- this.#pendingNode = node;
26151
- const controller = new AbortController();
26152
- this.#pendingController = controller;
26153
- this.dispatchEvent(new CustomEvent(`expand`, {
26154
- detail: {
26155
- node,
26156
- depth
26157
- },
26158
- bubbles: true,
26159
- composed: true
26160
- }));
26161
- const applyChildren = (children) => {
26162
- if (controller.signal.aborted) return;
26163
- node.children = children;
26164
- this.#pendingNode = void 0;
26165
- this.#pendingController = void 0;
26166
- this._loadingDepth = void 0;
26167
- const cols = this._columns.slice(0, depth + 1);
26168
- if (children.length > 0) cols.push(children);
26169
- this._columns = cols;
26170
- };
26171
- const handleError = (err) => {
26172
- if (err.name !== `AbortError`) console.error(err);
26173
- node.children = [];
26174
- this.#pendingNode = void 0;
26175
- this.#pendingController = void 0;
26176
- this._loadingDepth = void 0;
26177
- this.requestUpdate();
26178
- };
26179
- if (this.model || this.loadChildren) {
26180
- if (this.#expandTimer !== void 0) clearTimeout(this.#expandTimer);
26181
- this.#expandTimer = setTimeout(() => {
26182
- this.#expandTimer = void 0;
26183
- if (this.#pendingNode !== node) return;
26184
- (this.model ? this.model.getChildren(node, depth, controller.signal) : collectDataResult(this.loadChildren({
26185
- node,
26186
- depth
26187
- }, controller.signal), controller.signal)).then((children) => applyChildren([...children])).catch(handleError);
26188
- }, EXPAND_DEBOUNCE_MS);
26189
- } else {
26190
- this._loadingDepth = void 0;
26191
- this.#pendingNode = void 0;
26192
- this.#pendingController = void 0;
26193
- const newColumns = this._columns.slice(0, depth + 1);
26194
- newColumns.push([]);
26195
- this._columns = newColumns;
26196
- }
26197
- } else {
28856
+ if (node.children === void 0) this.#loadUnloadedChildren(node, depth, true);
28857
+ else {
26198
28858
  const newColumns = this._columns.slice(0, depth + 1);
26199
28859
  newColumns.push(node.children);
26200
28860
  this._columns = newColumns;
@@ -26208,6 +28868,68 @@ let MillerBaseElement = class MillerBaseElement extends i {
26208
28868
  }));
26209
28869
  }
26210
28870
  }
28871
+ /**
28872
+ * Fetch children for a node whose `children === undefined` and show them as
28873
+ * the column after `parentDepth` (with the loading placeholder meanwhile).
28874
+ * Shared by branch expansion (`parentDepth` = the branch's column) and view
28875
+ * rebasing (`parentDepth` = -1, so the children become column 0).
28876
+ *
28877
+ * The `depth` reported to `loadChildren`/`model.getChildren` is the node's
28878
+ * own depth: its column for expansions, -1 for a view root (it sits above
28879
+ * column 0, so loaders computing child depth as `depth + 1` stay correct).
28880
+ */
28881
+ #loadUnloadedChildren(node, parentDepth, fireExpandEvent) {
28882
+ const nodeDepth = parentDepth + 1;
28883
+ this._columns = this._columns.slice(0, nodeDepth);
28884
+ this._loadingDepth = nodeDepth;
28885
+ this.#pendingNode = node;
28886
+ const controller = new AbortController();
28887
+ this.#pendingController = controller;
28888
+ if (fireExpandEvent) this.dispatchEvent(new CustomEvent(`expand`, {
28889
+ detail: {
28890
+ node,
28891
+ depth: parentDepth
28892
+ },
28893
+ bubbles: true,
28894
+ composed: true
28895
+ }));
28896
+ const applyChildren = (children) => {
28897
+ if (controller.signal.aborted) return;
28898
+ node.children = children;
28899
+ this.#pendingNode = void 0;
28900
+ this.#pendingController = void 0;
28901
+ this._loadingDepth = void 0;
28902
+ const cols = this._columns.slice(0, nodeDepth);
28903
+ if (children.length > 0) cols.push(children);
28904
+ this._columns = cols;
28905
+ };
28906
+ const handleError = (err) => {
28907
+ if (err.name !== `AbortError`) console.error(err);
28908
+ node.children = [];
28909
+ this.#pendingNode = void 0;
28910
+ this.#pendingController = void 0;
28911
+ this._loadingDepth = void 0;
28912
+ this.requestUpdate();
28913
+ };
28914
+ if (this.model || this.loadChildren) {
28915
+ if (this.#expandTimer !== void 0) clearTimeout(this.#expandTimer);
28916
+ this.#expandTimer = setTimeout(() => {
28917
+ this.#expandTimer = void 0;
28918
+ if (this.#pendingNode !== node) return;
28919
+ (this.model ? this.model.getChildren(node, nodeDepth - 1, controller.signal) : collectDataResult(this.loadChildren({
28920
+ node,
28921
+ depth: nodeDepth - 1
28922
+ }, controller.signal), controller.signal)).then((children) => applyChildren([...children])).catch(handleError);
28923
+ }, EXPAND_DEBOUNCE_MS);
28924
+ } else {
28925
+ this._loadingDepth = void 0;
28926
+ this.#pendingNode = void 0;
28927
+ this.#pendingController = void 0;
28928
+ const newColumns = this._columns.slice(0, nodeDepth);
28929
+ newColumns.push([]);
28930
+ this._columns = newColumns;
28931
+ }
28932
+ }
26211
28933
  #onCheckboxClick(node) {
26212
28934
  const currentlySelected = this._selectedKeys.has(node.item.key);
26213
28935
  if (!!node.children?.length ? this.#allDescendantsSelected(node) : currentlySelected) this.#applyCheckedToggle(node, false);
@@ -26277,7 +28999,7 @@ let MillerBaseElement = class MillerBaseElement extends i {
26277
28999
  #onResizeMove;
26278
29000
  #onResizeEnd;
26279
29001
  #getColumnHeader(depth) {
26280
- if (depth === 0) return this.root?.item.label ?? ``;
29002
+ if (depth === 0) return this.#displayRoot?.item.label ?? ``;
26281
29003
  const explicit = this._selectedByDepth.get(depth - 1);
26282
29004
  if (explicit) return explicit.item.label;
26283
29005
  const prevCol = this._columns[depth - 1];
@@ -26288,9 +29010,11 @@ let MillerBaseElement = class MillerBaseElement extends i {
26288
29010
  }
26289
29011
  return ``;
26290
29012
  }
26291
- #columnStyle(depth) {
29013
+ #columnStyle(depth, isLast) {
26292
29014
  const w = this.#columnWidths.get(depth);
26293
- return w !== void 0 ? `width:${w}px;flex-shrink:0` : `width:max-content`;
29015
+ if (w !== void 0) return `width:${w}px;flex-grow:0;flex-shrink:0;flex-basis:auto;max-width:none`;
29016
+ if (this.stretchLastColumn && isLast) return `width:max-content;flex-grow:1;flex-shrink:0;flex-basis:auto;min-width:0`;
29017
+ return `width:max-content;flex-grow:0;flex-shrink:0;flex-basis:auto`;
26294
29018
  }
26295
29019
  #isFocused(node, depth) {
26296
29020
  return this.tickled.isTickled(this.#buildTickledId(node, depth));
@@ -26303,8 +29027,9 @@ let MillerBaseElement = class MillerBaseElement extends i {
26303
29027
  }
26304
29028
  #renderLoadingColumn() {
26305
29029
  const depth = this._loadingDepth;
29030
+ const stretched = this.stretchLastColumn && this.#columnWidths.get(depth) === void 0;
26306
29031
  return b`
26307
- <div class="column loading-column" style=${this.#columnStyle(depth)}>
29032
+ <div class="column loading-column ${stretched ? `stretch-last` : ``}" style=${this.#columnStyle(depth, true)}>
26308
29033
  ${this.headers ? b`
26309
29034
  <div class="column-header">${this.#getColumnHeader(depth)}</div>
26310
29035
  ` : A}
@@ -26319,8 +29044,10 @@ let MillerBaseElement = class MillerBaseElement extends i {
26319
29044
  <div id="container" @keydown=${this.#onKeyDown} tabindex="0">
26320
29045
  ${this._columns.map((nodes, depth) => {
26321
29046
  const visibleNodes = this.filterPredicate ? nodes.filter((n) => this.filterPredicate(n, depth)) : nodes;
29047
+ const isLast = depth === this._columns.length - 1 && this._loadingDepth === void 0;
29048
+ const stretched = this.stretchLastColumn && isLast && this.#columnWidths.get(depth) === void 0;
26322
29049
  return b`
26323
- <div class="column" style=${this.#columnStyle(depth)}>
29050
+ <div class="column ${stretched ? `stretch-last` : ``}" style=${this.#columnStyle(depth, isLast)}>
26324
29051
  ${this.headers ? b`
26325
29052
  <div class="column-header"
26326
29053
  @click=${() => this.#onHeaderClick(depth)}
@@ -26339,6 +29066,7 @@ let MillerBaseElement = class MillerBaseElement extends i {
26339
29066
  })}
26340
29067
  ${this._loadingDepth !== void 0 ? this.#renderLoadingColumn() : A}
26341
29068
  </div>
29069
+ ${this.search.renderField()}
26342
29070
  `;
26343
29071
  }
26344
29072
  _getFocusedIndex(depth) {
@@ -26374,6 +29102,7 @@ let MillerBaseElement = class MillerBaseElement extends i {
26374
29102
  this.styles = [
26375
29103
  themeFallbacks,
26376
29104
  tickledStyles,
29105
+ listSearchStyles,
26377
29106
  i$3`
26378
29107
  :host {
26379
29108
  --column-bg: var(--surface-3);
@@ -26408,6 +29137,20 @@ let MillerBaseElement = class MillerBaseElement extends i {
26408
29137
  background: var(--column-bg);
26409
29138
  border-right: 1px solid var(--column-separator);
26410
29139
  flex-shrink: 0;
29140
+ /* Content-sized columns never grow wider than 80% of the available
29141
+ width — pathological labels truncate with ellipsis instead of
29142
+ pushing every other column out of view */
29143
+ max-width: 80%;
29144
+ /* Smoothly animate the last column stretching to fill remaining
29145
+ space and shrinking back to content width when it stops being last */
29146
+ transition: flex-grow var(--transition, 0.2s ease-out);
29147
+ }
29148
+
29149
+ .column.stretch-last {
29150
+ min-width: 0;
29151
+ /* Stretching fills remaining space by definition, so it is exempt
29152
+ from the 80% auto-size cap */
29153
+ max-width: none;
26411
29154
  }
26412
29155
 
26413
29156
  .column-header {
@@ -26491,6 +29234,12 @@ let MillerBaseElement = class MillerBaseElement extends i {
26491
29234
  __decorate([property({ type: Object })], MillerBaseElement.prototype, "root", void 0);
26492
29235
  __decorate([property({ attribute: false })], MillerBaseElement.prototype, "model", void 0);
26493
29236
  __decorate([property({ type: Boolean })], MillerBaseElement.prototype, "headers", void 0);
29237
+ __decorate([property({
29238
+ type: Boolean,
29239
+ attribute: `stretch-last-column`,
29240
+ reflect: true
29241
+ })], MillerBaseElement.prototype, "stretchLastColumn", void 0);
29242
+ __decorate([property({ attribute: false })], MillerBaseElement.prototype, "viewRoot", void 0);
26494
29243
  __decorate([property({ type: String })], MillerBaseElement.prototype, "interactionMode", void 0);
26495
29244
  __decorate([property({
26496
29245
  reflect: true,
@@ -26502,6 +29251,11 @@ __decorate([property({
26502
29251
  })], MillerBaseElement.prototype, "exclusivity", void 0);
26503
29252
  __decorate([property({ type: Object })], MillerBaseElement.prototype, "loadChildren", void 0);
26504
29253
  __decorate([property({ attribute: false })], MillerBaseElement.prototype, "filterPredicate", void 0);
29254
+ __decorate([property({
29255
+ type: String,
29256
+ attribute: `search-modes`,
29257
+ reflect: true
29258
+ })], MillerBaseElement.prototype, "searchModes", void 0);
26505
29259
  __decorate([state()], MillerBaseElement.prototype, "_columns", void 0);
26506
29260
  __decorate([state()], MillerBaseElement.prototype, "_loadingDepth", void 0);
26507
29261
  __decorate([state()], MillerBaseElement.prototype, "_selectedNodes", void 0);
@@ -26739,14 +29493,28 @@ var MillerTreeController = class {
26739
29493
  * Any ancestor `MillerNode`s with unloaded children are populated
26740
29494
  * synchronously before calling `element.navigateTo()`.
26741
29495
  *
29496
+ * Key paths are relative to the element's current view root: when the view
29497
+ * is rebased, only matches at or below the view root can be reached.
29498
+ *
26742
29499
  * Returns `false` if no matching node is found.
26743
29500
  */
26744
29501
  navigateToValue(predicate) {
26745
29502
  const treePath = this.#findTreePath(this.#root, predicate);
26746
29503
  if (!treePath) return false;
26747
- let currentColumn = this.#element.root?.children ?? [];
29504
+ const base = this.#element.viewRoot ?? this.#element.root;
29505
+ if (!base) return false;
29506
+ let startIndex = 1;
29507
+ if (this.#element.viewRoot) {
29508
+ const mappedBase = this.#nodeToTree.get(base);
29509
+ if (!mappedBase) return false;
29510
+ const baseIndex = treePath.indexOf(mappedBase);
29511
+ if (baseIndex < 0) return false;
29512
+ startIndex = baseIndex + 1;
29513
+ if (base.children === void 0) base.children = this.#childrenOf(mappedBase);
29514
+ }
29515
+ let currentColumn = base.children ?? [];
26748
29516
  const keys = [];
26749
- for (let i = 1; i < treePath.length; i++) {
29517
+ for (let i = startIndex; i < treePath.length; i++) {
26750
29518
  const treeNode = treePath[i];
26751
29519
  const key = this.#options.valueToItem(treeNode.getValue(), treeNode).key;
26752
29520
  keys.push(key);
@@ -27228,6 +29996,136 @@ function dismiss(id) {
27228
29996
  syncRenderer();
27229
29997
  }
27230
29998
  //#endregion
29999
+ //#region src/pan-text/pan-text.ts
30000
+ const SPEED = 45;
30001
+ const END_PADDING = 4;
30002
+ const START_DELAY = 300;
30003
+ const PAN_EASING = `cubic-bezier(0.25, 0.46, 0.45, 0.94)`;
30004
+ let PanTextElement = class PanTextElement extends i {
30005
+ constructor(..._args) {
30006
+ super(..._args);
30007
+ this.direction = `end`;
30008
+ this.disabled = false;
30009
+ this._isOverflowing = false;
30010
+ this._isHovering = false;
30011
+ this._overflow = 0;
30012
+ this._resizeObserver = null;
30013
+ this._onMouseEnter = () => {
30014
+ this._isHovering = true;
30015
+ };
30016
+ this._onMouseLeave = () => {
30017
+ this._isHovering = false;
30018
+ };
30019
+ this._measure = () => {
30020
+ if (this._isHovering) return;
30021
+ const el = this._textEl;
30022
+ if (!el) return;
30023
+ this._overflow = Math.max(0, el.scrollWidth - el.clientWidth);
30024
+ const overflowing = this._overflow > 0;
30025
+ this.classList.toggle(`is-overflowing`, overflowing);
30026
+ if (overflowing !== this._isOverflowing) this._isOverflowing = overflowing;
30027
+ };
30028
+ }
30029
+ connectedCallback() {
30030
+ super.connectedCallback();
30031
+ this._resizeObserver = new ResizeObserver(() => this._measure());
30032
+ this.addEventListener(`mouseenter`, this._onMouseEnter);
30033
+ this.addEventListener(`mouseleave`, this._onMouseLeave);
30034
+ }
30035
+ disconnectedCallback() {
30036
+ super.disconnectedCallback();
30037
+ this._resizeObserver?.disconnect();
30038
+ this._resizeObserver = null;
30039
+ this.removeEventListener(`mouseenter`, this._onMouseEnter);
30040
+ this.removeEventListener(`mouseleave`, this._onMouseLeave);
30041
+ }
30042
+ firstUpdated() {
30043
+ this._measure();
30044
+ this._resizeObserver?.observe(this);
30045
+ document.fonts?.ready.then(() => this._measure());
30046
+ }
30047
+ updated() {
30048
+ this._measure();
30049
+ }
30050
+ _textStyle() {
30051
+ if (!this._isOverflowing) return ``;
30052
+ const distance = this._overflow + END_PADDING;
30053
+ const duration = Math.max(1.5, distance / SPEED);
30054
+ const revealEnd = this.direction === `end`;
30055
+ const restX = revealEnd ? 0 : -distance;
30056
+ const hoverX = revealEnd ? -distance : 0;
30057
+ if (this._isHovering) return [
30058
+ `transform: translateX(${hoverX}px)`,
30059
+ `transition: transform ${duration}s ${PAN_EASING} ${START_DELAY}ms`,
30060
+ `overflow: visible`,
30061
+ `text-overflow: clip`
30062
+ ].join(`; `);
30063
+ return [
30064
+ `transform: translateX(${restX}px)`,
30065
+ `transition: transform 400ms ${PAN_EASING}`,
30066
+ revealEnd ? `` : `overflow: visible; text-overflow: clip`
30067
+ ].filter(Boolean).join(`; `);
30068
+ }
30069
+ render() {
30070
+ const textClasses = {
30071
+ "is-overflowing": this._isOverflowing,
30072
+ "is-hovering": this._isHovering
30073
+ };
30074
+ return b`
30075
+ <span class="text ${classMap(textClasses)}" part="text" style=${this._textStyle()}>
30076
+ <slot @slotchange=${this._measure}></slot>
30077
+ </span>
30078
+ `;
30079
+ }
30080
+ static {
30081
+ this.styles = [themeFallbacks, i$3`
30082
+ /* Renders like plain inline text: no colours, border, padding or margin
30083
+ of its own — it only adds the clipping needed to pan on hover. */
30084
+ :host {
30085
+ display: inline-block;
30086
+ overflow: hidden;
30087
+ white-space: nowrap;
30088
+ max-width: 100%;
30089
+ box-sizing: border-box;
30090
+ font: inherit;
30091
+ color: inherit;
30092
+ }
30093
+
30094
+ :host([disabled]) {
30095
+ opacity: var(--disabled-opacity, 0.5);
30096
+ pointer-events: none;
30097
+ }
30098
+
30099
+ .text {
30100
+ display: block;
30101
+ overflow: hidden;
30102
+ text-overflow: ellipsis;
30103
+ white-space: nowrap;
30104
+ will-change: transform;
30105
+ }
30106
+
30107
+ @media (prefers-reduced-motion: reduce) {
30108
+ .text {
30109
+ transition: none !important;
30110
+ transform: none !important;
30111
+ }
30112
+ }
30113
+ `];
30114
+ }
30115
+ };
30116
+ __decorate([property({
30117
+ type: String,
30118
+ attribute: `direction`
30119
+ })], PanTextElement.prototype, "direction", void 0);
30120
+ __decorate([property({
30121
+ type: Boolean,
30122
+ attribute: `disabled`
30123
+ })], PanTextElement.prototype, "disabled", void 0);
30124
+ __decorate([state()], PanTextElement.prototype, "_isOverflowing", void 0);
30125
+ __decorate([state()], PanTextElement.prototype, "_isHovering", void 0);
30126
+ __decorate([query(`.text`)], PanTextElement.prototype, "_textEl", void 0);
30127
+ PanTextElement = __decorate([safeCustomElement(`ixfx-pan-text`)], PanTextElement);
30128
+ //#endregion
27231
30129
  //#region src/panel/panel-group.ts
27232
30130
  let PanelGroupElement = class PanelGroupElement extends i {
27233
30131
  constructor(..._args) {
@@ -35444,29 +38342,24 @@ var TreeController = class {
35444
38342
  navigateToValue(predicate) {
35445
38343
  const treePath = this.#findTreePath(this.#root, predicate);
35446
38344
  if (!treePath) return false;
38345
+ let currentColumn = this.#element.root?.children ?? [];
35447
38346
  const keys = [];
35448
38347
  for (let i = 1; i < treePath.length; i++) {
35449
38348
  const treeNode = treePath[i];
35450
38349
  const key = this.#options.valueToItem(treeNode.getValue(), treeNode).key;
35451
38350
  keys.push(key);
35452
38351
  if (i < treePath.length - 1) {
35453
- const currentChildren = i === 1 ? this.#element.root?.children ?? [] : this.#findInChildren(this.#element.root?.children ?? [], keys);
35454
- const treeNodeMap = this.#nodeToTree;
35455
- const node = currentChildren.find((n) => n.item.key === key);
38352
+ const node = currentColumn.find((n) => n.item.key === key);
35456
38353
  if (!node) return false;
35457
38354
  if (node.children === void 0) {
35458
- const mapped = treeNodeMap.get(node);
38355
+ const mapped = this.#nodeToTree.get(node);
35459
38356
  if (mapped) node.children = this.#childrenOf(mapped);
35460
38357
  }
38358
+ currentColumn = node.children ?? [];
35461
38359
  }
35462
38360
  }
35463
38361
  return this.#element.navigateTo(keys);
35464
38362
  }
35465
- #findInChildren(children, keys) {
35466
- if (keys.length === 0) return children;
35467
- const key = keys[0];
35468
- return children.find((n) => n.item.key === key)?.children ?? [];
35469
- }
35470
38363
  #childrenOf(treeNode) {
35471
38364
  return Array.from(treeNode.children(), (child) => this.#toTreeNode(child));
35472
38365
  }
@@ -36631,6 +39524,6 @@ function init() {
36631
39524
  console.log(`Init`);
36632
39525
  }
36633
39526
  //#endregion
36634
- export { AcTextElement, AcTokenElement, ButtonElement, Checkbox, ColourPicker, ColourPickerPopup, CrumbNavigationElement, CrumbPathController, CrumbPathElement, DataController, DataDisplayComponent, DataGridElement, DetailListElement, DockHostElement, DockPanelElement, EditableLabel, EditableNumber, FormElement, FormGroupElement, FormPartElement, GroupedItemListerElement, ICON_CARET_RIGHT, ICON_CHECK, ICON_CHEVRON_DOWN, ICON_CLOSE, IncrSearchTreeController as IncrSearchMillerController, IncrSearchTreeController, IxfxHexEditorElement, IxfxIconElement, IxfxTimelineElement, LabelledRadialInput, LabelledRangeInput, LabelledSliderInput, LedElement, ListSelectionController, menu_exports as Menu, MillerBaseElement, MillerListElement, MillerTreeController, NarrowedTextElement, NotificationManager, NotificationPillElement, PanelElement, PanelGroupElement, PlotDataSeries, PlotHistogram, PlotMultiAxis, PlotSingleAxis, PlotXyAxis, PolarPad, RadialInputElement, RadioGroup, RangeElement, RangeInput, RangeMultiElement, RatingElement, SelectHorizElement, SliderInputElement, SlidingNumberElement, SnackbarElement, SnapContainer, SplitButton, SplitLayoutElement, SplitPanelElement, SwipeElement, TabController, TabListElement, TabListItemElement, TabPanelElement, TabPanelsElement, TextShimmerElement, TickledController, TimelineController, TimelineTrackTabElement, TitlebarElement, ToolbarElement, ToolbarGroupElement, TooltipElement, TransitoryLabelElement, TreeBaseElement, TreeController, TreeDataModel, TreeKeyboardNavController, TreeListElement, TwoSplitLayoutElement, VerticalListElement, XyPad, addPanelToStack, applyAgeModification, applyHighlights, applySnapshot, attachTimelineInteractions, buildHighlightTargets, buildModel, clearHighlights, collectContainerNames, collectDataResult, collectPanelIds, collectStackPaths, computeColour, computeLeafGeoms, createAcCompletionProvider, createElementIncrSearch, createEmptyLayout, createEmptyStack, createFuzzySearch, createIncrSearch, createPillRenderer, createSplit, createStack, createTreeListAdapter, createVerticalListAdapter, distribute, extractStack, findNode, findParentOf, findStackOf, findStackPathByName, getActiveDragPanelId, getIcon, getIconEntry, getIconFont, getLeafNeighbours, hasIcon, iconBus, init, insertBeside, isDragging, isMultiMode, isSplit, isStack, monitorIndeterminate, moveLeafNodeToEdge, moveLeafStep, normalise, normalize, parseColourScaleAttr, parseLayout, pointerOverAnyHost, readBaseColour, registerFontIcon, registerIcon, removePanelFromStack, resolveCssVars, resolveGrid, resolveLeafPath, resolveTracks, serializeLayout, setActive, setIconFont, svgToDataUri, swapLeaves, tickledItemStyles, tickledStyles, timelineStyles, toSnapshot, walk, wrapRoot };
39527
+ export { AcTextElement, AcTokenElement, ButtonElement, Checkbox, ColourPicker, ColourPickerPopup, CrumbNavigationElement, CrumbPathController, CrumbPathElement, DataController, DataDisplayComponent, DataGridElement, DataTableElement, DetailListElement, DockHostElement, DockPanelElement, EditableLabel, EditableNumber, FormElement, FormGroupElement, FormPartElement, GridListElement, GroupedItemListerElement, ICON_CARET_RIGHT, ICON_CHECK, ICON_CHEVRON_DOWN, ICON_CLOSE, IncrSearchTreeController as IncrSearchMillerController, IncrSearchTreeController, IxfxHexEditorElement, IxfxIconElement, IxfxTimelineElement, KeyedElementIndex, LIST_ITEM_MOTION_MS, LIST_SEARCH_COMMAND_IDS, LIST_SEARCH_MODES, LabelledRadialInput, LabelledRangeInput, LabelledSliderInput, LedElement, ListSearchController, ListSelectionController, menu_exports as Menu, MillerBaseElement, MillerListElement, MillerTreeController, NarrowedTextElement, NotificationManager, NotificationPillElement, PanTextElement, PanelElement, PanelGroupElement, PlotDataSeries, PlotHistogram, PlotMultiAxis, PlotSingleAxis, PlotXyAxis, PolarPad, RadialInputElement, RadioGroup, RangeElement, RangeInput, RangeMultiElement, RatingElement, SelectHorizElement, SliderInputElement, SlidingNumberElement, SnackbarElement, SnapContainer, SplitButton, SplitLayoutElement, SplitPanelElement, SwipeElement, TabController, TabListElement, TabListItemElement, TabPanelElement, TabPanelsElement, TextShimmerElement, TickledController, TimelineController, TimelineTrackTabElement, TitlebarElement, ToolbarElement, ToolbarGroupElement, TooltipElement, TransitoryLabelElement, TreeBaseElement, TreeController, TreeDataModel, TreeKeyboardNavController, TreeListElement, TwoSplitLayoutElement, VerticalListElement, XyPad, addPanelToStack, animateListItemIn, animateListItemOut, applyAgeModification, applyHighlights, applySnapshot, attachTimelineInteractions, buildHighlightTargets, buildModel, clearHighlights, collectContainerNames, collectDataResult, collectPanelIds, collectStackPaths, computeColour, computeLeafGeoms, createAcCompletionProvider, createElementIncrSearch, createElementListSearchAdapter, createEmptyLayout, createEmptyStack, createFuzzySearch, createIncrSearch, createPillRenderer, createSplit, createStack, createTreeListAdapter, createTreeListSearchAdapter, createVerticalListAdapter, distribute, extractStack, findNode, findParentOf, findStackOf, findStackPathByName, getActiveDragPanelId, getIcon, getIconEntry, getIconFont, getLeafNeighbours, hasIcon, iconBus, init, insertBeside, isDragging, isMultiMode, isSplit, isStack, keyForElement, listItemFadeKeyframes, listSearchStyles, monitorIndeterminate, moveLeafNodeToEdge, moveLeafStep, normalise, normalize, parseColourScaleAttr, parseLayout, pointerOverAnyHost, prefersReducedMotion, readBaseColour, registerFontIcon, registerIcon, registerListSearchCommands, removePanelFromStack, resolveCssVars, resolveGrid, resolveLeafPath, resolveTracks, serializeLayout, setActive, setIconFont, svgToDataUri, swapLeaves, tickledItemStyles, tickledStyles, timelineStyles, toSnapshot, walk, wrapRoot };
36635
39528
 
36636
39529
  //# sourceMappingURL=index.js.map