@ixfx/components 0.7.4 → 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 (73) hide show
  1. package/bundle/index.d.ts +536 -178
  2. package/bundle/index.d.ts.map +1 -1
  3. package/bundle/index.js +1324 -442
  4. package/bundle/index.js.map +1 -1
  5. package/dist/ac-token.js +1 -1
  6. package/dist/crumbs.d.ts +5 -1
  7. package/dist/crumbs.d.ts.map +1 -1
  8. package/dist/crumbs.js +10 -0
  9. package/dist/crumbs.js.map +1 -1
  10. package/dist/{data-grid-BddUjAti.d.ts → data-grid-CtJ35v8L.d.ts} +26 -1
  11. package/dist/data-grid-CtJ35v8L.d.ts.map +1 -0
  12. package/dist/data-grid.d.ts +1 -1
  13. package/dist/data-grid.js +88 -7
  14. package/dist/data-grid.js.map +1 -1
  15. package/dist/{detail-list-Br0WAnry.d.ts → detail-list-BS5dRDda.d.ts} +20 -12
  16. package/dist/detail-list-BS5dRDda.d.ts.map +1 -0
  17. package/dist/detail-list.d.ts +2 -2
  18. package/dist/detail-list.js +51 -135
  19. package/dist/detail-list.js.map +1 -1
  20. package/dist/{grid-list-B_orPsot.d.ts → grid-list-D-GbghSH.d.ts} +18 -1
  21. package/dist/grid-list-D-GbghSH.d.ts.map +1 -0
  22. package/dist/grid-list.d.ts +1 -1
  23. package/dist/grid-list.js +76 -15
  24. package/dist/grid-list.js.map +1 -1
  25. package/dist/incr-search-DpbSJtgj.js +962 -0
  26. package/dist/incr-search-DpbSJtgj.js.map +1 -0
  27. package/dist/incr-search.d.ts +2 -2
  28. package/dist/incr-search.js +2 -3
  29. package/dist/index-CBS4z1v-.d.ts +365 -0
  30. package/dist/index-CBS4z1v-.d.ts.map +1 -0
  31. package/dist/index.d.ts +8 -8
  32. package/dist/index.js +4 -5
  33. package/dist/index.js.map +1 -1
  34. package/dist/{list-selection-controller-CUo-Hhcm.js → list-selection-controller-DTFI40li.js} +2 -76
  35. package/dist/list-selection-controller-DTFI40li.js.map +1 -0
  36. package/dist/miller.d.ts +81 -3
  37. package/dist/miller.d.ts.map +1 -1
  38. package/dist/miller.js +327 -68
  39. package/dist/miller.js.map +1 -1
  40. package/dist/narrowed-text.js +1 -2
  41. package/dist/narrowed-text.js.map +1 -1
  42. package/dist/{tree-B74zdI60.js → tree-DAii2A1g.js} +77 -15
  43. package/dist/tree-DAii2A1g.js.map +1 -0
  44. package/dist/{tree-component-BvV4muxx.d.ts → tree-component-F6qZ0Gdv.d.ts} +9 -1
  45. package/dist/{tree-component-BvV4muxx.d.ts.map → tree-component-F6qZ0Gdv.d.ts.map} +1 -1
  46. package/dist/tree.d.ts +22 -1
  47. package/dist/tree.d.ts.map +1 -1
  48. package/dist/tree.js +1 -1
  49. package/dist/{vertical-list-CQSEVeOT.d.ts → vertical-list-DwtCPy1b.d.ts} +17 -14
  50. package/dist/vertical-list-DwtCPy1b.d.ts.map +1 -0
  51. package/dist/vertical-list.d.ts +1 -1
  52. package/dist/vertical-list.js +59 -127
  53. package/dist/vertical-list.js.map +1 -1
  54. package/docs-user/data-grid.md +19 -0
  55. package/docs-user/detail-list.md +1 -1
  56. package/docs-user/grid-list.md +24 -0
  57. package/docs-user/incr-search.md +12 -2
  58. package/docs-user/index.json +2 -2
  59. package/docs-user/tree.md +22 -1
  60. package/docs-user/vertical-list.md +7 -1
  61. package/package.json +1 -1
  62. package/dist/data-grid-BddUjAti.d.ts.map +0 -1
  63. package/dist/detail-list-Br0WAnry.d.ts.map +0 -1
  64. package/dist/element-search-Y8SRfPGM.js +0 -185
  65. package/dist/element-search-Y8SRfPGM.js.map +0 -1
  66. package/dist/grid-list-B_orPsot.d.ts.map +0 -1
  67. package/dist/incr-search-vCzuao-2.js +0 -137
  68. package/dist/incr-search-vCzuao-2.js.map +0 -1
  69. package/dist/index-Cu5i8zZY.d.ts +0 -160
  70. package/dist/index-Cu5i8zZY.d.ts.map +0 -1
  71. package/dist/list-selection-controller-CUo-Hhcm.js.map +0 -1
  72. package/dist/tree-B74zdI60.js.map +0 -1
  73. package/dist/vertical-list-CQSEVeOT.d.ts.map +0 -1
