@lekoala/combobox 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +566 -0
  3. package/custom-elements.json +247 -0
  4. package/dist/combobox.css +447 -0
  5. package/dist/combobox.js +2220 -0
  6. package/dist/combobox.min.css +1 -0
  7. package/dist/combobox.min.js +2 -0
  8. package/dist/types/combo-box.d.ts +112 -0
  9. package/dist/types/combo-box.d.ts.map +1 -0
  10. package/dist/types/combobox.d.ts +530 -0
  11. package/dist/types/combobox.d.ts.map +1 -0
  12. package/dist/types/define.d.ts +2 -0
  13. package/dist/types/define.d.ts.map +1 -0
  14. package/dist/types/helpers.d.ts +251 -0
  15. package/dist/types/helpers.d.ts.map +1 -0
  16. package/dist/types/index.d.ts +24 -0
  17. package/dist/types/index.d.ts.map +1 -0
  18. package/dist/types/locales/de.d.ts +7 -0
  19. package/dist/types/locales/de.d.ts.map +1 -0
  20. package/dist/types/locales/en.d.ts +8 -0
  21. package/dist/types/locales/en.d.ts.map +1 -0
  22. package/dist/types/locales/es.d.ts +7 -0
  23. package/dist/types/locales/es.d.ts.map +1 -0
  24. package/dist/types/locales/fr.d.ts +7 -0
  25. package/dist/types/locales/fr.d.ts.map +1 -0
  26. package/dist/types/locales/it.d.ts +7 -0
  27. package/dist/types/locales/it.d.ts.map +1 -0
  28. package/dist/types/locales/nl.d.ts +7 -0
  29. package/dist/types/locales/nl.d.ts.map +1 -0
  30. package/dist/types/locales/pt.d.ts +7 -0
  31. package/dist/types/locales/pt.d.ts.map +1 -0
  32. package/dist/types/locales/ru.d.ts +7 -0
  33. package/dist/types/locales/ru.d.ts.map +1 -0
  34. package/dist/types/locales/zh-CN.d.ts +7 -0
  35. package/dist/types/locales/zh-CN.d.ts.map +1 -0
  36. package/dist/types/messages.d.ts +48 -0
  37. package/dist/types/messages.d.ts.map +1 -0
  38. package/package.json +92 -0
  39. package/src/combo-box.js +316 -0
  40. package/src/combobox.css +447 -0
  41. package/src/combobox.js +2975 -0
  42. package/src/define.js +20 -0
  43. package/src/helpers.js +377 -0
  44. package/src/index.js +35 -0
  45. package/src/locales/de.js +17 -0
  46. package/src/locales/en.js +18 -0
  47. package/src/locales/es.js +17 -0
  48. package/src/locales/fr.js +17 -0
  49. package/src/locales/it.js +17 -0
  50. package/src/locales/nl.js +17 -0
  51. package/src/locales/pt.js +17 -0
  52. package/src/locales/ru.js +17 -0
  53. package/src/locales/zh-CN.js +17 -0
  54. package/src/messages.js +55 -0
