@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.
- package/Combobox/Combobox.css +100 -0
- package/Combobox/ComboboxField.d.ts +243 -0
- package/Combobox/ComboboxField.js +1182 -0
- package/Combobox/ComboboxListbox.d.ts +15 -0
- package/Combobox/ComboboxListbox.js +34 -0
- package/Combobox/ComboboxOption.d.ts +39 -0
- package/Combobox/ComboboxOption.js +144 -0
- package/Combobox/README.md +17 -0
- package/Combobox/SelectEnhancer.d.ts +27 -0
- package/Combobox/SelectEnhancer.js +185 -0
- package/Combobox/index.d.ts +4 -0
- package/Combobox/index.js +4 -0
- package/Combobox/types/dom.d.ts +10 -0
- package/Combobox/types/helpers.d.ts +7 -0
- package/Combobox/types/preact.d.ts +43 -0
- package/Combobox/types/react.d.ts +54 -0
- package/Combobox/types/solid.d.ts +45 -0
- package/Combobox/types/svelte.d.ts +43 -0
- package/Combobox/types/vue.d.ts +93 -0
- package/LICENSE +16 -0
- package/README.md +3 -0
- package/index.d.ts +1 -0
- package/index.js +1 -0
- package/package.json +38 -0
- package/utils/dom.d.ts +10 -0
- package/utils/dom.js +13 -0
|
@@ -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;
|