flyonui
Version:
The easiest, free and open-source Tailwind CSS component library with semantic classes.
1,467 lines (1,185 loc) • 58.1 kB
text/typescript
/*
* 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