html-combobox-element 0.0.2-beta.9 → 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.
package/README.md CHANGED
@@ -1 +1,123 @@
1
1
  # html-combobox-element
2
+
3
+ The `<combo-box>` is a web component that provides dropdown/combobox functionality through `<select>` fully compatible API.
4
+
5
+ ## Install
6
+ ```sh
7
+ npm install html-combobox-element
8
+ ```
9
+
10
+ ```ts
11
+ // app entry point
12
+ import 'html-combobox-element';
13
+ ```
14
+ This registers the `<combo-box>`, `<box-option>` and `<box-tag>` custom elements with full TS support for plain DOM/vanilla-JS usage.
15
+
16
+ ### TypeScript + JSX
17
+ Using `<combo-box>` in JSX or a Vue template needs one extra step, due to a TypeScript limitation: JSX/template type-checking only picks up custom element typings from a
18
+ file in your own project - not from a package's shipped `.d.ts`.
19
+
20
+ This isn't specific to this package; it affects any library adding custom elements to `JSX.IntrinsicElements` under the modern automatic JSX runtime.
21
+
22
+ So, in addition to `import 'html-combobox-element'`, create and include one small file tsconfig.json, with the snippet for your framework:
23
+
24
+ <details>
25
+ <summary><strong>React</strong> (<code>jsx: "react-jsx"</code>)</summary>
26
+
27
+ ```ts
28
+ // combo-box.d.ts
29
+ declare module 'react/jsx-runtime' {
30
+ namespace JSX {
31
+ interface IntrinsicElements {
32
+ 'combo-box': ComboboxElementAttributes;
33
+ 'box-option': ComboboxOptionElementAttributes;
34
+ 'box-tag': ComboboxTagElementAttributes;
35
+ }
36
+ }
37
+ }
38
+ declare module 'react/jsx-dev-runtime' {
39
+ namespace JSX {
40
+ interface IntrinsicElements {
41
+ 'combo-box': ComboboxElementAttributes;
42
+ 'box-option': ComboboxOptionElementAttributes;
43
+ 'box-tag': ComboboxTagElementAttributes;
44
+ }
45
+ }
46
+ }
47
+ ```
48
+
49
+ </details>
50
+
51
+ <details>
52
+ <summary><strong>Preact</strong> (<code>jsx: "react-jsx"</code>, <code>jsxImportSource: "preact"</code>)</summary>
53
+
54
+ ```ts
55
+ // combo-box.d.ts
56
+ declare module 'preact/jsx-runtime' {
57
+ namespace JSX {
58
+ interface IntrinsicElements {
59
+ 'combo-box': ComboboxElementAttributes;
60
+ 'box-option': ComboboxOptionElementAttributes;
61
+ 'box-tag': ComboboxTagElementAttributes;
62
+ }
63
+ }
64
+ }
65
+ ```
66
+
67
+ </details>
68
+
69
+ <details>
70
+ <summary><strong>Solid</strong> (<code>jsx: "preserve"</code>, <code>jsxImportSource: "solid-js"</code>)</summary>
71
+
72
+ ```ts
73
+ // combo-box.d.ts
74
+ declare module 'solid-js/jsx-runtime' {
75
+ namespace JSX {
76
+ interface IntrinsicElements {
77
+ 'combo-box': ComboboxElementAttributes;
78
+ 'box-option': ComboboxOptionElementAttributes;
79
+ 'box-tag': ComboboxTagElementAttributes;
80
+ }
81
+ }
82
+ }
83
+ ```
84
+
85
+ </details>
86
+
87
+ <details>
88
+ <summary><strong>Vue</strong> (SFC <code>&lt;template&gt;</code>, checked via <code>vue-tsc</code>)</summary>
89
+
90
+ Vue templates aren't JSX, so they're type-checked through `GlobalComponents` instead:
91
+
92
+ ```ts
93
+ // combo-box.d.ts
94
+ declare module 'vue' {
95
+ export interface GlobalComponents {
96
+ 'combo-box': new () => { $props: ComboboxElementAttributes };
97
+ 'box-option': new () => { $props: ComboboxOptionElementAttributes };
98
+ }
99
+ }
100
+
101
+ ```
102
+
103
+ This checks required props and value types for props you pass; like any native custom
104
+ element, Vue still allows extra/unrecognized attributes through (that's expected Vue
105
+ behavior for custom elements, not a gap in this typing). The trailing `export {};` may be
106
+ required here (without it the file is treated as a script, not a module, and the
107
+ augmentation breaks Vue's own types).
108
+
109
+ </details>
110
+
111
+ ### Exported types
112
+
113
+ - `HTMLComboboxElement`, `HTMLComboboxOptionElement`, `HTMLComboboxTagElement` - element
114
+ interfaces, also usable via `document.querySelector('combo-box')` etc.
115
+ - `ComboboxEvent`, `ComboboxInputEvent`, `ComboboxFocusEvent` - typed event objects
116
+ (`event.target` is a properly typed `HTMLComboboxElement`).
117
+ - `ComboboxJsxAttributes`, `ComboboxOptionJsxAttributes` - the component's own props
118
+ (`value`, `multiple`, `placeholder`, `clearable`, `searchable`, `filterable`,
119
+ `required`, `disabled`, etc.), framework-independent.
120
+ - `ComboboxElementAttributes`, `ComboboxOptionElementAttributes`,
121
+ `ComboboxTagElementAttributes` - the above plus common passthrough attributes
122
+ (`class`/`className`, `style`, `ref`, `children`, `data-*`, `aria-*`, `on*`). These
123
+ are what the snippets above use for `'combo-box'` / `'box-option'` / `'box-tag'`.
@@ -22,10 +22,7 @@ class ComboboxMarkup {
22
22
  window.addEventListener('click', this.onClickOutside);
23
23
  this.#shadowRoot.host.addEventListener('focusin', this.onFocusIn);
24
24
  this.#shadowRoot.host.addEventListener('focusout', this.onFocusOut);
25
- // document.addEventListener('scroll', this.#onDimensionsChanges);
26
- // document.addEventListener('resize', this.#onDimensionsChanges);
27
25
  this.#rso = new ResizeObserver(this.#onDimensionsChanges);
28
- // document.addEventListener('scrollend', this.onConnected);
29
26
  }
30
27
  connect() {
31
28
  const placeholder = this.#shadowRoot.host.getAttribute('placeholder') || `Выбрать`;
@@ -44,6 +41,11 @@ class ComboboxMarkup {
44
41
  const doc = document.importNode(innerTemplate.content, true);
45
42
  this.tagTemplate = doc.querySelector('box-tag');
46
43
  }
44
+ onHostConnected() {
45
+ const host = this.#shadowRoot.host;
46
+ const clearable = host.hasAttribute('clearable') || host.hasAttribute('multiple');
47
+ this.tagTemplate.toggleAttribute('visible', clearable);
48
+ }
47
49
  disconnect() {
48
50
  this.#shadowRoot.host.removeEventListener('focusin', this.onFocusIn);
49
51
  this.#shadowRoot.host.removeEventListener('focusout', this.onFocusOut);
@@ -82,7 +84,6 @@ class ComboboxMarkup {
82
84
  sort(query) {
83
85
  clearTimeout(this.#timer);
84
86
  this.#timer = setTimeout(() => {
85
- // console.log(this.options)
86
87
  const regex = new RegExp(query.trim(), 'i');
87
88
  this.options.forEach((option) => {
88
89
  if (!regex.test(option.textContent)) {
@@ -132,8 +133,6 @@ class ComboboxMarkup {
132
133
  return;
133
134
  this.#hostTop = this.#shadowRoot.host.getBoundingClientRect().top;
134
135
  try {
135
- this.dropdown.style.display = 'flex';
136
- // @ts-ignore
137
136
  this.dropdown.showPopover();
138
137
  this.#internals.ariaExpanded = "true";
139
138
  }
@@ -143,9 +142,7 @@ class ComboboxMarkup {
143
142
  };
144
143
  closeDropdown() {
145
144
  try {
146
- // @ts-ignore
147
145
  this.dropdown.hidePopover();
148
- this.dropdown.style.display = 'none';
149
146
  this.#internals.ariaExpanded = "false";
150
147
  }
151
148
  catch (e) {
@@ -181,14 +178,12 @@ class ComboboxMarkup {
181
178
  }
182
179
  else {
183
180
  const template = this.tagTemplate;
184
- // console.log(this)
185
181
  tag = template.cloneNode(true);
186
182
  const label = tag.querySelector('[part="tag-label"]');
187
183
  label.textContent = option.label || option.textContent;
188
184
  button = tag.querySelector('[part="clear-tag"]');
189
185
  }
190
186
  button?.setAttribute('value', value);
191
- // button.addEventListener('blur', e => e.stopPropagation());
192
187
  tag.setAttribute('value', value);
193
188
  this.tagsContainer.appendChild(tag);
194
189
  this.clearAllButton?.removeAttribute('disabled');
@@ -236,6 +231,7 @@ ${urls.map(url => `@import "${url}";`)}
236
231
  }
237
232
 
238
233
  #dropdown {
234
+ contain: layout style paint;
239
235
  inset: unset;
240
236
  margin: 0;
241
237
  /*position: absolute;*/
@@ -247,29 +243,30 @@ ${urls.map(url => `@import "${url}";`)}
247
243
  border-width: inherit;
248
244
  background-color: Canvas;
249
245
  outline: none;
250
-
251
- top: anchor(bottom);
246
+ display: none;
247
+ top: calc(anchor(bottom) + 2px);
252
248
  left: anchor(left);
253
249
  position-anchor: --position;
254
250
  position-try: normal flip-block;
255
251
  }
256
252
 
253
+ #dropdown:popover-open {
254
+ display: flex;
255
+ }
256
+
257
257
  #dropdown::-webkit-scrollbar {
258
258
  display: none;
259
259
  }
260
260
 
261
- [part="options"] {
261
+ #options {
262
262
  display: flex;
263
263
  flex-direction: column;
264
264
  justify-content: start;
265
265
  gap: 2px;
266
- /*padding: inherit;*/
267
- /*padding-block: .5rem;*/
268
- border-radius: inherit;
269
-
266
+ border-radius: inherit;
270
267
  }
271
268
 
272
- :host([filterable]) [part="options"], :host([searchable]) [part="options"] {
269
+ :host([filterable]) #options, :host([searchable]) #options {
273
270
  padding-top: .5rem;
274
271
  }
275
272
 
@@ -280,8 +277,6 @@ ${urls.map(url => `@import "${url}";`)}
280
277
  cursor: pointer;
281
278
  padding: inherit;
282
279
  padding-inline: .5lh;
283
- /*padding-inline: 2px;*/
284
- /*padding-block: 1px;*/
285
280
  }
286
281
 
287
282
  box-option:hover {
@@ -294,26 +289,17 @@ ${urls.map(url => `@import "${url}";`)}
294
289
  pointer-events: none;
295
290
  }
296
291
 
297
- [part="search-input"] {
292
+ #dropdown > [part="search-input"] {
298
293
  display: none;
299
294
  position: sticky;
300
295
  top: 0;
301
296
  z-index: 2;
302
- /*border-radius: inherit;*/
303
- /*border-style: inherit;*/
304
- /*border-width: inherit;*/
305
- /*border-color: inherit;*/
306
- /*padding: inherit;*/
307
- /*padding-inline: .4lh;*/
308
297
  font-size: inherit;
309
298
  outline: inherit;
310
- &::placeholder {
311
- color: var(--search-input-placeholder-color);
312
- }
313
299
  }
314
300
 
315
- :host([searchable]) [part="search-input"],
316
- :host([filterable]) [part="search-input"] {
301
+ :host([searchable]) #dropdown > [part="search-input"],
302
+ :host([filterable]) #dropdown > [part="search-input"] {
317
303
  display: flow-root;
318
304
  }
319
305
 
@@ -321,7 +307,6 @@ ${urls.map(url => `@import "${url}";`)}
321
307
  text-align: left;
322
308
  overflow: hidden;
323
309
  padding-inline-start: 2px;
324
- /*font-size: smaller;*/
325
310
  color: dimgrey;
326
311
  outline: none;
327
312
  user-select: none;
@@ -335,15 +320,12 @@ ${urls.map(url => `@import "${url}";`)}
335
320
  grid-column: 1 / span 2;
336
321
  width: 100%;
337
322
  }
338
-
339
-
340
323
 
341
324
  #tags {
342
325
  display: flex;
343
326
  flex-wrap: wrap;
344
327
  overflow: hidden;
345
328
  gap: 2px;
346
- /*border-radius: inherit;*/
347
329
  }
348
330
 
349
331
  box-tag {
@@ -352,13 +334,10 @@ ${urls.map(url => `@import "${url}";`)}
352
334
  box-sizing: border-box;
353
335
  display: flex;
354
336
  align-items: center;
355
- /*border-radius: inherit;*/
356
337
  padding-inline-start: 0.4lh;
357
338
  padding-inline-end: 0.2lh;
358
339
  background-color: transparent;
359
340
  gap: 5px;
360
- /*font-size: medium;*/
361
- text-transform: uppercase;
362
341
  border-radius: 1lh;
363
342
  }
364
343
 
@@ -368,31 +347,28 @@ ${urls.map(url => `@import "${url}";`)}
368
347
  max-width: 100%;
369
348
  }
370
349
 
371
- box-tag [part*="tag-label"] {
350
+ [part*="tag-label"] {
372
351
  white-space: nowrap;
373
352
  text-overflow: ellipsis;
374
353
  overflow: hidden;
375
354
  user-select: none;
376
- /*font-size: 95%;*/
377
355
  flex-grow: 1;
378
356
  }
379
357
 
380
- :host([multiple]) box-tag[part*="tag-label"] {
358
+ :host([multiple]) [part*="tag-label"] {
381
359
  flex-grow: unset;
382
360
  }
383
361
 
384
- [part*="clear-tag"], [part*="clear-all"] {
362
+ [part*="clear-tag"], #internal > [part*="clear-all"] {
385
363
  border-radius: 100%;
386
364
  border: none;
387
365
  aspect-ratio: 1;
388
- /*line-height: 0;*/
389
366
  padding: 0!important;
390
367
  user-select: none;
391
368
  background-color: transparent;
392
369
  }
393
370
 
394
371
  [part*="clear-tag"] {
395
- /*inline-size: 1lh;*/
396
372
  block-size: 1lh;
397
373
  font-size: .7lh;
398
374
  text-transform: uppercase;
@@ -404,7 +380,7 @@ ${urls.map(url => `@import "${url}";`)}
404
380
  outline-width: 1px;
405
381
  }
406
382
 
407
- :host([multiple]) [part*="clear-all"] {
383
+ :host([multiple]) #internal > [part*="clear-all"] {
408
384
  display: block;
409
385
  }
410
386
 
@@ -414,13 +390,10 @@ ${urls.map(url => `@import "${url}";`)}
414
390
  place-content: center;
415
391
  }
416
392
 
417
- [part*="clear-all"] {
393
+ #internal > [part*="clear-all"] {
418
394
  font-size: inherit;
419
395
  text-transform: uppercase;
420
- /*inline-size: 1lh;*/
421
396
  block-size: 1lh;
422
- /*inline-size: 1.2em;*/
423
- /*block-size: 1.2em;*/
424
397
  display: none;
425
398
  }
426
399
 
@@ -428,28 +401,20 @@ ${urls.map(url => `@import "${url}";`)}
428
401
  outline: none;
429
402
  }
430
403
 
431
- [part*="clear-all"]:hover,
404
+ #internal > [part*="clear-all"]:hover,
432
405
  [part*="clear-tag"]:hover {
433
406
  color: ActiveText;
434
407
  cursor: pointer;
435
408
  }
436
409
 
437
- [part*="clear-all"]:hover {
410
+ #internal >[part*="clear-all"]:hover {
438
411
  background-color: ButtonFace;
439
412
  }
440
413
 
441
- :host:has(#tags:empty) [part*="clear-all"] {
414
+ #tags:empty + [part*="clear-all"] {
442
415
  pointer-events: none;
443
416
  outline: none;
444
417
  }
445
-
446
- [part*="not-found"] {
447
- visibility: visible;
448
- }
449
-
450
- /*:host:has(#dropdown:popover-open) #dropdown {*/
451
- /* border-top-left-radius: 0;*/
452
- /*}*/
453
418
 
454
419
  </style>
455
420
  <div id='internal' part='internal'>
@@ -460,8 +425,9 @@ ${urls.map(url => `@import "${url}";`)}
460
425
 
461
426
  <div id="dropdown" popover="manual" part='dropdown' >
462
427
  <input placeholder='Поиск' name="search-input" part="search-input" inputmode='search' autofocus />
463
- <div part="options"></div>
428
+ <div part="options" id='options'></div>
464
429
  </div>
430
+
465
431
  <template id='tag-template'>
466
432
  <box-tag>
467
433
  <span part="tag-label"></span>
@@ -469,5 +435,11 @@ ${urls.map(url => `@import "${url}";`)}
469
435
  </box-tag>
470
436
  </template>
471
437
  `;
438
+ static markup;
439
+ static {
440
+ const template = document.createElement('template');
441
+ template.innerHTML = ComboboxMarkup.template;
442
+ ComboboxMarkup.markup = template;
443
+ }
472
444
  }
473
445
  exports.ComboboxMarkup = ComboboxMarkup;
@@ -7,7 +7,7 @@ const HTML_combobox_option_element_js_1 = require("./HTML.combobox.option.elemen
7
7
  const Boolean_attribute_value_normalizer_js_1 = require("./Boolean.attribute.value.normalizer.js");
8
8
  class HTMLComboboxElement extends HTMLElement {
9
9
  static OWN_IDL = new Set(['required', 'disabled', 'clearable', 'multiple', 'filterable', 'searchable', 'value', 'placeholder', 'query']);
10
- static observerOptions = { childList: true, attributes: false, subtree: false };
10
+ static observerOptions = { childList: true };
11
11
  static styleSheet = [new CSSStyleSheet];
12
12
  static formAssociated = true;
13
13
  shadowRoot;
@@ -24,32 +24,51 @@ class HTMLComboboxElement extends HTMLElement {
24
24
  this.#internals.ariaHasPopup = "dialog";
25
25
  this.shadowRoot = this.attachShadow({ mode: 'open', delegatesFocus: true }); //delegatesFocus: true
26
26
  this.#markup = new Combobox_markup_js_1.ComboboxMarkup(this.shadowRoot, this.#internals);
27
- this.shadowRoot.innerHTML = Combobox_markup_js_1.ComboboxMarkup.template;
27
+ this.shadowRoot.appendChild(Combobox_markup_js_1.ComboboxMarkup.markup.content.cloneNode(true));
28
28
  this.shadowRoot.adoptedStyleSheets = _a.styleSheet;
29
29
  this.#observer = new MutationObserver(this.#onOptionsChanges);
30
30
  this.#markup.connect();
31
31
  }
32
32
  // Lifecycle callbacks
33
33
  connectedCallback() {
34
- // super.setAttribute('tabindex', '0');
34
+ for (const child of Array.from(this.children)) {
35
+ if (_a.#isOptionLike(child)) {
36
+ this.#markup.optionsContainer?.appendChild(child);
37
+ }
38
+ }
39
+ this.#markup.onHostConnected();
35
40
  this.shadowRoot.addEventListener('optionselectedstatechange', this.#onOptionSelectedStateChanges);
36
41
  this.shadowRoot.addEventListener('tagcleared', this.#onClearTag);
37
- this.#onOptionsChanges([{ addedNodes: Array.from(this.children) }]);
38
42
  this.#initialAttributesSynchronization();
39
- this.#observer.observe(this, _a.observerOptions);
43
+ this.#observer.observe(this.#markup.optionsContainer, _a.observerOptions);
40
44
  this.addEventListener('keydown', this.#onKeyDown);
41
45
  this.#markup.clearAllButton.addEventListener('click', this.#onClearAll);
42
46
  this.#markup.searchInput.addEventListener('input', this.#onInput);
43
47
  this.#markup.dropdown.addEventListener('toggle', this.#onPickerToggle);
44
48
  this.#setValidityAndFormValue();
45
- }
49
+ // Options/optgroups can end up as direct light-DOM children of <combo-box> without ever
50
+ // going through insertBefore/appendChild below: parsed straight from static HTML markup,
51
+ // or - notably - Solid's compiler, which renders static JSX subtrees via a single
52
+ // <template> + cloneNode(true), never calling any child-mutation method at all. Sweep
53
+ // whatever is already here once on connect and move it into the real options container.
54
+ }
55
+ static #isOptionLike(node) {
56
+ return node instanceof HTML_combobox_option_element_js_1.HTMLComboboxOptionElement || node instanceof HTMLOptGroupElement;
57
+ }
58
+ // Overwritten child-mutation methods.
59
+ //
60
+ // <box-option>/<optgroup> children don't actually live under <combo-box> itself - they're
61
+ // redirected into #markup.optionsContainer inside the shadow root, which is what the
62
+ // dropdown renders from and what #observer watches. Every native method a framework's
63
+ // renderer or plain user code might use to add/move/remove children needs to funnel through
64
+ // the same redirect, since browsers implement each of these independently (overriding one,
65
+ // e.g. insertBefore, does not make the others call it under the hood):
66
+ // - React calls appendChild for a plain append and insertBefore only when reordering.
67
+ // - Vue and Preact always call insertBefore, with a null reference for a plain append.
68
+ // - Solid mostly bypasses all of them for static subtrees (see connectedCallback above),
69
+ // but dynamic option lists still go through insert()/insertBefore().
46
70
  insertBefore = (node, referenceNode) => {
47
- // Since we just move options to the #markup.optionsContainer, they are no more part of this children
48
- // next time, when someone is calling this(combo-box).insertBefore, an error will be thrown:
49
- // Failed to execute 'insertBefore' on 'Node': The node before which the new node is to be inserted is not a child of this node.
50
- // Here we overwrite this method, and if new node is HTMLComboboxOptionElement,
51
- // we just delegate this job to the real option parent: optionsContainer
52
- if (node instanceof HTML_combobox_option_element_js_1.HTMLComboboxOptionElement || node instanceof HTMLOptGroupElement) {
71
+ if (_a.#isOptionLike(node)) {
53
72
  this.#markup.optionsContainer?.insertBefore(node, referenceNode);
54
73
  }
55
74
  else {
@@ -57,6 +76,52 @@ class HTMLComboboxElement extends HTMLElement {
57
76
  }
58
77
  return node;
59
78
  };
79
+ appendChild = (node) => {
80
+ if (_a.#isOptionLike(node)) {
81
+ this.#markup.optionsContainer?.appendChild(node);
82
+ }
83
+ else {
84
+ super.appendChild(node);
85
+ }
86
+ return node;
87
+ };
88
+ append = (...nodes) => {
89
+ nodes.forEach(node => this.appendChild(typeof node === 'string' ? new Text(node) : node));
90
+ };
91
+ prepend = (...nodes) => {
92
+ [...nodes].reverse().forEach(node => {
93
+ const n = typeof node === 'string' ? new Text(node) : node;
94
+ if (_a.#isOptionLike(n)) {
95
+ this.#markup.optionsContainer?.insertBefore(n, this.#markup.optionsContainer.firstChild);
96
+ }
97
+ else {
98
+ super.insertBefore(n, this.firstChild);
99
+ }
100
+ });
101
+ };
102
+ replaceChildren = (...nodes) => {
103
+ this.#markup.optionsContainer?.replaceChildren();
104
+ super.replaceChildren();
105
+ this.append(...nodes);
106
+ };
107
+ removeChild = (child) => {
108
+ if (child.parentNode === this.#markup.optionsContainer) {
109
+ this.#markup.optionsContainer.removeChild(child);
110
+ }
111
+ else if (child.parentNode === this) {
112
+ super.removeChild(child);
113
+ }
114
+ return child;
115
+ };
116
+ replaceChild = (newChild, oldChild) => {
117
+ if (oldChild.parentNode === this.#markup.optionsContainer) {
118
+ this.#markup.optionsContainer.replaceChild(newChild, oldChild);
119
+ }
120
+ else {
121
+ super.replaceChild(newChild, oldChild);
122
+ }
123
+ return oldChild;
124
+ };
60
125
  get changed() {
61
126
  return this.#changed;
62
127
  }
@@ -72,6 +137,7 @@ class HTMLComboboxElement extends HTMLElement {
72
137
  }
73
138
  formResetCallback() {
74
139
  this.selectedOptions.forEach(option => option.selected = false);
140
+ this.#markup.tagsContainer?.replaceChildren();
75
141
  this.dispatchEvent(new Event('reset'));
76
142
  requestAnimationFrame(() => this.#markup.searchInput?.focus());
77
143
  }
@@ -184,11 +250,14 @@ class HTMLComboboxElement extends HTMLElement {
184
250
  set value(value) {
185
251
  if (this.value === value || typeof value !== 'string')
186
252
  return;
187
- const values = value.split(',').filter(Boolean);
188
- this.#values = new Set(values);
253
+ const nextValues = new Set(value.split(',').filter(Boolean));
189
254
  this.#markup.options.forEach(option => {
190
- const value = option.value;
191
- option.selected = this.#values.has(value);
255
+ const optionValue = option.value;
256
+ // toggling `selected` drives #onOptionSelectedStateChanges, which is the
257
+ // single source of truth for #values and tags - don't pre-assign #values
258
+ // here, or the dedup logic there ends up deselecting the option it's in
259
+ // the middle of selecting (see: stale tag left behind on next selection)
260
+ option.selected = nextValues.has(optionValue);
192
261
  });
193
262
  }
194
263
  // Instance methods
@@ -237,9 +306,24 @@ class HTMLComboboxElement extends HTMLElement {
237
306
  // Internal
238
307
  #onOptionsChanges = (records) => {
239
308
  records.forEach(record => {
240
- record.addedNodes.forEach(node => {
241
- if (node instanceof HTML_combobox_option_element_js_1.HTMLComboboxOptionElement || node instanceof HTMLOptGroupElement) {
242
- this.#markup.optionsContainer.append(node);
309
+ record.removedNodes.forEach(option => {
310
+ if (option instanceof HTML_combobox_option_element_js_1.HTMLComboboxOptionElement) {
311
+ const value = option.value;
312
+ // mode is searchable,
313
+ // user type "a", select a result,
314
+ // then types "b". Here previous options will be replaced with new ones,
315
+ // this.#values may contain "a", but there is no relevant option yet,
316
+ // so just wait
317
+ if (this.searchable && this.#internals.ariaExpanded === 'true') {
318
+ return;
319
+ }
320
+ // options was removed, but values still include this value,
321
+ // just clear, this is like a reset was called
322
+ if (this.#values.has(value) && !this.#markup.optionsContainer?.querySelector(`[value="${value}"]`)) {
323
+ this.#values.delete(value);
324
+ const control = this.#markup.getTagByValue(value);
325
+ control?.remove();
326
+ }
243
327
  }
244
328
  });
245
329
  });
@@ -251,11 +335,17 @@ class HTMLComboboxElement extends HTMLElement {
251
335
  if (!this.multiple) {
252
336
  this.#values.forEach(value => {
253
337
  const prevOption = this.#markup.getOptionByValue(value);
254
- if (prevOption)
338
+ if (prevOption) {
255
339
  prevOption.selected = false;
340
+ }
341
+ else {
342
+ const tag = this.#markup.getTagByValue(value);
343
+ this.#values.delete(value);
344
+ tag?.remove();
345
+ }
256
346
  });
257
347
  }
258
- if (!this.#values.has(value)) {
348
+ if (!this.#values.has(value) || !this.#markup.getTagByValue(value)) {
259
349
  this.#values.add(value);
260
350
  this.#markup.createAndAppendTag(option);
261
351
  this.#setValidityAndFormValue();
@@ -270,14 +360,24 @@ class HTMLComboboxElement extends HTMLElement {
270
360
  this.#setValidityAndFormValue();
271
361
  this.dispatchEvent(new Event('change'));
272
362
  }
363
+ else {
364
+ const tag = this.#markup.getTagByValue(value);
365
+ tag?.remove();
366
+ }
273
367
  }
274
368
  };
275
369
  #onClearTag = (event) => {
276
370
  const target = event.target;
277
371
  if (target) {
278
372
  const option = this.#markup.getOptionByValue(target.value);
279
- if (option)
373
+ if (option) {
280
374
  option.selected = false;
375
+ }
376
+ else {
377
+ const tag = this.#markup.getTagByValue(target.value);
378
+ this.#values.delete(target.value);
379
+ tag?.remove();
380
+ }
281
381
  requestAnimationFrame(() => {
282
382
  if (this.#values.size) {
283
383
  this.focus();
@@ -319,6 +419,7 @@ class HTMLComboboxElement extends HTMLElement {
319
419
  if (activeElement === this.#markup.searchInput && event.key === 'Enter') {
320
420
  event.stopPropagation();
321
421
  if (!this.searchable && this.filterable) {
422
+ console.log('sss');
322
423
  this.#markup.sort(this.#markup.searchInput.value);
323
424
  }
324
425
  if (this.searchable) {
@@ -332,8 +433,14 @@ class HTMLComboboxElement extends HTMLElement {
332
433
  return;
333
434
  }
334
435
  }
335
- if (event.key === 'Enter' && activeElement instanceof HTMLElement && activeElement.part.contains('clear-tag')) {
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
336
442
  activeElement.click();
443
+ // event.stopPropagation();
337
444
  return;
338
445
  }
339
446
  if (event.key === 'Escape' || event.key === 'Enter') {
@@ -366,7 +473,6 @@ class HTMLComboboxElement extends HTMLElement {
366
473
  }
367
474
  #initialAttributesSynchronization() {
368
475
  for (const key of _a.OWN_IDL) {
369
- // @ts-ignore
370
476
  this[key] = this.getAttribute(key);
371
477
  }
372
478
  }
@@ -395,7 +501,7 @@ class HTMLComboboxElement extends HTMLElement {
395
501
  }
396
502
  exports.HTMLComboboxElement = HTMLComboboxElement;
397
503
  _a = HTMLComboboxElement;
398
- document.addEventListener('keypress', (event) => {
504
+ document.addEventListener('keypress', () => {
399
505
  if (document.activeElement instanceof HTMLComboboxElement) {
400
506
  const maybeHost = document.activeElement.shadowRoot.activeElement;
401
507
  if (maybeHost instanceof HTML_combobox_option_element_js_1.HTMLComboboxOptionElement) {