@synergy-design-system/components
Version:
1,382 lines (1,369 loc) • 48.1 kB
JavaScript
import {
combobox_styles_default
} from "./chunk.4P4UJDDZ.js";
import {
defaultOptionRenderer
} from "./chunk.KLL2SVR7.js";
import {
checkValueBelongsToOption,
createOptionFromDifferentTypes,
filterOnlyOptgroups,
getAllOptions,
getAssignedElementsForSlot,
getValueFromOption,
getValuesFromOptions,
normalizeString
} from "./chunk.FVIRHFY6.js";
import {
SynOption
} from "./chunk.ZQGIIVB5.js";
import {
compareValues,
isAllowedValue
} from "./chunk.VECCQZP5.js";
import {
scrollIntoView
} from "./chunk.2DT3C6WE.js";
import {
form_control_styles_default
} from "./chunk.34WYIU4C.js";
import {
FormControlController
} from "./chunk.YDQ424MR.js";
import {
SynTag
} from "./chunk.3TG5EZUB.js";
import {
HasSlotController
} from "./chunk.CHFWLQN5.js";
import {
SynIcon
} from "./chunk.KODS2CCZ.js";
import {
enableDefaultSettings
} from "./chunk.WYMKPLYK.js";
import {
SynPopup
} from "./chunk.C2RDCOO4.js";
import {
waitForEvent
} from "./chunk.LQDIH4Z5.js";
import {
animateTo,
stopAnimations
} from "./chunk.DLSBQOBA.js";
import {
getAnimation,
setDefaultAnimation
} from "./chunk.NIRQP534.js";
import {
LocalizeController
} from "./chunk.ZXMSJF2M.js";
import {
watch
} from "./chunk.UR2E7Y2Q.js";
import {
component_styles_default
} from "./chunk.2NT3B5WJ.js";
import {
SynergyElement
} from "./chunk.L2IYPRPF.js";
import {
__decorateClass
} from "./chunk.VK2FVWOF.js";
// src/components/combobox/combobox.component.ts
import { classMap } from "lit/directives/class-map.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { html } from "lit";
import { property, query, state } from "lit/decorators.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
var SynCombobox = class extends SynergyElement {
constructor() {
super(...arguments);
this.formControlController = new FormControlController(this, {
assumeInteractionOn: ["syn-blur", "syn-input"]
});
this.hasSlotController = new HasSlotController(this, "help-text", "label");
this.localize = new LocalizeController(this);
/**
* Cache of the last syn-options that were selected by user interaction (click or keyboard navigation).
* Used to track user selections and maintain selection state during value changes.
*/
this.lastOptions = [];
this.isInitialized = false;
/**
* Flag to prevent infinite loops when the option renderer programmatically updates options.
* Set to true during option rendering to ignore slot change events triggered by our own updates.
*/
this.isOptionRendererTriggered = false;
this.hasFocus = false;
this.isUserInput = false;
this.displayLabel = "";
this.selectedOptions = [];
this.numberFilteredOptions = 0;
this.cachedOptions = [];
this.valueHasChanged = false;
this.hideOptions = false;
this.name = "";
this._value = "";
this.defaultValue = "";
this.size = "medium";
this.placeholder = "";
this.disabled = false;
this.readonly = false;
this.clearable = false;
this.open = false;
this.label = "";
this.placement = "bottom";
this.helpText = "";
this.form = "";
this.required = false;
this.restricted = false;
this.multiple = false;
this.getOption = defaultOptionRenderer;
this.filter = (option, queryStr) => {
var _a;
let content = (option == null ? void 0 : option.textContent) || "";
if (option instanceof SynOption) {
content = option.getTextLabel();
}
const normalizedOption = normalizeString(content);
const normalizedQuery = normalizeString(queryStr);
if (normalizedOption.includes(normalizedQuery)) {
return true;
}
return ((_a = option == null ? void 0 : option.value) == null ? void 0 : _a.toString()) === queryStr;
};
this.delimiter = " ";
this.maxOptionsVisible = 3;
this.getTag = (option) => html`
<syn-tag
part="tag"
exportparts="
base:tag__base,
content:tag__content,
remove-button:tag__remove-button,
remove-button__base:tag__remove-button__base
"
size=${this.size}
removable
@syn-remove=${(event) => this.handleTagRemove(event, option)}
>
${option.getTextLabel()}
</syn-tag>
`;
this.calculateTagMaxWidth = (entries) => {
const input = entries.at(0);
if (!input || !this.tagContainer) return;
const inputWidth = input.contentRect.width;
const tagsWidth = this.tagContainer.getBoundingClientRect().width;
const availableTagSpace = Math.max(85, tagsWidth + inputWidth - 48);
this.tagContainer.style.setProperty("--syn-select-tag-max-width", `${availableTagSpace}px`);
};
this.handleDocumentFocusIn = (event) => {
const path = event.composedPath();
if (this && !path.includes(this)) {
this.hide();
}
};
/* eslint-disable @typescript-eslint/no-floating-promises */
// eslint-disable-next-line complexity
this.handleDocumentKeyDown = (event) => {
const target = event.target;
const isClearButton = target.closest(".combobox__clear") !== null;
if (isClearButton) {
return;
}
if (event.key === "Escape") {
if (this.open && !this.closeWatcher) {
event.preventDefault();
event.stopPropagation();
this.hide();
this.displayInput.focus({ preventScroll: true });
} else if (!this.open) {
if (this.multiple) {
this.clearInputField();
} else {
this.clearCombobox();
}
}
}
if (event.key === "Enter") {
const currentOption = this.getCurrentOption();
const hasModifier = event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
if (!this.open && !hasModifier) {
setTimeout(() => {
if (!event.defaultPrevented) {
this.formControlController.submit();
}
});
return;
}
if (!this.open || currentOption && currentOption.disabled) {
return;
}
if (currentOption) {
this.isUserInput = true;
this.valueHasChanged = true;
const oldValue = this.lastOptions ? getValuesFromOptions(this.lastOptions) : [];
if (this.multiple) {
this.toggleOptionSelection(currentOption);
} else {
this.setSelectedOptions(currentOption);
}
this.selectionChanged();
const value = Array.isArray(this.value) ? this.value : [this.value];
this.updateComplete.then(() => {
this.isUserInput = false;
});
if (!compareValues(oldValue, value)) {
this.updateComplete.then(() => {
this.emit("syn-input");
this.emit("syn-change");
});
}
}
if (!this.multiple) {
this.hide();
}
this.displayInput.focus({ preventScroll: true });
return;
}
if (["ArrowUp", "ArrowDown"].includes(event.key)) {
event.preventDefault();
event.stopPropagation();
if (!this.open) {
this.show();
}
this.selectNextOption(event.key === "ArrowDown");
}
if (["Home", "End"].includes(event.key)) {
event.preventDefault();
event.stopPropagation();
if (event.key === "Home") {
this.displayInput.setSelectionRange(0, 0);
} else if (event.key === "End") {
this.displayInput.setSelectionRange(
this.displayLabel.length,
this.displayLabel.length
);
}
}
};
/* eslint-enable @typescript-eslint/no-floating-promises */
this.handleDocumentMouseDown = (event) => {
const path = event.composedPath();
if (this && !path.includes(this)) {
this.hide();
}
};
}
get value() {
return this._value;
}
set value(val) {
if (this.multiple) {
if (!Array.isArray(val)) {
val = typeof val === "string" ? val.split(this.delimiter) : [val].filter(isAllowedValue);
}
} else {
val = Array.isArray(val) ? val.join(this.delimiter) : val;
}
if (compareValues(this._value, val)) {
return;
}
this.valueHasChanged = true;
this._value = val;
}
/** Gets the validity state object */
get validity() {
return this.valueInput.validity;
}
/** Gets the validation message */
get validationMessage() {
return this.valueInput.validationMessage;
}
enableResizeObserver() {
if (!this.multiple) return;
if (!this.resizeObserver) {
this.resizeObserver = new ResizeObserver(this.calculateTagMaxWidth);
}
this.resizeObserver.observe(this.displayInput);
}
connectedCallback() {
super.connectedCallback();
this.mutationObserver = new MutationObserver((entries) => {
var _a, _b, _c;
const hasRelevantValueChange = entries.some((entry) => {
if (!(entry.target instanceof SynOption)) {
return false;
}
if (entry.type !== "attributes" || entry.attributeName !== "value") {
return false;
}
const currentValue = entry.target.getAttribute("value");
return entry.oldValue !== currentValue && !!currentValue;
});
const hasRelevantSelectedLabelChange = (this.restricted || this.multiple) && entries.some((entry) => {
if (entry.type !== "characterData" && entry.type !== "childList") {
return false;
}
return this.selectedOptions.some((option) => option === entry.target || option.contains(entry.target));
});
if (hasRelevantValueChange) {
this.handleSlotContentChange();
}
if (hasRelevantSelectedLabelChange) {
if (this.multiple) {
if (this.readonly) {
this.displayLabel = this.selectedOptions.map((option) => option.getTextLabel()).join(", ");
} else {
this.requestUpdate();
}
} else {
this.displayLabel = (_c = (_b = (_a = this.selectedOptions[0]) == null ? void 0 : _a.getTextLabel) == null ? void 0 : _b.call(_a)) != null ? _c : this.displayLabel;
}
}
});
this.mutationObserver.observe(this, {
attributeFilter: ["value"],
attributeOldValue: true,
attributes: true,
characterData: true,
childList: true,
subtree: true
});
setTimeout(() => {
this.handleSlotContentChange();
});
this.open = false;
}
disconnectedCallback() {
var _a, _b;
super.disconnectedCallback();
(_a = this.resizeObserver) == null ? void 0 : _a.disconnect();
(_b = this.mutationObserver) == null ? void 0 : _b.disconnect();
this.removeOpenListeners();
}
firstUpdated() {
this.isInitialized = true;
this.formControlController.updateValidity();
}
updated(changedProperties) {
var _a;
super.updated(changedProperties);
if (changedProperties.has("multiple")) {
if (!this.multiple) {
(_a = this.resizeObserver) == null ? void 0 : _a.disconnect();
} else {
this.enableResizeObserver();
}
}
}
// eslint-disable-next-line complexity
willUpdate(changedProperties) {
super.willUpdate(changedProperties);
const isDefaultValueEmpty = this.defaultValue == null || this.defaultValue === "" || Array.isArray(this.defaultValue) && this.defaultValue.length === 0;
if (changedProperties.has("value") && isDefaultValueEmpty && this.value && !this.isUserInput) {
if (this.multiple && Array.isArray(this.value)) {
this.defaultValue = this.value.join(this.delimiter);
} else {
this.defaultValue = this.value;
}
this.valueHasChanged = false;
}
if (!this.isInitialized && changedProperties.has("value") && this.value !== void 0 && changedProperties.has("multiple") && this.multiple) {
if (!Array.isArray(this.defaultValue)) {
const cachedValueHasChanged = this.valueHasChanged;
this.value = typeof this.defaultValue === "string" ? this.defaultValue.split(this.delimiter) : [this.defaultValue].filter(isAllowedValue);
this.valueHasChanged = cachedValueHasChanged;
}
}
}
attributeChangedCallback(name, oldVal, newVal) {
super.attributeChangedCallback(name, oldVal, newVal);
if (name === "value") {
const cachedValueHasChanged = this.valueHasChanged;
this.value = this.defaultValue;
this.valueHasChanged = cachedValueHasChanged;
}
}
get tags() {
return this.selectedOptions.map((option, index) => {
if (index < this.maxOptionsVisible || this.maxOptionsVisible <= 0) {
const tag = this.getTag(option, index);
return html`<div @syn-remove=${(e) => this.handleTagRemove(e, option)}>
${typeof tag === "string" ? unsafeHTML(tag) : tag}
</div>`;
}
if (index === this.maxOptionsVisible) {
return html`<syn-tag size=${this.size}>+${this.selectedOptions.length - index}</syn-tag>`;
}
return html``;
});
}
addOpenListeners() {
var _a;
document.addEventListener("focusin", this.handleDocumentFocusIn);
document.addEventListener("mousedown", this.handleDocumentMouseDown);
if (this.getRootNode() !== document) {
this.getRootNode().addEventListener("focusin", this.handleDocumentFocusIn);
}
if ("CloseWatcher" in window) {
(_a = this.closeWatcher) == null ? void 0 : _a.destroy();
this.closeWatcher = new CloseWatcher();
this.closeWatcher.onclose = () => {
if (this.open) {
this.hide();
this.displayInput.focus({ preventScroll: true });
}
};
}
}
removeOpenListeners() {
var _a;
document.removeEventListener("focusin", this.handleDocumentFocusIn);
document.removeEventListener("mousedown", this.handleDocumentMouseDown);
if (this.getRootNode() !== document) {
this.getRootNode().removeEventListener("focusin", this.handleDocumentFocusIn);
}
(_a = this.closeWatcher) == null ? void 0 : _a.destroy();
}
handleFocus() {
this.hasFocus = true;
this.emit("syn-focus");
}
handleBlur() {
this.hasFocus = false;
this.emit("syn-blur");
}
handleFormControlClick() {
if (this.readonly) {
this.displayInput.focus();
}
}
handleLabelClick() {
this.displayInput.focus();
}
handleTagRemove(event, option) {
event.stopPropagation();
this.valueHasChanged = true;
if (!this.disabled && !this.readonly) {
this.toggleOptionSelection(option, false);
this.selectionChanged();
this.updateComplete.then(() => {
this.emit("syn-input");
this.emit("syn-change");
});
}
}
handleComboboxMouseDown(event) {
const path = event.composedPath();
const isIconButton = path.some((el) => el instanceof Element && el.tagName.toLowerCase() === "syn-icon-button");
if (this.disabled || this.readonly || isIconButton) {
return;
}
const toggleListboxOpen = () => this.open ? this.hide() : this.show();
event.preventDefault();
toggleListboxOpen().then(() => {
setTimeout(() => this.displayInput.focus({ preventScroll: true }));
});
}
handleComboboxKeyDown(event) {
if (event.key === "Tab") {
return;
}
this.handleDocumentKeyDown(event);
}
handleClearClick(event) {
event.stopPropagation();
this.clearCombobox();
}
clearInputField() {
if (this.displayLabel !== "") {
const cachedValueHasChanged = this.valueHasChanged;
this.value = getValuesFromOptions(this.selectedOptions);
this.valueHasChanged = cachedValueHasChanged;
this.displayInput.focus({ preventScroll: true });
this.updateComplete.then(() => {
this.emit("syn-input");
});
}
}
clearCombobox() {
this.valueHasChanged = true;
if (this.value !== "") {
this.value = "";
this.displayLabel = "";
this.lastOptions = [];
this.setSelectedOptions([]);
this.selectionChanged();
this.displayInput.focus({ preventScroll: true });
this.updateComplete.then(() => {
this.emit("syn-clear");
this.emit("syn-input");
this.emit("syn-change");
});
}
}
// eslint-disable-next-line class-methods-use-this
preventLoosingFocus(event) {
event.stopPropagation();
event.preventDefault();
}
/* eslint-disable @typescript-eslint/no-floating-promises */
handleOptionClick(event) {
const target = event.target;
const option = target.closest("syn-option");
const oldValue = this.lastOptions ? getValuesFromOptions(this.lastOptions) : [];
if (option && !option.disabled) {
this.isUserInput = true;
this.valueHasChanged = true;
if (this.multiple) {
this.toggleOptionSelection(option);
} else {
this.setSelectedOptions(option);
}
this.selectionChanged();
this.updateComplete.then(() => {
this.displayInput.focus({ preventScroll: true });
this.isUserInput = false;
});
const value = Array.isArray(this.value) ? this.value : [this.value];
if (!compareValues(oldValue, value)) {
this.updateComplete.then(() => {
this.emit("syn-input");
this.emit("syn-change");
});
}
if (!this.multiple) {
this.hide();
this.displayInput.focus({ preventScroll: true });
}
}
}
/* eslint-enable @typescript-eslint/no-floating-promises */
/**
* Selects the following or previous option.
*
* @param isNext - A boolean indicating whether to select the following option (true)
* or the previous option (false).
*/
selectNextOption(isNext) {
const filteredOptions = this.getAllFilteredOptions();
if (filteredOptions.length === 0) {
return;
}
const currentOption = this.getCurrentOption();
const currentIndex = filteredOptions.indexOf(currentOption);
let newIndex = Math.max(0, currentIndex);
if (isNext) {
const nextIndex = currentIndex + 1;
newIndex = nextIndex > filteredOptions.length - 1 ? 0 : nextIndex;
} else {
const previousIndex = currentIndex - 1;
newIndex = previousIndex < 0 ? filteredOptions.length - 1 : previousIndex;
}
this.setCurrentOption(filteredOptions[newIndex]);
scrollIntoView(this.getCurrentOption(), this.listbox, "vertical", "auto");
}
// Toggles an option's selected state
// eslint-disable-next-line class-methods-use-this
toggleOptionSelection(option, force) {
if (force === true || force === false) {
option.selected = force;
} else {
option.selected = !option.selected;
}
const cachedOption = this.cachedOptions.find((opt) => opt.id === option.id);
if (cachedOption) {
cachedOption.selected = option.selected;
}
}
// Sets the selected option(s)
setSelectedOptions(option) {
const newSelectedOptions = Array.isArray(option) ? option : [option];
if (!this.multiple && newSelectedOptions.length > 1) {
newSelectedOptions.splice(1);
}
const slottedOptions = this.getSlottedOptions();
slottedOptions.forEach((opt) => {
opt.selected = newSelectedOptions.some(
(selectedOpt) => selectedOpt.id === opt.id
);
});
this.cachedOptions.forEach((opt) => {
opt.selected = newSelectedOptions.some(
(selectedOpt) => selectedOpt.id === opt.id
);
});
}
getAllFilteredOptions() {
return this.getSlottedOptions().filter((option) => !option.hidden);
}
getCurrentOption() {
return this.getAllFilteredOptions().find((option) => option.current);
}
// Sets the current option, which is the option the user is currently interacting with
// (e.g. via keyboard). Only one option may be "current" at a time.
setCurrentOption(option) {
const allOptions = this.getAllFilteredOptions();
this.displayInput.removeAttribute("aria-activedescendant");
allOptions.forEach((el) => {
el.current = false;
el.setAttribute("aria-selected", "false");
});
if (option) {
option.current = true;
option.setAttribute("aria-selected", "true");
this.displayInput.setAttribute("aria-activedescendant", option.id);
}
}
/**
* Updates the component state after selection changes.
*
* This method synchronizes:
* 1. The selectedOptions cache with currently selected options
* 2. The component's value property (string or array)
* 3. The display label shown in the input
* 4. Form validation state
*
* **Validation Logic:**
* - In restricted mode, invalid values trigger a reset to last valid state
* - Multiple mode requires all values to correspond to existing options
* - Single mode allows free text input when not restricted
*/
// eslint-disable-next-line complexity
selectionChanged() {
const options = this.getSlottedOptions();
this.selectedOptions = options.filter((opt) => opt.selected);
if (this.selectedOptions.length === 0) {
this.displayLabel = Array.isArray(this.value) ? this.value.join(", ") : String(this.value);
}
let optionValue;
const cachedValueHasChanged = this.valueHasChanged;
if (this.multiple) {
this.value = this.selectedOptions.map((opt) => getValueFromOption(opt));
if (this.value.length === 0 && this.selectedOptions.length !== 0) {
this.valueHasChanged = cachedValueHasChanged;
this.resetToLastValidValue();
return;
}
} else {
if (this.selectedOptions.length !== 0) {
optionValue = getValueFromOption(this.selectedOptions[0]);
} else if (this.restricted && !this.isValidValue(this.displayLabel) && this.displayLabel !== "" && !this.isUserInput) {
this.resetToLastValidValue();
this.valueHasChanged = cachedValueHasChanged;
return;
}
this.value = optionValue != null ? optionValue : this.displayLabel;
}
this.valueHasChanged = cachedValueHasChanged;
this.lastOptions = [...this.selectedOptions];
this.updateComplete.then(() => {
var _a, _b;
let newLabel = this.displayLabel;
if (this.multiple && this.readonly) {
newLabel = this.selectedOptions.map((opt) => opt.getTextLabel()).join(", ");
} else if (this.multiple && !this.readonly) {
newLabel = "";
} else {
newLabel = (_b = (_a = this.selectedOptions[0]) == null ? void 0 : _a.getTextLabel()) != null ? _b : this.displayLabel;
}
this.displayLabel = newLabel;
this.formControlController.updateValidity();
});
}
handleInvalid(event) {
this.formControlController.setValidity(false);
this.formControlController.emitInvalidEvent(event);
}
handlePropertiesChange() {
this.createComboboxOptionsFromQuery(this.displayLabel);
if (this.open) {
this.updateComplete.then(() => {
this.open = this.multiple || this.restricted || this.numberFilteredOptions > 0;
});
}
}
handleDisplayInputValueChange() {
this.createComboboxOptionsFromQuery(this.displayLabel);
}
handleDisabledChange() {
if (this.disabled) {
this.formControlController.setValidity(this.disabled);
}
if (this.disabled || this.readonly) {
this.open = false;
this.handleOpenChange();
}
this.selectionChanged();
}
handleDelimiterChange() {
this.getSlottedOptions().forEach((option) => {
option.delimiter = this.delimiter;
});
}
handleValueChange() {
if (!this.valueHasChanged) {
const cachedValueHasChanged = this.valueHasChanged;
this.value = this.defaultValue;
this.valueHasChanged = cachedValueHasChanged;
}
this.updateSelectedOptionFromValue();
}
async handleOpenChange() {
if (this.open && (!this.disabled && !this.readonly)) {
if (this.numberFilteredOptions === 0 && !this.restricted && !this.multiple) {
this.open = false;
this.emit("syn-error");
return;
}
this.emit("syn-show");
this.addOpenListeners();
await stopAnimations(this);
this.listbox.hidden = false;
this.popup.active = true;
const { keyframes: keyframes2, options: options2 } = getAnimation(this, "combobox.show", { dir: this.localize.dir() });
await animateTo(this.popup.popup, keyframes2, options2);
this.emit("syn-after-show");
return;
}
this.setCurrentOption(null);
this.displayInput.removeAttribute("aria-activedescendant");
this.emit("syn-hide");
this.removeOpenListeners();
await stopAnimations(this);
const { keyframes, options } = getAnimation(this, "combobox.hide", { dir: this.localize.dir() });
await animateTo(this.popup.popup, keyframes, options);
this.listbox.hidden = true;
this.popup.active = false;
this.emit("syn-after-hide");
}
/**
* Shows the listbox. If it is not possible to open the listbox, because there are no
* appropriate filtered options, a syn-error is emitted and the listbox stays closed.
*/
async show() {
if (this.open || this.disabled || this.readonly) {
this.open = false;
return void 0;
}
this.open = true;
return Promise.race([waitForEvent(this, "syn-after-show"), waitForEvent(this, "syn-error")]);
}
/** Hides the listbox. */
async hide() {
if (!this.open || this.disabled || this.readonly) {
this.open = false;
return void 0;
}
this.open = false;
return waitForEvent(this, "syn-after-hide");
}
/**
* Checks for validity but does not show a validation message.
* Returns `true` when valid and `false` when invalid.
*/
checkValidity() {
return this.valueInput.checkValidity();
}
/** Gets the associated form, if one exists. */
getForm() {
return this.formControlController.getForm();
}
/** Checks for validity and shows the browser's validation message if the control is invalid. */
reportValidity() {
return this.valueInput.reportValidity();
}
/** Sets a custom validation message. Pass an empty string to restore validity. */
setCustomValidity(message) {
this.valueInput.setCustomValidity(message);
this.formControlController.updateValidity();
}
/** Sets focus on the control. */
focus(options) {
this.displayInput.focus(options);
}
/** Removes focus from the control. */
blur() {
this.displayInput.blur();
}
/**
* Updates the visibility and rendering of options based on the current query string.
*
* This method performs several critical tasks:
* 1. **Option Filtering**: Uses the `filter` function to determine which options should be visible
* 2. **Custom Rendering**: Applies the `getOption` function to customize option appearance
* 3. **Optgroup Management**: Shows/hides optgroups based on their visible children
* 4. **Counts visible options**: Tracks the number of visible options for UI logic
*
* **Performance Considerations:**
* - Uses cached options to avoid repeated DOM queries
* - Prevents infinite loops during option updates with `isOptionRendererTriggered`
*
* @param queryString - The current user input to filter and highlight options with
*/
/* eslint-disable no-param-reassign, @typescript-eslint/no-floating-promises */
createComboboxOptionsFromQuery(queryString) {
var _a;
this.numberFilteredOptions = 0;
this.isOptionRendererTriggered = true;
if (this.cachedOptions.length === 0) {
this.cacheSlottedOptionsAndOptgroups();
}
this.getSlottedOptions().forEach((option) => {
const cachedOption = this.cachedOptions.find((o) => o.id === option.id) || option;
const optionResult = this.getOption(cachedOption, queryString);
let updatedOption = createOptionFromDifferentTypes(optionResult);
if (!updatedOption) {
updatedOption = cachedOption;
}
const hideOption = !(this.filter(updatedOption, queryString) || queryString === "");
updatedOption.hidden = hideOption;
option.replaceWith(updatedOption);
if (!hideOption) {
this.numberFilteredOptions += 1;
}
});
const visibleOptgroups = this.getSlottedOptGroups().filter((optgroup) => {
const options = getAllOptions(Array.from(optgroup.children)).flat();
const isVisible = options.some((option) => !option.hidden);
optgroup.hidden = !isVisible;
return isVisible;
});
(_a = visibleOptgroups[0]) == null ? void 0 : _a.style.setProperty("--display-divider", "none");
setTimeout(() => {
this.isOptionRendererTriggered = false;
});
}
/* eslint-enable no-param-reassign, @typescript-eslint/no-floating-promises */
// eslint-disable-next-line complexity
async handleInput() {
const inputValue = this.displayInput.value;
this.displayLabel = inputValue;
const cachedLastOption = this.lastOptions;
this.isUserInput = true;
if (!this.multiple) {
this.selectedOptions = [];
}
if (this.multiple) {
const validValues = getValuesFromOptions(this.selectedOptions);
this.value = [...validValues, inputValue];
} else {
this.value = inputValue;
}
await this.updateComplete;
this.isUserInput = false;
this.lastOptions = cachedLastOption;
this.open = this.multiple || this.restricted || this.numberFilteredOptions > 0;
this.formControlController.updateValidity();
this.emit("syn-input");
}
/**
* Checks if the value is available in the options list.
* This is used to determine if the value is valid when the combobox is restricted.
* @param value - The value to check for validity.
* @returns `true` if the current value is available in the options list,
* otherwise `false`.
*/
isValidValue(value) {
const isValid = this.cachedOptions.some(
(option) => checkValueBelongsToOption(value, option)
);
return isValid;
}
getOptionsFromValue() {
const value = this.valueHasChanged ? this.value : this.defaultValue;
let convertedValue;
if (Array.isArray(value)) {
convertedValue = value;
} else if (value === void 0 || value == null) {
convertedValue = [];
} else if (this.multiple && typeof value === "string") {
convertedValue = value.split(this.delimiter);
} else {
convertedValue = [value];
}
return convertedValue.map((val) => this.cachedOptions.find((option) => checkValueBelongsToOption(val, option))).filter((opt) => opt !== void 0);
}
/**
* Resets the value to the last valid value or to an empty string.
*/
resetToLastValidValue() {
var _a, _b, _c, _d;
let label = "";
let value = [];
if (this.lastOptions.length !== 0) {
value = getValuesFromOptions(this.lastOptions);
if (!this.multiple) {
label = this.lastOptions[0].getTextLabel();
}
}
const popupAnimations = (_d = (_c = (_b = (_a = this.popup) == null ? void 0 : _a.popup) == null ? void 0 : _b.getAnimations) == null ? void 0 : _c.call(_b)) != null ? _d : [];
const waitForAnimations = popupAnimations.length ? Promise.all(
popupAnimations.map((animation) => animation.playState === "finished" ? Promise.resolve() : new Promise((resolve) => {
animation.addEventListener("finish", () => resolve(), { once: true });
}))
) : Promise.resolve();
this.hideOptions = true;
waitForAnimations.then(() => {
this.hideOptions = false;
});
const cachedValueHasChanged = this.valueHasChanged;
this.value = value;
this.displayLabel = label;
this.formControlController.updateValidity();
this.valueHasChanged = cachedValueHasChanged;
}
// eslint-disable-next-line complexity
handleChange() {
const isSameSelection = this.selectedOptions.length !== 0 && this.selectedOptions.length === this.getOptionsFromValue().length;
let values;
if (Array.isArray(this.value)) {
values = this.value;
} else if (typeof this.value === "string") {
values = this.value.split(this.delimiter);
} else {
values = [this.value];
}
const allValuesValid = values.every((val) => this.isValidValue(val));
const isMultipleAndUserInput = this.multiple && isSameSelection && allValuesValid;
const isNonMultipleAndUserInput = !this.multiple && this.selectedOptions.length > 0;
if (isNonMultipleAndUserInput || isMultipleAndUserInput) {
return;
}
const oldValue = this.lastOptions ? getValuesFromOptions(this.lastOptions) : [];
if ((this.restricted || this.multiple) && !this.isValidValue(this.displayLabel) && this.displayLabel !== "") {
this.resetToLastValidValue();
return;
}
const options = this.getOptionsFromValue();
this.setSelectedOptions(options);
this.selectionChanged();
this.lastOptions = [...options];
this.updateComplete.then(() => {
this.formControlController.updateValidity();
});
const value = Array.isArray(this.value) ? this.value : [this.value];
if (!compareValues(oldValue, value)) {
this.emit("syn-change");
}
}
getSlottedOptions() {
if (!this.defaultSlot) {
return [];
}
return getAllOptions(getAssignedElementsForSlot(this.defaultSlot)).flat();
}
getSlottedOptGroups() {
return filterOnlyOptgroups(getAssignedElementsForSlot(this.defaultSlot));
}
/* eslint-disable no-param-reassign */
cacheSlottedOptionsAndOptgroups() {
const slottedOptions = this.getSlottedOptions();
const slottedOptgroups = this.getSlottedOptGroups();
slottedOptions.forEach((option, index) => {
option.id = option.id || `syn-combobox-option-${index}`;
});
slottedOptgroups.forEach((optgroup, index) => {
optgroup.id = optgroup.id || `syn-combobox-optgroup-${index}`;
});
this.cachedOptions = [...slottedOptions];
}
/* eslint-enable no-param-reassign */
updateSelectedOptionFromValue() {
if (!this.isUserInput) {
const options = this.getOptionsFromValue();
this.setSelectedOptions(options);
this.selectionChanged();
}
let queryString = "";
if (this.multiple) {
queryString = this.displayLabel;
} else {
queryString = Array.isArray(this.value) ? this.value.join(", ") : String(this.value);
}
this.createComboboxOptionsFromQuery(queryString);
}
/**
* Synchronizes the internal component state with changes to slotted options.
*
* This method is automatically triggered by:
* - MutationObserver when option 'value' attributes change
* - Initial component setup during connectedCallback (after timeout)
* - Default slot changes (via handleDefaultSlotChange)
* - Custom element registration completion for syn-option (deferred execution)
*
* The synchronization process:
* 1. Waits for syn-option custom elements to be registered before processing
* 2. Updates delimiter settings on all slotted options
* 3. Refreshes the internal cache of options and optgroups
* 4. Synchronizes selected options based on current component value
* 5. Auto-opens listbox if component has focus, has value, and is currently closed
*
* This ensures the component's internal state stays consistent with the slotted
* DOM content after options are added, removed, or their values change.
*
*/
/* eslint-disable @typescript-eslint/no-floating-promises */
handleSlotContentChange() {
if (!customElements.get("syn-option")) {
customElements.whenDefined("syn-option").then(() => this.handleSlotContentChange());
return;
}
this.handleDelimiterChange();
this.cacheSlottedOptionsAndOptgroups();
this.updateSelectedOptionFromValue();
let hasValue;
if (Array.isArray(this.value)) {
hasValue = this.value.length > 0;
} else if (typeof this.value === "string") {
hasValue = this.value.length > 0;
} else {
hasValue = this.value !== void 0 && this.value !== null;
}
if (this.hasFocus && hasValue && !this.open) {
this.show();
}
}
/* eslint-enable @typescript-eslint/no-floating-promises */
handleDefaultSlotChange() {
if (this.isOptionRendererTriggered) {
return;
}
this.handleSlotContentChange();
}
/* eslint-disable @typescript-eslint/unbound-method */
// eslint-disable-next-line complexity
render() {
var _a;
const hasLabelSlot = this.hasSlotController.test("label");
const hasHelpTextSlot = this.hasSlotController.test("help-text");
const hasLabel = this.label ? true : !!hasLabelSlot;
const hasHelpText = this.helpText ? true : !!hasHelpTextSlot;
let hasValue;
if (Array.isArray(this.value)) {
hasValue = this.value.length > 0;
} else if (typeof this.value === "string") {
hasValue = this.value.length > 0;
} else {
hasValue = this.value !== void 0 && this.value !== null && typeof this.value === "number";
}
const hasClearIcon = this.clearable && (!this.disabled && !this.readonly) && hasValue;
const isPlaceholderVisible = this.placeholder && !hasValue;
const tagsVisible = this.multiple && this.selectedOptions.length > 0;
return html`
<div
part="form-control"
class=${classMap({
"form-control": true,
"form-control--has-help-text": hasHelpText,
"form-control--has-label": hasLabel,
"form-control--large": this.size === "large",
"form-control--medium": this.size === "medium",
"form-control--small": this.size === "small"
})}
@click=${this.handleFormControlClick}
>
<label
id="label"
part="form-control-label"
class="form-control__label"
aria-hidden=${hasLabel ? "false" : "true"}
@click=${this.handleLabelClick}
>
<slot name="label">${this.label}</slot>
</label>
<div part="form-control-input" class="form-control-input">
<syn-popup
class=${classMap({
combobox: true,
"combobox--bottom": this.placement === "bottom",
"combobox--disabled": this.disabled,
"combobox--focused": this.hasFocus,
"combobox--large": this.size === "large",
"combobox--medium": this.size === "medium",
"combobox--multiple": this.multiple,
"combobox--open": this.open,
"combobox--placeholder-visible": isPlaceholderVisible,
"combobox--readonly": this.readonly,
"combobox--small": this.size === "small",
"combobox--standard": true,
"combobox--tags-visible": tagsVisible,
"combobox--top": this.placement === "top"
})}
placement=${`${this.placement}-start`}
flip
shift
sync="width"
auto-size="vertical"
auto-size-padding="10"
exportparts="popup"
>
<div
part="combobox"
class="combobox__inputs"
slot="anchor"
@keydown=${this.handleComboboxKeyDown}
@mousedown=${this.handleComboboxMouseDown}
>
<slot part="prefix" name="prefix" class="combobox__prefix"></slot>
${this.multiple && !this.readonly ? html`<div part="tags" class="combobox__tags">${this.tags}</div>` : ""}
<input
part="display-input"
class="combobox__display-input"
type="text"
placeholder=${this.placeholder}
.disabled=${this.disabled}
.readOnly=${this.readonly}
.value=${this.displayLabel}
maxlength=${ifDefined(this.maxlength)}
autocomplete="off"
spellcheck="false"
autocapitalize="off"
aria-controls="listbox"
aria-expanded=${this.open ? "true" : "false"}
aria-haspopup="listbox"
aria-labelledby="label"
aria-disabled=${this.disabled ? "true" : "false"}
aria-describedby="help-text"
role="combobox"
tabindex="0"
@focus=${this.handleFocus}
@blur=${this.handleBlur}
aria-autocomplete="list"
aria-owns="listbox"
@input=${this.handleInput}
@change=${this.handleChange}
/>
<input
class="combobox__value-input"
type="text"
?disabled=${this.disabled}
?readonly=${this.readonly}
?required=${this.required}
.value=${Array.isArray(this.value) ? this.value.join(", ") : (_a = this.value) == null ? void 0 : _a.toString()}
tabindex="-1"
aria-hidden="true"
@focus=${() => this.focus()}
@invalid=${this.handleInvalid}
/>
${hasClearIcon ? html`
<button
part="clear-button"
class="combobox__clear"
type="button"
aria-label=${this.localize.term("clearEntry")}
@mousedown=${this.preventLoosingFocus}
@click=${this.handleClearClick}
tabindex="-1"
>
<slot name="clear-icon">
<syn-icon name="x-circle-fill" library="system"></syn-icon>
</slot>
</button>
` : ""}
<slot name="suffix" part="suffix" class="combobox__suffix"></slot>
<slot name="expand-icon" part="expand-icon" class="combobox__expand-icon">
<syn-icon library="system" name="chevron-down"></syn-icon>
</slot>
</div>
<div
id="listbox"
role="listbox"
aria-expanded=${this.open ? "true" : "false"}
aria-labelledby="label"
aria-multiselectable=${this.multiple ? "true" : "false"}
part="listbox"
class="combobox__listbox"
tabindex="-1"
@mousedown=${this.preventLoosingFocus}
@mouseup=${this.handleOptionClick}
>
<div class="listbox__options" part="filtered-listbox">
${this.hideOptions || this.numberFilteredOptions === 0 ? html`<span
class="listbox__no-results"
aria-hidden="true"
part="no-results"
>${this.localize.term("noResults")}</span
>` : ""}
<slot class=${classMap({ options__hide: this.hideOptions })} @slotchange=${this.handleDefaultSlotChange}></slot>
</div>
</div>
</syn-popup>
</div>
<div
part="form-control-help-text"
id="help-text"
class="form-control__help-text"
aria-hidden=${hasHelpText ? "false" : "true"}
>
<slot name="help-text">${this.helpText}</slot>
</div>
</div>
`;
}
/* eslint-enable @typescript-eslint/unbound-method */
};
SynCombobox.styles = [
component_styles_default,
form_control_styles_default,
combobox_styles_default
];
SynCombobox.dependencies = {
"syn-icon": SynIcon,
"syn-popup": SynPopup,
"syn-tag": SynTag
};
__decorateClass([
query(".combobox")
], SynCombobox.prototype, "popup", 2);
__decorateClass([
query(".combobox__inputs")
], SynCombobox.prototype, "combobox", 2);
__decorateClass([
query(".combobox__display-input")
], SynCombobox.prototype, "displayInput", 2);
__decorateClass([
query(".combobox__value-input")
], SynCombobox.prototype, "valueInput", 2);
__decorateClass([
query(".combobox__listbox")
], SynCombobox.prototype, "listbox", 2);
__decorateClass([
query("slot:not([name])")
], SynCombobox.prototype, "defaultSlot", 2);
__decorateClass([
query(".combobox__tags")
], SynCombobox.prototype, "tagContainer", 2);
__decorateClass([
state()
], SynCombobox.prototype, "hasFocus", 2);
__decorateClass([
state()
], SynCombobox.prototype, "isUserInput", 2);
__decorateClass([
state()
], SynCombobox.prototype, "displayLabel", 2);
__decorateClass([
state()
], SynCombobox.prototype, "selectedOptions", 2);
__decorateClass([
state()
], SynCombobox.prototype, "numberFilteredOptions", 2);
__decorateClass([
state()
], SynCombobox.prototype, "cachedOptions", 2);
__decorateClass([
state()
], SynCombobox.prototype, "valueHasChanged", 2);
__decorateClass([
state()
], SynCombobox.prototype, "hideOptions", 2);
__decorateClass([
property()
], SynCombobox.prototype, "name", 2);
__decorateClass([
state()
], SynCombobox.prototype, "value", 1);
__decorateClass([
property({ attribute: "value" })
], SynCombobox.prototype, "defaultValue", 2);
__decorateClass([
property({ reflect: true })
], SynCombobox.prototype, "size", 2);
__decorateClass([
property()
], SynCombobox.prototype, "placeholder", 2);
__decorateClass([
property({ reflect: true, type: Boolean })
], SynCombobox.prototype, "disabled", 2);
__decorateClass([
property({ reflect: true, type: Boolean })
], SynCombobox.prototype, "readonly", 2);
__decorateClass([
property({ type: Boolean })
], SynCombobox.prototype, "clearable", 2);
__decorateClass([
property({ reflect: true, type: Boolean })
], SynCombobox.prototype, "open", 2);
__decorateClass([
property()
], SynCombobox.prototype, "label", 2);
__decorateClass([
property({ type: Number })
], SynCombobox.prototype, "maxlength", 2);
__decorateClass([
property({ reflect: true })
], SynCombobox.prototype, "placement", 2);
__decorateClass([
property({ attribute: "help-text" })
], SynCombobox.prototype, "helpText", 2);
__decorateClass([
property({ reflect: true })
], SynCombobox.prototype, "form", 2);
__decorateClass([
property({ reflect: true, type: Boolean })
], SynCombobox.prototype, "required", 2);
__decorateClass([
property({ reflect: true, type: Boolean })
], SynCombobox.prototype, "restricted", 2);
__decorateClass([
property({ reflect: true, type: Boolean })
], SynCombobox.prototype, "multiple", 2);
__decorateClass([
property()
], SynCombobox.prototype, "getOption", 2);
__decorateClass([
property()
], SynCombobox.prototype, "filter", 2);
__decorateClass([
property()
], SynCombobox.prototype, "delimiter", 2);
__decorateClass([
property({ attribute: "max-options-visible", type: Number })
], SynCombobox.prototype, "maxOptionsVisible", 2);
__decorateClass([
property()
], SynCombobox.prototype, "getTag", 2);
__decorateClass([
watch(["filter", "getOption"], { waitUntilFirstUpdate: true })
], SynCombobox.prototype, "handlePropertiesChange", 1);
__decorateClass([
watch("displayLabel", { waitUntilFirstUpdate: true })
], SynCombobox.prototype, "handleDisplayInputValueChange", 1);
__decorateClass([
watch(["disabled", "readonly"], { waitUntilFirstUpdate: true })
], SynCombobox.prototype, "handleDisabledChange", 1);
__decorateClass([
watch("delimiter")
], SynCombobox.prototype, "handleDelimiterChange", 1);
__decorateClass([
watch(["defaultValue", "value", "delimiter", "multiple", "restricted"], { waitUntilFirstUpdate: true })
], SynCombobox.prototype, "handleValueChange", 1);
__decorateClass([
watch("open", { waitUntilFirstUpdate: true })
], SynCombobox.prototype, "handleOpenChange", 1);
SynCombobox = __decorateClass([
enableDefaultSettings("SynCombobox")
], SynCombobox);
setDefaultAnimation("combobox.show", {
keyframes: [
{ opacity: 0, scale: 0.9 },
{ opacity: 1, scale: 1 }
],
options: { duration: 100, easing: "ease" }
});
setDefaultAnimation("combobox.hide", {
keyframes: [
{ opacity: 1, scale: 1 },
{ opacity: 0, scale: 0.9 }
],
options: { duration: 100, easing: "ease" }
});
export {
SynCombobox
};
//# sourceMappingURL=chunk.A66CI5MO.js.map