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
|
@@ -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,11 +19,21 @@ 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
|
-
|
|
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' });
|
|
21
37
|
this.#markup = new ComboboxMarkup(this.shadowRoot, this.#internals);
|
|
22
38
|
this.shadowRoot.appendChild(ComboboxMarkup.markup.content.cloneNode(true));
|
|
23
39
|
this.shadowRoot.adoptedStyleSheets = HTMLComboboxElement.styleSheet;
|
|
@@ -25,22 +41,39 @@ export class HTMLComboboxElement extends HTMLElement {
|
|
|
25
41
|
this.#markup.connect();
|
|
26
42
|
this.#syncPopupSemantics();
|
|
27
43
|
}
|
|
44
|
+
/** Texts used only by assistive technology, override them to localize. */
|
|
45
|
+
static get strings() {
|
|
46
|
+
return ComboboxMarkup.strings;
|
|
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');
|
|
@@ -180,24 +256,19 @@ export class HTMLComboboxElement extends HTMLElement {
|
|
|
180
256
|
}
|
|
181
257
|
set filterable(value) {
|
|
182
258
|
super.toggleAttribute('filterable', toBoolean(value));
|
|
183
|
-
this.#syncPopupSemantics();
|
|
184
259
|
}
|
|
185
260
|
get searchable() {
|
|
186
261
|
return this.hasAttribute('searchable');
|
|
187
262
|
}
|
|
188
263
|
set searchable(value) {
|
|
189
264
|
super.toggleAttribute('searchable', toBoolean(value));
|
|
190
|
-
this.#syncPopupSemantics();
|
|
191
265
|
}
|
|
192
266
|
// <select> specific properties (implements HTMLSelectElement)
|
|
193
267
|
get disabled() {
|
|
194
268
|
return this.hasAttribute('disabled');
|
|
195
269
|
}
|
|
196
270
|
set disabled(value) {
|
|
197
|
-
|
|
198
|
-
value = toBoolean(value);
|
|
199
|
-
super.toggleAttribute('disabled', value);
|
|
200
|
-
super.toggleAttribute('inert', value);
|
|
271
|
+
super.toggleAttribute('disabled', toBoolean(value));
|
|
201
272
|
}
|
|
202
273
|
get form() {
|
|
203
274
|
return this.#internals.form;
|
|
@@ -206,25 +277,39 @@ export class HTMLComboboxElement extends HTMLElement {
|
|
|
206
277
|
return this.#internals.labels;
|
|
207
278
|
}
|
|
208
279
|
get length() {
|
|
209
|
-
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;
|
|
210
285
|
}
|
|
211
286
|
get multiple() {
|
|
212
287
|
return this.hasAttribute('multiple');
|
|
213
288
|
}
|
|
214
289
|
set multiple(value) {
|
|
215
290
|
super.toggleAttribute('multiple', toBoolean(value));
|
|
216
|
-
this.#syncPopupSemantics();
|
|
217
291
|
}
|
|
218
|
-
|
|
219
|
-
|
|
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
|
+
}
|
|
220
300
|
get required() {
|
|
221
301
|
return this.hasAttribute('required');
|
|
222
302
|
}
|
|
223
303
|
set required(value) {
|
|
224
|
-
this.#internals.ariaRequired = String(value);
|
|
225
304
|
super.toggleAttribute('required', toBoolean(value));
|
|
226
305
|
}
|
|
227
|
-
|
|
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
|
+
}
|
|
228
313
|
get selectedOptions() {
|
|
229
314
|
return this.#markup.selectedOptions;
|
|
230
315
|
}
|
|
@@ -246,10 +331,14 @@ export class HTMLComboboxElement extends HTMLElement {
|
|
|
246
331
|
get value() {
|
|
247
332
|
return this.valueAsArray.join(',');
|
|
248
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.
|
|
249
337
|
set value(value) {
|
|
250
|
-
|
|
338
|
+
const next = value == null ? '' : String(value);
|
|
339
|
+
if (this.value === next)
|
|
251
340
|
return;
|
|
252
|
-
const nextValues = new Set(
|
|
341
|
+
const nextValues = new Set(next.split(',').filter(Boolean));
|
|
253
342
|
this.#markup.options.forEach(option => {
|
|
254
343
|
const optionValue = option.value;
|
|
255
344
|
// toggling `selected` drives #onOptionSelectedStateChanges, which is the
|
|
@@ -261,18 +350,30 @@ export class HTMLComboboxElement extends HTMLElement {
|
|
|
261
350
|
}
|
|
262
351
|
// Instance methods
|
|
263
352
|
// <select> specific methods (implements HTMLSelectElement)
|
|
264
|
-
|
|
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
|
+
}
|
|
265
357
|
checkValidity() {
|
|
266
|
-
this.#internals.checkValidity();
|
|
358
|
+
return this.#internals.checkValidity();
|
|
267
359
|
}
|
|
268
360
|
item(index) {
|
|
269
|
-
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
|
+
}
|
|
270
374
|
}
|
|
271
|
-
// namedItem()
|
|
272
|
-
// !!! Conflicts with node.remove()
|
|
273
|
-
// remove(index: number)
|
|
274
375
|
reportValidity() {
|
|
275
|
-
this.#internals.reportValidity();
|
|
376
|
+
return this.#internals.reportValidity();
|
|
276
377
|
}
|
|
277
378
|
setCustomValidity(message) {
|
|
278
379
|
if (message === '') {
|
|
@@ -285,6 +386,10 @@ export class HTMLComboboxElement extends HTMLElement {
|
|
|
285
386
|
showPicker() {
|
|
286
387
|
this.#markup.showDropdown();
|
|
287
388
|
}
|
|
389
|
+
/** Closes the dropdown, if open */
|
|
390
|
+
hidePicker() {
|
|
391
|
+
this.#markup.closeDropdown();
|
|
392
|
+
}
|
|
288
393
|
// Overwritten methods
|
|
289
394
|
setAttribute(name, value) {
|
|
290
395
|
if (HTMLComboboxElement.OWN_IDL.has(name)) {
|
|
@@ -295,15 +400,21 @@ export class HTMLComboboxElement extends HTMLElement {
|
|
|
295
400
|
}
|
|
296
401
|
}
|
|
297
402
|
removeAttribute(name) {
|
|
298
|
-
if (HTMLComboboxElement.
|
|
403
|
+
if (HTMLComboboxElement.#BOOLEAN_ATTRIBUTES.has(name)) {
|
|
299
404
|
Reflect.set(this, name, false);
|
|
300
405
|
}
|
|
406
|
+
else if (name === 'placeholder' || name === 'query') {
|
|
407
|
+
Reflect.set(this, name, null);
|
|
408
|
+
}
|
|
301
409
|
else {
|
|
410
|
+
// includes `value`: the attribute is only an initial value, removing it keeps the selection
|
|
302
411
|
super.removeAttribute(name);
|
|
303
412
|
}
|
|
304
413
|
}
|
|
305
414
|
// Internal
|
|
306
415
|
#onOptionsChanges = (records) => {
|
|
416
|
+
this.#syncOptgroups();
|
|
417
|
+
this.#markup.syncValueText();
|
|
307
418
|
records.forEach(record => {
|
|
308
419
|
record.removedNodes.forEach(option => {
|
|
309
420
|
if (option instanceof HTMLComboboxOptionElement) {
|
|
@@ -318,7 +429,7 @@ export class HTMLComboboxElement extends HTMLElement {
|
|
|
318
429
|
}
|
|
319
430
|
// options was removed, but values still include this value,
|
|
320
431
|
// just clear, this is like a reset was called
|
|
321
|
-
if (this.#values.has(value) && !this.#markup.
|
|
432
|
+
if (this.#values.has(value) && !this.#markup.getOptionByValue(value)) {
|
|
322
433
|
this.#values.delete(value);
|
|
323
434
|
const control = this.#markup.getTagByValue(value);
|
|
324
435
|
control?.remove();
|
|
@@ -333,6 +444,9 @@ export class HTMLComboboxElement extends HTMLElement {
|
|
|
333
444
|
if (option.selected) {
|
|
334
445
|
if (!this.multiple) {
|
|
335
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;
|
|
336
450
|
const prevOption = this.#markup.getOptionByValue(value);
|
|
337
451
|
if (prevOption) {
|
|
338
452
|
prevOption.selected = false;
|
|
@@ -348,7 +462,6 @@ export class HTMLComboboxElement extends HTMLElement {
|
|
|
348
462
|
this.#values.add(value);
|
|
349
463
|
this.#markup.createAndAppendTag(option);
|
|
350
464
|
this.#setValidityAndFormValue();
|
|
351
|
-
this.dispatchEvent(new Event('change'));
|
|
352
465
|
}
|
|
353
466
|
}
|
|
354
467
|
else {
|
|
@@ -357,7 +470,6 @@ export class HTMLComboboxElement extends HTMLElement {
|
|
|
357
470
|
const control = this.#markup.getTagByValue(value);
|
|
358
471
|
control?.remove();
|
|
359
472
|
this.#setValidityAndFormValue();
|
|
360
|
-
this.dispatchEvent(new Event('change'));
|
|
361
473
|
}
|
|
362
474
|
else {
|
|
363
475
|
const tag = this.#markup.getTagByValue(value);
|
|
@@ -367,7 +479,11 @@ export class HTMLComboboxElement extends HTMLElement {
|
|
|
367
479
|
};
|
|
368
480
|
#onClearTag = (event) => {
|
|
369
481
|
const target = event.target;
|
|
370
|
-
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(() => {
|
|
371
487
|
const option = this.#markup.getOptionByValue(target.value);
|
|
372
488
|
if (option) {
|
|
373
489
|
option.selected = false;
|
|
@@ -376,23 +492,72 @@ export class HTMLComboboxElement extends HTMLElement {
|
|
|
376
492
|
const tag = this.#markup.getTagByValue(target.value);
|
|
377
493
|
this.#values.delete(target.value);
|
|
378
494
|
tag?.remove();
|
|
495
|
+
this.#setValidityAndFormValue();
|
|
379
496
|
}
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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();
|
|
391
511
|
}
|
|
392
512
|
};
|
|
393
513
|
#onClearAll = () => {
|
|
394
|
-
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();
|
|
395
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
|
+
}
|
|
396
561
|
#onPickerToggle = (event) => {
|
|
397
562
|
const state = Reflect.get(event, 'newState');
|
|
398
563
|
const oldState = Reflect.get(event, 'oldState');
|
|
@@ -408,74 +573,246 @@ export class HTMLComboboxElement extends HTMLElement {
|
|
|
408
573
|
this.dispatchEvent(new Event('close'));
|
|
409
574
|
}
|
|
410
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/
|
|
411
578
|
#onKeyDown = (event) => {
|
|
412
|
-
if (this.#
|
|
579
|
+
if (this.#isDisabled || event.defaultPrevented)
|
|
413
580
|
return;
|
|
414
|
-
const
|
|
415
|
-
|
|
416
|
-
|
|
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();
|
|
417
591
|
return;
|
|
418
592
|
}
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
if (
|
|
422
|
-
|
|
423
|
-
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;
|
|
424
611
|
}
|
|
425
|
-
if (this.
|
|
426
|
-
|
|
612
|
+
if (this.#isTypeaheadKey(event) && !this.#markup.hasSearch) {
|
|
613
|
+
event.preventDefault();
|
|
614
|
+
this.#markup.showDropdown();
|
|
615
|
+
this.#typeaheadFocus(key, null);
|
|
427
616
|
}
|
|
428
617
|
return;
|
|
429
618
|
}
|
|
430
|
-
if (
|
|
431
|
-
if (
|
|
619
|
+
if (active === this.#markup.searchInput) {
|
|
620
|
+
if (key === 'Enter') {
|
|
432
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
|
+
}
|
|
433
628
|
return;
|
|
434
629
|
}
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
// event.preventDefault()
|
|
441
|
-
// @ts-ignore
|
|
442
|
-
activeElement.click();
|
|
443
|
-
// event.stopPropagation();
|
|
630
|
+
if (key === 'ArrowDown') {
|
|
631
|
+
event.preventDefault();
|
|
632
|
+
this.#markup.showDropdown();
|
|
633
|
+
this.#focusOption(this.#firstOption());
|
|
634
|
+
}
|
|
444
635
|
return;
|
|
445
636
|
}
|
|
446
|
-
if (
|
|
447
|
-
this.#markup.
|
|
448
|
-
|
|
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
|
+
}
|
|
449
684
|
}
|
|
450
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
|
+
}
|
|
451
716
|
#onInput = () => {
|
|
452
717
|
this.query = this.#markup.searchInput.value;
|
|
453
718
|
if (!this.searchable && this.filterable) {
|
|
454
719
|
this.#markup.sort(this.#markup.searchInput?.value || '');
|
|
455
720
|
}
|
|
456
721
|
};
|
|
457
|
-
|
|
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>.
|
|
458
782
|
#syncPopupSemantics() {
|
|
783
|
+
const markup = this.#markup;
|
|
784
|
+
if (!markup)
|
|
785
|
+
return;
|
|
459
786
|
const withSearch = this.searchable || this.filterable;
|
|
460
787
|
this.#internals.ariaHasPopup = withSearch ? 'dialog' : 'listbox';
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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));
|
|
468
799
|
// Shadow content can't point at the outer <label> with aria-labelledby, so it borrows the label's text.
|
|
469
|
-
const name = Array.from(this.#internals.labels ?? [], (label) => label.textContent?.trim()).filter(Boolean).join(' ')
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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');
|
|
476
813
|
}
|
|
477
814
|
#setValidityAndFormValue() {
|
|
478
|
-
this.#markup.
|
|
815
|
+
this.#markup.syncValueText();
|
|
479
816
|
let empty = false;
|
|
480
817
|
if (this.#values.size === 0)
|
|
481
818
|
empty = true;
|
|
@@ -493,6 +830,10 @@ export class HTMLComboboxElement extends HTMLElement {
|
|
|
493
830
|
}
|
|
494
831
|
#initialAttributesSynchronization() {
|
|
495
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;
|
|
496
837
|
this[key] = this.getAttribute(key);
|
|
497
838
|
}
|
|
498
839
|
}
|
|
@@ -519,14 +860,6 @@ export class HTMLComboboxElement extends HTMLElement {
|
|
|
519
860
|
}
|
|
520
861
|
}
|
|
521
862
|
}
|
|
522
|
-
document.addEventListener('keypress', () => {
|
|
523
|
-
if (document.activeElement instanceof HTMLComboboxElement) {
|
|
524
|
-
const maybeHost = document.activeElement.shadowRoot.activeElement;
|
|
525
|
-
if (maybeHost instanceof HTMLComboboxOptionElement) {
|
|
526
|
-
maybeHost.click();
|
|
527
|
-
}
|
|
528
|
-
}
|
|
529
|
-
});
|
|
530
863
|
if (!window.customElements.get('combo-box')) {
|
|
531
864
|
window.customElements.define('combo-box', HTMLComboboxElement);
|
|
532
865
|
}
|