html-combobox-element 0.0.2 → 0.0.3

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.
@@ -3,7 +3,7 @@ import { HTMLComboboxOptionElement } from './HTML.combobox.option.element.js';
3
3
  import { toBoolean } from './Boolean.attribute.value.normalizer.js';
4
4
  export class HTMLComboboxElement extends HTMLElement {
5
5
  static OWN_IDL = new Set(['required', 'disabled', 'clearable', 'multiple', 'filterable', 'searchable', 'value', 'placeholder', 'query']);
6
- static observerOptions = { childList: true, attributes: false, subtree: false };
6
+ static observerOptions = { childList: true };
7
7
  static styleSheet = [new CSSStyleSheet];
8
8
  static formAssociated = true;
9
9
  shadowRoot;
@@ -11,41 +11,136 @@ export class HTMLComboboxElement extends HTMLElement {
11
11
  #observer;
12
12
  #markup;
13
13
  #values = new Set;
14
+ #prevValue = '';
15
+ #changed = false;
14
16
  constructor() {
15
17
  super();
16
18
  this.#internals = this.attachInternals();
17
19
  this.#internals.role = "combobox";
18
20
  this.#internals.ariaHasPopup = "dialog";
19
- this.shadowRoot = this.attachShadow({ mode: 'closed', delegatesFocus: true });
21
+ this.shadowRoot = this.attachShadow({ mode: 'open', delegatesFocus: true }); //delegatesFocus: true
20
22
  this.#markup = new ComboboxMarkup(this.shadowRoot, this.#internals);
21
- this.shadowRoot.innerHTML = ComboboxMarkup.template;
23
+ this.shadowRoot.appendChild(ComboboxMarkup.markup.content.cloneNode(true));
22
24
  this.shadowRoot.adoptedStyleSheets = HTMLComboboxElement.styleSheet;
23
25
  this.#observer = new MutationObserver(this.#onOptionsChanges);
26
+ this.#markup.connect();
24
27
  }
25
28
  // Lifecycle callbacks
26
29
  connectedCallback() {
27
- this.#markup.connect();
30
+ for (const child of Array.from(this.children)) {
31
+ if (HTMLComboboxElement.#isOptionLike(child)) {
32
+ this.#markup.optionsContainer?.appendChild(child);
33
+ }
34
+ }
35
+ this.#markup.onHostConnected();
36
+ this.shadowRoot.addEventListener('optionselectedstatechange', this.#onOptionSelectedStateChanges);
37
+ this.shadowRoot.addEventListener('tagcleared', this.#onClearTag);
28
38
  this.#initialAttributesSynchronization();
29
- this.#onOptionsChanges([{ addedNodes: Array.from(this.children) }]);
30
- this.#observer.observe(this, HTMLComboboxElement.observerOptions);
31
- this.#markup.clearAllButton.addEventListener('click', this.#onClear);
39
+ this.#observer.observe(this.#markup.optionsContainer, HTMLComboboxElement.observerOptions);
40
+ this.addEventListener('keydown', this.#onKeyDown);
41
+ this.#markup.clearAllButton.addEventListener('click', this.#onClearAll);
32
42
  this.#markup.searchInput.addEventListener('input', this.#onInput);
43
+ this.#markup.dropdown.addEventListener('toggle', this.#onPickerToggle);
44
+ this.#setValidityAndFormValue();
45
+ // Options/optgroups can end up as direct light-DOM children of <combo-box> without ever
46
+ // going through insertBefore/appendChild below: parsed straight from static HTML markup,
47
+ // or - notably - Solid's compiler, which renders static JSX subtrees via a single
48
+ // <template> + cloneNode(true), never calling any child-mutation method at all. Sweep
49
+ // whatever is already here once on connect and move it into the real options container.
50
+ }
51
+ static #isOptionLike(node) {
52
+ return node instanceof HTMLComboboxOptionElement || node instanceof HTMLOptGroupElement;
53
+ }
54
+ // Overwritten child-mutation methods.
55
+ //
56
+ // <box-option>/<optgroup> children don't actually live under <combo-box> itself - they're
57
+ // redirected into #markup.optionsContainer inside the shadow root, which is what the
58
+ // dropdown renders from and what #observer watches. Every native method a framework's
59
+ // renderer or plain user code might use to add/move/remove children needs to funnel through
60
+ // the same redirect, since browsers implement each of these independently (overriding one,
61
+ // e.g. insertBefore, does not make the others call it under the hood):
62
+ // - React calls appendChild for a plain append and insertBefore only when reordering.
63
+ // - Vue and Preact always call insertBefore, with a null reference for a plain append.
64
+ // - Solid mostly bypasses all of them for static subtrees (see connectedCallback above),
65
+ // but dynamic option lists still go through insert()/insertBefore().
66
+ insertBefore = (node, referenceNode) => {
67
+ if (HTMLComboboxElement.#isOptionLike(node)) {
68
+ this.#markup.optionsContainer?.insertBefore(node, referenceNode);
69
+ }
70
+ else {
71
+ super.insertBefore(node, referenceNode);
72
+ }
73
+ return node;
74
+ };
75
+ appendChild = (node) => {
76
+ if (HTMLComboboxElement.#isOptionLike(node)) {
77
+ this.#markup.optionsContainer?.appendChild(node);
78
+ }
79
+ else {
80
+ super.appendChild(node);
81
+ }
82
+ return node;
83
+ };
84
+ append = (...nodes) => {
85
+ nodes.forEach(node => this.appendChild(typeof node === 'string' ? new Text(node) : node));
86
+ };
87
+ prepend = (...nodes) => {
88
+ [...nodes].reverse().forEach(node => {
89
+ const n = typeof node === 'string' ? new Text(node) : node;
90
+ if (HTMLComboboxElement.#isOptionLike(n)) {
91
+ this.#markup.optionsContainer?.insertBefore(n, this.#markup.optionsContainer.firstChild);
92
+ }
93
+ else {
94
+ super.insertBefore(n, this.firstChild);
95
+ }
96
+ });
97
+ };
98
+ replaceChildren = (...nodes) => {
99
+ this.#markup.optionsContainer?.replaceChildren();
100
+ super.replaceChildren();
101
+ this.append(...nodes);
102
+ };
103
+ removeChild = (child) => {
104
+ if (child.parentNode === this.#markup.optionsContainer) {
105
+ this.#markup.optionsContainer.removeChild(child);
106
+ }
107
+ else if (child.parentNode === this) {
108
+ super.removeChild(child);
109
+ }
110
+ return child;
111
+ };
112
+ replaceChild = (newChild, oldChild) => {
113
+ if (oldChild.parentNode === this.#markup.optionsContainer) {
114
+ this.#markup.optionsContainer.replaceChild(newChild, oldChild);
115
+ }
116
+ else {
117
+ super.replaceChild(newChild, oldChild);
118
+ }
119
+ return oldChild;
120
+ };
121
+ get changed() {
122
+ return this.#changed;
33
123
  }
34
124
  disconnectedCallback() {
35
125
  this.#observer.disconnect();
36
126
  this.#markup.disconnect();
37
- this.#markup.clearAllButton.removeEventListener('click', this.#onClear);
127
+ this.removeEventListener('keydown', this.#onKeyDown);
128
+ this.#markup.clearAllButton.removeEventListener('click', this.#onClearAll);
38
129
  this.#markup.searchInput.removeEventListener('input', this.#onInput);
130
+ this.#markup.dropdown.removeEventListener('toggle', this.#onPickerToggle);
131
+ this.shadowRoot.removeEventListener('optionselectedstatechange', this.#onOptionSelectedStateChanges);
132
+ this.shadowRoot.removeEventListener('tagcleared', this.#onClearTag);
39
133
  }
40
134
  formResetCallback() {
41
- this.#values = new Set;
42
135
  this.selectedOptions.forEach(option => option.selected = false);
43
- this.#markup.tagsContainer.replaceChildren();
44
- this.#setValidityAndFormValue();
45
- this.dispatchEvent(new Event('change'));
136
+ this.#markup.tagsContainer?.replaceChildren();
137
+ this.dispatchEvent(new Event('reset'));
138
+ requestAnimationFrame(() => this.#markup.searchInput?.focus());
46
139
  }
47
- formDisabledCallback(isDisabled) {
48
- this.disabled = isDisabled;
140
+ formDisabledCallback() {
141
+ // toDo
142
+ // when inside fieldset, this callback is only invoked for the first time
143
+ // isDisabled: boolean
49
144
  }
50
145
  // Instance properties
51
146
  // <combo-box> specific properties
@@ -62,22 +157,17 @@ export class HTMLComboboxElement extends HTMLElement {
62
157
  value = '';
63
158
  value = String(value);
64
159
  super.setAttribute('query', value);
65
- if (this.#markup.connected) {
66
- this.#markup.searchInput.value = value;
67
- }
160
+ this.#markup?.setInputValue(value);
68
161
  }
69
162
  get placeholder() {
70
163
  return this.getAttribute('placeholder');
71
164
  }
72
165
  set placeholder(value) {
73
166
  if (value == null)
74
- value = '';
167
+ value = ' ';
75
168
  value = String(value);
76
169
  super.setAttribute('placeholder', value);
77
- if (this.#markup.connected) {
78
- this.#markup.placeholder.innerText = value;
79
- this.#markup.searchInput.placeholder = value;
80
- }
170
+ this.#markup?.setPlaceholder(value);
81
171
  }
82
172
  get clearable() {
83
173
  return this.hasAttribute('clearable');
@@ -103,7 +193,9 @@ export class HTMLComboboxElement extends HTMLElement {
103
193
  }
104
194
  set disabled(value) {
105
195
  this.#internals.ariaDisabled = String(value);
106
- super.toggleAttribute('disabled', toBoolean(value));
196
+ value = toBoolean(value);
197
+ super.toggleAttribute('disabled', value);
198
+ super.toggleAttribute('inert', value);
107
199
  }
108
200
  get form() {
109
201
  return this.#internals.form;
@@ -154,34 +246,14 @@ export class HTMLComboboxElement extends HTMLElement {
154
246
  set value(value) {
155
247
  if (this.value === value || typeof value !== 'string')
156
248
  return;
157
- const prevValue = new Set(this.#values);
158
- this.#values = new Set;
159
- const values = value.split(',').filter(Boolean);
160
- Promise.resolve(values)
161
- .then(values => {
162
- if (values.length) {
163
- if (!this.multiple) {
164
- if (this.#values.size === 0) {
165
- values.length = 1;
166
- }
167
- else {
168
- values.length = 0;
169
- }
170
- }
171
- for (const key of values) {
172
- const option = this.#markup.getOptionByValue(key);
173
- if (option)
174
- this.#selectOption(option);
175
- }
176
- }
177
- for (const key of prevValue) {
178
- if (this.#values.has(key))
179
- continue;
180
- const option = this.#markup.getOptionByValue(key);
181
- const tag = this.#markup.getTagByValue(key);
182
- tag?.remove();
183
- option?.toggleAttribute('selected', false);
184
- }
249
+ const nextValues = new Set(value.split(',').filter(Boolean));
250
+ this.#markup.options.forEach(option => {
251
+ const optionValue = option.value;
252
+ // toggling `selected` drives #onOptionSelectedStateChanges, which is the
253
+ // single source of truth for #values and tags - don't pre-assign #values
254
+ // here, or the dedup logic there ends up deselecting the option it's in
255
+ // the middle of selecting (see: stale tag left behind on next selection)
256
+ option.selected = nextValues.has(optionValue);
185
257
  });
186
258
  }
187
259
  // Instance methods
@@ -228,91 +300,168 @@ export class HTMLComboboxElement extends HTMLElement {
228
300
  }
229
301
  }
230
302
  // Internal
231
- #onInput = (event) => {
232
- if (!this.searchable && this.filterable) {
233
- if (event.target && event.target instanceof HTMLInputElement) {
234
- this.#markup.sort(event.target.value);
235
- }
236
- }
237
- };
238
303
  #onOptionsChanges = (records) => {
239
304
  records.forEach(record => {
240
- record.addedNodes.forEach(node => {
241
- if (node instanceof HTMLComboboxOptionElement) {
242
- node.addEventListener('click', this.#onSelectOption);
243
- if (node.selected) {
244
- if (this.multiple) {
245
- this.#selectOption(node);
246
- }
247
- else if (this.#values.size === 0) {
248
- this.#selectOption(node);
249
- }
305
+ record.removedNodes.forEach(option => {
306
+ if (option instanceof HTMLComboboxOptionElement) {
307
+ const value = option.value;
308
+ // mode is searchable,
309
+ // user type "a", select a result,
310
+ // then types "b". Here previous options will be replaced with new ones,
311
+ // this.#values may contain "a", but there is no relevant option yet,
312
+ // so just wait
313
+ if (this.searchable && this.#internals.ariaExpanded === 'true') {
314
+ return;
315
+ }
316
+ // options was removed, but values still include this value,
317
+ // just clear, this is like a reset was called
318
+ if (this.#values.has(value) && !this.#markup.optionsContainer?.querySelector(`[value="${value}"]`)) {
319
+ this.#values.delete(value);
320
+ const control = this.#markup.getTagByValue(value);
321
+ control?.remove();
250
322
  }
251
- }
252
- if (node instanceof HTMLComboboxOptionElement || node instanceof HTMLOptGroupElement) {
253
- this.#markup.optionsContainer.append(node);
254
323
  }
255
324
  });
256
325
  });
257
- this.#markup.invalidateOptionsCache();
258
- this.#setValidityAndFormValue();
259
326
  };
260
- #selectOption(option) {
261
- if (this.#values.has(option.value))
262
- return;
327
+ #onOptionSelectedStateChanges = (event) => {
328
+ const option = event.target;
263
329
  const value = option.value;
264
- this.#values.add(value);
265
- option.toggleAttribute('selected', true);
266
- const control = this.#markup.createAndAppendTag(option);
267
- control?.addEventListener('click', this.#onClearTag);
268
- if (!this.multiple) {
269
- this.#markup.closeDropdown();
330
+ if (option.selected) {
331
+ if (!this.multiple) {
332
+ this.#values.forEach(value => {
333
+ const prevOption = this.#markup.getOptionByValue(value);
334
+ if (prevOption) {
335
+ prevOption.selected = false;
336
+ }
337
+ else {
338
+ const tag = this.#markup.getTagByValue(value);
339
+ this.#values.delete(value);
340
+ tag?.remove();
341
+ }
342
+ });
343
+ }
344
+ if (!this.#values.has(value) || !this.#markup.getTagByValue(value)) {
345
+ this.#values.add(value);
346
+ this.#markup.createAndAppendTag(option);
347
+ this.#setValidityAndFormValue();
348
+ this.dispatchEvent(new Event('change'));
349
+ }
270
350
  }
271
- }
272
- #onSelectOption = (event) => {
273
- const target = event.target;
274
- if (target && target instanceof HTMLElement) {
275
- const option = target.closest('box-option');
276
- if (option) {
277
- if (this.#values.has(option.value))
278
- return;
279
- if (!this.multiple) {
280
- event.stopPropagation();
281
- this.#values.forEach(value => {
282
- this.#markup.getTagByValue(value)?.remove();
283
- this.#markup.getOptionByValue(value)?.toggleAttribute('selected', false);
284
- });
285
- this.#values.clear();
286
- this.#markup.tagsContainer.replaceChildren();
287
- }
288
- this.#selectOption(option);
351
+ else {
352
+ if (this.#values.has(value)) {
353
+ this.#values.delete(value);
354
+ const control = this.#markup.getTagByValue(value);
355
+ control?.remove();
289
356
  this.#setValidityAndFormValue();
290
357
  this.dispatchEvent(new Event('change'));
291
358
  }
359
+ else {
360
+ const tag = this.#markup.getTagByValue(value);
361
+ tag?.remove();
362
+ }
292
363
  }
293
364
  };
294
365
  #onClearTag = (event) => {
295
366
  const target = event.target;
296
- if (target && target instanceof HTMLElement) {
297
- const tag = target.closest('box-tag');
298
- if (tag) {
299
- const value = tag.getAttribute('value');
300
- const option = this.#markup.getOptionByValue(value);
301
- option.removeAttribute('selected');
302
- this.#values.delete(value);
303
- tag.remove();
304
- this.#setValidityAndFormValue();
305
- this.dispatchEvent(new Event('change'));
367
+ if (target) {
368
+ const option = this.#markup.getOptionByValue(target.value);
369
+ if (option) {
370
+ option.selected = false;
306
371
  }
372
+ else {
373
+ const tag = this.#markup.getTagByValue(target.value);
374
+ this.#values.delete(target.value);
375
+ tag?.remove();
376
+ }
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
+ });
307
388
  }
308
389
  };
309
- #onClear = () => {
390
+ #onClearAll = () => {
310
391
  this.formResetCallback();
311
392
  };
393
+ #onPickerToggle = (event) => {
394
+ const state = Reflect.get(event, 'newState');
395
+ const oldState = Reflect.get(event, 'oldState');
396
+ if (state === oldState)
397
+ return;
398
+ if (state === 'open') {
399
+ this.#prevValue = this.value;
400
+ this.#changed = true;
401
+ }
402
+ else {
403
+ this.#changed = this.#prevValue !== this.value;
404
+ this.dispatchEvent(new Event('close'));
405
+ }
406
+ };
407
+ #onKeyDown = (event) => {
408
+ if (this.#internals.ariaExpanded !== 'true')
409
+ return;
410
+ const activeElement = this.shadowRoot.activeElement;
411
+ if (!activeElement) {
412
+ // ??? how we should react ???
413
+ return;
414
+ }
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);
420
+ }
421
+ if (this.searchable) {
422
+ this.dispatchEvent(new Event('search'));
423
+ }
424
+ return;
425
+ }
426
+ if (event.key === 'Enter' && activeElement instanceof HTMLComboboxOptionElement) {
427
+ if (!activeElement.selected) {
428
+ event.stopPropagation();
429
+ return;
430
+ }
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();
440
+ return;
441
+ }
442
+ if (event.key === 'Escape' || event.key === 'Enter') {
443
+ this.#markup.closeDropdown();
444
+ this.blur();
445
+ }
446
+ };
447
+ #onInput = () => {
448
+ this.query = this.#markup.searchInput.value;
449
+ if (!this.searchable && this.filterable) {
450
+ this.#markup.sort(this.#markup.searchInput?.value || '');
451
+ }
452
+ };
312
453
  #setValidityAndFormValue() {
454
+ this.#markup.clearAllButton?.toggleAttribute('disabled', !Boolean(this.#values.size));
455
+ let empty = false;
456
+ if (this.#values.size === 0)
457
+ empty = true;
458
+ if (this.#values.size > 0 && !this.clearable && !this.multiple && (this.searchable || this.filterable)) {
459
+ empty = true;
460
+ }
461
+ this.#markup.searchInput?.toggleAttribute('autofocus', empty);
313
462
  this.#internals.setFormValue(this.value);
314
463
  if (this.required && this.value === '') {
315
- this.#internals.setValidity({ valueMissing: true });
464
+ this.#internals.setValidity({ valueMissing: true }, 'Value missing');
316
465
  }
317
466
  else {
318
467
  this.#internals.setValidity({});
@@ -346,7 +495,7 @@ export class HTMLComboboxElement extends HTMLElement {
346
495
  }
347
496
  }
348
497
  }
349
- document.addEventListener('keypress', (event) => {
498
+ document.addEventListener('keypress', () => {
350
499
  if (document.activeElement instanceof HTMLComboboxElement) {
351
500
  const maybeHost = document.activeElement.shadowRoot.activeElement;
352
501
  if (maybeHost instanceof HTMLComboboxOptionElement) {
@@ -1,15 +1,56 @@
1
1
  import { toBoolean } from './Boolean.attribute.value.normalizer.js';
2
2
  export class HTMLComboboxOptionElement extends HTMLElement {
3
- static OWN_IDL = new Set(['value', 'label', 'selected']);
3
+ static OWN_IDL = new Set(['value', 'label', 'selected', 'disabled']);
4
+ static EventInit = { bubbles: true, cancelable: true, composed: true };
5
+ static captureFocus(event) {
6
+ if (event.relatedTarget && event.relatedTarget instanceof HTMLComboboxOptionElement) {
7
+ event.stopPropagation();
8
+ event.preventDefault();
9
+ }
10
+ }
11
+ static onNavigate(event) {
12
+ if (!event.target ||
13
+ !(event.target instanceof HTMLComboboxOptionElement) ||
14
+ event.key === 'Tab' ||
15
+ event.key === 'Enter')
16
+ return;
17
+ event.preventDefault();
18
+ if (event.key === 'ArrowDown') {
19
+ const next = event.target.nextElementSibling;
20
+ if (next && next instanceof HTMLComboboxOptionElement) {
21
+ next.focus();
22
+ }
23
+ }
24
+ if (event.key === 'ArrowUp') {
25
+ const prev = event.target.previousElementSibling;
26
+ if (prev && prev instanceof HTMLComboboxOptionElement) {
27
+ prev.focus();
28
+ }
29
+ }
30
+ }
4
31
  connectedCallback() {
32
+ this.addEventListener('keydown', HTMLComboboxOptionElement.onNavigate);
33
+ this.addEventListener('focusout', HTMLComboboxOptionElement.captureFocus);
34
+ this.addEventListener('click', this.#toggleSelection);
5
35
  this.#initialAttributesSynchronization();
6
36
  this.part.add('option');
7
37
  super.setAttribute('tabindex', "0");
8
38
  super.setAttribute('role', "option");
9
- if (!this.value) {
10
- this.value = this.textContent;
11
- }
39
+ if (!this.value)
40
+ this.value = this.label;
41
+ if (!this.textContent)
42
+ this.textContent = this.value;
43
+ this.dispatchEvent(new Event('optionselectedstatechange', HTMLComboboxOptionElement.EventInit));
12
44
  }
45
+ disconnectedCallback() {
46
+ this.removeEventListener('click', this.#toggleSelection);
47
+ this.removeEventListener('keydown', HTMLComboboxOptionElement.onNavigate);
48
+ this.removeEventListener('focusout', HTMLComboboxOptionElement.captureFocus);
49
+ }
50
+ #toggleSelection = (event) => {
51
+ event.stopPropagation();
52
+ this.selected = !this.selected;
53
+ };
13
54
  get value() {
14
55
  return this.getAttribute('value');
15
56
  }
@@ -30,7 +71,26 @@ export class HTMLComboboxOptionElement extends HTMLElement {
30
71
  return this.hasAttribute('selected');
31
72
  }
32
73
  set selected(value) {
33
- super.toggleAttribute('selected', toBoolean(value));
74
+ const prev = this.selected;
75
+ const force = toBoolean(value);
76
+ if (prev === force) {
77
+ return;
78
+ }
79
+ super.toggleAttribute('selected', force);
80
+ this.part.toggle('selected', force);
81
+ // console.log('this.isConnected', this.isConnected)
82
+ if (this.isConnected) {
83
+ this.dispatchEvent(new Event('optionselectedstatechange', HTMLComboboxOptionElement.EventInit));
84
+ }
85
+ }
86
+ get disabled() {
87
+ return this.hasAttribute('disabled');
88
+ }
89
+ set disabled(value) {
90
+ const force = toBoolean(value);
91
+ super.toggleAttribute('disabled', force);
92
+ this.part.toggle('disabled', force);
93
+ super.toggleAttribute('inert', force);
34
94
  }
35
95
  #initialAttributesSynchronization() {
36
96
  for (const key of HTMLComboboxOptionElement.OWN_IDL) {
@@ -53,6 +113,11 @@ export class HTMLComboboxOptionElement extends HTMLElement {
53
113
  super.removeAttribute(name);
54
114
  }
55
115
  }
116
+ toggleAttribute(name, force) {
117
+ if (name === 'selected')
118
+ this.part.toggle(name, force);
119
+ return super.toggleAttribute(name, force);
120
+ }
56
121
  }
57
122
  if (!window.customElements.get('box-option')) {
58
123
  window.customElements.define('box-option', HTMLComboboxOptionElement);
@@ -1,17 +1,36 @@
1
1
  export class HTMLComboboxTagElement extends HTMLElement {
2
- constructor() {
3
- super();
2
+ static EventInit = { bubbles: true, cancelable: true, composed: true };
3
+ get value() {
4
+ return this.getAttribute('value') || '';
4
5
  }
5
6
  connectedCallback() {
6
7
  this.part.add('tag');
7
- if (this.parentElement) {
8
- if (this.parentElement.hasAttribute('multiple')) {
9
- if (!this.querySelector('[part*="clear-tag"]')) {
10
- throw new Error(`A <button> with part="clear-tag" is required for <combo-box> with multiple attribute`);
11
- }
8
+ const control = this.querySelector('[part*="clear-tag"]');
9
+ if (!this.hasAttribute('visible')) {
10
+ super.setAttribute('tabindex', '0');
11
+ }
12
+ control?.addEventListener('focusout', HTMLComboboxTagElement.captureFocus);
13
+ control?.addEventListener('click', HTMLComboboxTagElement.onClearTag);
14
+ const root = this.getRootNode();
15
+ if (root?.host.hasAttribute('multiple')) {
16
+ if (!control) {
17
+ throw new Error(`A <button> with part="clear-tag" is required for <combo-box> with multiple attribute`);
12
18
  }
13
19
  }
14
20
  }
21
+ disconnectedCallback() {
22
+ const control = this.querySelector('[part*="clear-tag"]');
23
+ control?.removeEventListener('focusout', HTMLComboboxTagElement.captureFocus);
24
+ control?.removeEventListener('click', HTMLComboboxTagElement.onClearTag);
25
+ }
26
+ static onClearTag(event) {
27
+ const target = event.target;
28
+ target?.parentElement?.dispatchEvent(new Event('tagcleared', HTMLComboboxTagElement.EventInit));
29
+ }
30
+ static captureFocus(event) {
31
+ event.stopPropagation();
32
+ event.preventDefault();
33
+ }
15
34
  }
16
35
  if (!window.customElements.get('box-tag')) {
17
36
  window.customElements.define('box-tag', HTMLComboboxTagElement);