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.
Files changed (33) hide show
  1. package/README.md +139 -24
  2. package/dist/cjs/combobox/Combobox.markup.d.ts +22 -1
  3. package/dist/cjs/combobox/Combobox.markup.js +157 -36
  4. package/dist/cjs/combobox/HTML.combobox.element.d.ts +32 -5
  5. package/dist/cjs/combobox/HTML.combobox.element.js +438 -81
  6. package/dist/cjs/combobox/HTML.combobox.option.element.d.ts +25 -4
  7. package/dist/cjs/combobox/HTML.combobox.option.element.js +104 -66
  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/playwright.d.ts +48 -0
  15. package/dist/cjs/playwright.js +127 -0
  16. package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
  17. package/dist/esm/combobox/Combobox.markup.js +157 -36
  18. package/dist/esm/combobox/HTML.combobox.element.js +438 -81
  19. package/dist/esm/combobox/HTML.combobox.option.element.js +104 -66
  20. package/dist/esm/combobox/HTML.combobox.options.collection.js +170 -0
  21. package/dist/esm/combobox/HTML.combobox.tag.element.js +36 -12
  22. package/dist/esm/combobox/index.js +1 -0
  23. package/dist/esm/playwright.js +123 -0
  24. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
  25. package/dist/types/combobox/Combobox.markup.d.ts +22 -1
  26. package/dist/types/combobox/HTML.combobox.element.d.ts +32 -5
  27. package/dist/types/combobox/HTML.combobox.option.element.d.ts +25 -4
  28. package/dist/types/combobox/HTML.combobox.options.collection.d.ts +37 -0
  29. package/dist/types/combobox/HTML.combobox.tag.element.d.ts +7 -1
  30. package/dist/types/combobox/index.d.ts +1 -0
  31. package/dist/types/playwright.d.ts +48 -0
  32. package/dist/types/tsconfig.types.tsbuildinfo +1 -1
  33. 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
- this.#internals.ariaHasPopup = "dialog";
21
- 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' });
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.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');
@@ -192,10 +268,7 @@ export class HTMLComboboxElement extends HTMLElement {
192
268
  return this.hasAttribute('disabled');
193
269
  }
194
270
  set disabled(value) {
195
- this.#internals.ariaDisabled = String(value);
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.#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;
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
- // get options
216
- // (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
+ }
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
- // 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
+ }
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
- if (this.value === value || typeof value !== 'string')
338
+ const next = value == null ? '' : String(value);
339
+ if (this.value === next)
248
340
  return;
249
- const nextValues = new Set(value.split(',').filter(Boolean));
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
- // 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
+ }
262
357
  checkValidity() {
263
- this.#internals.checkValidity();
358
+ return this.#internals.checkValidity();
264
359
  }
265
360
  item(index) {
266
- 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
+ }
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.OWN_IDL.has(name)) {
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.optionsContainer?.querySelector(`[value="${value}"]`)) {
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
- requestAnimationFrame(() => {
378
- if (this.#values.size) {
379
- this.focus();
380
- }
381
- else if (this.searchable || this.filterable) {
382
- this.#markup.searchInput?.focus();
383
- }
384
- else {
385
- this.focus();
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.#internals.ariaExpanded !== 'true')
579
+ if (this.#isDisabled || event.defaultPrevented)
409
580
  return;
410
- const activeElement = this.shadowRoot.activeElement;
411
- if (!activeElement) {
412
- // ??? 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();
413
591
  return;
414
592
  }
415
- if (activeElement === this.#markup.searchInput && event.key === 'Enter') {
416
- event.stopPropagation();
417
- if (!this.searchable && this.filterable) {
418
- console.log('sss');
419
- 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;
420
611
  }
421
- if (this.searchable) {
422
- 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);
423
616
  }
424
617
  return;
425
618
  }
426
- if (event.key === 'Enter' && activeElement instanceof HTMLComboboxOptionElement) {
427
- if (!activeElement.selected) {
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
- if (event.key === 'Enter' && activeElement.part.contains('clear-tag')) {
433
- // @ts-ignore
434
- // this.#onClearTag(event);
435
- // event.stopPropagation();
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 (event.key === 'Escape' || event.key === 'Enter') {
443
- this.#markup.closeDropdown();
444
- 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
+ }
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.clearAllButton?.toggleAttribute('disabled', !Boolean(this.#values.size));
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
  }