html-combobox-element 0.0.4 → 0.0.6
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/README.md +139 -24
- package/dist/cjs/combobox/Combobox.markup.d.ts +22 -1
- package/dist/cjs/combobox/Combobox.markup.js +157 -36
- package/dist/cjs/combobox/HTML.combobox.element.d.ts +32 -5
- package/dist/cjs/combobox/HTML.combobox.element.js +438 -81
- package/dist/cjs/combobox/HTML.combobox.option.element.d.ts +25 -4
- package/dist/cjs/combobox/HTML.combobox.option.element.js +104 -66
- package/dist/cjs/combobox/HTML.combobox.options.collection.d.ts +37 -0
- package/dist/cjs/combobox/HTML.combobox.options.collection.js +176 -0
- package/dist/cjs/combobox/HTML.combobox.tag.element.d.ts +7 -1
- package/dist/cjs/combobox/HTML.combobox.tag.element.js +36 -12
- package/dist/cjs/combobox/index.d.ts +1 -0
- package/dist/cjs/combobox/index.js +1 -0
- package/dist/cjs/playwright.d.ts +48 -0
- package/dist/cjs/playwright.js +127 -0
- package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
- package/dist/esm/combobox/Combobox.markup.js +157 -36
- package/dist/esm/combobox/HTML.combobox.element.js +438 -81
- package/dist/esm/combobox/HTML.combobox.option.element.js +104 -66
- package/dist/esm/combobox/HTML.combobox.options.collection.js +170 -0
- package/dist/esm/combobox/HTML.combobox.tag.element.js +36 -12
- package/dist/esm/combobox/index.js +1 -0
- package/dist/esm/playwright.js +123 -0
- package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
- package/dist/types/combobox/Combobox.markup.d.ts +22 -1
- package/dist/types/combobox/HTML.combobox.element.d.ts +32 -5
- package/dist/types/combobox/HTML.combobox.option.element.d.ts +25 -4
- package/dist/types/combobox/HTML.combobox.options.collection.d.ts +37 -0
- package/dist/types/combobox/HTML.combobox.tag.element.d.ts +7 -1
- package/dist/types/combobox/index.d.ts +1 -0
- package/dist/types/playwright.d.ts +48 -0
- package/dist/types/tsconfig.types.tsbuildinfo +1 -1
- package/package.json +18 -5
|
@@ -5,8 +5,14 @@ exports.HTMLComboboxElement = void 0;
|
|
|
5
5
|
const Combobox_markup_js_1 = require("./Combobox.markup.js");
|
|
6
6
|
const HTML_combobox_option_element_js_1 = require("./HTML.combobox.option.element.js");
|
|
7
7
|
const Boolean_attribute_value_normalizer_js_1 = require("./Boolean.attribute.value.normalizer.js");
|
|
8
|
+
const HTML_combobox_options_collection_js_1 = require("./HTML.combobox.options.collection.js");
|
|
8
9
|
class HTMLComboboxElement extends HTMLElement {
|
|
9
10
|
static OWN_IDL = new Set(['required', 'disabled', 'clearable', 'multiple', 'filterable', 'searchable', 'value', 'placeholder', 'query']);
|
|
11
|
+
// Setters (and the setAttribute/removeAttribute overrides routed to them) only normalize and write
|
|
12
|
+
// the attribute; attributeChangedCallback applies the side effects, whatever changed the attribute
|
|
13
|
+
// (setters, native Element.prototype methods, DevTools, other JavaScript worlds).
|
|
14
|
+
static observedAttributes = [..._a.OWN_IDL];
|
|
15
|
+
static #BOOLEAN_ATTRIBUTES = new Set(['required', 'disabled', 'clearable', 'multiple', 'filterable', 'searchable']);
|
|
10
16
|
static observerOptions = { childList: true };
|
|
11
17
|
static styleSheet = [new CSSStyleSheet];
|
|
12
18
|
static formAssociated = true;
|
|
@@ -17,34 +23,61 @@ class HTMLComboboxElement extends HTMLElement {
|
|
|
17
23
|
#values = new Set;
|
|
18
24
|
#prevValue = '';
|
|
19
25
|
#changed = false;
|
|
26
|
+
/** Disabled by an ancestor <fieldset disabled> (see formDisabledCallback) */
|
|
27
|
+
#formDisabled = false;
|
|
28
|
+
/** Whether aria-disabled on the host was set by the component (see #applyDisabledState) */
|
|
29
|
+
#ariaDisabledMirrored = false;
|
|
30
|
+
#options = null;
|
|
31
|
+
#typeahead = '';
|
|
32
|
+
#typeaheadTimer = undefined;
|
|
20
33
|
constructor() {
|
|
21
34
|
super();
|
|
22
35
|
this.#internals = this.attachInternals();
|
|
23
36
|
this.#internals.role = "combobox";
|
|
24
|
-
|
|
25
|
-
|
|
37
|
+
// The host itself takes focus (like a native <select>), so assistive technology reports the
|
|
38
|
+
// focused combobox with its name and value. Inner controls (tag buttons, search field, options)
|
|
39
|
+
// are focused explicitly. With delegatesFocus the focus would land on an inner element instead.
|
|
40
|
+
this.shadowRoot = this.attachShadow({ mode: 'open' });
|
|
26
41
|
this.#markup = new Combobox_markup_js_1.ComboboxMarkup(this.shadowRoot, this.#internals);
|
|
27
42
|
this.shadowRoot.appendChild(Combobox_markup_js_1.ComboboxMarkup.markup.content.cloneNode(true));
|
|
28
43
|
this.shadowRoot.adoptedStyleSheets = _a.styleSheet;
|
|
29
44
|
this.#observer = new MutationObserver(this.#onOptionsChanges);
|
|
30
45
|
this.#markup.connect();
|
|
46
|
+
this.#syncPopupSemantics();
|
|
47
|
+
}
|
|
48
|
+
/** Texts used only by assistive technology, override them to localize. */
|
|
49
|
+
static get strings() {
|
|
50
|
+
return Combobox_markup_js_1.ComboboxMarkup.strings;
|
|
31
51
|
}
|
|
32
52
|
// Lifecycle callbacks
|
|
33
53
|
connectedCallback() {
|
|
54
|
+
// Listen before sweeping the options in: a moved option reconnects and reports its `selected`
|
|
55
|
+
// state, which is how options pre-selected in the markup become the initial value.
|
|
56
|
+
this.shadowRoot.addEventListener('optionselectedstatechange', this.#onOptionSelectedStateChanges);
|
|
57
|
+
this.shadowRoot.addEventListener('tagcleared', this.#onClearTag);
|
|
58
|
+
this.shadowRoot.addEventListener('click', this.#onOptionClick, true);
|
|
34
59
|
for (const child of Array.from(this.children)) {
|
|
35
60
|
if (_a.#isOptionLike(child)) {
|
|
36
61
|
this.#markup.optionsContainer?.appendChild(child);
|
|
37
62
|
}
|
|
38
63
|
}
|
|
64
|
+
this.#syncOptgroups();
|
|
39
65
|
this.#markup.onHostConnected();
|
|
40
|
-
this.
|
|
41
|
-
|
|
66
|
+
if (!this.hasAttribute('tabindex'))
|
|
67
|
+
super.setAttribute('tabindex', '0');
|
|
68
|
+
// Mirrors the internals role, so tools that only read attributes (Playwright's getByRole,
|
|
69
|
+
// Testing Library) find the combobox too. The states stay in internals only.
|
|
70
|
+
if (!this.hasAttribute('role'))
|
|
71
|
+
super.setAttribute('role', 'combobox');
|
|
42
72
|
this.#initialAttributesSynchronization();
|
|
43
73
|
this.#observer.observe(this.#markup.optionsContainer, _a.observerOptions);
|
|
44
74
|
this.addEventListener('keydown', this.#onKeyDown);
|
|
75
|
+
this.addEventListener('click', this.#onHostClick);
|
|
45
76
|
this.#markup.clearAllButton.addEventListener('click', this.#onClearAll);
|
|
46
77
|
this.#markup.searchInput.addEventListener('input', this.#onInput);
|
|
47
78
|
this.#markup.dropdown.addEventListener('toggle', this.#onPickerToggle);
|
|
79
|
+
this.#applyDisabledState();
|
|
80
|
+
this.#syncPopupSemantics();
|
|
48
81
|
this.#setValidityAndFormValue();
|
|
49
82
|
// Options/optgroups can end up as direct light-DOM children of <combo-box> without ever
|
|
50
83
|
// going through insertBefore/appendChild below: parsed straight from static HTML markup,
|
|
@@ -52,8 +85,11 @@ class HTMLComboboxElement extends HTMLElement {
|
|
|
52
85
|
// <template> + cloneNode(true), never calling any child-mutation method at all. Sweep
|
|
53
86
|
// whatever is already here once on connect and move it into the real options container.
|
|
54
87
|
}
|
|
88
|
+
// Matched by name too: content created with innerHTML connects <combo-box> before its
|
|
89
|
+
// <box-option> children are upgraded, so they aren't instances of the class yet.
|
|
55
90
|
static #isOptionLike(node) {
|
|
56
|
-
return node instanceof HTML_combobox_option_element_js_1.HTMLComboboxOptionElement || node instanceof HTMLOptGroupElement
|
|
91
|
+
return node instanceof HTML_combobox_option_element_js_1.HTMLComboboxOptionElement || node instanceof HTMLOptGroupElement
|
|
92
|
+
|| (node instanceof Element && node.localName === 'box-option');
|
|
57
93
|
}
|
|
58
94
|
// Overwritten child-mutation methods.
|
|
59
95
|
//
|
|
@@ -129,22 +165,64 @@ class HTMLComboboxElement extends HTMLElement {
|
|
|
129
165
|
this.#observer.disconnect();
|
|
130
166
|
this.#markup.disconnect();
|
|
131
167
|
this.removeEventListener('keydown', this.#onKeyDown);
|
|
168
|
+
this.removeEventListener('click', this.#onHostClick);
|
|
132
169
|
this.#markup.clearAllButton.removeEventListener('click', this.#onClearAll);
|
|
133
170
|
this.#markup.searchInput.removeEventListener('input', this.#onInput);
|
|
134
171
|
this.#markup.dropdown.removeEventListener('toggle', this.#onPickerToggle);
|
|
135
172
|
this.shadowRoot.removeEventListener('optionselectedstatechange', this.#onOptionSelectedStateChanges);
|
|
136
173
|
this.shadowRoot.removeEventListener('tagcleared', this.#onClearTag);
|
|
174
|
+
this.shadowRoot.removeEventListener('click', this.#onOptionClick, true);
|
|
175
|
+
}
|
|
176
|
+
attributeChangedCallback(name, oldValue, newValue) {
|
|
177
|
+
if (oldValue === newValue || !this.#markup)
|
|
178
|
+
return;
|
|
179
|
+
// A path that skipped the setters can leave e.g. multiple="false": fix it once. The removal
|
|
180
|
+
// calls back again with a valid value, which applies the change; that one isn't fixed again,
|
|
181
|
+
// so this can't loop. The side effects below are safe to repeat.
|
|
182
|
+
if (_a.#BOOLEAN_ATTRIBUTES.has(name) && newValue !== null && !(0, Boolean_attribute_value_normalizer_js_1.toBoolean)(newValue)) {
|
|
183
|
+
super.removeAttribute(name);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
switch (name) {
|
|
187
|
+
case 'required':
|
|
188
|
+
this.#internals.ariaRequired = newValue !== null ? 'true' : null;
|
|
189
|
+
if (this.isConnected)
|
|
190
|
+
this.#setValidityAndFormValue();
|
|
191
|
+
break;
|
|
192
|
+
case 'disabled':
|
|
193
|
+
if (this.isConnected)
|
|
194
|
+
this.#applyDisabledState();
|
|
195
|
+
break;
|
|
196
|
+
case 'clearable':
|
|
197
|
+
case 'multiple':
|
|
198
|
+
case 'filterable':
|
|
199
|
+
case 'searchable':
|
|
200
|
+
this.#syncPopupSemantics();
|
|
201
|
+
break;
|
|
202
|
+
case 'placeholder':
|
|
203
|
+
this.#markup.setPlaceholder(newValue ?? '');
|
|
204
|
+
break;
|
|
205
|
+
case 'query':
|
|
206
|
+
this.#markup.setInputValue(newValue ?? '');
|
|
207
|
+
break;
|
|
208
|
+
case 'value':
|
|
209
|
+
// The value attribute is an initial value: the value setter doesn't write it back.
|
|
210
|
+
// Changed from outside the setter (natively, DevTools), apply it.
|
|
211
|
+
if (newValue !== null)
|
|
212
|
+
this.value = newValue;
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
137
215
|
}
|
|
138
216
|
formResetCallback() {
|
|
139
217
|
this.selectedOptions.forEach(option => option.selected = false);
|
|
218
|
+
this.#values.clear();
|
|
140
219
|
this.#markup.tagsContainer?.replaceChildren();
|
|
220
|
+
this.#setValidityAndFormValue();
|
|
141
221
|
this.dispatchEvent(new Event('reset'));
|
|
142
|
-
requestAnimationFrame(() => this.#markup.searchInput?.focus());
|
|
143
222
|
}
|
|
144
|
-
formDisabledCallback() {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
// isDisabled: boolean
|
|
223
|
+
formDisabledCallback(disabled) {
|
|
224
|
+
this.#formDisabled = disabled;
|
|
225
|
+
this.#applyDisabledState();
|
|
148
226
|
}
|
|
149
227
|
// Instance properties
|
|
150
228
|
// <combo-box> specific properties
|
|
@@ -161,7 +239,6 @@ class HTMLComboboxElement extends HTMLElement {
|
|
|
161
239
|
value = '';
|
|
162
240
|
value = String(value);
|
|
163
241
|
super.setAttribute('query', value);
|
|
164
|
-
this.#markup?.setInputValue(value);
|
|
165
242
|
}
|
|
166
243
|
get placeholder() {
|
|
167
244
|
return this.getAttribute('placeholder');
|
|
@@ -171,7 +248,6 @@ class HTMLComboboxElement extends HTMLElement {
|
|
|
171
248
|
value = ' ';
|
|
172
249
|
value = String(value);
|
|
173
250
|
super.setAttribute('placeholder', value);
|
|
174
|
-
this.#markup?.setPlaceholder(value);
|
|
175
251
|
}
|
|
176
252
|
get clearable() {
|
|
177
253
|
return this.hasAttribute('clearable');
|
|
@@ -196,10 +272,7 @@ class HTMLComboboxElement extends HTMLElement {
|
|
|
196
272
|
return this.hasAttribute('disabled');
|
|
197
273
|
}
|
|
198
274
|
set disabled(value) {
|
|
199
|
-
|
|
200
|
-
value = (0, Boolean_attribute_value_normalizer_js_1.toBoolean)(value);
|
|
201
|
-
super.toggleAttribute('disabled', value);
|
|
202
|
-
super.toggleAttribute('inert', value);
|
|
275
|
+
super.toggleAttribute('disabled', (0, Boolean_attribute_value_normalizer_js_1.toBoolean)(value));
|
|
203
276
|
}
|
|
204
277
|
get form() {
|
|
205
278
|
return this.#internals.form;
|
|
@@ -208,7 +281,11 @@ class HTMLComboboxElement extends HTMLElement {
|
|
|
208
281
|
return this.#internals.labels;
|
|
209
282
|
}
|
|
210
283
|
get length() {
|
|
211
|
-
return this
|
|
284
|
+
return this.options.length;
|
|
285
|
+
}
|
|
286
|
+
/** Like `select.length = n`: appends blank options, or removes the last ones */
|
|
287
|
+
set length(value) {
|
|
288
|
+
this.options.length = value;
|
|
212
289
|
}
|
|
213
290
|
get multiple() {
|
|
214
291
|
return this.hasAttribute('multiple');
|
|
@@ -216,16 +293,27 @@ class HTMLComboboxElement extends HTMLElement {
|
|
|
216
293
|
set multiple(value) {
|
|
217
294
|
super.toggleAttribute('multiple', (0, Boolean_attribute_value_normalizer_js_1.toBoolean)(value));
|
|
218
295
|
}
|
|
219
|
-
|
|
220
|
-
|
|
296
|
+
/** The options, as a live `HTMLOptionsCollection`-like list (like `select.options`) */
|
|
297
|
+
get options() {
|
|
298
|
+
this.#options ??= new HTML_combobox_options_collection_js_1.HTMLComboboxOptionsCollection({
|
|
299
|
+
container: () => this.#markup.optionsContainer,
|
|
300
|
+
options: () => this.#markup.options,
|
|
301
|
+
});
|
|
302
|
+
return this.#options;
|
|
303
|
+
}
|
|
221
304
|
get required() {
|
|
222
305
|
return this.hasAttribute('required');
|
|
223
306
|
}
|
|
224
307
|
set required(value) {
|
|
225
|
-
this.#internals.ariaRequired = String(value);
|
|
226
308
|
super.toggleAttribute('required', (0, Boolean_attribute_value_normalizer_js_1.toBoolean)(value));
|
|
227
309
|
}
|
|
228
|
-
|
|
310
|
+
/** Index of the first selected option, or -1. Setting it selects only that option. */
|
|
311
|
+
get selectedIndex() {
|
|
312
|
+
return this.options.selectedIndex;
|
|
313
|
+
}
|
|
314
|
+
set selectedIndex(value) {
|
|
315
|
+
this.options.selectedIndex = value;
|
|
316
|
+
}
|
|
229
317
|
get selectedOptions() {
|
|
230
318
|
return this.#markup.selectedOptions;
|
|
231
319
|
}
|
|
@@ -247,10 +335,14 @@ class HTMLComboboxElement extends HTMLElement {
|
|
|
247
335
|
get value() {
|
|
248
336
|
return this.valueAsArray.join(',');
|
|
249
337
|
}
|
|
338
|
+
// Like select.value: null/undefined clear the selection, anything else is converted to a string
|
|
339
|
+
// (42 selects the option with value "42"); a value no option has clears the selection.
|
|
340
|
+
// Comma-separated values select several options.
|
|
250
341
|
set value(value) {
|
|
251
|
-
|
|
342
|
+
const next = value == null ? '' : String(value);
|
|
343
|
+
if (this.value === next)
|
|
252
344
|
return;
|
|
253
|
-
const nextValues = new Set(
|
|
345
|
+
const nextValues = new Set(next.split(',').filter(Boolean));
|
|
254
346
|
this.#markup.options.forEach(option => {
|
|
255
347
|
const optionValue = option.value;
|
|
256
348
|
// toggling `selected` drives #onOptionSelectedStateChanges, which is the
|
|
@@ -262,18 +354,30 @@ class HTMLComboboxElement extends HTMLElement {
|
|
|
262
354
|
}
|
|
263
355
|
// Instance methods
|
|
264
356
|
// <select> specific methods (implements HTMLSelectElement)
|
|
265
|
-
|
|
357
|
+
/** Like `select.add()`: inserts an option or optgroup before `before` (an element or an index), or at the end */
|
|
358
|
+
add(element, before) {
|
|
359
|
+
this.options.add(element, before);
|
|
360
|
+
}
|
|
266
361
|
checkValidity() {
|
|
267
|
-
this.#internals.checkValidity();
|
|
362
|
+
return this.#internals.checkValidity();
|
|
268
363
|
}
|
|
269
364
|
item(index) {
|
|
270
|
-
return this
|
|
365
|
+
return this.options.item(index);
|
|
366
|
+
}
|
|
367
|
+
/** The first option whose `id` or `name` attribute is `name` */
|
|
368
|
+
namedItem(name) {
|
|
369
|
+
return this.options.namedItem(name);
|
|
370
|
+
}
|
|
371
|
+
remove(...args) {
|
|
372
|
+
if (args.length === 0) {
|
|
373
|
+
super.remove();
|
|
374
|
+
}
|
|
375
|
+
else {
|
|
376
|
+
this.options.remove(args[0]);
|
|
377
|
+
}
|
|
271
378
|
}
|
|
272
|
-
// namedItem()
|
|
273
|
-
// !!! Conflicts with node.remove()
|
|
274
|
-
// remove(index: number)
|
|
275
379
|
reportValidity() {
|
|
276
|
-
this.#internals.reportValidity();
|
|
380
|
+
return this.#internals.reportValidity();
|
|
277
381
|
}
|
|
278
382
|
setCustomValidity(message) {
|
|
279
383
|
if (message === '') {
|
|
@@ -286,6 +390,10 @@ class HTMLComboboxElement extends HTMLElement {
|
|
|
286
390
|
showPicker() {
|
|
287
391
|
this.#markup.showDropdown();
|
|
288
392
|
}
|
|
393
|
+
/** Closes the dropdown, if open */
|
|
394
|
+
hidePicker() {
|
|
395
|
+
this.#markup.closeDropdown();
|
|
396
|
+
}
|
|
289
397
|
// Overwritten methods
|
|
290
398
|
setAttribute(name, value) {
|
|
291
399
|
if (_a.OWN_IDL.has(name)) {
|
|
@@ -296,15 +404,21 @@ class HTMLComboboxElement extends HTMLElement {
|
|
|
296
404
|
}
|
|
297
405
|
}
|
|
298
406
|
removeAttribute(name) {
|
|
299
|
-
if (_a.
|
|
407
|
+
if (_a.#BOOLEAN_ATTRIBUTES.has(name)) {
|
|
300
408
|
Reflect.set(this, name, false);
|
|
301
409
|
}
|
|
410
|
+
else if (name === 'placeholder' || name === 'query') {
|
|
411
|
+
Reflect.set(this, name, null);
|
|
412
|
+
}
|
|
302
413
|
else {
|
|
414
|
+
// includes `value`: the attribute is only an initial value, removing it keeps the selection
|
|
303
415
|
super.removeAttribute(name);
|
|
304
416
|
}
|
|
305
417
|
}
|
|
306
418
|
// Internal
|
|
307
419
|
#onOptionsChanges = (records) => {
|
|
420
|
+
this.#syncOptgroups();
|
|
421
|
+
this.#markup.syncValueText();
|
|
308
422
|
records.forEach(record => {
|
|
309
423
|
record.removedNodes.forEach(option => {
|
|
310
424
|
if (option instanceof HTML_combobox_option_element_js_1.HTMLComboboxOptionElement) {
|
|
@@ -319,7 +433,7 @@ class HTMLComboboxElement extends HTMLElement {
|
|
|
319
433
|
}
|
|
320
434
|
// options was removed, but values still include this value,
|
|
321
435
|
// just clear, this is like a reset was called
|
|
322
|
-
if (this.#values.has(value) && !this.#markup.
|
|
436
|
+
if (this.#values.has(value) && !this.#markup.getOptionByValue(value)) {
|
|
323
437
|
this.#values.delete(value);
|
|
324
438
|
const control = this.#markup.getTagByValue(value);
|
|
325
439
|
control?.remove();
|
|
@@ -334,6 +448,9 @@ class HTMLComboboxElement extends HTMLElement {
|
|
|
334
448
|
if (option.selected) {
|
|
335
449
|
if (!this.multiple) {
|
|
336
450
|
this.#values.forEach(value => {
|
|
451
|
+
// The same option can report its state more than once (moved, upgraded): never deselect it
|
|
452
|
+
if (value === option.value)
|
|
453
|
+
return;
|
|
337
454
|
const prevOption = this.#markup.getOptionByValue(value);
|
|
338
455
|
if (prevOption) {
|
|
339
456
|
prevOption.selected = false;
|
|
@@ -349,7 +466,6 @@ class HTMLComboboxElement extends HTMLElement {
|
|
|
349
466
|
this.#values.add(value);
|
|
350
467
|
this.#markup.createAndAppendTag(option);
|
|
351
468
|
this.#setValidityAndFormValue();
|
|
352
|
-
this.dispatchEvent(new Event('change'));
|
|
353
469
|
}
|
|
354
470
|
}
|
|
355
471
|
else {
|
|
@@ -358,7 +474,6 @@ class HTMLComboboxElement extends HTMLElement {
|
|
|
358
474
|
const control = this.#markup.getTagByValue(value);
|
|
359
475
|
control?.remove();
|
|
360
476
|
this.#setValidityAndFormValue();
|
|
361
|
-
this.dispatchEvent(new Event('change'));
|
|
362
477
|
}
|
|
363
478
|
else {
|
|
364
479
|
const tag = this.#markup.getTagByValue(value);
|
|
@@ -368,7 +483,11 @@ class HTMLComboboxElement extends HTMLElement {
|
|
|
368
483
|
};
|
|
369
484
|
#onClearTag = (event) => {
|
|
370
485
|
const target = event.target;
|
|
371
|
-
if (target)
|
|
486
|
+
if (!target)
|
|
487
|
+
return;
|
|
488
|
+
const tags = Array.from(this.#markup.tagsContainer?.querySelectorAll('box-tag') ?? []);
|
|
489
|
+
const index = tags.indexOf(target);
|
|
490
|
+
this.#asUserAction(() => {
|
|
372
491
|
const option = this.#markup.getOptionByValue(target.value);
|
|
373
492
|
if (option) {
|
|
374
493
|
option.selected = false;
|
|
@@ -377,29 +496,79 @@ class HTMLComboboxElement extends HTMLElement {
|
|
|
377
496
|
const tag = this.#markup.getTagByValue(target.value);
|
|
378
497
|
this.#values.delete(target.value);
|
|
379
498
|
tag?.remove();
|
|
499
|
+
this.#setValidityAndFormValue();
|
|
380
500
|
}
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
501
|
+
});
|
|
502
|
+
// Keep the keyboard user where they were: on the next remaining tag's button,
|
|
503
|
+
// otherwise on the search field (when it's shown) or on the combobox itself.
|
|
504
|
+
const remaining = Array.from(this.#markup.tagsContainer?.querySelectorAll('[part*="clear-tag"]') ?? [])
|
|
505
|
+
.filter((button) => button.checkVisibility());
|
|
506
|
+
const next = remaining[Math.min(index, remaining.length - 1)];
|
|
507
|
+
if (next) {
|
|
508
|
+
next.focus();
|
|
509
|
+
}
|
|
510
|
+
else if (this.#markup.hasSearch && this.#markup.expanded) {
|
|
511
|
+
this.#markup.searchInput?.focus();
|
|
512
|
+
}
|
|
513
|
+
else {
|
|
514
|
+
this.#markup.focusHost();
|
|
392
515
|
}
|
|
393
516
|
};
|
|
394
517
|
#onClearAll = () => {
|
|
395
|
-
this.formResetCallback();
|
|
518
|
+
this.#asUserAction(() => this.formResetCallback());
|
|
519
|
+
if (this.#markup.hasSearch && this.#markup.expanded) {
|
|
520
|
+
this.#markup.searchInput?.focus();
|
|
521
|
+
}
|
|
522
|
+
else {
|
|
523
|
+
this.#markup.focusHost();
|
|
524
|
+
}
|
|
525
|
+
};
|
|
526
|
+
// The dropdown opens when focus arrives. A combobox that keeps focus after it closed (an option
|
|
527
|
+
// was picked, Escape) gets no new focus from a click, so the click itself opens it.
|
|
528
|
+
// Not for clicks inside the dropdown (picking an option) or on the tag/clear buttons.
|
|
529
|
+
#onHostClick = (event) => {
|
|
530
|
+
if (this.#isDisabled || this.#markup.expanded)
|
|
531
|
+
return;
|
|
532
|
+
const path = event.composedPath();
|
|
533
|
+
if (this.#markup.dropdown && path.includes(this.#markup.dropdown))
|
|
534
|
+
return;
|
|
535
|
+
const onButton = path.some((node) => node instanceof Element && /\bclear-(tag|all)\b/.test(node.getAttribute('part') ?? ''));
|
|
536
|
+
if (onButton)
|
|
537
|
+
return;
|
|
538
|
+
this.#markup.showDropdown();
|
|
396
539
|
};
|
|
540
|
+
// Picking an option with the pointer: toggles it (multiple) or selects it and closes (single),
|
|
541
|
+
// like <select>. The click may land on content inside the option.
|
|
542
|
+
#onOptionClick = (event) => {
|
|
543
|
+
const option = event.target?.closest?.('box-option');
|
|
544
|
+
if (!option || Combobox_markup_js_1.ComboboxMarkup.isOptionDisabled(option))
|
|
545
|
+
return;
|
|
546
|
+
this.#asUserAction(() => {
|
|
547
|
+
option.selected = this.multiple ? !option.selected : true;
|
|
548
|
+
});
|
|
549
|
+
if (!this.multiple) {
|
|
550
|
+
this.#markup.closeDropdown();
|
|
551
|
+
this.#markup.focusHost();
|
|
552
|
+
}
|
|
553
|
+
};
|
|
554
|
+
// Events follow the native rule: `change` reports what the *user* did, once per action, and
|
|
555
|
+
// only when the value actually changed. Changes made by script (value, selectedIndex,
|
|
556
|
+
// option.selected, attributes, the options collection, form reset) dispatch nothing: firing
|
|
557
|
+
// for them makes frameworks that set a value and listen for change loop or handle twice.
|
|
558
|
+
// (`input` is the search field's own event: the user typed.)
|
|
559
|
+
#asUserAction(action) {
|
|
560
|
+
const before = this.value;
|
|
561
|
+
action();
|
|
562
|
+
if (this.value !== before)
|
|
563
|
+
this.dispatchEvent(new Event('change', { bubbles: true }));
|
|
564
|
+
}
|
|
397
565
|
#onPickerToggle = (event) => {
|
|
398
566
|
const state = Reflect.get(event, 'newState');
|
|
399
567
|
const oldState = Reflect.get(event, 'oldState');
|
|
400
568
|
if (state === oldState)
|
|
401
569
|
return;
|
|
402
570
|
if (state === 'open') {
|
|
571
|
+
this.#syncPopupSemantics();
|
|
403
572
|
this.#prevValue = this.value;
|
|
404
573
|
this.#changed = true;
|
|
405
574
|
}
|
|
@@ -408,54 +577,246 @@ class HTMLComboboxElement extends HTMLElement {
|
|
|
408
577
|
this.dispatchEvent(new Event('close'));
|
|
409
578
|
}
|
|
410
579
|
};
|
|
580
|
+
// Keyboard interaction follows the WAI-ARIA "select-only combobox" pattern:
|
|
581
|
+
// https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-select-only/
|
|
411
582
|
#onKeyDown = (event) => {
|
|
412
|
-
if (this.#
|
|
583
|
+
if (this.#isDisabled || event.defaultPrevented)
|
|
413
584
|
return;
|
|
414
|
-
const
|
|
415
|
-
|
|
416
|
-
|
|
585
|
+
const active = this.shadowRoot.activeElement;
|
|
586
|
+
const expanded = this.#markup.expanded;
|
|
587
|
+
const { key } = event;
|
|
588
|
+
if (key === 'Escape') {
|
|
589
|
+
if (!expanded)
|
|
590
|
+
return;
|
|
591
|
+
event.preventDefault();
|
|
592
|
+
event.stopPropagation();
|
|
593
|
+
this.#markup.closeDropdown();
|
|
594
|
+
this.#markup.focusHost();
|
|
417
595
|
return;
|
|
418
596
|
}
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
if (
|
|
422
|
-
|
|
423
|
-
this.#markup.
|
|
597
|
+
// Focus is on the combobox itself
|
|
598
|
+
if (!active) {
|
|
599
|
+
if (key === 'ArrowDown' || key === 'ArrowUp' || key === 'Enter' || key === ' ' || key === 'Home' || key === 'End') {
|
|
600
|
+
event.preventDefault();
|
|
601
|
+
this.#markup.showDropdown();
|
|
602
|
+
if (this.#markup.hasSearch && key !== 'ArrowUp' && key !== 'End' && key !== 'Home') {
|
|
603
|
+
this.#markup.searchInput?.focus();
|
|
604
|
+
}
|
|
605
|
+
else if (key === 'ArrowUp' || key === 'End') {
|
|
606
|
+
this.#focusOption(key === 'End' ? this.#lastOption() : this.#selectedNavigableOption() ?? this.#lastOption());
|
|
607
|
+
}
|
|
608
|
+
else if (key === 'Home') {
|
|
609
|
+
this.#focusOption(this.#firstOption());
|
|
610
|
+
}
|
|
611
|
+
else {
|
|
612
|
+
this.#focusOption(this.#selectedNavigableOption() ?? this.#firstOption());
|
|
613
|
+
}
|
|
614
|
+
return;
|
|
424
615
|
}
|
|
425
|
-
if (this.
|
|
426
|
-
|
|
616
|
+
if (this.#isTypeaheadKey(event) && !this.#markup.hasSearch) {
|
|
617
|
+
event.preventDefault();
|
|
618
|
+
this.#markup.showDropdown();
|
|
619
|
+
this.#typeaheadFocus(key, null);
|
|
427
620
|
}
|
|
428
621
|
return;
|
|
429
622
|
}
|
|
430
|
-
if (
|
|
431
|
-
if (
|
|
623
|
+
if (active === this.#markup.searchInput) {
|
|
624
|
+
if (key === 'Enter') {
|
|
432
625
|
event.stopPropagation();
|
|
626
|
+
if (!this.searchable && this.filterable) {
|
|
627
|
+
this.#markup.sort(this.#markup.searchInput.value);
|
|
628
|
+
}
|
|
629
|
+
if (this.searchable) {
|
|
630
|
+
this.dispatchEvent(new Event('search'));
|
|
631
|
+
}
|
|
433
632
|
return;
|
|
434
633
|
}
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
// event.preventDefault()
|
|
441
|
-
// @ts-ignore
|
|
442
|
-
activeElement.click();
|
|
443
|
-
// event.stopPropagation();
|
|
634
|
+
if (key === 'ArrowDown') {
|
|
635
|
+
event.preventDefault();
|
|
636
|
+
this.#markup.showDropdown();
|
|
637
|
+
this.#focusOption(this.#firstOption());
|
|
638
|
+
}
|
|
444
639
|
return;
|
|
445
640
|
}
|
|
446
|
-
if (
|
|
447
|
-
this.#markup.
|
|
448
|
-
|
|
641
|
+
if (active instanceof HTML_combobox_option_element_js_1.HTMLComboboxOptionElement) {
|
|
642
|
+
const options = this.#markup.navigableOptions;
|
|
643
|
+
const index = options.indexOf(active);
|
|
644
|
+
switch (key) {
|
|
645
|
+
case 'ArrowDown':
|
|
646
|
+
event.preventDefault();
|
|
647
|
+
this.#focusOption(options[index + 1] ?? null);
|
|
648
|
+
return;
|
|
649
|
+
case 'ArrowUp':
|
|
650
|
+
event.preventDefault();
|
|
651
|
+
if (index <= 0 && this.#markup.hasSearch) {
|
|
652
|
+
this.#markup.searchInput?.focus();
|
|
653
|
+
}
|
|
654
|
+
else {
|
|
655
|
+
this.#focusOption(options[index - 1] ?? null);
|
|
656
|
+
}
|
|
657
|
+
return;
|
|
658
|
+
case 'Home':
|
|
659
|
+
case 'PageUp':
|
|
660
|
+
event.preventDefault();
|
|
661
|
+
this.#focusOption(this.#firstOption());
|
|
662
|
+
return;
|
|
663
|
+
case 'End':
|
|
664
|
+
case 'PageDown':
|
|
665
|
+
event.preventDefault();
|
|
666
|
+
this.#focusOption(this.#lastOption());
|
|
667
|
+
return;
|
|
668
|
+
case 'Enter':
|
|
669
|
+
case ' ':
|
|
670
|
+
event.preventDefault();
|
|
671
|
+
event.stopPropagation();
|
|
672
|
+
if (Combobox_markup_js_1.ComboboxMarkup.isOptionDisabled(active))
|
|
673
|
+
return;
|
|
674
|
+
this.#asUserAction(() => {
|
|
675
|
+
active.selected = this.multiple ? !active.selected : true;
|
|
676
|
+
});
|
|
677
|
+
if (!this.multiple) {
|
|
678
|
+
this.#markup.closeDropdown();
|
|
679
|
+
this.#markup.focusHost();
|
|
680
|
+
}
|
|
681
|
+
return;
|
|
682
|
+
default:
|
|
683
|
+
if (this.#isTypeaheadKey(event)) {
|
|
684
|
+
event.preventDefault();
|
|
685
|
+
this.#typeaheadFocus(key, active);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
449
688
|
}
|
|
450
689
|
};
|
|
690
|
+
#firstOption() {
|
|
691
|
+
return this.#markup.navigableOptions[0] ?? null;
|
|
692
|
+
}
|
|
693
|
+
#lastOption() {
|
|
694
|
+
return this.#markup.navigableOptions.at(-1) ?? null;
|
|
695
|
+
}
|
|
696
|
+
#selectedNavigableOption() {
|
|
697
|
+
return this.#markup.navigableOptions.find((option) => option.selected) ?? null;
|
|
698
|
+
}
|
|
699
|
+
#focusOption(option) {
|
|
700
|
+
if (!option)
|
|
701
|
+
return;
|
|
702
|
+
option.focus();
|
|
703
|
+
option.scrollIntoView?.({ block: 'nearest' });
|
|
704
|
+
}
|
|
705
|
+
#isTypeaheadKey(event) {
|
|
706
|
+
return event.key.length === 1 && event.key !== ' ' && !event.ctrlKey && !event.metaKey && !event.altKey;
|
|
707
|
+
}
|
|
708
|
+
// Typing characters moves to the next option whose label starts with them, as in <select>
|
|
709
|
+
#typeaheadFocus(char, from) {
|
|
710
|
+
clearTimeout(this.#typeaheadTimer);
|
|
711
|
+
this.#typeaheadTimer = setTimeout(() => this.#typeahead = '', 500);
|
|
712
|
+
const repeated = this.#typeahead.length === 1 && this.#typeahead === char.toLowerCase();
|
|
713
|
+
this.#typeahead = repeated ? this.#typeahead : this.#typeahead + char.toLowerCase();
|
|
714
|
+
const options = this.#markup.navigableOptions;
|
|
715
|
+
const start = from ? options.indexOf(from) + (repeated || this.#typeahead.length === 1 ? 1 : 0) : 0;
|
|
716
|
+
const ordered = [...options.slice(start), ...options.slice(0, start)];
|
|
717
|
+
const match = ordered.find((option) => Combobox_markup_js_1.ComboboxMarkup.optionLabel(option).toLowerCase().startsWith(this.#typeahead));
|
|
718
|
+
this.#focusOption(match ?? null);
|
|
719
|
+
}
|
|
451
720
|
#onInput = () => {
|
|
452
721
|
this.query = this.#markup.searchInput.value;
|
|
453
722
|
if (!this.searchable && this.filterable) {
|
|
454
723
|
this.#markup.sort(this.#markup.searchInput?.value || '');
|
|
455
724
|
}
|
|
456
725
|
};
|
|
726
|
+
get #isDisabled() {
|
|
727
|
+
return this.disabled || this.#formDisabled;
|
|
728
|
+
}
|
|
729
|
+
// A disabled combobox stays in the accessibility tree, exposed as disabled like a native
|
|
730
|
+
// <select disabled> (`inert` would remove it from the tree). The browser already takes a
|
|
731
|
+
// disabled form-associated element out of the focus order; its inner controls are made inert
|
|
732
|
+
// and it ignores the pointer (:state(disabled)).
|
|
733
|
+
// Known Chrome limitation: the value of a disabled combobox is not exposed, because Chrome
|
|
734
|
+
// computes the value of an ARIA combobox only while it is focusable.
|
|
735
|
+
#applyDisabledState() {
|
|
736
|
+
const disabled = this.#isDisabled;
|
|
737
|
+
this.#markup.disabled = disabled;
|
|
738
|
+
this.#internals.ariaDisabled = disabled ? 'true' : null;
|
|
739
|
+
// Mirrored as an attribute, like role: tools reading the DOM (Playwright's actionability
|
|
740
|
+
// checks and toBeDisabled(), Testing Library) can't see internals. An author's own
|
|
741
|
+
// aria-disabled is left alone.
|
|
742
|
+
if (disabled && !this.hasAttribute('aria-disabled')) {
|
|
743
|
+
super.setAttribute('aria-disabled', 'true');
|
|
744
|
+
this.#ariaDisabledMirrored = true;
|
|
745
|
+
}
|
|
746
|
+
else if (!disabled && this.#ariaDisabledMirrored) {
|
|
747
|
+
super.removeAttribute('aria-disabled');
|
|
748
|
+
this.#ariaDisabledMirrored = false;
|
|
749
|
+
}
|
|
750
|
+
try {
|
|
751
|
+
if (disabled)
|
|
752
|
+
this.#internals.states.add('disabled');
|
|
753
|
+
else
|
|
754
|
+
this.#internals.states.delete('disabled');
|
|
755
|
+
}
|
|
756
|
+
catch (e) { /* CustomStateSet not supported */ }
|
|
757
|
+
if (this.#markup.internal)
|
|
758
|
+
this.#markup.internal.inert = disabled;
|
|
759
|
+
if (this.#markup.dropdown)
|
|
760
|
+
this.#markup.dropdown.inert = disabled;
|
|
761
|
+
if (disabled) {
|
|
762
|
+
this.#markup.closeDropdown();
|
|
763
|
+
if (this.matches(':focus, :focus-within'))
|
|
764
|
+
this.blur();
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
// <optgroup> has its group semantics only inside a <select>; restore them here.
|
|
768
|
+
#syncOptgroups() {
|
|
769
|
+
this.#markup.optionsContainer?.querySelectorAll('optgroup').forEach((group) => {
|
|
770
|
+
group.setAttribute('role', 'group');
|
|
771
|
+
if (group.label)
|
|
772
|
+
group.setAttribute('aria-label', group.label);
|
|
773
|
+
else
|
|
774
|
+
group.removeAttribute('aria-label');
|
|
775
|
+
if (group.disabled)
|
|
776
|
+
group.setAttribute('aria-disabled', 'true');
|
|
777
|
+
else
|
|
778
|
+
group.removeAttribute('aria-disabled');
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
// Without a search field the popup is just the listbox, unnamed like the popup of a native <select>.
|
|
782
|
+
// With one, it is a dialog holding the field and the listbox.
|
|
783
|
+
// Note: any *named* element inside the combobox would be read as part of its value
|
|
784
|
+
// (Chrome computes the value of a non-editable combobox from its content), so the
|
|
785
|
+
// dialog stays unnamed; the listbox inside it is named after the <label>.
|
|
786
|
+
#syncPopupSemantics() {
|
|
787
|
+
const markup = this.#markup;
|
|
788
|
+
if (!markup)
|
|
789
|
+
return;
|
|
790
|
+
const withSearch = this.searchable || this.filterable;
|
|
791
|
+
this.#internals.ariaHasPopup = withSearch ? 'dialog' : 'listbox';
|
|
792
|
+
// Without a search field the popup has no role of its own: Chrome exposes the bare popover as an
|
|
793
|
+
// unnamed group around the listbox. Keep it: a listbox directly inside the combobox would add
|
|
794
|
+
// its selected option to the combobox value a second time ("Banana Banana").
|
|
795
|
+
if (withSearch)
|
|
796
|
+
markup.dropdown?.setAttribute('role', 'dialog');
|
|
797
|
+
else
|
|
798
|
+
markup.dropdown?.removeAttribute('role');
|
|
799
|
+
const popup = withSearch ? markup.dropdown : markup.optionsContainer;
|
|
800
|
+
if (popup)
|
|
801
|
+
this.#internals.ariaControlsElements = [popup];
|
|
802
|
+
markup.optionsContainer?.setAttribute('aria-multiselectable', String(this.multiple));
|
|
803
|
+
// Shadow content can't point at the outer <label> with aria-labelledby, so it borrows the label's text.
|
|
804
|
+
const name = Array.from(this.#internals.labels ?? [], (label) => label.textContent?.trim()).filter(Boolean).join(' ')
|
|
805
|
+
|| this.getAttribute('aria-label') || '';
|
|
806
|
+
if (withSearch && name)
|
|
807
|
+
markup.optionsContainer?.setAttribute('aria-label', name);
|
|
808
|
+
else
|
|
809
|
+
markup.optionsContainer?.removeAttribute('aria-label');
|
|
810
|
+
markup.dropdown?.removeAttribute('aria-label');
|
|
811
|
+
// The tags area has interactive content (remove buttons) only when clearable or multiple.
|
|
812
|
+
// Otherwise it only repeats the value, which is already exposed, so hide it.
|
|
813
|
+
if (this.clearable || this.multiple)
|
|
814
|
+
markup.internal?.removeAttribute('aria-hidden');
|
|
815
|
+
else
|
|
816
|
+
markup.internal?.setAttribute('aria-hidden', 'true');
|
|
817
|
+
}
|
|
457
818
|
#setValidityAndFormValue() {
|
|
458
|
-
this.#markup.
|
|
819
|
+
this.#markup.syncValueText();
|
|
459
820
|
let empty = false;
|
|
460
821
|
if (this.#values.size === 0)
|
|
461
822
|
empty = true;
|
|
@@ -473,6 +834,10 @@ class HTMLComboboxElement extends HTMLElement {
|
|
|
473
834
|
}
|
|
474
835
|
#initialAttributesSynchronization() {
|
|
475
836
|
for (const key of _a.OWN_IDL) {
|
|
837
|
+
// Without a value attribute, the selection comes from the options' `selected` attributes:
|
|
838
|
+
// `value = null` would clear it
|
|
839
|
+
if (key === 'value' && !this.hasAttribute('value'))
|
|
840
|
+
continue;
|
|
476
841
|
this[key] = this.getAttribute(key);
|
|
477
842
|
}
|
|
478
843
|
}
|
|
@@ -501,14 +866,6 @@ class HTMLComboboxElement extends HTMLElement {
|
|
|
501
866
|
}
|
|
502
867
|
exports.HTMLComboboxElement = HTMLComboboxElement;
|
|
503
868
|
_a = HTMLComboboxElement;
|
|
504
|
-
document.addEventListener('keypress', () => {
|
|
505
|
-
if (document.activeElement instanceof HTMLComboboxElement) {
|
|
506
|
-
const maybeHost = document.activeElement.shadowRoot.activeElement;
|
|
507
|
-
if (maybeHost instanceof HTML_combobox_option_element_js_1.HTMLComboboxOptionElement) {
|
|
508
|
-
maybeHost.click();
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
});
|
|
512
869
|
if (!window.customElements.get('combo-box')) {
|
|
513
870
|
window.customElements.define('combo-box', HTMLComboboxElement);
|
|
514
871
|
}
|