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