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
@@ -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,34 +23,61 @@ 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.#internals.ariaHasPopup = "dialog";
25
- 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' });
26
41
  this.#markup = new Combobox_markup_js_1.ComboboxMarkup(this.shadowRoot, this.#internals);
27
42
  this.shadowRoot.appendChild(Combobox_markup_js_1.ComboboxMarkup.markup.content.cloneNode(true));
28
43
  this.shadowRoot.adoptedStyleSheets = _a.styleSheet;
29
44
  this.#observer = new MutationObserver(this.#onOptionsChanges);
30
45
  this.#markup.connect();
46
+ this.#syncPopupSemantics();
47
+ }
48
+ /** Texts used only by assistive technology, override them to localize. */
49
+ static get strings() {
50
+ return Combobox_markup_js_1.ComboboxMarkup.strings;
31
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');
@@ -196,10 +272,7 @@ class HTMLComboboxElement extends HTMLElement {
196
272
  return this.hasAttribute('disabled');
197
273
  }
198
274
  set disabled(value) {
199
- this.#internals.ariaDisabled = String(value);
200
- value = (0, Boolean_attribute_value_normalizer_js_1.toBoolean)(value);
201
- super.toggleAttribute('disabled', value);
202
- super.toggleAttribute('inert', value);
275
+ super.toggleAttribute('disabled', (0, Boolean_attribute_value_normalizer_js_1.toBoolean)(value));
203
276
  }
204
277
  get form() {
205
278
  return this.#internals.form;
@@ -208,7 +281,11 @@ class HTMLComboboxElement extends HTMLElement {
208
281
  return this.#internals.labels;
209
282
  }
210
283
  get length() {
211
- 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;
212
289
  }
213
290
  get multiple() {
214
291
  return this.hasAttribute('multiple');
@@ -216,16 +293,27 @@ class HTMLComboboxElement extends HTMLElement {
216
293
  set multiple(value) {
217
294
  super.toggleAttribute('multiple', (0, Boolean_attribute_value_normalizer_js_1.toBoolean)(value));
218
295
  }
219
- // get options
220
- // (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
+ }
221
304
  get required() {
222
305
  return this.hasAttribute('required');
223
306
  }
224
307
  set required(value) {
225
- this.#internals.ariaRequired = String(value);
226
308
  super.toggleAttribute('required', (0, Boolean_attribute_value_normalizer_js_1.toBoolean)(value));
227
309
  }
228
- // 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
+ }
229
317
  get selectedOptions() {
230
318
  return this.#markup.selectedOptions;
231
319
  }
@@ -247,10 +335,14 @@ class HTMLComboboxElement extends HTMLElement {
247
335
  get value() {
248
336
  return this.valueAsArray.join(',');
249
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.
250
341
  set value(value) {
251
- if (this.value === value || typeof value !== 'string')
342
+ const next = value == null ? '' : String(value);
343
+ if (this.value === next)
252
344
  return;
253
- const nextValues = new Set(value.split(',').filter(Boolean));
345
+ const nextValues = new Set(next.split(',').filter(Boolean));
254
346
  this.#markup.options.forEach(option => {
255
347
  const optionValue = option.value;
256
348
  // toggling `selected` drives #onOptionSelectedStateChanges, which is the
@@ -262,18 +354,30 @@ class HTMLComboboxElement extends HTMLElement {
262
354
  }
263
355
  // Instance methods
264
356
  // <select> specific methods (implements HTMLSelectElement)
265
- // 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
+ }
266
361
  checkValidity() {
267
- this.#internals.checkValidity();
362
+ return this.#internals.checkValidity();
268
363
  }
269
364
  item(index) {
270
- 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
+ }
271
378
  }
272
- // namedItem()
273
- // !!! Conflicts with node.remove()
274
- // remove(index: number)
275
379
  reportValidity() {
276
- this.#internals.reportValidity();
380
+ return this.#internals.reportValidity();
277
381
  }
278
382
  setCustomValidity(message) {
279
383
  if (message === '') {
@@ -286,6 +390,10 @@ class HTMLComboboxElement extends HTMLElement {
286
390
  showPicker() {
287
391
  this.#markup.showDropdown();
288
392
  }
393
+ /** Closes the dropdown, if open */
394
+ hidePicker() {
395
+ this.#markup.closeDropdown();
396
+ }
289
397
  // Overwritten methods
290
398
  setAttribute(name, value) {
291
399
  if (_a.OWN_IDL.has(name)) {
@@ -296,15 +404,21 @@ class HTMLComboboxElement extends HTMLElement {
296
404
  }
297
405
  }
298
406
  removeAttribute(name) {
299
- if (_a.OWN_IDL.has(name)) {
407
+ if (_a.#BOOLEAN_ATTRIBUTES.has(name)) {
300
408
  Reflect.set(this, name, false);
301
409
  }
410
+ else if (name === 'placeholder' || name === 'query') {
411
+ Reflect.set(this, name, null);
412
+ }
302
413
  else {
414
+ // includes `value`: the attribute is only an initial value, removing it keeps the selection
303
415
  super.removeAttribute(name);
304
416
  }
305
417
  }
306
418
  // Internal
307
419
  #onOptionsChanges = (records) => {
420
+ this.#syncOptgroups();
421
+ this.#markup.syncValueText();
308
422
  records.forEach(record => {
309
423
  record.removedNodes.forEach(option => {
310
424
  if (option instanceof HTML_combobox_option_element_js_1.HTMLComboboxOptionElement) {
@@ -319,7 +433,7 @@ class HTMLComboboxElement extends HTMLElement {
319
433
  }
320
434
  // options was removed, but values still include this value,
321
435
  // just clear, this is like a reset was called
322
- if (this.#values.has(value) && !this.#markup.optionsContainer?.querySelector(`[value="${value}"]`)) {
436
+ if (this.#values.has(value) && !this.#markup.getOptionByValue(value)) {
323
437
  this.#values.delete(value);
324
438
  const control = this.#markup.getTagByValue(value);
325
439
  control?.remove();
@@ -334,6 +448,9 @@ class HTMLComboboxElement extends HTMLElement {
334
448
  if (option.selected) {
335
449
  if (!this.multiple) {
336
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;
337
454
  const prevOption = this.#markup.getOptionByValue(value);
338
455
  if (prevOption) {
339
456
  prevOption.selected = false;
@@ -349,7 +466,6 @@ class HTMLComboboxElement extends HTMLElement {
349
466
  this.#values.add(value);
350
467
  this.#markup.createAndAppendTag(option);
351
468
  this.#setValidityAndFormValue();
352
- this.dispatchEvent(new Event('change'));
353
469
  }
354
470
  }
355
471
  else {
@@ -358,7 +474,6 @@ class HTMLComboboxElement extends HTMLElement {
358
474
  const control = this.#markup.getTagByValue(value);
359
475
  control?.remove();
360
476
  this.#setValidityAndFormValue();
361
- this.dispatchEvent(new Event('change'));
362
477
  }
363
478
  else {
364
479
  const tag = this.#markup.getTagByValue(value);
@@ -368,7 +483,11 @@ class HTMLComboboxElement extends HTMLElement {
368
483
  };
369
484
  #onClearTag = (event) => {
370
485
  const target = event.target;
371
- 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(() => {
372
491
  const option = this.#markup.getOptionByValue(target.value);
373
492
  if (option) {
374
493
  option.selected = false;
@@ -377,29 +496,79 @@ class HTMLComboboxElement extends HTMLElement {
377
496
  const tag = this.#markup.getTagByValue(target.value);
378
497
  this.#values.delete(target.value);
379
498
  tag?.remove();
499
+ this.#setValidityAndFormValue();
380
500
  }
381
- requestAnimationFrame(() => {
382
- if (this.#values.size) {
383
- this.focus();
384
- }
385
- else if (this.searchable || this.filterable) {
386
- this.#markup.searchInput?.focus();
387
- }
388
- else {
389
- this.focus();
390
- }
391
- });
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();
392
515
  }
393
516
  };
394
517
  #onClearAll = () => {
395
- 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();
396
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
+ }
397
565
  #onPickerToggle = (event) => {
398
566
  const state = Reflect.get(event, 'newState');
399
567
  const oldState = Reflect.get(event, 'oldState');
400
568
  if (state === oldState)
401
569
  return;
402
570
  if (state === 'open') {
571
+ this.#syncPopupSemantics();
403
572
  this.#prevValue = this.value;
404
573
  this.#changed = true;
405
574
  }
@@ -408,54 +577,246 @@ class HTMLComboboxElement extends HTMLElement {
408
577
  this.dispatchEvent(new Event('close'));
409
578
  }
410
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/
411
582
  #onKeyDown = (event) => {
412
- if (this.#internals.ariaExpanded !== 'true')
583
+ if (this.#isDisabled || event.defaultPrevented)
413
584
  return;
414
- const activeElement = this.shadowRoot.activeElement;
415
- if (!activeElement) {
416
- // ??? 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();
417
595
  return;
418
596
  }
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);
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;
424
615
  }
425
- if (this.searchable) {
426
- 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);
427
620
  }
428
621
  return;
429
622
  }
430
- if (event.key === 'Enter' && activeElement instanceof HTML_combobox_option_element_js_1.HTMLComboboxOptionElement) {
431
- if (!activeElement.selected) {
623
+ if (active === this.#markup.searchInput) {
624
+ if (key === 'Enter') {
432
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
+ }
433
632
  return;
434
633
  }
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();
634
+ if (key === 'ArrowDown') {
635
+ event.preventDefault();
636
+ this.#markup.showDropdown();
637
+ this.#focusOption(this.#firstOption());
638
+ }
444
639
  return;
445
640
  }
446
- if (event.key === 'Escape' || event.key === 'Enter') {
447
- this.#markup.closeDropdown();
448
- 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
+ }
449
688
  }
450
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
+ }
451
720
  #onInput = () => {
452
721
  this.query = this.#markup.searchInput.value;
453
722
  if (!this.searchable && this.filterable) {
454
723
  this.#markup.sort(this.#markup.searchInput?.value || '');
455
724
  }
456
725
  };
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>.
786
+ #syncPopupSemantics() {
787
+ const markup = this.#markup;
788
+ if (!markup)
789
+ return;
790
+ const withSearch = this.searchable || this.filterable;
791
+ this.#internals.ariaHasPopup = withSearch ? 'dialog' : 'listbox';
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));
803
+ // Shadow content can't point at the outer <label> with aria-labelledby, so it borrows the label's text.
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');
817
+ }
457
818
  #setValidityAndFormValue() {
458
- this.#markup.clearAllButton?.toggleAttribute('disabled', !Boolean(this.#values.size));
819
+ this.#markup.syncValueText();
459
820
  let empty = false;
460
821
  if (this.#values.size === 0)
461
822
  empty = true;
@@ -473,6 +834,10 @@ class HTMLComboboxElement extends HTMLElement {
473
834
  }
474
835
  #initialAttributesSynchronization() {
475
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;
476
841
  this[key] = this.getAttribute(key);
477
842
  }
478
843
  }
@@ -501,14 +866,6 @@ class HTMLComboboxElement extends HTMLElement {
501
866
  }
502
867
  exports.HTMLComboboxElement = HTMLComboboxElement;
503
868
  _a = HTMLComboboxElement;
504
- document.addEventListener('keypress', () => {
505
- if (document.activeElement instanceof HTMLComboboxElement) {
506
- const maybeHost = document.activeElement.shadowRoot.activeElement;
507
- if (maybeHost instanceof HTML_combobox_option_element_js_1.HTMLComboboxOptionElement) {
508
- maybeHost.click();
509
- }
510
- }
511
- });
512
869
  if (!window.customElements.get('combo-box')) {
513
870
  window.customElements.define('combo-box', HTMLComboboxElement);
514
871
  }