@synergy-design-system/mcp 2.2.0 → 2.4.0

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.
@@ -1,9 +1,11 @@
1
- import type { CSSResultGroup, PropertyValues } from 'lit';
1
+ /* eslint-disable no-param-reassign */
2
+ /* eslint-disable no-underscore-dangle */
3
+ import type { CSSResultGroup, PropertyValues, TemplateResult } from 'lit';
2
4
  import { classMap } from 'lit/directives/class-map.js';
3
5
  import { html } from 'lit';
4
6
  import { property, query, state } from 'lit/decorators.js';
7
+ import { unsafeHTML } from 'lit/directives/unsafe-html.js';
5
8
  import { animateTo, stopAnimations } from '../../internal/animate.js';
6
- import { defaultValue } from '../../internal/default-value.js';
7
9
  import { FormControlController } from '../../internal/form.js';
8
10
  import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry.js';
9
11
  import { HasSlotController } from '../../internal/slot.js';
@@ -19,24 +21,30 @@ import SynPopup from '../popup/popup.component.js';
19
21
  import type { SynergyFormControl } from '../../internal/synergy-element.js';
20
22
  import SynOption from '../option/option.component.js';
21
23
  import type SynOptGroup from '../optgroup/optgroup.js';
24
+ import SynTag from '../tag/tag.component.js';
22
25
  import styles from './combobox.styles.js';
23
26
  import customStyles from './combobox.custom.styles.js';
24
27
  import {
25
28
  checkValueBelongsToOption,
26
29
  createOptionFromDifferentTypes, filterOnlyOptgroups, getAllOptions, getAssignedElementsForSlot,
27
- getValueFromOption, normalizeString,
30
+ getValueFromOption, getValuesFromOptions, normalizeString,
28
31
  } from './utils.js';
29
32
  import { scrollIntoView } from '../../internal/scroll.js';
30
33
  import { type OptionRenderer, defaultOptionRenderer } from './option-renderer.js';
31
34
  import { enableDefaultSettings } from '../../utilities/defaultSettings/decorator.js';
35
+ import type { SynRemoveEvent } from '../../events/events.js';
36
+ import { compareValues, isAllowedValue } from '../select/utility.js';
32
37
 
33
38
  /**
34
- * @summary Comboboxes allow you to choose items from a menu of predefined options.
39
+ * @summary A combobox component that combines the functionality of a text input with a dropdown listbox,
40
+ * allowing users to either select from predefined options or enter custom values (when not restricted).
41
+ *
35
42
  * @documentation https://synergy-design-system.github.io/?path=/docs/components-syn-combobox--docs
36
43
  * @status stable
37
44
  *
38
45
  * @dependency syn-icon
39
46
  * @dependency syn-popup
47
+ * @dependency syn-tag
40
48
  *
41
49
  * @slot - The listbox options. Must be `<syn-option>` elements.
42
50
  * You can use `<syn-optgroup>`'s to group items visually.
@@ -66,7 +74,7 @@ import { enableDefaultSettings } from '../../utilities/defaultSettings/decorator
66
74
  * @csspart form-control-label - The label's wrapper.
67
75
  * @csspart form-control-input - The combobox's wrapper.
68
76
  * @csspart form-control-help-text - The help text's wrapper.
69
- * @csspart combobox - The container the wraps the prefix, combobox, clear icon, and expand button.
77
+ * @csspart combobox - The container that wraps the prefix, combobox, clear icon, and expand button.
70
78
  * @csspart prefix - The container that wraps the prefix slot.
71
79
  * @csspart suffix - The container that wraps the suffix slot.
72
80
  * @csspart display-input - The element that displays the selected option's label,
@@ -79,6 +87,12 @@ import { enableDefaultSettings } from '../../utilities/defaultSettings/decorator
79
87
  * @csspart popup - The popup's exported `popup` part.
80
88
  * Use this to target the tooltip's popup container.
81
89
  * @csspart no-results - The container that wraps the "no results" message.
90
+ * @csspart tags - The container that houses option tags when `multiple` is used.
91
+ * @csspart tag - The individual tags that represent each selected option in `multiple`.
92
+ * @csspart tag__base - The tag's base part.
93
+ * @csspart tag__content - The tag's content part.
94
+ * @csspart tag__remove-button - The tag's remove button.
95
+ * @csspart tag__remove-button__base - The tag's remove button base part.
82
96
  *
83
97
  * @animation combobox.show - The animation to use when showing the combobox.
84
98
  * @animation combobox.hide - The animation to use when hiding the combobox.
@@ -96,6 +110,7 @@ export default class SynCombobox extends SynergyElement implements SynergyFormCo
96
110
  static dependencies = {
97
111
  'syn-icon': SynIcon,
98
112
  'syn-popup': SynPopup,
113
+ 'syn-tag': SynTag,
99
114
  };
100
115
 
101
116
  private readonly formControlController = new FormControlController(this, {
@@ -108,13 +123,24 @@ export default class SynCombobox extends SynergyElement implements SynergyFormCo
108
123
 
109
124
  private closeWatcher: CloseWatcher | null;
110
125
 
111
- /** The last syn-option, that was selected by click or via keyboard navigation */
112
- private lastOption: SynOption | undefined;
113
-
114
- private isOptionRendererTriggered = false;
126
+ /**
127
+ * Cache of the last syn-options that were selected by user interaction (click or keyboard navigation).
128
+ * Used to track user selections and maintain selection state during value changes.
129
+ */
130
+ private lastOptions: SynOption[] = [];
115
131
 
116
132
  private isInitialized: boolean = false;
117
133
 
134
+ /**
135
+ * Flag to prevent infinite loops when the option renderer programmatically updates options.
136
+ * Set to true during option rendering to ignore slot change events triggered by our own updates.
137
+ */
138
+ private isOptionRendererTriggered: boolean = false;
139
+
140
+ private resizeObserver: ResizeObserver;
141
+
142
+ private mutationObserver: MutationObserver;
143
+
118
144
  @query('.combobox') popup: SynPopup;
119
145
 
120
146
  @query('.combobox__inputs') combobox: HTMLSlotElement;
@@ -127,28 +153,58 @@ export default class SynCombobox extends SynergyElement implements SynergyFormCo
127
153
 
128
154
  @query('slot:not([name])') private defaultSlot: HTMLSlotElement;
129
155
 
156
+ @query('.combobox__tags') tagContainer: HTMLDivElement;
157
+
130
158
  @state() private hasFocus = false;
131
159
 
132
160
  @state() private isUserInput = false;
133
161
 
134
162
  @state() displayLabel = '';
135
163
 
136
- @state() selectedOption: SynOption | undefined;
164
+ @state() selectedOptions: SynOption[] = [];
137
165
 
138
166
  @state() numberFilteredOptions = 0;
139
167
 
140
- @state() cachedOptions: SynOption [] = [];
168
+ @state() cachedOptions: SynOption[] = [];
169
+
170
+ @state() private valueHasChanged: boolean = false;
171
+
172
+ @state() private hideOptions = false;
141
173
 
142
174
  /** The name of the combobox, submitted as a name/value pair with form data. */
143
175
  @property() name = '';
144
176
 
177
+ private _value: string | number | Array<string | number> = '';
178
+
179
+ get value() {
180
+ return this._value;
181
+ }
182
+
145
183
  /**
146
- * The current value of the combobox, submitted as a name/value pair with form data.
184
+ * The current value of the combobox, submitted as a name/value pair with form data. When `multiple` is enabled, the
185
+ * value attribute will be a list of values separated by the delimiter, based on the options selected, and the value property will
186
+ * be an array. **For this reason, values must not contain the delimiter character.**
147
187
  */
