UNPKG

flyonui

Version:

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

1,467 lines (1,185 loc) 58.1 kB
/* * HSSelect * @version: 3.0.1 * @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 './interfaces' import { type Strategy, autoUpdate, computePosition, offset } from '@floating-ui/dom' import { ICollectionItem } from '../../interfaces' import HSBasePlugin from '../base-plugin' 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 apiFieldsMap: IApiFieldMap | null private readonly apiIconTag: 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 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 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 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.apiFieldsMap = concatOptions?.apiFieldsMap || null this.apiIconTag = concatOptions?.apiIconTag || null 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.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.animationInProcess = false this.selectOptions = [] this.remoteOptions = [] this.tagsInputHelper = null 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) { 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.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-select-option') this.selectOptions = [ ...this.selectOptions, { title: el.textContent, val: el.value, disabled: el.disabled, options: data !== 'undefined' ? JSON.parse(data) : null } ] }) } 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('advance-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() 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) icon.classList.add('hidden') else icon.classList.remove('hidden') } } private setToggleTitle() { const title = this.toggle.querySelector('[data-title]') if (title) { title.innerHTML = this.getItemByValue(this.value as string)?.title || this.placeholder title.classList.add('truncate') this.toggle.append(title) } else { this.toggle.innerText = this.getItemByValue(this.value as string)?.title || this.placeholder } } 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') 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-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() } private buildFloatingUI() { // If it’s okay to append to the document: document.body.appendChild(this.dropdown) const reference = this.mode === 'tags' ? this.wrapper : this.toggle const options = { placement: POSITIONS[this.dropdownPlacement] || 'bottom', strategy: 'fixed' as Strategy, middleware: [offset(0)] } const update = () => { computePosition(reference, this.dropdown, options).then( ({ x, y, placement: computedPlacement }: { x: number; y: number; placement: string }) => { Object.assign(this.dropdown.style, { position: 'fixed', left: `${x}px`, top: `${y}px` }) this.dropdown.setAttribute('data-placement', computedPlacement) } ) } // Initial positioning update() // Auto-update on scroll/resize/layout changes 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] || el[this.apiFieldsMap.title]) { value = (el[this.apiFieldsMap.val] as string) || (el[this.apiFieldsMap.title] as string) } if (el[this.apiFieldsMap.title]) title = el[this.apiFieldsMap.title] as string 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 isSelected = (typeof this.value === 'string' && this.value === `${value}`) || (Array.isArray(this.value) && this.value.includes(`${value}`)) this.buildOriginalOption(title, `${value}`, id, false, isSelected, options as ISingleOptionOptions & IApiFieldMap) this.buildOptionFromRemoteData( title, `${value}`, false, isSelected, `${i}`, id, options as ISingleOptionOptions & IApiFieldMap ) }) 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 || {} if (search) url += `?${search}` if (this.apiQuery) url += `${search ? '&' : '?'}${query}` 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)) 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('There is no data were 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) { 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) // TODO:: test with this line commented out // dispatch('change.advance.select', this.el, 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.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.value?.length) { 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', this.onTagsInputKeydownListener) } if (this.search) this.search.removeEventListener('input', this.onSearchInputListener) const parent = this.el.parentElement.parentElement this.el.classList.add('hidden') this.el.style.display = '' parent.prepend(this.el) parent.querySelector('.advance-select').remove() this.wrapper = null window.$hsSelectCollection = window.$hsSelectCollection.filter(({ element }) => element.el !== this.el) } public open() { const currentlyOpened = window?.$hsSelectCollection?.find(el => el.element.isOpened) || null if (currentlyOpened) currentlyOpened.element.close() if (this.animationInProcess) return false this.animationInProcess = true if (this.dropdownScope === 'window') this.dropdown.classList.add('invisible') this.dropdown.classList.remove('hidden') this.recalculateDirection() setTimeout(() => { if (this?.toggle?.ariaExpanded) this.toggle.ariaExpanded = 'true' this.wrapper.classList.add('active') this.dropdown.classList.add('opened') if (this.dropdown.classList.contains('w-full') && this.dropdownScope === 'window') this.updateDropdownWidth() if (this.floatingUIInstance && this.dropdownScope === 'window') { this.floatingUIInstance.update() this.dropdown.classList.remove('invisible') } if (this.hasSearch && !this.preventSearchFocus) this.search.focus() this.animationInProcess = false }) this.isOpened = true } public close(forceFocus = false) { if (this.animationInProcess) return false this.animationInProcess = true if (this?.toggle?.ariaExpanded) this.toggle.ariaExpanded = 'false' this.wrapper.classList.remove('active') this.dropdown.classList.remove('opened', 'bottom-full', 'top-full') if (this.dropdownDirectionClasses?.bottom) this.dropdown.classList.remove(this.dropdownDirectionClasses.bottom) if (this.dropdownDirectionClasses?.top) this.dropdown.classList.remove(this.dropdownDirectionClasses.top) this.dropdown.style.marginTop = '' this.dropdown.style.marginBottom = '' afterTransition(this.dropdown, () => { this.dropdown.classList.add('hidden') if (this.hasSearch) { this.search.value = '' this.search.dispatchEvent(new Event('input', { bubbles: true })) this.search.blur() } if (forceFocus) this.toggle.focus() this.animationInProcess = false }) this.dropdown.querySelector('.select-option-highlighted')?.classList.remove('select-option-highlighted') this.isOpened = false } public addOption(items: ISingleOption | ISingleOption[]) { let i = `${this.selectOptions.length}` const addOption = (option: ISingleOption) => { const { title, val, disabled, selected, options } = option const hasOption = !!this.selectOptions.some((el: ISingleOption) => el.val === val) if (!hasOption) { this.addSelectOption(title, val, disabled, selected, options) this.buildOption(title, val, disabled, selected, options, i) this.buildOriginalOption(title, val, null, disabled, selected, options) if (selected && !this.isMultiple) this.onSelectOption(val) } } if (Array.isArray(items)) { items.forEach(option => { addOption(option) }) } else { addOption(items) } } public removeOption(values: string | string[]) { const removeOption = (val: string, isArray = false) => { const hasOption = !!this.selectOptions.some((el: ISingleOption) => el.val === val) if (hasOption) { this.removeSelectOption(val, isArray) this.destroyOption(val) this.destroyOriginalOption(val) if (this.value === val) { this.value = null this.eraseToggleTitle() this.eraseToggleIcon() } } } if (Array.isArray(values)) { values.forEach(val => { removeOption(val, this.isMultiple) }) } else { removeOption(values, this.isMultiple) } this.setNewValue() } public recalculateDirection() { if ( this?.dropdownVerticalFixedPlacement && (this.dropdown.classList.contains('bottom-full') || this.dropdown.classList.contains('top-full')) ) return false if (this?.dropdownVerticalFixedPlacement === 'top') { this.dropdown.classList.add('bottom-full') this.dropdown.style.marginBottom = `${this.dropdownSpace}px` } else if (this?.dropdownVerticalFixedPlacement === 'bottom') { this.dropdown.classList.add('top-full') this.dropdown.style.marginTop = `${this.dropdownSpace}px` } else if ( isEnoughSpace(this.dropdown, this.toggle || this.tagsInput, 'bottom', this.dropdownSpace, this.viewport) ) { this.dropdown.classList.remove('bottom-full') if (this.dropdownDirectionClasses?.bottom) this.dropdown.classList.remove(this.dropdownDirectionClasses.bottom) this.dropdown.style.marginBottom = '' this.dropdown.classList.add('top-full') if (this.dropdownDirectionClasses?.top) this.dropdown.classList.add(this.dropdownDirectionClasses.top) this.dropdown.style.marginTop = `${this.dropdownSpace}px` } else { this.dropdown.classList.remove('top-full') if (this.dropdownDirectionClasses?.top) this.dropdown.classList.remove(this.dropdownDirectionClasses.top) this.dropdown.style.marginTop = '' this.dropdown.classList.add('bottom-full') if (this.dropdownDirectionClasses?.bottom) this.dropdown.classList.add(this.dropdownDirectionClasses.bottom) this.dropdown.style.marginBottom = `${this.dropdownSpace}px` } } // Static methods private static findInCollection(target: HSSelect | HTMLElement | string): ICollectionItem<HSSelect> | null { return ( window.$hsSelectCollection.find(el => { if (target instan