@@ -0,0 +1,2975 @@
1
+ import {
2
+ hasOwn,
3
+ matchesField,
4
+ moveValueInOrder,
5
+ normalize,
6
+ rankByScore,
7
+ reconcileSelected,
8
+ splitTokens,
9
+ toItem,
10
+ } from "./helpers.js";
11
+ import { DEFAULT_MESSAGES, getDefaultMessages, setDefaultMessages } from "./messages.js";
12
+
13
+ /* ---------------------------------------------------------------------- */
14
+ /* Public type contracts */
15
+ /* ---------------------------------------------------------------------- */
16
+
17
+ /**
18
+ * A selectable source: a free-form `<input list>` or a `<select>`.
19
+ * @typedef {HTMLInputElement | HTMLSelectElement} ComboboxSource
20
+ */
21
+
22
+ /**
23
+ * Matching/search strategy for a combobox.
24
+ * @typedef {"includes" | "startswith" | "fuzzy" | "pattern"} MatchStrategy
25
+ */
26
+
27
+ /**
28
+ * Context passed to filtering, matching, scoring and sorting hooks, plus the
29
+ * async load/create callbacks.
30
+ * @typedef {Object} ComboboxContext
31
+ * @property {Combobox} combobox
32
+ * @property {ComboboxSource} source
33
+ * @property {HTMLInputElement} input The live search/filter input
34
+ */
35
+
36
+ /**
37
+ * Load context for the async `load` callback.
38
+ * @typedef {Object} LoadContext
39
+ * @property {AbortSignal} signal
40
+ * @property {string | null} cursor
41
+ * @property {Combobox} combobox
42
+ * @property {ComboboxSource} source
43
+ * @property {HTMLInputElement} input
44
+ */
45
+
46
+ /**
47
+ * Result of an async load: either a bare item list or `{ items, cursor }` for
48
+ * paged/append-only loading.
49
+ * @typedef {{ items: import("./helpers.js").ComboboxItem[], cursor: string | null } | import("./helpers.js").ComboboxItem[]} LoadResult
50
+ */
51
+
52
+ /**
53
+ * async load callback.
54
+ * @typedef {(query: string, context: LoadContext) => Promise<LoadResult>} LoadCallback
55
+ */
56
+
57
+ /**
58
+ * Context passed to the create and guards callbacks.
59
+ * @typedef {Object} CreateContext
60
+ * @property {AbortSignal} signal
61
+ * @property {Combobox} combobox
62
+ * @property {ComboboxSource} source
63
+ * @property {HTMLInputElement} input
64
+ * @property {boolean} [fallback] True when running on the native fallback path
65
+ */
66
+
67
+ /**
68
+ * async create callback.
69
+ * @typedef {(label: string, context: CreateContext) => Promise<any>} CreateCallback
70
+ */
71
+
72
+ /**
73
+ * Guard callback for `add`/`remove`/`clear`. A resolved `false` refuses the
74
+ * change; any other resolved value allows it. A thrown/rejected value is
75
+ * surfaced as an application error (guards distinguish `false` from rejection).
76
+ * @typedef {(payload: any, context: CreateContext) => Promise<boolean | void> | boolean | void} GuardCallback
77
+ */
78
+
79
+ /**
80
+ * Custom tokenizer seam: `tokens` are complete consumed values; `rest` is the
81
+ * trailing incomplete text that must keep living in the input.
82
+ * @typedef {(value: string, context: ComboboxContext) => { tokens: string[], rest?: string }} TokenizeCallback
83
+ */
84
+
85
+ /**
86
+ * Render context for the `render.*` hooks.
87
+ * @typedef {Object} RenderContext
88
+ * @property {Combobox} combobox
89
+ * @property {string} [query]
90
+ * @property {boolean} [selected]
91
+ * @property {*} [error]
92
+ */
93
+
94
+ /**
95
+ * A renderer returns a DOM Node for rich content (strings render as text).
96
+ * Item renderers (`option`, `item`, `group`) receive the item; the row
97
+ * renderers (`create`, `noResults`, `loading`, `error`) receive the query or
98
+ * the load error instead.
99
+ * @typedef {(item: import("./helpers.js").ComboboxItem, context: RenderContext) => Node | string | null | undefined} ItemRenderer
100
+ * @typedef {(query: string, context: RenderContext) => Node | string | null | undefined} TextRenderer
101
+ * @typedef {(query: string, context: RenderContext & { error: any }) => Node | string | null | undefined} ErrorRenderer
102
+ */
103
+
104
+ /** @type {WeakMap<ComboboxSource, Combobox>} */
105
+ const instances = new WeakMap();
106
+ let uid = 0;
107
+ /** @type {Combobox | null} */
108
+ let openCombobox = null;
109
+
110
+ // Generated UI text lives in messages.js (`render` is the DOM-representation
111
+ // seam and stays separate; behavior options are above it).
112
+ const DEFAULTS = {
113
+ create: false,
114
+ allowEmptyOption: false,
115
+ placeholder: "Search…",
116
+ messages: DEFAULT_MESSAGES,
117
+ match: "includes", // Open UI-aligned: includes | startswith | fuzzy | pattern | function
118
+ searchFields: ["label"],
119
+ minChars: 0,
120
+ load: null,
121
+ loadOnEmpty: false,
122
+ shouldLoad: null,
123
+ debounce: 200,
124
+ createFilter: null,
125
+ maxItems: 0, // 0 = unlimited: cap on selected values, never on rendering
126
+ maxOptions: 0, // 0 = unlimited: cap on rendered options only
127
+ separators: [],
128
+ tokenize: null, // custom seam: (value, ctx) => { tokens: string[], rest?: string }
129
+ closeOnSelect: undefined, // default: single closes, multiple stays open
130
+ createOnBlur: false,
131
+ autoselectFirst: false,
132
+ tabSelect: false, // when true, Tab commits the active option like Enter
133
+ labelField: undefined,
134
+ valueField: undefined,
135
+ guards: {}, // async add/remove/clear guards
136
+ selectionOrder: "source", // source | selected
137
+ observeSource: false, // opt-in MutationObserver -> debounced sync()
138
+ sort: null,
139
+ score: null,
140
+ filter: null,
141
+ render: {},
142
+ anchor: null,
143
+ };
144
+
145
+ function supportsModernCombobox() {
146
+ return (
147
+ typeof HTMLElement.prototype.showPopover === "function" &&
148
+ typeof HTMLElement.prototype.hidePopover === "function" &&
149
+ CSS.supports("position-area: bottom") &&
150
+ CSS.supports("inline-size: anchor-size(width)") &&
151
+ CSS.supports("position-try: flip-block")
152
+ );
153
+ }
154
+
155
+ /**
156
+ * @param {EventTarget} target
157
+ * @param {string} type
158
+ * @param {Record<string, any>} [detail]
159
+ * @param {{ cancelable?: boolean }} [options]
160
+ * @returns {CustomEvent}
161
+ */
162
+ function emit(target, type, detail = {}, { cancelable = false } = {}) {
163
+ const event = new CustomEvent(type, {
164
+ bubbles: true,
165
+ cancelable,
166
+ detail,
167
+ });
168
+
169
+ // Open UI's proposed beforefilter exposes event.query directly. Mirror that
170
+ // now while retaining CustomEvent.detail for ordinary library consumers.
171
+ if (hasOwn(detail, "query")) {
172
+ Object.defineProperty(event, "query", {
173
+ configurable: true,
174
+ enumerable: true,
175
+ value: detail.query,
176
+ });
177
+ }
178
+
179
+ target.dispatchEvent(event);
180
+ return event;
181
+ }
182
+
183
+ /**
184
+ * @param {number} ms
185
+ * @param {AbortSignal} [signal]
186
+ * @returns {Promise<void>}
187
+ */
188
+ function wait(ms, signal) {
189
+ return new Promise((resolve, reject) => {
190
+ const timer = setTimeout(resolve, ms);
191
+ signal?.addEventListener(
192
+ "abort",
193
+ () => {
194
+ clearTimeout(timer);
195
+ reject(new DOMException("Aborted", "AbortError"));
196
+ },
197
+ { once: true },
198
+ );
199
+ });
200
+ }
201
+
202
+ /**
203
+ * @param {HTMLElement} element
204
+ * @param {Node | string | null | undefined} content
205
+ */
206
+ function setContent(element, content) {
207
+ element.replaceChildren();
208
+ if (content instanceof Node) {
209
+ element.append(content);
210
+ } else if (content !== null && content !== undefined) {
211
+ // Strings are text by default. Rich HTML should be returned as DOM Nodes,
212
+ // which keeps the default renderer safe without a global allowHtml mode.
213
+ element.textContent = String(content);
214
+ }
215
+ }
216
+
217
+ /** Stable, font-independent icon for generated remove buttons. */
218
+ function createRemoveIcon() {
219
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
220
+ svg.setAttribute("viewBox", "0 0 20 20");
221
+ svg.setAttribute("aria-hidden", "true");
222
+ svg.setAttribute("focusable", "false");
223
+
224
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
225
+ path.setAttribute("d", "M4 4l12 12m0-12L4 16");
226
+ svg.append(path);
227
+ return svg;
228
+ }
229
+
230
+ /**
231
+ * Snapshot a set of attributes on an element we do not own so `dispose()` can
232
+ * restore the authored state exactly. The returned object has a `restore()`
233
+ * method: attributes that were absent are removed, those that had a value are
234
+ * written back. This makes upgrade/dispose symmetry impossible to forget.
235
+ *
236
+ * @param {Element} element
237
+ * @param {string[]} names
238
+ * @returns {AttributeSnapshot}
239
+ */
240
+ function captureAttributes(element, names) {
241
+ const original = new Map(names.map((name) => [name, element.getAttribute(name)]));
242
+ return {
243
+ restore() {
244
+ for (const [name, value] of original) {
245
+ if (value === null) element.removeAttribute(name);
246
+ else element.setAttribute(name, value);
247
+ }
248
+ },
249
+ };
250
+ }
251
+
252
+ // Every attribute the engine may touch on an input it does not own (the source
253
+ // input for input+datalist, or an authored filter input). Snapshot before
254
+ // mutation, restore on dispose().
255
+ const INPUT_ATTRS = [
256
+ "list",
257
+ "name",
258
+ "type",
259
+ "autocomplete",
260
+ "spellcheck",
261
+ "placeholder",
262
+ "hidden",
263
+ "tabindex",
264
+ "role",
265
+ "aria-autocomplete",
266
+ "aria-expanded",
267
+ "aria-controls",
268
+ "aria-activedescendant",
269
+ "aria-invalid",
270
+ "aria-label",
271
+ "aria-labelledby",
272
+ "aria-required",
273
+ "aria-describedby",
274
+ "style",
275
+ ];
276
+
277
+ /**
278
+ * Optional bookkeeping snapshot of one element's attributes so `dispose()` can
279
+ * restore the authored state exactly.
280
+ * @typedef {Object} AttributeSnapshot
281
+ * @property {() => void} restore Restore the captured attributes (removes what
282
+ * was absent, rewrites what had a value)
283
+ */
284
+
285
+ /**
286
+ * UI status messages (noResults/loading/loadError text, create/position label
287
+ * producers). See messages.js for the canonical catalog and the locale
288
+ * registry.
289
+ * @typedef {import("./messages.js").Messages} Messages
290
+ */
291
+
292
+ /**
293
+ * Optional DOM-representation hooks. `option`/`group`/`item`/`create`/
294
+ * `noResults`/`loading`/`error` render the respective rows; a returned DOM
295
+ * Node is inserted for rich content, anything else (strings) is text.
296
+ * @typedef {Object} RenderMap
297
+ * @property {ItemRenderer} [option]
298
+ * @property {TextRenderer} [group]
299
+ * @property {ItemRenderer} [item]
300
+ * @property {TextRenderer} [create]
301
+ * @property {TextRenderer} [noResults]
302
+ * @property {TextRenderer} [loading]
303
+ * @property {ErrorRenderer} [error]
304
+ */
305
+
306
+ /**
307
+ * Async guards keyed by lifecycle phase. `false` is a voluntary refusal that
308
+ * mutates nothing; a rejected promise is an application error surfaced via
309
+ * `combobox:guarderror`.
310
+ * @typedef {Object} GuardMap
311
+ * @property {GuardCallback} [add]
312
+ * @property {GuardCallback} [remove]
313
+ * @property {GuardCallback} [clear]
314
+ */
315
+
316
+ /**
317
+ * Combobox configuration. All properties are optional; the engine deep-merges
318
+ * with its defaults. Callbacks are the JavaScript-only surface.
319
+ * @typedef {Object} ComboboxOptions
320
+ * @property {MatchStrategy | ((item: import("./helpers.js").ComboboxItem, query: string, context: ComboboxContext) => boolean)} [match]
321
+ * @property {string | string[]} [searchFields]
322
+ * @property {number} [minChars]
323
+ * @property {boolean} [allowEmptyOption]
324
+ * @property {string} [placeholder]
325
+ * @property {Messages} [messages]
326
+ * @property {LoadCallback} [load]
327
+ * @property {boolean} [loadOnEmpty]
328
+ * @property {(query: string, context: ComboboxContext) => boolean} [shouldLoad]
329
+ * @property {number} [debounce]
330
+ * @property {(value: string, context: ComboboxContext) => boolean} [createFilter]
331
+ * @property {boolean | CreateCallback} [create]
332
+ * @property {number} [maxItems]
333
+ * @property {number} [maxOptions]
334
+ * @property {string[]} [separators]
335
+ * @property {TokenizeCallback} [tokenize]
336
+ * @property {boolean} [closeOnSelect]
337
+ * @property {boolean} [createOnBlur]
338
+ * @property {boolean} [autoselectFirst]
339
+ * @property {boolean} [tabSelect]
340
+ * @property {string} [labelField]
341
+ * @property {string} [valueField]
342
+ * @property {GuardMap} [guards]
343
+ * @property {"source" | "selected"} [selectionOrder]
344
+ * @property {boolean} [observeSource]
345
+ * @property {RenderMap} [render]
346
+ * @property {HTMLElement} [anchor] Consumer-authored positioning/control region
347
+ * @property {(a: import("./helpers.js").ComboboxItem, b: import("./helpers.js").ComboboxItem, query: string, context: ComboboxContext) => number} [sort]
348
+ * @property {(item: import("./helpers.js").ComboboxItem, query: string, context: ComboboxContext) => number | false | null} [score]
349
+ * @property {(item: import("./helpers.js").ComboboxItem, query: string, context: ComboboxContext) => boolean} [filter]
350
+ */
351
+
352
+ /**
353
+ * Internal options after the defaults merge. Every defaulted field is present
354
+ * and readable without optional-chaining; the nullable seams (`load`, `create`
355
+ * and friends) stay nullable and are always guarded by `typeof === "function"`.
356
+ * @typedef {ComboboxOptions & {
357
+ * match: MatchStrategy | ((item: import("./helpers.js").ComboboxItem, query: string, context: ComboboxContext) => boolean),
358
+ * searchFields: string | string[],
359
+ * minChars: number,
360
+ * allowEmptyOption: boolean,
361
+ * placeholder: string,
362
+ * messages: Messages,
363
+ * loadOnEmpty: boolean,
364
+ * debounce: number,
365
+ * maxItems: number,
366
+ * maxOptions: number,
367
+ * separators: string[],
368
+ * createOnBlur: boolean,
369
+ * autoselectFirst: boolean,
370
+ * tabSelect: boolean,
371
+ * guards: GuardMap,
372
+ * selectionOrder: "source" | "selected",
373
+ * observeSource: boolean,
374
+ * render: RenderMap,
375
+ * load: LoadCallback | null,
376
+ * create: boolean | CreateCallback,
377
+ * createFilter: ((value: string, context: ComboboxContext) => boolean) | null,
378
+ * shouldLoad: ((query: string, context: ComboboxContext) => boolean) | null,
379
+ * tokenize: TokenizeCallback | null,
380
+ * sort: ((a: import("./helpers.js").ComboboxItem, b: import("./helpers.js").ComboboxItem, query: string, context: ComboboxContext) => number) | null,
381
+ * score: ((item: import("./helpers.js").ComboboxItem, query: string, context: ComboboxContext) => number | false | null) | null,
382
+ * filter: ((item: import("./helpers.js").ComboboxItem, query: string, context: ComboboxContext) => boolean) | null,
383
+ * anchor: HTMLElement | null,
384
+ * }} ResolvedOptions
385
+ */
386
+
387
+ /**
388
+ * State produced by the two initialisation branches (select vs input+datalist).
389
+ * Both return every property so the constructor receives a uniform view; the
390
+ * null-able entries are genuinely branch- or mode-conditional.
391
+ * @typedef {Object} ViewState
392
+ * @property {HTMLElement | null} control Wrapper control (select-only; null for
393
+ * an input whose source input is itself the control)
394
+ * @property {HTMLInputElement} input
395
+ * @property {HTMLElement | null} chips Only meaningful for a select
396
+ * @property {HTMLDataListElement | null} datalist Only meaningful for an input
397
+ * @property {AttributeSnapshot | null} inputSnapshot
398
+ * @property {AttributeSnapshot | null} sourceSnapshot
399
+ */
400
+
401
+ /**
402
+ * Native-first combobox / filterable-select skeleton.
403
+ *
404
+ * The source element is always the form-value owner:
405
+ * - <input list>: the original input owns arbitrary text.
406
+ * - <select>: the original select owns one constrained value.
407
+ * - <select multiple>: selected <option>s own multiple values.
408
+ *
409
+ * The modern select filter input is a separate, unnamed interaction control.
410
+ * It may be generated, or supplied explicitly through a liaison attribute on
411
+ * the input itself: <input data-filter-for="select-id" hidden>.
412
+ */
413
+ export class Combobox {
414
+ static supported = supportsModernCombobox();
415
+
416
+ /**
417
+ * Read the current default UI messages. Returns a shallow copy; mutating
418
+ * the result does not affect the engine.
419
+ * @returns {Messages}
420
+ */
421
+ static getDefaultMessages() {
422
+ return getDefaultMessages();
423
+ }
424
+
425
+ /**
426
+ * Merge application or locale-provided UI text into the default messages.
427
+ * Called by the shipped `locales/*` modules on import. Only comboboxes
428
+ * created *after* this call see the new text: instances resolve their
429
+ * messages as a snapshot at construction time. Per-instance `messages`
430
+ * options always take precedence over these defaults. Missing keys keep
431
+ * their current translation, and producer keys (`create`, `position`) stay
432
+ * functions.
433
+ * @param {Partial<Messages>} messages
434
+ * @returns {void}
435
+ */
436
+ static setDefaultMessages(messages) {
437
+ setDefaultMessages(messages);
438
+ }
439
+
440
+ /**
441
+ * Discover and enhance combobox sources. This is a discovery/creation API
442
+ * only: an element that already has an instance is returned as-is and never
443
+ * reconfigured with new options (idempotence is unambiguous).
444
+ *
445
+ * Valid shapes:
446
+ * init("selector") / init("selector", options)
447
+ * init(root, "selector") / init(root, "selector", options)
448
+ * init([element, ...], options) / init(nodeList, options)
449
+ *
450
+ * Discovery is always explicit: a string root is a CSS selector, an
451
+ * Element/Document root is a scope for the selector, and any other iterable
452
+ * is a list of source elements. An element root without a selector and a bare
453
+ * init() discover nothing (there is no implicit `data-*` marker). Unsupported
454
+ * elements inside collections are ignored without invalidating the call.
455
+ * Returns the array of Combobox instances.
456
+ *
457
+ * @param {string | ParentNode | Iterable<EventTarget> | null | undefined} rootOrSelector
458
+ * @param {string | ComboboxOptions | null | undefined} selectorOrOptions
459
+ * @param {ComboboxOptions} maybeOptions
460
+ * @returns {Combobox[]}
461
+ */
462
+ static init(rootOrSelector = document, selectorOrOptions = null, maybeOptions = {}) {
463
+ /** @type {ComboboxSource[]} */
464
+ const targets = [];
465
+ /** @type {ComboboxOptions} */
466
+ let options = {};
467
+
468
+ /**
469
+ * A root either is a Node (scope for a selector) or a collection of sources.
470
+ * @param {unknown} value
471
+ * @returns {value is Node}
472
+ */
473
+ const isNode = (value) => value instanceof Node;
474
+ /**
475
+ * Accept only plain option objects (not selectors, elements or arrays).
476
+ * @param {unknown} value
477
+ * @returns {ComboboxOptions}
478
+ */
479
+ const picks = (value) =>
480
+ value !== null && typeof value === "object" && !isNode(value) && !Array.isArray(value)
481
+ ? /** @type {ComboboxOptions} */ (value)
482
+ : {};
483
+
484
+ if (typeof rootOrSelector === "string") {
485
+ targets.push(.../** @type {Iterable<ComboboxSource>} */ (document.querySelectorAll(rootOrSelector)));
486
+ options = picks(selectorOrOptions);
487
+ } else if (isNode(rootOrSelector)) {
488
+ const root = /** @type {ParentNode} */ (rootOrSelector);
489
+ if (typeof selectorOrOptions === "string") {
490
+ targets.push(.../** @type {Iterable<ComboboxSource>} */ (root.querySelectorAll(selectorOrOptions)));
491
+ options = maybeOptions;
492
+ } else {
493
+ // An element root needs an explicit selector; options alone do not pick
494
+ // targets. This keeps `data-*` discovery out of the API.
495
+ options = picks(selectorOrOptions);
496
+ }
497
+ } else {
498
+ targets.push(.../** @type {Iterable<ComboboxSource>} */ (Array.from(rootOrSelector ?? [])));
499
+ options = picks(selectorOrOptions);
500
+ }
501
+
502
+ /** @type {Combobox[]} */
503
+ const instances = [];
504
+ for (const element of targets) {
505
+ if (!(element instanceof HTMLInputElement || element instanceof HTMLSelectElement)) continue;
506
+ const instance = Combobox.getOrCreateInstance(element, options);
507
+ if (instance && !instances.includes(instance)) instances.push(instance);
508
+ }
509
+ return instances;
510
+ }
511
+
512
+ /**
513
+ * @param {ComboboxSource} element
514
+ * @returns {Combobox | null}
515
+ */
516
+ static getInstance(element) {
517
+ return instances.get(element) ?? null;
518
+ }
519
+
520
+ /**
521
+ * @param {ComboboxSource} element
522
+ * @param {ComboboxOptions} options
523
+ * @returns {Combobox}
524
+ */
525
+ static getOrCreateInstance(element, options = {}) {
526
+ return Combobox.getInstance(element) ?? new Combobox(element, options);
527
+ }
528
+
529
+ /**
530
+ * @param {ComboboxSource} element
531
+ * @param {ComboboxOptions} options
532
+ */
533
+ constructor(element, options = {}) {
534
+ if (!(element instanceof HTMLInputElement || element instanceof HTMLSelectElement)) {
535
+ throw new TypeError("Combobox expects an input or select element");
536
+ }
537
+
538
+ /** @type {ComboboxSource} */
539
+ this.source = element;
540
+ this.isSelect = element instanceof HTMLSelectElement;
541
+ this.isMultiple = this.isSelect && element.multiple;
542
+ this.abortController = new AbortController();
543
+ /** @type {AbortController | null} */
544
+ this.loadController = null;
545
+ this.activeIndex = -1;
546
+ /** @type {import("./helpers.js").ComboboxItem[]} */
547
+ this.filteredItems = [];
548
+ // Remote/custom results are deliberately transient. The native select is
549
+ // the selection/value owner, not a cache for every server result.
550
+ /** @type {import("./helpers.js").ComboboxItem[] | null} */
551
+ this.results = null;
552
+ // For a <select>, option identity is the HTMLOptionElement itself; a
553
+ // duplicate `value` does not collapse identities. `selectionOrder` holds
554
+ // option references (in source order initially), `value` is payload only.
555
+ /** @type {HTMLOptionElement[]} */
556
+ this.selectionOrder = this.isSelect ? Array.from(this.#selectSource().selectedOptions) : [];
557
+ /** @type {WeakMap<HTMLElement, HTMLOptionElement>} */
558
+ this._chipOptions = new WeakMap();
559
+ this.searchGeneration = 0;
560
+ /** @type {string | null} */
561
+ this.nextCursor = null;
562
+ this.loading = false;
563
+ /** @type {*} */
564
+ this.loadError = null;
565
+ this.query = "";
566
+ this.id = ++uid;
567
+ this.mode =
568
+ /** @type {ComboboxOptions & { mode?: "fallback" }} */ (options).mode === "fallback" ||
569
+ !Combobox.supported
570
+ ? "fallback"
571
+ : "enhanced";
572
+ this.anchorName = `--combobox-${this.id}`;
573
+ this.suppressReopen = false;
574
+ this.composing = false;
575
+ /** @type {MutationObserver | null} */
576
+ this._sourceObserver = null;
577
+ /** @type {ReturnType<typeof setTimeout> | null} */
578
+ this._sourceSyncTimer = null;
579
+
580
+ /** @type {ComboboxOptions} */
581
+ this.explicitOptions = options;
582
+ /** @type {ResolvedOptions} */
583
+ this.options = /** @type {ResolvedOptions} */ ({
584
+ ...DEFAULTS,
585
+ ...options,
586
+ messages: {
587
+ ...DEFAULT_MESSAGES,
588
+ ...(options.messages || {}),
589
+ },
590
+ render: {
591
+ ...DEFAULTS.render,
592
+ ...(options.render || {}),
593
+ },
594
+ });
595
+
596
+ this.original = {
597
+ // explicit filter input
598
+ /** @type {Comment | null} */
599
+ filterInputPlaceholder: null,
600
+ // detached datalist position marker
601
+ /** @type {Comment | null} */
602
+ datalistPlaceholder: null,
603
+ // <label> elements whose id the engine invented for aria-labelledby
604
+ /** @type {Array<{ label: HTMLLabelElement, id: string }>} */
605
+ inventedLabels: [],
606
+ };
607
+ /** @type {HTMLLabelElement[]} */
608
+ this.boundLabels = [];
609
+ this.ownsInput = false;
610
+ /** @type {HTMLElement | null} */
611
+ this.fallbackControl = null;
612
+
613
+ /** @type {HTMLElement | null} */
614
+ this.control = null;
615
+ /** @type {HTMLElement | null} */
616
+ this.anchor = null;
617
+ /** @type {AttributeSnapshot | null} */
618
+ this.anchorSnapshot = null;
619
+ /** @type {HTMLInputElement | null} */
620
+ this.input = null;
621
+ /** @type {HTMLElement | null} */
622
+ this.chips = null;
623
+ /** @type {HTMLDataListElement | null} */
624
+ this.datalist = null;
625
+ /** @type {AttributeSnapshot | null} */
626
+ this.inputSnapshot = null;
627
+ /** @type {AttributeSnapshot | null} */
628
+ this.sourceSnapshot = null;
629
+ /** @type {HTMLElement | null} */
630
+ this.popover = null;
631
+ /** @type {HTMLElement | null} */
632
+ this.listbox = null;
633
+ /** @type {HTMLElement | null} */
634
+ this.status = null;
635
+
636
+ if (this.mode === "fallback") {
637
+ this.#initFallback();
638
+ } else {
639
+ // Registration is transactional: an element whose init throws (e.g. a
640
+ // broken datalist) must not stay associated with a half-built instance.
641
+ try {
642
+ const view =
643
+ element instanceof HTMLSelectElement ? this.#enhanceSelect(element) : this.#enhanceInput(element);
644
+ this.control = view.control;
645
+ this.input = view.input;
646
+ this.chips = view.chips;
647
+ this.datalist = view.datalist;
648
+ this.inputSnapshot = view.inputSnapshot;
649
+ this.sourceSnapshot = view.sourceSnapshot;
650
+
651
+ const requestedAnchor = this.options.anchor;
652
+ this.anchor = requestedAnchor instanceof HTMLElement ? requestedAnchor : view.control || view.input;
653
+ if (this.anchor !== view.control && this.anchor !== view.input) {
654
+ this.anchorSnapshot = captureAttributes(this.anchor, ["style"]);
655
+ }
656
+ this.anchor.style.setProperty("anchor-name", this.anchorName);
657
+
658
+ const picker = this.#createPicker();
659
+ this.popover = picker.popover;
660
+ this.listbox = picker.listbox;
661
+ this.status = picker.status;
662
+
663
+ this.#bind();
664
+ this.refresh();
665
+ this.#watchSource();
666
+ } catch (error) {
667
+ this.dispose();
668
+ throw error;
669
+ }
670
+ }
671
+
672
+ instances.set(element, this);
673
+ }
674
+
675
+ /**
676
+ * Discriminate the source to a `<select>`. A select-backed combobox is the
677
+ * only context that calls the select-only operations, so this throws when
678
+ * the invariant is violated rather than casting (an unchecked cast would
679
+ * silently lie to the checker).
680
+ * @returns {HTMLSelectElement}
681
+ */
682
+ #selectSource() {
683
+ if (!(this.source instanceof HTMLSelectElement)) {
684
+ throw new TypeError("Expected a select-backed combobox");
685
+ }
686
+ return this.source;
687
+ }
688
+
689
+ /**
690
+ * The interaction input. Only enhanced instances call this, and enhanced
691
+ * construction always creates the input — an absent input is an invariant
692
+ * violation, not a normal runtime condition.
693
+ * @returns {HTMLInputElement}
694
+ */
695
+ #inputEl() {
696
+ return /** @type {HTMLInputElement} */ (this.input);
697
+ }
698
+
699
+ /**
700
+ * The popover picker root. Enhanced instances always have one.
701
+ * @returns {HTMLElement}
702
+ */
703
+ #popoverEl() {
704
+ return /** @type {HTMLElement} */ (this.popover);
705
+ }
706
+
707
+ /**
708
+ * The listbox container. Enhanced instances always have one.
709
+ * @returns {HTMLElement}
710
+ */
711
+ #listEl() {
712
+ return /** @type {HTMLElement} */ (this.listbox);
713
+ }
714
+
715
+ /**
716
+ * The live status region. Enhanced instances always have one.
717
+ * @returns {HTMLElement}
718
+ */
719
+ #statusEl() {
720
+ return /** @type {HTMLElement} */ (this.status);
721
+ }
722
+
723
+ /**
724
+ * The chips container. Only select-backed enhanced instances have chips.
725
+ * @returns {HTMLElement | null}
726
+ */
727
+ #chipsEl() {
728
+ return this.chips;
729
+ }
730
+
731
+ /* ---------------------------------------------------------------------- */
732
+ /* Progressive fallback */
733
+ /* ---------------------------------------------------------------------- */
734
+
735
+ #initFallback() {
736
+ // Native input+datalist and native selects already work. The only cheap
737
+ // enhancement worth retaining is creation: keep the native multiple
738
+ // select visible and add an unnamed text input + Add button.
739
+ if (!this.isSelect || !this.options.create) return;
740
+
741
+ const control = document.createElement("div");
742
+ control.className = "cb-fallback-create";
743
+
744
+ const input = document.createElement("input");
745
+ input.type = "text";
746
+ input.className = "cb-fallback-input";
747
+ input.placeholder = this.options.placeholder ?? "";
748
+ input.autocomplete = "off";
749
+ input.setAttribute("aria-label", this.options.placeholder ?? "");
750
+ // Deliberately no name: the select remains the only successful control.
751
+
752
+ const button = document.createElement("button");
753
+ button.type = "button";
754
+ button.className = "cb-fallback-add";
755
+ button.textContent = "Add";
756
+
757
+ const add = async () => {
758
+ const label = input.value.trim();
759
+ if (!this.#canCreate(label)) return;
760
+ await this.#createFallbackOption(label, input);
761
+ input.value = "";
762
+ input.focus();
763
+ };
764
+
765
+ button.addEventListener("click", add, { signal: this.abortController.signal });
766
+ input.addEventListener(
767
+ "keydown",
768
+ (event) => {
769
+ if (event.key === "Enter") {
770
+ event.preventDefault();
771
+ add();
772
+ }
773
+ },
774
+ { signal: this.abortController.signal },
775
+ );
776
+
777
+ control.append(input, button);
778
+ this.source.after(control);
779
+ this.fallbackControl = control;
780
+ }
781
+
782
+ /**
783
+ * Native-fallback creation: run the guard/create pipeline and materialize the
784
+ * option on the visible select.
785
+ * @param {string} label
786
+ * @param {HTMLInputElement} input The fallback Add control
787
+ * @returns {Promise<HTMLOptionElement | null>}
788
+ */
789
+ async #createFallbackOption(label, input) {
790
+ const guard = await this.#runGuard("add", { label });
791
+ if (!guard.ok) return null;
792
+
793
+ const before = emit(
794
+ this.source,
795
+ "combobox:beforecreate",
796
+ { combobox: this, label },
797
+ { cancelable: true },
798
+ );
799
+ if (before.defaultPrevented) return null;
800
+
801
+ /** @type {import("./helpers.js").ComboboxItem} */
802
+ let created = { value: label, label };
803
+ try {
804
+ if (typeof this.options.create === "function") {
805
+ const result = await this.options.create(label, {
806
+ signal: this.abortController.signal,
807
+ combobox: this,
808
+ source: this.source,
809
+ input,
810
+ fallback: true,
811
+ });
812
+ if (!result) return null;
813
+ created = /** @type {import("./helpers.js").ComboboxItem} */ (toItem(result, this.#fields()));
814
+ }
815
+
816
+ let option = this.#findOption(created.value);
817
+ if (!option) {
818
+ // Creation changes live form state, never the form-reset baseline.
819
+ option = new Option(created.label, created.value, false, true);
820
+ if (created.data) Object.assign(option.dataset, created.data);
821
+ this.#selectSource().add(option);
822
+ } else {
823
+ option.selected = true;
824
+ }
825
+ this.#rememberSelection(option);
826
+ this.#dispatchNativeValueEvents();
827
+ emit(this.source, "combobox:create", { combobox: this, item: { ...created, option, selected: true } });
828
+ return option;
829
+ } catch (/** @type {any} */ error) {
830
+ if (error?.name !== "AbortError") {
831
+ emit(this.source, "combobox:createerror", { combobox: this, label, error });
832
+ }
833
+ return null;
834
+ }
835
+ }
836
+
837
+ /* ---------------------------------------------------------------------- */
838
+ /* Source adapters */
839
+ /* ---------------------------------------------------------------------- */
840
+
841
+ /**
842
+ * Prepare the input+datalist view. The input is its own control (there is no
843
+ * wrapper), so `control` is null and `input` is the source itself.
844
+ * @param {HTMLInputElement} source
845
+ * @returns {ViewState}
846
+ */
847
+ #enhanceInput(source) {
848
+ const listId = source.getAttribute("list");
849
+ if (!listId) throw new TypeError("Input combobox expects an input with a datalist");
850
+
851
+ const datalist = document.getElementById(listId);
852
+ if (!(datalist instanceof HTMLDataListElement)) {
853
+ throw new TypeError(`No datalist found for #${listId}`);
854
+ }
855
+
856
+ const inputSnapshot = captureAttributes(source, INPUT_ATTRS);
857
+
858
+ // In enhanced mode the datalist is a data source only. Detach it so the UA
859
+ // picker can never flash/race our popover. dispose() restores it exactly.
860
+ source.removeAttribute("list");
861
+ source.autocomplete = "off";
862
+ const datalistPlaceholder = document.createComment(`combobox-datalist-${this.id}`);
863
+ this.original.datalistPlaceholder = datalistPlaceholder;
864
+ datalist.before(datalistPlaceholder);
865
+ datalist.remove();
866
+
867
+ source.classList.add("cb-text-control");
868
+ return {
869
+ control: null,
870
+ input: source,
871
+ chips: null,
872
+ datalist,
873
+ inputSnapshot,
874
+ sourceSnapshot: null,
875
+ };
876
+ }
877
+
878
+ /**
879
+ * Prepare the select view: a wrapper control holding chips and the filter
880
+ * input, plus a snapshot of the untouched source attributes.
881
+ * @param {HTMLSelectElement} source
882
+ * @returns {ViewState}
883
+ */
884
+ #enhanceSelect(source) {
885
+ source.classList.add("cb-source-hidden");
886
+ const sourceSnapshot = captureAttributes(source, ["aria-hidden", "tabindex"]);
887
+ source.tabIndex = -1;
888
+ source.setAttribute("aria-hidden", "true");
889
+
890
+ const control = document.createElement("div");
891
+ control.className = `cb-control ${this.isMultiple ? "cb-control-multiple" : "cb-control-single"}`;
892
+ const chips = document.createElement("span");
893
+ chips.className = "cb-chips";
894
+ control.append(chips);
895
+
896
+ const { input, inputSnapshot } = this.#resolveFilterInput();
897
+ input.classList.add("cb-input");
898
+ input.type = "text";
899
+ input.autocomplete = "off";
900
+ input.spellcheck = false;
901
+ input.removeAttribute("name");
902
+
903
+ if (!input.placeholder) input.placeholder = this.options.placeholder ?? "";
904
+
905
+ this.#copyAccessibleName(input);
906
+ control.append(input);
907
+ source.after(control);
908
+
909
+ return {
910
+ control,
911
+ input,
912
+ chips,
913
+ datalist: null,
914
+ inputSnapshot,
915
+ sourceSnapshot,
916
+ };
917
+ }
918
+
919
+ /**
920
+ * Resolve the interaction filter input, preferring an author-supplied one
921
+ * declared via `<input data-filter-for="select-id">`. Returns the input together with
922
+ * the snapshot needed by dispose() to restore the authored state.
923
+ * @returns {{ input: HTMLInputElement, inputSnapshot: AttributeSnapshot | null }}
924
+ */
925
+ #resolveFilterInput() {
926
+ let input = null;
927
+ // An author-supplied filter input is declared with a liaison attribute on
928
+ // the input itself: <input data-filter-for="select-id">. Configuration stays
929
+ // on <combo-box> attributes or JS — the source select never carries data-*.
930
+ if (this.source.id) {
931
+ input = document.querySelector(`input[data-filter-for="${CSS.escape(this.source.id)}"]`);
932
+ }
933
+
934
+ if (input instanceof HTMLInputElement) {
935
+ const inputSnapshot = captureAttributes(input, INPUT_ATTRS);
936
+ const filterInputPlaceholder = document.createComment(`combobox-filter-input-${this.id}`);
937
+ this.original.filterInputPlaceholder = filterInputPlaceholder;
938
+ input.before(filterInputPlaceholder);
939
+ input.hidden = false;
940
+ return { input, inputSnapshot };
941
+ }
942
+
943
+ this.ownsInput = true;
944
+ // An owned control is disposed with the wrapper: there is nothing to restore.
945
+ return { input: document.createElement("input"), inputSnapshot: null };
946
+ }
947
+
948
+ /**
949
+ * Copy the source select's accessible name/description/required state onto
950
+ * the interaction input. Must run before the input is attached to the
951
+ * control so aria-labelledby/aria-describedby reference live labels.
952
+ * @param {HTMLInputElement} input
953
+ */
954
+ #copyAccessibleName(input) {
955
+ // The source's own aria-labelledby (e.g. a grid naming its filter select
956
+ // after the column header) is the authoritative accessible name — an
957
+ // association that no <label> traversal can rediscover. Only when it is
958
+ // absent do we fall back to derived labels, then aria-label.
959
+ const labelledBy = this.source.getAttribute("aria-labelledby");
960
+ if (labelledBy) {
961
+ input.setAttribute("aria-labelledby", labelledBy);
962
+ } else {
963
+ this.#copyLabeledNames(input);
964
+ }
965
+
966
+ const ariaLabel = this.source.getAttribute("aria-label");
967
+ if (!input.hasAttribute("aria-labelledby") && ariaLabel) {
968
+ input.setAttribute("aria-label", ariaLabel);
969
+ }
970
+ if (this.source.required) input.setAttribute("aria-required", "true");
971
+ const describedBy = this.source.getAttribute("aria-describedby");
972
+ if (describedBy) {
973
+ input.setAttribute("aria-describedby", describedBy);
974
+ }
975
+ }
976
+
977
+ /**
978
+ * Derive the accessible name from <label> associations: explicit
979
+ * `label[for=source]` links plus a wrapping label. Invented ids are recorded
980
+ * so dispose() can strip them again.
981
+ * @param {HTMLInputElement} input
982
+ */
983
+ #copyLabeledNames(input) {
984
+ const labels = [];
985
+ if (this.source.id) {
986
+ labels.push(...document.querySelectorAll(`label[for="${CSS.escape(this.source.id)}"]`));
987
+ }
988
+ // A label wrapping the select (no `for`) still names it accessibly. Clicks
989
+ // are already redirected to the filter input by the focus forwarding on the
990
+ // hidden select; here we propagate the name itself.
991
+ const wrapped = this.source.closest("label");
992
+ if (wrapped) labels.push(wrapped);
993
+
994
+ const seen = new Set();
995
+ this.boundLabels = /** @type {HTMLLabelElement[]} */ (
996
+ labels.filter((label) => {
997
+ if (seen.has(label)) return false;
998
+ seen.add(label);
999
+ return true;
1000
+ })
1001
+ );
1002
+
1003
+ const labelIds = this.boundLabels.map((label, index) => {
1004
+ if (!label.id) {
1005
+ label.id = `combobox-label-${this.id}-${index}`;
1006
+ this.original.inventedLabels.push({ label, id: label.id });
1007
+ }
1008
+ return label.id;
1009
+ });
1010
+ if (labelIds.length) input.setAttribute("aria-labelledby", labelIds.join(" "));
1011
+ }
1012
+
1013
+ #sourceItems() {
1014
+ if (this.isSelect) {
1015
+ return Array.from(this.#selectSource().options)
1016
+ .filter((option) => option.value || this.options.allowEmptyOption)
1017
+ .map((option) => ({
1018
+ value: option.value,
1019
+ label: option.textContent.trim(),
1020
+ disabled:
1021
+ option.disabled ||
1022
+ (option.parentElement instanceof HTMLOptGroupElement ? option.parentElement.disabled : false),
1023
+ selected: option.selected,
1024
+ group: option.parentElement instanceof HTMLOptGroupElement ? option.parentElement.label : "",
1025
+ option,
1026
+ data: { ...option.dataset },
1027
+ }));
1028
+ }
1029
+
1030
+ if (!this.datalist) return [];
1031
+ return Array.from(this.datalist.options).map((option) => ({
1032
+ value: option.value,
1033
+ label: option.label || option.value,
1034
+ disabled: option.disabled,
1035
+ selected: this.source.value === option.value,
1036
+ group: option.dataset.group || "",
1037
+ option,
1038
+ data: { ...option.dataset },
1039
+ }));
1040
+ }
1041
+
1042
+ #items() {
1043
+ if (!this.results) return this.#sourceItems();
1044
+
1045
+ if (!this.isSelect) return this.results;
1046
+
1047
+ return this.results.map((item) => {
1048
+ // Option identity is the element, never the value string, so transient
1049
+ // items resolve their selected state through their exact option. A plain
1050
+ // value falls back to the first matching source option.
1051
+ const option = item.option || this.#findOption(item.value);
1052
+ return { ...item, selected: option?.selected ?? false, option };
1053
+ });
1054
+ }
1055
+
1056
+ /** Map data objects to canonical items when label/value fields are set. */
1057
+ #fields() {
1058
+ const { labelField, valueField } = this.options;
1059
+ return labelField || valueField ? { labelField, valueField } : null;
1060
+ }
1061
+
1062
+ // maxOptions is a rendering cap only: the result store (filteredItems) may
1063
+ // be large, but at most maxOptions options are ever rendered/navigated.
1064
+ get visibleItems() {
1065
+ return this.options.maxOptions > 0
1066
+ ? this.filteredItems.slice(0, this.options.maxOptions)
1067
+ : this.filteredItems;
1068
+ }
1069
+
1070
+ /** Set transient picker results without turning the select into a remote cache. */
1071
+ /**
1072
+ * @param {Array<any>} items
1073
+ * @returns {this}
1074
+ */
1075
+ setResults(items) {
1076
+ this.results = Array.from(items || [], (item) => toItem(item, this.#fields())).filter(
1077
+ (item) => item !== null,
1078
+ );
1079
+ return this;
1080
+ }
1081
+
1082
+ clearResults() {
1083
+ this.results = null;
1084
+ // A failed load is scoped to the newest search: any later local query,
1085
+ // successful load or explicit clear drops the stale error row.
1086
+ this.loadError = null;
1087
+ return this;
1088
+ }
1089
+
1090
+ /**
1091
+ * @param {*} value
1092
+ * @returns {HTMLOptionElement | null}
1093
+ */
1094
+ #findOption(value) {
1095
+ if (!this.isSelect) return null;
1096
+ const select = this.#selectSource();
1097
+ return Array.from(select.options).find((option) => option.value === String(value)) || null;
1098
+ }
1099
+
1100
+ /**
1101
+ * Resolve a plain value to the option a fresh selection should land on: the
1102
+ * first non-disabled match, skipping already-selected options in multiple
1103
+ * mode (each native option is selected at most once; identical values on
1104
+ * distinct options are distinct choices). Single-select returns the first
1105
+ * non-disabled match regardless of the current selection.
1106
+ * @param {*} value
1107
+ * @returns {HTMLOptionElement | null}
1108
+ */
1109
+ #findSelectableOption(value) {
1110
+ if (!this.isSelect) return null;
1111
+ const wanted = String(value);
1112
+ return (
1113
+ Array.from(this.#selectSource().options).find(
1114
+ (option) =>
1115
+ option.value === wanted && !option.disabled && (this.isMultiple && option.selected) === false,
1116
+ ) || null
1117
+ );
1118
+ }
1119
+
1120
+ /** Match a token to an existing native option by value or label. */
1121
+ /**
1122
+ * @param {string} label
1123
+ * @returns {import("./helpers.js").ComboboxItem | null}
1124
+ */
1125
+ #findCreateMatch(label) {
1126
+ const lookup = normalize(label);
1127
+ for (const item of this.#sourceItems()) {
1128
+ if (normalize(item.value) === lookup || normalize(item.label) === lookup) return item;
1129
+ }
1130
+ return null;
1131
+ }
1132
+
1133
+ /** Replace the native catalogue explicitly. Prefer setResults() for remote search. */
1134
+ /**
1135
+ * @param {Array<any>} items
1136
+ * @param {{ preserveSelected?: boolean }} [options]
1137
+ * @returns {this}
1138
+ */
1139
+ setOptions(items, { preserveSelected = this.isSelect } = {}) {
1140
+ const normalized = Array.from(items || [], (item) => toItem(item, this.#fields())).filter(
1141
+ (item) => item !== null,
1142
+ );
1143
+
1144
+ if (this.isSelect) {
1145
+ const select = this.#selectSource();
1146
+ /** @type {import("./helpers.js").ComboboxItem[]} */
1147
+ const preserved = preserveSelected
1148
+ ? Array.from(select.selectedOptions).map((option) => ({
1149
+ value: option.value,
1150
+ label: option.textContent.trim(),
1151
+ selected: true,
1152
+ disabled: option.disabled,
1153
+ group: option.parentElement instanceof HTMLOptGroupElement ? option.parentElement.label : "",
1154
+ }))
1155
+ : [];
1156
+
1157
+ const emptyOption = Array.from(select.options).find((option) => !option.value);
1158
+ select.replaceChildren();
1159
+ if (emptyOption && !this.isMultiple) select.append(emptyOption);
1160
+
1161
+ // No value-based dedupe: catalogue identity is the <option> element, so
1162
+ // repeated values in the payload map to their own options.
1163
+ const catalog = [...preserved, ...normalized];
1164
+
1165
+ const groups = new Map();
1166
+ for (const item of catalog) {
1167
+ // An empty value is a legitimate option only when allowEmptyOption
1168
+ // admits it; otherwise it would shadow the collection's real entries.
1169
+ if (!item.value && !this.options.allowEmptyOption) continue;
1170
+ const option = new Option(item.label, item.value, Boolean(item.selected), Boolean(item.selected));
1171
+ option.disabled = Boolean(item.disabled);
1172
+ if (item.data) Object.assign(option.dataset, item.data);
1173
+
1174
+ if (item.group) {
1175
+ let group = groups.get(item.group);
1176
+ if (!group) {
1177
+ group = document.createElement("optgroup");
1178
+ group.label = item.group;
1179
+ groups.set(item.group, group);
1180
+ select.append(group);
1181
+ }
1182
+ group.append(option);
1183
+ } else {
1184
+ select.append(option);
1185
+ }
1186
+ }
1187
+ } else {
1188
+ if (!this.datalist) return this;
1189
+ this.datalist.replaceChildren();
1190
+ for (const item of normalized) {
1191
+ const option = document.createElement("option");
1192
+ option.value = item.value;
1193
+ if (item.label !== item.value) option.label = item.label;
1194
+ if (item.data) Object.assign(option.dataset, item.data);
1195
+ this.datalist.append(option);
1196
+ }
1197
+ }
1198
+
1199
+ this.clearResults();
1200
+ if (this.mode === "enhanced") this.refresh();
1201
+ this.#markEngineMutation();
1202
+ return this;
1203
+ }
1204
+
1205
+ /** Explicit sync point for external DOM mutations. */
1206
+ sync() {
1207
+ // External source mutations invalidate transient results unless the caller
1208
+ // explicitly sets them again. This keeps catalogue and result-store roles clear.
1209
+ this.clearResults();
1210
+ this.refresh();
1211
+ return this;
1212
+ }
1213
+
1214
+ /**
1215
+ * Engine-driven native mutations are never observed. The engine drops the
1216
+ * observer (discarding queued records and any pending debounced sync) right
1217
+ * before it re-renders from the native source, and reconnects on the next
1218
+ * microtask. A refresh after an engine mutation re-reads the source anyway,
1219
+ * so any external change that landed in the same window is still reflected.
1220
+ */
1221
+ #markEngineMutation() {
1222
+ if (this._sourceSyncTimer) {
1223
+ clearTimeout(this._sourceSyncTimer);
1224
+ this._sourceSyncTimer = null;
1225
+ }
1226
+ this._sourceObserver?.disconnect();
1227
+ this._sourceObserver = null;
1228
+ queueMicrotask(() => {
1229
+ if (instances.get(this.source) === this && this.options.observeSource && this.mode === "enhanced") {
1230
+ this.#watchSource();
1231
+ }
1232
+ });
1233
+ }
1234
+
1235
+ /**
1236
+ * Opt-in automatic source sync. `observeSource` watches the native catalogue
1237
+ * (select's <option>/<optgroup> structure and selected/disabled/required/
1238
+ * readonly state; the detached datalist's <option> set for inputs) and calls
1239
+ * `sync()` once per debounced batch. `multiple` is deliberately not observed:
1240
+ * the value model is fixed at init time.
1241
+ */
1242
+ #watchSource() {
1243
+ if (!this.options.observeSource || this._sourceObserver) return;
1244
+
1245
+ const config = this.isSelect
1246
+ ? {
1247
+ childList: true,
1248
+ subtree: true,
1249
+ attributes: true,
1250
+ attributeFilter: ["selected", "disabled", "required", "readonly"],
1251
+ }
1252
+ : {
1253
+ childList: true,
1254
+ attributes: true,
1255
+ attributeFilter: ["value", "disabled", "label"],
1256
+ };
1257
+
1258
+ this._sourceObserver = new MutationObserver(() => this.#scheduleSourceSync());
1259
+ // For input+datalist the datalist is detached in enhanced mode; a
1260
+ // MutationObserver can observe a detached node just fine.
1261
+ const target = this.isSelect ? this.source : this.datalist;
1262
+ if (target) this._sourceObserver.observe(target, config);
1263
+ }
1264
+
1265
+ #scheduleSourceSync() {
1266
+ if (this._sourceSyncTimer) clearTimeout(this._sourceSyncTimer);
1267
+ this._sourceSyncTimer = setTimeout(() => {
1268
+ this._sourceSyncTimer = null;
1269
+ if (instances.get(this.source) !== this) return;
1270
+ this.sync();
1271
+ }, 50);
1272
+ }
1273
+
1274
+ /* ---------------------------------------------------------------------- */
1275
+ /* Picker / interaction */
1276
+ /* ---------------------------------------------------------------------- */
1277
+
1278
+ /**
1279
+ * Build the popover picker (top layer) plus its listbox and live status
1280
+ * region. The popover is appended to the nearest ancestor dialog (or body)
1281
+ * so a modal <dialog> does not make it inert.
1282
+ * @returns {{ popover: HTMLElement, listbox: HTMLElement, status: HTMLElement }}
1283
+ */
1284
+ #createPicker() {
1285
+ const popover = document.createElement("div");
1286
+ popover.className = "cb-popover";
1287
+ popover.popover = "manual";
1288
+ popover.style.setProperty("position-anchor", this.anchorName);
1289
+
1290
+ const listbox = document.createElement("div");
1291
+ listbox.className = "cb-listbox";
1292
+ listbox.role = "listbox";
1293
+ listbox.id = `combobox-listbox-${this.id}`;
1294
+ if (this.isMultiple) listbox.setAttribute("aria-multiselectable", "true");
1295
+
1296
+ const status = document.createElement("div");
1297
+ status.className = "cb-status";
1298
+ status.setAttribute("role", "status");
1299
+ status.setAttribute("aria-live", "polite");
1300
+
1301
+ popover.append(listbox, status);
1302
+ // Popover renders in the top layer, but a modal <dialog> makes everything
1303
+ // outside it (including a body-level popover) inert. Stay a descendant of
1304
+ // an ancestor dialog so the picker stays interactive inside showModal().
1305
+ const dialog = this.source.closest("dialog");
1306
+ (dialog || document.body).append(popover);
1307
+
1308
+ // The popover renders in the top layer, outside the control's subtree, so
1309
+ // it cannot inherit the control's typography. Adopt the interaction
1310
+ // input's resolved font to avoid falling back to the page-level font.
1311
+ popover.style.font = getComputedStyle(this.#inputEl()).font;
1312
+
1313
+ this.#inputEl().setAttribute("role", "combobox");
1314
+ this.#inputEl().setAttribute("aria-autocomplete", "list");
1315
+ this.#inputEl().setAttribute("aria-expanded", "false");
1316
+ this.#inputEl().setAttribute("aria-controls", listbox.id);
1317
+
1318
+ return { popover, listbox, status };
1319
+ }
1320
+
1321
+ #bind() {
1322
+ const signal = this.abortController.signal;
1323
+
1324
+ // Persistent listeners go through #handleEvent so no per-render closure is
1325
+ // ever added. #renderList/#renderChips stay listener-free.
1326
+ this.#inputEl().addEventListener("focus", this, { signal });
1327
+ this.#inputEl().addEventListener("input", this, { signal });
1328
+ this.#inputEl().addEventListener("compositionstart", this, { signal });
1329
+ this.#inputEl().addEventListener("compositionend", this, { signal });
1330
+ this.#inputEl().addEventListener("keydown", this, { signal });
1331
+ this.#inputEl().addEventListener("blur", this, { signal });
1332
+
1333
+ this.#listEl().addEventListener("pointerdown", (event) => event.preventDefault(), { signal });
1334
+ this.#listEl().addEventListener("pointermove", this, { signal });
1335
+ this.#listEl().addEventListener("click", this, { signal });
1336
+
1337
+ if (this.chips) {
1338
+ this.chips.addEventListener("keydown", this, { signal });
1339
+ this.chips.addEventListener("click", this, { signal });
1340
+ }
1341
+
1342
+ this.control?.addEventListener("click", this, { signal });
1343
+
1344
+ document.addEventListener(
1345
+ "pointerdown",
1346
+ (event) => {
1347
+ if (!this.isOpen()) return;
1348
+ const path = event.composedPath();
1349
+ const control = this.anchor || this.control || this.#inputEl();
1350
+ if (path.includes(control) || path.includes(this.#popoverEl())) return;
1351
+ this.hide();
1352
+ },
1353
+ { capture: true, signal },
1354
+ );
1355
+
1356
+ this.#popoverEl().addEventListener(
1357
+ "toggle",
1358
+ (event) => {
1359
+ const open = event.newState === "open";
1360
+ this.#inputEl().setAttribute("aria-expanded", String(open));
1361
+ emit(this.source, open ? "combobox:open" : "combobox:close", { combobox: this });
1362
+ if (!open) {
1363
+ this.#setActive(-1);
1364
+ if (this.isSelect && !this.isMultiple) this.#syncSingleLabel();
1365
+ }
1366
+ },
1367
+ { signal },
1368
+ );
1369
+
1370
+ if (this.isSelect) {
1371
+ this.source.addEventListener("change", () => this.refresh(), { signal });
1372
+ this.source.addEventListener("focus", () => this.#inputEl().focus(), { signal });
1373
+ for (const label of this.boundLabels) {
1374
+ label.addEventListener(
1375
+ "click",
1376
+ (event) => {
1377
+ event.preventDefault();
1378
+ this.#inputEl().focus();
1379
+ },
1380
+ { signal },
1381
+ );
1382
+ }
1383
+ this.source.form?.addEventListener("reset", () => queueMicrotask(() => this.refresh()), { signal });
1384
+ if (
1385
+ this.isMultiple &&
1386
+ this.options.selectionOrder === "selected" &&
1387
+ this.source.name &&
1388
+ this.source.form
1389
+ ) {
1390
+ this.source.form.addEventListener(
1391
+ "formdata",
1392
+ (event) => {
1393
+ event.formData.delete(this.source.name);
1394
+ for (const value of this.getSelectedValues()) event.formData.append(this.source.name, value);
1395
+ },
1396
+ { signal },
1397
+ );
1398
+ }
1399
+ this.source.addEventListener(
1400
+ "invalid",
1401
+ (event) => {
1402
+ event.preventDefault();
1403
+ this.#inputEl().setAttribute("aria-invalid", "true");
1404
+ this.#inputEl().focus();
1405
+ },
1406
+ { signal },
1407
+ );
1408
+ }
1409
+ }
1410
+
1411
+ /** Single entry point for all listeners bound with `this` as the handler. */
1412
+ /**
1413
+ * @param {Event} event
1414
+ */
1415
+ handleEvent(event) {
1416
+ if (event.currentTarget === this.#inputEl()) return this.#onInputEvent(event);
1417
+ if (event.currentTarget === this.control) return this.#onControlEvent(event);
1418
+ if (event.currentTarget === this.#listEl()) return this.#onListboxEvent(event);
1419
+ if (event.currentTarget === this.chips) return this.#onChipsEvent(event);
1420
+ }
1421
+
1422
+ /**
1423
+ * @param {Event} event
1424
+ */
1425
+ #onInputEvent(event) {
1426
+ switch (event.type) {
1427
+ case "focus": {
1428
+ if (this.isSelect && !this.isMultiple && this.#selectSource().selectedOptions.length)
1429
+ this.#inputEl().select();
1430
+ const query = this.isSelect && !this.isMultiple ? "" : this.#inputEl().value;
1431
+ this.search(query, { show: true, reason: "focus" });
1432
+ return;
1433
+ }
1434
+ case "input": {
1435
+ // Separator tokens are consumed as they complete (typing or paste).
1436
+ // IME composition feeds search but never tokenizes/creates.
1437
+ const inputEvent = /** @type {InputEvent} */ (event);
1438
+ if (this.isMultiple && !inputEvent.isComposing && this.#separatorsActive()) {
1439
+ void this.#handleTokenInput();
1440
+ return;
1441
+ }
1442
+ this.search(this.#inputEl().value, { show: true, reason: "input" });
1443
+ return;
1444
+ }
1445
+ case "compositionstart":
1446
+ this.composing = true;
1447
+ return;
1448
+ case "compositionend":
1449
+ this.composing = false;
1450
+ return;
1451
+ case "keydown":
1452
+ return this.#onInputKeyDown(/** @type {KeyboardEvent} */ (event));
1453
+ case "blur":
1454
+ return this.#onInputBlur();
1455
+ }
1456
+ }
1457
+
1458
+ /**
1459
+ * @param {Event} event
1460
+ */
1461
+ #onControlEvent(event) {
1462
+ const target = /** @type {HTMLElement} */ (event.target);
1463
+ if (target.closest("button")) return;
1464
+ this.#inputEl().focus();
1465
+ }
1466
+
1467
+ /**
1468
+ * @param {Event} event
1469
+ */
1470
+ #onListboxEvent(event) {
1471
+ const target = /** @type {HTMLElement} */ (event.target);
1472
+ if (event.type === "pointermove") {
1473
+ const option = target.closest(".cb-option[data-index]");
1474
+ if (option) this.#setActive(Number(/** @type {HTMLElement} */ (option).dataset.index));
1475
+ return;
1476
+ }
1477
+ if (event.type === "click") {
1478
+ const option = target.closest(".cb-option");
1479
+ if (!option) return;
1480
+ if (option.classList.contains("cb-create")) {
1481
+ const query = this.#inputEl().value.trim();
1482
+ if (query) void this.#createItem(query);
1483
+ return;
1484
+ }
1485
+ const item = this.visibleItems[Number(/** @type {HTMLElement} */ (option).dataset.index)];
1486
+ if (item) this.#selectItem(item);
1487
+ }
1488
+ }
1489
+
1490
+ /**
1491
+ * @param {Event} event
1492
+ */
1493
+ #onChipsEvent(event) {
1494
+ const target = /** @type {HTMLElement} */ (event.target);
1495
+ if (event.type === "click") {
1496
+ const remove = target.closest(".cb-chip-remove");
1497
+ if (!remove) return;
1498
+ const chip = /** @type {HTMLElement} */ (remove.closest(".cb-chip"));
1499
+ if (!chip) return;
1500
+ // The chip's exact option is authoritative (duplicate values share a
1501
+ // data-value but never an option). data-value is only a fallback.
1502
+ const option = this._chipOptions.get(chip);
1503
+ void this.remove(option ?? chip.dataset.value ?? "").then((removed) => {
1504
+ if (removed) this.#inputEl().focus();
1505
+ });
1506
+ return;
1507
+ }
1508
+ if (event.type === "keydown") {
1509
+ const chip = /** @type {HTMLElement} */ (target.closest(".cb-chip"));
1510
+ if (!chip) return;
1511
+ const option = this._chipOptions.get(chip);
1512
+ const item = option
1513
+ ? this.getSelectedItems().find((entry) => entry.option === option) || {
1514
+ value: option.value,
1515
+ label: option.textContent.trim(),
1516
+ option,
1517
+ }
1518
+ : { value: chip.dataset.value ?? "", label: chip.dataset.value ?? "" };
1519
+ this.#onChipKeyDown(/** @type {KeyboardEvent} */ (event), item);
1520
+ }
1521
+ }
1522
+
1523
+ #onInputBlur() {
1524
+ queueMicrotask(async () => {
1525
+ const active = document.activeElement;
1526
+ // Blur caused by internal interaction (picker click, adornment,
1527
+ // chip removal, clear) never closes and never blur-creates.
1528
+ const stillInside =
1529
+ active === this.#inputEl() ||
1530
+ (this.#popoverEl()?.contains(active) ?? false) ||
1531
+ (this.anchor && active && this.anchor.contains(active)) ||
1532
+ (this.control && active && this.control.contains(active));
1533
+ if (this.isOpen() && stillInside) return;
1534
+
1535
+ if (this.isOpen() || this.options.createOnBlur) {
1536
+ if (this.isSelect && this.isMultiple && this.options.createOnBlur && !this.composing) {
1537
+ const value = this.#inputEl().value;
1538
+ this.suppressReopen = true;
1539
+ try {
1540
+ if (this.#separatorsActive()) {
1541
+ const result = await this.#processTokens(value, { final: true });
1542
+ if (result?.consumed) this.#inputEl().value = result.rest;
1543
+ } else if (value.trim()) {
1544
+ this.#inputEl().value = "";
1545
+ await this.#createItem(value.trim());
1546
+ }
1547
+ } finally {
1548
+ this.suppressReopen = false;
1549
+ }
1550
+ this.refresh();
1551
+ }
1552
+ if (this.isSelect && !this.isMultiple) this.#syncSingleLabel();
1553
+ this.hide();
1554
+ }
1555
+ });
1556
+ }
1557
+
1558
+ /**
1559
+ * @param {KeyboardEvent} event
1560
+ */
1561
+ #onInputKeyDown(event) {
1562
+ if (event.key === "ArrowDown") {
1563
+ event.preventDefault();
1564
+ if (!this.isOpen()) this.search(this.#inputEl().value, { show: true, reason: "keyboard" });
1565
+ this.#moveActive(1);
1566
+ return;
1567
+ }
1568
+
1569
+ if (event.key === "ArrowUp") {
1570
+ event.preventDefault();
1571
+ if (!this.isOpen()) this.search(this.#inputEl().value, { show: true, reason: "keyboard" });
1572
+ this.#moveActive(-1);
1573
+ return;
1574
+ }
1575
+
1576
+ if (event.key === "PageUp" || event.key === "PageDown") {
1577
+ event.preventDefault();
1578
+ if (!this.isOpen()) this.search(this.#inputEl().value, { show: true, reason: "keyboard" });
1579
+ const down = event.key === "PageDown";
1580
+ const base = this.activeIndex < 0 ? (down ? -1 : 0) : this.activeIndex;
1581
+ const distance = base + (down ? this.#pageSize() : -this.#pageSize());
1582
+ this.#setActive(this.#nearestSelectable(distance, down ? 1 : -1));
1583
+ return;
1584
+ }
1585
+
1586
+ if (event.key === "Enter" && this.isOpen()) {
1587
+ // IME composition owns the key: returning first never steals Enter from a
1588
+ // composing input, and never preventDefault()s it.
1589
+ if (event.isComposing || this.composing) return;
1590
+ event.preventDefault();
1591
+ if (this.isMultiple && this.#separatorsActive()) {
1592
+ void this.#commitEnterTokens();
1593
+ return;
1594
+ }
1595
+ const active = this.visibleItems[this.activeIndex];
1596
+ if (active) this.#selectItem(active);
1597
+ else if (this.#canCreate(this.#inputEl().value)) void this.#createItem(this.#inputEl().value.trim());
1598
+ return;
1599
+ }
1600
+
1601
+ // tabSelect deals with an open picker, not with the open state itself.
1602
+ // preventDefault() only fires when a commit is actually possible; otherwise
1603
+ // Tab keeps its native focus-traversal behavior. IME composition is never a
1604
+ // commit (this.composing mirrors the blur handler) and always falls through
1605
+ // to native Tab. An open top-layer popover traps sequential focus in some
1606
+ // engines (Firefox keeps focus inside an open manual popover), so whenever
1607
+ // Tab does not commit it still closes the picker before letting traversal
1608
+ // proceed — without ever preventDefault()ing.
1609
+ if (event.key === "Tab" && this.isOpen()) {
1610
+ if (this.options.tabSelect) {
1611
+ if (event.isComposing || this.composing) return;
1612
+ if (this.isMultiple && this.#separatorsActive() && this.#inputEl().value.trim()) {
1613
+ event.preventDefault();
1614
+ void this.#commitEnterTokens();
1615
+ return;
1616
+ }
1617
+ const active = this.visibleItems[this.activeIndex];
1618
+ if (active) {
1619
+ event.preventDefault();
1620
+ this.#selectItem(active);
1621
+ return;
1622
+ }
1623
+ if (this.#canCreate(this.#inputEl().value)) {
1624
+ event.preventDefault();
1625
+ void this.#createItem(this.#inputEl().value.trim());
1626
+ return;
1627
+ }
1628
+ this.hide();
1629
+ return;
1630
+ }
1631
+ this.hide();
1632
+ return;
1633
+ }
1634
+
1635
+ if (event.key === "Escape" && this.isOpen()) {
1636
+ event.preventDefault();
1637
+ this.hide();
1638
+ return;
1639
+ }
1640
+
1641
+ if (event.key === "ArrowLeft" && this.isMultiple && !this.#inputEl().value) {
1642
+ const chips = Array.from(this.chips?.querySelectorAll(".cb-chip") || []);
1643
+ if (chips.length) {
1644
+ event.preventDefault();
1645
+ /** @type {HTMLElement} */ (chips[chips.length - 1]).focus();
1646
+ return;
1647
+ }
1648
+ }
1649
+
1650
+ if (
1651
+ event.key === "Backspace" &&
1652
+ this.isMultiple &&
1653
+ !this.#inputEl().value &&
1654
+ this.#selectSource().selectedOptions.length
1655
+ ) {
1656
+ const selected = this.#selectedOptionsInOrder();
1657
+ const last = selected[selected.length - 1];
1658
+ if (last && !last.disabled) void this.remove(last);
1659
+ }
1660
+ }
1661
+
1662
+ /* ---------------------------------------------------------------------- */
1663
+ /* Filtering / loading */
1664
+ /* ---------------------------------------------------------------------- */
1665
+
1666
+ /**
1667
+ * Run the normal filtering pipeline: beforefilter -> optional load -> filter.
1668
+ * @param {string} [query]
1669
+ * @param {{ show?: boolean, reason?: string }} [options]
1670
+ */
1671
+ async search(query = "", { show = false, reason = "api" } = {}) {
1672
+ if (this.mode !== "enhanced") return;
1673
+
1674
+ const generation = ++this.searchGeneration;
1675
+ this.query = String(query ?? "");
1676
+
1677
+ const before = emit(
1678
+ this.#inputEl(),
1679
+ "beforefilter",
1680
+ {
1681
+ query: this.query,
1682
+ combobox: this,
1683
+ source: this.source,
1684
+ reason,
1685
+ },
1686
+ { cancelable: true },
1687
+ );
1688
+
1689
+ // Open UI semantics: cancel beforefilter and script fully owns filtering.
1690
+ if (before.defaultPrevented) return;
1691
+
1692
+ if (this.#shouldLoad(this.query)) {
1693
+ await this.#load(this.query, { debounce: reason === "input" });
1694
+ if (generation !== this.searchGeneration) return;
1695
+ } else {
1696
+ // A local query should not keep stale remote results around.
1697
+ if (typeof this.options.load === "function") this.clearResults();
1698
+ }
1699
+
1700
+ this.#applyFilter(this.query);
1701
+ emit(this.#inputEl(), "filter", {
1702
+ query: this.query,
1703
+ combobox: this,
1704
+ items: this.filteredItems,
1705
+ source: this.source,
1706
+ });
1707
+
1708
+ if (show) this.show();
1709
+ }
1710
+
1711
+ /**
1712
+ * Update the visible interaction text and run the normal search pipeline.
1713
+ * Unlike search(), this keeps the DOM input and `query` in sync. Programmatic
1714
+ * assignment follows the platform and does not dispatch native input/change
1715
+ * events.
1716
+ * @param {*} value
1717
+ * @param {{ show?: boolean, reason?: string }} [options]
1718
+ * @returns {Promise<void>}
1719
+ */
1720
+ setQuery(value, { show = true, reason = "api" } = {}) {
1721
+ const query = String(value ?? "");
1722
+ if (this.mode === "fallback") {
1723
+ this.query = query;
1724
+ if (!this.isSelect) this.source.value = query;
1725
+ return Promise.resolve();
1726
+ }
1727
+
1728
+ this.#inputEl().value = query;
1729
+ return this.search(query, { show, reason });
1730
+ }
1731
+
1732
+ /**
1733
+ * Clear the visible interaction text and run the normal search pipeline.
1734
+ * The picker is not opened when it was closed unless `{ show: true }` is
1735
+ * requested explicitly.
1736
+ * @param {{ show?: boolean, reason?: string }} [options]
1737
+ * @returns {Promise<void>}
1738
+ */
1739
+ clearQuery({ show = false, reason = "api" } = {}) {
1740
+ return this.setQuery("", { show, reason });
1741
+ }
1742
+
1743
+ /**
1744
+ * Apply the local filter directly, without re-firing beforefilter or load.
1745
+ * This is the escape hatch intended for a canceled beforefilter handler:
1746
+ * event.preventDefault();
1747
+ * combobox.setResults(results).applyFilter(event.query, { show: true });
1748
+ * @param {string} [query]
1749
+ * @param {{ show?: boolean }} [options]
1750
+ * @returns {this | undefined}
1751
+ */
1752
+ applyFilter(query = "", { show = false } = {}) {
1753
+ if (this.mode !== "enhanced") return this;
1754
+ this.query = String(query ?? "");
1755
+ this.#applyFilter(this.query);
1756
+ emit(this.#inputEl(), "filter", {
1757
+ query: this.query,
1758
+ combobox: this,
1759
+ items: this.filteredItems,
1760
+ source: this.source,
1761
+ manual: true,
1762
+ });
1763
+ if (show) this.show();
1764
+ return this;
1765
+ }
1766
+
1767
+ /**
1768
+ * @param {string} query
1769
+ * @returns {boolean}
1770
+ */
1771
+ #shouldLoad(query) {
1772
+ if (
1773
+ typeof this.options.shouldLoad === "function" &&
1774
+ !this.options.shouldLoad(query, { combobox: this, source: this.source, input: this.#inputEl() })
1775
+ ) {
1776
+ return false;
1777
+ }
1778
+ return (
1779
+ typeof this.options.load === "function" &&
1780
+ query.length >= Number(this.options.minChars || 0) &&
1781
+ (query.length > 0 || this.options.loadOnEmpty)
1782
+ );
1783
+ }
1784
+
1785
+ /**
1786
+ * @param {string} query
1787
+ * @param {{ cursor?: string | null, append?: boolean, debounce?: boolean }} [options]
1788
+ */
1789
+ async #load(query, { cursor = null, append = false, debounce = false } = {}) {
1790
+ this.loadController?.abort();
1791
+ this.loadController = new AbortController();
1792
+ const signal = this.loadController.signal;
1793
+ this.loadError = null;
1794
+
1795
+ if (debounce && Number(this.options.debounce) > 0) {
1796
+ try {
1797
+ await wait(Number(this.options.debounce), signal);
1798
+ } catch {
1799
+ return;
1800
+ }
1801
+ }
1802
+
1803
+ const before = emit(
1804
+ this.source,
1805
+ "combobox:beforeload",
1806
+ {
1807
+ query,
1808
+ cursor,
1809
+ combobox: this,
1810
+ signal,
1811
+ },
1812
+ { cancelable: true },
1813
+ );
1814
+ if (before.defaultPrevented) return;
1815
+
1816
+ this.loading = true;
1817
+ this.#renderLoading();
1818
+ this.show();
1819
+
1820
+ try {
1821
+ const result = await this.options.load(query, {
1822
+ signal,
1823
+ cursor,
1824
+ combobox: this,
1825
+ source: this.source,
1826
+ input: this.#inputEl(),
1827
+ });
1828
+ if (signal.aborted) return;
1829
+
1830
+ const items = Array.isArray(result) ? result : result?.items;
1831
+ if (items) {
1832
+ const merged = append && this.results ? [...this.results, ...items] : items;
1833
+ this.setResults(merged);
1834
+ }
1835
+ // Keep the cursor contract open for future paged loading without
1836
+ // implementing virtual/infinite scrolling in the core.
1837
+ this.nextCursor = Array.isArray(result) ? null : (result?.cursor ?? null);
1838
+
1839
+ emit(this.source, "combobox:load", {
1840
+ query,
1841
+ combobox: this,
1842
+ result,
1843
+ });
1844
+ } catch (error) {
1845
+ const caught = /** @type {any} */ (error);
1846
+ if (signal.aborted || caught?.name === "AbortError") return;
1847
+ // The error row mirrors loading: it replaces the list for this query and
1848
+ // is cleared by the next successful load or local search. Long-lived
1849
+ // selection lives on the native source and is untouched by a failed load.
1850
+ this.loadError = caught;
1851
+ emit(this.source, "combobox:loaderror", {
1852
+ query,
1853
+ combobox: this,
1854
+ error: caught,
1855
+ });
1856
+ } finally {
1857
+ if (!signal.aborted) this.loading = false;
1858
+ }
1859
+ }
1860
+
1861
+ /**
1862
+ * @param {string} query
1863
+ */
1864
+ #applyFilter(query) {
1865
+ const items = this.#items();
1866
+
1867
+ let visible = items.filter((item) => {
1868
+ if (this.isMultiple && item.selected) return false;
1869
+ return this.#matches(item, query);
1870
+ });
1871
+
1872
+ const context = { combobox: this, source: this.source, input: this.#inputEl() };
1873
+
1874
+ if (typeof this.options.filter === "function") {
1875
+ visible = visible.filter((item) => this.options.filter(item, query, context));
1876
+ }
1877
+
1878
+ if (typeof this.options.score === "function") {
1879
+ visible = rankByScore(visible, (item, _index) => this.options.score(item, query, context));
1880
+ }
1881
+
1882
+ if (typeof this.options.sort === "function") {
1883
+ visible.sort((a, b) => this.options.sort(a, b, query, context));
1884
+ }
1885
+
1886
+ this.filteredItems = visible;
1887
+
1888
+ // Mirror the proposed :filtered state on the actual source option so the
1889
+ // migration path to the platform primitive is explicit.
1890
+ const visibleOptions = new Set(visible.map((item) => item.option));
1891
+ for (const item of items) {
1892
+ item.option?.toggleAttribute("data-filtered", !visibleOptions.has(item.option));
1893
+ }
1894
+
1895
+ this.#renderList();
1896
+ this.#setActive(
1897
+ this.options.autoselectFirst ? this.visibleItems.findIndex((item) => !item.disabled) : -1,
1898
+ );
1899
+ }
1900
+
1901
+ /**
1902
+ * Decision helper: an empty query is "no textual search", so the matcher
1903
+ * (including a custom match) has nothing to decide — everything passes the
1904
+ * match stage (`filter` admissibility still applies independently).
1905
+ * For any other query, the strategy is applied **per `searchField` value**:
1906
+ * `matchesField` owns every strategy and receives exactly one value, so a
1907
+ * match can never cross field boundaries.
1908
+ * @param {import("./helpers.js").ComboboxItem} item
1909
+ * @param {string} query
1910
+ * @returns {boolean}
1911
+ */
1912
+ #matches(item, query) {
1913
+ if (!query) return true;
1914
+
1915
+ if (typeof this.options.match === "function") {
1916
+ return this.options.match(item, query, { combobox: this, source: this.source, input: this.#inputEl() });
1917
+ }
1918
+
1919
+ const fields = Array.isArray(this.options.searchFields)
1920
+ ? this.options.searchFields
1921
+ : this.options.searchFields
1922
+ ? [this.options.searchFields]
1923
+ : [];
1924
+ const values = fields.map((field) => {
1925
+ if (field in item) return String(item[field] ?? "");
1926
+ return String(item.data?.[field] ?? "");
1927
+ });
1928
+ return values.some((value) => matchesField(value, query, this.options.match));
1929
+ }
1930
+
1931
+ /**
1932
+ * @param {string | null | undefined} label
1933
+ * @returns {boolean}
1934
+ */
1935
+ #canCreate(label) {
1936
+ const value = String(label ?? "").trim();
1937
+ if (!this.isSelect || !this.options.create || !value) return false;
1938
+ if (
1939
+ this.options.maxItems > 0 &&
1940
+ this.isMultiple &&
1941
+ this.#selectSource().selectedOptions.length >= this.options.maxItems
1942
+ )
1943
+ return false;
1944
+ if (typeof this.options.createFilter === "function") {
1945
+ return (
1946
+ this.options.createFilter(value, { combobox: this, source: this.source, input: this.#inputEl() }) !==
1947
+ false
1948
+ );
1949
+ }
1950
+ return true;
1951
+ }
1952
+
1953
+ /* ---------------------------------------------------------------------- */
1954
+ /* Rendering */
1955
+ /* ---------------------------------------------------------------------- */
1956
+
1957
+ #renderList() {
1958
+ this.#listEl().replaceChildren();
1959
+ this.#statusEl().textContent = "";
1960
+
1961
+ if (this.loading) {
1962
+ this.#renderLoading();
1963
+ return;
1964
+ }
1965
+
1966
+ if (this.loadError) {
1967
+ this.#renderError();
1968
+ return;
1969
+ }
1970
+
1971
+ let previousGroup = null;
1972
+ for (const [index, item] of this.visibleItems.entries()) {
1973
+ if (item.group && item.group !== previousGroup) {
1974
+ const group = document.createElement("div");
1975
+ group.className = "cb-group";
1976
+ group.setAttribute("role", "presentation");
1977
+ setContent(group, this.options.render.group?.(item.group, { combobox: this }) ?? item.group);
1978
+ this.#listEl().append(group);
1979
+ previousGroup = item.group;
1980
+ }
1981
+
1982
+ const option = document.createElement("div");
1983
+ option.className = "cb-option";
1984
+ option.id = `combobox-option-${this.id}-${index}`;
1985
+ option.role = "option";
1986
+ option.tabIndex = -1;
1987
+ option.dataset.index = String(index);
1988
+ option.setAttribute("aria-selected", String(Boolean(item.selected)));
1989
+ // Preserve native <option title="…"> tooltips on the row.
1990
+ if (item.option?.title || item.title) option.title = item.option?.title ?? item.title ?? "";
1991
+ if (item.disabled) {
1992
+ option.setAttribute("aria-disabled", "true");
1993
+ }
1994
+
1995
+ const rendered = this.options.render.option?.(item, {
1996
+ query: this.query,
1997
+ selected: item.selected,
1998
+ combobox: this,
1999
+ });
2000
+ const label = document.createElement("span");
2001
+ label.className = "cb-option-label";
2002
+ setContent(label, rendered ?? item.label);
2003
+ option.append(label);
2004
+ this.#listEl().append(option);
2005
+ }
2006
+
2007
+ if (!this.filteredItems.length) {
2008
+ if (this.#canCreate(this.#inputEl().value)) {
2009
+ const create = document.createElement("div");
2010
+ create.className = "cb-option cb-create";
2011
+ create.tabIndex = -1;
2012
+ create.role = "option";
2013
+ const query = this.#inputEl().value.trim();
2014
+ const rendered = this.options.render.create?.(query, { combobox: this });
2015
+ const createLabel = document.createElement("span");
2016
+ createLabel.className = "cb-option-label";
2017
+ setContent(
2018
+ createLabel,
2019
+ rendered ??
2020
+ this.options.messages.create?.(query, {
2021
+ combobox: this,
2022
+ source: this.source,
2023
+ input: this.#inputEl(),
2024
+ }) ??
2025
+ query,
2026
+ );
2027
+ create.append(createLabel);
2028
+ this.#listEl().append(create);
2029
+ } else {
2030
+ const empty = document.createElement("div");
2031
+ empty.className = "cb-empty";
2032
+ const rendered = this.options.render.noResults?.(this.query, { combobox: this });
2033
+ setContent(empty, rendered ?? this.options.messages.noResults);
2034
+ this.#listEl().append(empty);
2035
+ this.#statusEl().textContent = this.options.messages.noResults ?? "";
2036
+ }
2037
+ }
2038
+ }
2039
+
2040
+ #renderLoading() {
2041
+ this.#listEl().replaceChildren();
2042
+ // A loading/error row replaces the option list, so no row may stay active:
2043
+ // the previous aria-activedescendant would point at removed DOM.
2044
+ this.#setActive(-1);
2045
+ const loading = document.createElement("div");
2046
+ loading.className = "cb-empty cb-loading";
2047
+ const rendered = this.options.render.loading?.(this.query, { combobox: this });
2048
+ setContent(loading, rendered ?? this.options.messages.loading);
2049
+ this.#listEl().append(loading);
2050
+ this.#statusEl().textContent = this.options.messages.loading ?? "";
2051
+ }
2052
+
2053
+ #renderError() {
2054
+ this.#listEl().replaceChildren();
2055
+ this.#setActive(-1);
2056
+ const error = document.createElement("div");
2057
+ error.className = "cb-empty cb-error";
2058
+ const rendered = this.options.render.error?.(this.query, { error: this.loadError, combobox: this });
2059
+ setContent(error, rendered ?? this.options.messages.loadError);
2060
+ this.#listEl().append(error);
2061
+ this.#statusEl().textContent = this.options.messages.loadError ?? "";
2062
+ }
2063
+
2064
+ #renderChips() {
2065
+ const chips = this.#chipsEl();
2066
+ if (!chips) return;
2067
+ chips.replaceChildren();
2068
+
2069
+ for (const option of this.#selectedOptionsInOrder()) {
2070
+ // An empty value is not a real selection unless allowEmptyOption is on;
2071
+ // the old-demo placeholder pattern (<option value="" selected disabled
2072
+ // hidden>) is never a chip.
2073
+ const placeholder = option.disabled && option.hidden;
2074
+ if (!option.value && (!this.options.allowEmptyOption || placeholder)) continue;
2075
+
2076
+ const item = {
2077
+ value: option.value,
2078
+ label: option.textContent.trim(),
2079
+ selected: true,
2080
+ disabled: option.disabled,
2081
+ option,
2082
+ data: { ...option.dataset },
2083
+ };
2084
+
2085
+ const chip = document.createElement("span");
2086
+ chip.className = "cb-chip";
2087
+ chip.tabIndex = -1;
2088
+ chip.dataset.value = item.value;
2089
+ // data-value is for inspection/debug only; the authoritative identity is
2090
+ // the option link kept in the #chipOptions WeakMap.
2091
+ this._chipOptions.set(chip, option);
2092
+ if (option.title) chip.title = option.title;
2093
+
2094
+ const label = document.createElement("span");
2095
+ label.className = "cb-chip-label";
2096
+ const rendered = this.options.render.item?.(item, { combobox: this });
2097
+ setContent(label, rendered ?? item.label);
2098
+ chip.append(label);
2099
+
2100
+ if (!option.disabled && !this.source.disabled) {
2101
+ const remove = document.createElement("button");
2102
+ remove.type = "button";
2103
+ remove.className = "cb-chip-remove";
2104
+ remove.append(createRemoveIcon());
2105
+ remove.setAttribute("aria-label", `Remove ${item.label}`);
2106
+ chip.append(remove);
2107
+ }
2108
+
2109
+ chips.append(chip);
2110
+ }
2111
+ }
2112
+
2113
+ #selectedOptionsInOrder() {
2114
+ const selected = Array.from(this.#selectSource().selectedOptions);
2115
+ if (this.options.selectionOrder !== "selected") return selected;
2116
+
2117
+ // Reconcile the remembered order against the actual selection. Options no
2118
+ // longer selected are dropped; options selected by external DOM mutations
2119
+ // and absent from the remembered order are appended in native order. Set
2120
+ // membership is object identity, so duplicate values stay distinct.
2121
+ return reconcileSelected(selected, this.selectionOrder);
2122
+ }
2123
+
2124
+ /**
2125
+ * @param {HTMLOptionElement} option
2126
+ */
2127
+ #rememberSelection(option) {
2128
+ if (!this.selectionOrder.includes(option)) this.selectionOrder.push(option);
2129
+ }
2130
+
2131
+ /**
2132
+ * @param {HTMLOptionElement} option
2133
+ */
2134
+ #forgetSelection(option) {
2135
+ const index = this.selectionOrder.indexOf(option);
2136
+ if (index >= 0) this.selectionOrder.splice(index, 1);
2137
+ }
2138
+
2139
+ /**
2140
+ * Keyboard interaction on a focused chip.
2141
+ * @param {KeyboardEvent} event
2142
+ * @param {import("./helpers.js").ComboboxItem} item
2143
+ */
2144
+ #onChipKeyDown(event, item) {
2145
+ const chips = /** @type {HTMLElement[]} */ (
2146
+ Array.from(this.#chipsEl()?.querySelectorAll(".cb-chip") || [])
2147
+ );
2148
+ const current =
2149
+ event.target instanceof HTMLElement
2150
+ ? /** @type {HTMLElement | null} */ (event.target.closest(".cb-chip"))
2151
+ : null;
2152
+ const index = current ? chips.indexOf(current) : -1;
2153
+
2154
+ // Ordered-mode keyboard reorder: Alt+Arrow/Home/End reorders a focused chip
2155
+ // without changing navigation keys. The gesture is consumed only when a real
2156
+ // move is possible; otherwise it falls through untouched (mirroring the
2157
+ // tabSelect "preventDefault only when a commit is possible" rule).
2158
+ if (event.altKey && index >= 0 && this.options.selectionOrder === "selected") {
2159
+ const target =
2160
+ event.key === "ArrowLeft"
2161
+ ? index - 1
2162
+ : event.key === "ArrowRight"
2163
+ ? index + 1
2164
+ : event.key === "Home"
2165
+ ? 0
2166
+ : event.key === "End"
2167
+ ? chips.length - 1
2168
+ : index;
2169
+ if (target !== index && target >= 0 && target < chips.length) {
2170
+ event.preventDefault();
2171
+ this.#reorderChip(item, target);
2172
+ return;
2173
+ }
2174
+ }
2175
+
2176
+ if (
2177
+ event.key === "ArrowLeft" ||
2178
+ event.key === "ArrowRight" ||
2179
+ event.key === "Home" ||
2180
+ event.key === "End"
2181
+ ) {
2182
+ event.preventDefault();
2183
+ let next = index;
2184
+ if (event.key === "ArrowLeft") next = Math.max(0, index - 1);
2185
+ if (event.key === "ArrowRight") next = index + 1;
2186
+ if (event.key === "Home") next = 0;
2187
+ if (event.key === "End") next = chips.length - 1;
2188
+ if (next >= chips.length) this.#inputEl().focus();
2189
+ else chips[next]?.focus();
2190
+ return;
2191
+ }
2192
+
2193
+ if (event.key === "Delete" || event.key === "Backspace") {
2194
+ event.preventDefault();
2195
+ void this.remove(item.option ?? item.value).then((removed) => {
2196
+ if (!removed) return;
2197
+ queueMicrotask(() => {
2198
+ const remaining = /** @type {HTMLElement[]} */ (
2199
+ Array.from(this.#chipsEl()?.querySelectorAll(".cb-chip") || [])
2200
+ );
2201
+ remaining[Math.min(index, remaining.length - 1)]?.focus();
2202
+ if (!remaining.length) this.#inputEl().focus();
2203
+ });
2204
+ });
2205
+ return;
2206
+ }
2207
+
2208
+ if (event.key === "Escape") {
2209
+ event.preventDefault();
2210
+ this.#inputEl().focus();
2211
+ }
2212
+ }
2213
+
2214
+ /** Move a focused chip to an absolute position, keep it focused and announce. */
2215
+ /**
2216
+ * @param {import("./helpers.js").ComboboxItem} item
2217
+ * @param {number} target
2218
+ */
2219
+ #reorderChip(item, target) {
2220
+ const identity = item.option ?? item.value;
2221
+ if (!this.move(identity, target)) return;
2222
+ const chips = /** @type {HTMLElement[]} */ (
2223
+ Array.from(this.#chipsEl()?.querySelectorAll(".cb-chip") || [])
2224
+ );
2225
+ const chip = item.option
2226
+ ? chips.find((candidate) => this._chipOptions.get(candidate) === item.option)
2227
+ : chips.find((candidate) => candidate.dataset.value === item.value);
2228
+ chip?.focus();
2229
+ this.#statusEl().textContent =
2230
+ this.options.messages.position?.(
2231
+ item.label,
2232
+ chips.indexOf(/** @type {HTMLElement} */ (chip)) + 1,
2233
+ chips.length,
2234
+ { combobox: this, source: this.source, input: this.#inputEl() },
2235
+ ) ?? "";
2236
+ }
2237
+
2238
+ /* ---------------------------------------------------------------------- */
2239
+ /* Selection / creation */
2240
+ /* ---------------------------------------------------------------------- */
2241
+
2242
+ /**
2243
+ * @param {number} delta
2244
+ */
2245
+ #moveActive(delta) {
2246
+ const visible = this.visibleItems;
2247
+ if (!visible.length) return;
2248
+
2249
+ let next = this.activeIndex < 0 ? (delta > 0 ? -1 : 0) : this.activeIndex;
2250
+ for (let checked = 0; checked < visible.length; checked++) {
2251
+ next = (next + delta + visible.length) % visible.length;
2252
+ if (!visible[next].disabled) {
2253
+ this.#setActive(next);
2254
+ return;
2255
+ }
2256
+ }
2257
+ }
2258
+
2259
+ /** Nearest selectable row at/after (`direction > 0`) or at/before an index, or -1. */
2260
+ /**
2261
+ * @param {number} from
2262
+ * @param {number} direction
2263
+ * @returns {number}
2264
+ */
2265
+ #nearestSelectable(from, direction) {
2266
+ const visible = this.visibleItems;
2267
+ const len = visible.length;
2268
+ if (!len) return -1;
2269
+ from = Math.max(0, Math.min(from, len - 1));
2270
+ for (let i = from; i >= 0 && i < len; i += direction) {
2271
+ if (!visible[i]?.disabled) return i;
2272
+ }
2273
+ if (direction > 0) {
2274
+ for (let i = from - 1; i >= 0; i--) if (!visible[i]?.disabled) return i;
2275
+ } else {
2276
+ for (let i = from + 1; i < len; i++) if (!visible[i]?.disabled) return i;
2277
+ }
2278
+ return -1;
2279
+ }
2280
+
2281
+ /** Rendered page height in selectable rows, used by PageUp/PageDown. */
2282
+ #pageSize() {
2283
+ const first = this.#listEl().querySelector(".cb-option");
2284
+ if (!first) return 1;
2285
+ const row = /** @type {HTMLElement} */ (first).offsetHeight || 48;
2286
+ const view = this.#popoverEl().clientHeight || 0;
2287
+ return Math.max(1, Math.floor(view / row));
2288
+ }
2289
+
2290
+ /**
2291
+ * @param {number} index
2292
+ */
2293
+ #setActive(index) {
2294
+ if (index >= this.visibleItems.length) index = -1;
2295
+ this.activeIndex = index;
2296
+
2297
+ for (const item of this.#sourceItems()) item.option?.removeAttribute("data-active-option");
2298
+
2299
+ for (const option of this.#listEl().querySelectorAll(".cb-option[data-index]")) {
2300
+ const el = /** @type {HTMLElement} */ (option);
2301
+ const active = Number(el.dataset.index) === index;
2302
+ el.toggleAttribute("data-active", active);
2303
+ if (active) {
2304
+ this.#inputEl().setAttribute("aria-activedescendant", el.id);
2305
+ this.visibleItems[index]?.option?.setAttribute("data-active-option", "");
2306
+ el.scrollIntoView({ block: "nearest" });
2307
+ }
2308
+ }
2309
+
2310
+ if (index < 0) this.#inputEl().removeAttribute("aria-activedescendant");
2311
+ }
2312
+
2313
+ /**
2314
+ * @param {import("./helpers.js").ComboboxItem} item
2315
+ * @param {{ materialize?: boolean }} [options]
2316
+ * @returns {boolean}
2317
+ */
2318
+ #selectItem(item, { materialize = true } = {}) {
2319
+ if (item.disabled) return false;
2320
+
2321
+ let option = null;
2322
+ if (this.isSelect) {
2323
+ // Option identity is authoritative: an exact item.option always wins over
2324
+ // a value lookup, so the third `value="2"` row a user picks never
2325
+ // collapses into the first one. A plain value resolves to the first
2326
+ // selectable match (unselected in multiple mode); materialization only
2327
+ // creates a native option when the value is genuinely absent.
2328
+ option =
2329
+ item.option instanceof HTMLOptionElement ? item.option : this.#findSelectableOption(item.value);
2330
+ if (option?.disabled || (!option && !materialize)) return false;
2331
+
2332
+ const unchanged = option
2333
+ ? this.isMultiple
2334
+ ? option.selected
2335
+ : this.#selectSource().selectedOptions[0] === option
2336
+ : false;
2337
+ if (unchanged) {
2338
+ if (!this.isMultiple) this.hide();
2339
+ return false;
2340
+ }
2341
+ if (
2342
+ this.isMultiple &&
2343
+ this.options.maxItems > 0 &&
2344
+ this.#selectSource().selectedOptions.length >= this.options.maxItems
2345
+ ) {
2346
+ return false;
2347
+ }
2348
+ if (option) item = { ...item, option, selected: true };
2349
+ } else if (this.source.value === item.value) {
2350
+ this.hide();
2351
+ return false;
2352
+ }
2353
+
2354
+ const before = emit(
2355
+ this.source,
2356
+ "combobox:beforeselect",
2357
+ {
2358
+ combobox: this,
2359
+ item,
2360
+ },
2361
+ { cancelable: true },
2362
+ );
2363
+ if (before.defaultPrevented) return false;
2364
+
2365
+ if (this.isSelect) {
2366
+ // A transient result becomes native state only after beforeselect has
2367
+ // allowed the operation. Cancellation therefore has zero catalogue or
2368
+ // value side effects, as a synchronous `before*` contract promises.
2369
+ if (!option) option = this.addOption(item);
2370
+ const selectOption = /** @type {HTMLOptionElement} */ (option);
2371
+ item = { ...item, option: selectOption, selected: true };
2372
+ if (this.isMultiple) {
2373
+ selectOption.selected = true;
2374
+ this.#rememberSelection(selectOption);
2375
+ this.#inputEl().value = "";
2376
+ this.#commit();
2377
+ if (this.suppressReopen) this.refresh();
2378
+ else if (this.#closeOnSelect()) this.hide();
2379
+ else this.search("", { show: true, reason: "select" });
2380
+ } else {
2381
+ // Select the exact option rather than assigning source.value, so a
2382
+ // duplicate value keeps its own label and selectedIndex.
2383
+ selectOption.selected = true;
2384
+ this.selectionOrder = [selectOption];
2385
+ this.#inputEl().value = item.label;
2386
+ this.#commit();
2387
+ if (this.#closeOnSelect()) this.hide();
2388
+ }
2389
+ } else {
2390
+ this.source.value = item.value;
2391
+ this.#dispatchNativeValueEvents();
2392
+ this.hide();
2393
+ }
2394
+
2395
+ emit(this.source, "combobox:select", { combobox: this, item });
2396
+ this.#markEngineMutation();
2397
+ return true;
2398
+ }
2399
+
2400
+ /**
2401
+ * @param {string} label
2402
+ * @returns {Promise<HTMLOptionElement | null>}
2403
+ */
2404
+ async #createItem(label) {
2405
+ if (!this.#canCreate(label)) return null;
2406
+
2407
+ const existing = this.#findCreateMatch(label);
2408
+ if (existing) {
2409
+ this.#selectItem(existing);
2410
+ return existing.option ?? null;
2411
+ }
2412
+
2413
+ // guards.add is about creating a brand-new item; existing matches are
2414
+ // selected above without running it.
2415
+ const guard = await this.#runGuard("add", { label });
2416
+ if (!guard.ok) return null;
2417
+
2418
+ const before = emit(
2419
+ this.source,
2420
+ "combobox:beforecreate",
2421
+ {
2422
+ combobox: this,
2423
+ label,
2424
+ },
2425
+ { cancelable: true },
2426
+ );
2427
+ if (before.defaultPrevented) return null;
2428
+
2429
+ /** @type {import("./helpers.js").ComboboxItem} */
2430
+ let created = { value: label, label };
2431
+ if (typeof this.options.create === "function") {
2432
+ this.loading = true;
2433
+ this.#renderLoading();
2434
+ try {
2435
+ const result = await this.options.create(label, {
2436
+ signal: this.abortController.signal,
2437
+ combobox: this,
2438
+ source: this.source,
2439
+ input: this.#inputEl(),
2440
+ });
2441
+ if (!result) return null;
2442
+ created = /** @type {import("./helpers.js").ComboboxItem} */ (toItem(result, this.#fields()));
2443
+ } catch (/** @type {any} */ error) {
2444
+ if (error?.name !== "AbortError")
2445
+ emit(this.source, "combobox:createerror", { combobox: this, label, error });
2446
+ return null;
2447
+ } finally {
2448
+ this.loading = false;
2449
+ }
2450
+ }
2451
+
2452
+ const option = this.addOption(created, { selected: true });
2453
+ this.#rememberSelection(option);
2454
+ this.#inputEl().value = "";
2455
+ this.#commit();
2456
+
2457
+ emit(this.source, "combobox:create", {
2458
+ combobox: this,
2459
+ item: { ...created, option, selected: true },
2460
+ });
2461
+
2462
+ if (this.isMultiple) {
2463
+ if (this.suppressReopen) this.refresh();
2464
+ else if (this.#closeOnSelect()) this.hide();
2465
+ else this.search("", { show: true, reason: "create" });
2466
+ } else {
2467
+ this.hide();
2468
+ }
2469
+ this.#markEngineMutation();
2470
+ return option;
2471
+ }
2472
+
2473
+ /**
2474
+ * Run an async guard. `false` is a voluntary refusal (no mutation, no
2475
+ * error). A rejected promise is an application error: a generic
2476
+ * `combobox:guarderror` event is emitted and the operation is blocked.
2477
+ * @param {"add" | "remove" | "clear"} name
2478
+ * @param {any} payload
2479
+ * @returns {Promise<{ ok: boolean, refused?: boolean, error?: any }>}
2480
+ */
2481
+ async #runGuard(name, payload) {
2482
+ const guards = this.options.guards;
2483
+ const guard = guards[name];
2484
+ if (typeof guard !== "function") return { ok: true };
2485
+ try {
2486
+ const result = await guard(payload, {
2487
+ combobox: this,
2488
+ source: this.source,
2489
+ input: this.#inputEl(),
2490
+ signal: this.abortController.signal,
2491
+ });
2492
+ return { ok: result !== false, refused: result === false };
2493
+ } catch (error) {
2494
+ emit(this.source, "combobox:guarderror", { combobox: this, guard: name, error });
2495
+ return { ok: false, refused: false, error };
2496
+ }
2497
+ }
2498
+
2499
+ /**
2500
+ * @returns {boolean}
2501
+ */
2502
+ #closeOnSelect() {
2503
+ return this.options.closeOnSelect ?? !this.isMultiple;
2504
+ }
2505
+
2506
+ /**
2507
+ * @returns {boolean}
2508
+ */
2509
+ #separatorsActive() {
2510
+ return this.isMultiple && Array.isArray(this.options.separators) && this.options.separators.length > 0;
2511
+ }
2512
+
2513
+ /**
2514
+ * Resolve the input value into token entries. Honors the optional `tokenize`
2515
+ * seam: `tokenize(value, ctx) => { tokens: string[], rest?: string }`.
2516
+ * `tokens` are complete tokens to consume; `rest` is the trailing
2517
+ * incomplete text that must keep living in the input (defaults to `""`).
2518
+ * @param {string} value
2519
+ * @param {boolean} [final]
2520
+ * @returns {{ entries: Array<{ text: string, sep: string }>, rest: string }}
2521
+ */
2522
+ #resolveTokens(value, final = false) {
2523
+ const custom = this.options.tokenize;
2524
+ if (typeof custom === "function") {
2525
+ const result = custom(value, { combobox: this, source: this.source, input: this.#inputEl() });
2526
+ const tokens = result && Array.isArray(result.tokens) ? result.tokens : [];
2527
+ const entries = tokens.map((text) => ({ text: String(text), sep: "" }));
2528
+ return { entries, rest: final ? "" : String(result?.rest ?? "") };
2529
+ }
2530
+
2531
+ const { done, rest } = splitTokens(value, this.options.separators);
2532
+ const entries = final && rest.trim() ? [...done, { text: rest.trim(), sep: "" }] : done;
2533
+ return { entries, rest: final ? "" : rest };
2534
+ }
2535
+
2536
+ /**
2537
+ * Consume completed tokens sequentially. Never Promise.all a batch: each
2538
+ * token runs existing -> guard -> create -> select in order, re-evaluating
2539
+ * maxItems between tokens. On refusal/error/maxItems the unprocessed
2540
+ * remainder stays in the input; a trailing incomplete token stays too.
2541
+ * @param {string} value
2542
+ * @param {{ final?: boolean }} [options]
2543
+ * @returns {Promise<{ consumed: boolean, rest: string } | null>}
2544
+ */
2545
+ async #processTokens(value, { final = false } = {}) {
2546
+ if (!this.#separatorsActive()) return null;
2547
+
2548
+ const { entries, rest } = this.#resolveTokens(value, final);
2549
+ if (!entries.length) return { consumed: false, rest };
2550
+
2551
+ let consumedLength = 0;
2552
+ for (let index = 0; index < entries.length; index++) {
2553
+ const entry = entries[index];
2554
+ if (this.options.maxItems > 0 && this.#selectSource().selectedOptions.length >= this.options.maxItems) {
2555
+ return { consumed: false, rest: value.slice(consumedLength) };
2556
+ }
2557
+ if (!(await this.#applyToken(entry.text))) {
2558
+ return { consumed: false, rest: value.slice(consumedLength) };
2559
+ }
2560
+ consumedLength += entry.text.length + entry.sep.length;
2561
+ }
2562
+ return { consumed: true, rest: final ? "" : rest };
2563
+ }
2564
+
2565
+ /** Apply one token: existing option wins, otherwise guarded creation. */
2566
+ /**
2567
+ * @param {string | null | undefined} text
2568
+ * @returns {Promise<boolean>}
2569
+ */
2570
+ async #applyToken(text) {
2571
+ const term = String(text ?? "").trim();
2572
+ if (!term) return true;
2573
+
2574
+ const existing = this.#findCreateMatch(term);
2575
+ if (existing) {
2576
+ this.#selectItem(existing);
2577
+ return true;
2578
+ }
2579
+ if (!this.#canCreate(term)) return false;
2580
+ const created = await this.#createItem(term);
2581
+ return created !== null;
2582
+ }
2583
+
2584
+ async #handleTokenInput() {
2585
+ const result = await this.#processTokens(this.#inputEl().value);
2586
+ // #processTokens always returns the computed remainder (`rest`): the
2587
+ // trailing incomplete token on a full consumption, or the unconsumed tail
2588
+ // (including a refused token) on a refusal/maxItems stop. #createItem
2589
+ // clears the interaction input for each created token, so the remainder
2590
+ // must be written back in both cases — otherwise a mid-batch refusal
2591
+ // would silently swallow the rest of the pasted text.
2592
+ if (result) this.#inputEl().value = result.rest;
2593
+ this.search(this.#inputEl().value, { show: true, reason: "input" });
2594
+ }
2595
+
2596
+ async #commitEnterTokens() {
2597
+ const result = await this.#processTokens(this.#inputEl().value, { final: true });
2598
+ if (result?.consumed) {
2599
+ this.#inputEl().value = result.rest;
2600
+ this.search("", { show: true, reason: "create" });
2601
+ }
2602
+ }
2603
+
2604
+ #dispatchNativeValueEvents() {
2605
+ this.source.dispatchEvent(new Event("input", { bubbles: true }));
2606
+ this.source.dispatchEvent(new Event("change", { bubbles: true }));
2607
+ }
2608
+
2609
+ #commit() {
2610
+ this.source.removeAttribute("aria-invalid");
2611
+ this.#inputEl()?.removeAttribute("aria-invalid");
2612
+ this.#dispatchNativeValueEvents();
2613
+ }
2614
+
2615
+ /* ---------------------------------------------------------------------- */
2616
+ /* Public state */
2617
+ /* ---------------------------------------------------------------------- */
2618
+
2619
+ /**
2620
+ * Add a catalogue option to the select.
2621
+ * @param {*} rawItem
2622
+ * @param {{ selected?: boolean }} [options]
2623
+ * @returns {HTMLOptionElement}
2624
+ */
2625
+ addOption(rawItem, { selected = false } = {}) {
2626
+ if (!this.isSelect) throw new TypeError("addOption() is only available for select-backed comboboxes");
2627
+ const item = toItem(rawItem, this.#fields());
2628
+ if (!item) throw new TypeError("Option requires a value");
2629
+ // `""` is a legitimate value only when allowEmptyOption admits it; the
2630
+ // empty-placeholder convention stays the single-select default otherwise.
2631
+ if (item.value === "" && !this.options.allowEmptyOption) throw new TypeError("Option requires a value");
2632
+
2633
+ // Each catalogue entry is its own identity: an existing value never
2634
+ // short-circuits a fresh option, so two distinct {value: "2"} entries stay
2635
+ // distinct choices. An explicit item.option is adopted as-is instead.
2636
+ const option =
2637
+ item.option instanceof HTMLOptionElement
2638
+ ? item.option
2639
+ : // `selected` is live state only. `defaultSelected` belongs to authored
2640
+ // markup (or an explicit setOptions catalogue replacement), otherwise
2641
+ // a dynamic selection would silently rewrite form.reset()'s baseline.
2642
+ new Option(item.label, item.value, false, selected);
2643
+ if (!(item.option instanceof HTMLOptionElement)) {
2644
+ option.disabled = Boolean(item.disabled);
2645
+ if (item.data) Object.assign(option.dataset, item.data);
2646
+ if (item.group) {
2647
+ let group = /** @type {HTMLOptGroupElement | undefined} */ (
2648
+ Array.from(this.#selectSource().children).find(
2649
+ (node) => node instanceof HTMLOptGroupElement && node.label === item.group,
2650
+ )
2651
+ );
2652
+ if (!group) {
2653
+ group = document.createElement("optgroup");
2654
+ group.label = item.group;
2655
+ this.#selectSource().append(group);
2656
+ }
2657
+ group.append(option);
2658
+ } else {
2659
+ this.#selectSource().add(option);
2660
+ }
2661
+ }
2662
+ if (selected && !option.selected) option.selected = true;
2663
+ if (selected) this.#rememberSelection(option);
2664
+ return option;
2665
+ }
2666
+
2667
+ /**
2668
+ * Select an item. Bare values resolve to existing catalogue entries (never
2669
+ * materialising new options); objects/options may materialise.
2670
+ * @param {string | number | import("./helpers.js").ComboboxItem | HTMLOptionElement} itemOrValue
2671
+ * @returns {boolean}
2672
+ */
2673
+ select(itemOrValue) {
2674
+ const isObject = typeof itemOrValue === "object" && itemOrValue !== null;
2675
+
2676
+ if (this.mode === "fallback" && this.isSelect) {
2677
+ const item = isObject
2678
+ ? toItem(itemOrValue, this.#fields())
2679
+ : { value: String(itemOrValue), label: String(itemOrValue) };
2680
+ if (!item) return false;
2681
+ const option =
2682
+ (this.isMultiple ? this.#findSelectableOption(item.value) : null) || this.#findOption(item.value);
2683
+ if (!option) {
2684
+ if (!isObject) return false;
2685
+ const created = this.addOption(item, { selected: true });
2686
+ this.#dispatchNativeValueEvents();
2687
+ return created !== null;
2688
+ }
2689
+ if (option.disabled) return false;
2690
+ const unchanged = this.isMultiple ? option.selected : this.source.value === option.value;
2691
+ if (unchanged) return false;
2692
+ if (!this.isMultiple) {
2693
+ for (const other of this.#selectSource().options) other.selected = false;
2694
+ }
2695
+ option.selected = true;
2696
+ this.#rememberSelection(option);
2697
+ this.#dispatchNativeValueEvents();
2698
+ return true;
2699
+ }
2700
+
2701
+ if (isObject) {
2702
+ const item = toItem(itemOrValue, this.#fields());
2703
+ if (!item) return false;
2704
+ // An exact <option> passed to select() keeps its identity, so a
2705
+ // duplicate value is never resolved back to the first occurrence.
2706
+ if (itemOrValue instanceof HTMLOptionElement) item.option = itemOrValue;
2707
+ return this.#selectItem(item, { materialize: true });
2708
+ }
2709
+
2710
+ // A bare string means "select an existing catalogue value": no implicit
2711
+ // creation, and each call resolves to the next selectable occurrence (see
2712
+ // #findSelectableOption), so select("2") x3 picks three distinct options.
2713
+ const value = String(itemOrValue);
2714
+ const foundRaw =
2715
+ this.#items().find((candidate) => candidate.value === value) ||
2716
+ this.#sourceItems().find((candidate) => candidate.value === value);
2717
+ const found = foundRaw ? /** @type {import("./helpers.js").ComboboxItem} */ (foundRaw) : null;
2718
+ if (!found) return false;
2719
+ return this.#selectItem({ value: found.value, label: found.label }, { materialize: false });
2720
+ }
2721
+
2722
+ /**
2723
+ * @param {string | HTMLOptionElement} valueOrOption
2724
+ * @returns {Promise<boolean>}
2725
+ */
2726
+ async remove(valueOrOption) {
2727
+ if (!this.isSelect) return false;
2728
+ // An exact option is authoritative (the chip a user clicked); a bare value
2729
+ // resolves to the first selected occurrence in the current order.
2730
+ const option =
2731
+ valueOrOption instanceof HTMLOptionElement
2732
+ ? valueOrOption
2733
+ : this.#selectedOptionsInOrder().find((entry) => entry.value === String(valueOrOption));
2734
+ if (!option?.selected || option.disabled) return false;
2735
+ const item = {
2736
+ value: option.value,
2737
+ label: option.textContent.trim(),
2738
+ option,
2739
+ selected: true,
2740
+ data: { ...option.dataset },
2741
+ };
2742
+ const guard = await this.#runGuard("remove", { item });
2743
+ if (!guard.ok) return false;
2744
+ const before = emit(this.source, "combobox:beforeremove", { combobox: this, item }, { cancelable: true });
2745
+ if (before.defaultPrevented) return false;
2746
+ option.selected = false;
2747
+ this.#forgetSelection(option);
2748
+ this.#commit();
2749
+ emit(this.source, "combobox:remove", { combobox: this, item });
2750
+ this.refresh();
2751
+ this.#markEngineMutation();
2752
+ return true;
2753
+ }
2754
+
2755
+ async clear() {
2756
+ if (!this.isSelect) {
2757
+ if (!this.source.value) return false;
2758
+ const guard = await this.#runGuard("clear", {});
2759
+ if (!guard.ok) return false;
2760
+ const before = emit(this.source, "combobox:beforeclear", { combobox: this }, { cancelable: true });
2761
+ if (before.defaultPrevented) return false;
2762
+ this.source.value = "";
2763
+ this.#dispatchNativeValueEvents();
2764
+ emit(this.source, "combobox:clear", { combobox: this });
2765
+ return true;
2766
+ }
2767
+
2768
+ const selected = Array.from(this.#selectSource().selectedOptions).filter((option) => !option.disabled);
2769
+ if (!selected.length) return false;
2770
+ const guard = await this.#runGuard("clear", {});
2771
+ if (!guard.ok) return false;
2772
+ const before = emit(this.source, "combobox:beforeclear", { combobox: this }, { cancelable: true });
2773
+ if (before.defaultPrevented) return false;
2774
+ for (const option of selected) option.selected = false;
2775
+ this.selectionOrder = this.selectionOrder.filter((option) => option.selected);
2776
+ this.#commit();
2777
+ emit(this.source, "combobox:clear", { combobox: this });
2778
+ this.refresh();
2779
+ this.#markEngineMutation();
2780
+ return true;
2781
+ }
2782
+
2783
+ /**
2784
+ * @returns {string[]}
2785
+ */
2786
+ getSelectedValues() {
2787
+ if (!this.isSelect) return [this.source.value].filter(Boolean);
2788
+ return this.#selectedOptionsInOrder().map((option) => option.value);
2789
+ }
2790
+
2791
+ /**
2792
+ * @returns {import("./helpers.js").ComboboxItem[]}
2793
+ */
2794
+ getSelectedItems() {
2795
+ if (!this.isSelect)
2796
+ return [{ value: this.source.value, label: this.source.value }].filter((item) => item.value);
2797
+ return this.#selectedOptionsInOrder().map((option) => ({
2798
+ value: option.value,
2799
+ label: option.textContent.trim(),
2800
+ option,
2801
+ data: { ...option.dataset },
2802
+ }));
2803
+ }
2804
+
2805
+ /**
2806
+ * @param {string | HTMLOptionElement} itemOrValue
2807
+ * @param {number} index
2808
+ * @returns {boolean}
2809
+ */
2810
+ move(itemOrValue, index) {
2811
+ if (!this.isMultiple || this.options.selectionOrder !== "selected") return false;
2812
+ // An exact option moves that identity (duplicate values stay distinct); a
2813
+ // bare value moves the first selected occurrence in the ordered model.
2814
+ const option =
2815
+ itemOrValue instanceof HTMLOptionElement
2816
+ ? itemOrValue
2817
+ : this.selectionOrder.find((entry) => entry.value === String(itemOrValue));
2818
+ if (!option?.selected) return false;
2819
+
2820
+ const moved = moveValueInOrder(this.selectionOrder, option, index);
2821
+ if (!moved) return false;
2822
+ const { order: nextOrder, from, to } = moved;
2823
+
2824
+ const before = emit(
2825
+ this.source,
2826
+ "combobox:beforereorder",
2827
+ { combobox: this, value: option.value, from, to },
2828
+ { cancelable: true },
2829
+ );
2830
+ if (before.defaultPrevented) return false;
2831
+ this.selectionOrder = nextOrder;
2832
+ this.#renderChips();
2833
+ emit(this.source, "combobox:reorder", {
2834
+ combobox: this,
2835
+ value: option.value,
2836
+ from,
2837
+ to,
2838
+ values: this.getSelectedValues(),
2839
+ });
2840
+ return true;
2841
+ }
2842
+
2843
+ async loadMore() {
2844
+ if (!this.nextCursor || typeof this.options.load !== "function") return false;
2845
+ const cursor = this.nextCursor;
2846
+ await this.#load(this.query, { cursor, append: true, debounce: false });
2847
+ this.#applyFilter(this.query);
2848
+ return true;
2849
+ }
2850
+
2851
+ refresh() {
2852
+ if (this.mode !== "enhanced") return this;
2853
+
2854
+ if (this.isSelect) {
2855
+ this.#inputEl().disabled = this.source.disabled;
2856
+ this.#inputEl().readOnly = this.source.hasAttribute("readonly");
2857
+ for (const option of this.#selectSource().selectedOptions) this.#rememberSelection(option);
2858
+ if (this.source.required) this.#inputEl().setAttribute("aria-required", "true");
2859
+ else this.#inputEl().removeAttribute("aria-required");
2860
+ if (this.isMultiple) this.#renderChips();
2861
+ else this.#syncSingleLabel();
2862
+ }
2863
+
2864
+ this.#applyFilter(this.isSelect && !this.isMultiple ? "" : this.#inputEl().value);
2865
+ return this;
2866
+ }
2867
+
2868
+ #syncSingleLabel() {
2869
+ const selected = this.#selectSource().selectedOptions[0];
2870
+ this.#inputEl().value = selected?.value ? selected.textContent.trim() : "";
2871
+ }
2872
+
2873
+ show() {
2874
+ if (this.mode !== "enhanced" || this.isOpen()) return false;
2875
+ if (openCombobox && openCombobox !== this) {
2876
+ openCombobox.hide();
2877
+ if (openCombobox?.isOpen()) return false;
2878
+ }
2879
+ const before = emit(this.source, "combobox:beforeopen", { combobox: this }, { cancelable: true });
2880
+ if (before.defaultPrevented) return false;
2881
+
2882
+ try {
2883
+ this.#popoverEl().showPopover({ source: this.#inputEl() });
2884
+ } catch {
2885
+ this.#popoverEl().showPopover();
2886
+ }
2887
+ openCombobox = this;
2888
+ return true;
2889
+ }
2890
+
2891
+ hide() {
2892
+ if (this.mode !== "enhanced" || !this.isOpen()) return false;
2893
+ const before = emit(this.source, "combobox:beforeclose", { combobox: this }, { cancelable: true });
2894
+ if (before.defaultPrevented) return false;
2895
+ this.#popoverEl().hidePopover();
2896
+ if (openCombobox === this) openCombobox = null;
2897
+ return true;
2898
+ }
2899
+
2900
+ isOpen() {
2901
+ return this.mode === "enhanced" && this.#popoverEl().matches(":popover-open");
2902
+ }
2903
+
2904
+ dispose() {
2905
+ instances.delete(this.source);
2906
+ this.loadController?.abort();
2907
+ this.abortController.abort();
2908
+ this._sourceObserver?.disconnect();
2909
+ this._sourceObserver = null;
2910
+ if (this._sourceSyncTimer) {
2911
+ clearTimeout(this._sourceSyncTimer);
2912
+ this._sourceSyncTimer = null;
2913
+ }
2914
+
2915
+ if (this.mode === "fallback") {
2916
+ this.fallbackControl?.remove();
2917
+ return;
2918
+ }
2919
+
2920
+ if (openCombobox === this) openCombobox = null;
2921
+ this.#popoverEl()?.remove();
2922
+ this.anchorSnapshot?.restore();
2923
+
2924
+ // Cleanup only touches real source options. An init that failed before the
2925
+ // datalist resolved (input without a valid `list`) never produced mirror
2926
+ // state, and #sourceItems would crash on the null datalist.
2927
+ if (this.isSelect || this.datalist instanceof HTMLDataListElement) {
2928
+ for (const item of this.#sourceItems()) {
2929
+ item.option?.removeAttribute("data-filtered");
2930
+ item.option?.removeAttribute("data-active-option");
2931
+ }
2932
+ }
2933
+
2934
+ // <label> ids invented by #copyAccessibleName are stripped again unless the
2935
+ // application has since reused them.
2936
+ for (const { label, id } of this.original.inventedLabels) {
2937
+ if (label.id === id) label.removeAttribute("id");
2938
+ }
2939
+
2940
+ // Restore every attribute the engine touched on elements it does not own.
2941
+ if (this.isSelect) {
2942
+ this.control?.remove();
2943
+ this.source.classList.remove("cb-source-hidden");
2944
+ this.sourceSnapshot?.restore();
2945
+
2946
+ // A placeholder that was consumed by a previous dispose() has no parent.
2947
+ // Restoring must also work when the whole wrapper subtree is detached.
2948
+ if (!this.ownsInput && this.#inputEl() && this.original.filterInputPlaceholder?.parentNode) {
2949
+ this.original.filterInputPlaceholder.replaceWith(this.#inputEl());
2950
+ }
2951
+ this.#inputEl()?.classList.remove("cb-input");
2952
+ this.inputSnapshot?.restore();
2953
+ } else {
2954
+ this.source.classList.remove("cb-text-control");
2955
+ // A placeholder that was consumed by a previous dispose() has no parent.
2956
+ // Restoring must also work when the whole wrapper subtree is detached.
2957
+ const datalist = this.datalist;
2958
+ if (datalist && this.original.datalistPlaceholder?.parentNode) {
2959
+ this.original.datalistPlaceholder.replaceWith(datalist);
2960
+ }
2961
+ this.inputSnapshot?.restore();
2962
+ }
2963
+ }
2964
+ }
2965
+
2966
+ // TODO / platform migration notes:
2967
+ // - When Open UI's native `search`, `beforefilter`, `:filtered`, and
2968
+ // `:active-option` primitives ship broadly, the matching state above can be
2969
+ // progressively delegated to the browser without changing the public API.
2970
+ // - When filterable-select settles on a declarative input/select relationship,
2971
+ // #resolveFilterInput() is the adapter boundary to map to it.
2972
+ // - If native customizable select multiple becomes interoperable, chips can
2973
+ // remain script-owned while the picker/listbox implementation shrinks.
2974
+
2975
+ export default Combobox;