flyonui
Version:
The easiest, free and open-source Tailwind CSS component library with semantic classes.
1,194 lines (953 loc) • 37.3 kB
text/typescript
/*
* HSComboBox
* @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, debounce, dispatch, htmlToElement, isEnoughSpace } from '../../utils'
import { IComboBox, IComboBoxItemAttr, IComboBoxOptions } from './interfaces'
import HSBasePlugin from '../base-plugin'
import { ICollectionItem } from '../../interfaces'
import { IAccessibilityComponent } from '../accessibility-manager/interfaces'
import HSAccessibilityObserver from '../accessibility-manager'
class HSComboBox extends HSBasePlugin<IComboBoxOptions> implements IComboBox {
gap: number
viewport: string | HTMLElement | null
preventVisibility: boolean
minSearchLength: number
apiUrl: string | null
apiDataPart: string | null
apiQuery: string | null
apiSearchQuery: string | null
apiSearchPath: string | null
apiSearchDefaultPath: string | null
apiHeaders: {}
apiGroupField: string | null
outputItemTemplate: string | null
outputEmptyTemplate: string | null
outputLoaderTemplate: string | null
groupingType: 'default' | 'tabs' | null
groupingTitleTemplate: string | null
tabsWrapperTemplate: string | null
preventSelection: boolean
preventAutoPosition: boolean
preventClientFiltering: boolean
isOpenOnFocus: boolean
keepOriginalOrder: boolean
preserveSelectionOnEmpty: boolean
private accessibilityComponent: IAccessibilityComponent
private readonly input: HTMLInputElement | null
private readonly output: HTMLElement | null
private readonly itemsWrapper: HTMLElement | null
private items: HTMLElement[]
private tabs: HTMLElement[] | []
private readonly toggle: HTMLElement | null
private readonly toggleClose: HTMLElement | null
private readonly toggleOpen: HTMLElement | null
private outputPlaceholder: HTMLElement | null
private outputLoader: HTMLElement | null
private value: string | null
private selected: string | null
private currentData: {} | {}[] | null
private groups: any[] | null
private selectedGroup: string | null
isOpened: boolean
isCurrent: boolean
private animationInProcess: boolean
private isSearchLengthExceeded = false
private onInputFocusListener: () => void
private onInputInputListener: (evt: InputEvent) => void
private onToggleClickListener: () => void
private onToggleCloseClickListener: () => void
private onToggleOpenClickListener: () => void
constructor(el: HTMLElement, options?: IComboBoxOptions, events?: {}) {
super(el, options, events)
// Data parameters
const data = el.getAttribute('data-combo-box')
const dataOptions: IComboBoxOptions = data ? JSON.parse(data) : {}
const concatOptions = {
...dataOptions,
...options
}
this.gap = 6
this.viewport =
(typeof concatOptions?.viewport === 'string'
? (document.querySelector(concatOptions?.viewport) as HTMLElement)
: concatOptions?.viewport) ?? null
this.preventVisibility = concatOptions?.preventVisibility ?? false
this.minSearchLength = concatOptions?.minSearchLength ?? 0
this.apiUrl = concatOptions?.apiUrl ?? null
this.apiDataPart = concatOptions?.apiDataPart ?? null
this.apiQuery = concatOptions?.apiQuery ?? null
this.apiSearchQuery = concatOptions?.apiSearchQuery ?? null
this.apiSearchPath = concatOptions?.apiSearchPath ?? null
this.apiSearchDefaultPath = concatOptions?.apiSearchDefaultPath ?? null
this.apiHeaders = concatOptions?.apiHeaders ?? {}
this.apiGroupField = concatOptions?.apiGroupField ?? null
this.outputItemTemplate =
concatOptions?.outputItemTemplate ??
`<div class="dropdown-item combo-box-selected:dropdown-active" data-combo-box-output-item>
<div class="flex justify-between items-center w-full">
<span data-combo-box-search-text></span>
<span class="hidden combo-box-selected:block">
<svg class="shrink-0 size-4 text-primary" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m5 12l5 5L20 7"/></svg>
</span>
</div>
</div>`
this.outputEmptyTemplate = concatOptions?.outputEmptyTemplate ?? `<div class="dropdown-item">Nothing found...</div>`
this.outputLoaderTemplate =
concatOptions?.outputLoaderTemplate ?? `<span class="loading loading-spinner text-primary"></span>`
this.groupingType = concatOptions?.groupingType ?? null
this.groupingTitleTemplate =
concatOptions?.groupingTitleTemplate ??
(this.groupingType === 'default'
? `<div class="block mb-1 text-xs font-semibold uppercase text-primary"></div>`
: `<button type="button" class="btn btn-soft btn-primary"></button>`)
this.tabsWrapperTemplate = concatOptions?.tabsWrapperTemplate ?? `<div class="overflow-x-auto p-4"></div>`
this.preventSelection = concatOptions?.preventSelection ?? false
this.preventAutoPosition = concatOptions?.preventAutoPosition ?? false
this.preventClientFiltering =
options?.preventClientFiltering ?? (!!concatOptions?.apiSearchQuery || !!concatOptions?.apiSearchPath)
this.isOpenOnFocus = concatOptions?.isOpenOnFocus ?? false
this.keepOriginalOrder = concatOptions?.keepOriginalOrder ?? false
this.preserveSelectionOnEmpty = concatOptions?.preserveSelectionOnEmpty ?? true
// Internal parameters
this.input = this.el.querySelector('[data-combo-box-input]') ?? null
this.output = this.el.querySelector('[data-combo-box-output]') ?? null
this.itemsWrapper = this.el.querySelector('[data-combo-box-output-items-wrapper]') ?? null
this.items = Array.from(this.el.querySelectorAll('[data-combo-box-output-item]')) ?? []
this.tabs = []
this.toggle = this.el.querySelector('[data-combo-box-toggle]') ?? null
this.toggleClose = this.el.querySelector('[data-combo-box-close]') ?? null
this.toggleOpen = this.el.querySelector('[data-combo-box-open]') ?? null
this.outputPlaceholder = null
this.selected = this.value = (this.el.querySelector('[data-combo-box-input]') as HTMLInputElement).value ?? ''
this.currentData = null
this.isOpened = false
this.isCurrent = false
this.animationInProcess = false
this.selectedGroup = 'all'
this.init()
}
private inputFocus() {
if (!this.isOpened) {
this.setResultAndRender()
this.open()
}
}
private inputInput(evt: InputEvent) {
const val = (evt.target as HTMLInputElement).value.trim()
if (val.length <= this.minSearchLength) this.setResultAndRender('')
else this.setResultAndRender(val)
if (!this.preserveSelectionOnEmpty && val === '') {
this.selected = ''
this.value = ''
this.currentData = null
}
if (this.input.value !== '') this.el.classList.add('has-value')
else this.el.classList.remove('has-value')
if (!this.isOpened) this.open()
}
private toggleClick() {
if (this.isOpened) this.close()
else this.open(this.toggle.getAttribute('data-combo-box-toggle'))
}
private toggleCloseClick() {
this.close()
}
private toggleOpenClick() {
this.open()
}
private init() {
this.createCollection(window.$hsComboBoxCollection, this)
this.build()
if (typeof window !== 'undefined') {
if (!window.HSAccessibilityObserver) {
window.HSAccessibilityObserver = new HSAccessibilityObserver()
}
this.setupAccessibility()
}
}
private build() {
this.buildInput()
if (this.groupingType) this.setGroups()
this.buildItems()
if (this.preventVisibility) {
// TODO:: test the plugin while the line below is commented.
// this.isOpened = true;
if (!this.preventAutoPosition) this.recalculateDirection()
}
if (this.toggle) this.buildToggle()
if (this.toggleClose) this.buildToggleClose()
if (this.toggleOpen) this.buildToggleOpen()
}
private getNestedProperty<T>(obj: T, path: string): any {
return path.split('.').reduce((acc: any, key: string) => acc && acc[key], obj)
}
private setValue(val: string, data: {} | null = null) {
this.selected = val
this.value = val
this.input.value = val
if (data) this.currentData = data
this.fireEvent('select', this.currentData)
dispatch('select.combobox', this.el, this.currentData)
}
private setValueAndOpen(val: string) {
this.value = val
if (this.items.length) {
this.setItemsVisibility()
}
}
private setValueAndClear(val: string | null, data: {} | null = null) {
if (val) this.setValue(val, data)
else this.setValue(this.selected, data)
if (this.outputPlaceholder) this.destroyOutputPlaceholder()
}
private setSelectedByValue(val: string[]) {
this.items.forEach(el => {
const valueElement = el.querySelector('[data-combo-box-value]')
if (valueElement && val.includes(valueElement.textContent)) {
;(el as HTMLElement).classList.add('selected')
} else {
;(el as HTMLElement).classList.remove('selected')
}
})
}
private setResultAndRender(value = '') {
// TODO:: test the plugin with below code added.
let _value = this.preventVisibility ? this.input.value : value
this.setResults(_value)
if (this.apiSearchQuery || this.apiSearchPath || this.apiSearchDefaultPath) this.itemsFromJson()
if (_value === '') this.isSearchLengthExceeded = true
else this.isSearchLengthExceeded = false
this.updatePlaceholderVisibility()
}
private setResults(val: string) {
this.value = val
this.resultItems()
this.updatePlaceholderVisibility()
}
private updatePlaceholderVisibility() {
if (this.hasVisibleItems()) this.destroyOutputPlaceholder()
else this.buildOutputPlaceholder()
}
private setGroups() {
const groups: any[] = []
this.items.forEach((item: HTMLElement) => {
const { group } = JSON.parse(item.getAttribute('data-combo-box-output-item'))
if (!groups.some(el => el?.name === group.name)) {
groups.push(group)
}
})
this.groups = groups
}
private setApiGroups(items: any) {
const groups: any[] = []
items.forEach((item: any) => {
const group = item[this.apiGroupField]
if (!groups.some(el => el.name === group)) {
groups.push({
name: group,
title: group
})
}
})
this.groups = groups
}
private setItemsVisibility() {
if (this.preventClientFiltering) {
this.items.forEach(el => {
;(el as HTMLElement).style.display = ''
})
return false
}
if (this.groupingType === 'tabs' && this.selectedGroup !== 'all') {
this.items.forEach(item => {
;(item as HTMLElement).style.display = 'none'
})
}
const items =
this.groupingType === 'tabs'
? this.selectedGroup === 'all'
? this.items
: this.items.filter((f: HTMLElement) => {
const { group } = JSON.parse(f.getAttribute('data-combo-box-output-item'))
return group.name === this.selectedGroup
})
: this.items
if (this.groupingType === 'tabs' && this.selectedGroup !== 'all') {
items.forEach(item => {
;(item as HTMLElement).style.display = 'block'
})
}
items.forEach(item => {
if (!this.isTextExistsAny(item, this.value)) {
;(item as HTMLElement).style.display = 'none'
} else (item as HTMLElement).style.display = 'block'
})
if (this.groupingType === 'default') {
this.output.querySelectorAll('[data-combo-box-group-title]').forEach((el: HTMLElement) => {
const g = el.getAttribute('data-combo-box-group-title')
const items = this.items.filter((f: HTMLElement) => {
const { group } = JSON.parse(f.getAttribute('data-combo-box-output-item'))
return group.name === g && f.style.display === 'block'
})
if (items.length) el.style.display = 'block'
else el.style.display = 'none'
})
}
}
private isTextExistsAny(el: HTMLElement, val: string): boolean {
return Array.from(el.querySelectorAll('[data-combo-box-search-text]')).some((elI: HTMLElement) =>
elI.getAttribute('data-combo-box-search-text').toLowerCase().includes(val.toLowerCase())
)
}
private hasVisibleItems() {
if (!this.items.length) return false
return this.items.some((el: HTMLElement) => {
const style = window.getComputedStyle(el)
return style.display !== 'none' && style.visibility !== 'hidden'
})
}
private valuesBySelector(el: HTMLElement) {
return Array.from(el.querySelectorAll('[data-combo-box-search-text]')).reduce(
(acc: any, cur: HTMLElement) => [...acc, cur.getAttribute('data-combo-box-search-text')],
[]
)
}
private sortItems() {
if (this.keepOriginalOrder) return this.items
const compareFn = (i1: HTMLElement, i2: HTMLElement) => {
const a = i1.querySelector('[data-combo-box-value]').textContent
const b = i2.querySelector('[data-combo-box-value]').textContent
if (a < b) {
return -1
} else if (a > b) {
return 1
}
return 0
}
return this.items.sort(compareFn)
}
private buildInput() {
if (this.isOpenOnFocus) {
this.onInputFocusListener = () => this.inputFocus()
this.input.addEventListener('focus', this.onInputFocusListener)
}
this.onInputInputListener = debounce((evt: InputEvent) => this.inputInput(evt))
this.input.addEventListener('input', this.onInputInputListener)
}
private async buildItems() {
this.output.role = 'listbox'
this.output.tabIndex = -1
this.output.ariaOrientation = 'vertical'
if (this.apiUrl) await this.itemsFromJson()
else {
if (this.itemsWrapper) this.itemsWrapper.innerHTML = ''
else this.output.innerHTML = ''
this.itemsFromHtml()
}
if (this?.items.length && this.items[0].classList.contains('selected')) {
this.currentData = JSON.parse(this.items[0].getAttribute('data-combo-box-item-stored-data'))
}
}
private buildOutputLoader() {
if (this.outputLoader) return false
this.outputLoader = htmlToElement(this.outputLoaderTemplate)
if (this.items.length || this.outputPlaceholder) {
this.outputLoader.style.position = 'absolute'
this.outputLoader.style.top = '0'
this.outputLoader.style.bottom = '0'
this.outputLoader.style.left = '0'
this.outputLoader.style.right = '0'
this.outputLoader.style.zIndex = '2'
} else {
this.outputLoader.style.position = ''
this.outputLoader.style.top = ''
this.outputLoader.style.bottom = ''
this.outputLoader.style.left = ''
this.outputLoader.style.right = ''
this.outputLoader.style.zIndex = ''
this.outputLoader.style.height = '30px'
}
this.output.append(this.outputLoader)
}
private buildToggle() {
if (this.isOpened) {
if (this?.toggle?.ariaExpanded) this.toggle.ariaExpanded = 'true'
if (this?.input?.ariaExpanded) this.input.ariaExpanded = 'true'
} else {
if (this?.toggle?.ariaExpanded) this.toggle.ariaExpanded = 'false'
if (this?.input?.ariaExpanded) this.input.ariaExpanded = 'false'
}
this.onToggleClickListener = () => this.toggleClick()
this.toggle.addEventListener('click', this.onToggleClickListener)
}
private buildToggleClose() {
this.onToggleCloseClickListener = () => this.toggleCloseClick()
this.toggleClose.addEventListener('click', this.onToggleCloseClickListener)
}
private buildToggleOpen() {
this.onToggleOpenClickListener = () => this.toggleOpenClick()
this.toggleOpen.addEventListener('click', this.onToggleOpenClickListener)
}
private buildOutputPlaceholder() {
if (!this.outputPlaceholder) {
this.outputPlaceholder = htmlToElement(this.outputEmptyTemplate)
}
this.appendItemsToWrapper(this.outputPlaceholder)
}
private destroyOutputLoader() {
if (this.outputLoader) this.outputLoader.remove()
this.outputLoader = null
}
private itemRender(item: HTMLElement) {
const val = item.querySelector('[data-combo-box-value]').textContent
const data = JSON.parse(item.getAttribute('data-combo-box-item-stored-data')) ?? null
if (this.itemsWrapper) this.itemsWrapper.append(item)
else this.output.append(item)
if (!this.preventSelection) {
item.addEventListener('click', () => {
this.close(val, data)
this.setSelectedByValue(this.valuesBySelector(item))
})
}
}
private plainRender(items: HTMLElement[]) {
items.forEach((item: HTMLElement) => {
this.itemRender(item)
})
}
private jsonItemsRender(items: any) {
items.forEach((item: never, index: number) => {
const newItem = htmlToElement(this.outputItemTemplate)
newItem.setAttribute('data-combo-box-item-stored-data', JSON.stringify(item))
newItem.querySelectorAll('[data-combo-box-output-item-field]').forEach(el => {
const valueAttr = el.getAttribute('data-combo-box-output-item-field')
let value = ''
try {
const fields = JSON.parse(valueAttr)
if (Array.isArray(fields)) {
value = fields
.map(field => this.getNestedProperty(item, field))
.filter(Boolean)
.join(' ')
} else {
value = this.getNestedProperty(item, valueAttr)
}
} catch (e) {
value = this.getNestedProperty(item, valueAttr)
}
el.textContent = value ?? ''
if (!value && el.hasAttribute('data-combo-box-output-item-hide-if-empty')) {
;(el as HTMLElement).style.display = 'none'
}
})
newItem.querySelectorAll('[data-combo-box-search-text]').forEach(el => {
const valueAttr = el.getAttribute('data-combo-box-output-item-field')
let value = ''
try {
const fields = JSON.parse(valueAttr)
if (Array.isArray(fields)) {
value = fields
.map(field => this.getNestedProperty(item, field))
.filter(Boolean)
.join(' ')
} else {
value = this.getNestedProperty(item, valueAttr)
}
} catch (e) {
value = this.getNestedProperty(item, valueAttr)
}
el.setAttribute('data-combo-box-search-text', value ?? '')
})
newItem.querySelectorAll('[data-combo-box-output-item-attr]').forEach(el => {
const attributes = JSON.parse(el.getAttribute('data-combo-box-output-item-attr'))
attributes.forEach((attr: IComboBoxItemAttr) => {
let value: string = item[attr.valueFrom]
if (attr.attr === 'class' && el.className) {
el.className = `${el.className} ${value}`.trim()
} else {
el.setAttribute(attr.attr, value)
}
})
})
newItem.setAttribute('tabIndex', `${index}`)
if (this.groupingType === 'tabs' || this.groupingType === 'default') {
newItem.setAttribute(
'data-combo-box-output-item',
`{"group": {"name": "${item[this.apiGroupField]}", "title": "${item[this.apiGroupField]}"}}`
)
}
this.items = [...this.items, newItem]
if (!this.preventSelection) {
;(newItem as HTMLElement).addEventListener('click', () => {
this.close(
(newItem as HTMLElement).querySelector('[data-combo-box-value]').textContent,
JSON.parse((newItem as HTMLElement).getAttribute('data-combo-box-item-stored-data'))
)
this.setSelectedByValue(this.valuesBySelector(newItem))
})
}
this.appendItemsToWrapper(newItem)
})
}
private groupDefaultRender() {
this.groups.forEach(el => {
const title = htmlToElement(this.groupingTitleTemplate)
title.setAttribute('data-combo-box-group-title', el.name)
title.classList.add('--exclude-accessibility')
title.innerText = el.title
if (this.itemsWrapper) this.itemsWrapper.append(title)
else this.output.append(title)
const items = this.sortItems().filter(f => {
const { group } = JSON.parse(f.getAttribute('data-combo-box-output-item'))
return group.name === el.name
})
this.plainRender(items)
})
}
private groupTabsRender() {
const tabsScroll = htmlToElement(this.tabsWrapperTemplate)
const tabsWrapper = htmlToElement(`<div class="flex flex-nowrap gap-x-2"></div>`)
tabsScroll.append(tabsWrapper)
this.output.insertBefore(tabsScroll, this.output.firstChild)
const tabDef = htmlToElement(this.groupingTitleTemplate)
tabDef.setAttribute('data-combo-box-group-title', 'all')
tabDef.classList.add('--exclude-accessibility', 'active')
tabDef.innerText = 'All'
this.tabs = [...this.tabs, tabDef]
tabsWrapper.append(tabDef)
tabDef.addEventListener('click', () => {
this.selectedGroup = 'all'
const selectedTab = this.tabs.find(
(elI: HTMLElement) => elI.getAttribute('data-combo-box-group-title') === this.selectedGroup
)
this.tabs.forEach((el: HTMLElement) => el.classList.remove('active'))
selectedTab.classList.add('active')
this.setItemsVisibility()
})
this.groups.forEach(el => {
const tab = htmlToElement(this.groupingTitleTemplate)
tab.setAttribute('data-combo-box-group-title', el.name)
tab.classList.add('--exclude-accessibility')
tab.innerText = el.title
this.tabs = [...this.tabs, tab]
tabsWrapper.append(tab)
tab.addEventListener('click', () => {
this.selectedGroup = el.name
const selectedTab = this.tabs.find(
(elI: HTMLElement) => elI.getAttribute('data-combo-box-group-title') === this.selectedGroup
)
this.tabs.forEach((el: HTMLElement) => el.classList.remove('active'))
selectedTab.classList.add('active')
this.setItemsVisibility()
})
})
}
private itemsFromHtml() {
if (this.groupingType === 'default') {
this.groupDefaultRender()
} else if (this.groupingType === 'tabs') {
const items = this.sortItems()
this.groupTabsRender()
this.plainRender(items)
} else {
const items = this.sortItems()
this.plainRender(items)
}
this.setResults(this.input.value)
}
private async itemsFromJson() {
if (this.isSearchLengthExceeded) {
this.buildOutputPlaceholder()
return false
}
this.buildOutputLoader()
try {
const query = `${this.apiQuery}`
let searchQuery
let searchPath
let url = this.apiUrl
if (!this.apiSearchQuery && this.apiSearchPath) {
if (this.apiSearchDefaultPath && this.value === '') {
searchPath = `/${this.apiSearchDefaultPath}`
} else {
searchPath = `/${this.apiSearchPath}/${this.value.toLowerCase()}`
}
if (this.apiSearchPath || this.apiSearchDefaultPath) {
url += searchPath
}
} else {
searchQuery = `${this.apiSearchQuery}=${this.value.toLowerCase()}`
if (this.apiQuery && this.apiSearchQuery) {
url += `?${searchQuery}&${query}`
} else if (this.apiQuery) {
url += `?${query}`
} else if (this.apiSearchQuery) {
url += `?${searchQuery}`
}
}
const res = await fetch(url, this.apiHeaders)
if (!res.ok) {
this.items = []
if (this.itemsWrapper) this.itemsWrapper.innerHTML = ''
else this.output.innerHTML = ''
this.setResults(this.input.value)
return
}
let items = await res.json()
if (this.apiDataPart) {
items = items[this.apiDataPart]
}
if (!Array.isArray(items)) {
items = []
}
if (this.apiSearchQuery || this.apiSearchPath) {
this.items = []
}
if (this.itemsWrapper) {
this.itemsWrapper.innerHTML = ''
} else {
this.output.innerHTML = ''
}
if (this.groupingType === 'tabs') {
this.setApiGroups(items)
this.groupTabsRender()
this.jsonItemsRender(items)
} else if (this.groupingType === 'default') {
this.setApiGroups(items)
this.groups.forEach(el => {
const title = htmlToElement(this.groupingTitleTemplate)
title.setAttribute('data-combo-box-group-title', el.name)
title.classList.add('--exclude-accessibility')
title.innerText = el.title
const newItems = items.filter((i: any) => i[this.apiGroupField] === el.name)
if (this.itemsWrapper) this.itemsWrapper.append(title)
else this.output.append(title)
this.jsonItemsRender(newItems)
})
} else {
this.jsonItemsRender(items)
}
this.setResults(this.input.value.length <= this.minSearchLength ? '' : this.input.value)
this.updatePlaceholderVisibility()
} catch (err) {
console.error('Error fetching items:', err)
this.items = []
if (this.itemsWrapper) {
this.itemsWrapper.innerHTML = ''
} else {
this.output.innerHTML = ''
}
this.setResults(this.input.value)
} finally {
this.destroyOutputLoader()
}
}
private appendItemsToWrapper(item: HTMLElement) {
if (this.itemsWrapper) {
this.itemsWrapper.append(item)
} else {
this.output.append(item)
}
}
private resultItems() {
if (!this.items.length) return false
this.setItemsVisibility()
this.setSelectedByValue([this.selected])
}
private destroyOutputPlaceholder() {
if (this.outputPlaceholder) this.outputPlaceholder.remove()
this.outputPlaceholder = null
}
private getPreparedItems(isReversed = false, output: HTMLElement | null): Element[] | null {
if (!output) return null
const preparedItems = isReversed
? Array.from(output.querySelectorAll(':scope > *:not(.--exclude-accessibility)'))
.filter(el => (el as HTMLElement).style.display !== 'none')
.reverse()
: Array.from(output.querySelectorAll(':scope > *:not(.--exclude-accessibility)')).filter(
el => (el as HTMLElement).style.display !== 'none'
)
const items = preparedItems.filter((el: any) => !el.classList.contains('disabled'))
return items
}
private setHighlighted(prev: Element, current: HTMLElement, input: HTMLInputElement): void {
current.focus()
input.value = current.querySelector('[data-combo-box-value]').getAttribute('data-combo-box-search-text')
if (prev) prev.classList.remove('combo-box-output-item-highlighted')
current.classList.add('combo-box-output-item-highlighted')
}
// Accessibility methods
private setupAccessibility(): void {
const output = this.itemsWrapper ?? this.output
this.accessibilityComponent = window.HSAccessibilityObserver.registerComponent(
this.el,
{
onEnter: () => this.onEnter(),
onSpace: () => this.onEnter(),
onEsc: () => {
if (this.isOpened) {
this.close()
if (this.input) this.input.focus()
}
},
onArrow: (evt: KeyboardEvent) => {
if (!this.isOpened && evt.key === 'ArrowDown') {
this.open()
return
}
if (this.isOpened) {
switch (evt.key) {
case 'ArrowDown':
this.focusMenuItem('next')
break
case 'ArrowUp':
this.focusMenuItem('prev')
break
case 'Home':
this.onStartEnd(true)
break
case 'End':
this.onStartEnd(false)
break
}
}
}
// onTab: () => this.onTab(),
// onFirstLetter: (key: string) => this.onFirstLetter(key),
},
this.isOpened,
'ComboBox',
'[data-combo-box]',
output
)
}
private onEnter(): void {
if (!this.isOpened) {
this.open()
} else {
const highlighted = this.output.querySelector('.combo-box-output-item-highlighted')
if (highlighted) {
this.close(
highlighted.querySelector('[data-combo-box-value]')?.getAttribute('data-combo-box-search-text') ?? null,
JSON.parse(highlighted.getAttribute('data-combo-box-item-stored-data')) ?? null
)
if (this.input) this.input.focus()
}
}
}
private focusMenuItem(direction: 'next' | 'prev') {
const output = this.itemsWrapper ?? this.output
if (!output) return false
const options = Array.from(output.querySelectorAll(':scope > *:not(.--exclude-accessibility)')).filter(
el => (el as HTMLElement).style.display !== 'none'
)
if (!options.length) return false
const current = output.querySelector('.combo-box-output-item-highlighted')
const currentIndex = current ? options.indexOf(current) : -1
const nextIndex =
direction === 'next' ? (currentIndex + 1) % options.length : (currentIndex - 1 + options.length) % options.length
if (current) {
current.classList.remove('combo-box-output-item-highlighted')
}
options[nextIndex].classList.add('combo-box-output-item-highlighted')
;(options[nextIndex] as HTMLElement).focus()
this.input.value = options[nextIndex]
.querySelector('[data-combo-box-value]')
.getAttribute('data-combo-box-search-text')
}
private onStartEnd(isStart = true) {
const output = this.itemsWrapper ?? this.output
if (!output) return false
const options = Array.from(output.querySelectorAll(':scope > *:not(.--exclude-accessibility)')).filter(
el => (el as HTMLElement).style.display !== 'none'
)
if (!options.length) return false
const current = output.querySelector('.combo-box-output-item-highlighted')
this.setHighlighted(current, options[0] as HTMLButtonElement, this.input)
}
// Public methods
public getCurrentData() {
return this.currentData
}
public setCurrent() {
if (window.$hsComboBoxCollection.length) {
window.$hsComboBoxCollection.map(el => (el.element.isCurrent = false))
this.isCurrent = true
}
}
public open(val?: string) {
if (this.animationInProcess) return false
if (typeof val !== 'undefined') this.setValueAndOpen(val)
if (this.preventVisibility) return false
this.animationInProcess = true
this.output.style.display = 'block'
if (!this.preventAutoPosition) this.recalculateDirection()
setTimeout(() => {
if (this?.input?.ariaExpanded) this.input.ariaExpanded = 'true'
if (this?.toggle?.ariaExpanded) this.toggle.ariaExpanded = 'true'
this.el.classList.add('active')
this.animationInProcess = false
})
this.isOpened = true
if (window.HSAccessibilityObserver && this.accessibilityComponent) {
window.HSAccessibilityObserver.updateComponentState(this.accessibilityComponent, true)
}
}
public close(val?: string | null, data: {} | null = null) {
if (this.animationInProcess) return false
if (this.preventVisibility) {
this.setValueAndClear(val, data)
if (this.input.value !== '') this.el.classList.add('has-value')
else this.el.classList.remove('has-value')
return false
}
if (!this.preserveSelectionOnEmpty && this.input.value.trim() === '') {
this.selected = ''
this.value = ''
}
this.animationInProcess = true
if (this?.input?.ariaExpanded) this.input.ariaExpanded = 'false'
if (this?.toggle?.ariaExpanded) this.toggle.ariaExpanded = 'false'
this.el.classList.remove('active')
if (!this.preventAutoPosition) {
this.output.classList.remove('bottom-full', 'top-full')
this.output.style.marginTop = ''
this.output.style.marginBottom = ''
}
afterTransition(this.output, () => {
this.output.style.display = 'none'
this.setValueAndClear(val, data || null)
this.animationInProcess = false
})
if (this.input.value !== '') this.el.classList.add('has-value')
else this.el.classList.remove('has-value')
this.isOpened = false
if (window.HSAccessibilityObserver && this.accessibilityComponent) {
window.HSAccessibilityObserver.updateComponentState(this.accessibilityComponent, false)
}
}
public recalculateDirection() {
if (isEnoughSpace(this.output, this.input, 'bottom', this.gap, this.viewport as HTMLElement)) {
this.output.classList.remove('bottom-full')
this.output.style.marginBottom = ''
this.output.classList.add('top-full')
this.output.style.marginTop = `${this.gap}px`
} else {
this.output.classList.remove('top-full')
this.output.style.marginTop = ''
this.output.classList.add('bottom-full')
this.output.style.marginBottom = `${this.gap}px`
}
}
public destroy() {
// Remove listeners
this.input.removeEventListener('focus', this.onInputFocusListener)
this.input.removeEventListener('input', this.onInputInputListener)
this.toggle.removeEventListener('click', this.onToggleClickListener)
if (this.toggleClose) {
this.toggleClose.removeEventListener('click', this.onToggleCloseClickListener)
}
if (this.toggleOpen) {
this.toggleOpen.removeEventListener('click', this.onToggleOpenClickListener)
}
// Remove classes
this.el.classList.remove('has-value', 'active')
if (this.items.length) {
this.items.forEach(el => {
;(el as HTMLElement).classList.remove('selected')
;(el as HTMLElement).style.display = ''
})
}
// Remove attributes
this.output.removeAttribute('role')
this.output.removeAttribute('tabindex')
this.output.removeAttribute('aria-orientation')
// Remove generated markup
if (this.outputLoader) {
this.outputLoader.remove()
this.outputLoader = null
}
if (this.outputPlaceholder) {
this.outputPlaceholder.remove()
this.outputPlaceholder = null
}
if (this.apiUrl) {
this.output.innerHTML = ''
}
this.items = []
if (typeof window !== 'undefined' && window.HSAccessibilityObserver) {
window.HSAccessibilityObserver.unregisterComponent(this.accessibilityComponent)
}
window.$hsComboBoxCollection = window.$hsComboBoxCollection.filter(({ element }) => element.el !== this.el)
}
// Static methods
static getInstance(target: HTMLElement | string, isInstance?: boolean) {
const elInCollection = window.$hsComboBoxCollection.find(
el => el.element.el === (typeof target === 'string' ? document.querySelector(target) : target)
)
return elInCollection ? (isInstance ? elInCollection : elInCollection.element) : null
}
static autoInit() {
if (!window.$hsComboBoxCollection) {
window.$hsComboBoxCollection = []
window.addEventListener('click', evt => {
const evtTarget = evt.target
HSComboBox.closeCurrentlyOpened(evtTarget as HTMLElement)
})
}
if (window.$hsComboBoxCollection) {
window.$hsComboBoxCollection = window.$hsComboBoxCollection.filter(({ element }) => document.contains(element.el))
}
document.querySelectorAll('[data-combo-box]:not(.--prevent-on-load-init)').forEach((el: HTMLElement) => {
if (!window.$hsComboBoxCollection.find(elC => (elC?.element?.el as HTMLElement) === el)) {
const data = el.getAttribute('data-combo-box')
const options: IComboBoxOptions = data ? JSON.parse(data) : {}
new HSComboBox(el, options)
}
})
}
static close(target: HTMLElement | string) {
const elInCollection = window.$hsComboBoxCollection.find(
el => el.element.el === (typeof target === 'string' ? document.querySelector(target) : target)
)
if (elInCollection && elInCollection.element.isOpened) {
elInCollection.element.close()
}
}
static closeCurrentlyOpened(evtTarget: HTMLElement | null = null) {
if (!evtTarget.closest('[data-combo-box].active')) {
const currentlyOpened = window.$hsComboBoxCollection.filter(el => el.element.isOpened) || null
if (currentlyOpened) {
currentlyOpened.forEach(el => {
el.element.close()
})
}
}
}
}
declare global {
interface Window {
HSComboBox: Function
$hsComboBoxCollection: ICollectionItem<HSComboBox>[]
}
}
window.addEventListener('load', () => {
HSComboBox.autoInit()
// Uncomment for debug
// console.log('ComboBox collection:', window.$hsComboBoxCollection);
})
document.addEventListener('scroll', () => {
if (!window.$hsComboBoxCollection) return false
const target = window.$hsComboBoxCollection.find(el => el.element.isOpened)
if (target && !target.element.preventAutoPosition) {
target.element.recalculateDirection()
}
})
if (typeof window !== 'undefined') {
window.HSComboBox = HSComboBox
}
export default HSComboBox