html-combobox-element 0.0.5 → 0.0.7

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.
Files changed (37) hide show
  1. package/README.md +162 -46
  2. package/dist/cjs/combobox/Combobox.markup.d.ts +22 -1
  3. package/dist/cjs/combobox/Combobox.markup.js +156 -35
  4. package/dist/cjs/combobox/HTML.combobox.element.d.ts +32 -5
  5. package/dist/cjs/combobox/HTML.combobox.element.js +431 -98
  6. package/dist/cjs/combobox/HTML.combobox.option.element.d.ts +25 -4
  7. package/dist/cjs/combobox/HTML.combobox.option.element.js +104 -73
  8. package/dist/cjs/combobox/HTML.combobox.options.collection.d.ts +37 -0
  9. package/dist/cjs/combobox/HTML.combobox.options.collection.js +176 -0
  10. package/dist/cjs/combobox/HTML.combobox.tag.element.d.ts +7 -1
  11. package/dist/cjs/combobox/HTML.combobox.tag.element.js +36 -12
  12. package/dist/cjs/combobox/index.d.ts +1 -0
  13. package/dist/cjs/combobox/index.js +1 -0
  14. package/dist/cjs/index.d.ts +66 -22
  15. package/dist/cjs/index.js +11 -0
  16. package/dist/cjs/playwright.d.ts +48 -0
  17. package/dist/cjs/playwright.js +127 -0
  18. package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
  19. package/dist/esm/combobox/Combobox.markup.js +156 -35
  20. package/dist/esm/combobox/HTML.combobox.element.js +431 -98
  21. package/dist/esm/combobox/HTML.combobox.option.element.js +104 -73
  22. package/dist/esm/combobox/HTML.combobox.options.collection.js +170 -0
  23. package/dist/esm/combobox/HTML.combobox.tag.element.js +36 -12
  24. package/dist/esm/combobox/index.js +1 -0
  25. package/dist/esm/index.js +10 -0
  26. package/dist/esm/playwright.js +123 -0
  27. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
  28. package/dist/types/combobox/Combobox.markup.d.ts +22 -1
  29. package/dist/types/combobox/HTML.combobox.element.d.ts +32 -5
  30. package/dist/types/combobox/HTML.combobox.option.element.d.ts +25 -4
  31. package/dist/types/combobox/HTML.combobox.options.collection.d.ts +37 -0
  32. package/dist/types/combobox/HTML.combobox.tag.element.d.ts +7 -1
  33. package/dist/types/combobox/index.d.ts +1 -0
  34. package/dist/types/index.d.ts +66 -22
  35. package/dist/types/playwright.d.ts +48 -0
  36. package/dist/types/tsconfig.types.tsbuildinfo +1 -1
  37. package/package.json +49 -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
- this.shadowRoot = this.attachShadow({ mode: 'open', delegatesFocus: true }); //delegatesFocus: true
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.shadowRoot.addEventListener('optionselectedstatechange', this.#onOptionSelectedStateChanges);
37
- this.shadowRoot.addEventListener('tagcleared', this.#onClearTag);
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
- // toDo
142
- // when inside fieldset, this callback is only invoked for the first time
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
- this.#internals.ariaDisabled = String(value);
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.#markup.options.length;
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
- // get options
219
- // (there is no constructor of HTMLOptionsCollection, need to implement)
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
- // selected index is part of HTMLOptionsCollection, see option above
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
- if (this.value === value || typeof value !== 'string')
338
+ const next = value == null ? '' : String(value);
339
+ if (this.value === next)
251
340
  return;
252
- const nextValues = new Set(value.split(',').filter(Boolean));
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
- // add()
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.#markup.options.item(index);
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.OWN_IDL.has(name)) {
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.optionsContainer?.querySelector(`[value="${value}"]`)) {
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
- requestAnimationFrame(() => {
381
- if (this.#values.size) {
382
- this.focus();
383
- }
384
- else if (this.searchable || this.filterable) {
385
- this.#markup.searchInput?.focus();
386
- }
387
- else {
388
- this.focus();
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.#internals.ariaExpanded !== 'true')
579
+ if (this.#isDisabled || event.defaultPrevented)
413
580
  return;
414
- const activeElement = this.shadowRoot.activeElement;
415
- if (!activeElement) {
416
- // ??? how we should react ???
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
- if (activeElement === this.#markup.searchInput && event.key === 'Enter') {
420
- event.stopPropagation();
421
- if (!this.searchable && this.filterable) {
422
- console.log('sss');
423
- this.#markup.sort(this.#markup.searchInput.value);
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.searchable) {
426
- this.dispatchEvent(new Event('search'));
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 (event.key === 'Enter' && activeElement instanceof HTMLComboboxOptionElement) {
431
- if (!activeElement.selected) {
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
- if (event.key === 'Enter' && activeElement.part.contains('clear-tag')) {
437
- // @ts-ignore
438
- // this.#onClearTag(event);
439
- // event.stopPropagation();
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 (event.key === 'Escape' || event.key === 'Enter') {
447
- this.#markup.closeDropdown();
448
- this.blur();
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
- // Without a search field the popup is just the listbox. With one, it is a dialog holding the field and the listbox.
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
- if (withSearch) {
462
- this.#markup?.dropdown?.setAttribute('role', 'dialog');
463
- }
464
- else {
465
- this.#markup?.dropdown?.removeAttribute('role');
466
- }
467
- this.#markup?.optionsContainer?.setAttribute('aria-multiselectable', String(this.multiple));
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
- for (const popup of [this.#markup?.dropdown, this.#markup?.optionsContainer]) {
471
- if (name && popup?.hasAttribute('role'))
472
- popup.setAttribute('aria-label', name);
473
- else
474
- popup?.removeAttribute('aria-label');
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.clearAllButton?.toggleAttribute('disabled', !Boolean(this.#values.size));
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
  }