148
- @property() value = '';
188
+ @state()
189
+ set value(val: string | number | Array<string | number>) {
190
+ if (this.multiple) {
191
+ if (!Array.isArray(val)) {
192
+ val = typeof val === 'string' ? val.split(this.delimiter) : [val].filter(isAllowedValue);
193
+ }
194
+ } else {
195
+ val = Array.isArray(val) ? val.join(this.delimiter) : val;
196
+ }
197
+
198
+ if (compareValues(this._value, val)) {
199
+ return;
200
+ }
201
+
202
+ this.valueHasChanged = true;
203
+ this._value = val;
204
+ }
149
205
 
150
206
  /** The default value of the form control. Primarily used for resetting the form control. */
151
- @defaultValue() defaultValue = '';
207
+ @property({ attribute: 'value' }) defaultValue: string | number | Array<string | number> = '';
152
208
 
153
209
  /** The combobox's size. */
154
210
  @property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium';
@@ -195,9 +251,16 @@ export default class SynCombobox extends SynergyElement implements SynergyFormCo
195
251
  /**
196
252
  * When set to `true`, restricts the combobox to only allow selection from the available options.
197
253
  * Users will not be able to enter custom values that are not present in the list.
254
+ * This will always be true, if `multiple` is active.
198
255
  */
199
256
  @property({ reflect: true, type: Boolean }) restricted = false;
200
257
 
258
+ /**
259
+ * Allows more than one option to be selected.
260
+ * If `multiple` is set, the combobox will always be `restricted` to the available options
261
+ * */
262
+ @property({ reflect: true, type: Boolean }) multiple = false;
263
+
201
264
  /**
202
265
  * A function that customizes the rendered option. The first argument is the option, the second
203
266
  * is the query string, which is typed into the combobox.
@@ -228,9 +291,44 @@ export default class SynCombobox extends SynergyElement implements SynergyFormCo
228
291
  return true;
229
292
  }
230
293
 
231
- return option?.value === queryStr;
294
+ return option?.value?.toString() === queryStr;
232
295
  };
233
296
 
297
+ /**
298
+ * The delimiter to use when setting the value when `multiple` is enabled.
299
+ * The default is a space ' ', but you can set it to a comma or other character(s).
300
+ * @example <syn-combobox delimiter="~" value="option-1~option-2"></syn-combobox>
301
+ */
302
+ @property() delimiter = ' ';
303
+
304
+ /**
305
+ * The maximum number of selected options to show when `multiple` is true. After the maximum, "+n" will be shown to
306
+ * indicate the number of additional items that are selected. Set to 0 to remove the limit.
307
+ */
308
+ @property({ attribute: 'max-options-visible', type: Number }) maxOptionsVisible = 3;
309
+
310
+ /**
311
+ * A function that customizes the tags to be rendered when `multiple` is true. The first argument is the option, the second
312
+ * is the current tag's index. The function should return either a Lit TemplateResult or a string containing trusted HTML of the symbol to render at
313
+ * the specified value.
314
+ */
315
+ @property() getTag: (option: SynOption, index: number) => TemplateResult | string | HTMLElement = option => html`
316
+ <syn-tag
317
+ part="tag"
318
+ exportparts="
319
+ base:tag__base,
320
+ content:tag__content,
321
+ remove-button:tag__remove-button,
322
+ remove-button__base:tag__remove-button__base
323
+ "
324
+ size=${this.size}
325
+ removable
326
+ @syn-remove=${(event: SynRemoveEvent) => this.handleTagRemove(event, option)}
327
+ >
328
+ ${option.getTextLabel()}
329
+ </syn-tag>
330
+ `;
331
+
234
332
  /** Gets the validity state object */
235
333
  get validity() {
236
334
  return this.valueInput.validity;
@@ -241,29 +339,159 @@ export default class SynCombobox extends SynergyElement implements SynergyFormCo
241
339
  return this.valueInput.validationMessage;
242
340
  }
243
341
 
342
+ private calculateTagMaxWidth = (entries: ResizeObserverEntry[]) => {
343
+ const input = entries.at(0);
344
+ if (!input || !this.tagContainer) return;
345
+
346
+ const inputWidth = input.contentRect.width;
347
+ const tagsWidth = this.tagContainer.getBoundingClientRect().width;
348
+
349
+ // The min-width of the input is 48px, this should stay available for the input
350
+ // The min-width of the tags is 85px, so we should not go below that
351
+ const availableTagSpace = Math.max(85, tagsWidth + inputWidth - 48);
352
+
353
+ this.tagContainer.style.setProperty('--syn-select-tag-max-width', `${availableTagSpace}px`);
354
+ };
355
+
356
+ private enableResizeObserver() {
357
+ if (!this.multiple) return;
358
+
359
+ if (!this.resizeObserver) {
360
+ this.resizeObserver = new ResizeObserver(this.calculateTagMaxWidth);
361
+ }
362
+ // We use the `displayInput`, as the observer is fired for the initial state if someone is selecting an option
363
+ // and when the combobox size changes e.g. via window resize
364
+ this.resizeObserver.observe(this.displayInput);
365
+ }
366
+
244
367
  connectedCallback() {
245
368
  super.connectedCallback();
246
369
 
370
+ this.mutationObserver = new MutationObserver((entries) => {
371
+ // Only process attribute mutations on SynOption instances for the 'value' attribute. This is needed for changing of "delimiter"
372
+ const hasRelevantValueChange = entries.some(entry => {
373
+ // Check if the target is a SynOption instance
374
+ if (!(entry.target instanceof SynOption)) {
375
+ return false;
376
+ }
377
+
378
+ // Check if it's an attribute mutation for the 'value' attribute
379
+ if (entry.type !== 'attributes' || entry.attributeName !== 'value') {
380
+ return false;
381
+ }
382
+
383
+ // Check if the value actually changed and is not nullish
384
+ const currentValue = (entry.target as HTMLElement).getAttribute('value');
385
+ return entry.oldValue !== currentValue && !!currentValue;
386
+ });
387
+
388
+ if (hasRelevantValueChange) {
389
+ this.handleSlotContentChange();
390
+ }
391
+ });
392
+
393
+ this.mutationObserver.observe(this, {
394
+ attributeFilter: ['value'],
395
+ attributeOldValue: true,
396
+ attributes: true,
397
+ childList: true,
398
+ subtree: true,
399
+ });
400
+
401
+ setTimeout(() => {
402
+ // #813 needed to catch initial value via property binding
403
+ this.handleSlotContentChange();
404
+ });
405
+
247
406
  // Because this is a form control, it shouldn't be opened initially
248
407
  this.open = false;
249
408
  }
250
409
 
410
+ disconnectedCallback() {
411
+ super.disconnectedCallback();
412
+
413
+ this.resizeObserver?.disconnect();
414
+ this.mutationObserver?.disconnect();
415
+ this.removeOpenListeners();
416
+ }
417
+
251
418
  firstUpdated() {
252
419
  this.isInitialized = true;
253
420
  this.formControlController.updateValidity();
254
421
  }
255
422
 
