UNPKG

flyonui

Version:

The easiest, free and open-source Tailwind CSS component library with semantic classes.

1,491 lines (1,224 loc) 66.7 kB
/* * HSSelect * @version: 3.2.2 * @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 { type Strategy, computePosition, autoUpdate, offset, flip } from '@floating-ui/dom' 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 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 mode: string | null private readonly viewport: HTMLElement | null 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 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 disabledObserver: MutationObserver | null = null 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-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-base-100 sticky top-0 mb-2 px-2 pt-3' this.searchId = concatOptions?.searchId || null this.searchLimit = concatOptions?.searchLimit || Infinity this.isSearchDirectMatch = typeof concatOptions?.isSearchDirectMatch !== 'undefined' ? concatOptions?.isSearchDirectMatch : true this.searchClasses = concatOptions?.searchClasses || 'border-base-content/40 focus:border-primary focus:outline-primary bg-base-100 block w-full rounded-field border px-3 py-2 text-base focus:outline-1' this.searchPlaceholder = concatOptions?.searchPlaceholder || 'Search...' this.searchNoResultTemplate = concatOptions?.searchNoResultTemplate || '<span></span>' this.searchNoResultText = concatOptions?.searchNoResultText || 'No results found' this.searchNoResultClasses = concatOptions?.searchNoResultClasses || 'block advance-select-option' 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 ?? false this.animationInProcess = false this.selectOptions = [] 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-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.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() { 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' 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-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) 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('advance-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 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.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 && 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.hasValue()) { 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) { 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-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.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 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 } } // This method is updated in FlyonUI and will work natively: private buildFloatingUI() { document.body.appendChild(this.dropdown) const reference = this.mode === 'tags' ? this.wrapper : this.toggle const middleware = [offset(0)] if (this.dropdownAutoPlacement) { middleware.push( flip({ fallbackPlacements: ['bottom-start', 'bottom-end', 'top-start', 'top-end'] }) ) } const options = { placement: POSITIONS[this.dropdownPlacement] || 'bottom', strategy: 'fixed' as Strategy, middleware } const update = () => { 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 = autoUpdate(reference, this.dropdown, update) this.floatingUIInstance = { update, destroy: cleanup } } 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 = ''): Promise<any> { try { const url = new URL(this.apiUrl) const queryParams = new URLSearchParams(this.apiQuery ?? '') const options = this.apiOptions ?? {} const key = this.apiSearchQueryKey ?? 'q' const trimmed = (val ?? '').trim().toLowerCase() if (trimmed !== '') queryParams.set(key, encodeURIComponent(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) queryParams.delete(pageKey) queryParams.delete(limitKey) queryParams.set(pageKey, isOffset ? '0' : '1') queryParams.set(limitKey, String(perPage)) } url.search = queryParams.toString() const res = await fetch(url.toString(), options) const json = await res.json() return this.apiDataPart ? json[this.apiDataPart] : json } 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-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-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) { if (!Array.isArray(this.value)) this.value = [] this.value = this.value.includes(val) ? this.value.filter(el => el !== val) : [...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.hasValue() && 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.advance.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.hasValue()) { 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 (Array.isArray(this.value) && 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 (Array.isArray(this.value) && 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') this.sortElements(this.el, 'option') this.sortElements(this.dropdown, '[data-value]') } private selectMultipleItems() { if (!Array.isArray(this.value)) return 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))