preline
Version:
Preline UI is an open-source set of prebuilt UI components based on the utility-first Tailwind CSS framework.
1,759 lines (1,485 loc) • 63.8 kB
text/typescript
/*
* HSSelect
* @version: 3.1.0
* @author: Preline Labs Ltd.
* @license: Licensed under MIT and Preline UI Fair Use License (https://preline.co/docs/license.html)
* Copyright 2024 Preline Labs Ltd.
*/
import {
afterTransition,
classToClassList,
debounce,
dispatch,
htmlToElement,
isEnoughSpace,
isFocused,
} from "../../utils";
import {
IApiFieldMap,
ISelect,
ISelectOptions,
ISingleOption,
ISingleOptionOptions,
} from "../select/interfaces";
import HSBasePlugin from "../base-plugin";
import { ICollectionItem } from "../../interfaces";
import { POSITIONS, SELECT_ACCESSIBILITY_KEY_SET } from "../../constants";
class HSSelect extends HSBasePlugin<ISelectOptions> implements ISelect {
value: string | string[] | null;
private readonly placeholder: string | null;
private readonly hasSearch: boolean;
private readonly minSearchLength: number;
private readonly preventSearchFocus: boolean;
private readonly mode: string | null;
private readonly viewport: HTMLElement | null;
isOpened: boolean | null;
isMultiple: boolean | null;
isDisabled: boolean | null;
selectedItems: string[];
private readonly apiUrl: string | null;
private readonly apiQuery: string | null;
private readonly apiOptions: RequestInit | null;
private readonly apiDataPart: string | null;
private readonly apiSearchQueryKey: string | null;
private readonly apiLoadMore: boolean | {
perPage: number;
scrollThreshold: number;
};
private readonly apiFieldsMap: IApiFieldMap | null;
private readonly apiIconTag: string | null;
private readonly apiSelectedValues: string | string[] | null;
private readonly toggleTag: string | null;
private readonly toggleClasses: string | null;
private readonly toggleSeparators: {
items?: string;
betweenItemsAndCounter?: string;
} | null;
private readonly toggleCountText: string | null;
private readonly toggleCountTextPlacement:
| "postfix"
| "prefix"
| "postfix-no-space"
| "prefix-no-space";
private readonly toggleCountTextMinItems: number | null;
private readonly toggleCountTextMode: string | null;
private readonly wrapperClasses: string | null;
private readonly tagsItemTemplate: string | null;
private readonly tagsItemClasses: string | null;
private readonly tagsInputId: string | null;
private readonly tagsInputClasses: string | null;
private readonly dropdownTag: string | null;
private readonly dropdownClasses: string | null;
private readonly dropdownDirectionClasses: {
top?: string;
bottom?: string;
} | null;
public dropdownSpace: number | null;
public readonly dropdownPlacement: string | null;
private readonly dropdownAutoPlacement: boolean;
public readonly dropdownVerticalFixedPlacement: "top" | "bottom" | null;
public readonly dropdownScope: "window" | "parent";
private readonly searchTemplate: string | null;
private readonly searchWrapperTemplate: string | null;
private readonly searchPlaceholder: string | null;
private readonly searchId: string | null;
private readonly searchLimit: number | typeof Infinity;
private readonly isSearchDirectMatch: boolean;
private readonly searchClasses: string | null;
private readonly searchWrapperClasses: string | null;
private readonly searchNoResultTemplate: string | null;
private readonly searchNoResultText: string | null;
private readonly searchNoResultClasses: string | null;
private readonly optionAllowEmptyOption: boolean;
private readonly optionTag: string | null;
private readonly optionTemplate: string | null;
private readonly optionClasses: string | null;
private readonly descriptionClasses: string | null;
private readonly iconClasses: string | null;
private animationInProcess: boolean;
private currentPage: number;
private isLoading: boolean;
private hasMore: boolean;
private wrapper: HTMLElement | null;
private toggle: HTMLElement | null;
private toggleTextWrapper: HTMLElement | null;
private tagsInput: HTMLElement | null;
private dropdown: HTMLElement | null;
private floatingUIInstance: any;
private searchWrapper: HTMLElement | null;
private search: HTMLInputElement | null;
private searchNoResult: HTMLElement | null;
private selectOptions: ISingleOption[] | [];
private extraMarkup: string | string[] | Element | null;
private readonly isAddTagOnEnter: boolean;
private tagsInputHelper: HTMLElement | null;
private remoteOptions: unknown[];
private optionId = 0;
private onWrapperClickListener: (evt: Event) => void;
private onToggleClickListener: () => void;
private onTagsInputFocusListener: () => void;
private onTagsInputInputListener: () => void;
private onTagsInputInputSecondListener: (evt: InputEvent) => void;
private onTagsInputKeydownListener: (evt: KeyboardEvent) => void;
private onSearchInputListener: (evt: InputEvent) => void;
private readonly isSelectedOptionOnTop: boolean;
constructor(el: HTMLElement, options?: ISelectOptions) {
super(el, options);
const data = el.getAttribute("data-hs-select");
const dataOptions: ISelectOptions = data ? JSON.parse(data) : {};
const concatOptions = {
...dataOptions,
...options,
};
this.value = concatOptions?.value || (this.el as HTMLSelectElement).value ||
null;
this.placeholder = concatOptions?.placeholder || "Select...";
this.hasSearch = concatOptions?.hasSearch || false;
this.minSearchLength = concatOptions?.minSearchLength ?? 0;
this.preventSearchFocus = concatOptions?.preventSearchFocus || false;
this.mode = concatOptions?.mode || "default";
this.viewport = typeof concatOptions?.viewport !== "undefined"
? document.querySelector(concatOptions?.viewport)
: null;
this.isOpened = Boolean(concatOptions?.isOpened) || false;
this.isMultiple = this.el.hasAttribute("multiple") || false;
this.isDisabled = this.el.hasAttribute("disabled") || false;
this.selectedItems = [];
this.apiUrl = concatOptions?.apiUrl || null;
this.apiQuery = concatOptions?.apiQuery || null;
this.apiOptions = concatOptions?.apiOptions || null;
this.apiSearchQueryKey = concatOptions?.apiSearchQueryKey || null;
this.apiDataPart = concatOptions?.apiDataPart || null;
this.apiLoadMore = concatOptions?.apiLoadMore === true
? {
perPage: 10,
scrollThreshold: 100,
}
: typeof concatOptions?.apiLoadMore === "object" &&
concatOptions?.apiLoadMore !== null
? {
perPage: concatOptions.apiLoadMore.perPage || 10,
scrollThreshold: concatOptions.apiLoadMore.scrollThreshold || 100,
}
: false;
this.apiFieldsMap = concatOptions?.apiFieldsMap || null;
this.apiIconTag = concatOptions?.apiIconTag || null;
this.apiSelectedValues = concatOptions?.apiSelectedValues || null;
this.currentPage = 0;
this.isLoading = false;
this.hasMore = true;
this.wrapperClasses = concatOptions?.wrapperClasses || null;
this.toggleTag = concatOptions?.toggleTag || null;
this.toggleClasses = concatOptions?.toggleClasses || null;
this.toggleCountText = typeof concatOptions?.toggleCountText === undefined
? null
: concatOptions.toggleCountText;
this.toggleCountTextPlacement = concatOptions?.toggleCountTextPlacement ||
"postfix";
this.toggleCountTextMinItems = concatOptions?.toggleCountTextMinItems || 1;
this.toggleCountTextMode = concatOptions?.toggleCountTextMode ||
"countAfterLimit";
this.toggleSeparators = {
items: concatOptions?.toggleSeparators?.items || ", ",
betweenItemsAndCounter:
concatOptions?.toggleSeparators?.betweenItemsAndCounter || "and",
};
this.tagsItemTemplate = concatOptions?.tagsItemTemplate || null;
this.tagsItemClasses = concatOptions?.tagsItemClasses || null;
this.tagsInputId = concatOptions?.tagsInputId || null;
this.tagsInputClasses = concatOptions?.tagsInputClasses || null;
this.dropdownTag = concatOptions?.dropdownTag || null;
this.dropdownClasses = concatOptions?.dropdownClasses || null;
this.dropdownDirectionClasses = concatOptions?.dropdownDirectionClasses ||
null;
this.dropdownSpace = concatOptions?.dropdownSpace || 10;
this.dropdownPlacement = concatOptions?.dropdownPlacement || null;
this.dropdownVerticalFixedPlacement =
concatOptions?.dropdownVerticalFixedPlacement || null;
this.dropdownScope = concatOptions?.dropdownScope || "parent";
this.dropdownAutoPlacement = concatOptions?.dropdownAutoPlacement || false;
this.searchTemplate = concatOptions?.searchTemplate || null;
this.searchWrapperTemplate = concatOptions?.searchWrapperTemplate || null;
this.searchWrapperClasses = concatOptions?.searchWrapperClasses ||
"bg-white p-2 sticky top-0";
this.searchId = concatOptions?.searchId || null;
this.searchLimit = concatOptions?.searchLimit || Infinity;
this.isSearchDirectMatch =
typeof concatOptions?.isSearchDirectMatch !== "undefined"
? concatOptions?.isSearchDirectMatch
: true;
this.searchClasses = concatOptions?.searchClasses ||
"block w-[calc(100%-32px)] text-sm border-gray-200 rounded-md focus:border-blue-500 focus:ring-blue-500 dark:bg-neutral-900 dark:border-neutral-700 dark:text-neutral-400 py-2 px-3 my-2 mx-4";
this.searchPlaceholder = concatOptions?.searchPlaceholder || "Search...";
this.searchNoResultTemplate = concatOptions?.searchNoResultTemplate ||
"<span></span>";
this.searchNoResultText = concatOptions?.searchNoResultText ||
"No results found";
this.searchNoResultClasses = concatOptions?.searchNoResultClasses ||
"px-4 text-sm text-gray-800 dark:text-neutral-200";
this.optionAllowEmptyOption =
typeof concatOptions?.optionAllowEmptyOption !== "undefined"
? concatOptions?.optionAllowEmptyOption
: false;
this.optionTemplate = concatOptions?.optionTemplate || null;
this.optionTag = concatOptions?.optionTag || null;
this.optionClasses = concatOptions?.optionClasses || null;
this.extraMarkup = concatOptions?.extraMarkup || null;
this.descriptionClasses = concatOptions?.descriptionClasses || null;
this.iconClasses = concatOptions?.iconClasses || null;
this.isAddTagOnEnter = concatOptions?.isAddTagOnEnter ?? true;
this.isSelectedOptionOnTop = concatOptions?.isSelectedOptionOnTop ?? true;
this.animationInProcess = false;
this.selectOptions = [];
this.remoteOptions = [];
this.tagsInputHelper = null;
this.init();
}
private wrapperClick(evt: Event) {
if (
!(evt.target as HTMLElement).closest("[data-hs-select-dropdown]") &&
!(evt.target as HTMLElement).closest("[data-tag-value]")
) {
this.tagsInput.focus();
}
}
private toggleClick() {
if (this.isDisabled) return false;
this.toggleFn();
}
private tagsInputFocus() {
if (!this.isOpened) this.open();
}
private tagsInputInput() {
this.calculateInputWidth();
}
private tagsInputInputSecond(evt: InputEvent) {
if (!this.apiUrl) {
this.searchOptions((evt.target as HTMLInputElement).value);
}
}
private tagsInputKeydown(evt: KeyboardEvent) {
if (evt.key === "Enter" && this.isAddTagOnEnter) {
const val = (evt.target as HTMLInputElement).value;
if (this.selectOptions.find((el: ISingleOption) => el.val === val)) {
return false;
}
this.addSelectOption(val, val);
this.buildOption(val, val);
this.buildOriginalOption(val, val);
(
this.dropdown.querySelector(`[data-value="${val}"]`) as HTMLElement
).click();
this.resetTagsInputField();
// this.close();
}
}
private searchInput(evt: InputEvent) {
const newVal = (evt.target as HTMLInputElement).value;
if (this.apiUrl) this.remoteSearch(newVal);
else this.searchOptions(newVal);
}
public setValue(val: string | string[]) {
this.value = val;
this.clearSelections();
if (Array.isArray(val)) {
if (this.mode === "tags") {
this.unselectMultipleItems();
this.selectMultipleItems();
this.selectedItems = [];
const existingTags = this.wrapper.querySelectorAll("[data-tag-value]");
existingTags.forEach((tag) => tag.remove());
this.setTagsItems();
this.reassignTagsInputPlaceholder(
this.value.length ? "" : this.placeholder,
);
} else {
this.toggleTextWrapper.innerHTML = this.value.length
? this.stringFromValue()
: this.placeholder;
this.unselectMultipleItems();
this.selectMultipleItems();
}
} else {
this.setToggleTitle();
if (this.toggle.querySelector("[data-icon]")) this.setToggleIcon();
if (this.toggle.querySelector("[data-title]")) this.setToggleTitle();
this.selectSingleItem();
}
}
private init() {
this.createCollection(window.$hsSelectCollection, this);
this.build();
}
private build() {
this.el.style.display = "none";
if (this.el.children) {
Array.from(this.el.children)
.filter((el: HTMLOptionElement) =>
this.optionAllowEmptyOption ||
(!this.optionAllowEmptyOption && el.value && el.value !== "")
)
.forEach((el: HTMLOptionElement) => {
const data = el.getAttribute("data-hs-select-option");
this.selectOptions = [
...this.selectOptions,
{
title: el.textContent,
val: el.value,
disabled: el.disabled,
options: data !== "undefined" ? JSON.parse(data) : null,
},
];
});
}
if (this.optionAllowEmptyOption && !this.value) {
this.value = "";
}
if (this.isMultiple) {
const selectedOptions = Array.from(this.el.children).filter(
(el: HTMLOptionElement) => el.selected,
);
if (selectedOptions) {
const values: string[] = [];
selectedOptions.forEach((el: HTMLOptionElement) => {
values.push(el.value);
});
this.value = values;
}
}
this.buildWrapper();
if (this.mode === "tags") this.buildTags();
else this.buildToggle();
this.buildDropdown();
if (this.extraMarkup) this.buildExtraMarkup();
}
private buildWrapper() {
this.wrapper = document.createElement("div");
this.wrapper.classList.add("hs-select", "relative");
if (this.mode === "tags") {
this.onWrapperClickListener = (evt) => this.wrapperClick(evt);
this.wrapper.addEventListener("click", this.onWrapperClickListener);
}
if (this.wrapperClasses) {
classToClassList(this.wrapperClasses, this.wrapper);
}
this.el.before(this.wrapper);
this.wrapper.append(this.el);
}
private buildExtraMarkup() {
const appendMarkup = (markup: string): HTMLElement => {
const el = htmlToElement(markup);
this.wrapper.append(el);
return el;
};
const clickHandle = (el: HTMLElement) => {
if (!el.classList.contains("--prevent-click")) {
el.addEventListener("click", (evt: Event) => {
evt.stopPropagation();
if (!this.isDisabled) this.toggleFn();
});
}
};
if (Array.isArray(this.extraMarkup)) {
this.extraMarkup.forEach((el) => {
const newEl = appendMarkup(el);
clickHandle(newEl);
});
} else {
const newEl = appendMarkup(this.extraMarkup as string);
clickHandle(newEl);
}
}
private buildToggle() {
let icon, title;
this.toggleTextWrapper = document.createElement("span");
this.toggleTextWrapper.classList.add("truncate");
this.toggle = htmlToElement(this.toggleTag || "<div></div>");
icon = this.toggle.querySelector("[data-icon]");
title = this.toggle.querySelector("[data-title]");
if (!this.isMultiple && icon) this.setToggleIcon();
if (!this.isMultiple && title) this.setToggleTitle();
if (this.isMultiple) {
this.toggleTextWrapper.innerHTML = this.value.length
? this.stringFromValue()
: this.placeholder;
} else {
this.toggleTextWrapper.innerHTML =
this.getItemByValue(this.value as string)?.title || this.placeholder;
}
if (!title) this.toggle.append(this.toggleTextWrapper);
if (this.toggleClasses) classToClassList(this.toggleClasses, this.toggle);
if (this.isDisabled) this.toggle.classList.add("disabled");
if (this.wrapper) this.wrapper.append(this.toggle);
if (this.toggle?.ariaExpanded) {
if (this.isOpened) this.toggle.ariaExpanded = "true";
else this.toggle.ariaExpanded = "false";
}
this.onToggleClickListener = () => this.toggleClick();
this.toggle.addEventListener("click", this.onToggleClickListener);
}
private setToggleIcon() {
const item = this.getItemByValue(this.value as string) as
& ISingleOption
& IApiFieldMap;
const icon = this.toggle.querySelector("[data-icon]");
if (icon) {
icon.innerHTML = "";
const img = htmlToElement(
this.apiUrl && this.apiIconTag
? this.apiIconTag || ""
: item?.options?.icon || "",
) as HTMLImageElement;
if (
this.value &&
this.apiUrl &&
this.apiIconTag &&
item[this.apiFieldsMap.icon]
) {
img.src = (item[this.apiFieldsMap.icon] as string) || "";
}
icon.append(img);
if (!img?.src) icon.classList.add("hidden");
else icon.classList.remove("hidden");
}
}
private setToggleTitle() {
const title = this.toggle.querySelector("[data-title]");
let value = this.placeholder;
if (this.optionAllowEmptyOption && this.value === "") {
const emptyOption = this.selectOptions.find((el: ISingleOption) =>
el.val === ""
);
value = emptyOption?.title || this.placeholder;
} else if (this.value) {
if (this.apiUrl) {
const selectedOption = (this.remoteOptions as IApiFieldMap[]).find(
(el) =>
`${el[this.apiFieldsMap.val]}` === this.value ||
`${el[this.apiFieldsMap.title]}` === this.value,
);
if (selectedOption) {
value = selectedOption[this.apiFieldsMap.title] as string;
}
} else {
const selectedOption = this.selectOptions.find((el: ISingleOption) =>
el.val === this.value
);
if (selectedOption) {
value = selectedOption.title;
}
}
}
if (title) {
title.innerHTML = value;
title.classList.add("truncate");
this.toggle.append(title);
} else {
this.toggleTextWrapper.innerHTML = value;
}
}
private buildTags() {
if (this.isDisabled) this.wrapper.classList.add("disabled");
this.buildTagsInput();
this.setTagsItems();
}
private reassignTagsInputPlaceholder(placeholder: string) {
(this.tagsInput as HTMLInputElement).placeholder = placeholder;
this.tagsInputHelper.innerHTML = placeholder;
this.calculateInputWidth();
}
private buildTagsItem(val: string) {
const item = this.getItemByValue(val) as ISingleOption & IApiFieldMap;
let template, title, remove, icon: null | HTMLElement;
const newItem = document.createElement("div");
newItem.setAttribute("data-tag-value", val);
if (this.tagsItemClasses) classToClassList(this.tagsItemClasses, newItem);
if (this.tagsItemTemplate) {
template = htmlToElement(this.tagsItemTemplate);
newItem.append(template);
}
// Icon
if (item?.options?.icon || this.apiIconTag) {
const img = htmlToElement(
this.apiUrl && this.apiIconTag ? this.apiIconTag : item?.options?.icon,
) as HTMLImageElement;
if (this.apiUrl && this.apiIconTag && item[this.apiFieldsMap.icon]) {
img.src = (item[this.apiFieldsMap.icon] as string) || "";
}
icon = template
? template.querySelector("[data-icon]")
: document.createElement("span");
icon.append(img);
if (!template) newItem.append(icon);
}
if (
template &&
template.querySelector("[data-icon]") &&
!item?.options?.icon &&
!this.apiUrl &&
!this.apiIconTag &&
!item[this.apiFieldsMap?.icon]
) {
template.querySelector("[data-icon]").classList.add("hidden");
}
// Title
title = template
? template.querySelector("[data-title]")
: document.createElement("span");
if (
this.apiUrl && this.apiFieldsMap?.title && item[this.apiFieldsMap.title]
) {
title.textContent = item[this.apiFieldsMap.title] as string;
} else {
title.textContent = item.title || "";
}
if (!template) newItem.append(title);
// Remove
if (template) {
remove = template.querySelector("[data-remove]");
} else {
remove = document.createElement("span");
remove.textContent = "X";
newItem.append(remove);
}
remove.addEventListener("click", () => {
this.value = (this.value as string[]).filter((el) => el !== val);
this.selectedItems = this.selectedItems.filter((el) => el !== val);
if (!this.value.length) {
this.reassignTagsInputPlaceholder(this.placeholder);
}
this.unselectMultipleItems();
this.selectMultipleItems();
newItem.remove();
this.triggerChangeEventForNativeSelect();
});
this.wrapper.append(newItem);
}
private getItemByValue(val: string) {
const value = this.apiUrl
? (this.remoteOptions as (ISingleOption & IApiFieldMap)[]).find(
(el) =>
`${el[this.apiFieldsMap.val]}` === val ||
el[this.apiFieldsMap.title] === val,
)
: this.selectOptions.find((el: ISingleOption) => el.val === val);
return value;
}
private setTagsItems() {
if (this.value) {
(this.value as string[]).forEach((val) => {
if (!this.selectedItems.includes(val)) this.buildTagsItem(val);
this.selectedItems = !this.selectedItems.includes(val)
? [...this.selectedItems, val]
: this.selectedItems;
});
}
if (this.isOpened && this.floatingUIInstance) {
this.floatingUIInstance.update();
}
}
private buildTagsInput() {
this.tagsInput = document.createElement("input");
if (this.tagsInputId) this.tagsInput.id = this.tagsInputId;
if (this.tagsInputClasses) {
classToClassList(this.tagsInputClasses, this.tagsInput);
}
this.onTagsInputFocusListener = () => this.tagsInputFocus();
this.onTagsInputInputListener = () => this.tagsInputInput();
this.onTagsInputInputSecondListener = debounce((evt: InputEvent) =>
this.tagsInputInputSecond(evt)
);
this.onTagsInputKeydownListener = (evt) => this.tagsInputKeydown(evt);
this.tagsInput.addEventListener("focus", this.onTagsInputFocusListener);
this.tagsInput.addEventListener("input", this.onTagsInputInputListener);
this.tagsInput.addEventListener(
"input",
this.onTagsInputInputSecondListener,
);
this.tagsInput.addEventListener("keydown", this.onTagsInputKeydownListener);
this.wrapper.append(this.tagsInput);
setTimeout(() => {
this.adjustInputWidth();
this.reassignTagsInputPlaceholder(
this.value.length ? "" : this.placeholder,
);
});
}
private buildDropdown() {
this.dropdown = htmlToElement(this.dropdownTag || "<div></div>");
this.dropdown.setAttribute("data-hs-select-dropdown", "");
if (this.dropdownScope === "parent") {
this.dropdown.classList.add("absolute");
if (!this.dropdownVerticalFixedPlacement) {
this.dropdown.classList.add("top-full");
}
}
this.dropdown.role = "listbox";
this.dropdown.tabIndex = -1;
this.dropdown.ariaOrientation = "vertical";
if (!this.isOpened) this.dropdown.classList.add("hidden");
if (this.dropdownClasses) {
classToClassList(this.dropdownClasses, this.dropdown);
}
if (this.wrapper) this.wrapper.append(this.dropdown);
if (this.dropdown && this.hasSearch) this.buildSearch();
if (this.selectOptions) {
this.selectOptions.forEach((props: ISingleOption, i) =>
this.buildOption(
props.title,
props.val,
props.disabled,
props.selected,
props.options,
`${i}`,
)
);
}
if (this.apiUrl) this.optionsFromRemoteData();
if (this.dropdownScope === "window") this.buildFloatingUI();
if (this.dropdown && this.apiLoadMore) this.setupInfiniteScroll();
}
private setupInfiniteScroll() {
this.dropdown.addEventListener("scroll", this.handleScroll.bind(this));
}
private async handleScroll() {
if (
!this.dropdown || this.isLoading || !this.hasMore || !this.apiLoadMore
) return;
const { scrollTop, scrollHeight, clientHeight } = this.dropdown;
const scrollThreshold = typeof this.apiLoadMore === "object"
? this.apiLoadMore.scrollThreshold
: 100;
const isNearBottom =
scrollHeight - scrollTop - clientHeight < scrollThreshold;
if (isNearBottom) await this.loadMore();
}
private async loadMore() {
if (!this.apiUrl || this.isLoading || !this.hasMore || !this.apiLoadMore) {
return;
}
this.isLoading = true;
try {
const url = new URL(this.apiUrl);
const paginationParam =
(this.apiFieldsMap?.page || this.apiFieldsMap?.offset ||
"page") as string;
const isOffsetBased = !!this.apiFieldsMap?.offset;
const perPage = typeof this.apiLoadMore === "object"
? this.apiLoadMore.perPage
: 10;
if (isOffsetBased) {
const offset = this.currentPage * perPage;
url.searchParams.set(paginationParam, offset.toString());
this.currentPage++;
} else {
this.currentPage++;
url.searchParams.set(paginationParam, this.currentPage.toString());
}
url.searchParams.set(
this.apiFieldsMap?.limit || "limit",
perPage.toString(),
);
const response = await fetch(url.toString(), this.apiOptions || {});
const data = await response.json();
const items = this.apiDataPart ? data[this.apiDataPart] : data.results;
const total = data.count || 0;
const currentOffset = this.currentPage * perPage;
if (items && items.length > 0) {
this.remoteOptions = [...(this.remoteOptions || []), ...items];
this.buildOptionsFromRemoteData(items);
this.hasMore = currentOffset < total;
} else {
this.hasMore = false;
}
} catch (error) {
this.hasMore = false;
console.error("Error loading more options:", error);
} finally {
this.isLoading = false;
}
}
private buildFloatingUI() {
if (typeof FloatingUIDOM !== "undefined" && FloatingUIDOM.computePosition) {
document.body.appendChild(this.dropdown);
const reference = this.mode === "tags" ? this.wrapper : this.toggle;
const middleware = [
FloatingUIDOM.offset([0, 5]),
];
if (
this.dropdownAutoPlacement && typeof FloatingUIDOM.flip === "function"
) {
middleware.push(FloatingUIDOM.flip({
fallbackPlacements: [
"bottom-start",
"bottom-end",
"top-start",
"top-end",
],
}));
}
const options = {
placement: POSITIONS[this.dropdownPlacement] || "bottom",
strategy: "fixed",
middleware,
};
const update = () => {
FloatingUIDOM.computePosition(reference, this.dropdown, options).then(
({ x, y, placement: computedPlacement }) => {
Object.assign(this.dropdown.style, {
position: "fixed",
left: `${x}px`,
top: `${y}px`,
[
`margin${
computedPlacement === "bottom"
? "Top"
: computedPlacement === "top"
? "Bottom"
: computedPlacement === "right"
? "Left"
: "Right"
}`
]: `${this.dropdownSpace}px`,
});
this.dropdown.setAttribute("data-placement", computedPlacement);
},
);
};
update();
const cleanup = FloatingUIDOM.autoUpdate(
reference,
this.dropdown,
update,
);
this.floatingUIInstance = {
update,
destroy: cleanup,
};
} else {
console.error("FloatingUIDOM not found! Please enable it on the page.");
}
}
private updateDropdownWidth() {
const toggle = this.mode === "tags" ? this.wrapper : this.toggle;
this.dropdown.style.width = `${toggle.clientWidth}px`;
}
private buildSearch() {
let input;
this.searchWrapper = htmlToElement(
this.searchWrapperTemplate || "<div></div>",
);
if (this.searchWrapperClasses) {
classToClassList(this.searchWrapperClasses, this.searchWrapper);
}
input = this.searchWrapper.querySelector("[data-input]");
const search = htmlToElement(
this.searchTemplate || '<input type="text">',
);
this.search = (
search.tagName === "INPUT" ? search : search.querySelector(":scope input")
) as HTMLInputElement;
this.search.placeholder = this.searchPlaceholder;
if (this.searchClasses) classToClassList(this.searchClasses, this.search);
if (this.searchId) this.search.id = this.searchId;
this.onSearchInputListener = debounce((evt: InputEvent) =>
this.searchInput(evt)
);
this.search.addEventListener("input", this.onSearchInputListener);
if (input) input.append(search);
else this.searchWrapper.append(search);
this.dropdown.append(this.searchWrapper);
}
private buildOption(
title: string,
val: string,
disabled: boolean = false,
selected: boolean = false,
options?: ISingleOptionOptions,
index: string = "1",
id?: string,
) {
let template: HTMLElement | null = null;
let titleWrapper: HTMLElement | null = null;
let iconWrapper: HTMLElement | null = null;
let descriptionWrapper: HTMLElement | null = null;
const option = htmlToElement(this.optionTag || "<div></div>");
option.setAttribute("data-value", val);
option.setAttribute("data-title-value", title);
option.setAttribute("tabIndex", index);
option.classList.add("cursor-pointer");
option.setAttribute("data-id", id || `${this.optionId}`);
if (!id) this.optionId++;
if (disabled) option.classList.add("disabled");
if (selected) {
if (this.isMultiple) this.value = [...(this.value as []), val];
else this.value = val;
}
if (this.optionTemplate) {
template = htmlToElement(this.optionTemplate);
option.append(template);
}
if (template) {
titleWrapper = template.querySelector("[data-title]");
titleWrapper.textContent = title || "";
} else {
option.textContent = title || "";
}
if (options) {
if (options.icon) {
const img = htmlToElement(this.apiIconTag ?? options.icon);
img.classList.add("max-w-full");
if (this.apiUrl) {
img.setAttribute("alt", title);
img.setAttribute("src", options.icon);
}
if (template) {
iconWrapper = template.querySelector("[data-icon]");
iconWrapper.append(img);
} else {
const icon = htmlToElement("<div></div>");
if (this.iconClasses) classToClassList(this.iconClasses, icon);
icon.append(img);
option.append(icon);
}
}
if (options.description) {
if (template) {
descriptionWrapper = template.querySelector("[data-description]");
if (descriptionWrapper) {
descriptionWrapper.append(options.description);
}
} else {
const description = htmlToElement("<div></div>");
description.textContent = options.description;
if (this.descriptionClasses) {
classToClassList(this.descriptionClasses, description);
}
option.append(description);
}
}
}
if (
template &&
template.querySelector("[data-icon]") &&
!options &&
!options?.icon
) {
template.querySelector("[data-icon]").classList.add("hidden");
}
if (
this.value &&
(this.isMultiple ? this.value.includes(val) : this.value === val)
) {
option.classList.add("selected");
}
if (!disabled) {
option.addEventListener("click", () => this.onSelectOption(val));
}
if (this.optionClasses) classToClassList(this.optionClasses, option);
if (this.dropdown) this.dropdown.append(option);
if (selected) this.setNewValue();
}
private buildOptionFromRemoteData(
title: string,
val: string,
disabled: boolean = false,
selected: boolean = false,
index: string = "1",
id: string | null,
options?: ISingleOptionOptions,
) {
if (index) {
this.buildOption(title, val, disabled, selected, options, index, id);
} else {
alert(
"ID parameter is required for generating remote options! Please check your API endpoint have it.",
);
}
}
private buildOptionsFromRemoteData(data: []) {
data.forEach((el: IApiFieldMap, i) => {
let id = null;
let title = "";
let value = "";
const options: IApiFieldMap & { rest: { [key: string]: unknown } } = {
id: "",
val: "",
title: "",
icon: null,
description: null,
rest: {},
};
Object.keys(el).forEach((key: string) => {
if (el[this.apiFieldsMap.id]) id = el[this.apiFieldsMap.id];
if (el[this.apiFieldsMap.val]) {
value = `${el[this.apiFieldsMap.val]}`;
}
if (el[this.apiFieldsMap.title]) {
title = el[this.apiFieldsMap.title] as string;
if (!el[this.apiFieldsMap.val]) {
value = title;
}
}
if (el[this.apiFieldsMap.icon]) {
options.icon = el[this.apiFieldsMap.icon] as string;
}
if (el[this.apiFieldsMap?.description]) {
options.description = el[this.apiFieldsMap.description] as string;
}
options.rest[key] = el[key];
});
const existingOption = this.dropdown.querySelector(
`[data-value="${value}"]`,
);
if (!existingOption) {
const isSelected = this.apiSelectedValues
? Array.isArray(this.apiSelectedValues)
? this.apiSelectedValues.includes(value)
: this.apiSelectedValues === value
: false;
this.buildOriginalOption(
title,
value,
id,
false,
isSelected,
options as ISingleOptionOptions & IApiFieldMap,
);
this.buildOptionFromRemoteData(
title,
value,
false,
isSelected,
`${i}`,
id,
options as ISingleOptionOptions & IApiFieldMap,
);
if (isSelected) {
if (this.isMultiple) {
if (!this.value) this.value = [];
if (Array.isArray(this.value)) {
this.value = [...this.value, value];
}
} else {
this.value = value;
}
}
}
});
this.sortElements(this.el, "option");
this.sortElements(this.dropdown, "[data-value]");
}
private async optionsFromRemoteData(val = "") {
const res = await this.apiRequest(val);
this.remoteOptions = res;
if (res.length) this.buildOptionsFromRemoteData(this.remoteOptions as []);
else console.log("There is no data were responded!");
}
private async apiRequest(val = "") {
try {
let url = this.apiUrl;
const search = this.apiSearchQueryKey
? `${this.apiSearchQueryKey}=${val.toLowerCase()}`
: null;
const query = this.apiQuery || "";
const options = this.apiOptions || {};
const queryParams = new URLSearchParams(query);
const cleanQuery = queryParams.toString();
if (this.apiLoadMore) {
const paginationParam =
(this.apiFieldsMap?.page || this.apiFieldsMap?.offset ||
"page") as string;
const isOffsetBased = !!this.apiFieldsMap?.offset;
const limitParam = this.apiFieldsMap?.limit || "limit";
const perPage = typeof this.apiLoadMore === "object"
? this.apiLoadMore.perPage
: 10;
queryParams.delete(paginationParam);
queryParams.delete(limitParam);
if (isOffsetBased) {
url += `?${paginationParam}=0`;
} else {
url += `?${paginationParam}=1`;
}
url += `&${limitParam}=${perPage}`;
} else if (search || cleanQuery) {
url += `?${search || cleanQuery}`;
}
if (search && cleanQuery) {
url += `&${cleanQuery}`;
} else if (search && !cleanQuery && !this.apiLoadMore) {
url += `?${search}`;
}
const req = await fetch(url, options);
const res = await req.json();
return this.apiDataPart ? res[this.apiDataPart] : res;
} catch (err) {
console.error(err);
}
}
private sortElements(container: HTMLElement, selector: string): void {
const items = Array.from(container.querySelectorAll(selector));
if (this.isSelectedOptionOnTop) {
items.sort((a, b) => {
const isASelected = a.classList.contains("selected") ||
a.hasAttribute("selected");
const isBSelected = b.classList.contains("selected") ||
b.hasAttribute("selected");
if (isASelected && !isBSelected) return -1;
if (!isASelected && isBSelected) return 1;
return 0;
});
}
items.forEach((item) => container.appendChild(item));
}
private async remoteSearch(val: string) {
if (val.length <= this.minSearchLength) {
const res = await this.apiRequest("");
this.remoteOptions = res;
Array.from(this.dropdown.querySelectorAll("[data-value]")).forEach((el) =>
el.remove()
);
Array.from(this.el.querySelectorAll("option[value]")).forEach(
(el: HTMLOptionElement) => {
el.remove();
},
);
if (res.length) this.buildOptionsFromRemoteData(res);
else console.log("No data responded!");
return false;
}
const res = await this.apiRequest(val);
this.remoteOptions = res;
let newIds = res.map((item: { id: string }) => `${item.id}`);
let restOptions = null;
const pseudoOptions = this.dropdown.querySelectorAll("[data-value]");
const options = this.el.querySelectorAll("[data-hs-select-option]");
options.forEach((el: HTMLOptionElement) => {
const dataId = el.getAttribute("data-id");
if (!newIds.includes(dataId) && !this.value?.includes(el.value)) {
this.destroyOriginalOption(el.value);
}
});
pseudoOptions.forEach((el: HTMLElement) => {
const dataId = el.getAttribute("data-id");
if (
!newIds.includes(dataId) &&
!this.value?.includes(el.getAttribute("data-value"))
) {
this.destroyOption(el.getAttribute("data-value"));
} else newIds = newIds.filter((item: string) => item !== dataId);
});
restOptions = res.filter((item: { id: string }) =>
newIds.includes(`${item.id}`)
);
if (restOptions.length) this.buildOptionsFromRemoteData(restOptions as []);
else console.log("No data responded!");
}
private destroyOption(val: string) {
const option = this.dropdown.querySelector(`[data-value="${val}"]`);
if (!option) return false;
option.remove();
}
private buildOriginalOption(
title: string,
val: string,
id?: string | null,
disabled?: boolean,
selected?: boolean,
options?: ISingleOptionOptions,
) {
const option = htmlToElement("<option></option>");
option.setAttribute("value", val);
if (disabled) option.setAttribute("disabled", "disabled");
if (selected) option.setAttribute("selected", "selected");
if (id) option.setAttribute("data-id", id);
option.setAttribute("data-hs-select-option", JSON.stringify(options));
option.innerText = title;
this.el.append(option);
}
private destroyOriginalOption(val: string) {
const option = this.el.querySelector(`[value="${val}"]`);
if (!option) return false;
option.remove();
}
private buildTagsInputHelper() {
this.tagsInputHelper = document.createElement("span");
this.tagsInputHelper.style.fontSize = window.getComputedStyle(
this.tagsInput,
).fontSize;
this.tagsInputHelper.style.fontFamily = window.getComputedStyle(
this.tagsInput,
).fontFamily;
this.tagsInputHelper.style.fontWeight = window.getComputedStyle(
this.tagsInput,
).fontWeight;
this.tagsInputHelper.style.letterSpacing = window.getComputedStyle(
this.tagsInput,
).letterSpacing;
this.tagsInputHelper.style.visibility = "hidden";
this.tagsInputHelper.style.whiteSpace = "pre";
this.tagsInputHelper.style.position = "absolute";
this.wrapper.appendChild(this.tagsInputHelper);
}
private calculateInputWidth() {
this.tagsInputHelper.textContent =
(this.tagsInput as HTMLInputElement).value ||
(this.tagsInput as HTMLInputElement).placeholder;
const inputPadding =
parseInt(window.getComputedStyle(this.tagsInput).paddingLeft) +
parseInt(window.getComputedStyle(this.tagsInput).paddingRight);
const inputBorder =
parseInt(window.getComputedStyle(this.tagsInput).borderLeftWidth) +
parseInt(window.getComputedStyle(this.tagsInput).borderRightWidth);
const newWidth = this.tagsInputHelper.offsetWidth + inputPadding +
inputBorder;
const maxWidth = this.wrapper.offsetWidth -
(parseInt(window.getComputedStyle(this.wrapper).paddingLeft) +
parseInt(window.getComputedStyle(this.wrapper).paddingRight));
(this.tagsInput as HTMLInputElement).style.width = `${
Math.min(newWidth, maxWidth) + 2
}px`;
}
private adjustInputWidth() {
this.buildTagsInputHelper();
this.calculateInputWidth();
}
private onSelectOption(val: string) {
this.clearSelections();
if (this.isMultiple) {
this.value = this.value.includes(val)
? Array.from(this.value).filter((el) => el !== val)
: [...Array.from(this.value), val];
this.selectMultipleItems();
this.setNewValue();
} else {
this.value = val;
this.selectSingleItem();
this.setNewValue();
}
this.fireEvent("change", this.value);
if (this.mode === "tags") {
const intersection = this.selectedItems.filter(
(x) => !(this.value as string[]).includes(x),
);
if (intersection.length) {
intersection.forEach((el) => {
this.selectedItems = this.selectedItems.filter((elI) => elI !== el);
this.wrapper.querySelector(`[data-tag-value="${el}"]`).remove();
});
}
this.resetTagsInputField();
}
if (!this.isMultiple) {
if (this.toggle.querySelector("[data-icon]")) this.setToggleIcon();
if (this.toggle.querySelector("[data-title]")) this.setToggleTitle();
this.close(true);
}
if (!this.value.length && this.mode === "tags") {
this.reassignTagsInputPlaceholder(this.placeholder);
}
if (this.isOpened && this.mode === "tags" && this.tagsInput) {
this.tagsInput.focus();
}
this.triggerChangeEventForNativeSelect();
}
private triggerChangeEventForNativeSelect() {
const selectChangeEvent = new Event("change", { bubbles: true });
(this.el as HTMLSelectElement).dispatchEvent(selectChangeEvent);
// TODO:: test with these lines added
dispatch("change.hs.select", this.el, this.value);
}
private addSelectOption(
title: string,
val: string,
disabled?: boolean,
selected?: boolean,
options?: ISingleOptionOptions,
) {
this.selectOptions = [
...this.selectOptions,
{
title,
val,
disabled,
selected,
options,
},
];
}
private removeSelectOption(val: string, isArray = false) {
const hasOption = !!this.selectOptions.some(
(el: ISingleOption) => el.val === val,
);
if (!hasOption) return false;
this.selectOptions = this.selectOptions.filter(
(el: ISingleOption) => el.val !== val,
);
this.value = isArray
? (this.value as string[]).filter((item: string) => item !== val)
: val;
}
private resetTagsInputField() {
(this.tagsInput as HTMLInputElement).value = "";
this.reassignTagsInputPlaceholder("");
this.searchOptions("");
}
private clearSelections() {
Array.from(this.dropdown.children).forEach((el) => {
if (el.classList.contains("selected")) el.classList.remove("selected");
});
Array.from(this.el.children).forEach((el) => {
if ((el as HTMLOptionElement).selected) {
(el as HTMLOptionElement).selected = false;
}
});
}
private setNewValue() {
if (this.mode === "tags") {
this.setTagsItems();
} else {
if (this.optionAllowEmptyOption && this.value === "") {
const emptyOption = this.selectOptions.find((el: ISingleOption) =>
el.val === ""
);
this.toggleTextWrapper.innerHTML = emptyOption?.title ||
this.placeholder;
} else {
if (this.value) {
if (this.apiUrl) {
const selectedItem = this.dropdown.querySelector(
`[data-value="${this.value}"]`,
);
if (selectedItem) {
this.toggleTextWrapper.innerHTML =
selectedItem.getAttribute("data-title-value") ||
this.placeholder;
} else {
const selectedOption = (this.remoteOptions as IApiFieldMap[])
.find(
(el) => {
const val = el[this.apiFieldsMap.val]
? `${el[this.apiFieldsMap.val]}`
: el[this.apiFieldsMap.title] as string;
return val === this.value;
},
);
this.toggleTextWrapper.innerHTML = selectedOption
? `${selectedOption[this.apiFieldsMap.title]}`
: this.stringFromValue();
}
} else {
this.toggleTextWrapper.innerHTML = this.stringFromValue();
}
} else {
this.toggleTextWrapper.innerHTML = this.placeholder;
}
}
}
}
private stringFromValueBasic(options: ISingleOption[]) {
const value: string[] = [];
let title = "";
options.forEach((el: ISingleOption) => {
if (this.isMultiple) {
if (this.value.includes(el.val)) value.push(el.title);
} else {
if (this.value === el.val) value.push(el.title);
}
});
if (
this.toggleCountText !== undefined &&
this.toggleCountText !== null &&
value.length >= this.toggleCountTextMinItems
) {
if (this.toggleCountTextMode === "nItemsAndCount") {
const nItems = value.slice(0, this.toggleCountTextMinItems - 1);
const tempTitle = [nItems.join(this.toggleSeparators.items)];
const count = `${value.length - nItems.length}`;
if (this?.toggleSeparators?.betweenItemsAndCounter) {
tempTitle.push(this.toggleSeparators.betweenItemsAndCounter);
}
if (this.toggleCountText) {
switch (this.toggleCountTextPlacement) {
case "postfix-no-space":
tempTitle.push(`${count}${this.toggleCountText}`);
break;
case "prefix-no-space":
tempTitle.push(`${this.toggleCountText}${count}`);
break;
case "prefix":
tempTitle.push(`${this.toggleCountText} ${count}`);
break;
default:
tempTitle.push(`${count} ${this.toggleCountText}`);
break;
}
}
title = tempTitle.join(" ");
} else {
title = `${value.length} ${this.toggleCountText}`;
}
} else {
title = value.join(this.toggleSeparators.items);
}
return title;
}
private stringFromValueRemoteData() {
const options = this.dropdown.querySelectorAll("[data-title-value]");
const value: string[] = [];
let title = "";
options.forEach((el: HTMLElement) => {
const dataValue = el.getAttribute("data-value");
const dataTitleValue = el.getAttribute("data-title-value");
if (this.isMultiple) {
if (this.value.includes(dataValue)) value.push(dataTitleValue);
} else {
if (this.value === dataValue) value.push(dataTitleValue);
}
});
if (
this.toggleCountText &&
this.toggleCountText !== "" &&
value.length >= this.toggleCountTextMinItems
) {
if (this.toggleCountTextMode === "nItemsAndCount") {
const nItems = value.slice(0, this.toggleCountTextMinItems - 1);
title = `${
nItems.join(this.toggleSeparators.items)
} ${this.toggleSeparators.betweenItemsAndCounter} ${
value.length - nItems.length
} ${this.toggleCountText}`;
} else {
title = `${value.length} ${this.toggleCountText}`;
}
} else {
title = value.join(this.toggleSeparators.items);
}
return title;
}
private stringFromValue() {
const result = this.apiUrl
? this.stringFromValueRemoteData()
: this.stringFromValueBasic(this.selectOptions);
return result;
}
private selectSingleItem() {
const selectedOption = Array.from(this.el.children).find(
(el) => this.value === (el as HTMLOptionElement).value,
);
(selectedOption as HTMLOptionElement).selected = true;
const selectedItem = Array.from(this.dropdown.children).find(
(el) =>
this.value === (el as HTMLOptionElement).getAttribute("data-value"),
);
if (selectedItem) selectedItem.classList.add("selected");
}
private selectMultipleItems() {
Array.from(this.dropdown.children)
.filter((el) => this.value.includes(el.getAttribute("data-value")))
.forEach((el) => el.classList.add("selected"));
Array.from(this.el.children)
.filter((el) => this.value.includes((el as HTMLOptionElement).value))
.forEach((el) => ((el as HTMLOptionElement).selected = true));
}
private unselectMultipleItems() {
Array.from(this.dropdown.children).forEach((el) =>
el.classList.remove("selected")
);
Array.from(this.el.children).forEach(
(el) => ((el as HTMLOptionElement).selected = false),
);
}
private searchOptions(val: string) {
if (val.length <= this.minSearchLength) {
if (this.searchNoResult) {
this.searchNoResult.remove();
this.searchNoResult = null;
}
const options = this.dropdown.querySelectorAll("[data-value]");
options.forEach((el) => {
el.classList.remove("hidden");
});
return false;
}
if (this.searchNoResult) {
this.searchNoResult.remove();
this.searchNoResult = null;
}
this.searchNoResult = htmlToElement(this.searchNoResultTemplate);
this.searchNoResult.innerText = this.searchNoResultText;
classToClassList(this.searchNoResultClasses, this.searchNoResult);
const options = this.dropdown.querySelectorAll("[data-value]");
let hasItems = false;
let countLimit: number;
if (this.searchLimit) countLimit = 0;
options.forEach((el) => {
const optionVal = el.getAttribute("data-title-value").toLocaleLowerCase();
const directMatch = this.isSearchDirectMatch;
let condition;
if (directMatch) {
condition = !optionVal.includes(val.toLowerCase()) ||
(this.searchLimit && countLimit >= this.searchLimit);
} else {
const regexSafeVal = val
? val.split("").map((
char,
) => (/\w/.test(char) ? `${char}[\\W_]*` : "\\W*")).join("")
: "";
const regex = new RegExp(regexSafeVal, "i");
condition = !regex.test(optionVal.trim()) ||
(this.searchLimit && countLimit >= this.searchLimit);
}
if (condition) {
el.classList.add("hidden");
} else {
el.classList.remove("hidden");
hasItems = true;
if (this.searchLimit) countLimit++;
}
});
if (!hasItems) this.dropdown.append(this.searchNoResult);
}
private eraseToggleIcon() {
const icon = this.toggle.querySelector("[data-icon]");
if (icon) {
icon.innerHTML = null;
icon.classList.add("hidden");
}
}
private eraseToggleTitle() {
const title = this.toggle.querySelector("[data-title]");
if (title) {
title.innerHTML = this.placeholder;
} else {
this.toggleTextWrapper.innerHTML = this.placeholder;
}
}
private toggleFn() {
if (this.isOpened) this.close();
else this.open();
}
// Public methods
public destroy() {
// Remove listeners
if (this.wrapper) {
this.wrapper.removeEventListener("click", this.onWrapperClickListener);
}
if (this.toggle) {
this.toggle.removeEventListener("click", this.onToggleClickListener);
}
if (this.tagsInput) {
this.tagsInput.removeEventListener(
"focus",
this.onTagsInputFocusListener,
);
this.tagsInput.removeEventListener(
"input",
this.onTagsInputInputListener,
);
this.tagsInput.removeEventListener(
"input",
this.onTagsInputInputSecondListener,
);
this.tagsInput.removeEventListener(
"keydown",