html-combobox-element 0.0.5 → 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 +156 -35
- package/dist/cjs/combobox/HTML.combobox.element.d.ts +32 -5
- package/dist/cjs/combobox/HTML.combobox.element.js +431 -98
- package/dist/cjs/combobox/HTML.combobox.option.element.d.ts +25 -4
- package/dist/cjs/combobox/HTML.combobox.option.element.js +104 -73
- 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 +156 -35
- package/dist/esm/combobox/HTML.combobox.element.js +431 -98
- package/dist/esm/combobox/HTML.combobox.option.element.js +104 -73
- 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,11 +23,21 @@ 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
|
-
|
|
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' });
|
|
25
41
|
this.#markup = new Combobox_markup_js_1.ComboboxMarkup(this.shadowRoot, this.#internals);
|
|
26
42
|
this.shadowRoot.appendChild(Combobox_markup_js_1.ComboboxMarkup.markup.content.cloneNode(true));
|
|
27
43
|
this.shadowRoot.adoptedStyleSheets = _a.styleSheet;
|
|
@@ -29,22 +45,39 @@ class HTMLComboboxElement extends HTMLElement {
|
|
|
29
45
|
this.#markup.connect();
|
|
30
46
|
this.#syncPopupSemantics();
|
|
31
47
|
}
|
|
48
|
+
/** Texts used only by assistive technology, override them to localize. */
|
|
49
|
+
static get strings() {
|
|
50
|
+
return Combobox_markup_js_1.ComboboxMarkup.strings;
|
|
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');
|
|
@@ -184,24 +260,19 @@ class HTMLComboboxElement extends HTMLElement {
|
|
|
184
260
|
}
|
|
185
261
|
set filterable(value) {
|
|
186
262
|
super.toggleAttribute('filterable', (0, Boolean_attribute_value_normalizer_js_1.toBoolean)(value));
|
|
187
|
-
this.#syncPopupSemantics();
|
|
188
263
|
}
|
|
189
264
|
get searchable() {
|
|
190
265
|
return this.hasAttribute('searchable');
|
|
191
266
|
}
|
|
192
267
|
set searchable(value) {
|
|
193
268
|
super.toggleAttribute('searchable', (0, Boolean_attribute_value_normalizer_js_1.toBoolean)(value));
|
|
194
|
-
this.#syncPopupSemantics();
|
|
195
269
|
}
|
|
196
270
|
// <select> specific properties (implements HTMLSelectElement)
|
|
197
271
|
get disabled() {
|
|
198
272
|
return this.hasAttribute('disabled');
|
|
199
273
|
}
|
|
200
274
|
set disabled(value) {
|
|
201
|
-
|
|
202
|
-
value = (0, Boolean_attribute_value_normalizer_js_1.toBoolean)(value);
|
|
203
|
-
super.toggleAttribute('disabled', value);
|
|
204
|
-
super.toggleAttribute('inert', value);
|
|
275
|
+
super.toggleAttribute('disabled', (0, Boolean_attribute_value_normalizer_js_1.toBoolean)(value));
|
|
205
276
|
}
|
|
206
277
|
get form() {
|
|
207
278
|
return this.#internals.form;
|
|
@@ -210,25 +281,39 @@ class HTMLComboboxElement extends HTMLElement {
|
|
|
210
281
|
return this.#internals.labels;
|
|
211
282
|
}
|
|
212
283
|
get length() {
|
|
213
|
-
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;
|
|
214
289
|
}
|
|
215
290
|
get multiple() {
|
|
216
291
|
return this.hasAttribute('multiple');
|
|
217
292
|
}
|
|
218
293
|
set multiple(value) {
|
|
219
294
|
super.toggleAttribute('multiple', (0, Boolean_attribute_value_normalizer_js_1.toBoolean)(value));
|
|
220
|
-
this.#syncPopupSemantics();
|
|
221
295
|
}
|
|
222
|
-
|
|
223
|
-
|
|
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
|
+
}
|
|
224
304
|
get required() {
|
|
225
305
|
return this.hasAttribute('required');
|
|
226
306
|
}
|
|
227
307
|
set required(value) {
|
|
228
|
-
this.#internals.ariaRequired = String(value);
|
|
229
308
|
super.toggleAttribute('required', (0, Boolean_attribute_value_normalizer_js_1.toBoolean)(value));
|
|
230
309
|
}
|
|
231
|
-
|
|
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
|
+
}
|
|
232
317
|
get selectedOptions() {
|
|
233
318
|
return this.#markup.selectedOptions;
|
|
234
319
|
}
|
|
@@ -250,10 +335,14 @@ class HTMLComboboxElement extends HTMLElement {
|
|
|
250
335
|
get value() {
|
|
251
336
|
return this.valueAsArray.join(',');
|
|
252
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.
|
|
253
341
|
set value(value) {
|
|
254
|
-
|
|
342
|
+
const next = value == null ? '' : String(value);
|
|
343
|
+
if (this.value === next)
|
|
255
344
|
return;
|
|
256
|
-
const nextValues = new Set(
|
|
345
|
+
const nextValues = new Set(next.split(',').filter(Boolean));
|
|
257
346
|
this.#markup.options.forEach(option => {
|
|
258
347
|
const optionValue = option.value;
|
|
259
348
|
// toggling `selected` drives #onOptionSelectedStateChanges, which is the
|
|
@@ -265,18 +354,30 @@ class HTMLComboboxElement extends HTMLElement {
|
|
|
265
354
|
}
|
|
266
355
|
// Instance methods
|
|
267
356
|
// <select> specific methods (implements HTMLSelectElement)
|
|
268
|
-
|
|
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
|
+
}
|
|
269
361
|
checkValidity() {
|
|
270
|
-
this.#internals.checkValidity();
|
|
362
|
+
return this.#internals.checkValidity();
|
|
271
363
|
}
|
|
272
364
|
item(index) {
|
|
273
|
-
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
|
+
}
|
|
274
378
|
}
|
|
275
|
-
// namedItem()
|
|
276
|
-
// !!! Conflicts with node.remove()
|
|
277
|
-
// remove(index: number)
|
|
278
379
|
reportValidity() {
|
|
279
|
-
this.#internals.reportValidity();
|
|
380
|
+
return this.#internals.reportValidity();
|
|
280
381
|
}
|
|
281
382
|
setCustomValidity(message) {
|
|
282
383
|
if (message === '') {
|
|
@@ -289,6 +390,10 @@ class HTMLComboboxElement extends HTMLElement {
|
|
|
289
390
|
showPicker() {
|
|
290
391
|
this.#markup.showDropdown();
|
|
291
392
|
}
|
|
393
|
+
/** Closes the dropdown, if open */
|
|
394
|
+
hidePicker() {
|
|
395
|
+
this.#markup.closeDropdown();
|
|
396
|
+
}
|
|
292
397
|
// Overwritten methods
|
|
293
398
|
setAttribute(name, value) {
|
|
294
399
|
if (_a.OWN_IDL.has(name)) {
|
|
@@ -299,15 +404,21 @@ class HTMLComboboxElement extends HTMLElement {
|
|
|
299
404
|
}
|
|
300
405
|
}
|
|
301
406
|
removeAttribute(name) {
|
|
302
|
-
if (_a.
|
|
407
|
+
if (_a.#BOOLEAN_ATTRIBUTES.has(name)) {
|
|
303
408
|
Reflect.set(this, name, false);
|
|
304
409
|
}
|
|
410
|
+
else if (name === 'placeholder' || name === 'query') {
|
|
411
|
+
Reflect.set(this, name, null);
|
|
412
|
+
}
|
|
305
413
|
else {
|
|
414
|
+
// includes `value`: the attribute is only an initial value, removing it keeps the selection
|
|
306
415
|
super.removeAttribute(name);
|
|
307
416
|
}
|
|
308
417
|
}
|
|
309
418
|
// Internal
|
|
310
419
|
#onOptionsChanges = (records) => {
|
|
420
|
+
this.#syncOptgroups();
|
|
421
|
+
this.#markup.syncValueText();
|
|
311
422
|
records.forEach(record => {
|
|
312
423
|
record.removedNodes.forEach(option => {
|
|
313
424
|
if (option instanceof HTML_combobox_option_element_js_1.HTMLComboboxOptionElement) {
|
|
@@ -322,7 +433,7 @@ class HTMLComboboxElement extends HTMLElement {
|
|
|
322
433
|
}
|
|
323
434
|
// options was removed, but values still include this value,
|
|
324
435
|
// just clear, this is like a reset was called
|
|
325
|
-
if (this.#values.has(value) && !this.#markup.
|
|
436
|
+
if (this.#values.has(value) && !this.#markup.getOptionByValue(value)) {
|
|
326
437
|
this.#values.delete(value);
|
|
327
438
|
const control = this.#markup.getTagByValue(value);
|
|
328
439
|
control?.remove();
|
|
@@ -337,6 +448,9 @@ class HTMLComboboxElement extends HTMLElement {
|
|
|
337
448
|
if (option.selected) {
|
|
338
449
|
if (!this.multiple) {
|
|
339
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;
|
|
340
454
|
const prevOption = this.#markup.getOptionByValue(value);
|
|
341
455
|
if (prevOption) {
|
|
342
456
|
prevOption.selected = false;
|
|
@@ -352,7 +466,6 @@ class HTMLComboboxElement extends HTMLElement {
|
|
|
352
466
|
this.#values.add(value);
|
|
353
467
|
this.#markup.createAndAppendTag(option);
|
|
354
468
|
this.#setValidityAndFormValue();
|
|
355
|
-
this.dispatchEvent(new Event('change'));
|
|
356
469
|
}
|
|
357
470
|
}
|
|
358
471
|
else {
|
|
@@ -361,7 +474,6 @@ class HTMLComboboxElement extends HTMLElement {
|
|
|
361
474
|
const control = this.#markup.getTagByValue(value);
|
|
362
475
|
control?.remove();
|
|
363
476
|
this.#setValidityAndFormValue();
|
|
364
|
-
this.dispatchEvent(new Event('change'));
|
|
365
477
|
}
|
|
366
478
|
else {
|
|
367
479
|
const tag = this.#markup.getTagByValue(value);
|
|
@@ -371,7 +483,11 @@ class HTMLComboboxElement extends HTMLElement {
|
|
|
371
483
|
};
|
|
372
484
|
#onClearTag = (event) => {
|
|
373
485
|
const target = event.target;
|
|
374
|
-
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(() => {
|
|
375
491
|
const option = this.#markup.getOptionByValue(target.value);
|
|
376
492
|
if (option) {
|
|
377
493
|
option.selected = false;
|
|
@@ -380,23 +496,72 @@ class HTMLComboboxElement extends HTMLElement {
|
|
|
380
496
|
const tag = this.#markup.getTagByValue(target.value);
|
|
381
497
|
this.#values.delete(target.value);
|
|
382
498
|
tag?.remove();
|
|
499
|
+
this.#setValidityAndFormValue();
|
|
383
500
|
}
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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();
|
|
395
515
|
}
|
|
396
516
|
};
|
|
397
517
|
#onClearAll = () => {
|
|
398
|
-
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();
|
|
399
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
|
+
}
|
|
400
565
|
#onPickerToggle = (event) => {
|
|
401
566
|
const state = Reflect.get(event, 'newState');
|
|
402
567
|
const oldState = Reflect.get(event, 'oldState');
|
|
@@ -412,74 +577,246 @@ class HTMLComboboxElement extends HTMLElement {
|
|
|
412
577
|
this.dispatchEvent(new Event('close'));
|
|
413
578
|
}
|
|
414
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/
|
|
415
582
|
#onKeyDown = (event) => {
|
|
416
|
-
if (this.#
|
|
583
|
+
if (this.#isDisabled || event.defaultPrevented)
|
|
417
584
|
return;
|
|
418
|
-
const
|
|
419
|
-
|
|
420
|
-
|
|
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();
|
|
421
595
|
return;
|
|
422
596
|
}
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
if (
|
|
426
|
-
|
|
427
|
-
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;
|
|
428
615
|
}
|
|
429
|
-
if (this.
|
|
430
|
-
|
|
616
|
+
if (this.#isTypeaheadKey(event) && !this.#markup.hasSearch) {
|
|
617
|
+
event.preventDefault();
|
|
618
|
+
this.#markup.showDropdown();
|
|
619
|
+
this.#typeaheadFocus(key, null);
|
|
431
620
|
}
|
|
432
621
|
return;
|
|
433
622
|
}
|
|
434
|
-
if (
|
|
435
|
-
if (
|
|
623
|
+
if (active === this.#markup.searchInput) {
|
|
624
|
+
if (key === 'Enter') {
|
|
436
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
|
+
}
|
|
437
632
|
return;
|
|
438
633
|
}
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
// event.preventDefault()
|
|
445
|
-
// @ts-ignore
|
|
446
|
-
activeElement.click();
|
|
447
|
-
// event.stopPropagation();
|
|
634
|
+
if (key === 'ArrowDown') {
|
|
635
|
+
event.preventDefault();
|
|
636
|
+
this.#markup.showDropdown();
|
|
637
|
+
this.#focusOption(this.#firstOption());
|
|
638
|
+
}
|
|
448
639
|
return;
|
|
449
640
|
}
|
|
450
|
-
if (
|
|
451
|
-
this.#markup.
|
|
452
|
-
|
|
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
|
+
}
|
|
453
688
|
}
|
|
454
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
|
+
}
|
|
455
720
|
#onInput = () => {
|
|
456
721
|
this.query = this.#markup.searchInput.value;
|
|
457
722
|
if (!this.searchable && this.filterable) {
|
|
458
723
|
this.#markup.sort(this.#markup.searchInput?.value || '');
|
|
459
724
|
}
|
|
460
725
|
};
|
|
461
|
-
|
|
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>.
|
|
462
786
|
#syncPopupSemantics() {
|
|
787
|
+
const markup = this.#markup;
|
|
788
|
+
if (!markup)
|
|
789
|
+
return;
|
|
463
790
|
const withSearch = this.searchable || this.filterable;
|
|
464
791
|
this.#internals.ariaHasPopup = withSearch ? 'dialog' : 'listbox';
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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));
|
|
472
803
|
// Shadow content can't point at the outer <label> with aria-labelledby, so it borrows the label's text.
|
|
473
|
-
const name = Array.from(this.#internals.labels ?? [], (label) => label.textContent?.trim()).filter(Boolean).join(' ')
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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');
|
|
480
817
|
}
|
|
481
818
|
#setValidityAndFormValue() {
|
|
482
|
-
this.#markup.
|
|
819
|
+
this.#markup.syncValueText();
|
|
483
820
|
let empty = false;
|
|
484
821
|
if (this.#values.size === 0)
|
|
485
822
|
empty = true;
|
|
@@ -497,6 +834,10 @@ class HTMLComboboxElement extends HTMLElement {
|
|
|
497
834
|
}
|
|
498
835
|
#initialAttributesSynchronization() {
|
|
499
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;
|
|
500
841
|
this[key] = this.getAttribute(key);
|
|
501
842
|
}
|
|
502
843
|
}
|
|
@@ -525,14 +866,6 @@ class HTMLComboboxElement extends HTMLElement {
|
|
|
525
866
|
}
|
|
526
867
|
exports.HTMLComboboxElement = HTMLComboboxElement;
|
|
527
868
|
_a = HTMLComboboxElement;
|
|
528
|
-
document.addEventListener('keypress', () => {
|
|
529
|
-
if (document.activeElement instanceof HTMLComboboxElement) {
|
|
530
|
-
const maybeHost = document.activeElement.shadowRoot.activeElement;
|
|
531
|
-
if (maybeHost instanceof HTML_combobox_option_element_js_1.HTMLComboboxOptionElement) {
|
|
532
|
-
maybeHost.click();
|
|
533
|
-
}
|
|
534
|
-
}
|
|
535
|
-
});
|
|
536
869
|
if (!window.customElements.get('combo-box')) {
|
|
537
870
|
window.customElements.define('combo-box', HTMLComboboxElement);
|
|
538
871
|
}
|