preline
Version:
Preline UI is an open-source set of prebuilt UI components based on the utility-first Tailwind CSS framework.
1,738 lines (1,469 loc) • 81.8 kB
text/typescript
/*
* HSSelect
* @version: 4.2.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,
} from '../../utils';
import {
IApiFieldMap,
ISelect,
ISelectOptions,
ISingleOption,
ISingleOptionOptions,
} from '../select/interfaces';
import HSBasePlugin from '../base-plugin';
import { ICollectionItem } from '../../interfaces';
import { IAccessibilityComponent } from '../accessibility-manager/interfaces';
import HSAccessibilityObserver from '../accessibility-manager';
import { POSITIONS } from '../../constants';
class HSSelect extends HSBasePlugin<ISelectOptions> implements ISelect {
private static globalListenersInitialized = false;
private accessibilityComponent: IAccessibilityComponent;
value: string | string[] | null;
private readonly placeholder: string | null;
private readonly hasSearch: boolean;
private readonly minSearchLength: number;
private readonly preventSearchFocus: boolean;
private readonly preventSearchInsideDescription: boolean;
private readonly mode: string | null;
private readonly viewport: HTMLElement | null;
private readonly scrollToSelected: boolean;
private _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 searchMatchMode:
| 'substring'
| 'chars-sequence'
| 'token-all'
| 'hybrid';
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 optgroupTag: string | null;
private readonly optgroupClasses: 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 hasOptgroup: 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 staticOptions: ISingleOption[] | [];
private extraMarkup: string | string[] | Element | null;
private readonly isAddTagOnEnter: boolean;
private tagsInputHelper: HTMLElement | null;
private remoteOptions: unknown[];
private disabledObserver: MutationObserver | null = null;
private remoteSearchAbortController: AbortController | null = null;
private loadMoreAbortController: AbortController | null = null;
private requestId: number = 0;
private lastQuery: string = '';
private readonly apiPageStart?: number;
private readonly apiTotalPath?: string | null;
private isLoadEventFired: boolean = false;
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.preventSearchInsideDescription =
concatOptions?.preventSearchInsideDescription || false;
this.mode = concatOptions?.mode || 'default';
this.viewport =
typeof concatOptions?.viewport !== 'undefined'
? document.querySelector(concatOptions?.viewport)
: null;
this.scrollToSelected =
typeof concatOptions?.scrollToSelected !== 'undefined'
? concatOptions?.scrollToSelected
: false;
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.apiPageStart =
typeof concatOptions?.apiPageStart === 'number'
? concatOptions.apiPageStart
: undefined;
this.apiTotalPath =
typeof concatOptions?.apiTotalPath === 'string'
? concatOptions.apiTotalPath
: null;
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.searchMatchMode =
concatOptions?.searchMatchMode ||
(this.isSearchDirectMatch ? 'substring' : 'chars-sequence');
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.optgroupTag = concatOptions?.optgroupTag || null;
this.optgroupClasses = concatOptions?.optgroupClasses || 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 ?? false;
this.animationInProcess = false;
this.selectOptions = [];
this.staticOptions = [];
this.remoteOptions = [];
this.tagsInputHelper = null;
this.disabledObserver = new MutationObserver((muts) => {
if (muts.some((m) => m.attributeName === 'disabled')) {
this.setDisabledState(this.el.hasAttribute('disabled'));
}
});
this.disabledObserver.observe(this.el, {
attributes: true,
attributeFilter: ['disabled'],
});
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();
}
}
private searchInput(evt: InputEvent) {
const newVal = (evt.target as HTMLInputElement).value;
this.lastQuery = newVal;
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.hasValue() ? '' : this.placeholder,
);
} else {
this.toggleTextWrapper.innerHTML = this.hasValue()
? 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 setDisabledState(isDisabled: boolean): void {
this.isDisabled = isDisabled;
const target = this.mode === 'tags' ? this.wrapper : this.toggle;
target?.classList.toggle('disabled', isDisabled);
if (isDisabled && this.isOpened()) this.close();
}
private hasValue(): boolean {
if (!this.isMultiple) {
return (
this.value !== null && this.value !== undefined && this.value !== ''
);
}
return (
Array.isArray(this.value) &&
this.value.length > 0 &&
this.value.some((val) => val !== null && val !== undefined && val !== '')
);
}
private init() {
HSSelect.ensureGlobalHandlers();
this.createCollection(window.$hsSelectCollection, this);
this.build();
if (typeof window !== 'undefined') {
if (!window.HSAccessibilityObserver) {
window.HSAccessibilityObserver = new HSAccessibilityObserver();
}
this.setupAccessibility();
}
}
private build() {
this.el.style.display = 'none';
const options = this.el.querySelectorAll('option');
if (options.length) this.setOptions();
if (this.optionAllowEmptyOption && !this.value) {
this.value = '';
}
if (this.isMultiple) {
const options = this.el.querySelectorAll('option');
const selectedOptions = Array.from(options).filter(
(el: HTMLOptionElement) => el.selected,
);
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();
if (!this.apiUrl) this.fireLoadEvent();
}
private fireLoadEvent() {
if (this.isLoadEventFired) return;
this.isLoadEventFired = true;
setTimeout(() => {
this.fireEvent('load', this.el);
dispatch('load.hs.select', this.el, this.el);
});
}
private setOptions() {
const options = this.el.querySelectorAll('option');
Array.from(options)
.filter(
(el: HTMLOptionElement) =>
this.optionAllowEmptyOption ||
(!this.optionAllowEmptyOption && el.value && el.value !== ''),
)
.forEach((el: HTMLOptionElement) => {
const data = el.getAttribute('data-hs-select-option');
const optionData: ISingleOption = {
title: el.textContent,
val: el.value,
disabled: el.disabled,
options: data && data !== 'undefined' ? JSON.parse(data) : null,
optgroupName:
el.parentElement?.tagName === 'OPTGROUP'
? (el.parentElement as HTMLOptGroupElement).label
: null,
};
this.selectOptions = [...this.selectOptions, optionData];
if (this.apiUrl) {
this.staticOptions = [...this.staticOptions, optionData];
el.setAttribute('data-static', 'true');
}
});
}
private buildWrapper() {
this.wrapper = document.createElement('div');
this.wrapper.classList.add('hs-select', 'relative');
this.setDisabledState(this.isDisabled);
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.hasValue()
? 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 staticIconSrc = item?.options?.apiFields?.icon;
const remoteIconSrc = item?.[this.apiFieldsMap?.icon];
const directIconSrc = item?.options?.icon;
const img = htmlToElement(
(this.apiUrl || staticIconSrc) && this.apiIconTag
? this.apiIconTag || ''
: directIconSrc || '',
) as HTMLImageElement;
if (this.value) {
if (staticIconSrc) {
img.src = staticIconSrc as string;
} else if (this.apiUrl && this.apiIconTag && remoteIconSrc) {
img.src = remoteIconSrc as string;
} else if (
directIconSrc &&
typeof directIconSrc === 'string' &&
!directIconSrc.trim().startsWith('<')
) {
img.src = directIconSrc;
}
}
icon.append(img);
if (img instanceof HTMLImageElement ? !img.src : !img) {
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 staticOption = this.staticOptions.find(
(el: ISingleOption) => el.val === this.value,
);
if (staticOption) {
value = staticOption.title;
} else {
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.wrapper.setAttribute('tabindex', '0');
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) {
const iconSrc =
(item[this.apiFieldsMap.icon] as string) ||
(item?.options?.apiFields?.icon as string) ||
(item?.options?.icon as string) ||
'';
if (iconSrc) img.src = iconSrc;
}
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.hasValue()) {
this.reassignTagsInputPlaceholder(this.placeholder);
}
this.unselectMultipleItems();
this.selectMultipleItems();
newItem.remove();
this.triggerChangeEventForNativeSelect();
});
this.wrapper.append(newItem);
}
private getItemByValue(val: string) {
if (this.apiUrl) {
const staticValue = this.staticOptions.find(
(el: ISingleOption) => el.val === val,
);
if (staticValue) return staticValue;
return (this.remoteOptions as (ISingleOption & IApiFieldMap)[]).find(
(el) =>
`${el[this.apiFieldsMap.val]}` === val ||
el[this.apiFieldsMap.title] === val,
);
}
return this.selectOptions.find((el: ISingleOption) => el.val === val);
}
private setTagsItems() {
if (this.value) {
const values = Array.isArray(this.value)
? this.value
: this.value != null
? [this.value]
: [];
values.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.tagsInput.setAttribute('tabindex', '-1');
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.hasValue() ? '' : 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) {
let optgroupName = '';
this.selectOptions.forEach((props: ISingleOption, i) => {
if (props.optgroupName && props.optgroupName !== optgroupName) {
this.hasOptgroup = true;
optgroupName = props.optgroupName;
this.buildOptgroup(optgroupName);
}
this.buildOption(
props.title,
props.val,
props.disabled,
props.selected,
props.options,
`${i}`,
undefined,
!!this.apiUrl,
);
});
optgroupName = '';
}
if (this.apiUrl) {
this.optionsFromRemoteData().then(() => this.fireLoadEvent());
}
if (!this.apiUrl) {
this.sortElements(this.el, 'option');
this.sortElements(this.dropdown, '[data-value]');
}
if (this.dropdownScope === 'window') this.buildFloatingUI();
if (this.dropdown && this.apiLoadMore) this.setupInfiniteScroll();
}
private buildOptgroup(name: string) {
const optgroup = htmlToElement(this.optgroupTag || '<div></div>');
optgroup.textContent = name;
optgroup.setAttribute('data-optgroup', '');
if (this.optgroupClasses) classToClassList(this.optgroupClasses, optgroup);
this.dropdown.append(optgroup);
}
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;
}
const tempRequestId = this.requestId;
this.isLoading = true;
try {
const url = new URL(this.apiUrl);
const tempQuery = (this.lastQuery ?? '').trim().toLowerCase();
const tempQueryParams = new URLSearchParams(this.apiQuery ?? '');
const tempSearchQuery = this.apiSearchQueryKey ?? 'q';
if (tempQuery !== '') tempQueryParams.set(tempSearchQuery, tempQuery);
url.search = tempQueryParams.toString();
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;
const nextPage = this.currentPage + 1;
if (isOffsetBased) {
const offset = this.currentPage * perPage;
url.searchParams.set(paginationParam, String(offset));
} else {
url.searchParams.set(paginationParam, String(nextPage));
}
url.searchParams.set(
this.apiFieldsMap?.limit || 'limit',
String(perPage),
);
if (this.loadMoreAbortController) {
try {
this.loadMoreAbortController.abort();
} catch {}
}
this.loadMoreAbortController = new AbortController();
const apiOpts = {
...(this.apiOptions || {}),
signal: this.loadMoreAbortController.signal,
};
const response = await fetch(url.toString(), apiOpts);
const data = await response.json();
if (tempRequestId !== this.requestId) {
this.isLoading = false;
return;
}
const items = this.apiDataPart
? (data[this.apiDataPart] ?? data.results ?? data)
: (data.results ?? data);
const getByPath = (obj: any, path: string) =>
path.split('.').reduce((o, k) => (o ? o[k] : undefined), obj);
let total: number | null = null;
if (this.apiTotalPath) {
const val = getByPath(data, this.apiTotalPath);
total = typeof val === 'number' ? val : null;
} else {
total =
(typeof data.count === 'number' ? data.count : null) ??
(typeof data.total === 'number' ? data.total : null) ??
(data.info && typeof data.info.count === 'number'
? data.info.count
: null);
}
if (items && items.length > 0) {
this.remoteOptions = [...(this.remoteOptions || []), ...items];
this.buildOptionsFromRemoteData(items);
if (typeof total === 'number') {
const consumed = nextPage * perPage;
this.hasMore = consumed < total;
} else {
this.hasMore = items.length === perPage;
}
this.currentPage = nextPage;
} else {
this.hasMore = false;
}
} catch (error) {
this.hasMore = false;
console.error('Error loading more options:', error);
} finally {
this.isLoading = false;
}
}
/**
* Positions the dropdown using Floating UI when `dropdownScope` is set to `"window"`.
*
* Requires `@floating-ui/dom` to be loaded on the page (e.g. via CDN or npm).
* Used by: `dropdownScope: "window"`, `dropdownPlacement`, `dropdownAutoPlacement`.
*
* @see https://floating-ui.com
*/
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 = () => {
Object.assign(this.dropdown.style, {
marginLeft: '',
marginTop: '',
marginRight: '',
marginBottom: '',
});
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.startsWith('bottom') ||
computedPlacement.startsWith('top')
? 'Top'
: computedPlacement.startsWith('right')
? 'Left'
: 'Right'
}`]: `${
computedPlacement.startsWith('top') ? '-' : ''
}${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() {
if (!this.hasSearch) return;
if (this.mode === 'tags') {
const tagsInput =
((this as any).tagsInput as HTMLInputElement) ??
(this.wrapper?.querySelector(
':scope input',
) as HTMLInputElement | null) ??
(this.el.querySelector(':scope input') as HTMLInputElement | null);
if (tagsInput) {
this.search = tagsInput;
if (this.searchPlaceholder) {
this.search.placeholder = this.searchPlaceholder;
}
if (this.searchId) this.search.id = this.searchId;
if (this.searchClasses) {
classToClassList(this.searchClasses, this.search);
}
if (this.apiUrl) {
this.onSearchInputListener = debounce((evt: InputEvent) =>
this.searchInput(evt),
);
this.search.addEventListener('input', this.onSearchInputListener);
}
return;
}
return;
}
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,
isStatic: boolean = false,
) {
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 (isStatic) option.setAttribute('data-static', 'true');
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) {
const iconValue = options.apiFields?.icon ?? options.icon;
const descriptionValue =
options.apiFields?.description ?? options.description;
if (iconValue) {
const img = htmlToElement(this.apiIconTag ?? (iconValue as string));
img.classList.add('max-w-full');
if (this.apiUrl || options.apiFields) {
img.setAttribute('alt', title);
img.setAttribute('src', iconValue as string);
}
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 (Array.isArray(options.additionalClasses)) {
options.additionalClasses.forEach(([selector, classes]) => {
const element = selector ? option.querySelector(selector) : option;
if (element) classes.forEach((cl) => element.classList.add(cl));
});
}
if (descriptionValue) {
option.dataset.description = descriptionValue as string;
if (template) {
descriptionWrapper = template.querySelector('[data-description]');
if (descriptionWrapper) {
descriptionWrapper.append(descriptionValue as string);
}
} else {
const description = htmlToElement('<div></div>');
description.textContent = descriptionValue as string;
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];
options.id = `${id}`;
}
if (el[this.apiFieldsMap.val]) {
value = `${el[this.apiFieldsMap.val]}`;
options.val = value;
}
if (el[this.apiFieldsMap.title]) {
title = el[this.apiFieldsMap.title] as string;
options.title = title;
if (!el[this.apiFieldsMap.val]) {
value = title;
options.val = value;
}
}
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];
});
// Check if option already exists in static options
const staticOptionIndex = this.staticOptions.findIndex(
(staticOpt: ISingleOption) => staticOpt.val === value,
);
if (staticOptionIndex !== -1) {
this.mergeRemoteDataIntoStaticOption(
staticOptionIndex,
value,
options,
i,
);
return;
}
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;
}
if (this.toggle) {
if (this.toggle.querySelector('[data-title]'))
this.setToggleTitle();
if (this.toggle.querySelector('[data-icon]')) this.setToggleIcon();
}
}
}
});
this.sortElements(this.el, 'option');
this.sortElements(this.dropdown, '[data-value]');
}
private mergeRemoteDataIntoStaticOption(
staticOptionIndex: number,
value: string,
remoteData: IApiFieldMap & { rest: { [key: string]: unknown } },
remoteIndex: number,
) {
const staticOption = this.staticOptions[staticOptionIndex];
if (!staticOption.options) {
staticOption.options = {};
}
if (!staticOption.options.apiFields) {
staticOption.options.apiFields = {};
}
if (remoteData.title) {
staticOption.title = remoteData.title as string;
}
if (remoteData.icon) {
staticOption.options.apiFields.icon = remoteData.icon;
}
if (remoteData.description) {
staticOption.options.apiFields.description = remoteData.description;
}
Object.keys(remoteData.rest).forEach((key) => {
staticOption.options.apiFields[key] = remoteData.rest[key];
});
const dropdownOption = this.dropdown.querySelector(
`[data-value="${value}"][data-static]`,
) as HTMLElement;
if (dropdownOption) {
dropdownOption.setAttribute('tabIndex', `${remoteIndex}`);
if (remoteData.id) {
dropdownOption.setAttribute('data-id', `${remoteData.id}`);
}
if (remoteData.title) {
dropdownOption.setAttribute(
'data-title-value',
remoteData.title as string,
);
const titleWrapper = dropdownOption.querySelector('[data-title]');
if (titleWrapper) {
titleWrapper.textContent = remoteData.title as string;
}
}
if (remoteData.icon) {
const iconWrapper = dropdownOption.querySelector('[data-icon]');
if (iconWrapper) {
iconWrapper.innerHTML = '';
const img = htmlToElement(
this.apiIconTag || '<img />',
) as HTMLImageElement;
img.classList.add('max-w-full');
img.setAttribute('alt', staticOption.title);
img.setAttribute('src', remoteData.icon as string);
iconWrapper.append(img);
}
}
if (remoteData.description) {
dropdownOption.dataset.description = remoteData.description as string;
const descWrapper = dropdownOption.querySelector('[data-description]');
if (descWrapper) {
descWrapper.textContent = remoteData.description as string;
}
}
}
const originalOption = this.el.querySelector(
`option[value="${value}"][data-static]`,
) as HTMLOptionElement;
if (originalOption) {
if (remoteData.id) {
originalOption.setAttribute('data-id', `${remoteData.id}`);
}
if (remoteData.title) {
originalOption.textContent = remoteData.title as string;
}
const optionData = {
id: remoteData.id || '',
val: remoteData.val || '',
title: remoteData.title || '',
icon: remoteData.icon || null,
description: remoteData.description || null,
rest: remoteData.rest,
};
originalOption.setAttribute(
'data-hs-select-option',
JSON.stringify(optionData),
);
}
const selectOptionIndex = this.selectOptions.findIndex(
(opt: ISingleOption) => opt.val === value,
);
if (selectOptionIndex !== -1) {
this.selectOptions[selectOptionIndex] = staticOption;
}
const isCurrentlySelected = this.isMultiple
? Array.isArray(this.value) && this.value.includes(value)
: this.value === value;
if (isCurrentlySelected && this.toggle) {
if (remoteData.title) {
if (this.toggle.querySelector('[data-title]')) {
this.setToggleTitle();
} else if (this.toggleTextWrapper) {
if (this.isMultiple) {
this.toggleTextWrapper.innerHTML = this.stringFromValue();
} else {
this.toggleTextWrapper.innerHTML = remoteData.title as string;
}
}
}
if (remoteData.icon && this.toggle.querySelector('[data-icon]')) {
this.setToggleIcon();
}
}
}
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 = '', signal?: AbortSignal): Promise<any> {
try {
const url = new URL(this.apiUrl);
const queryParams = new URLSearchParams(this.apiQuery ?? '');
const options = this.apiOptions ?? {};
const tempOptions = { ...(options as any) } as RequestInit;
if (signal) tempOptions.signal = signal;
const key = this.apiSearchQueryKey ?? 'q';
const trimmed = (val ?? '').trim().toLowerCase();
if (trimmed !== '') queryParams.set(key, trimmed);
if (this.apiLoadMore) {
const perPage =
typeof this.apiLoadMore === 'object' ? this.apiLoadMore.perPage : 10;
const pageKey =
this.apiFieldsMap?.page ?? this.apiFieldsMap?.offset ?? 'page';
const limitKey = this.apiFieldsMap?.limit ?? 'limit';
const isOffset = Boolean(this.apiFieldsMap?.offset);
const defaultStart = isOffset ? 0 : 1;
const pageStart =
typeof this.apiPageStart === 'number'
? this.apiPageStart
: defaultStart;
queryParams.delete(pageKey);
queryParams.delete(limitKey);
queryParams.set(pageKey, String(pageStart));
queryParams.set(limitKey, String(perPage));
}
url.search = queryParams.toString();
const res = await fetch(url.toString(), tempOptions);
const json = await res.json();
return this.apiDataPart ? json[this.apiDataPart] : json;
} catch (err) {
console.error(err);
}
}
private sortElements(container: HTMLElement, selector: string): void {
if (this.hasOptgroup) return;
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, index) => {
container.appendChild(item);
if (item.hasAttribute('tabindex')) {
item.setAttribute('tabIndex', `${index}`);
}
});
}
private async remoteSearch(val: string) {
this.requestId++;
if (this.remoteSearchAbortController) {
try {
this.remoteSearchAbortController.abort();
} catch (_) {}
}
if (this.loadMoreAbortController) {
try {
this.loadMoreAbortController.abort();
} catch {}
}
this.remoteSearchAbortController = new AbortController();
this.currentPage = 0;
this.hasMore = true;
this.isLoading = false;
this.filterStaticOptions(val);
if (val.length <= this.minSearchLength) {
const res = await this.apiRequest(
'',
this.remoteSearchAbortController?.signal,
);
if (!res) return false;
const isOffset = Boolean(this.apiFieldsMap?.offset);
const defaultStart = isOffset ? 0 : 1;
const pageStart =
typeof this.apiPageStart === 'number'
? this.apiPageStart
: defaultStart;
this.currentPage = pageStart;
const currentlySelectedValsOnClear = Array.isArray(this.value)
? this.value
: this.value
? [this.value]
: [];
const prevSelectedRemoteOnClear = (
this.remoteOptions as IApiFieldMap[]
).filter((el) =>
currentlySelectedValsOnClear.includes(`${el[this.apiFieldsMap?.val]}`),
);
const resValSetOnClear = new Set(
res.map((el: IApiFieldMap) => `${el[this.apiFieldsMap?.val]}`),
);
this.remoteOptions = [
...res,
...prevSelectedRemoteOnClear.filter(
(el) => !resValSetOnClear.has(`${el[this.apiFieldsMap?.val]}`),
),
];
Array.from(
this.dropdown.querySelec