@itenthusiasm/custom-elements 0.0.1

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.
@@ -0,0 +1,1182 @@
1
+ /** @import {ListboxWithChildren} from "./types/helpers.js" */
2
+ import { setAttributeFor } from "../utils/dom.js";
3
+ import ComboboxOption from "./ComboboxOption.js";
4
+ import ComboboxListbox from "./ComboboxListbox.js";
5
+
6
+ /*
7
+ * "TypeScript Lies" to Be Aware of:
8
+ * (Probably should move this comment to a markdown file)
9
+ *
10
+ * 1) `#matchingOptions` could technically be `null` but is `ComboboxOption[]` (never a practical problem)
11
+ */
12
+
13
+ /** Internally used to retrieve the value that the `combobox` had when it was focused. */
14
+ const valueOnFocusKey = Symbol("value-on-focus-key");
15
+ /** Internally used to determine if the `combobox`'s value is actively being modified through user's filter changes. */
16
+ const editingKey = Symbol("editing-key");
17
+
18
+ /** The attributes _commonly_ used by the `ComboboxField` component. (These are declared to help avoid typos.) */
19
+ const attrs = Object.freeze({
20
+ "aria-activedescendant": "aria-activedescendant",
21
+ "aria-expanded": "aria-expanded",
22
+ });
23
+
24
+ /**
25
+ * @typedef {Pick<ElementInternals,
26
+ "labels" | "form" | "validity" | "validationMessage" | "willValidate" | "checkValidity" | "reportValidity"
27
+ >} ExposedInternals
28
+ */
29
+
30
+ /**
31
+ * @typedef {Pick<HTMLInputElement, "name" | "required" | "disabled" | "setCustomValidity">} FieldPropertiesAndMethods
32
+ */
33
+
34
+ /*
35
+ * TODO: Some of our functionality requires (or recommends) CSS to be properly implemented (e.g., hiding `listbox`,
36
+ * properly showing white spaces, etc.). We should probably explain all such things to developers.
37
+ */
38
+ /** @implements {ExposedInternals} @implements {FieldPropertiesAndMethods} */
39
+ class ComboboxField extends HTMLElement {
40
+ /* ------------------------------ Custom Element Settings ------------------------------ */
41
+ /** @returns {true} */
42
+ static get formAssociated() {
43
+ return true;
44
+ }
45
+
46
+ static get observedAttributes() {
47
+ return /** @type {const} */ (["id", "required", "filter", "valueis", "nomatchesmessage", "valuemissingerror"]);
48
+ }
49
+
50
+ /* ------------------------------ Internals ------------------------------ */
51
+ #mounted = false;
52
+ /** Internally used to indicate when the `combobox` is actively transitioning out of {@link filter} mode. */
53
+ static #filterDisabedKey = Symbol("filter-disabled");
54
+ /** @readonly */ #internals = this.attachInternals();
55
+
56
+ /** @type {string} The temporary search string used for {@link filter _unfiltered_} `combobox`es */
57
+ #searchString = "";
58
+
59
+ /** @type {number | undefined} The `id` of the latest timeout function that will clear the `#searchString` */
60
+ #searchTimeout;
61
+
62
+ /**
63
+ * @type {ComboboxOption[]} The list of `option`s that match the user's current filter. Only guaranteed
64
+ * to exist when the `combobox` is in {@link filter} mode. Otherwise, is irrelevant and may yield `null`.
65
+ */
66
+ #matchingOptions = /** @type {ComboboxOption[]} */ (/** @type {unknown} */ (null));
67
+
68
+ /**
69
+ * @type {number} The index of the `option` in `#matchingOptions` that is currently active.
70
+ * Only relevant for {@link filter filterable} `combobox`es.
71
+ */
72
+ #activeIndex = 0;
73
+
74
+ /** @readonly */ #textNodeObserver = new MutationObserver(ComboboxField.#preserveTextNode);
75
+ /** @readonly */ #activeDescendantObserver = new MutationObserver(ComboboxField.#watchActiveDescendant);
76
+ /** @readonly */ #expansionObserver = new MutationObserver(this.#watchExpansion.bind(this));
77
+ /** @readonly */ #optionNodesObserver = new MutationObserver(this.#watchOptionNodes.bind(this));
78
+
79
+ /**
80
+ * @type {string | null} The Custom Element's internal value. If you are updating the `combobox`'s value to anything
81
+ * other than `null`, then you should use the {@link value setter} instead.
82
+ *
83
+ * **Note**: A `null` value indicates that the `combobox` value has not yet been initialized (for instance, if
84
+ * the `combobox` was rendered without any `option`s).
85
+ */
86
+ #value = null;
87
+ /** @private @type {string | null} */ [valueOnFocusKey] = null;
88
+ /** @private @type {boolean} */ [editingKey] = false;
89
+
90
+ /* ------------------------------ Lifecycle Callbacks ------------------------------ */
91
+ /**
92
+ * @param {typeof ComboboxField.observedAttributes[number]} name
93
+ * @param {string | null} oldValue
94
+ * @param {string | null} newValue
95
+ * @returns {void}
96
+ */
97
+ attributeChangedCallback(name, oldValue, newValue) {
98
+ if (name === "id" && newValue !== oldValue) {
99
+ this.listbox.id = `${this.id}-listbox`;
100
+ for (let option = this.listbox.firstElementChild; option; option = /** @type {any} */ (option.nextElementSibling))
101
+ option.id = `${this.id}-option-${option.value}`;
102
+
103
+ return;
104
+ }
105
+ if (name === "required") return this.#validateRequiredConstraint();
106
+ if (name === "nomatchesmessage" && newValue !== oldValue) {
107
+ return this.listbox.setAttribute(name, newValue ?? ComboboxField.defaultNoMatchesMessage);
108
+ }
109
+ if (name === "valuemissingerror" && newValue !== oldValue) {
110
+ const { valueMissing, customError } = this.validity;
111
+ if (valueMissing && !customError) this.#internals.setValidity({ valueMissing }, this.valueMissingError);
112
+ return;
113
+ }
114
+
115
+ if (name === "valueis" && newValue !== oldValue) {
116
+ if (!this.#mounted) return;
117
+ const filterModeIsBeingDisabled = newValue === /** @type {any} */ (ComboboxField.#filterDisabedKey);
118
+ if (!this.filter && !filterModeIsBeingDisabled) return;
119
+
120
+ const trueNewValue = this.valueIs;
121
+ /** @type {this["valueIs"]} */ const trueOldValue =
122
+ oldValue === "anyvalue" || oldValue === "unclearable" ? oldValue : "clearable";
123
+ if (trueNewValue === trueOldValue && !filterModeIsBeingDisabled) return;
124
+
125
+ // `anyvalue` activated
126
+ if (trueNewValue === "anyvalue" && !filterModeIsBeingDisabled) {
127
+ if (this.text.data === "") return this.forceEmptyValue();
128
+ if (this.getAttribute(attrs["aria-expanded"]) !== String(true)) return; // A valid value should already exist
129
+
130
+ if (this.#autoselectableOption) this.value = this.#autoselectableOption.value;
131
+ else this.value = this.text.data;
132
+ }
133
+ // `clearable` activated (default when `filter` mode is ON)
134
+ else if (trueNewValue === "clearable" && !filterModeIsBeingDisabled) {
135
+ if (this.text.data === "") return this.forceEmptyValue();
136
+ if (trueOldValue !== "anyvalue") return; // A valid value should already exist
137
+
138
+ if (this.#autoselectableOption) this.value = this.#autoselectableOption.value;
139
+ else this.formResetCallback();
140
+ }
141
+ // `unclearable` activated (default when `filter` mode is OFF)
142
+ else {
143
+ /** @type {ComboboxOption | null | undefined} */ let option;
144
+
145
+ if (trueOldValue !== "unclearable" && this.text.data === "") option = this.getOptionByValue("");
146
+ else if (trueOldValue === "anyvalue") option = this.#autoselectableOption;
147
+ else {
148
+ // A valid value should already exist in `filter` mode. In that case, don't disrupt User's current filter.
149
+ if (!filterModeIsBeingDisabled) return;
150
+ option = this.#value == null ? null : this.getOptionByValue(this.#value);
151
+ }
152
+
153
+ if (!option) return this.formResetCallback();
154
+ option.selected = true;
155
+ if (this.text.data !== option.label) this.text.data = option.label;
156
+ }
157
+
158
+ return;
159
+ }
160
+
161
+ if (name === "filter" && (newValue == null) !== (oldValue == null)) {
162
+ if (newValue == null) {
163
+ this.removeAttribute("aria-autocomplete");
164
+ this.removeAttribute("contenteditable");
165
+
166
+ // NOTE: The old `valueIs` value/property has to be calculated here because the `filter` attribute was removed.
167
+ // This means that `this.filterIs` would return `unclearable` in some cases where it should return `clearable`.
168
+ const rawValueIs = this.getAttribute("valueis");
169
+ /** @type {this["valueIs"]} */
170
+ const oldValueIs = rawValueIs === "anyvalue" || rawValueIs === "unclearable" ? rawValueIs : "clearable";
171
+ this.attributeChangedCallback("valueis", oldValueIs, /** @type {any} */ (ComboboxField.#filterDisabedKey));
172
+
173
+ if (this.getAttribute(attrs["aria-expanded"]) === String(true)) this.#resetOptions();
174
+
175
+ if (this.isConnected) {
176
+ this.removeEventListener("mousedown", ComboboxField.#handleMousedown);
177
+ this.removeEventListener("focus", ComboboxField.#handleFocus);
178
+ this.removeEventListener("beforeinput", this.#handleSearch);
179
+ this.addEventListener("keydown", this.#handleTypeahead, { passive: true });
180
+ }
181
+ } else {
182
+ this.setAttribute("aria-autocomplete", "list");
183
+ this.setAttribute("contenteditable", String(!this.disabled));
184
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- This is due to our own TS Types. :\
185
+ this.#matchingOptions ??= Array.from(this.listbox?.children ?? []);
186
+
187
+ if (this.isConnected) {
188
+ if (/** @type {Document | ShadowRoot} */ (this.getRootNode()).activeElement === this) {
189
+ this.ownerDocument.getSelection()?.setBaseAndExtent(this.text, 0, this.text, this.text.length);
190
+ }
191
+
192
+ this.removeEventListener("keydown", this.#handleTypeahead);
193
+ this.addEventListener("mousedown", ComboboxField.#handleMousedown, { passive: true });
194
+ this.addEventListener("focus", ComboboxField.#handleFocus, { passive: true });
195
+ this.addEventListener("beforeinput", this.#handleSearch);
196
+ }
197
+ }
198
+
199
+ return; // eslint-disable-line no-useless-return -- I want code in this callback to be easily moved around
200
+ }
201
+ }
202
+
203
+ /** "On Mount" for Custom Elements @returns {void} */
204
+ connectedCallback() {
205
+ if (!this.isConnected) return;
206
+
207
+ if (!this.#mounted) {
208
+ // Setup Attributes
209
+ this.setAttribute("role", "combobox");
210
+ this.setAttribute("tabindex", String(0));
211
+ this.setAttribute("aria-haspopup", "listbox");
212
+ this.setAttribute(attrs["aria-expanded"], String(false));
213
+ this.setAttribute(attrs["aria-activedescendant"], "");
214
+ if (!this.noMatchesMessage) this.noMatchesMessage = ComboboxField.defaultNoMatchesMessage;
215
+
216
+ // NOTE: This initialization of `#matchingOptions` is incompatible with `group`ed `option`s
217
+ if (this.filter) this.#matchingOptions = Array.from(this.listbox.children);
218
+ this.appendChild(this.text);
219
+ this.#mounted = true;
220
+ }
221
+
222
+ // Require a Corresponding `listbox`
223
+ if (!(this.listbox instanceof ComboboxListbox) || this.listbox.getAttribute("role") !== "listbox") {
224
+ throw new Error(`The ${this.constructor.name} must be placed before a valid \`[role="listbox"]\` element.`);
225
+ }
226
+
227
+ // Setup Mutation Observers
228
+ this.#optionNodesObserver.observe(this.listbox, { childList: true });
229
+ this.#textNodeObserver.observe(this, { childList: true });
230
+ this.#expansionObserver.observe(this, { attributes: true, attributeFilter: [attrs["aria-expanded"]] });
231
+ this.#activeDescendantObserver.observe(this, {
232
+ attributes: true,
233
+ attributeFilter: [attrs["aria-activedescendant"]],
234
+ attributeOldValue: true,
235
+ });
236
+
237
+ // Setup Event Listeners
238
+ this.addEventListener("click", ComboboxField.#handleClick, { passive: true });
239
+ this.addEventListener("blur", ComboboxField.#handleBlur, { passive: true });
240
+ this.addEventListener("keydown", this.#handleKeydown);
241
+
242
+ if (this.filter) {
243
+ this.addEventListener("mousedown", ComboboxField.#handleMousedown, { passive: true });
244
+ this.addEventListener("focus", ComboboxField.#handleFocus, { passive: true });
245
+ this.addEventListener("beforeinput", this.#handleSearch);
246
+ } else {
247
+ this.addEventListener("keydown", this.#handleTypeahead, { passive: true });
248
+ }
249
+
250
+ this.listbox.addEventListener("mouseover", ComboboxField.#handleDelegatedOptionHover, { passive: true });
251
+ this.listbox.addEventListener("click", ComboboxField.#handleDelegatedOptionClick, { passive: true });
252
+ this.listbox.addEventListener("mousedown", ComboboxField.#handleDelegatedMousedown);
253
+ }
254
+
255
+ /** "On Unmount" for Custom Elements @returns {void} */
256
+ disconnectedCallback() {
257
+ // TODO: We should consider handling `disconnection` more safely/robustly with `takeRecords` since
258
+ // this element could be relocated (rather than being completely removed from the DOM).
259
+ this.#optionNodesObserver.disconnect();
260
+ this.#textNodeObserver.disconnect();
261
+ this.#expansionObserver.disconnect();
262
+ this.#activeDescendantObserver.disconnect();
263
+
264
+ this.removeEventListener("click", ComboboxField.#handleClick);
265
+ this.removeEventListener("blur", ComboboxField.#handleBlur);
266
+ this.removeEventListener("keydown", this.#handleKeydown);
267
+
268
+ this.removeEventListener("mousedown", ComboboxField.#handleMousedown);
269
+ this.removeEventListener("focus", ComboboxField.#handleFocus);
270
+ this.removeEventListener("beforeinput", this.#handleSearch);
271
+ this.removeEventListener("keydown", this.#handleTypeahead);
272
+
273
+ this.listbox.removeEventListener("mouseover", ComboboxField.#handleDelegatedOptionHover);
274
+ this.listbox.removeEventListener("click", ComboboxField.#handleDelegatedOptionClick);
275
+ this.listbox.removeEventListener("mousedown", ComboboxField.#handleDelegatedMousedown);
276
+ }
277
+
278
+ /**
279
+ * Handles the searching logic for `combobox`es without a {@link filter}
280
+ * @param {KeyboardEvent} event
281
+ * @returns {void}
282
+ */
283
+ #handleTypeahead = (event) => {
284
+ const combobox = /** @type {ComboboxField} */ (event.currentTarget);
285
+ const { listbox } = combobox;
286
+ // TODO: This will probably be faster with getElementById?
287
+ const activeOption = listbox.querySelector(":scope [role='option'][data-active='true']");
288
+
289
+ if (event.key.length === 1 && !event.altKey && !event.ctrlKey && !event.metaKey) {
290
+ if (event.key === " " && !this.#searchString) return;
291
+ setAttributeFor(combobox, attrs["aria-expanded"], String(true));
292
+ this.#searchString += event.key;
293
+
294
+ /* -------------------- Determine Next Active `option` -------------------- */
295
+ // NOTE: This approach won't work with `group`ed `option`s, but it can be fairly easily modified to do so
296
+ const lastOptionToEvaluate = activeOption ?? listbox.lastElementChild;
297
+ let nextActiveOption = lastOptionToEvaluate;
298
+
299
+ while (nextActiveOption !== null) {
300
+ nextActiveOption = nextActiveOption.nextElementSibling ?? listbox.firstElementChild;
301
+ if (nextActiveOption?.textContent.toLowerCase().startsWith(this.#searchString.toLowerCase())) break;
302
+ if (nextActiveOption === lastOptionToEvaluate) nextActiveOption = null;
303
+ }
304
+
305
+ /* -------------------- Update `search` and Active `option` -------------------- */
306
+ clearTimeout(this.#searchTimeout);
307
+ if (!nextActiveOption) {
308
+ this.#searchString = "";
309
+ return;
310
+ }
311
+
312
+ setAttributeFor(combobox, attrs["aria-activedescendant"], nextActiveOption.id);
313
+ this.#searchTimeout = window.setTimeout(() => (this.#searchString = ""), 500);
314
+ }
315
+ };
316
+
317
+ /**
318
+ * Handles the searching logic for `combobox`es with a {@link filter}
319
+ * @param {InputEvent} event
320
+ * @returns {void}
321
+ */
322
+ #handleSearch = (event) => {
323
+ /*
324
+ * Prevent developers from receiving irrelevant `input` events from a `ComboboxField`.
325
+ *
326
+ * NOTE: This will sadly disable `historyUndo`/`historyRedo`, but that's probably not a big problem.
327
+ * If it does become a point of contention/need in the future, then we can make a history `Stack` that is opt-in.
328
+ */
329
+ event.preventDefault();
330
+ if (!event.isTrusted) return;
331
+ const combobox = /** @type {ComboboxField} */ (event.currentTarget);
332
+ const { text } = combobox;
333
+
334
+ // Update `combobox`'s Text Content based on user input
335
+ const { inputType } = event;
336
+ if (!inputType.startsWith("delete") && !inputType.startsWith("insert")) return;
337
+
338
+ /** The `data` input by the user, modified to be valid for the `combobox` */
339
+ let data = event.data ?? event.dataTransfer?.getData("text/plain") ?? "";
340
+ data = data.replace(/[\r\n]/g, ""); // NOTE: This deletion seems to be safe for our range looping logic.
341
+
342
+ const staticRanges = event.getTargetRanges();
343
+ for (let i = 0, rangeShift = 0; i < staticRanges.length; i++) {
344
+ const staticRange = staticRanges[i];
345
+ const deletedCharacters = staticRange.endOffset - staticRange.startOffset;
346
+
347
+ const correctedStartOffset = staticRange.startOffset + rangeShift;
348
+ text.deleteData(correctedStartOffset, deletedCharacters);
349
+ text.insertData(correctedStartOffset, data);
350
+ rangeShift = rangeShift - deletedCharacters + data.length;
351
+
352
+ if (i !== staticRanges.length - 1) continue;
353
+ const cursorLocation = correctedStartOffset + data.length;
354
+ const selection = /** @type {Selection} */ (text.ownerDocument.getSelection());
355
+ selection.setBaseAndExtent(text, cursorLocation, text, cursorLocation);
356
+
357
+ if (deletedCharacters === 0 && data.length === 0) return; // User attempted to "delete" nothing
358
+ }
359
+
360
+ // Filter `option`s
361
+ setAttributeFor(combobox, attrs["aria-expanded"], String(true));
362
+ if (this.dispatchEvent(new Event("filterchange", { bubbles: true, cancelable: true }))) {
363
+ this.#filterOptions();
364
+ }
365
+
366
+ // Update `combobox` value if needed.
367
+ // NOTE: We MUST set the internal value DIRECTLY here to produce desirable behavior. See Development Notes for details.
368
+ if (!combobox.acceptsValue(text.data)) return;
369
+ const prevOption = this.#value == null ? null : this.getOptionByValue(this.#value);
370
+
371
+ this.#value = text.data;
372
+ this.#internals.setFormValue(this.#value);
373
+ if (prevOption?.selected) prevOption.selected = false;
374
+ this.#validateRequiredConstraint();
375
+
376
+ // TODO: We might want to document that this `InputEvent` is not cancelable (and why)
377
+ combobox[editingKey] = true;
378
+ combobox.dispatchEvent(
379
+ new InputEvent("input", {
380
+ bubbles: event.bubbles,
381
+ composed: event.composed,
382
+ cancelable: false,
383
+ view: event.view,
384
+ detail: event.detail,
385
+ inputType: event.inputType,
386
+ isComposing: event.isComposing,
387
+ data: event.data || event.dataTransfer ? data : null,
388
+ dataTransfer: null,
389
+ }),
390
+ );
391
+ };
392
+
393
+ /** @returns {void} */
394
+ #filterOptions() {
395
+ ({ matchingOptions: this.#matchingOptions, autoselectableOption: this.#autoselectableOption = null } =
396
+ this.getFilteredOptions());
397
+
398
+ this.#activeIndex = 0;
399
+ this.toggleAttribute("data-bad-filter", !this.#matchingOptions.length); // TODO: Remove Legacy Implementation
400
+ this.#internals.states[this.#matchingOptions.length ? "delete" : "add"]("--bad-filter");
401
+ setAttributeFor(this, attrs["aria-activedescendant"], this.#matchingOptions[0]?.id ?? "");
402
+ }
403
+
404
+ /**
405
+ * Updates the {@link ComboboxOption.filteredOut `filteredOut`} property for all of the `option`s,
406
+ * then returns the `option`s that match the user's current filter.
407
+ *
408
+ * @returns {GetFilteredOptionsReturnType}
409
+ */
410
+ getFilteredOptions() {
411
+ let matches = 0;
412
+ const search = this.text.data;
413
+ /** @type {GetFilteredOptionsReturnType["autoselectableOption"]} */ let autoselectableOption;
414
+
415
+ // NOTE: This approach won't work with `group`ed `option`s, but it can be fairly easily modified to do so.
416
+ // NOTE: The responsibility of setting `autoselectableOption` to a non-null `option` belongs to this method ONLY.
417
+ // However, what is _done_ with said `option` is ultimately up to the developer, not this component.
418
+ for (let option = this.listbox.firstElementChild; option; option = /** @type {any} */ (option.nextElementSibling)) {
419
+ if (!this.optionMatchesFilter(option)) option.filteredOut = true;
420
+ else {
421
+ if (option.textContent === search) autoselectableOption = option;
422
+
423
+ option.filteredOut = false;
424
+ this.#matchingOptions[matches++] = option;
425
+ }
426
+ }
427
+
428
+ // Remove any remaining `option`s that belonged to the previous filter
429
+ this.#matchingOptions.splice(matches);
430
+
431
+ return { matchingOptions: this.#matchingOptions, autoselectableOption };
432
+ }
433
+
434
+ /**
435
+ * @typedef GetFilteredOptionsReturnType
436
+ * @property {ComboboxOption[]} matchingOptions The `option`s which match the user's current filter
437
+ * @property {ComboboxOption} [autoselectableOption] (Optional): The `option` which is a candidate for
438
+ * automatic selection. See: {@link ComboboxField.autoselectableOption}.
439
+ */
440
+
441
+ /**
442
+ * The logic used by {@link filter filterable} `combobox`es to determine if an `option` matches the user's filter.
443
+ *
444
+ * **Note**: If {@link getFilteredOptions} is overridden, this method will do nothing unless it is
445
+ * used directly within the new implementation.
446
+ *
447
+ * @param {ComboboxOption} option
448
+ * @returns {boolean}
449
+ */
450
+ optionMatchesFilter(option) {
451
+ // NOTE: An "Empty String Option" won't be `autoselectable` with the approach here, and that's intentional
452
+ const search = this.text.data;
453
+ if (!search) return true;
454
+ if (!option.value) return false;
455
+
456
+ return option.textContent.toLowerCase()[this.filterMethod](search.toLowerCase());
457
+ }
458
+
459
+ /**
460
+ * Resets all of the `option`s in the {@link listbox} so that none of them are marked as filtered out.
461
+ * Also re-initializes the stored `#matchingOptions`.
462
+ * @returns {void}
463
+ */
464
+ #resetOptions() {
465
+ let i = 0;
466
+ for (let option = this.listbox.firstElementChild; option; option = /** @type {any} */ (option.nextElementSibling)) {
467
+ option.filteredOut = false;
468
+ this.#matchingOptions[i++] = option;
469
+ }
470
+
471
+ // Remove any remaining `option`s that no longer belong to the `listbox`
472
+ this.#matchingOptions.splice(i);
473
+ if (this.#matchingOptions.length) {
474
+ this.removeAttribute("data-bad-filter"); // TODO: Remove Legacy Implementation
475
+ this.#internals.states.delete("--bad-filter");
476
+ }
477
+ }
478
+
479
+ /* ------------------------------ Exposed Form Properties ------------------------------ */
480
+ /** Sets or retrieves the `value` of the `combobox` @returns {string | null} */
481
+ get value() {
482
+ return this.#value;
483
+ }
484
+
485
+ /** @param {string} v */
486
+ set value(v) {
487
+ const newOption = this.getOptionByValue(v);
488
+ if (v === this.#value && newOption?.selected === true) return;
489
+
490
+ /* ---------- Update Values ---------- */
491
+ if (!newOption && !this.acceptsValue(v)) return; // Ignore invalid values
492
+ const prevOption = this.#value == null ? null : this.getOptionByValue(this.#value);
493
+
494
+ this.#value = v;
495
+ this.#internals.setFormValue(this.#value);
496
+ const label = newOption ? newOption.label : this.#value;
497
+ if (this.text.data !== label) {
498
+ this.text.data = label;
499
+ this.#autoselectableOption = null;
500
+ }
501
+
502
+ // Update `option`s AFTER updating `value`
503
+ if (newOption?.selected === false) newOption.selected = true;
504
+ if (prevOption?.selected && prevOption !== newOption) prevOption.selected = false;
505
+ this.#validateRequiredConstraint();
506
+ }
507
+
508
+ /**
509
+ * Coerces the value and filter of the `combobox` to an empty string, and deselects the currently-selected `option`
510
+ * if one exists (including any `option` whose value is an empty string).
511
+ *
512
+ * @returns {void}
513
+ * @throws {TypeError} if the `combobox` is not {@link filter filterable}, or if its value
514
+ * {@link valueIs cannot be cleared}.
515
+ */
516
+ forceEmptyValue() {
517
+ if (this.valueIs !== "anyvalue" && this.valueIs !== "clearable") {
518
+ throw new TypeError('Method requires `filter` mode to be on and `valueis` to be "anyvalue" or "clearable"');
519
+ }
520
+ if (this.valueIs === "clearable" && this.#value === null) {
521
+ throw new TypeError('Cannot coerce value to `""` for a `clearable` `combobox` that owns no `option`s');
522
+ }
523
+
524
+ const prevOption = this.#value == null ? null : this.getOptionByValue(this.#value);
525
+
526
+ this.text.data = "";
527
+ this.#value = "";
528
+ this.#internals.setFormValue(this.#value);
529
+ this.#autoselectableOption = null;
530
+ if (prevOption?.selected) prevOption.selected = false;
531
+ this.#validateRequiredConstraint();
532
+ }
533
+
534
+ /**
535
+ * Retrieves the `option` with the provided `value` (if it exists)
536
+ * @param {string} value
537
+ * @returns {ComboboxOption | null}
538
+ */
539
+ getOptionByValue(value) {
540
+ const root = /** @type {Document | DocumentFragment | ShadowRoot} */ (this.getRootNode());
541
+ const option = /** @type {ComboboxOption | null} */ (root.getElementById(`${this.id}-option-${value}`));
542
+ return option;
543
+ }
544
+
545
+ /** @returns {HTMLInputElement["name"]} */
546
+ get name() {
547
+ return this.getAttribute("name") ?? "";
548
+ }
549
+
550
+ set name(value) {
551
+ this.setAttribute("name", value);
552
+ }
553
+
554
+ /** @returns {HTMLInputElement["disabled"]} */
555
+ get disabled() {
556
+ return this.hasAttribute("disabled");
557
+ }
558
+
559
+ set disabled(value) {
560
+ this.toggleAttribute("disabled", Boolean(value));
561
+ }
562
+
563
+ /** @returns {HTMLInputElement["required"]} */
564
+ get required() {
565
+ return this.hasAttribute("required");
566
+ }
567
+
568
+ set required(value) {
569
+ this.toggleAttribute("required", Boolean(value));
570
+ }
571
+
572
+ /**
573
+ * The `listbox` that this `combobox` controls.
574
+ * @returns {ListboxWithChildren<ComboboxOption>}
575
+ */
576
+ get listbox() {
577
+ return /** @type {typeof this.listbox} */ (this.nextElementSibling);
578
+ }
579
+
580
+ /* ------------------------------ Custom Attributes and Properties ------------------------------ */
581
+ /** @type {this["text"]} */
582
+ #text = new Text();
583
+
584
+ /**
585
+ * The _singular_ {@link Text} Node associated with the `combobox`.
586
+ *
587
+ * To alter the `combobox`'s text content, update this node **_instead of_** using {@link textContent}.
588
+ * @returns {Text}
589
+ */
590
+ get text() {
591
+ return this.#text;
592
+ }
593
+
594
+ /** Activates a textbox that can be used to filter the list of `combobox` `option`s. @returns {boolean} */
595
+ get filter() {
596
+ return this.hasAttribute("filter");
597
+ }
598
+
599
+ set filter(value) {
600
+ this.toggleAttribute("filter", Boolean(value));
601
+ }
602
+
603
+ /**
604
+ * Determines the method used to filter the `option`s as the user types.
605
+ * - `startsWith`: {@link String.startsWith} will be used to filter the `option`s.
606
+ * - `includes`: {@link String.includes} will be used to filter the `option`s.
607
+ *
608
+ * **Note**: This property does nothing if {@link optionMatchesFilter} or {@link getFilteredOptions} is overridden.
609
+ *
610
+ * @returns {Extract<keyof String, "startsWith" | "includes">}
611
+ */
612
+ get filterMethod() {
613
+ const value = this.getAttribute("filtermethod");
614
+ return value === "includes" ? value : "startsWith";
615
+ }
616
+
617
+ set filterMethod(value) {
618
+ this.setAttribute("filtermethod", value);
619
+ }
620
+
621
+ /**
622
+ * Indicates how a `combobox`'s value will behave.
623
+ * - `unclearable`: The field's {@link value `value`} must be a string matching one of the `option`s,
624
+ * and it cannot be cleared. (Default when {@link filter `filter`} mode is off.)
625
+ * - `clearable`: The field's `value` must be a string matching one of the `option`s,
626
+ * but it can be cleared. (Default in `filter` mode. Requires enabling `filter` mode.)
627
+ * - `anyvalue`: The field's `value` can be any string, and it will automatically be set to
628
+ * whatever value the user types. (Requires enabling `filter` mode.)
629
+ *
630
+ * <!--
631
+ * TODO: Link to Documentation for More Details (like TS does for MDN). The deeper details of the behavior
632
+ * are too sophisticated to place them all in a JSDoc, which should be [sufficiently] clear and succinct
633
+ * -->
634
+ *
635
+ * @returns {"unclearable" | "clearable" | "anyvalue"}
636
+ */
637
+ get valueIs() {
638
+ if (!this.filter) return "unclearable";
639
+
640
+ const value = this.getAttribute("valueis");
641
+ return value === "anyvalue" || value === "unclearable" ? value : "clearable";
642
+ }
643
+
644
+ set valueIs(value) {
645
+ this.setAttribute("valueis", value);
646
+ }
647
+
648
+ /**
649
+ * @param {string} value
650
+ * @returns {boolean} `true` if the `combobox` will accept the provided `value` when no corresponding `option` exists.
651
+ * Otherwise, returns `false`.
652
+ */
653
+ acceptsValue(value) {
654
+ if (!this.filter) return false;
655
+ if (this.valueIs !== "anyvalue" && this.#value === null) return false;
656
+ return this.valueIs === "anyvalue" || (this.valueIs === "clearable" && value === "");
657
+ }
658
+
659
+ /** @type {this["autoselectableOption"]} */
660
+ #autoselectableOption = null;
661
+
662
+ /**
663
+ * Returns the `option` whose `label` matches the user's most recent filter input (if one exists).
664
+ *
665
+ * Value will be `null` if:
666
+ * - The user's filter didn't match any `option`s
667
+ * - The `combobox`'s text content was altered by a `value` change
668
+ * - The `combobox` was just recently expanded
669
+ * @returns {ComboboxOption | null}
670
+ */
671
+ get autoselectableOption() {
672
+ return this.#autoselectableOption;
673
+ }
674
+
675
+ static defaultNoMatchesMessage = "No options found";
676
+
677
+ /**
678
+ * The message displayed to users when none of the `combobox`'s `option`s match their filter.
679
+ * @returns {string}
680
+ */
681
+ get noMatchesMessage() {
682
+ return /** @type {string} Note: Logic forces attribute to always exist */ (this.getAttribute("nomatchesmessage"));
683
+ }
684
+
685
+ set noMatchesMessage(value) {
686
+ this.setAttribute("nomatchesmessage", value);
687
+ }
688
+
689
+ /** The error message displayed to users when the `combobox`'s `required` constraint is broken. @returns {string} */
690
+ get valueMissingError() {
691
+ return this.getAttribute("valuemissingerror") ?? "Please select an item in the list.";
692
+ }
693
+
694
+ set valueMissingError(value) {
695
+ this.setAttribute("valuemissingerror", value);
696
+ }
697
+
698
+ /* ------------------------------ Exposed `ElementInternals` ------------------------------ */
699
+ /** @returns {ElementInternals["labels"]} */
700
+ get labels() {
701
+ return this.#internals.labels;
702
+ }
703
+
704
+ /** @returns {ElementInternals["form"]} */
705
+ get form() {
706
+ return this.#internals.form;
707
+ }
708
+
709
+ /** @returns {ElementInternals["validity"]} */
710
+ get validity() {
711
+ return this.#internals.validity;
712
+ }
713
+
714
+ /** @returns {ElementInternals["validationMessage"]} */
715
+ get validationMessage() {
716
+ return this.#internals.validationMessage;
717
+ }
718
+
719
+ /** @returns {ElementInternals["willValidate"]} */
720
+ get willValidate() {
721
+ return this.#internals.willValidate;
722
+ }
723
+
724
+ /** @type {ElementInternals["checkValidity"]} */
725
+ checkValidity() {
726
+ return this.#internals.checkValidity();
727
+ }
728
+
729
+ /** @type {ElementInternals["reportValidity"]} */
730
+ reportValidity() {
731
+ return this.#internals.reportValidity();
732
+ }
733
+
734
+ /** @type {HTMLInputElement["setCustomValidity"]} */
735
+ setCustomValidity(error) {
736
+ const { valueMissing } = this.validity;
737
+ const errorMessage = valueMissing && !error ? this.valueMissingError : error;
738
+ this.#internals.setValidity({ valueMissing, customError: Boolean(error) }, errorMessage);
739
+ }
740
+
741
+ /** @returns {void} */
742
+ #validateRequiredConstraint() {
743
+ const { customError } = this.validity;
744
+
745
+ // NOTE: We don't check for `this.value == null` here because that would only be a Developer Error, not a User Error
746
+ if (this.required && this.value === "") {
747
+ return this.#internals.setValidity(
748
+ { valueMissing: true, customError },
749
+ this.validationMessage || this.valueMissingError,
750
+ );
751
+ }
752
+
753
+ this.#internals.setValidity({ valueMissing: false, customError }, this.validationMessage);
754
+ }
755
+
756
+ /* ------------------------------ Form Control Callbacks ------------------------------ */
757
+ /** @returns {void} */
758
+ formResetCallback() {
759
+ const { listbox } = this;
760
+
761
+ // NOTE: This logic might not work with `group`s (which we don't currently intend to support)
762
+ /** @type {ComboboxOption | null} */
763
+ const defaultOption = listbox.querySelector(":scope [role='option']:nth-last-child(1 of [selected])");
764
+
765
+ if (defaultOption) this.value = defaultOption.value;
766
+ else if (this.valueIs === "anyvalue" || this.valueIs === "clearable") this.value = "";
767
+ else if (listbox.firstElementChild) this.value = listbox.firstElementChild.value;
768
+ }
769
+
770
+ /**
771
+ * @param {string} state
772
+ * @param {"restore" | "autocomplete"} _mode
773
+ * @returns {void}
774
+ */
775
+ formStateRestoreCallback(state, _mode) {
776
+ this.value = state;
777
+ }
778
+
779
+ /**
780
+ * @param {boolean} disabled
781
+ * @returns {void}
782
+ */
783
+ formDisabledCallback(disabled) {
784
+ if (disabled) setAttributeFor(this, attrs["aria-expanded"], String(false));
785
+ if (this.filter) this.setAttribute("contenteditable", String(!disabled));
786
+ }
787
+
788
+ /* ------------------------------ Combobox Event Handlers ------------------------------ */
789
+ /**
790
+ * @param {MouseEvent} event
791
+ * @returns {void}
792
+ */
793
+ static #handleClick(event) {
794
+ const combobox = /** @type {ComboboxField} */ (event.currentTarget);
795
+ const expanded = combobox.getAttribute(attrs["aria-expanded"]) === String(true);
796
+
797
+ if (combobox.filter && expanded) return;
798
+ combobox.setAttribute(attrs["aria-expanded"], String(!expanded));
799
+ }
800
+
801
+ /**
802
+ * Used to determine if a {@link filter filterable} `combobox` was `:focus`ed by a `click` event.
803
+ * @param {MouseEvent} event
804
+ * @returns {void}
805
+ */
806
+ static #handleMousedown(event) {
807
+ const combobox = /** @type {ComboboxField} */ (event.currentTarget);
808
+ if (/** @type {Document | ShadowRoot} */ (combobox.getRootNode()).activeElement === combobox) return;
809
+
810
+ combobox.setAttribute("data-mousedown", "");
811
+ combobox.addEventListener("mouseup", () => combobox.removeAttribute("data-mousedown"), { once: true });
812
+ }
813
+
814
+ /**
815
+ * (For {@link filter filtered} `combobox`es only)
816
+ * @param {FocusEvent} event
817
+ * @returns {void}
818
+ */
819
+ static #handleFocus(event) {
820
+ const combobox = /** @type {ComboboxField} */ (event.currentTarget);
821
+ combobox[valueOnFocusKey] = combobox.value;
822
+ if (combobox.hasAttribute("data-mousedown")) return;
823
+
824
+ const textNode = combobox.text;
825
+ document.getSelection()?.setBaseAndExtent(textNode, 0, textNode, textNode.length);
826
+ }
827
+
828
+ /**
829
+ * @param {FocusEvent} event
830
+ * @returns {void}
831
+ */
832
+ static #handleBlur(event) {
833
+ const combobox = /** @type {ComboboxField} */ (event.currentTarget);
834
+ setAttributeFor(combobox, attrs["aria-expanded"], String(false));
835
+
836
+ // Remove text selection from `combobox` if needed
837
+ const selection = /** @type {Selection} */ (document.getSelection());
838
+ if (selection.containsNode(combobox.text)) selection.empty();
839
+
840
+ // Determine if a `change` event should be dispatched (for `clearable` and `anyvalue` mode only)
841
+ const { [valueOnFocusKey]: valueOnFocus, [editingKey]: editing } = combobox;
842
+ if (valueOnFocus !== combobox.value && editing && combobox.value !== null) {
843
+ combobox.dispatchEvent(new Event("change", { bubbles: true, composed: false, cancelable: false }));
844
+ }
845
+ combobox[valueOnFocusKey] = null;
846
+ combobox[editingKey] = false;
847
+ }
848
+
849
+ /**
850
+ * @param {KeyboardEvent} event
851
+ * @returns {void}
852
+ */
853
+ #handleKeydown = (event) => {
854
+ const combobox = /** @type {ComboboxField} */ (event.currentTarget);
855
+ const { listbox } = combobox;
856
+ const activeOption = /** @type {ComboboxOption | null} */ (
857
+ listbox.querySelector(":scope [role='option'][data-active='true']")
858
+ );
859
+
860
+ if (event.altKey && event.key === "ArrowDown") {
861
+ event.preventDefault(); // Don't scroll
862
+ return setAttributeFor(combobox, attrs["aria-expanded"], String(true));
863
+ }
864
+
865
+ if (event.key === "ArrowDown") {
866
+ event.preventDefault(); // Don't scroll
867
+ if (combobox.getAttribute(attrs["aria-expanded"]) !== String(true)) {
868
+ return combobox.setAttribute(attrs["aria-expanded"], String(true));
869
+ }
870
+
871
+ const nextActiveOption = combobox.filter
872
+ ? this.#matchingOptions[(this.#activeIndex = Math.min(this.#activeIndex + 1, this.#matchingOptions.length - 1))]
873
+ : activeOption?.nextElementSibling;
874
+
875
+ if (nextActiveOption) setAttributeFor(combobox, attrs["aria-activedescendant"], nextActiveOption.id);
876
+ return;
877
+ }
878
+
879
+ if (event.key === "End") {
880
+ event.preventDefault(); // Don't scroll
881
+
882
+ const lastOption = combobox.filter
883
+ ? this.#matchingOptions[(this.#activeIndex = this.#matchingOptions.length - 1)]
884
+ : listbox.lastElementChild;
885
+
886
+ setAttributeFor(combobox, attrs["aria-expanded"], String(true));
887
+ setAttributeFor(combobox, attrs["aria-activedescendant"], lastOption?.id ?? "");
888
+ return;
889
+ }
890
+
891
+ if (event.key === "Escape") {
892
+ if (combobox.getAttribute(attrs["aria-expanded"]) !== String(true)) return;
893
+
894
+ event.preventDefault(); // Avoid unexpected side-effects like closing `dialog`s
895
+ return combobox.setAttribute(attrs["aria-expanded"], String(false));
896
+ }
897
+
898
+ if (event.altKey && event.key === "ArrowUp") {
899
+ event.preventDefault(); // Don't scroll
900
+ return setAttributeFor(combobox, attrs["aria-expanded"], String(false));
901
+ }
902
+
903
+ if (event.key === "ArrowUp") {
904
+ event.preventDefault(); // Don't scroll
905
+ if (combobox.getAttribute(attrs["aria-expanded"]) !== String(true)) {
906
+ return combobox.setAttribute(attrs["aria-expanded"], String(true));
907
+ }
908
+
909
+ const nextActiveOption = combobox.filter
910
+ ? this.#matchingOptions[(this.#activeIndex = Math.max(this.#activeIndex - 1, 0))]
911
+ : activeOption?.previousElementSibling;
912
+
913
+ if (nextActiveOption) setAttributeFor(combobox, attrs["aria-activedescendant"], nextActiveOption.id);
914
+ return;
915
+ }
916
+
917
+ if (event.key === "Home") {
918
+ event.preventDefault(); // Don't scroll
919
+
920
+ const firstOption = combobox.filter ? this.#matchingOptions[(this.#activeIndex = 0)] : listbox.firstElementChild;
921
+ setAttributeFor(combobox, attrs["aria-expanded"], String(true));
922
+ setAttributeFor(combobox, attrs["aria-activedescendant"], firstOption?.id ?? "");
923
+ return;
924
+ }
925
+
926
+ if (event.key === " ") {
927
+ if (combobox.filter) return; // Defer to `#handleSearch` instead
928
+ event.preventDefault(); // Don't scroll
929
+
930
+ if (combobox.getAttribute(attrs["aria-expanded"]) !== String(true)) {
931
+ return combobox.setAttribute(attrs["aria-expanded"], String(true));
932
+ }
933
+
934
+ // Defer to `#handleTypeahead` instead of selecting the active `option` if there's an active Search String
935
+ return this.#searchString ? undefined : activeOption?.click();
936
+ }
937
+
938
+ if (event.key === "Enter") {
939
+ // Prevent `#handleSearch` from triggering
940
+ if (combobox.filter) event.preventDefault();
941
+
942
+ // Select a Value (if the element is expanded)
943
+ if (combobox.getAttribute(attrs["aria-expanded"]) === String(true)) return activeOption?.click();
944
+
945
+ // Submit the Form (if the element is collapsed)
946
+ const { form } = combobox;
947
+ if (!form) return;
948
+
949
+ // See: https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#implicit-submission
950
+ /** @type {HTMLButtonElement | HTMLInputElement | null} */
951
+ const submitter = Array.prototype.find.call(form.elements, (control) => {
952
+ if (!(control instanceof HTMLInputElement) && !(control instanceof HTMLButtonElement)) return false;
953
+ return control.type === "submit";
954
+ });
955
+
956
+ if (submitter) return submitter.disabled ? undefined : submitter.click();
957
+ return form.requestSubmit();
958
+ }
959
+ };
960
+
961
+ /* -------------------- Listbox Handlers -------------------- */
962
+ /**
963
+ * @param {MouseEvent} event
964
+ * @returns {void}
965
+ */
966
+ static #handleDelegatedOptionHover(event) {
967
+ const listbox = /** @type {HTMLElement} */ (event.currentTarget);
968
+ const option = /** @type {HTMLElement} */ (event.target).closest("[role='option']");
969
+ if (!option) return; // We hovered the `listbox`, not an `option`
970
+
971
+ const combobox = /** @type {ComboboxField} */ (listbox.previousElementSibling);
972
+ setAttributeFor(combobox, attrs["aria-activedescendant"], option.id);
973
+ }
974
+
975
+ /**
976
+ * @param {MouseEvent} event
977
+ * @returns {void}
978
+ */
979
+ static #handleDelegatedOptionClick(event) {
980
+ const listbox = /** @type {HTMLElement} */ (event.currentTarget);
981
+ const option = /** @type {ComboboxOption | null} */ (
982
+ /** @type {HTMLElement} */ (event.target).closest("[role='option']")
983
+ );
984
+ if (!option) return; // We clicked the `listbox`, not an `option`
985
+ if (option.disabled) return;
986
+
987
+ const combobox = /** @type {ComboboxField} */ (listbox.previousElementSibling);
988
+ combobox.setAttribute(attrs["aria-expanded"], String(false));
989
+
990
+ if (option.selected) return;
991
+ combobox.value = option.value;
992
+ combobox.dispatchEvent(new Event("input", { bubbles: true, composed: true, cancelable: false }));
993
+ combobox.dispatchEvent(new Event("change", { bubbles: true, composed: false, cancelable: false }));
994
+ combobox[editingKey] = false;
995
+ }
996
+
997
+ /**
998
+ * @param {MouseEvent} event
999
+ * @returns {void}
1000
+ */
1001
+ static #handleDelegatedMousedown(event) {
1002
+ const listbox = /** @type {HTMLElement} */ (event.currentTarget);
1003
+ if (listbox.contains(/** @type {HTMLElement} */ (event.target))) return event.preventDefault();
1004
+ }
1005
+
1006
+ /* ------------------------------ Combobox Mutation Observer Details ------------------------------ */
1007
+ /**
1008
+ * @param {MutationRecord[]} mutations
1009
+ * @returns {void}
1010
+ */
1011
+ static #preserveTextNode(mutations) {
1012
+ const combobox = /** @type {ComboboxField} */ (mutations[0].target);
1013
+ const { text } = combobox;
1014
+ if (text !== combobox.firstChild || text !== combobox.lastChild) combobox.replaceChildren(text);
1015
+ }
1016
+
1017
+ /**
1018
+ * @param {MutationRecord[]} mutations
1019
+ * @returns {void}
1020
+ */
1021
+ #watchExpansion(mutations) {
1022
+ for (let i = 0; i < mutations.length; i++) {
1023
+ const mutation = mutations[i];
1024
+ const combobox = /** @type {ComboboxField} */ (mutation.target);
1025
+ const expanded = combobox.getAttribute(attrs["aria-expanded"]) === String(true);
1026
+
1027
+ // Open Combobox
1028
+ if (expanded) {
1029
+ /*
1030
+ * NOTE: If the user opens the `combobox` with search/typeahead, then `aria-activedescendant` will already
1031
+ * exist and this expansion logic will be irrelevant. Remember that `MutationObserver` callbacks are run
1032
+ * asynchronously, so this check would happen AFTER the search/typeahead handler completed. It's also
1033
+ * possible for this condition to be met if we redundantly set `aria-expanded`. Although we should be
1034
+ * be able to avoid that, we can't prevent Developers from accidentally doing that themselves.
1035
+ */
1036
+ if (combobox.getAttribute(attrs["aria-activedescendant"]) !== "") return;
1037
+
1038
+ /** @type {ComboboxOption | null} */
1039
+ const selectedOption = combobox.value == null ? null : combobox.getOptionByValue(combobox.value);
1040
+ let activeOption = selectedOption ?? combobox.listbox.firstElementChild;
1041
+ if (combobox.filter && activeOption?.filteredOut) [activeOption] = this.#matchingOptions;
1042
+
1043
+ if (combobox.filter) {
1044
+ this.#autoselectableOption = null;
1045
+ this.#activeIndex = activeOption ? this.#matchingOptions.indexOf(activeOption) : -1;
1046
+ }
1047
+
1048
+ if (activeOption) combobox.setAttribute(attrs["aria-activedescendant"], activeOption.id);
1049
+ }
1050
+ // Close Combobox
1051
+ else {
1052
+ combobox.setAttribute(attrs["aria-activedescendant"], "");
1053
+ this.#searchString = "";
1054
+
1055
+ // See if logic _exclusive_ to `filter`ed `combobox`es needs to be run
1056
+ if (!combobox.filter || combobox.value == null) return;
1057
+ this.#resetOptions();
1058
+
1059
+ // Reset `combobox` display if needed
1060
+ // NOTE: `option` CAN be `null` or unselected if `combobox` is `clearable`, empty, and `collapsed` with a non-empty filter
1061
+ const textNode = combobox.text;
1062
+ if (!combobox.acceptsValue(textNode.data)) {
1063
+ const option = combobox.getOptionByValue(combobox.value);
1064
+ if (combobox.valueIs === "clearable" && !combobox.value && !option?.selected) textNode.data = "";
1065
+ else if (textNode.data !== option?.textContent) textNode.data = /** @type {string} */ (option?.textContent);
1066
+ }
1067
+
1068
+ // Reset cursor if `combobox` is still `:focus`ed
1069
+ if (/** @type {Document | ShadowRoot} */ (combobox.getRootNode()).activeElement !== combobox) return;
1070
+
1071
+ const selection = /** @type {Selection} */ (combobox.ownerDocument.getSelection());
1072
+ selection.setBaseAndExtent(textNode, textNode.length, textNode, textNode.length);
1073
+ }
1074
+ }
1075
+ }
1076
+
1077
+ /**
1078
+ * @param {MutationRecord[]} mutations
1079
+ * @returns {void}
1080
+ */
1081
+ static #watchActiveDescendant(mutations) {
1082
+ for (let i = 0; i < mutations.length; i++) {
1083
+ const mutation = mutations[i];
1084
+ const combobox = /** @type {ComboboxField} */ (mutation.target);
1085
+ const root = /** @type {Document | DocumentFragment | ShadowRoot} */ (combobox.getRootNode());
1086
+
1087
+ // Deactivate Previous Option
1088
+ const lastOptionId = mutation.oldValue;
1089
+ const lastOption = lastOptionId ? root.getElementById(lastOptionId) : null;
1090
+ lastOption?.removeAttribute("data-active");
1091
+
1092
+ // Activate New Option
1093
+ const activeOptionId = /** @type {string} */ (combobox.getAttribute(attrs["aria-activedescendant"]));
1094
+ const activeOption = root.getElementById(activeOptionId);
1095
+ activeOption?.setAttribute("data-active", String(true));
1096
+
1097
+ // If Needed, Scroll to New Active Option
1098
+ if (!activeOption) return;
1099
+ const { listbox } = combobox;
1100
+ const bounds = listbox.getBoundingClientRect();
1101
+ const { top, bottom, height } = activeOption.getBoundingClientRect();
1102
+
1103
+ /**
1104
+ * The offset used to prevent unwanted, rapid scrolling caused by hovering an element at the infinitesimal limit where
1105
+ * the very edge of the `listbox` border intersects the very edge of the `element` outside the scroll container.
1106
+ */
1107
+ const safetyOffset = 0.5;
1108
+
1109
+ // Align preceding `option` with top of listbox
1110
+ if (top < bounds.top) {
1111
+ if (activeOption === listbox.firstElementChild) listbox.scrollTop = 0;
1112
+ else listbox.scrollTop = activeOption.offsetTop + safetyOffset;
1113
+ }
1114
+ // Align succeeding `option` with bottom of listbox
1115
+ else if (bottom > bounds.bottom) {
1116
+ if (activeOption === listbox.lastElementChild) listbox.scrollTop = listbox.scrollHeight;
1117
+ else {
1118
+ const borderWidth = parseFloat(getComputedStyle(listbox).getPropertyValue("border-width"));
1119
+ listbox.scrollTop = activeOption.offsetTop - (bounds.height - borderWidth * 2) + height - safetyOffset;
1120
+ }
1121
+ }
1122
+ }
1123
+ }
1124
+
1125
+ /**
1126
+ * @param {MutationRecord[]} mutations
1127
+ * @returns {void}
1128
+ */
1129
+ #watchOptionNodes(mutations) {
1130
+ const textNode = this.text;
1131
+ const nullable = this.valueIs !== "anyvalue";
1132
+
1133
+ if (!this.listbox.children.length) {
1134
+ if (!nullable) this.value = textNode.data;
1135
+ else {
1136
+ this.#value = null;
1137
+ this.#internals.setFormValue(null);
1138
+ textNode.data = "";
1139
+ this.#validateRequiredConstraint();
1140
+ }
1141
+
1142
+ if (this.filter) this.#filterOptions(); // Clean up internal data and show "No Matches" Message
1143
+ return;
1144
+ }
1145
+
1146
+ for (let i = 0; i < mutations.length; i++) {
1147
+ const mutation = mutations[i];
1148
+
1149
+ // Handle added nodes first. This keeps us from running redundant Deselect Logic if a newly-added node is `selected`.
1150
+ mutation.addedNodes.forEach((node, j) => {
1151
+ if (!(node instanceof ComboboxOption)) return node.parentNode?.removeChild(node);
1152
+
1153
+ if (node.defaultSelected) this.value = node.value;
1154
+ else if (nullable && this.#value === null && j === 0) {
1155
+ if (this.valueIs !== "clearable") this.value = node.value;
1156
+ else {
1157
+ this.#value = "";
1158
+ this.forceEmptyValue();
1159
+ }
1160
+ }
1161
+ });
1162
+
1163
+ mutation.removedNodes.forEach((node) => {
1164
+ if (!(node instanceof ComboboxOption)) return;
1165
+ if (this.#autoselectableOption === node) this.#autoselectableOption = null;
1166
+ if (node.selected) {
1167
+ if (nullable) this.formResetCallback();
1168
+ else this.value = textNode.data;
1169
+ }
1170
+ });
1171
+ }
1172
+
1173
+ if (!this.filter) return;
1174
+
1175
+ // NOTE: This can produce a confusing UX if the `combobox` is expanded but a filter was NOT applied yet.
1176
+ // However, such a scenario is unlikely and impractical. So we're keeping this logic to help with async loading.
1177
+ if (this.getAttribute(attrs["aria-expanded"]) === String(true)) this.#filterOptions();
1178
+ else this.#resetOptions();
1179
+ }
1180
+ }
1181
+
1182
+ export default ComboboxField;