423
+ protected updated(changedProperties: PropertyValues<this>) {
424
+ super.updated(changedProperties);
425
+ if (changedProperties.has('multiple')) {
426
+ if (!this.multiple) {
427
+ this.resizeObserver?.disconnect();
428
+ } else {
429
+ this.enableResizeObserver();
430
+ }
431
+ }
432
+ }
433
+
434
+ // eslint-disable-next-line complexity
256
435
  protected override willUpdate(changedProperties: PropertyValues) {
257
436
  super.willUpdate(changedProperties);
258
437
 
259
- if (!this.isInitialized && !this.defaultValue && this.value) {
260
- // If the value was set initially via property binding instead of attribute, we need to set
261
- // the defaultValue manually to be able to reset forms and the dynamic loading of options
262
- // are working correctly.
263
- this.defaultValue = this.value;
438
+ // Check for defaultValue if it is not undefined, null, empty string or empty array
439
+ const isDefaultValueEmpty = this.defaultValue == null
440
+ || this.defaultValue === ''
441
+ || (Array.isArray(this.defaultValue) && this.defaultValue.length === 0);
442
+
443
+ if (changedProperties.has('value') && isDefaultValueEmpty && this.value && !this.isUserInput) {
444
+ // If the value was set initially via property binding instead of attribute, we need to set the defaultValue manually
445
+ // to be able to reset forms and the dynamic loading of options are working correctly.
446
+ if (this.multiple && Array.isArray(this.value)) {
447
+ this.defaultValue = this.value.join(this.delimiter);
448
+ } else {
449
+ this.defaultValue = this.value;
450
+ }
451
+ this.valueHasChanged = false;
452
+ }
453
+
454
+ // This is needed, as the result otherwise is different on the attribute order of "multiple", "delimiter" and "value".
455
+ if (!this.isInitialized && changedProperties.has('value') && this.value !== undefined && changedProperties.has('multiple') && this.multiple) {
456
+ if (!Array.isArray(this.defaultValue)) {
457
+ const cachedValueHasChanged = this.valueHasChanged;
458
+ this.value = typeof this.defaultValue === 'string' ? this.defaultValue.split(this.delimiter) : [this.defaultValue].filter(isAllowedValue);
459
+
460
+ // Set it back to false since this isn't an interaction.
461
+ this.valueHasChanged = cachedValueHasChanged;
462
+ }
463
+ }
464
+ }
465
+
466
+ attributeChangedCallback(name: string, oldVal: string | null, newVal: string | null) {
467
+ super.attributeChangedCallback(name, oldVal, newVal);
468
+
469
+ /** This is a backwards compatibility call. In a new major version we should make a clean separation between "value" the attribute mapping to "defaultValue" property and "value" the property not reflecting. */
470
+ if (name === 'value') {
471
+ const cachedValueHasChanged = this.valueHasChanged;
472
+ this.value = this.defaultValue;
473
+
474
+ // Set it back to false since this isn't an interaction.
475
+ this.valueHasChanged = cachedValueHasChanged;
264
476
  }
265
477
  }
266
478
 
479
+ protected get tags() {
480
+ return this.selectedOptions.map((option, index) => {
481
+ if (index < this.maxOptionsVisible || this.maxOptionsVisible <= 0) {
482
+ const tag = this.getTag(option, index);
483
+ // Wrap so we can handle the remove
484
+ return html`<div @syn-remove=${(e: SynRemoveEvent) => this.handleTagRemove(e, option)}>
485
+ ${typeof tag === 'string' ? unsafeHTML(tag) : tag}
486
+ </div>`;
487
+ } if (index === this.maxOptionsVisible) {
488
+ // Hit tag limit
489
+ return html`<syn-tag size=${this.size}>+${this.selectedOptions.length - index}</syn-tag>`;
490
+ }
491
+ return html``;
492
+ });
493
+ }
494
+
267
495
  private addOpenListeners() {
268
496
  //
269
497
  // Listen on the root node instead of the document in case the elements are inside a shadow root
@@ -271,7 +499,6 @@ export default class SynCombobox extends SynergyElement implements SynergyFormCo
271
499
  // https://github.com/synergy-design-system/synergy/issues/1763
272
500
  //
273
501
  document.addEventListener('focusin', this.handleDocumentFocusIn);
274
- document.addEventListener('keydown', this.handleDocumentKeyDown);
275
502
  document.addEventListener('mousedown', this.handleDocumentMouseDown);
276
503
 
277
504
  // If the component is rendered in a shadow root,
@@ -295,7 +522,6 @@ export default class SynCombobox extends SynergyElement implements SynergyFormCo
295
522
 
296
523
  private removeOpenListeners() {
297
524
  document.removeEventListener('focusin', this.handleDocumentFocusIn);
298
- document.removeEventListener('keydown', this.handleDocumentKeyDown);
299
525
  document.removeEventListener('mousedown', this.handleDocumentMouseDown);
300
526
 
301
527
  if (this.getRootNode() !== document) {
@@ -342,11 +568,17 @@ export default class SynCombobox extends SynergyElement implements SynergyFormCo
342
568
  this.hide();
343
569
  this.displayInput.focus({ preventScroll: true });
344
570
  } else if (!this.open) {
345
- this.clearCombobox();
571
+ if (this.multiple) {
572
+ // In multiple mode, only clear the input field but preserve selected options
573
+ this.clearInputField();
574
+ } else {
575
+ // In single mode, clear everything as before
576
+ this.clearCombobox();
577
+ }
346
578
  }
347
579
  }
348
580
 
349
- // Handle enter.
581
+ // Handle enter - either select option or submit form
350
582
  if (event.key === 'Enter') {
351
583
  const currentOption = this.getCurrentOption();
352
584
 
@@ -368,11 +600,26 @@ export default class SynCombobox extends SynergyElement implements SynergyFormCo
368
600
 
369
601
  // Update the value based on the current selection and close it
370
602
  if (currentOption) {
371
- const oldValue = this.lastOption ? getValueFromOption(this.lastOption) : undefined;
372
- this.updateSelectedOptionsCacheAndValue(currentOption);
603
+ this.isUserInput = true;
604
+ this.valueHasChanged = true;
605
+ const oldValue = this.lastOptions ? getValuesFromOptions(this.lastOptions) : [];
606
+
607
+ if (this.multiple) {
608
+ this.toggleOptionSelection(currentOption);
609
+ } else {
610
+ this.setSelectedOptions(currentOption);
611
+ }
612
+
613
+ this.selectionChanged();
373
614
 
374
- if (this.value !== oldValue) {
375
- this.setSelectedOptionToSelected();
615
+ const value = Array.isArray(this.value) ? this.value : [this.value];
616
+
617
+ // Make reset in forms work correctly via isUserInput flag
618
+ this.updateComplete.then(() => {
619
+ this.isUserInput = false;
620
+ });
621
+
622
+ if (!compareValues(oldValue, value)) {
376
623
  // Emit after updating
377
624
  this.updateComplete.then(() => {
378
625
  this.emit('syn-input');
@@ -381,7 +628,9 @@ export default class SynCombobox extends SynergyElement implements SynergyFormCo
381
628
  }
382
629
  }
383
630
 
384
- this.hide();
631
+ if (!this.multiple) {
632
+ this.hide();
633
+ }
385
634
  this.displayInput.focus({ preventScroll: true });
386
635
  return;
387
636
  }
@@ -408,8 +657,8 @@ export default class SynCombobox extends SynergyElement implements SynergyFormCo
408
657
  this.displayInput.setSelectionRange(0, 0);
409
658
  } else if (event.key === 'End') {
410
659
  this.displayInput.setSelectionRange(
411
- this.displayInput.value.length,
412
- this.displayInput.value.length,
660
+ this.displayLabel.length,
661
+ this.displayLabel.length,
413
662
  );
414
663
  }
415
664
  }
@@ -429,9 +678,30 @@ export default class SynCombobox extends SynergyElement implements SynergyFormCo
429
678
  this.displayInput.focus();
430
679
  }
431
680
 
681
+ private handleTagRemove(event: SynRemoveEvent, option: SynOption) {
682
+ event.stopPropagation();
683
+
684
+ this.valueHasChanged = true;
685
+
686
+ if (!this.disabled) {
687
+ this.toggleOptionSelection(option, false);
688
+ this.selectionChanged();
689
+
690
+ // Emit after updating
691
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
692
+ this.updateComplete.then(() => {
693
+ this.emit('syn-input');
694
+ this.emit('syn-change');
695
+ });
696
+ }
697
+ }
698
+
432
699
  private handleComboboxMouseDown(event: MouseEvent) {
433
- // Ignore disabled controls
434
- if (this.disabled) {
700
+ const path = event.composedPath();
701
+ const isIconButton = path.some(el => el instanceof Element && el.tagName.toLowerCase() === 'syn-icon-button');
702
+
703
+ // Ignore disabled controls and clicks on tags (remove buttons)
704
+ if (this.disabled || isIconButton) {
435
705
  return;
436
706
  }
437
707
 
@@ -457,14 +727,30 @@ export default class SynCombobox extends SynergyElement implements SynergyFormCo
457
727
  this.clearCombobox();
458
728
  }
459
729
 
730
+ private clearInputField() {
731
+ if (this.displayLabel !== '') {
732
+ const cachedValueHasChanged = this.valueHasChanged;
733
+ // remove the text from the input field only by reset value to selected options
734
+ this.value = getValuesFromOptions(this.selectedOptions);
735
+ this.valueHasChanged = cachedValueHasChanged;
736
+ this.displayInput.focus({ preventScroll: true });
737
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
738
+ this.updateComplete.then(() => {
739
+ this.emit('syn-input');
740
+ });
741
+ }
742
+ }
743
+
460
744
  private clearCombobox() {
745
+ this.valueHasChanged = true;
746
+
461
747
  if (this.value !== '') {
462
748
  this.value = '';
463
- this.displayInput.value = '';
464
- this.lastOption = undefined;
465
- this.updateSelectedOptionsCacheAndValue(undefined);
749
+ this.displayLabel = '';
750
+ this.lastOptions = [];
751
+ this.setSelectedOptions([]);
752
+ this.selectionChanged();
466
753
  this.displayInput.focus({ preventScroll: true });
467
- this.setSelectedOptionToSelected();
468
754
 
469
755
  // Emit after update
470
756
  // eslint-disable-next-line @typescript-eslint/no-floating-promises
@@ -487,15 +773,28 @@ export default class SynCombobox extends SynergyElement implements SynergyFormCo
487
773
  private handleOptionClick(event: MouseEvent) {
488
774
  const target = event.target as HTMLElement;
489
775
  const option = target.closest('syn-option');
490
- const oldValue = this.lastOption ? getValueFromOption(this.lastOption) : undefined;
776
+ const oldValue = this.lastOptions ? getValuesFromOptions(this.lastOptions) : [];
491
777
  if (option && !option.disabled) {
492
- this.updateSelectedOptionsCacheAndValue(option);
778
+ this.isUserInput = true;
779
+
780
+ this.valueHasChanged = true;
781
+ if (this.multiple) {
782
+ this.toggleOptionSelection(option);
783
+ } else {
784
+ this.setSelectedOptions(option);
785
+ }
786
+ this.selectionChanged();
493
787
 
494
788
  // Set focus after updating so the value is announced by screen readers
495
- this.updateComplete.then(() => this.displayInput.focus({ preventScroll: true }));
789
+ // and make the reset in forms work correctly via isUserInput flag
790
+ this.updateComplete.then(() => {
791
+ this.displayInput.focus({ preventScroll: true });
792
+ this.isUserInput = false;
793
+ });
794
+
795
+ const value = Array.isArray(this.value) ? this.value : [this.value];
496
796
 
497
- if (this.value !== oldValue) {
498
- this.setSelectedOptionToSelected();
797
+ if (!compareValues(oldValue, value)) {
499
798
  // Emit after updating
500
799
  this.updateComplete.then(() => {
501
800
  this.emit('syn-input');
@@ -503,8 +802,10 @@ export default class SynCombobox extends SynergyElement implements SynergyFormCo
503
802
  });
504
803
  }
505
804
 
506
- this.hide();
507
- this.displayInput.focus({ preventScroll: true });
805
+ if (!this.multiple) {
806
+ this.hide();
807
+ this.displayInput.focus({ preventScroll: true });
808
+ }
508
809
  }
509
810
  }
510
811
  /* eslint-enable @typescript-eslint/no-floating-promises */
@@ -537,6 +838,44 @@ export default class SynCombobox extends SynergyElement implements SynergyFormCo
537
838
  scrollIntoView(this.getCurrentOption()!, this.listbox, 'vertical', 'auto');
538
839
  }
539
840
 
841
+ // Toggles an option's selected state
842
+ // eslint-disable-next-line class-methods-use-this
843
+ private toggleOptionSelection(option: SynOption, force?: boolean) {
844
+ if (force === true || force === false) {
845
+ option.selected = force;
846
+ } else {
847
+ option.selected = !option.selected;
848
+ }
849
+
850
+ // This is needed so the highlight option renderer is working correctly for `restricted` and `multiple` comboboxes
851
+ const cachedOption = this.cachedOptions.find(opt => opt.id === option.id);
852
+ if (cachedOption) {
853
+ cachedOption.selected = option.selected;
854
+ }
855
+ }
856
+
857
+ // Sets the selected option(s)
858
+ private setSelectedOptions(option: SynOption | SynOption[]) {
859
+ const newSelectedOptions = Array.isArray(option) ? option : [option];
860
+ // In single-select mode, if multiple options are provided, keep only the first one to select
861
+ if (!this.multiple && newSelectedOptions.length > 1) {
862
+ newSelectedOptions.splice(1);
863
+ }
864
+
865
+ const slottedOptions = this.getSlottedOptions();
866
+ slottedOptions.forEach((opt) => {
867
+ opt.selected = newSelectedOptions.some(
868
+ selectedOpt => selectedOpt.id === opt.id,
869
+ );
870
+ });
871
+ // This is needed so the highlight option renderer is working correctly for `restricted` and `multiple` comboboxes
872
+ this.cachedOptions.forEach((opt) => {
873
+ opt.selected = newSelectedOptions.some(
874
+ selectedOpt => selectedOpt.id === opt.id,
875
+ );
876
+ });
877
+ }
878
+
540
879
  private getAllFilteredOptions() {
541
880
  return this.getSlottedOptions().filter(option => !option.hidden);
542
881
  }
@@ -569,47 +908,68 @@ export default class SynCombobox extends SynergyElement implements SynergyFormCo
569
908
  }
570
909
 
571
910
  /**
572
- * Updates the selected options cache, the current value, and the display value
911
+ * Updates the component state after selection changes.
912
+ *
913
+ * This method synchronizes:
914
+ * 1. The selectedOptions cache with currently selected options
915
+ * 2. The component's value property (string or array)
916
+ * 3. The display label shown in the input
917
+ * 4. Form validation state
918
+ *
919
+ * **Validation Logic:**
920
+ * - In restricted mode, invalid values trigger a reset to last valid state
921
+ * - Multiple mode requires all values to correspond to existing options
922
+ * - Single mode allows free text input when not restricted
573
923
  */
574
924
  // eslint-disable-next-line complexity
575
- private updateSelectedOptionsCacheAndValue(option: SynOption | undefined) {
576
- this.selectedOption = option;
925
+ private selectionChanged() {
926
+ const options = this.getSlottedOptions();
927
+ this.selectedOptions = options.filter(opt => opt.selected);
928
+
929
+ // This is needed if there are no valid options and therefore just the typed in value should be displayed
930
+ if (this.selectedOptions.length === 0) {
931
+ this.displayLabel = Array.isArray(this.value) ? this.value.join(', ') : String(this.value);
932
+ }
577
933
 
578
934
  let optionValue;
579
935
 
580
- if (option) {
581
- this.lastOption = option;
582
- optionValue = String(getValueFromOption(option));
583
- } else if (this.restricted && !this.isValidValue() && this.value !== '' && !this.isUserInput) {
584
- // if an invalid value was set via property binding for `restricted`comboboxes,
585
- // reset to last valid value
586
- this.resetToLastValidValue();
587
- return;
936
+ // Keep a reference to the previous `valueHasChanged`. Changes made here don't count has changing the value.
937
+ const cachedValueHasChanged = this.valueHasChanged;
938
+
939
+ if (this.multiple) {
940
+ this.value = this.selectedOptions.map(opt => getValueFromOption(opt));
941
+ if (this.value.length === 0 && this.selectedOptions.length !== 0) {
942
+ this.valueHasChanged = cachedValueHasChanged;
943
+ this.resetToLastValidValue();
944
+ return;
945
+ }
946
+ } else {
947
+ if (this.selectedOptions.length !== 0) {
948
+ // This is only for non multiple
949
+ optionValue = getValueFromOption(this.selectedOptions[0]);
950
+ } else if (this.restricted && !this.isValidValue(this.displayLabel) && this.displayLabel !== '' && !this.isUserInput) {
951
+ // if an invalid value was set via property binding for `restricted`comboboxes,
952
+ // reset to last valid value
953
+ this.resetToLastValidValue();
954
+ this.valueHasChanged = cachedValueHasChanged;
955
+ return;
956
+ }
957
+ this.value = optionValue ?? this.displayLabel;
588
958
  }
589
959
 
590
- // Update the value
591
- this.value = optionValue ?? this.displayInput.value;
960
+ this.valueHasChanged = cachedValueHasChanged;
961
+
962
+ // Store the new last selected options
963
+ this.lastOptions = [...this.selectedOptions];
592
964
 
593
965
  // Update validity and display label
594
966
  // eslint-disable-next-line @typescript-eslint/no-floating-promises
595
967
  this.updateComplete.then(() => {
596
- this.displayLabel = this.selectedOption?.getTextLabel() ?? this.displayInput.value;
968
+ this.displayLabel = this.multiple ? '' : this.selectedOptions[0]?.getTextLabel() ?? this.displayLabel;
597
969
  this.formControlController.updateValidity();
598
970
  });
599
971
  }
600
972
 
601
- private setSelectedOptionToSelected() {
602
- const slottedOptions = this.getSlottedOptions();
603
- slottedOptions.forEach((opt) => {
604
- // eslint-disable-next-line no-param-reassign
605
- opt.selected = false;
606
- });
607
-
608
- if (this.selectedOption) {
609
- this.selectedOption.selected = true;
610
- }
611
- }
612
-
613
973
  private handleInvalid(event: Event) {
614
974
  this.formControlController.setValidity(false);
615
975
  this.formControlController.emitInvalidEvent(event);
@@ -617,7 +977,18 @@ export default class SynCombobox extends SynergyElement implements SynergyFormCo
617
977
 
618
978
  @watch(['filter', 'getOption'], { waitUntilFirstUpdate: true })
619
979
  handlePropertiesChange() {
620
- this.createComboboxOptionsFromQuery(this.value);
980
+ this.createComboboxOptionsFromQuery(this.displayLabel);
981
+ if (this.open) {
982
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
983
+ this.updateComplete.then(() => {
984
+ this.open = this.multiple || this.restricted || this.numberFilteredOptions > 0;
985
+ });
986
+ }
987
+ }
988
+
989
+ @watch('displayLabel', { waitUntilFirstUpdate: true })
990
+ handleDisplayInputValueChange() {
991
+ this.createComboboxOptionsFromQuery(this.displayLabel);
621
992
  }
622
993
 
623
994
  @watch('disabled', { waitUntilFirstUpdate: true })
@@ -633,17 +1004,31 @@ export default class SynCombobox extends SynergyElement implements SynergyFormCo
633
1004
  }
634
1005
  }
635
1006
 
636
- @watch('value', { waitUntilFirstUpdate: true })
1007
+ @watch('delimiter')
1008
+ handleDelimiterChange() {
1009
+ this.getSlottedOptions().forEach(option => {
1010
+ option.delimiter = this.delimiter;
1011
+ });
1012
+ }
1013
+
1014
+ @watch(['defaultValue', 'value', 'delimiter', 'multiple', 'restricted'], { waitUntilFirstUpdate: true })
637
1015
  handleValueChange() {
1016
+ if (!this.valueHasChanged) {
1017
+ const cachedValueHasChanged = this.valueHasChanged;
1018
+ this.value = this.defaultValue;
1019
+
1020
+ // Set it back to false since this isn't an interaction.
1021
+ this.valueHasChanged = cachedValueHasChanged;
1022
+ }
1023
+
638
1024
  this.updateSelectedOptionFromValue();
639
- this.setCurrentOption(null);
640
1025
  }
641
1026
 
642
1027
  @watch('open', { waitUntilFirstUpdate: true })
643
1028
  async handleOpenChange() {
644
1029
  if (this.open && !this.disabled) {
645
- if (this.numberFilteredOptions === 0 && !this.restricted) {
646
- // Don't open the listbox if there are no options and it is not restricted
1030
+ if (this.numberFilteredOptions === 0 && !this.restricted && !this.multiple) {
1031
+ // Don't open the listbox if there are no options and it is not restricted or multiple
647
1032
  this.open = false;
648
1033
  this.emit('syn-error');
649
1034
  return;
@@ -738,32 +1123,53 @@ export default class SynCombobox extends SynergyElement implements SynergyFormCo
738
1123
  this.displayInput.blur();
739
1124
  }
740
1125
 
1126
+ /**
1127
+ * Updates the visibility and rendering of options based on the current query string.
1128
+ *
1129
+ * This method performs several critical tasks:
1130
+ * 1. **Option Filtering**: Uses the `filter` function to determine which options should be visible
1131
+ * 2. **Custom Rendering**: Applies the `getOption` function to customize option appearance
1132
+ * 3. **Optgroup Management**: Shows/hides optgroups based on their visible children
1133
+ * 4. **Counts visible options**: Tracks the number of visible options for UI logic
1134
+ *
1135
+ * **Performance Considerations:**
1136
+ * - Uses cached options to avoid repeated DOM queries
1137
+ * - Prevents infinite loops during option updates with `isOptionRendererTriggered`
1138
+ *
1139
+ * @param queryString - The current user input to filter and highlight options with
1140
+ */
741
1141
  /* eslint-disable no-param-reassign, @typescript-eslint/no-floating-promises */
742
1142
  private createComboboxOptionsFromQuery(queryString: string) {
743
1143
  this.numberFilteredOptions = 0;
1144
+
1145
+ // Prevent slot change events during option updates to avoid infinite loops
744
1146
  this.isOptionRendererTriggered = true;
745
1147
 
746
- // This is needed for angular. For some reason the handleDefaultSlotChange is not triggered
1148
+ // This is needed for angular. For some reason the handleSlotContentChange is not triggered
747
1149
  // initially and therefore the cachedOptions are not set.
748
1150
  if (this.cachedOptions.length === 0) {
749
1151
  this.cacheSlottedOptionsAndOptgroups();
750
1152
  }
751
1153
 
752
- // Update the syn-option's based on the query string and getOption
1154
+ // Update each syn-option based on the query string and custom getOption renderer
753
1155
  this.getSlottedOptions()
754
1156
  .forEach(option => {
755
1157
  // Use the original cached option, to do changes on it
756
1158
  const cachedOption = this.cachedOptions.find(o => o.id === option.id) || option;
1159
+
1160
+ // Apply custom option rendering
757
1161
  const optionResult = this.getOption(cachedOption, queryString);
758
1162
  let updatedOption = createOptionFromDifferentTypes(optionResult);
759
1163
 
760
- // The type of the getOption function is incorrect, therefore use the original option
1164
+ // Fall back to original option if rendering fails
761
1165
  if (!updatedOption) {
762
1166
  updatedOption = cachedOption;
763
1167
  }
764
1168
 
1169
+ // Apply filtering logic to determine visibility
765
1170
  const hideOption = !(this.filter(updatedOption, queryString) || queryString === '');
766
1171
  updatedOption.hidden = hideOption;
1172
+
767
1173
  option.replaceWith(updatedOption);
768
1174
 
769
1175
  if (!hideOption) {
@@ -771,7 +1177,7 @@ export default class SynCombobox extends SynergyElement implements SynergyFormCo
771
1177
  }
772
1178
  });
773
1179
 
774
- // Update the syn-optgroup's if available
1180
+ // Update optgroup visibility based on their children
775
1181
  const visibleOptgroups = this.getSlottedOptGroups().filter(optgroup => {
776
1182
  const options = getAllOptions(Array.from(optgroup.children) as HTMLElement[]).flat();
777
1183
  const isVisible = options.some(option => !option.hidden);
@@ -782,45 +1188,75 @@ export default class SynCombobox extends SynergyElement implements SynergyFormCo
782
1188
  // Hide the divider of the first visible optgroup, as it can unfortunately not be hidden via css
783
1189
  visibleOptgroups[0]?.style.setProperty('--display-divider', 'none');
784
1190
 
785
- // This is needed so the component does not endlessly rerender,
786
- // because the slot change event is endlessly retriggered otherwise.
1191
+ // Reset flag after updates are complete to re-enable slot change handling
787
1192
  setTimeout(() => {
788
1193
  this.isOptionRendererTriggered = false;
789
- }, 0);
1194
+ });
790
1195
  }
791
1196
  /* eslint-enable no-param-reassign, @typescript-eslint/no-floating-promises */
792
1197
 
1198
+ // eslint-disable-next-line complexity
793
1199
  private async handleInput() {
794
1200
  const inputValue = this.displayInput.value;
795
- const cachedLastOption = this.lastOption;
1201
+ this.displayLabel = inputValue;
1202
+ const cachedLastOption = this.lastOptions;
796
1203
  this.isUserInput = true;
797
1204
 
798
- this.value = inputValue;
1205
+ // Do the reset of the selected options before the value setting, as otherwise we are getting endless default slot triggering in safari
1206
+ if (!this.multiple) {
1207
+ this.selectedOptions = [];
1208
+ }
1209
+
1210
+ if (this.multiple) {
1211
+ // In multiple mode, combine selected option values with current input
1212
+ const validValues = getValuesFromOptions(this.selectedOptions);
1213
+ this.value = [...validValues, inputValue];
1214
+ } else {
1215
+ // In single mode, replace value with current input
1216
+ this.value = inputValue;
1217
+ }
1218
+
799
1219
  await this.updateComplete;
800
1220
  this.isUserInput = false;
801
- this.lastOption = cachedLastOption;
802
- this.open = this.restricted || this.numberFilteredOptions > 0;
803
- this.selectedOption = undefined;
1221
+ this.lastOptions = cachedLastOption;
1222
+ this.open = this.multiple || this.restricted || this.numberFilteredOptions > 0;
1223
+
804
1224
  this.formControlController.updateValidity();
805
1225
  this.emit('syn-input');
806
1226
  }
807
1227
 
808
1228
  /**
809
- * Checks if the current value is available in the options list.
1229
+ * Checks if the value is available in the options list.
810
1230
  * This is used to determine if the value is valid when the combobox is restricted.
811
- *
1231
+ * @param value - The value to check for validity.
812
1232
  * @returns `true` if the current value is available in the options list,
813
1233
  * otherwise `false`.
814
1234
  */
815
- private isValidValue(): boolean {
1235
+ private isValidValue(value: string | number): boolean {
816
1236
  const isValid = this.cachedOptions.some(
817
- option => getValueFromOption(option) === this.value,
1237
+ option => checkValueBelongsToOption(value, option),
818
1238
  );
819
1239
  return isValid;
820
1240
  }
821
1241
 
822
- private getOptionFromValue(): SynOption | undefined {
823
- return this.cachedOptions.find(option => checkValueBelongsToOption(this.value, option));
1242
+ private getOptionsFromValue(): SynOption[] {
1243
+ // Use defaultValue only if the value has not been changed by the user
1244
+ const value = this.valueHasChanged ? this.value : this.defaultValue;
1245
+ // #845: if value is undefined return empty array
1246
+ let convertedValue: (string | number)[];
1247
+ if (Array.isArray(value)) {
1248
+ convertedValue = value;
1249
+ } else if (value === undefined || value == null) {
1250
+ convertedValue = [];
1251
+ } else if (this.multiple && typeof value === 'string') {
1252
+ convertedValue = value.split(this.delimiter);
1253
+ } else {
1254
+ convertedValue = [value];
1255
+ }
1256
+
1257
+ return convertedValue
1258
+ .map((val) => this.cachedOptions.find(option => checkValueBelongsToOption(val, option)))
1259
+ .filter((opt) => opt !== undefined);
824
1260
  }
825
1261
 
826
1262
  /**
@@ -828,11 +1264,14 @@ export default class SynCombobox extends SynergyElement implements SynergyFormCo
828
1264
  */
829
1265
  private resetToLastValidValue() {
830
1266
  let label = '';
831
- let value = '';
1267
+ let value: Array<string | number> = [];
832
1268
 
833
- if (this.lastOption) {
834
- value = String(getValueFromOption(this.lastOption));
835
- label = this.lastOption.getTextLabel();
1269
+ if (this.lastOptions.length !== 0) {
1270
+ value = getValuesFromOptions(this.lastOptions);
1271
+
1272
+ if (!this.multiple) {
1273
+ label = this.lastOptions[0].getTextLabel();
1274
+ }
836
1275
  }
837
1276
 
838
1277
  // Wait for the popup close animation to be finished before updating the value.
@@ -847,40 +1286,68 @@ export default class SynCombobox extends SynergyElement implements SynergyFormCo
847
1286
  )
848
1287
  : Promise.resolve();
849
1288
 
1289
+ this.hideOptions = true;
1290
+
850
1291
  // eslint-disable-next-line @typescript-eslint/no-floating-promises
851
1292
  waitForAnimations.then(() => {
852
- this.value = value;
853
- this.displayInput.value = label;
854
- this.formControlController.updateValidity();
1293
+ // restore options after the animation
1294
+ this.hideOptions = false;
855
1295
  });
1296
+ // Keep a reference to the previous `valueHasChanged`. Changes made here don't count has changing the value.
1297
+ const cachedValueHasChanged = this.valueHasChanged;
1298
+ this.value = value;
1299
+ this.displayLabel = label;
1300
+ this.formControlController.updateValidity();
1301
+ this.valueHasChanged = cachedValueHasChanged;
856
1302
  }
857
1303
 
1304
+ // eslint-disable-next-line complexity
858
1305
  private handleChange() {
859
1306
  // Only update the value and emit the event, if the change event occurred by
860
1307
  // the user typing something in and removing focus of the combobox
861
- if (this.selectedOption) {
1308
+ const isSameSelection = (this.selectedOptions.length !== 0 && this.selectedOptions.length === this.getOptionsFromValue().length);
1309
+ let values: (string | number)[];
1310
+ if (Array.isArray(this.value)) {
1311
+ values = this.value;
1312
+ } else if (typeof this.value === 'string') {
1313
+ values = this.value.split(this.delimiter);
1314
+ } else {
1315
+ values = [this.value];
1316
+ }
1317
+ const allValuesValid = values.every((val) => this.isValidValue(val));
1318
+ const isMultipleAndUserInput = this.multiple && isSameSelection && allValuesValid;
1319
+ const isNonMultipleAndUserInput = !this.multiple && this.selectedOptions.length > 0;
1320
+ if (isNonMultipleAndUserInput || isMultipleAndUserInput) {
862
1321
  return;
863
1322
  }
864
1323
 
1324
+ const oldValue = this.lastOptions ? getValuesFromOptions(this.lastOptions) : [];
1325
+
865
1326
  // If the value is not valid, we need to reset the value to the last valid value
866
- if (this.restricted && !this.isValidValue() && this.value !== '') {
1327
+ if ((this.restricted || this.multiple) && !this.isValidValue(this.displayLabel) && this.displayLabel !== '') {
867
1328
  this.resetToLastValidValue();
868
1329
  return;
869
1330
  }
1331
+ const options = this.getOptionsFromValue();
870
1332
 
871
- // Otherwise, update value from input and emit change
872
- this.value = this.displayInput.value;
873
- this.lastOption = this.getOptionFromValue();
874
- this.selectedOption = this.getOptionFromValue();
1333
+ this.setSelectedOptions(options);
1334
+ this.selectionChanged();
1335
+ this.lastOptions = [...options];
875
1336
  // eslint-disable-next-line @typescript-eslint/no-floating-promises
876
1337
  this.updateComplete.then(() => {
877
1338
  this.formControlController.updateValidity();
878
1339
  });
879
- this.setSelectedOptionToSelected();
880
- this.emit('syn-change');
1340
+
1341
+ const value = Array.isArray(this.value) ? this.value : [this.value];
1342
+ if (!compareValues(oldValue, value)) {
1343
+ this.emit('syn-change');
1344
+ }
881
1345
  }
882
1346
 
883
1347
  private getSlottedOptions() {
1348
+ if (!this.defaultSlot) {
1349
+ return [];
1350
+ }
884
1351
  return getAllOptions(getAssignedElementsForSlot(this.defaultSlot)).flat();
885
1352
  }
886
1353
 
@@ -907,44 +1374,81 @@ export default class SynCombobox extends SynergyElement implements SynergyFormCo
907
1374
  /* eslint-enable no-param-reassign */
908
1375
 
909
1376
  private updateSelectedOptionFromValue(): void {
910
- // check if the value has a corresponding option via value or text content
911
- // for empty values use the text content, as then the values of the option are not set
912
- const option = this.getOptionFromValue();
1377
+ if (!this.isUserInput) {
1378
+ // check if the value has corresponding options via value or text content
1379
+ // for empty values use the text content, as then the values of the options are not set
1380
+ const options = this.getOptionsFromValue();
913
1381
 
914
- if (!option) {
915
- this.displayInput.value = this.value;
1382
+ this.setSelectedOptions(options);
1383
+ this.selectionChanged();
916
1384
  }
917
1385
 
918
- this.updateSelectedOptionsCacheAndValue(option);
919
- this.createComboboxOptionsFromQuery(this.value);
1386
+ let queryString = '';
1387
+ if (this.multiple) {
1388
+ queryString = this.displayLabel;
1389
+ } else {
1390
+ queryString = Array.isArray(this.value) ? this.value.join(', ') : String(this.value);
1391
+ }
1392
+ this.createComboboxOptionsFromQuery(queryString);
920
1393
  }
921
1394
 
922
- /* eslint-disable @typescript-eslint/no-floating-promises, complexity */
923
- /* @internal - used by options to update labels */
924
- public handleDefaultSlotChange() {
925
- // We need to check if the slotChange is triggered by our own changes we do to the already
926
- // slotted options or because new options were slotted into the combobox
927
- const slottedOptions = this.getSlottedOptions();
928
- const optionsLength = slottedOptions.length;
929
- const cachedOptionsLength = this.cachedOptions.length;
1395
+ /**
1396
+ * Synchronizes the internal component state with changes to slotted options.
1397
+ *
1398
+ * This method is automatically triggered by:
1399
+ * - MutationObserver when option 'value' attributes change
1400
+ * - Initial component setup during connectedCallback (after timeout)
1401
+ * - Default slot changes (via handleDefaultSlotChange)
1402
+ * - Custom element registration completion for syn-option (deferred execution)
1403
+ *
1404
+ * The synchronization process:
1405
+ * 1. Waits for syn-option custom elements to be registered before processing
1406
+ * 2. Updates delimiter settings on all slotted options
1407
+ * 3. Refreshes the internal cache of options and optgroups
1408
+ * 4. Synchronizes selected options based on current component value
1409
+ * 5. Auto-opens listbox if component has focus, has value, and is currently closed
1410
+ *
1411
+ * This ensures the component's internal state stays consistent with the slotted
1412
+ * DOM content after options are added, removed, or their values change.
1413
+ *
1414
+ */
1415
+ /* eslint-disable @typescript-eslint/no-floating-promises */
1416
+ private handleSlotContentChange() {
1417
+ // Rerun this handler when <syn-option> is registered
1418
+ if (!customElements.get('syn-option')) {
1419
+ customElements.whenDefined('syn-option').then(() => this.handleSlotContentChange());
1420
+ return;
1421
+ }
930
1422
 
931
- if (!this.isOptionRendererTriggered || cachedOptionsLength !== optionsLength) {
932
- // Rerun this handler when <syn-option> is registered
933
- if (!customElements.get('syn-option')) {
934
- customElements.whenDefined('syn-option').then(() => this.handleDefaultSlotChange());
935
- return;
936
- }
1423
+ this.handleDelimiterChange();
1424
+ this.cacheSlottedOptionsAndOptgroups();
937
1425
 
938
- this.cacheSlottedOptionsAndOptgroups();
1426
+ this.updateSelectedOptionFromValue();
939
1427
 
940
- this.updateSelectedOptionFromValue();
1428
+ // Auto-open listbox for better UX when new options are added during interaction
1429
+ let hasValue: boolean;
1430
+ if (Array.isArray(this.value)) {
1431
+ hasValue = this.value.length > 0;
1432
+ } else if (typeof this.value === 'string') {
1433
+ hasValue = this.value.length > 0;
1434
+ } else {
1435
+ hasValue = this.value !== undefined && this.value !== null;
1436
+ }
941
1437
 
942
- if (this.hasFocus && this.value.length > 0 && !this.open) {
943
- this.show();
944
- }
1438
+ if (this.hasFocus && hasValue && !this.open) {
1439
+ this.show();
1440
+ }
1441
+ }
1442
+ /* eslint-enable @typescript-eslint/no-floating-promises */
1443
+
1444
+ public handleDefaultSlotChange() {
1445
+ // Ignore slot changes triggered by our own option updates
1446
+ if (this.isOptionRendererTriggered) {
1447
+ return;
945
1448
  }
1449
+
1450
+ this.handleSlotContentChange();
946
1451
  }
947
- /* eslint-enable @typescript-eslint/no-floating-promises, complexity */
948
1452
 
949
1453
  /* eslint-disable @typescript-eslint/unbound-method */
950
1454
  // eslint-disable-next-line complexity
@@ -953,20 +1457,30 @@ export default class SynCombobox extends SynergyElement implements SynergyFormCo
953
1457
  const hasHelpTextSlot = this.hasSlotController.test('help-text');
954
1458
  const hasLabel = this.label ? true : !!hasLabelSlot;
955
1459
  const hasHelpText = this.helpText ? true : !!hasHelpTextSlot;
956
- const hasClearIcon = this.clearable && !this.disabled && this.value.length > 0;
957
- const isPlaceholderVisible = this.placeholder && this.value.length === 0;
1460
+ let hasValue: boolean;
1461
+ if (Array.isArray(this.value)) {
1462
+ hasValue = this.value.length > 0;
1463
+ } else if (typeof this.value === 'string') {
1464
+ hasValue = this.value.length > 0;
1465
+ } else {
1466
+ hasValue = this.value !== undefined && this.value !== null && typeof this.value === 'number';
1467
+ }
1468
+
1469
+ const hasClearIcon = this.clearable && !this.disabled && hasValue;
1470
+ const isPlaceholderVisible = this.placeholder && !hasValue;
1471
+ const tagsVisible = this.multiple && this.selectedOptions.length > 0;
958
1472
 
959
1473
  return html`
960
1474
  <div
961
1475
  part="form-control"
962
1476
  class=${classMap({
963
- 'form-control': true,
964
- 'form-control--has-help-text': hasHelpText,
965
- 'form-control--has-label': hasLabel,
966
- 'form-control--large': this.size === 'large',
967
- 'form-control--medium': this.size === 'medium',
968
- 'form-control--small': this.size === 'small',
969
- })}
1477
+ 'form-control': true,
1478
+ 'form-control--has-help-text': hasHelpText,
1479
+ 'form-control--has-label': hasLabel,
1480
+ 'form-control--large': this.size === 'large',
1481
+ 'form-control--medium': this.size === 'medium',
1482
+ 'form-control--small': this.size === 'small',
1483
+ })}
970
1484
  >
971
1485
  <label
972
1486
  id="label"
@@ -981,18 +1495,20 @@ export default class SynCombobox extends SynergyElement implements SynergyFormCo
981
1495
  <div part="form-control-input" class="form-control-input">
982
1496
  <syn-popup
983
1497
  class=${classMap({
984
- combobox: true,
985
- 'combobox--bottom': this.placement === 'bottom',
986
- 'combobox--disabled': this.disabled,
987
- 'combobox--focused': this.hasFocus,
988
- 'combobox--large': this.size === 'large',
989
- 'combobox--medium': this.size === 'medium',
990
- 'combobox--open': this.open,
991
- 'combobox--placeholder-visible': isPlaceholderVisible,
992
- 'combobox--small': this.size === 'small',
993
- 'combobox--standard': true,
994
- 'combobox--top': this.placement === 'top',
995
- })}
1498
+ combobox: true,
1499
+ 'combobox--bottom': this.placement === 'bottom',
1500
+ 'combobox--disabled': this.disabled,
1501
+ 'combobox--focused': this.hasFocus,
1502
+ 'combobox--large': this.size === 'large',
1503
+ 'combobox--medium': this.size === 'medium',
1504
+ 'combobox--multiple': this.multiple,
1505
+ 'combobox--open': this.open,
1506
+ 'combobox--placeholder-visible': isPlaceholderVisible,
1507
+ 'combobox--small': this.size === 'small',
1508
+ 'combobox--standard': true,
1509
+ 'combobox--tags-visible': tagsVisible,
1510
+ 'combobox--top': this.placement === 'top',
1511
+ })}
996
1512
  placement=${`${this.placement}-start`}
997
1513
  flip
998
1514
  shift
@@ -1010,6 +1526,8 @@ export default class SynCombobox extends SynergyElement implements SynergyFormCo
1010
1526
  >
1011
1527
  <slot part="prefix" name="prefix" class="combobox__prefix"></slot>
1012
1528
 
1529
+ ${this.multiple ? html`<div part="tags" class="combobox__tags">${this.tags}</div>` : ''}
1530
+
1013
1531
  <input
1014
1532
  part="display-input"
1015
1533
  class="combobox__display-input"
@@ -1042,7 +1560,7 @@ export default class SynCombobox extends SynergyElement implements SynergyFormCo
1042
1560
  type="text"
1043
1561
  ?disabled=${this.disabled}
1044
1562
  ?required=${this.required}
1045
- .value=${this.value}
1563
+ .value=${Array.isArray(this.value) ? this.value.join(', ') : this.value?.toString()}
1046
1564
  tabindex="-1"
1047
1565
  aria-hidden="true"
1048
1566
  @focus=${() => this.focus()}
@@ -1050,7 +1568,7 @@ export default class SynCombobox extends SynergyElement implements SynergyFormCo
1050
1568
  />
1051
1569
 
1052
1570
  ${hasClearIcon
1053
- ? html`
1571
+ ? html`
1054
1572
  <button
1055
1573
  part="clear-button"
1056
1574
  class="combobox__clear"
@@ -1065,7 +1583,7 @@ export default class SynCombobox extends SynergyElement implements SynergyFormCo
1065
1583
  </slot>
1066
1584
  </button>
1067
1585
  `
1068
- : ''}
1586
+ : ''}
1069
1587
 
1070
1588
  <slot name="suffix" part="suffix" class="combobox__suffix"></slot>
1071
1589
 
@@ -1079,6 +1597,7 @@ export default class SynCombobox extends SynergyElement implements SynergyFormCo
1079
1597
  role="listbox"
1080
1598
  aria-expanded=${this.open ? 'true' : 'false'}
1081
1599
  aria-labelledby="label"
1600
+ aria-multiselectable=${this.multiple ? 'true' : 'false'}
1082
1601
  part="listbox"
1083
1602
  class="combobox__listbox"
1084
1603
  tabindex="-1"
@@ -1086,15 +1605,15 @@ export default class SynCombobox extends SynergyElement implements SynergyFormCo
1086
1605
  @mouseup=${this.handleOptionClick}
1087
1606
  >
1088
1607
  <div class="listbox__options" part="filtered-listbox">
1089
- ${this.numberFilteredOptions === 0
1090
- ? html`<span
1608
+ ${this.hideOptions || this.numberFilteredOptions === 0
1609
+ ? html`<span
1091
1610
  class="listbox__no-results"
1092
1611
  aria-hidden="true"
1093
1612
  part="no-results"
1094
1613
  >${this.localize.term('noResults')}</span
1095
1614
  >`
1096
- : ''}
1097
- <slot @slotchange=${this.handleDefaultSlotChange}></slot>
1615
+ : ''}
1616
+ <slot class=${classMap({ options__hide: this.hideOptions })} @slotchange=${this.handleDefaultSlotChange}></slot>
1098
1617
  </div>
1099
1618
  </div>
1100
1619
  </syn-popup>