@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,2220 @@
1
+ /*** @lekoala/combobox v0.1.0 - https://github.com/lekoala/combobox ***/
2
+ (() => {
3
+ // src/helpers.js
4
+ function hasOwn(object, key) {
5
+ return Object.hasOwn(object, key);
6
+ }
7
+ function escapeRegExp(value) {
8
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
9
+ }
10
+ function stripDiacritics(value) {
11
+ return String(value ?? "").normalize("NFD").replace(/[\u0300-\u036f]/g, "");
12
+ }
13
+ function normalize(value) {
14
+ return stripDiacritics(value).toLocaleLowerCase();
15
+ }
16
+ function matchesField(value, query, mode) {
17
+ const normalized = normalize(value);
18
+ const lookup = normalize(query);
19
+ switch (String(mode).toLowerCase()) {
20
+ case "startswith":
21
+ return normalized.startsWith(lookup);
22
+ case "fuzzy":
23
+ return fuzzyMatch(normalized, lookup);
24
+ case "pattern":
25
+ return patternMatch(value, query);
26
+ default:
27
+ return normalized.includes(lookup);
28
+ }
29
+ }
30
+ function patternMatch(value, query) {
31
+ try {
32
+ const raw = String(value ?? "");
33
+ const source = String(query ?? "");
34
+ const pattern = new RegExp(source, "i");
35
+ const foldedQuery = stripDiacritics(source);
36
+ const foldedPattern = foldedQuery === source ? pattern : new RegExp(foldedQuery, "i");
37
+ return pattern.test(raw) || foldedPattern.test(stripDiacritics(raw));
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+ function toItem(raw, fields = null) {
43
+ if (raw == null)
44
+ return null;
45
+ if (typeof raw === "string" || typeof raw === "number") {
46
+ return { value: String(raw), label: String(raw) };
47
+ }
48
+ if (fields && (fields.labelField || fields.valueField) && !hasOwn(raw, "value") && !hasOwn(raw, "label")) {
49
+ const value = (fields.valueField && raw[fields.valueField]) ?? raw.id ?? raw.label ?? "";
50
+ const label = (fields.labelField && raw[fields.labelField]) ?? raw.text ?? raw.value ?? raw.id ?? "";
51
+ return { ...raw, value: String(value ?? ""), label: String(label ?? "") };
52
+ } else {
53
+ const value = raw.value ?? raw.id ?? raw.label ?? "";
54
+ const label = raw.label ?? raw.text ?? raw.value ?? raw.id ?? "";
55
+ return { ...raw, value: String(value ?? ""), label: String(label ?? "") };
56
+ }
57
+ }
58
+ function parseSeparators(raw) {
59
+ if (Array.isArray(raw)) {
60
+ return raw.map(String).filter((separator) => separator.length > 0);
61
+ }
62
+ if (raw == null)
63
+ return [];
64
+ return String(raw).split("|").filter((separator) => separator.length > 0);
65
+ }
66
+ function splitTokens(input, separators) {
67
+ const result = {
68
+ done: [],
69
+ rest: String(input ?? "")
70
+ };
71
+ if (!result.rest)
72
+ return result;
73
+ const kinds = parseSeparators(separators).sort((a, b) => b.length - a.length);
74
+ if (!kinds.length)
75
+ return result;
76
+ const pattern = new RegExp(`(${kinds.map(escapeRegExp).join("|")})`, "g");
77
+ const parts = result.rest.split(pattern);
78
+ let buffer = "";
79
+ const done = [];
80
+ for (const part of parts) {
81
+ if (kinds.includes(part)) {
82
+ if (buffer)
83
+ done.push({ text: buffer, sep: part });
84
+ buffer = "";
85
+ } else {
86
+ buffer += part;
87
+ }
88
+ }
89
+ result.done = done;
90
+ result.rest = buffer;
91
+ return result;
92
+ }
93
+ function rankByScore(items, score) {
94
+ return items.map((item, index) => ({ item, index, score: score(item, index) })).filter((entry) => entry.score !== false && entry.score !== null).sort((a, b) => Number(b.score) - Number(a.score) || a.index - b.index).map((entry) => entry.item);
95
+ }
96
+ function reconcileSelected(values, order) {
97
+ const remaining = new Set(values);
98
+ const result = [];
99
+ for (const value of order) {
100
+ if (remaining.has(value)) {
101
+ result.push(value);
102
+ remaining.delete(value);
103
+ }
104
+ }
105
+ result.push(...remaining);
106
+ return result;
107
+ }
108
+ function moveValueInOrder(list, identity, index) {
109
+ const order = [...list];
110
+ const from = order.indexOf(identity);
111
+ if (from < 0)
112
+ return null;
113
+ const to = Math.max(0, Math.min(Number(index), order.length - 1));
114
+ if (from === to)
115
+ return null;
116
+ order.splice(to, 0, ...order.splice(from, 1));
117
+ return { order, from, to };
118
+ }
119
+ function parseList(raw) {
120
+ if (raw == null)
121
+ return [];
122
+ return String(raw).split(",").map((entry) => entry.trim()).filter(Boolean);
123
+ }
124
+ function booleanAttribute(element, name) {
125
+ if (!element.hasAttribute(name))
126
+ return;
127
+ return element.getAttribute(name) !== "false";
128
+ }
129
+ function parseInteger(raw) {
130
+ if (raw == null)
131
+ return;
132
+ const number = Number(raw);
133
+ return Number.isInteger(number) ? number : undefined;
134
+ }
135
+ function fuzzyMatch(str, lookup) {
136
+ const wanted = String(lookup ?? "");
137
+ if (!wanted.trim())
138
+ return true;
139
+ if (str.includes(wanted))
140
+ return true;
141
+ let pos = 0;
142
+ for (const char of wanted) {
143
+ if (char === " ")
144
+ continue;
145
+ const index = str.indexOf(char, pos);
146
+ if (index === -1)
147
+ return false;
148
+ pos = index + char.length;
149
+ }
150
+ return true;
151
+ }
152
+
153
+ // src/messages.js
154
+ var DEFAULT_MESSAGES = {
155
+ noResults: "No results",
156
+ loading: "Loading…",
157
+ loadError: "Failed to load results",
158
+ create: (query) => `Create “${query}”`,
159
+ position: (label, position, total) => `${label} position ${position} of ${total}`
160
+ };
161
+ function getDefaultMessages() {
162
+ return { ...DEFAULT_MESSAGES };
163
+ }
164
+ function setDefaultMessages(messages) {
165
+ Object.assign(DEFAULT_MESSAGES, messages);
166
+ }
167
+
168
+ // src/combobox.js
169
+ var instances = new WeakMap;
170
+ var uid = 0;
171
+ var openCombobox = null;
172
+ var DEFAULTS = {
173
+ create: false,
174
+ allowEmptyOption: false,
175
+ placeholder: "Search…",
176
+ messages: DEFAULT_MESSAGES,
177
+ match: "includes",
178
+ searchFields: ["label"],
179
+ minChars: 0,
180
+ load: null,
181
+ loadOnEmpty: false,
182
+ shouldLoad: null,
183
+ debounce: 200,
184
+ createFilter: null,
185
+ maxItems: 0,
186
+ maxOptions: 0,
187
+ separators: [],
188
+ tokenize: null,
189
+ closeOnSelect: undefined,
190
+ createOnBlur: false,
191
+ autoselectFirst: false,
192
+ tabSelect: false,
193
+ labelField: undefined,
194
+ valueField: undefined,
195
+ guards: {},
196
+ selectionOrder: "source",
197
+ observeSource: false,
198
+ sort: null,
199
+ score: null,
200
+ filter: null,
201
+ render: {},
202
+ anchor: null
203
+ };
204
+ function supportsModernCombobox() {
205
+ return typeof HTMLElement.prototype.showPopover === "function" && typeof HTMLElement.prototype.hidePopover === "function" && CSS.supports("position-area: bottom") && CSS.supports("inline-size: anchor-size(width)") && CSS.supports("position-try: flip-block");
206
+ }
207
+ function emit(target, type, detail = {}, { cancelable = false } = {}) {
208
+ const event = new CustomEvent(type, {
209
+ bubbles: true,
210
+ cancelable,
211
+ detail
212
+ });
213
+ if (hasOwn(detail, "query")) {
214
+ Object.defineProperty(event, "query", {
215
+ configurable: true,
216
+ enumerable: true,
217
+ value: detail.query
218
+ });
219
+ }
220
+ target.dispatchEvent(event);
221
+ return event;
222
+ }
223
+ function wait(ms, signal) {
224
+ return new Promise((resolve, reject) => {
225
+ const timer = setTimeout(resolve, ms);
226
+ signal?.addEventListener("abort", () => {
227
+ clearTimeout(timer);
228
+ reject(new DOMException("Aborted", "AbortError"));
229
+ }, { once: true });
230
+ });
231
+ }
232
+ function setContent(element, content) {
233
+ element.replaceChildren();
234
+ if (content instanceof Node) {
235
+ element.append(content);
236
+ } else if (content !== null && content !== undefined) {
237
+ element.textContent = String(content);
238
+ }
239
+ }
240
+ function createRemoveIcon() {
241
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
242
+ svg.setAttribute("viewBox", "0 0 20 20");
243
+ svg.setAttribute("aria-hidden", "true");
244
+ svg.setAttribute("focusable", "false");
245
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
246
+ path.setAttribute("d", "M4 4l12 12m0-12L4 16");
247
+ svg.append(path);
248
+ return svg;
249
+ }
250
+ function captureAttributes(element, names) {
251
+ const original = new Map(names.map((name) => [name, element.getAttribute(name)]));
252
+ return {
253
+ restore() {
254
+ for (const [name, value] of original) {
255
+ if (value === null)
256
+ element.removeAttribute(name);
257
+ else
258
+ element.setAttribute(name, value);
259
+ }
260
+ }
261
+ };
262
+ }
263
+ var INPUT_ATTRS = [
264
+ "list",
265
+ "name",
266
+ "type",
267
+ "autocomplete",
268
+ "spellcheck",
269
+ "placeholder",
270
+ "hidden",
271
+ "tabindex",
272
+ "role",
273
+ "aria-autocomplete",
274
+ "aria-expanded",
275
+ "aria-controls",
276
+ "aria-activedescendant",
277
+ "aria-invalid",
278
+ "aria-label",
279
+ "aria-labelledby",
280
+ "aria-required",
281
+ "aria-describedby",
282
+ "style"
283
+ ];
284
+
285
+ class Combobox {
286
+ static supported = supportsModernCombobox();
287
+ static getDefaultMessages() {
288
+ return getDefaultMessages();
289
+ }
290
+ static setDefaultMessages(messages) {
291
+ setDefaultMessages(messages);
292
+ }
293
+ static init(rootOrSelector = document, selectorOrOptions = null, maybeOptions = {}) {
294
+ const targets = [];
295
+ let options = {};
296
+ const isNode = (value) => value instanceof Node;
297
+ const picks = (value) => value !== null && typeof value === "object" && !isNode(value) && !Array.isArray(value) ? value : {};
298
+ if (typeof rootOrSelector === "string") {
299
+ targets.push(...document.querySelectorAll(rootOrSelector));
300
+ options = picks(selectorOrOptions);
301
+ } else if (isNode(rootOrSelector)) {
302
+ const root = rootOrSelector;
303
+ if (typeof selectorOrOptions === "string") {
304
+ targets.push(...root.querySelectorAll(selectorOrOptions));
305
+ options = maybeOptions;
306
+ } else {
307
+ options = picks(selectorOrOptions);
308
+ }
309
+ } else {
310
+ targets.push(...Array.from(rootOrSelector ?? []));
311
+ options = picks(selectorOrOptions);
312
+ }
313
+ const instances2 = [];
314
+ for (const element of targets) {
315
+ if (!(element instanceof HTMLInputElement || element instanceof HTMLSelectElement))
316
+ continue;
317
+ const instance = Combobox.getOrCreateInstance(element, options);
318
+ if (instance && !instances2.includes(instance))
319
+ instances2.push(instance);
320
+ }
321
+ return instances2;
322
+ }
323
+ static getInstance(element) {
324
+ return instances.get(element) ?? null;
325
+ }
326
+ static getOrCreateInstance(element, options = {}) {
327
+ return Combobox.getInstance(element) ?? new Combobox(element, options);
328
+ }
329
+ constructor(element, options = {}) {
330
+ if (!(element instanceof HTMLInputElement || element instanceof HTMLSelectElement)) {
331
+ throw new TypeError("Combobox expects an input or select element");
332
+ }
333
+ this.source = element;
334
+ this.isSelect = element instanceof HTMLSelectElement;
335
+ this.isMultiple = this.isSelect && element.multiple;
336
+ this.abortController = new AbortController;
337
+ this.loadController = null;
338
+ this.activeIndex = -1;
339
+ this.filteredItems = [];
340
+ this.results = null;
341
+ this.selectionOrder = this.isSelect ? Array.from(this.#selectSource().selectedOptions) : [];
342
+ this._chipOptions = new WeakMap;
343
+ this.searchGeneration = 0;
344
+ this.nextCursor = null;
345
+ this.loading = false;
346
+ this.loadError = null;
347
+ this.query = "";
348
+ this.id = ++uid;
349
+ this.mode = options.mode === "fallback" || !Combobox.supported ? "fallback" : "enhanced";
350
+ this.anchorName = `--combobox-${this.id}`;
351
+ this.suppressReopen = false;
352
+ this.composing = false;
353
+ this._sourceObserver = null;
354
+ this._sourceSyncTimer = null;
355
+ this.explicitOptions = options;
356
+ this.options = {
357
+ ...DEFAULTS,
358
+ ...options,
359
+ messages: {
360
+ ...DEFAULT_MESSAGES,
361
+ ...options.messages || {}
362
+ },
363
+ render: {
364
+ ...DEFAULTS.render,
365
+ ...options.render || {}
366
+ }
367
+ };
368
+ this.original = {
369
+ filterInputPlaceholder: null,
370
+ datalistPlaceholder: null,
371
+ inventedLabels: []
372
+ };
373
+ this.boundLabels = [];
374
+ this.ownsInput = false;
375
+ this.fallbackControl = null;
376
+ this.control = null;
377
+ this.anchor = null;
378
+ this.anchorSnapshot = null;
379
+ this.input = null;
380
+ this.chips = null;
381
+ this.datalist = null;
382
+ this.inputSnapshot = null;
383
+ this.sourceSnapshot = null;
384
+ this.popover = null;
385
+ this.listbox = null;
386
+ this.status = null;
387
+ if (this.mode === "fallback") {
388
+ this.#initFallback();
389
+ } else {
390
+ try {
391
+ const view = element instanceof HTMLSelectElement ? this.#enhanceSelect(element) : this.#enhanceInput(element);
392
+ this.control = view.control;
393
+ this.input = view.input;
394
+ this.chips = view.chips;
395
+ this.datalist = view.datalist;
396
+ this.inputSnapshot = view.inputSnapshot;
397
+ this.sourceSnapshot = view.sourceSnapshot;
398
+ const requestedAnchor = this.options.anchor;
399
+ this.anchor = requestedAnchor instanceof HTMLElement ? requestedAnchor : view.control || view.input;
400
+ if (this.anchor !== view.control && this.anchor !== view.input) {
401
+ this.anchorSnapshot = captureAttributes(this.anchor, ["style"]);
402
+ }
403
+ this.anchor.style.setProperty("anchor-name", this.anchorName);
404
+ const picker = this.#createPicker();
405
+ this.popover = picker.popover;
406
+ this.listbox = picker.listbox;
407
+ this.status = picker.status;
408
+ this.#bind();
409
+ this.refresh();
410
+ this.#watchSource();
411
+ } catch (error) {
412
+ this.dispose();
413
+ throw error;
414
+ }
415
+ }
416
+ instances.set(element, this);
417
+ }
418
+ #selectSource() {
419
+ if (!(this.source instanceof HTMLSelectElement)) {
420
+ throw new TypeError("Expected a select-backed combobox");
421
+ }
422
+ return this.source;
423
+ }
424
+ #inputEl() {
425
+ return this.input;
426
+ }
427
+ #popoverEl() {
428
+ return this.popover;
429
+ }
430
+ #listEl() {
431
+ return this.listbox;
432
+ }
433
+ #statusEl() {
434
+ return this.status;
435
+ }
436
+ #chipsEl() {
437
+ return this.chips;
438
+ }
439
+ #initFallback() {
440
+ if (!this.isSelect || !this.options.create)
441
+ return;
442
+ const control = document.createElement("div");
443
+ control.className = "cb-fallback-create";
444
+ const input = document.createElement("input");
445
+ input.type = "text";
446
+ input.className = "cb-fallback-input";
447
+ input.placeholder = this.options.placeholder ?? "";
448
+ input.autocomplete = "off";
449
+ input.setAttribute("aria-label", this.options.placeholder ?? "");
450
+ const button = document.createElement("button");
451
+ button.type = "button";
452
+ button.className = "cb-fallback-add";
453
+ button.textContent = "Add";
454
+ const add = async () => {
455
+ const label = input.value.trim();
456
+ if (!this.#canCreate(label))
457
+ return;
458
+ await this.#createFallbackOption(label, input);
459
+ input.value = "";
460
+ input.focus();
461
+ };
462
+ button.addEventListener("click", add, { signal: this.abortController.signal });
463
+ input.addEventListener("keydown", (event) => {
464
+ if (event.key === "Enter") {
465
+ event.preventDefault();
466
+ add();
467
+ }
468
+ }, { signal: this.abortController.signal });
469
+ control.append(input, button);
470
+ this.source.after(control);
471
+ this.fallbackControl = control;
472
+ }
473
+ async#createFallbackOption(label, input) {
474
+ const guard = await this.#runGuard("add", { label });
475
+ if (!guard.ok)
476
+ return null;
477
+ const before = emit(this.source, "combobox:beforecreate", { combobox: this, label }, { cancelable: true });
478
+ if (before.defaultPrevented)
479
+ return null;
480
+ let created = { value: label, label };
481
+ try {
482
+ if (typeof this.options.create === "function") {
483
+ const result = await this.options.create(label, {
484
+ signal: this.abortController.signal,
485
+ combobox: this,
486
+ source: this.source,
487
+ input,
488
+ fallback: true
489
+ });
490
+ if (!result)
491
+ return null;
492
+ created = toItem(result, this.#fields());
493
+ }
494
+ let option = this.#findOption(created.value);
495
+ if (!option) {
496
+ option = new Option(created.label, created.value, false, true);
497
+ if (created.data)
498
+ Object.assign(option.dataset, created.data);
499
+ this.#selectSource().add(option);
500
+ } else {
501
+ option.selected = true;
502
+ }
503
+ this.#rememberSelection(option);
504
+ this.#dispatchNativeValueEvents();
505
+ emit(this.source, "combobox:create", { combobox: this, item: { ...created, option, selected: true } });
506
+ return option;
507
+ } catch (error) {
508
+ if (error?.name !== "AbortError") {
509
+ emit(this.source, "combobox:createerror", { combobox: this, label, error });
510
+ }
511
+ return null;
512
+ }
513
+ }
514
+ #enhanceInput(source) {
515
+ const listId = source.getAttribute("list");
516
+ if (!listId)
517
+ throw new TypeError("Input combobox expects an input with a datalist");
518
+ const datalist = document.getElementById(listId);
519
+ if (!(datalist instanceof HTMLDataListElement)) {
520
+ throw new TypeError(`No datalist found for #${listId}`);
521
+ }
522
+ const inputSnapshot = captureAttributes(source, INPUT_ATTRS);
523
+ source.removeAttribute("list");
524
+ source.autocomplete = "off";
525
+ const datalistPlaceholder = document.createComment(`combobox-datalist-${this.id}`);
526
+ this.original.datalistPlaceholder = datalistPlaceholder;
527
+ datalist.before(datalistPlaceholder);
528
+ datalist.remove();
529
+ source.classList.add("cb-text-control");
530
+ return {
531
+ control: null,
532
+ input: source,
533
+ chips: null,
534
+ datalist,
535
+ inputSnapshot,
536
+ sourceSnapshot: null
537
+ };
538
+ }
539
+ #enhanceSelect(source) {
540
+ source.classList.add("cb-source-hidden");
541
+ const sourceSnapshot = captureAttributes(source, ["aria-hidden", "tabindex"]);
542
+ source.tabIndex = -1;
543
+ source.setAttribute("aria-hidden", "true");
544
+ const control = document.createElement("div");
545
+ control.className = `cb-control ${this.isMultiple ? "cb-control-multiple" : "cb-control-single"}`;
546
+ const chips = document.createElement("span");
547
+ chips.className = "cb-chips";
548
+ control.append(chips);
549
+ const { input, inputSnapshot } = this.#resolveFilterInput();
550
+ input.classList.add("cb-input");
551
+ input.type = "text";
552
+ input.autocomplete = "off";
553
+ input.spellcheck = false;
554
+ input.removeAttribute("name");
555
+ if (!input.placeholder)
556
+ input.placeholder = this.options.placeholder ?? "";
557
+ this.#copyAccessibleName(input);
558
+ control.append(input);
559
+ source.after(control);
560
+ return {
561
+ control,
562
+ input,
563
+ chips,
564
+ datalist: null,
565
+ inputSnapshot,
566
+ sourceSnapshot
567
+ };
568
+ }
569
+ #resolveFilterInput() {
570
+ let input = null;
571
+ if (this.source.id) {
572
+ input = document.querySelector(`input[data-filter-for="${CSS.escape(this.source.id)}"]`);
573
+ }
574
+ if (input instanceof HTMLInputElement) {
575
+ const inputSnapshot = captureAttributes(input, INPUT_ATTRS);
576
+ const filterInputPlaceholder = document.createComment(`combobox-filter-input-${this.id}`);
577
+ this.original.filterInputPlaceholder = filterInputPlaceholder;
578
+ input.before(filterInputPlaceholder);
579
+ input.hidden = false;
580
+ return { input, inputSnapshot };
581
+ }
582
+ this.ownsInput = true;
583
+ return { input: document.createElement("input"), inputSnapshot: null };
584
+ }
585
+ #copyAccessibleName(input) {
586
+ const labelledBy = this.source.getAttribute("aria-labelledby");
587
+ if (labelledBy) {
588
+ input.setAttribute("aria-labelledby", labelledBy);
589
+ } else {
590
+ this.#copyLabeledNames(input);
591
+ }
592
+ const ariaLabel = this.source.getAttribute("aria-label");
593
+ if (!input.hasAttribute("aria-labelledby") && ariaLabel) {
594
+ input.setAttribute("aria-label", ariaLabel);
595
+ }
596
+ if (this.source.required)
597
+ input.setAttribute("aria-required", "true");
598
+ const describedBy = this.source.getAttribute("aria-describedby");
599
+ if (describedBy) {
600
+ input.setAttribute("aria-describedby", describedBy);
601
+ }
602
+ }
603
+ #copyLabeledNames(input) {
604
+ const labels = [];
605
+ if (this.source.id) {
606
+ labels.push(...document.querySelectorAll(`label[for="${CSS.escape(this.source.id)}"]`));
607
+ }
608
+ const wrapped = this.source.closest("label");
609
+ if (wrapped)
610
+ labels.push(wrapped);
611
+ const seen = new Set;
612
+ this.boundLabels = labels.filter((label) => {
613
+ if (seen.has(label))
614
+ return false;
615
+ seen.add(label);
616
+ return true;
617
+ });
618
+ const labelIds = this.boundLabels.map((label, index) => {
619
+ if (!label.id) {
620
+ label.id = `combobox-label-${this.id}-${index}`;
621
+ this.original.inventedLabels.push({ label, id: label.id });
622
+ }
623
+ return label.id;
624
+ });
625
+ if (labelIds.length)
626
+ input.setAttribute("aria-labelledby", labelIds.join(" "));
627
+ }
628
+ #sourceItems() {
629
+ if (this.isSelect) {
630
+ return Array.from(this.#selectSource().options).filter((option) => option.value || this.options.allowEmptyOption).map((option) => ({
631
+ value: option.value,
632
+ label: option.textContent.trim(),
633
+ disabled: option.disabled || (option.parentElement instanceof HTMLOptGroupElement ? option.parentElement.disabled : false),
634
+ selected: option.selected,
635
+ group: option.parentElement instanceof HTMLOptGroupElement ? option.parentElement.label : "",
636
+ option,
637
+ data: { ...option.dataset }
638
+ }));
639
+ }
640
+ if (!this.datalist)
641
+ return [];
642
+ return Array.from(this.datalist.options).map((option) => ({
643
+ value: option.value,
644
+ label: option.label || option.value,
645
+ disabled: option.disabled,
646
+ selected: this.source.value === option.value,
647
+ group: option.dataset.group || "",
648
+ option,
649
+ data: { ...option.dataset }
650
+ }));
651
+ }
652
+ #items() {
653
+ if (!this.results)
654
+ return this.#sourceItems();
655
+ if (!this.isSelect)
656
+ return this.results;
657
+ return this.results.map((item) => {
658
+ const option = item.option || this.#findOption(item.value);
659
+ return { ...item, selected: option?.selected ?? false, option };
660
+ });
661
+ }
662
+ #fields() {
663
+ const { labelField, valueField } = this.options;
664
+ return labelField || valueField ? { labelField, valueField } : null;
665
+ }
666
+ get visibleItems() {
667
+ return this.options.maxOptions > 0 ? this.filteredItems.slice(0, this.options.maxOptions) : this.filteredItems;
668
+ }
669
+ setResults(items) {
670
+ this.results = Array.from(items || [], (item) => toItem(item, this.#fields())).filter((item) => item !== null);
671
+ return this;
672
+ }
673
+ clearResults() {
674
+ this.results = null;
675
+ this.loadError = null;
676
+ return this;
677
+ }
678
+ #findOption(value) {
679
+ if (!this.isSelect)
680
+ return null;
681
+ const select = this.#selectSource();
682
+ return Array.from(select.options).find((option) => option.value === String(value)) || null;
683
+ }
684
+ #findSelectableOption(value) {
685
+ if (!this.isSelect)
686
+ return null;
687
+ const wanted = String(value);
688
+ return Array.from(this.#selectSource().options).find((option) => option.value === wanted && !option.disabled && (this.isMultiple && option.selected) === false) || null;
689
+ }
690
+ #findCreateMatch(label) {
691
+ const lookup = normalize(label);
692
+ for (const item of this.#sourceItems()) {
693
+ if (normalize(item.value) === lookup || normalize(item.label) === lookup)
694
+ return item;
695
+ }
696
+ return null;
697
+ }
698
+ setOptions(items, { preserveSelected = this.isSelect } = {}) {
699
+ const normalized = Array.from(items || [], (item) => toItem(item, this.#fields())).filter((item) => item !== null);
700
+ if (this.isSelect) {
701
+ const select = this.#selectSource();
702
+ const preserved = preserveSelected ? Array.from(select.selectedOptions).map((option) => ({
703
+ value: option.value,
704
+ label: option.textContent.trim(),
705
+ selected: true,
706
+ disabled: option.disabled,
707
+ group: option.parentElement instanceof HTMLOptGroupElement ? option.parentElement.label : ""
708
+ })) : [];
709
+ const emptyOption = Array.from(select.options).find((option) => !option.value);
710
+ select.replaceChildren();
711
+ if (emptyOption && !this.isMultiple)
712
+ select.append(emptyOption);
713
+ const catalog = [...preserved, ...normalized];
714
+ const groups = new Map;
715
+ for (const item of catalog) {
716
+ if (!item.value && !this.options.allowEmptyOption)
717
+ continue;
718
+ const option = new Option(item.label, item.value, Boolean(item.selected), Boolean(item.selected));
719
+ option.disabled = Boolean(item.disabled);
720
+ if (item.data)
721
+ Object.assign(option.dataset, item.data);
722
+ if (item.group) {
723
+ let group = groups.get(item.group);
724
+ if (!group) {
725
+ group = document.createElement("optgroup");
726
+ group.label = item.group;
727
+ groups.set(item.group, group);
728
+ select.append(group);
729
+ }
730
+ group.append(option);
731
+ } else {
732
+ select.append(option);
733
+ }
734
+ }
735
+ } else {
736
+ if (!this.datalist)
737
+ return this;
738
+ this.datalist.replaceChildren();
739
+ for (const item of normalized) {
740
+ const option = document.createElement("option");
741
+ option.value = item.value;
742
+ if (item.label !== item.value)
743
+ option.label = item.label;
744
+ if (item.data)
745
+ Object.assign(option.dataset, item.data);
746
+ this.datalist.append(option);
747
+ }
748
+ }
749
+ this.clearResults();
750
+ if (this.mode === "enhanced")
751
+ this.refresh();
752
+ this.#markEngineMutation();
753
+ return this;
754
+ }
755
+ sync() {
756
+ this.clearResults();
757
+ this.refresh();
758
+ return this;
759
+ }
760
+ #markEngineMutation() {
761
+ if (this._sourceSyncTimer) {
762
+ clearTimeout(this._sourceSyncTimer);
763
+ this._sourceSyncTimer = null;
764
+ }
765
+ this._sourceObserver?.disconnect();
766
+ this._sourceObserver = null;
767
+ queueMicrotask(() => {
768
+ if (instances.get(this.source) === this && this.options.observeSource && this.mode === "enhanced") {
769
+ this.#watchSource();
770
+ }
771
+ });
772
+ }
773
+ #watchSource() {
774
+ if (!this.options.observeSource || this._sourceObserver)
775
+ return;
776
+ const config = this.isSelect ? {
777
+ childList: true,
778
+ subtree: true,
779
+ attributes: true,
780
+ attributeFilter: ["selected", "disabled", "required", "readonly"]
781
+ } : {
782
+ childList: true,
783
+ attributes: true,
784
+ attributeFilter: ["value", "disabled", "label"]
785
+ };
786
+ this._sourceObserver = new MutationObserver(() => this.#scheduleSourceSync());
787
+ const target = this.isSelect ? this.source : this.datalist;
788
+ if (target)
789
+ this._sourceObserver.observe(target, config);
790
+ }
791
+ #scheduleSourceSync() {
792
+ if (this._sourceSyncTimer)
793
+ clearTimeout(this._sourceSyncTimer);
794
+ this._sourceSyncTimer = setTimeout(() => {
795
+ this._sourceSyncTimer = null;
796
+ if (instances.get(this.source) !== this)
797
+ return;
798
+ this.sync();
799
+ }, 50);
800
+ }
801
+ #createPicker() {
802
+ const popover = document.createElement("div");
803
+ popover.className = "cb-popover";
804
+ popover.popover = "manual";
805
+ popover.style.setProperty("position-anchor", this.anchorName);
806
+ const listbox = document.createElement("div");
807
+ listbox.className = "cb-listbox";
808
+ listbox.role = "listbox";
809
+ listbox.id = `combobox-listbox-${this.id}`;
810
+ if (this.isMultiple)
811
+ listbox.setAttribute("aria-multiselectable", "true");
812
+ const status = document.createElement("div");
813
+ status.className = "cb-status";
814
+ status.setAttribute("role", "status");
815
+ status.setAttribute("aria-live", "polite");
816
+ popover.append(listbox, status);
817
+ const dialog = this.source.closest("dialog");
818
+ (dialog || document.body).append(popover);
819
+ popover.style.font = getComputedStyle(this.#inputEl()).font;
820
+ this.#inputEl().setAttribute("role", "combobox");
821
+ this.#inputEl().setAttribute("aria-autocomplete", "list");
822
+ this.#inputEl().setAttribute("aria-expanded", "false");
823
+ this.#inputEl().setAttribute("aria-controls", listbox.id);
824
+ return { popover, listbox, status };
825
+ }
826
+ #bind() {
827
+ const signal = this.abortController.signal;
828
+ this.#inputEl().addEventListener("focus", this, { signal });
829
+ this.#inputEl().addEventListener("input", this, { signal });
830
+ this.#inputEl().addEventListener("compositionstart", this, { signal });
831
+ this.#inputEl().addEventListener("compositionend", this, { signal });
832
+ this.#inputEl().addEventListener("keydown", this, { signal });
833
+ this.#inputEl().addEventListener("blur", this, { signal });
834
+ this.#listEl().addEventListener("pointerdown", (event) => event.preventDefault(), { signal });
835
+ this.#listEl().addEventListener("pointermove", this, { signal });
836
+ this.#listEl().addEventListener("click", this, { signal });
837
+ if (this.chips) {
838
+ this.chips.addEventListener("keydown", this, { signal });
839
+ this.chips.addEventListener("click", this, { signal });
840
+ }
841
+ this.control?.addEventListener("click", this, { signal });
842
+ document.addEventListener("pointerdown", (event) => {
843
+ if (!this.isOpen())
844
+ return;
845
+ const path = event.composedPath();
846
+ const control = this.anchor || this.control || this.#inputEl();
847
+ if (path.includes(control) || path.includes(this.#popoverEl()))
848
+ return;
849
+ this.hide();
850
+ }, { capture: true, signal });
851
+ this.#popoverEl().addEventListener("toggle", (event) => {
852
+ const open = event.newState === "open";
853
+ this.#inputEl().setAttribute("aria-expanded", String(open));
854
+ emit(this.source, open ? "combobox:open" : "combobox:close", { combobox: this });
855
+ if (!open) {
856
+ this.#setActive(-1);
857
+ if (this.isSelect && !this.isMultiple)
858
+ this.#syncSingleLabel();
859
+ }
860
+ }, { signal });
861
+ if (this.isSelect) {
862
+ this.source.addEventListener("change", () => this.refresh(), { signal });
863
+ this.source.addEventListener("focus", () => this.#inputEl().focus(), { signal });
864
+ for (const label of this.boundLabels) {
865
+ label.addEventListener("click", (event) => {
866
+ event.preventDefault();
867
+ this.#inputEl().focus();
868
+ }, { signal });
869
+ }
870
+ this.source.form?.addEventListener("reset", () => queueMicrotask(() => this.refresh()), { signal });
871
+ if (this.isMultiple && this.options.selectionOrder === "selected" && this.source.name && this.source.form) {
872
+ this.source.form.addEventListener("formdata", (event) => {
873
+ event.formData.delete(this.source.name);
874
+ for (const value of this.getSelectedValues())
875
+ event.formData.append(this.source.name, value);
876
+ }, { signal });
877
+ }
878
+ this.source.addEventListener("invalid", (event) => {
879
+ event.preventDefault();
880
+ this.#inputEl().setAttribute("aria-invalid", "true");
881
+ this.#inputEl().focus();
882
+ }, { signal });
883
+ }
884
+ }
885
+ handleEvent(event) {
886
+ if (event.currentTarget === this.#inputEl())
887
+ return this.#onInputEvent(event);
888
+ if (event.currentTarget === this.control)
889
+ return this.#onControlEvent(event);
890
+ if (event.currentTarget === this.#listEl())
891
+ return this.#onListboxEvent(event);
892
+ if (event.currentTarget === this.chips)
893
+ return this.#onChipsEvent(event);
894
+ }
895
+ #onInputEvent(event) {
896
+ switch (event.type) {
897
+ case "focus": {
898
+ if (this.isSelect && !this.isMultiple && this.#selectSource().selectedOptions.length)
899
+ this.#inputEl().select();
900
+ const query = this.isSelect && !this.isMultiple ? "" : this.#inputEl().value;
901
+ this.search(query, { show: true, reason: "focus" });
902
+ return;
903
+ }
904
+ case "input": {
905
+ const inputEvent = event;
906
+ if (this.isMultiple && !inputEvent.isComposing && this.#separatorsActive()) {
907
+ this.#handleTokenInput();
908
+ return;
909
+ }
910
+ this.search(this.#inputEl().value, { show: true, reason: "input" });
911
+ return;
912
+ }
913
+ case "compositionstart":
914
+ this.composing = true;
915
+ return;
916
+ case "compositionend":
917
+ this.composing = false;
918
+ return;
919
+ case "keydown":
920
+ return this.#onInputKeyDown(event);
921
+ case "blur":
922
+ return this.#onInputBlur();
923
+ }
924
+ }
925
+ #onControlEvent(event) {
926
+ const target = event.target;
927
+ if (target.closest("button"))
928
+ return;
929
+ this.#inputEl().focus();
930
+ }
931
+ #onListboxEvent(event) {
932
+ const target = event.target;
933
+ if (event.type === "pointermove") {
934
+ const option = target.closest(".cb-option[data-index]");
935
+ if (option)
936
+ this.#setActive(Number(option.dataset.index));
937
+ return;
938
+ }
939
+ if (event.type === "click") {
940
+ const option = target.closest(".cb-option");
941
+ if (!option)
942
+ return;
943
+ if (option.classList.contains("cb-create")) {
944
+ const query = this.#inputEl().value.trim();
945
+ if (query)
946
+ this.#createItem(query);
947
+ return;
948
+ }
949
+ const item = this.visibleItems[Number(option.dataset.index)];
950
+ if (item)
951
+ this.#selectItem(item);
952
+ }
953
+ }
954
+ #onChipsEvent(event) {
955
+ const target = event.target;
956
+ if (event.type === "click") {
957
+ const remove = target.closest(".cb-chip-remove");
958
+ if (!remove)
959
+ return;
960
+ const chip = remove.closest(".cb-chip");
961
+ if (!chip)
962
+ return;
963
+ const option = this._chipOptions.get(chip);
964
+ this.remove(option ?? chip.dataset.value ?? "").then((removed) => {
965
+ if (removed)
966
+ this.#inputEl().focus();
967
+ });
968
+ return;
969
+ }
970
+ if (event.type === "keydown") {
971
+ const chip = target.closest(".cb-chip");
972
+ if (!chip)
973
+ return;
974
+ const option = this._chipOptions.get(chip);
975
+ const item = option ? this.getSelectedItems().find((entry) => entry.option === option) || {
976
+ value: option.value,
977
+ label: option.textContent.trim(),
978
+ option
979
+ } : { value: chip.dataset.value ?? "", label: chip.dataset.value ?? "" };
980
+ this.#onChipKeyDown(event, item);
981
+ }
982
+ }
983
+ #onInputBlur() {
984
+ queueMicrotask(async () => {
985
+ const active = document.activeElement;
986
+ const stillInside = active === this.#inputEl() || (this.#popoverEl()?.contains(active) ?? false) || this.anchor && active && this.anchor.contains(active) || this.control && active && this.control.contains(active);
987
+ if (this.isOpen() && stillInside)
988
+ return;
989
+ if (this.isOpen() || this.options.createOnBlur) {
990
+ if (this.isSelect && this.isMultiple && this.options.createOnBlur && !this.composing) {
991
+ const value = this.#inputEl().value;
992
+ this.suppressReopen = true;
993
+ try {
994
+ if (this.#separatorsActive()) {
995
+ const result = await this.#processTokens(value, { final: true });
996
+ if (result?.consumed)
997
+ this.#inputEl().value = result.rest;
998
+ } else if (value.trim()) {
999
+ this.#inputEl().value = "";
1000
+ await this.#createItem(value.trim());
1001
+ }
1002
+ } finally {
1003
+ this.suppressReopen = false;
1004
+ }
1005
+ this.refresh();
1006
+ }
1007
+ if (this.isSelect && !this.isMultiple)
1008
+ this.#syncSingleLabel();
1009
+ this.hide();
1010
+ }
1011
+ });
1012
+ }
1013
+ #onInputKeyDown(event) {
1014
+ if (event.key === "ArrowDown") {
1015
+ event.preventDefault();
1016
+ if (!this.isOpen())
1017
+ this.search(this.#inputEl().value, { show: true, reason: "keyboard" });
1018
+ this.#moveActive(1);
1019
+ return;
1020
+ }
1021
+ if (event.key === "ArrowUp") {
1022
+ event.preventDefault();
1023
+ if (!this.isOpen())
1024
+ this.search(this.#inputEl().value, { show: true, reason: "keyboard" });
1025
+ this.#moveActive(-1);
1026
+ return;
1027
+ }
1028
+ if (event.key === "PageUp" || event.key === "PageDown") {
1029
+ event.preventDefault();
1030
+ if (!this.isOpen())
1031
+ this.search(this.#inputEl().value, { show: true, reason: "keyboard" });
1032
+ const down = event.key === "PageDown";
1033
+ const base = this.activeIndex < 0 ? down ? -1 : 0 : this.activeIndex;
1034
+ const distance = base + (down ? this.#pageSize() : -this.#pageSize());
1035
+ this.#setActive(this.#nearestSelectable(distance, down ? 1 : -1));
1036
+ return;
1037
+ }
1038
+ if (event.key === "Enter" && this.isOpen()) {
1039
+ if (event.isComposing || this.composing)
1040
+ return;
1041
+ event.preventDefault();
1042
+ if (this.isMultiple && this.#separatorsActive()) {
1043
+ this.#commitEnterTokens();
1044
+ return;
1045
+ }
1046
+ const active = this.visibleItems[this.activeIndex];
1047
+ if (active)
1048
+ this.#selectItem(active);
1049
+ else if (this.#canCreate(this.#inputEl().value))
1050
+ this.#createItem(this.#inputEl().value.trim());
1051
+ return;
1052
+ }
1053
+ if (event.key === "Tab" && this.isOpen()) {
1054
+ if (this.options.tabSelect) {
1055
+ if (event.isComposing || this.composing)
1056
+ return;
1057
+ if (this.isMultiple && this.#separatorsActive() && this.#inputEl().value.trim()) {
1058
+ event.preventDefault();
1059
+ this.#commitEnterTokens();
1060
+ return;
1061
+ }
1062
+ const active = this.visibleItems[this.activeIndex];
1063
+ if (active) {
1064
+ event.preventDefault();
1065
+ this.#selectItem(active);
1066
+ return;
1067
+ }
1068
+ if (this.#canCreate(this.#inputEl().value)) {
1069
+ event.preventDefault();
1070
+ this.#createItem(this.#inputEl().value.trim());
1071
+ return;
1072
+ }
1073
+ this.hide();
1074
+ return;
1075
+ }
1076
+ this.hide();
1077
+ return;
1078
+ }
1079
+ if (event.key === "Escape" && this.isOpen()) {
1080
+ event.preventDefault();
1081
+ this.hide();
1082
+ return;
1083
+ }
1084
+ if (event.key === "ArrowLeft" && this.isMultiple && !this.#inputEl().value) {
1085
+ const chips = Array.from(this.chips?.querySelectorAll(".cb-chip") || []);
1086
+ if (chips.length) {
1087
+ event.preventDefault();
1088
+ chips[chips.length - 1].focus();
1089
+ return;
1090
+ }
1091
+ }
1092
+ if (event.key === "Backspace" && this.isMultiple && !this.#inputEl().value && this.#selectSource().selectedOptions.length) {
1093
+ const selected = this.#selectedOptionsInOrder();
1094
+ const last = selected[selected.length - 1];
1095
+ if (last && !last.disabled)
1096
+ this.remove(last);
1097
+ }
1098
+ }
1099
+ async search(query = "", { show = false, reason = "api" } = {}) {
1100
+ if (this.mode !== "enhanced")
1101
+ return;
1102
+ const generation = ++this.searchGeneration;
1103
+ this.query = String(query ?? "");
1104
+ const before = emit(this.#inputEl(), "beforefilter", {
1105
+ query: this.query,
1106
+ combobox: this,
1107
+ source: this.source,
1108
+ reason
1109
+ }, { cancelable: true });
1110
+ if (before.defaultPrevented)
1111
+ return;
1112
+ if (this.#shouldLoad(this.query)) {
1113
+ await this.#load(this.query, { debounce: reason === "input" });
1114
+ if (generation !== this.searchGeneration)
1115
+ return;
1116
+ } else {
1117
+ if (typeof this.options.load === "function")
1118
+ this.clearResults();
1119
+ }
1120
+ this.#applyFilter(this.query);
1121
+ emit(this.#inputEl(), "filter", {
1122
+ query: this.query,
1123
+ combobox: this,
1124
+ items: this.filteredItems,
1125
+ source: this.source
1126
+ });
1127
+ if (show)
1128
+ this.show();
1129
+ }
1130
+ setQuery(value, { show = true, reason = "api" } = {}) {
1131
+ const query = String(value ?? "");
1132
+ if (this.mode === "fallback") {
1133
+ this.query = query;
1134
+ if (!this.isSelect)
1135
+ this.source.value = query;
1136
+ return Promise.resolve();
1137
+ }
1138
+ this.#inputEl().value = query;
1139
+ return this.search(query, { show, reason });
1140
+ }
1141
+ clearQuery({ show = false, reason = "api" } = {}) {
1142
+ return this.setQuery("", { show, reason });
1143
+ }
1144
+ applyFilter(query = "", { show = false } = {}) {
1145
+ if (this.mode !== "enhanced")
1146
+ return this;
1147
+ this.query = String(query ?? "");
1148
+ this.#applyFilter(this.query);
1149
+ emit(this.#inputEl(), "filter", {
1150
+ query: this.query,
1151
+ combobox: this,
1152
+ items: this.filteredItems,
1153
+ source: this.source,
1154
+ manual: true
1155
+ });
1156
+ if (show)
1157
+ this.show();
1158
+ return this;
1159
+ }
1160
+ #shouldLoad(query) {
1161
+ if (typeof this.options.shouldLoad === "function" && !this.options.shouldLoad(query, { combobox: this, source: this.source, input: this.#inputEl() })) {
1162
+ return false;
1163
+ }
1164
+ return typeof this.options.load === "function" && query.length >= Number(this.options.minChars || 0) && (query.length > 0 || this.options.loadOnEmpty);
1165
+ }
1166
+ async#load(query, { cursor = null, append = false, debounce = false } = {}) {
1167
+ this.loadController?.abort();
1168
+ this.loadController = new AbortController;
1169
+ const signal = this.loadController.signal;
1170
+ this.loadError = null;
1171
+ if (debounce && Number(this.options.debounce) > 0) {
1172
+ try {
1173
+ await wait(Number(this.options.debounce), signal);
1174
+ } catch {
1175
+ return;
1176
+ }
1177
+ }
1178
+ const before = emit(this.source, "combobox:beforeload", {
1179
+ query,
1180
+ cursor,
1181
+ combobox: this,
1182
+ signal
1183
+ }, { cancelable: true });
1184
+ if (before.defaultPrevented)
1185
+ return;
1186
+ this.loading = true;
1187
+ this.#renderLoading();
1188
+ this.show();
1189
+ try {
1190
+ const result = await this.options.load(query, {
1191
+ signal,
1192
+ cursor,
1193
+ combobox: this,
1194
+ source: this.source,
1195
+ input: this.#inputEl()
1196
+ });
1197
+ if (signal.aborted)
1198
+ return;
1199
+ const items = Array.isArray(result) ? result : result?.items;
1200
+ if (items) {
1201
+ const merged = append && this.results ? [...this.results, ...items] : items;
1202
+ this.setResults(merged);
1203
+ }
1204
+ this.nextCursor = Array.isArray(result) ? null : result?.cursor ?? null;
1205
+ emit(this.source, "combobox:load", {
1206
+ query,
1207
+ combobox: this,
1208
+ result
1209
+ });
1210
+ } catch (error) {
1211
+ const caught = error;
1212
+ if (signal.aborted || caught?.name === "AbortError")
1213
+ return;
1214
+ this.loadError = caught;
1215
+ emit(this.source, "combobox:loaderror", {
1216
+ query,
1217
+ combobox: this,
1218
+ error: caught
1219
+ });
1220
+ } finally {
1221
+ if (!signal.aborted)
1222
+ this.loading = false;
1223
+ }
1224
+ }
1225
+ #applyFilter(query) {
1226
+ const items = this.#items();
1227
+ let visible = items.filter((item) => {
1228
+ if (this.isMultiple && item.selected)
1229
+ return false;
1230
+ return this.#matches(item, query);
1231
+ });
1232
+ const context = { combobox: this, source: this.source, input: this.#inputEl() };
1233
+ if (typeof this.options.filter === "function") {
1234
+ visible = visible.filter((item) => this.options.filter(item, query, context));
1235
+ }
1236
+ if (typeof this.options.score === "function") {
1237
+ visible = rankByScore(visible, (item, _index) => this.options.score(item, query, context));
1238
+ }
1239
+ if (typeof this.options.sort === "function") {
1240
+ visible.sort((a, b) => this.options.sort(a, b, query, context));
1241
+ }
1242
+ this.filteredItems = visible;
1243
+ const visibleOptions = new Set(visible.map((item) => item.option));
1244
+ for (const item of items) {
1245
+ item.option?.toggleAttribute("data-filtered", !visibleOptions.has(item.option));
1246
+ }
1247
+ this.#renderList();
1248
+ this.#setActive(this.options.autoselectFirst ? this.visibleItems.findIndex((item) => !item.disabled) : -1);
1249
+ }
1250
+ #matches(item, query) {
1251
+ if (!query)
1252
+ return true;
1253
+ if (typeof this.options.match === "function") {
1254
+ return this.options.match(item, query, { combobox: this, source: this.source, input: this.#inputEl() });
1255
+ }
1256
+ const fields = Array.isArray(this.options.searchFields) ? this.options.searchFields : this.options.searchFields ? [this.options.searchFields] : [];
1257
+ const values = fields.map((field) => {
1258
+ if (field in item)
1259
+ return String(item[field] ?? "");
1260
+ return String(item.data?.[field] ?? "");
1261
+ });
1262
+ return values.some((value) => matchesField(value, query, this.options.match));
1263
+ }
1264
+ #canCreate(label) {
1265
+ const value = String(label ?? "").trim();
1266
+ if (!this.isSelect || !this.options.create || !value)
1267
+ return false;
1268
+ if (this.options.maxItems > 0 && this.isMultiple && this.#selectSource().selectedOptions.length >= this.options.maxItems)
1269
+ return false;
1270
+ if (typeof this.options.createFilter === "function") {
1271
+ return this.options.createFilter(value, { combobox: this, source: this.source, input: this.#inputEl() }) !== false;
1272
+ }
1273
+ return true;
1274
+ }
1275
+ #renderList() {
1276
+ this.#listEl().replaceChildren();
1277
+ this.#statusEl().textContent = "";
1278
+ if (this.loading) {
1279
+ this.#renderLoading();
1280
+ return;
1281
+ }
1282
+ if (this.loadError) {
1283
+ this.#renderError();
1284
+ return;
1285
+ }
1286
+ let previousGroup = null;
1287
+ for (const [index, item] of this.visibleItems.entries()) {
1288
+ if (item.group && item.group !== previousGroup) {
1289
+ const group = document.createElement("div");
1290
+ group.className = "cb-group";
1291
+ group.setAttribute("role", "presentation");
1292
+ setContent(group, this.options.render.group?.(item.group, { combobox: this }) ?? item.group);
1293
+ this.#listEl().append(group);
1294
+ previousGroup = item.group;
1295
+ }
1296
+ const option = document.createElement("div");
1297
+ option.className = "cb-option";
1298
+ option.id = `combobox-option-${this.id}-${index}`;
1299
+ option.role = "option";
1300
+ option.tabIndex = -1;
1301
+ option.dataset.index = String(index);
1302
+ option.setAttribute("aria-selected", String(Boolean(item.selected)));
1303
+ if (item.option?.title || item.title)
1304
+ option.title = item.option?.title ?? item.title ?? "";
1305
+ if (item.disabled) {
1306
+ option.setAttribute("aria-disabled", "true");
1307
+ }
1308
+ const rendered = this.options.render.option?.(item, {
1309
+ query: this.query,
1310
+ selected: item.selected,
1311
+ combobox: this
1312
+ });
1313
+ const label = document.createElement("span");
1314
+ label.className = "cb-option-label";
1315
+ setContent(label, rendered ?? item.label);
1316
+ option.append(label);
1317
+ this.#listEl().append(option);
1318
+ }
1319
+ if (!this.filteredItems.length) {
1320
+ if (this.#canCreate(this.#inputEl().value)) {
1321
+ const create = document.createElement("div");
1322
+ create.className = "cb-option cb-create";
1323
+ create.tabIndex = -1;
1324
+ create.role = "option";
1325
+ const query = this.#inputEl().value.trim();
1326
+ const rendered = this.options.render.create?.(query, { combobox: this });
1327
+ const createLabel = document.createElement("span");
1328
+ createLabel.className = "cb-option-label";
1329
+ setContent(createLabel, rendered ?? this.options.messages.create?.(query, {
1330
+ combobox: this,
1331
+ source: this.source,
1332
+ input: this.#inputEl()
1333
+ }) ?? query);
1334
+ create.append(createLabel);
1335
+ this.#listEl().append(create);
1336
+ } else {
1337
+ const empty = document.createElement("div");
1338
+ empty.className = "cb-empty";
1339
+ const rendered = this.options.render.noResults?.(this.query, { combobox: this });
1340
+ setContent(empty, rendered ?? this.options.messages.noResults);
1341
+ this.#listEl().append(empty);
1342
+ this.#statusEl().textContent = this.options.messages.noResults ?? "";
1343
+ }
1344
+ }
1345
+ }
1346
+ #renderLoading() {
1347
+ this.#listEl().replaceChildren();
1348
+ this.#setActive(-1);
1349
+ const loading = document.createElement("div");
1350
+ loading.className = "cb-empty cb-loading";
1351
+ const rendered = this.options.render.loading?.(this.query, { combobox: this });
1352
+ setContent(loading, rendered ?? this.options.messages.loading);
1353
+ this.#listEl().append(loading);
1354
+ this.#statusEl().textContent = this.options.messages.loading ?? "";
1355
+ }
1356
+ #renderError() {
1357
+ this.#listEl().replaceChildren();
1358
+ this.#setActive(-1);
1359
+ const error = document.createElement("div");
1360
+ error.className = "cb-empty cb-error";
1361
+ const rendered = this.options.render.error?.(this.query, { error: this.loadError, combobox: this });
1362
+ setContent(error, rendered ?? this.options.messages.loadError);
1363
+ this.#listEl().append(error);
1364
+ this.#statusEl().textContent = this.options.messages.loadError ?? "";
1365
+ }
1366
+ #renderChips() {
1367
+ const chips = this.#chipsEl();
1368
+ if (!chips)
1369
+ return;
1370
+ chips.replaceChildren();
1371
+ for (const option of this.#selectedOptionsInOrder()) {
1372
+ const placeholder = option.disabled && option.hidden;
1373
+ if (!option.value && (!this.options.allowEmptyOption || placeholder))
1374
+ continue;
1375
+ const item = {
1376
+ value: option.value,
1377
+ label: option.textContent.trim(),
1378
+ selected: true,
1379
+ disabled: option.disabled,
1380
+ option,
1381
+ data: { ...option.dataset }
1382
+ };
1383
+ const chip = document.createElement("span");
1384
+ chip.className = "cb-chip";
1385
+ chip.tabIndex = -1;
1386
+ chip.dataset.value = item.value;
1387
+ this._chipOptions.set(chip, option);
1388
+ if (option.title)
1389
+ chip.title = option.title;
1390
+ const label = document.createElement("span");
1391
+ label.className = "cb-chip-label";
1392
+ const rendered = this.options.render.item?.(item, { combobox: this });
1393
+ setContent(label, rendered ?? item.label);
1394
+ chip.append(label);
1395
+ if (!option.disabled && !this.source.disabled) {
1396
+ const remove = document.createElement("button");
1397
+ remove.type = "button";
1398
+ remove.className = "cb-chip-remove";
1399
+ remove.append(createRemoveIcon());
1400
+ remove.setAttribute("aria-label", `Remove ${item.label}`);
1401
+ chip.append(remove);
1402
+ }
1403
+ chips.append(chip);
1404
+ }
1405
+ }
1406
+ #selectedOptionsInOrder() {
1407
+ const selected = Array.from(this.#selectSource().selectedOptions);
1408
+ if (this.options.selectionOrder !== "selected")
1409
+ return selected;
1410
+ return reconcileSelected(selected, this.selectionOrder);
1411
+ }
1412
+ #rememberSelection(option) {
1413
+ if (!this.selectionOrder.includes(option))
1414
+ this.selectionOrder.push(option);
1415
+ }
1416
+ #forgetSelection(option) {
1417
+ const index = this.selectionOrder.indexOf(option);
1418
+ if (index >= 0)
1419
+ this.selectionOrder.splice(index, 1);
1420
+ }
1421
+ #onChipKeyDown(event, item) {
1422
+ const chips = Array.from(this.#chipsEl()?.querySelectorAll(".cb-chip") || []);
1423
+ const current = event.target instanceof HTMLElement ? event.target.closest(".cb-chip") : null;
1424
+ const index = current ? chips.indexOf(current) : -1;
1425
+ if (event.altKey && index >= 0 && this.options.selectionOrder === "selected") {
1426
+ const target = event.key === "ArrowLeft" ? index - 1 : event.key === "ArrowRight" ? index + 1 : event.key === "Home" ? 0 : event.key === "End" ? chips.length - 1 : index;
1427
+ if (target !== index && target >= 0 && target < chips.length) {
1428
+ event.preventDefault();
1429
+ this.#reorderChip(item, target);
1430
+ return;
1431
+ }
1432
+ }
1433
+ if (event.key === "ArrowLeft" || event.key === "ArrowRight" || event.key === "Home" || event.key === "End") {
1434
+ event.preventDefault();
1435
+ let next = index;
1436
+ if (event.key === "ArrowLeft")
1437
+ next = Math.max(0, index - 1);
1438
+ if (event.key === "ArrowRight")
1439
+ next = index + 1;
1440
+ if (event.key === "Home")
1441
+ next = 0;
1442
+ if (event.key === "End")
1443
+ next = chips.length - 1;
1444
+ if (next >= chips.length)
1445
+ this.#inputEl().focus();
1446
+ else
1447
+ chips[next]?.focus();
1448
+ return;
1449
+ }
1450
+ if (event.key === "Delete" || event.key === "Backspace") {
1451
+ event.preventDefault();
1452
+ this.remove(item.option ?? item.value).then((removed) => {
1453
+ if (!removed)
1454
+ return;
1455
+ queueMicrotask(() => {
1456
+ const remaining = Array.from(this.#chipsEl()?.querySelectorAll(".cb-chip") || []);
1457
+ remaining[Math.min(index, remaining.length - 1)]?.focus();
1458
+ if (!remaining.length)
1459
+ this.#inputEl().focus();
1460
+ });
1461
+ });
1462
+ return;
1463
+ }
1464
+ if (event.key === "Escape") {
1465
+ event.preventDefault();
1466
+ this.#inputEl().focus();
1467
+ }
1468
+ }
1469
+ #reorderChip(item, target) {
1470
+ const identity = item.option ?? item.value;
1471
+ if (!this.move(identity, target))
1472
+ return;
1473
+ const chips = Array.from(this.#chipsEl()?.querySelectorAll(".cb-chip") || []);
1474
+ const chip = item.option ? chips.find((candidate) => this._chipOptions.get(candidate) === item.option) : chips.find((candidate) => candidate.dataset.value === item.value);
1475
+ chip?.focus();
1476
+ this.#statusEl().textContent = this.options.messages.position?.(item.label, chips.indexOf(chip) + 1, chips.length, { combobox: this, source: this.source, input: this.#inputEl() }) ?? "";
1477
+ }
1478
+ #moveActive(delta) {
1479
+ const visible = this.visibleItems;
1480
+ if (!visible.length)
1481
+ return;
1482
+ let next = this.activeIndex < 0 ? delta > 0 ? -1 : 0 : this.activeIndex;
1483
+ for (let checked = 0;checked < visible.length; checked++) {
1484
+ next = (next + delta + visible.length) % visible.length;
1485
+ if (!visible[next].disabled) {
1486
+ this.#setActive(next);
1487
+ return;
1488
+ }
1489
+ }
1490
+ }
1491
+ #nearestSelectable(from, direction) {
1492
+ const visible = this.visibleItems;
1493
+ const len = visible.length;
1494
+ if (!len)
1495
+ return -1;
1496
+ from = Math.max(0, Math.min(from, len - 1));
1497
+ for (let i = from;i >= 0 && i < len; i += direction) {
1498
+ if (!visible[i]?.disabled)
1499
+ return i;
1500
+ }
1501
+ if (direction > 0) {
1502
+ for (let i = from - 1;i >= 0; i--)
1503
+ if (!visible[i]?.disabled)
1504
+ return i;
1505
+ } else {
1506
+ for (let i = from + 1;i < len; i++)
1507
+ if (!visible[i]?.disabled)
1508
+ return i;
1509
+ }
1510
+ return -1;
1511
+ }
1512
+ #pageSize() {
1513
+ const first = this.#listEl().querySelector(".cb-option");
1514
+ if (!first)
1515
+ return 1;
1516
+ const row = first.offsetHeight || 48;
1517
+ const view = this.#popoverEl().clientHeight || 0;
1518
+ return Math.max(1, Math.floor(view / row));
1519
+ }
1520
+ #setActive(index) {
1521
+ if (index >= this.visibleItems.length)
1522
+ index = -1;
1523
+ this.activeIndex = index;
1524
+ for (const item of this.#sourceItems())
1525
+ item.option?.removeAttribute("data-active-option");
1526
+ for (const option of this.#listEl().querySelectorAll(".cb-option[data-index]")) {
1527
+ const el = option;
1528
+ const active = Number(el.dataset.index) === index;
1529
+ el.toggleAttribute("data-active", active);
1530
+ if (active) {
1531
+ this.#inputEl().setAttribute("aria-activedescendant", el.id);
1532
+ this.visibleItems[index]?.option?.setAttribute("data-active-option", "");
1533
+ el.scrollIntoView({ block: "nearest" });
1534
+ }
1535
+ }
1536
+ if (index < 0)
1537
+ this.#inputEl().removeAttribute("aria-activedescendant");
1538
+ }
1539
+ #selectItem(item, { materialize = true } = {}) {
1540
+ if (item.disabled)
1541
+ return false;
1542
+ let option = null;
1543
+ if (this.isSelect) {
1544
+ option = item.option instanceof HTMLOptionElement ? item.option : this.#findSelectableOption(item.value);
1545
+ if (option?.disabled || !option && !materialize)
1546
+ return false;
1547
+ const unchanged = option ? this.isMultiple ? option.selected : this.#selectSource().selectedOptions[0] === option : false;
1548
+ if (unchanged) {
1549
+ if (!this.isMultiple)
1550
+ this.hide();
1551
+ return false;
1552
+ }
1553
+ if (this.isMultiple && this.options.maxItems > 0 && this.#selectSource().selectedOptions.length >= this.options.maxItems) {
1554
+ return false;
1555
+ }
1556
+ if (option)
1557
+ item = { ...item, option, selected: true };
1558
+ } else if (this.source.value === item.value) {
1559
+ this.hide();
1560
+ return false;
1561
+ }
1562
+ const before = emit(this.source, "combobox:beforeselect", {
1563
+ combobox: this,
1564
+ item
1565
+ }, { cancelable: true });
1566
+ if (before.defaultPrevented)
1567
+ return false;
1568
+ if (this.isSelect) {
1569
+ if (!option)
1570
+ option = this.addOption(item);
1571
+ const selectOption = option;
1572
+ item = { ...item, option: selectOption, selected: true };
1573
+ if (this.isMultiple) {
1574
+ selectOption.selected = true;
1575
+ this.#rememberSelection(selectOption);
1576
+ this.#inputEl().value = "";
1577
+ this.#commit();
1578
+ if (this.suppressReopen)
1579
+ this.refresh();
1580
+ else if (this.#closeOnSelect())
1581
+ this.hide();
1582
+ else
1583
+ this.search("", { show: true, reason: "select" });
1584
+ } else {
1585
+ selectOption.selected = true;
1586
+ this.selectionOrder = [selectOption];
1587
+ this.#inputEl().value = item.label;
1588
+ this.#commit();
1589
+ if (this.#closeOnSelect())
1590
+ this.hide();
1591
+ }
1592
+ } else {
1593
+ this.source.value = item.value;
1594
+ this.#dispatchNativeValueEvents();
1595
+ this.hide();
1596
+ }
1597
+ emit(this.source, "combobox:select", { combobox: this, item });
1598
+ this.#markEngineMutation();
1599
+ return true;
1600
+ }
1601
+ async#createItem(label) {
1602
+ if (!this.#canCreate(label))
1603
+ return null;
1604
+ const existing = this.#findCreateMatch(label);
1605
+ if (existing) {
1606
+ this.#selectItem(existing);
1607
+ return existing.option ?? null;
1608
+ }
1609
+ const guard = await this.#runGuard("add", { label });
1610
+ if (!guard.ok)
1611
+ return null;
1612
+ const before = emit(this.source, "combobox:beforecreate", {
1613
+ combobox: this,
1614
+ label
1615
+ }, { cancelable: true });
1616
+ if (before.defaultPrevented)
1617
+ return null;
1618
+ let created = { value: label, label };
1619
+ if (typeof this.options.create === "function") {
1620
+ this.loading = true;
1621
+ this.#renderLoading();
1622
+ try {
1623
+ const result = await this.options.create(label, {
1624
+ signal: this.abortController.signal,
1625
+ combobox: this,
1626
+ source: this.source,
1627
+ input: this.#inputEl()
1628
+ });
1629
+ if (!result)
1630
+ return null;
1631
+ created = toItem(result, this.#fields());
1632
+ } catch (error) {
1633
+ if (error?.name !== "AbortError")
1634
+ emit(this.source, "combobox:createerror", { combobox: this, label, error });
1635
+ return null;
1636
+ } finally {
1637
+ this.loading = false;
1638
+ }
1639
+ }
1640
+ const option = this.addOption(created, { selected: true });
1641
+ this.#rememberSelection(option);
1642
+ this.#inputEl().value = "";
1643
+ this.#commit();
1644
+ emit(this.source, "combobox:create", {
1645
+ combobox: this,
1646
+ item: { ...created, option, selected: true }
1647
+ });
1648
+ if (this.isMultiple) {
1649
+ if (this.suppressReopen)
1650
+ this.refresh();
1651
+ else if (this.#closeOnSelect())
1652
+ this.hide();
1653
+ else
1654
+ this.search("", { show: true, reason: "create" });
1655
+ } else {
1656
+ this.hide();
1657
+ }
1658
+ this.#markEngineMutation();
1659
+ return option;
1660
+ }
1661
+ async#runGuard(name, payload) {
1662
+ const guards = this.options.guards;
1663
+ const guard = guards[name];
1664
+ if (typeof guard !== "function")
1665
+ return { ok: true };
1666
+ try {
1667
+ const result = await guard(payload, {
1668
+ combobox: this,
1669
+ source: this.source,
1670
+ input: this.#inputEl(),
1671
+ signal: this.abortController.signal
1672
+ });
1673
+ return { ok: result !== false, refused: result === false };
1674
+ } catch (error) {
1675
+ emit(this.source, "combobox:guarderror", { combobox: this, guard: name, error });
1676
+ return { ok: false, refused: false, error };
1677
+ }
1678
+ }
1679
+ #closeOnSelect() {
1680
+ return this.options.closeOnSelect ?? !this.isMultiple;
1681
+ }
1682
+ #separatorsActive() {
1683
+ return this.isMultiple && Array.isArray(this.options.separators) && this.options.separators.length > 0;
1684
+ }
1685
+ #resolveTokens(value, final = false) {
1686
+ const custom = this.options.tokenize;
1687
+ if (typeof custom === "function") {
1688
+ const result = custom(value, { combobox: this, source: this.source, input: this.#inputEl() });
1689
+ const tokens = result && Array.isArray(result.tokens) ? result.tokens : [];
1690
+ const entries2 = tokens.map((text) => ({ text: String(text), sep: "" }));
1691
+ return { entries: entries2, rest: final ? "" : String(result?.rest ?? "") };
1692
+ }
1693
+ const { done, rest } = splitTokens(value, this.options.separators);
1694
+ const entries = final && rest.trim() ? [...done, { text: rest.trim(), sep: "" }] : done;
1695
+ return { entries, rest: final ? "" : rest };
1696
+ }
1697
+ async#processTokens(value, { final = false } = {}) {
1698
+ if (!this.#separatorsActive())
1699
+ return null;
1700
+ const { entries, rest } = this.#resolveTokens(value, final);
1701
+ if (!entries.length)
1702
+ return { consumed: false, rest };
1703
+ let consumedLength = 0;
1704
+ for (let index = 0;index < entries.length; index++) {
1705
+ const entry = entries[index];
1706
+ if (this.options.maxItems > 0 && this.#selectSource().selectedOptions.length >= this.options.maxItems) {
1707
+ return { consumed: false, rest: value.slice(consumedLength) };
1708
+ }
1709
+ if (!await this.#applyToken(entry.text)) {
1710
+ return { consumed: false, rest: value.slice(consumedLength) };
1711
+ }
1712
+ consumedLength += entry.text.length + entry.sep.length;
1713
+ }
1714
+ return { consumed: true, rest: final ? "" : rest };
1715
+ }
1716
+ async#applyToken(text) {
1717
+ const term = String(text ?? "").trim();
1718
+ if (!term)
1719
+ return true;
1720
+ const existing = this.#findCreateMatch(term);
1721
+ if (existing) {
1722
+ this.#selectItem(existing);
1723
+ return true;
1724
+ }
1725
+ if (!this.#canCreate(term))
1726
+ return false;
1727
+ const created = await this.#createItem(term);
1728
+ return created !== null;
1729
+ }
1730
+ async#handleTokenInput() {
1731
+ const result = await this.#processTokens(this.#inputEl().value);
1732
+ if (result)
1733
+ this.#inputEl().value = result.rest;
1734
+ this.search(this.#inputEl().value, { show: true, reason: "input" });
1735
+ }
1736
+ async#commitEnterTokens() {
1737
+ const result = await this.#processTokens(this.#inputEl().value, { final: true });
1738
+ if (result?.consumed) {
1739
+ this.#inputEl().value = result.rest;
1740
+ this.search("", { show: true, reason: "create" });
1741
+ }
1742
+ }
1743
+ #dispatchNativeValueEvents() {
1744
+ this.source.dispatchEvent(new Event("input", { bubbles: true }));
1745
+ this.source.dispatchEvent(new Event("change", { bubbles: true }));
1746
+ }
1747
+ #commit() {
1748
+ this.source.removeAttribute("aria-invalid");
1749
+ this.#inputEl()?.removeAttribute("aria-invalid");
1750
+ this.#dispatchNativeValueEvents();
1751
+ }
1752
+ addOption(rawItem, { selected = false } = {}) {
1753
+ if (!this.isSelect)
1754
+ throw new TypeError("addOption() is only available for select-backed comboboxes");
1755
+ const item = toItem(rawItem, this.#fields());
1756
+ if (!item)
1757
+ throw new TypeError("Option requires a value");
1758
+ if (item.value === "" && !this.options.allowEmptyOption)
1759
+ throw new TypeError("Option requires a value");
1760
+ const option = item.option instanceof HTMLOptionElement ? item.option : new Option(item.label, item.value, false, selected);
1761
+ if (!(item.option instanceof HTMLOptionElement)) {
1762
+ option.disabled = Boolean(item.disabled);
1763
+ if (item.data)
1764
+ Object.assign(option.dataset, item.data);
1765
+ if (item.group) {
1766
+ let group = Array.from(this.#selectSource().children).find((node) => node instanceof HTMLOptGroupElement && node.label === item.group);
1767
+ if (!group) {
1768
+ group = document.createElement("optgroup");
1769
+ group.label = item.group;
1770
+ this.#selectSource().append(group);
1771
+ }
1772
+ group.append(option);
1773
+ } else {
1774
+ this.#selectSource().add(option);
1775
+ }
1776
+ }
1777
+ if (selected && !option.selected)
1778
+ option.selected = true;
1779
+ if (selected)
1780
+ this.#rememberSelection(option);
1781
+ return option;
1782
+ }
1783
+ select(itemOrValue) {
1784
+ const isObject = typeof itemOrValue === "object" && itemOrValue !== null;
1785
+ if (this.mode === "fallback" && this.isSelect) {
1786
+ const item = isObject ? toItem(itemOrValue, this.#fields()) : { value: String(itemOrValue), label: String(itemOrValue) };
1787
+ if (!item)
1788
+ return false;
1789
+ const option = (this.isMultiple ? this.#findSelectableOption(item.value) : null) || this.#findOption(item.value);
1790
+ if (!option) {
1791
+ if (!isObject)
1792
+ return false;
1793
+ const created = this.addOption(item, { selected: true });
1794
+ this.#dispatchNativeValueEvents();
1795
+ return created !== null;
1796
+ }
1797
+ if (option.disabled)
1798
+ return false;
1799
+ const unchanged = this.isMultiple ? option.selected : this.source.value === option.value;
1800
+ if (unchanged)
1801
+ return false;
1802
+ if (!this.isMultiple) {
1803
+ for (const other of this.#selectSource().options)
1804
+ other.selected = false;
1805
+ }
1806
+ option.selected = true;
1807
+ this.#rememberSelection(option);
1808
+ this.#dispatchNativeValueEvents();
1809
+ return true;
1810
+ }
1811
+ if (isObject) {
1812
+ const item = toItem(itemOrValue, this.#fields());
1813
+ if (!item)
1814
+ return false;
1815
+ if (itemOrValue instanceof HTMLOptionElement)
1816
+ item.option = itemOrValue;
1817
+ return this.#selectItem(item, { materialize: true });
1818
+ }
1819
+ const value = String(itemOrValue);
1820
+ const foundRaw = this.#items().find((candidate) => candidate.value === value) || this.#sourceItems().find((candidate) => candidate.value === value);
1821
+ const found = foundRaw ? foundRaw : null;
1822
+ if (!found)
1823
+ return false;
1824
+ return this.#selectItem({ value: found.value, label: found.label }, { materialize: false });
1825
+ }
1826
+ async remove(valueOrOption) {
1827
+ if (!this.isSelect)
1828
+ return false;
1829
+ const option = valueOrOption instanceof HTMLOptionElement ? valueOrOption : this.#selectedOptionsInOrder().find((entry) => entry.value === String(valueOrOption));
1830
+ if (!option?.selected || option.disabled)
1831
+ return false;
1832
+ const item = {
1833
+ value: option.value,
1834
+ label: option.textContent.trim(),
1835
+ option,
1836
+ selected: true,
1837
+ data: { ...option.dataset }
1838
+ };
1839
+ const guard = await this.#runGuard("remove", { item });
1840
+ if (!guard.ok)
1841
+ return false;
1842
+ const before = emit(this.source, "combobox:beforeremove", { combobox: this, item }, { cancelable: true });
1843
+ if (before.defaultPrevented)
1844
+ return false;
1845
+ option.selected = false;
1846
+ this.#forgetSelection(option);
1847
+ this.#commit();
1848
+ emit(this.source, "combobox:remove", { combobox: this, item });
1849
+ this.refresh();
1850
+ this.#markEngineMutation();
1851
+ return true;
1852
+ }
1853
+ async clear() {
1854
+ if (!this.isSelect) {
1855
+ if (!this.source.value)
1856
+ return false;
1857
+ const guard2 = await this.#runGuard("clear", {});
1858
+ if (!guard2.ok)
1859
+ return false;
1860
+ const before2 = emit(this.source, "combobox:beforeclear", { combobox: this }, { cancelable: true });
1861
+ if (before2.defaultPrevented)
1862
+ return false;
1863
+ this.source.value = "";
1864
+ this.#dispatchNativeValueEvents();
1865
+ emit(this.source, "combobox:clear", { combobox: this });
1866
+ return true;
1867
+ }
1868
+ const selected = Array.from(this.#selectSource().selectedOptions).filter((option) => !option.disabled);
1869
+ if (!selected.length)
1870
+ return false;
1871
+ const guard = await this.#runGuard("clear", {});
1872
+ if (!guard.ok)
1873
+ return false;
1874
+ const before = emit(this.source, "combobox:beforeclear", { combobox: this }, { cancelable: true });
1875
+ if (before.defaultPrevented)
1876
+ return false;
1877
+ for (const option of selected)
1878
+ option.selected = false;
1879
+ this.selectionOrder = this.selectionOrder.filter((option) => option.selected);
1880
+ this.#commit();
1881
+ emit(this.source, "combobox:clear", { combobox: this });
1882
+ this.refresh();
1883
+ this.#markEngineMutation();
1884
+ return true;
1885
+ }
1886
+ getSelectedValues() {
1887
+ if (!this.isSelect)
1888
+ return [this.source.value].filter(Boolean);
1889
+ return this.#selectedOptionsInOrder().map((option) => option.value);
1890
+ }
1891
+ getSelectedItems() {
1892
+ if (!this.isSelect)
1893
+ return [{ value: this.source.value, label: this.source.value }].filter((item) => item.value);
1894
+ return this.#selectedOptionsInOrder().map((option) => ({
1895
+ value: option.value,
1896
+ label: option.textContent.trim(),
1897
+ option,
1898
+ data: { ...option.dataset }
1899
+ }));
1900
+ }
1901
+ move(itemOrValue, index) {
1902
+ if (!this.isMultiple || this.options.selectionOrder !== "selected")
1903
+ return false;
1904
+ const option = itemOrValue instanceof HTMLOptionElement ? itemOrValue : this.selectionOrder.find((entry) => entry.value === String(itemOrValue));
1905
+ if (!option?.selected)
1906
+ return false;
1907
+ const moved = moveValueInOrder(this.selectionOrder, option, index);
1908
+ if (!moved)
1909
+ return false;
1910
+ const { order: nextOrder, from, to } = moved;
1911
+ const before = emit(this.source, "combobox:beforereorder", { combobox: this, value: option.value, from, to }, { cancelable: true });
1912
+ if (before.defaultPrevented)
1913
+ return false;
1914
+ this.selectionOrder = nextOrder;
1915
+ this.#renderChips();
1916
+ emit(this.source, "combobox:reorder", {
1917
+ combobox: this,
1918
+ value: option.value,
1919
+ from,
1920
+ to,
1921
+ values: this.getSelectedValues()
1922
+ });
1923
+ return true;
1924
+ }
1925
+ async loadMore() {
1926
+ if (!this.nextCursor || typeof this.options.load !== "function")
1927
+ return false;
1928
+ const cursor = this.nextCursor;
1929
+ await this.#load(this.query, { cursor, append: true, debounce: false });
1930
+ this.#applyFilter(this.query);
1931
+ return true;
1932
+ }
1933
+ refresh() {
1934
+ if (this.mode !== "enhanced")
1935
+ return this;
1936
+ if (this.isSelect) {
1937
+ this.#inputEl().disabled = this.source.disabled;
1938
+ this.#inputEl().readOnly = this.source.hasAttribute("readonly");
1939
+ for (const option of this.#selectSource().selectedOptions)
1940
+ this.#rememberSelection(option);
1941
+ if (this.source.required)
1942
+ this.#inputEl().setAttribute("aria-required", "true");
1943
+ else
1944
+ this.#inputEl().removeAttribute("aria-required");
1945
+ if (this.isMultiple)
1946
+ this.#renderChips();
1947
+ else
1948
+ this.#syncSingleLabel();
1949
+ }
1950
+ this.#applyFilter(this.isSelect && !this.isMultiple ? "" : this.#inputEl().value);
1951
+ return this;
1952
+ }
1953
+ #syncSingleLabel() {
1954
+ const selected = this.#selectSource().selectedOptions[0];
1955
+ this.#inputEl().value = selected?.value ? selected.textContent.trim() : "";
1956
+ }
1957
+ show() {
1958
+ if (this.mode !== "enhanced" || this.isOpen())
1959
+ return false;
1960
+ if (openCombobox && openCombobox !== this) {
1961
+ openCombobox.hide();
1962
+ if (openCombobox?.isOpen())
1963
+ return false;
1964
+ }
1965
+ const before = emit(this.source, "combobox:beforeopen", { combobox: this }, { cancelable: true });
1966
+ if (before.defaultPrevented)
1967
+ return false;
1968
+ try {
1969
+ this.#popoverEl().showPopover({ source: this.#inputEl() });
1970
+ } catch {
1971
+ this.#popoverEl().showPopover();
1972
+ }
1973
+ openCombobox = this;
1974
+ return true;
1975
+ }
1976
+ hide() {
1977
+ if (this.mode !== "enhanced" || !this.isOpen())
1978
+ return false;
1979
+ const before = emit(this.source, "combobox:beforeclose", { combobox: this }, { cancelable: true });
1980
+ if (before.defaultPrevented)
1981
+ return false;
1982
+ this.#popoverEl().hidePopover();
1983
+ if (openCombobox === this)
1984
+ openCombobox = null;
1985
+ return true;
1986
+ }
1987
+ isOpen() {
1988
+ return this.mode === "enhanced" && this.#popoverEl().matches(":popover-open");
1989
+ }
1990
+ dispose() {
1991
+ instances.delete(this.source);
1992
+ this.loadController?.abort();
1993
+ this.abortController.abort();
1994
+ this._sourceObserver?.disconnect();
1995
+ this._sourceObserver = null;
1996
+ if (this._sourceSyncTimer) {
1997
+ clearTimeout(this._sourceSyncTimer);
1998
+ this._sourceSyncTimer = null;
1999
+ }
2000
+ if (this.mode === "fallback") {
2001
+ this.fallbackControl?.remove();
2002
+ return;
2003
+ }
2004
+ if (openCombobox === this)
2005
+ openCombobox = null;
2006
+ this.#popoverEl()?.remove();
2007
+ this.anchorSnapshot?.restore();
2008
+ if (this.isSelect || this.datalist instanceof HTMLDataListElement) {
2009
+ for (const item of this.#sourceItems()) {
2010
+ item.option?.removeAttribute("data-filtered");
2011
+ item.option?.removeAttribute("data-active-option");
2012
+ }
2013
+ }
2014
+ for (const { label, id } of this.original.inventedLabels) {
2015
+ if (label.id === id)
2016
+ label.removeAttribute("id");
2017
+ }
2018
+ if (this.isSelect) {
2019
+ this.control?.remove();
2020
+ this.source.classList.remove("cb-source-hidden");
2021
+ this.sourceSnapshot?.restore();
2022
+ if (!this.ownsInput && this.#inputEl() && this.original.filterInputPlaceholder?.parentNode) {
2023
+ this.original.filterInputPlaceholder.replaceWith(this.#inputEl());
2024
+ }
2025
+ this.#inputEl()?.classList.remove("cb-input");
2026
+ this.inputSnapshot?.restore();
2027
+ } else {
2028
+ this.source.classList.remove("cb-text-control");
2029
+ const datalist = this.datalist;
2030
+ if (datalist && this.original.datalistPlaceholder?.parentNode) {
2031
+ this.original.datalistPlaceholder.replaceWith(datalist);
2032
+ }
2033
+ this.inputSnapshot?.restore();
2034
+ }
2035
+ }
2036
+ }
2037
+
2038
+ // src/combo-box.js
2039
+ var OPTION_ATTRIBUTES = {
2040
+ create: { type: "boolean" },
2041
+ placeholder: {},
2042
+ search: { option: "match" },
2043
+ "min-chars": { type: "integer" },
2044
+ "max-items": { type: "integer" },
2045
+ "max-options": { type: "integer" },
2046
+ "selection-order": {},
2047
+ separators: { parse: parseSeparators },
2048
+ "create-on-blur": { type: "boolean" },
2049
+ "close-on-select": { type: "boolean" },
2050
+ "autoselect-first": { type: "boolean" },
2051
+ "tab-select": { type: "boolean" },
2052
+ "search-fields": { parse: parseList },
2053
+ "label-field": {},
2054
+ "value-field": {},
2055
+ "load-on-empty": { type: "boolean" },
2056
+ "allow-empty-option": { type: "boolean" },
2057
+ debounce: { type: "integer" }
2058
+ };
2059
+ function camelCase(name) {
2060
+ return name.replace(/-([a-z])/g, (_, char) => char.toUpperCase());
2061
+ }
2062
+
2063
+ class ComboBoxElement extends HTMLElement {
2064
+ static get observedAttributes() {
2065
+ return Object.keys(OPTION_ATTRIBUTES);
2066
+ }
2067
+ constructor() {
2068
+ super();
2069
+ this._combobox = null;
2070
+ this._source = null;
2071
+ this._options = {};
2072
+ this._sourceObserver = null;
2073
+ this._revision = 0;
2074
+ this._rebuildQueued = false;
2075
+ this._readyResolvers = [];
2076
+ this.#upgradeProperty("options");
2077
+ }
2078
+ connectedCallback() {
2079
+ const revision = ++this._revision;
2080
+ queueMicrotask(() => {
2081
+ if (revision !== this._revision || !this.isConnected)
2082
+ return;
2083
+ this.upgrade();
2084
+ });
2085
+ }
2086
+ disconnectedCallback() {
2087
+ const revision = ++this._revision;
2088
+ queueMicrotask(() => {
2089
+ if (revision !== this._revision || this.isConnected)
2090
+ return;
2091
+ this.dispose();
2092
+ });
2093
+ }
2094
+ attributeChangedCallback(_name, oldValue, newValue) {
2095
+ if (oldValue === newValue || !this._combobox)
2096
+ return;
2097
+ this.#scheduleRebuild();
2098
+ }
2099
+ get source() {
2100
+ return this._source || this.#findSource();
2101
+ }
2102
+ get combobox() {
2103
+ return this._combobox;
2104
+ }
2105
+ get options() {
2106
+ return { ...this._options };
2107
+ }
2108
+ set options(value) {
2109
+ if (value == null)
2110
+ value = {};
2111
+ if (typeof value !== "object")
2112
+ throw new TypeError("combo-box options must be an object");
2113
+ this._options = { ...value };
2114
+ if (this._combobox)
2115
+ this.#scheduleRebuild();
2116
+ }
2117
+ configure(options = {}) {
2118
+ this.options = { ...this._options, ...options };
2119
+ return this;
2120
+ }
2121
+ upgrade() {
2122
+ const source = this.#findSource();
2123
+ if (!source) {
2124
+ this.#watchForSource();
2125
+ return null;
2126
+ }
2127
+ this._sourceObserver?.disconnect();
2128
+ this._sourceObserver = null;
2129
+ if (this._combobox && this._source === source)
2130
+ return this._combobox;
2131
+ if (this._combobox)
2132
+ this._combobox.dispose();
2133
+ this._source = source;
2134
+ this._combobox = new Combobox(source, this.#resolvedOptions());
2135
+ const ready = this._readyResolvers.splice(0);
2136
+ for (const resolve of ready)
2137
+ resolve(this._combobox);
2138
+ this.dispatchEvent(new CustomEvent("combobox:ready", {
2139
+ bubbles: true,
2140
+ detail: { combobox: this._combobox, source }
2141
+ }));
2142
+ return this._combobox;
2143
+ }
2144
+ whenReady() {
2145
+ if (this._combobox)
2146
+ return Promise.resolve(this._combobox);
2147
+ return new Promise((resolve) => this._readyResolvers.push(resolve));
2148
+ }
2149
+ dispose() {
2150
+ this._sourceObserver?.disconnect();
2151
+ this._sourceObserver = null;
2152
+ this._combobox?.dispose();
2153
+ this._combobox = null;
2154
+ this._source = null;
2155
+ }
2156
+ #findSource() {
2157
+ for (const child of this.children) {
2158
+ if (child instanceof HTMLSelectElement)
2159
+ return child;
2160
+ }
2161
+ for (const child of this.children) {
2162
+ if (child instanceof HTMLInputElement && child.hasAttribute("list"))
2163
+ return child;
2164
+ }
2165
+ return null;
2166
+ }
2167
+ #watchForSource() {
2168
+ if (this._sourceObserver)
2169
+ return;
2170
+ this._sourceObserver = new MutationObserver(() => {
2171
+ if (this.#findSource())
2172
+ this.upgrade();
2173
+ });
2174
+ this._sourceObserver.observe(this, { childList: true });
2175
+ }
2176
+ #resolvedOptions() {
2177
+ const attrs = {};
2178
+ for (const [attribute, config] of Object.entries(OPTION_ATTRIBUTES)) {
2179
+ if (!this.hasAttribute(attribute))
2180
+ continue;
2181
+ const raw = this.getAttribute(attribute);
2182
+ const value = config.parse ? config.parse(raw) : config.type === "boolean" ? booleanAttribute(this, attribute) : config.type === "integer" ? parseInteger(raw) : raw;
2183
+ if (value !== undefined)
2184
+ attrs[config.option ?? camelCase(attribute)] = value;
2185
+ }
2186
+ return { ...attrs, ...this._options };
2187
+ }
2188
+ #scheduleRebuild() {
2189
+ if (this._rebuildQueued)
2190
+ return;
2191
+ this._rebuildQueued = true;
2192
+ queueMicrotask(() => {
2193
+ this._rebuildQueued = false;
2194
+ if (!this.isConnected || !this._combobox)
2195
+ return;
2196
+ const source = this._source;
2197
+ this._combobox.dispose();
2198
+ this._combobox = null;
2199
+ this._source = source;
2200
+ this.upgrade();
2201
+ });
2202
+ }
2203
+ #upgradeProperty(name) {
2204
+ if (!hasOwn(this, name))
2205
+ return;
2206
+ const value = Reflect.get(this, name);
2207
+ Reflect.deleteProperty(this, name);
2208
+ Reflect.set(this, name, value);
2209
+ }
2210
+ }
2211
+ function defineCombobox() {
2212
+ const registry = globalThis.customElements;
2213
+ if (!registry.get("combo-box"))
2214
+ registry.define("combo-box", ComboBoxElement);
2215
+ return ComboBoxElement;
2216
+ }
2217
+
2218
+ // src/define.js
2219
+ defineCombobox();
2220
+ })();