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