@@ -0,0 +1,962 @@
1
+ import { n as buildHighlightTargets, r as clearHighlights, t as applyHighlights } from "./highlight-B-eQrhhb.js";
2
+ import { css, html, nothing } from "lit";
3
+ import { Fzf } from "fzf";
4
+ //#region src/incr-search/fuzzy.ts
5
+ function defaultSelector(item) {
6
+ if (typeof item === `string`) return item;
7
+ const r = item;
8
+ if (typeof r.label === `string`) return r.label;
9
+ if (typeof r.key === `string`) return r.key;
10
+ if (typeof r.value === `string`) return r.value;
11
+ return String(item);
12
+ }
13
+ function createFuzzySearch(items, options = {}) {
14
+ const selector = options.selector ?? defaultSelector;
15
+ const fzf = new Fzf(items, {
16
+ selector,
17
+ ...options.fzf ?? {}
18
+ });
19
+ const find = (query) => {
20
+ if (!query.trim()) return items.map((item) => ({
21
+ item,
22
+ positions: /* @__PURE__ */ new Set(),
23
+ score: 0
24
+ }));
25
+ return fzf.find(query).map((r) => ({
26
+ item: r.item,
27
+ positions: r.positions,
28
+ score: r.score
29
+ }));
30
+ };
31
+ return {
32
+ find,
33
+ items
34
+ };
35
+ }
36
+ //#endregion
37
+ //#region src/incr-search/ac-adapter.ts
38
+ /**
39
+ * Creates an ixfx-ac-text-compatible `completionProvider` function from a
40
+ * static item list. Uses synchronous fuzzy search wrapped in a Promise so it
41
+ * fits the async provider signature.
42
+ *
43
+ * Each returned item has `positions` populated with matched character indices,
44
+ * which `ixfx-ac-text` uses to render CSS Custom Highlights on the label.
45
+ *
46
+ * Empty query returns all items in original order (no positions).
47
+ * A cancelled signal causes the returned promise to reject with an AbortError.
48
+ */
49
+ function createAcCompletionProvider(items, options = {}) {
50
+ const fuzzy = createFuzzySearch(items, { selector: options.selector ?? ((item) => item.label) });
51
+ return (query, signal) => new Promise((resolve, reject) => {
52
+ if (signal.aborted) {
53
+ reject(new DOMException(`Aborted`, `AbortError`));
54
+ return;
55
+ }
56
+ const results = fuzzy.find(query).map((r) => ({
57
+ ...r.item,
58
+ positions: r.positions
59
+ }));
60
+ if (signal.aborted) {
61
+ reject(new DOMException(`Aborted`, `AbortError`));
62
+ return;
63
+ }
64
+ resolve(results);
65
+ });
66
+ }
67
+ //#endregion
68
+ //#region src/incr-search/incr-search.ts
69
+ function buildState(results, query) {
70
+ const positionsByItem = /* @__PURE__ */ new Map();
71
+ for (const r of results) positionsByItem.set(r.item, r.positions);
72
+ return Object.freeze({
73
+ query,
74
+ results: Object.freeze([...results]),
75
+ positionsByItem
76
+ });
77
+ }
78
+ function createIncrSearch(options = {}) {
79
+ let items = options.items ?? [];
80
+ let query = ``;
81
+ let fuzzy = createFuzzySearch(items, {
82
+ selector: options.selector,
83
+ fzf: options.fzf
84
+ });
85
+ let state = buildState(fuzzy.find(query), query);
86
+ let disposed = false;
87
+ const observers = /* @__PURE__ */ new Set();
88
+ if (options.onChange) observers.add(options.onChange);
89
+ const notify = (s) => {
90
+ for (const cb of observers) cb(s);
91
+ };
92
+ const run = async (q, signal) => {
93
+ await Promise.resolve();
94
+ if (signal?.aborted || disposed) return state;
95
+ const results = fuzzy.find(q);
96
+ if (signal?.aborted || disposed) return state;
97
+ state = buildState(results, q);
98
+ notify(state);
99
+ return state;
100
+ };
101
+ return {
102
+ get state() {
103
+ return state;
104
+ },
105
+ async setItems(newItems, signal) {
106
+ items = newItems;
107
+ fuzzy = createFuzzySearch(items, {
108
+ selector: options.selector,
109
+ fzf: options.fzf
110
+ });
111
+ await run(query, signal);
112
+ },
113
+ async setQuery(q, signal) {
114
+ query = q;
115
+ return run(q, signal);
116
+ },
117
+ async clearQuery() {
118
+ query = ``;
119
+ await run(``, void 0);
120
+ },
121
+ onChange(cb) {
122
+ observers.add(cb);
123
+ cb(state);
124
+ return () => {
125
+ observers.delete(cb);
126
+ };
127
+ },
128
+ dispose() {
129
+ disposed = true;
130
+ observers.clear();
131
+ items = [];
132
+ }
133
+ };
134
+ }
135
+ //#endregion
136
+ //#region src/incr-search/controller-tree.ts
137
+ const DEFAULT_HIGHLIGHT_KEY$1 = `incr-search-highlight`;
138
+ /**
139
+ * External-input filter-search controller for `TreeSearchHost` components.
140
+ *
141
+ * @deprecated `ixfx-tree-list` / `ixfx-miller-list` now have a built-in
142
+ * incremental-search popover with navigate / select / filter modes — invoke
143
+ * `el.registry.invoke('list.search.filter')` (see `docs/infra-incremental.md`)
144
+ * instead of wiring an external `<input>`. This class remains for existing
145
+ * consumers and covers filter mode only.
146
+ *
147
+ * @example Using with ixfx-tree-list:
148
+ * ```typescript
149
+ * const ctrl = new IncrSearchTreeController(myTreeList, {
150
+ * selector: item => item.node.item.label,
151
+ * });
152
+ * ctrl.connect();
153
+ * ```
154
+ *
155
+ * @example Using with ixfx-miller-list (different label selector):
156
+ * ```typescript
157
+ * const ctrl = new IncrSearchTreeController(myMillerList, {
158
+ * selector: item => item.node.item.label,
159
+ * labelSelector: '.label',
160
+ * });
161
+ * ctrl.connect();
162
+ * ```
163
+ */
164
+ var IncrSearchTreeController = class {
165
+ #el;
166
+ #opts;
167
+ #search;
168
+ #unsub;
169
+ #labelMap = /* @__PURE__ */ new Map();
170
+ constructor(el, opts) {
171
+ this.#el = el;
172
+ this.#opts = opts;
173
+ this.#search = createIncrSearch({
174
+ selector: opts.selector,
175
+ fzf: opts.fzf
176
+ });
177
+ }
178
+ connect() {
179
+ this.disconnect();
180
+ const items = this.#el.getNodes();
181
+ for (const item of items) this.#labelMap.set(this.#opts.selector(item), item);
182
+ this.#search.setItems(items);
183
+ if (this.#opts.input) {
184
+ this.#opts.input.addEventListener(`input`, this.#onInput);
185
+ this.#opts.input.addEventListener(`keydown`, this.#onKeydown);
186
+ }
187
+ this.#unsub = this.#search.onChange((state) => this.#onStateChange(state));
188
+ }
189
+ disconnect() {
190
+ if (this.#unsub) {
191
+ this.#unsub();
192
+ this.#unsub = void 0;
193
+ }
194
+ if (this.#opts.input) {
195
+ this.#opts.input.removeEventListener(`input`, this.#onInput);
196
+ this.#opts.input.removeEventListener(`keydown`, this.#onKeydown);
197
+ }
198
+ this.#el.filterPredicate = void 0;
199
+ clearHighlights({ key: this.#opts.highlightKey ?? DEFAULT_HIGHLIGHT_KEY$1 });
200
+ this.#labelMap.clear();
201
+ }
202
+ async setQuery(query) {
203
+ await this.#search.setQuery(query);
204
+ }
205
+ async clearQuery() {
206
+ await this.#search.clearQuery();
207
+ }
208
+ #onInput = () => {
209
+ const value = this.#opts.input?.value ?? ``;
210
+ this.setQuery(value);
211
+ };
212
+ #onKeydown = (e) => {
213
+ if (e.key === `Escape` && this.#opts.input) {
214
+ this.#opts.input.value = ``;
215
+ this.clearQuery();
216
+ }
217
+ };
218
+ #onStateChange(state) {
219
+ const matched = new Set(state.results.map((r) => r.item.node.item.key));
220
+ const hasQuery = state.query.trim().length > 0;
221
+ this.#el.filterPredicate = hasQuery && matched.size > 0 ? (node, _depth) => matched.has(node.item.key) : void 0;
222
+ const key = this.#opts.highlightKey ?? DEFAULT_HIGHLIGHT_KEY$1;
223
+ if (!this.#el.shadowRoot) return;
224
+ if (!state.query.trim()) {
225
+ clearHighlights({ key });
226
+ return;
227
+ }
228
+ const labelSelector = this.#opts.labelSelector ?? `.tree-label`;
229
+ const targets = buildHighlightTargets(this.#el.shadowRoot, labelSelector, state.positionsByItem, (labelEl) => {
230
+ const text = labelEl.textContent ?? ``;
231
+ return this.#labelMap.get(text);
232
+ }, (labelEl) => labelEl.textContent ?? ``);
233
+ applyHighlights(targets, { key });
234
+ }
235
+ };
236
+ //#endregion
237
+ //#region src/interaction/keyed-list.ts
238
+ /**
239
+ * Helpers for key-based selection on the Element-flavoured list components
240
+ * (`ixfx-vertical-list`, `ixfx-detail-list`, `ixfx-grid-list`).
241
+ *
242
+ * A "key" is a stable string the host puts on each item element — `data-key`
243
+ * (preferred) or `data-value`. It survives the host rebuilding the item set
244
+ * (re-fetch, re-sort, filter) where raw `Element` references do not.
245
+ */
246
+ /**
247
+ * The stable key for an item element: `data-key`, then `data-value`, else
248
+ * `undefined`.
249
+ */
250
+ function keyForElement(el) {
251
+ return el.getAttribute(`data-key`) ?? el.getAttribute(`data-value`) ?? void 0;
252
+ }
253
+ /**
254
+ * A lazily-built key → element index over a container. Rebuilt on first use
255
+ * and after any child-list mutation under `root`; `invalidate()` forces a
256
+ * rebuild on the next `get()`.
257
+ *
258
+ * ```ts
259
+ * #keyIndex = new KeyedElementIndex(this);
260
+ * // ...
261
+ * const el = this.#keyIndex.get(`row-42`);
262
+ * // on disconnect:
263
+ * this.#keyIndex.dispose();
264
+ * ```
265
+ */
266
+ var KeyedElementIndex = class {
267
+ #root;
268
+ #selector;
269
+ #map = /* @__PURE__ */ new Map();
270
+ #dirty = true;
271
+ #observer;
272
+ /**
273
+ * @param root Container whose descendants are indexed.
274
+ * @param itemSelector Which descendants count as items (default: any `[data-key]`).
275
+ */
276
+ constructor(root, itemSelector = `[data-key]`) {
277
+ this.#root = root;
278
+ this.#selector = itemSelector;
279
+ this.#observer = new MutationObserver(() => {
280
+ this.#dirty = true;
281
+ });
282
+ this.#observer.observe(root, {
283
+ childList: true,
284
+ subtree: true
285
+ });
286
+ }
287
+ /** The element with this key, or `undefined`. First match wins on duplicates. */
288
+ get(key) {
289
+ if (this.#dirty) this.#rebuild();
290
+ return this.#map.get(key);
291
+ }
292
+ /** Force a rebuild on the next `get()` — e.g. after a `data-key` attribute change. */
293
+ invalidate() {
294
+ this.#dirty = true;
295
+ }
296
+ /** Stop observing and drop the cache. */
297
+ dispose() {
298
+ this.#observer.disconnect();
299
+ this.#map.clear();
300
+ }
301
+ #rebuild() {
302
+ this.#map.clear();
303
+ for (const el of this.#root.querySelectorAll(this.#selector)) {
304
+ const key = keyForElement(el);
305
+ if (key !== void 0 && !this.#map.has(key)) this.#map.set(key, el);
306
+ }
307
+ this.#dirty = false;
308
+ }
309
+ };
310
+ //#endregion
311
+ //#region src/incr-search/element-list-search-adapter.ts
312
+ function defaultLabel(el) {
313
+ return el.getAttribute(`data-search-label`) ?? el.textContent?.trim() ?? ``;
314
+ }
315
+ /**
316
+ * A {@link ListSearchAdapter} over the light-DOM item elements of a list
317
+ * component. Filter mode toggles `hidden` (only on elements it hid itself, so a
318
+ * consumer's own `hidden` items are left alone). Highlights are painted on the
319
+ * item elements in the light DOM — the consuming page styles
320
+ * `::highlight(<highlightKey>)`. See `docs/infra-incremental.md`.
321
+ */
322
+ function createElementListSearchAdapter(host, options = {}) {
323
+ const itemSelector = options.itemSelector ?? `:scope > *`;
324
+ const getLabel = options.getLabel ?? defaultLabel;
325
+ const getKey = options.getKey ?? ((el) => keyForElement(el) ?? defaultLabel(el));
326
+ const hidden = /* @__PURE__ */ new Set();
327
+ const items = () => Array.from(host.querySelectorAll(itemSelector));
328
+ return {
329
+ getItems: items,
330
+ getLabel,
331
+ getKey,
332
+ focusItem: (el) => {
333
+ if (el) host.focusKey(getKey(el));
334
+ },
335
+ activateItem: (el) => {
336
+ el.dispatchEvent(new MouseEvent(`click`, {
337
+ bubbles: true,
338
+ composed: true
339
+ }));
340
+ },
341
+ selectItems: (els) => {
342
+ host.selectKeys(els.map(getKey));
343
+ },
344
+ applyFilter: (matched) => {
345
+ if (matched === void 0) {
346
+ for (const el of hidden) el.hidden = false;
347
+ hidden.clear();
348
+ return;
349
+ }
350
+ for (const el of items()) if (matched.has(getKey(el))) {
351
+ if (hidden.delete(el)) el.hidden = false;
352
+ } else {
353
+ el.hidden = true;
354
+ hidden.add(el);
355
+ }
356
+ },
357
+ highlightRoot: () => host,
358
+ highlightSelector: itemSelector,
359
+ highlightKeyOf: (el) => getKey(el),
360
+ highlightText: getLabel,
361
+ highlightKey: options.highlightKey,
362
+ container: () => host
363
+ };
364
+ }
365
+ //#endregion
366
+ //#region src/incr-search/element-search.ts
367
+ const defaultGetLabel = (el) => el.textContent?.trim() ?? ``;
368
+ function defaultGetKey(el) {
369
+ return el.getAttribute(`data-key`) ?? el.getAttribute(`data-value`) ?? el.textContent?.trim() ?? ``;
370
+ }
371
+ /**
372
+ * DOM filter-search over a container's item elements (sets `hidden` on
373
+ * non-matches, applies highlights).
374
+ *
375
+ * @deprecated The list components (`ixfx-grid-list`, `ixfx-vertical-list`,
376
+ * `ixfx-detail-list`, …) now have a built-in navigate / select / filter search
377
+ * popover — see `docs/infra-incremental.md` and `ListSearchController`. Kept for
378
+ * standalone / non-component use.
379
+ */
380
+ function createElementIncrSearch(opts) {
381
+ const { container, itemSelector, getLabel = defaultGetLabel, getKey = defaultGetKey, labelSelector, highlightKey } = opts;
382
+ let elementByKey = /* @__PURE__ */ new Map();
383
+ const search = createIncrSearch({
384
+ selector: (item) => item.label,
385
+ fzf: opts.fzf
386
+ });
387
+ const updateVisibility = (state) => {
388
+ const matchedKeys = new Set(state.results.map((r) => r.item.key));
389
+ const hasQuery = state.query.trim().length > 0;
390
+ for (const [key, el] of elementByKey) el.hidden = hasQuery ? !matchedKeys.has(key) : false;
391
+ };
392
+ const updateHighlights = (state) => {
393
+ if (!state.query.trim()) {
394
+ clearHighlights({ key: highlightKey });
395
+ return;
396
+ }
397
+ const targets = buildHighlightTargets(container, labelSelector ?? itemSelector, state.positionsByItem, (el) => {
398
+ const key = getKey(el);
399
+ return state.results.find((r) => r.item.key === key)?.item;
400
+ }, (el) => getLabel(el));
401
+ applyHighlights(targets, { key: highlightKey });
402
+ };
403
+ const unsubscribeState = search.onChange((state) => {
404
+ updateVisibility(state);
405
+ updateHighlights(state);
406
+ opts.onChange?.(state);
407
+ });
408
+ let refreshPending = false;
409
+ const refresh = () => {
410
+ if (refreshPending) return;
411
+ refreshPending = true;
412
+ Promise.resolve().then(() => {
413
+ refreshPending = false;
414
+ elementByKey = /* @__PURE__ */ new Map();
415
+ const els = container.querySelectorAll(itemSelector);
416
+ const items = [];
417
+ for (const el of els) {
418
+ const key = getKey(el);
419
+ elementByKey.set(key, el);
420
+ items.push({
421
+ key,
422
+ label: getLabel(el)
423
+ });
424
+ }
425
+ search.setItems(items);
426
+ });
427
+ };
428
+ const observer = new MutationObserver(() => refresh());
429
+ observer.observe(container, {
430
+ childList: true,
431
+ subtree: false
432
+ });
433
+ refresh();
434
+ return {
435
+ async setQuery(query, signal) {
436
+ await search.setQuery(query, signal);
437
+ },
438
+ async clearQuery() {
439
+ await search.clearQuery();
440
+ },
441
+ refresh,
442
+ onChange(cb) {
443
+ return search.onChange(cb);
444
+ },
445
+ dispose() {
446
+ observer.disconnect();
447
+ unsubscribeState();
448
+ search.dispose();
449
+ clearHighlights({ key: highlightKey });
450
+ }
451
+ };
452
+ }
453
+ //#endregion
454
+ //#region src/incr-search/list-search-commands.ts
455
+ /** Command ids registered by {@link registerListSearchCommands}, keyed by mode. */
456
+ const LIST_SEARCH_COMMAND_IDS = {
457
+ navigate: `list.search.navigate`,
458
+ select: `list.search.select`,
459
+ filter: `list.search.filter`
460
+ };
461
+ const LABELS = {
462
+ navigate: {
463
+ label: `Find`,
464
+ description: `Type to move the cursor to the best match`
465
+ },
466
+ select: {
467
+ label: `Find & Select`,
468
+ description: `Type to select every matching item`
469
+ },
470
+ filter: {
471
+ label: `Filter`,
472
+ description: `Type to hide items that don't match`
473
+ }
474
+ };
475
+ /**
476
+ * Register one command per mode the controller offers (see
477
+ * `controller.registeredModes`). Each command opens the search popover in its
478
+ * mode. Returns a function that unregisters them again — call it from
479
+ * `disconnectedCallback`. See `docs/infra-incremental.md`.
480
+ */
481
+ function registerListSearchCommands(registry, controller, options = {}) {
482
+ const id = (mode) => options.idPrefix ? `${options.idPrefix}.search.${mode}` : LIST_SEARCH_COMMAND_IDS[mode];
483
+ const registered = [];
484
+ for (const mode of controller.registeredModes) {
485
+ const commandId = id(mode);
486
+ if (registry.has(commandId)) continue;
487
+ registry.register({
488
+ id: commandId,
489
+ label: LABELS[mode].label,
490
+ description: LABELS[mode].description,
491
+ execute: () => controller.open(mode)
492
+ });
493
+ registered.push(commandId);
494
+ }
495
+ return () => {
496
+ for (const commandId of registered) registry.unregister(commandId);
497
+ };
498
+ }
499
+ //#endregion
500
+ //#region src/incr-search/list-search-controller.ts
501
+ /** Canonical order — used for the switch-row and to normalise a requested set. */
502
+ const LIST_SEARCH_MODES = [
503
+ `navigate`,
504
+ `select`,
505
+ `filter`
506
+ ];
507
+ const DEFAULT_HIGHLIGHT_KEY = `incr-search-highlight`;
508
+ const INPUT_CLASS = `list-search-input`;
509
+ /**
510
+ * Shared brain for the three-mode incremental-search popover. A Lit
511
+ * `ReactiveController`: the host adds `listSearchStyles` to `static styles`,
512
+ * drops `${controller.renderField()}` into `render()`, and registers the
513
+ * commands with {@link registerListSearchCommands}. See `docs/infra-incremental.md`.
514
+ */
515
+ var ListSearchController = class {
516
+ #host;
517
+ #adapter;
518
+ #modes;
519
+ #placeholder;
520
+ #search;
521
+ #unsub;
522
+ #observer;
523
+ #itemByKey = /* @__PURE__ */ new Map();
524
+ #open = false;
525
+ #mode = `filter`;
526
+ #query = ``;
527
+ #matchIndex = 0;
528
+ #matches = [];
529
+ /** Whether a filter has been pushed to the adapter and still needs undoing. */
530
+ #filterActive = false;
531
+ constructor(host, options) {
532
+ this.#host = host;
533
+ host.addController(this);
534
+ this.#adapter = options.adapter;
535
+ const requested = new Set(options.modes ?? LIST_SEARCH_MODES);
536
+ this.#modes = LIST_SEARCH_MODES.filter((m) => requested.has(m));
537
+ this.#mode = this.#modes[0] ?? `filter`;
538
+ this.#placeholder = options.placeholder ?? `Search…`;
539
+ this.#search = createIncrSearch({
540
+ selector: (i) => i.label,
541
+ fzf: options.fzf
542
+ });
543
+ this.#unsub = this.#search.onChange((state) => this.#onState(state));
544
+ }
545
+ /** `false` when no modes are available — the host should register no commands and render no field. */
546
+ get enabled() {
547
+ return this.#modes.length > 0;
548
+ }
549
+ /** The modes actually offered (component capability ∩ consumer request). */
550
+ get registeredModes() {
551
+ return this.#modes;
552
+ }
553
+ /**
554
+ * Replace the offered mode set (normalised to {@link LIST_SEARCH_MODES}
555
+ * order). If the current mode is dropped the popover closes and falls back to
556
+ * the first remaining mode. Callers should re-run
557
+ * {@link registerListSearchCommands} afterwards.
558
+ */
559
+ setModes(modes) {
560
+ const set = new Set(modes);
561
+ this.#modes = LIST_SEARCH_MODES.filter((m) => set.has(m));
562
+ if (!this.#modes.includes(this.#mode)) {
563
+ if (this.#open) this.close();
564
+ this.#mode = this.#modes[0] ?? `filter`;
565
+ }
566
+ this.#host.requestUpdate();
567
+ }
568
+ get isOpen() {
569
+ return this.#open;
570
+ }
571
+ get mode() {
572
+ return this.#mode;
573
+ }
574
+ get query() {
575
+ return this.#query;
576
+ }
577
+ get matchCount() {
578
+ return this.#matches.length;
579
+ }
580
+ get matchIndex() {
581
+ return this.#matchIndex;
582
+ }
583
+ hostConnected() {
584
+ const container = this.#adapter.container?.();
585
+ if (container) {
586
+ this.#observer = new MutationObserver(() => this.refresh());
587
+ this.#observer.observe(container, {
588
+ childList: true,
589
+ subtree: false
590
+ });
591
+ }
592
+ this.refresh();
593
+ }
594
+ hostDisconnected() {
595
+ this.#observer?.disconnect();
596
+ this.#observer = void 0;
597
+ if (this.#open) this.#teardownEffects();
598
+ this.#open = false;
599
+ this.#unsub?.();
600
+ this.#unsub = void 0;
601
+ this.#search.dispose();
602
+ }
603
+ /** Open the popover in `mode` (clamped to the available set). */
604
+ open(mode) {
605
+ if (!this.enabled) return;
606
+ this.#mode = this.#modes.includes(mode) ? mode : this.#modes[0];
607
+ this.#open = true;
608
+ this.#query = ``;
609
+ this.#matchIndex = 0;
610
+ this.#matches = [];
611
+ this.refresh();
612
+ this.#search.setQuery(``);
613
+ this.#host.requestUpdate();
614
+ this.#host.updateComplete.then(() => this.#focusInput());
615
+ }
616
+ /** Close the popover, restoring any live filter. Selection / cursor stay put. */
617
+ close() {
618
+ if (!this.#open) return;
619
+ this.#open = false;
620
+ this.#teardownEffects();
621
+ this.#query = ``;
622
+ this.#matches = [];
623
+ this.#search.clearQuery();
624
+ this.#host.requestUpdate();
625
+ this.#host.focus();
626
+ }
627
+ setQuery(query) {
628
+ this.#query = query;
629
+ this.#matchIndex = 0;
630
+ this.#search.setQuery(query);
631
+ }
632
+ /** Re-interpret the current query under a new mode (undoing the old mode's effect). */
633
+ switchMode(next) {
634
+ if (!this.#modes.includes(next) || next === this.#mode) return;
635
+ if (this.#mode === `filter` && this.#filterActive) {
636
+ this.#adapter.applyFilter(void 0);
637
+ this.#filterActive = false;
638
+ }
639
+ this.#mode = next;
640
+ this.#applyMode();
641
+ this.#host.requestUpdate();
642
+ this.#host.updateComplete.then(() => this.#focusInput());
643
+ }
644
+ /** One-shot: turn the current match set into the selection, staying in the current mode. */
645
+ selectMatches() {
646
+ if (!this.#modes.includes(`select`)) return;
647
+ this.#adapter.selectItems(this.#currentItems());
648
+ this.#host.requestUpdate();
649
+ }
650
+ nextMatch() {
651
+ this.#cycle(1);
652
+ }
653
+ prevMatch() {
654
+ this.#cycle(-1);
655
+ }
656
+ /** Enter: in `navigate` activate the current match; then close. */
657
+ commit() {
658
+ if (this.#mode === `navigate`) {
659
+ const item = this.#itemFor(this.#matches[this.#matchIndex]);
660
+ if (item) {
661
+ if (this.#adapter.activateItem) this.#adapter.activateItem(item);
662
+ else this.#adapter.focusItem(item);
663
+ }
664
+ }
665
+ this.close();
666
+ }
667
+ /** Rebuild the searchable item set from the adapter. Called on open and on container mutation. */
668
+ refresh() {
669
+ this.#itemByKey.clear();
670
+ const items = [];
671
+ for (const it of this.#adapter.getItems()) {
672
+ const key = this.#adapter.getKey(it);
673
+ this.#itemByKey.set(key, it);
674
+ items.push({
675
+ key,
676
+ label: this.#adapter.getLabel(it)
677
+ });
678
+ }
679
+ this.#search.setItems(items);
680
+ }
681
+ /** Popover markup. Pair with `listSearchStyles` in the host's `static styles`. */
682
+ renderField() {
683
+ if (!this.enabled) return nothing;
684
+ const hasQuery = this.#query.trim().length > 0;
685
+ const others = this.#modes.filter((m) => m !== this.#mode);
686
+ const showSelectMatches = this.#modes.includes(`select`) && this.#mode !== `select`;
687
+ return html`
688
+ <div class="list-search" part="search" ?hidden=${!this.#open}>
689
+ <div class="list-search-row">
690
+ <span class="list-search-mode">${this.#mode}</span>
691
+ <input
692
+ class=${INPUT_CLASS}
693
+ type="text"
694
+ part="search-input"
695
+ placeholder=${this.#placeholder}
696
+ .value=${this.#query}
697
+ @input=${this.#onInput}
698
+ @keydown=${this.#onKeydown}
699
+ />
700
+ ${hasQuery ? html`<span class="list-search-count">${this.#matches.length === 0 ? `0` : this.#matchIndex + 1} / ${this.#matches.length}</span>` : nothing}
701
+ ${this.#mode === `navigate` && this.#matches.length > 1 ? html`
702
+ <button class="list-search-icon-btn" title="Previous match (↑)" @click=${() => this.prevMatch()}>‹</button>
703
+ <button class="list-search-icon-btn" title="Next match (↓)" @click=${() => this.nextMatch()}>›</button>
704
+ ` : nothing}
705
+ <button class="list-search-icon-btn" title="Close (Esc)" @click=${() => this.close()}>✕</button>
706
+ </div>
707
+ ${others.length > 0 || showSelectMatches ? html`
708
+ <div class="list-search-switch">
709
+ ${others.map((m) => html`<button class="list-search-btn" @click=${() => this.switchMode(m)}>${m}</button>`)}
710
+ ${showSelectMatches ? html`<button class="list-search-btn" @click=${() => this.selectMatches()}>Select matches</button>` : nothing}
711
+ </div>
712
+ ` : nothing}
713
+ </div>
714
+ `;
715
+ }
716
+ #focusInput() {
717
+ this.#host.renderRoot?.querySelector(`.${INPUT_CLASS}`)?.focus();
718
+ }
719
+ #onInput = (e) => {
720
+ this.setQuery(e.target.value);
721
+ };
722
+ #onKeydown = (e) => {
723
+ switch (e.key) {
724
+ case `Escape`:
725
+ e.preventDefault();
726
+ e.stopPropagation();
727
+ this.close();
728
+ break;
729
+ case `Enter`:
730
+ e.preventDefault();
731
+ e.stopPropagation();
732
+ this.commit();
733
+ break;
734
+ case `ArrowDown`:
735
+ if (this.#mode === `navigate`) {
736
+ e.preventDefault();
737
+ this.nextMatch();
738
+ }
739
+ break;
740
+ case `ArrowUp`: if (this.#mode === `navigate`) {
741
+ e.preventDefault();
742
+ this.prevMatch();
743
+ }
744
+ }
745
+ };
746
+ #cycle(delta) {
747
+ if (this.#matches.length === 0) return;
748
+ const n = this.#matches.length;
749
+ this.#matchIndex = (this.#matchIndex + delta + n) % n;
750
+ if (this.#mode === `navigate`) this.#adapter.focusItem(this.#itemFor(this.#matches[this.#matchIndex]));
751
+ this.#host.requestUpdate();
752
+ }
753
+ #itemFor(match) {
754
+ return match ? this.#itemByKey.get(match.item.key) : void 0;
755
+ }
756
+ #currentItems() {
757
+ return this.#matches.map((m) => this.#itemFor(m)).filter((x) => x !== void 0);
758
+ }
759
+ get #highlightKey() {
760
+ return this.#adapter.highlightKey ?? DEFAULT_HIGHLIGHT_KEY;
761
+ }
762
+ #onState(state) {
763
+ if (!this.#open) return;
764
+ const hasQuery = state.query.trim().length > 0;
765
+ this.#matches = hasQuery ? [...state.results] : [];
766
+ if (this.#matchIndex >= this.#matches.length) this.#matchIndex = 0;
767
+ this.#applyMode();
768
+ this.#host.requestUpdate();
769
+ }
770
+ #applyMode() {
771
+ if (!(this.#query.trim().length > 0)) {
772
+ if (this.#filterActive) {
773
+ this.#adapter.applyFilter(void 0);
774
+ this.#filterActive = false;
775
+ }
776
+ this.#applyHighlights();
777
+ return;
778
+ }
779
+ switch (this.#mode) {
780
+ case `navigate`:
781
+ this.#adapter.focusItem(this.#itemFor(this.#matches[this.#matchIndex]));
782
+ break;
783
+ case `select`:
784
+ this.#adapter.selectItems(this.#currentItems());
785
+ break;
786
+ case `filter`:
787
+ this.#adapter.applyFilter(new Set(this.#matches.map((m) => m.item.key)));
788
+ this.#filterActive = true;
789
+ }
790
+ this.#applyHighlights();
791
+ }
792
+ #applyHighlights() {
793
+ const root = this.#adapter.highlightRoot?.();
794
+ const selector = this.#adapter.highlightSelector;
795
+ const keyOf = this.#adapter.highlightKeyOf;
796
+ if (!root || !selector || !keyOf) return;
797
+ if (this.#query.trim().length === 0) {
798
+ clearHighlights({ key: this.#highlightKey });
799
+ return;
800
+ }
801
+ const state = this.#search.state;
802
+ const targets = buildHighlightTargets(root, selector, state.positionsByItem, (el) => {
803
+ const key = keyOf(el);
804
+ return key === void 0 ? void 0 : state.results.find((r) => r.item.key === key)?.item;
805
+ }, this.#adapter.highlightText ?? ((el) => el.textContent ?? ``));
806
+ try {
807
+ applyHighlights(targets, { key: this.#highlightKey });
808
+ } catch {
809
+ clearHighlights({ key: this.#highlightKey });
810
+ }
811
+ }
812
+ #teardownEffects() {
813
+ if (this.#filterActive) {
814
+ this.#adapter.applyFilter(void 0);
815
+ this.#filterActive = false;
816
+ }
817
+ clearHighlights({ key: this.#highlightKey });
818
+ }
819
+ };
820
+ //#endregion
821
+ //#region src/incr-search/list-search-styles.ts
822
+ /**
823
+ * Styles for the incremental-search popover rendered by
824
+ * `ListSearchController.renderField()`. Add to a component's `static styles`;
825
+ * the class names are the contract between this fragment and `renderField()`.
826
+ *
827
+ * The host must give the popover a positioned ancestor (a wrapper with
828
+ * `position: relative`, or `:host { position: relative }`). Tunables:
829
+ * `--list-search-inset` (default `8px`) offsets it from the top-right corner.
830
+ *
831
+ * See `docs/infra-incremental.md`.
832
+ */
833
+ const listSearchStyles = css`
834
+ .list-search {
835
+ position: absolute;
836
+ top: var(--list-search-inset, 8px);
837
+ right: var(--list-search-inset, 8px);
838
+ z-index: 20;
839
+ display: flex;
840
+ flex-direction: column;
841
+ gap: 4px;
842
+ padding: 6px;
843
+ box-sizing: border-box;
844
+ background: var(--surface-4, #2a2a2a);
845
+ color: var(--surface-3-text, inherit);
846
+ border: 1px solid var(--border, #444);
847
+ border-radius: var(--radius-m, 6px);
848
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
849
+ font-size: var(--text-s, 0.85em);
850
+ }
851
+
852
+ .list-search[hidden] {
853
+ display: none;
854
+ }
855
+
856
+ .list-search-row {
857
+ display: flex;
858
+ align-items: center;
859
+ gap: 4px;
860
+ }
861
+
862
+ .list-search-mode {
863
+ flex: none;
864
+ padding: 1px 7px;
865
+ border-radius: 999px;
866
+ font-weight: 600;
867
+ text-transform: capitalize;
868
+ background: var(--accent, #60a5fa);
869
+ color: var(--accent-text, #000);
870
+ }
871
+
872
+ .list-search-input {
873
+ flex: 1;
874
+ min-width: 140px;
875
+ background: transparent;
876
+ border: none;
877
+ outline: none;
878
+ color: inherit;
879
+ font: inherit;
880
+ }
881
+
882
+ .list-search-count {
883
+ flex: none;
884
+ opacity: 0.6;
885
+ font-variant-numeric: tabular-nums;
886
+ white-space: nowrap;
887
+ }
888
+
889
+ .list-search-switch {
890
+ display: flex;
891
+ flex-wrap: wrap;
892
+ gap: 4px;
893
+ }
894
+
895
+ .list-search-btn {
896
+ padding: 1px 7px;
897
+ background: var(--surface-3, #333);
898
+ color: inherit;
899
+ border: 1px solid var(--border, #444);
900
+ border-radius: var(--radius-s, 4px);
901
+ cursor: pointer;
902
+ font: inherit;
903
+ text-transform: capitalize;
904
+ }
905
+
906
+ .list-search-btn:hover {
907
+ background: var(--item-bg-tickled, #444);
908
+ }
909
+
910
+ .list-search-icon-btn {
911
+ flex: none;
912
+ padding: 0 2px;
913
+ background: transparent;
914
+ border: none;
915
+ color: var(--surface-muted-text, #888);
916
+ cursor: pointer;
917
+ font-size: 1.1em;
918
+ line-height: 1;
919
+ }
920
+
921
+ .list-search-icon-btn:hover {
922
+ color: inherit;
923
+ }
924
+ `;
925
+ //#endregion
926
+ //#region src/incr-search/tree-list-search-adapter.ts
927
+ /**
928
+ * A {@link ListSearchAdapter} over any `TreeSearchHost` (`ixfx-tree-list`,
929
+ * `ixfx-miller-list`, `ixfx-crumb-navigation`). Navigate reveals the match
930
+ * (expands its ancestors, moves the cursor); select drives `selectMany`; filter
931
+ * sets `filterPredicate`. The item set is the whole tree — `getNodes()` returns
932
+ * every node regardless of expansion or an active filter — so refining a query
933
+ * can always re-surface a node. See `docs/infra-incremental.md`.
934
+ */
935
+ function createTreeListSearchAdapter(host, options = {}) {
936
+ return {
937
+ getItems: () => host.getNodes(),
938
+ getLabel: options.getLabel ?? ((item) => item.node.item.label),
939
+ getKey: (item) => item.node.item.key,
940
+ focusItem: (item) => {
941
+ if (item) host.revealKey(item.node.item.key);
942
+ },
943
+ activateItem: (item) => {
944
+ host.revealKey(item.node.item.key);
945
+ host.select(item.node);
946
+ },
947
+ selectItems: (items) => {
948
+ host.selectMany(items.map((i) => i.node));
949
+ },
950
+ applyFilter: (matched) => {
951
+ host.filterPredicate = matched === void 0 ? void 0 : (node) => matched.has(node.item.key);
952
+ },
953
+ highlightRoot: () => host.shadowRoot,
954
+ highlightSelector: options.labelSelector ?? `.tree-label`,
955
+ highlightKeyOf: (el) => el.closest(`[data-key]`)?.dataset.key,
956
+ highlightKey: options.highlightKey
957
+ };
958
+ }
959
+ //#endregion
960
+ export { LIST_SEARCH_COMMAND_IDS as a, createElementListSearchAdapter as c, IncrSearchTreeController as d, createIncrSearch as f, ListSearchController as i, KeyedElementIndex as l, createFuzzySearch as m, listSearchStyles as n, registerListSearchCommands as o, createAcCompletionProvider as p, LIST_SEARCH_MODES as r, createElementIncrSearch as s, createTreeListSearchAdapter as t, keyForElement as u };
961
+
962
+ //# sourceMappingURL=incr-search-DpbSJtgj.js.map