/* * HSComboBox * @version: 4.1.3 * @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 '../combobox/interfaces'; import HSBasePlugin from '../base-plugin'; import { IAccessibilityComponent } from '../accessibility-manager/interfaces'; import HSAccessibilityObserver from '../accessibility-manager'; class HSComboBox extends HSBasePlugin implements IComboBox { private static globalListenersInitialized = false; 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 lastQuery = ''; private queryAbortController?: AbortController; 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-hs-combo-box'); const dataOptions: IComboBoxOptions = data ? JSON.parse(data) : {}; const concatOptions = { ...dataOptions, ...options, }; this.gap = 5; 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 ?? `
`; this.outputEmptyTemplate = concatOptions?.outputEmptyTemplate ?? `
Nothing found...
`; this.outputLoaderTemplate = concatOptions?.outputLoaderTemplate ?? `
Loading...
`; this.groupingType = concatOptions?.groupingType ?? null; this.groupingTitleTemplate = concatOptions?.groupingTitleTemplate ?? (this.groupingType === 'default' ? `
` : ``); this.tabsWrapperTemplate = concatOptions?.tabsWrapperTemplate ?? `
`; 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-hs-combo-box-input]') ?? null; this.output = this.el.querySelector('[data-hs-combo-box-output]') ?? null; this.itemsWrapper = this.el.querySelector('[data-hs-combo-box-output-items-wrapper]') ?? null; this.items = Array.from(this.el.querySelectorAll('[data-hs-combo-box-output-item]')) ?? []; this.tabs = []; this.toggle = this.el.querySelector('[data-hs-combo-box-toggle]') ?? null; this.toggleClose = this.el.querySelector('[data-hs-combo-box-close]') ?? null; this.toggleOpen = this.el.querySelector('[data-hs-combo-box-open]') ?? null; this.outputPlaceholder = null; this.selected = this.value = (this.el.querySelector('[data-hs-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() { const val = this.input.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-hs-combo-box-toggle')); } private toggleCloseClick() { this.close(); } private toggleOpenClick() { this.open(); } private init() { HSComboBox.ensureGlobalHandlers(); 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(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.hs.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-hs-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 = '') { const rawValue = this.preventVisibility ? this.input.value : value; const query = rawValue.trim(); const isShort = query.length < this.minSearchLength; this.isSearchLengthExceeded = isShort; if (query === this.lastQuery) { this.updatePlaceholderVisibility(); return; } this.lastQuery = query; this.setResults(query); if ( !isShort && (this.apiSearchQuery || this.apiSearchPath || this.apiSearchDefaultPath) ) { this.itemsFromJson(); } 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-hs-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-hs-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-hs-combo-box-group-title]') .forEach((el: HTMLElement) => { const g = el.getAttribute('data-hs-combo-box-group-title'); const items = this.items.filter((f: HTMLElement) => { const { group } = JSON.parse( f.getAttribute('data-hs-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-hs-combo-box-search-text]'), ).some((elI: HTMLElement) => elI .getAttribute('data-hs-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-hs-combo-box-search-text]'), ).reduce( (acc: any, cur: HTMLElement) => [ ...acc, cur.getAttribute('data-hs-combo-box-search-text'), ], [], ); } private sortItems() { if (this.keepOriginalOrder) return this.items; const compareFn = (i1: HTMLElement, i2: HTMLElement) => { const a = i1.querySelector('[data-hs-combo-box-value]').textContent; const b = i2.querySelector('[data-hs-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(() => this.inputInput()); this.input.addEventListener('input', this.onInputInputListener); this.input.addEventListener('paste', (evt: ClipboardEvent) => { const pasted = evt.clipboardData?.getData('text') ?? ''; evt.preventDefault(); const start = this.input.selectionStart ?? this.input.value.length; const end = this.input.selectionEnd ?? this.input.value.length; const next = this.input.value.slice(0, start) + pasted + this.input.value.slice(end); this.input.value = next; this.onInputInputListener?.( new InputEvent('input', { inputType: 'insertFromPaste', data: pasted }), ); }); } 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-hs-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-hs-combo-box-value]').textContent; const data = JSON.parse(item.getAttribute('data-hs-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, index = 0) { items.forEach((item: never) => { const newItem = htmlToElement(this.outputItemTemplate); newItem.setAttribute( 'data-hs-combo-box-item-stored-data', JSON.stringify(item), ); newItem .querySelectorAll('[data-hs-combo-box-output-item-field]') .forEach((el) => { const valueAttr = el.getAttribute( 'data-hs-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-hs-combo-box-output-item-hide-if-empty') ) { (el as HTMLElement).style.display = 'none'; } }); newItem .querySelectorAll('[data-hs-combo-box-search-text]') .forEach((el) => { const valueAttr = el.getAttribute( 'data-hs-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-hs-combo-box-search-text', value ?? ''); }); newItem .querySelectorAll('[data-hs-combo-box-output-item-attr]') .forEach((el) => { const attributes = JSON.parse( el.getAttribute('data-hs-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-hs-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-hs-combo-box-value]') .textContent, JSON.parse( (newItem as HTMLElement).getAttribute( 'data-hs-combo-box-item-stored-data', ), ), ); this.setSelectedByValue(this.valuesBySelector(newItem)); }); } this.appendItemsToWrapper(newItem); index++; }); } private groupDefaultRender() { this.groups.forEach((el) => { const title = htmlToElement(this.groupingTitleTemplate); title.setAttribute('data-hs-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-hs-combo-box-output-item'), ); return group.name === el.name; }); this.plainRender(items); }); } private groupTabsRender() { const tabsScroll = htmlToElement(this.tabsWrapperTemplate); const tabsWrapper = htmlToElement( `
`, ); tabsScroll.append(tabsWrapper); this.output.insertBefore(tabsScroll, this.output.firstChild); const tabDef = htmlToElement(this.groupingTitleTemplate); tabDef.setAttribute('data-hs-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-hs-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-hs-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-hs-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 { if (this.queryAbortController) this.queryAbortController.abort(); const ctrl = new AbortController(); this.queryAbortController = ctrl; 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, signal: ctrl.signal }); 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') { let index = 0; this.setApiGroups(items); this.groups.forEach((el) => { const title = htmlToElement(this.groupingTitleTemplate); title.setAttribute('data-hs-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, index); index += newItems.length; }); } 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 { if (this.queryAbortController && this.queryAbortController.signal.aborted) this.queryAbortController = undefined; 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 setHighlighted( prev: Element, current: HTMLElement, input: HTMLInputElement, ): void { current.focus(); input.value = current .querySelector('[data-hs-combo-box-value]') .getAttribute('data-hs-combo-box-search-text'); if (prev) prev.classList.remove('hs-combo-box-output-item-highlighted'); current.classList.add('hs-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: (evt: KeyboardEvent) => { if (!this.isOpened) return; evt.preventDefault(); evt.stopPropagation(); this.focusMenuItem('next'); }, onShiftTab: (evt: KeyboardEvent) => { if (!this.isOpened) return; evt.preventDefault(); evt.stopPropagation(); this.focusMenuItem('prev'); }, // onFirstLetter: (key: string) => this.onFirstLetter(key), }, this.isOpened, 'ComboBox', '[data-hs-combo-box]', output, ); } private onEnter(): void { if (!this.isOpened) { this.open(); } else { const highlighted = this.output.querySelector( '.hs-combo-box-output-item-highlighted', ); if (highlighted) { this.close( highlighted .querySelector('[data-hs-combo-box-value]') ?.getAttribute('data-hs-combo-box-search-text') ?? null, JSON.parse( highlighted.getAttribute('data-hs-combo-box-item-stored-data'), ) ?? null, ); if (highlighted.tagName === 'A') { window.location.href = (highlighted as HTMLAnchorElement).href; return; } 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( '.hs-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('hs-combo-box-output-item-highlighted'); } options[nextIndex].classList.add('hs-combo-box-output-item-highlighted'); (options[nextIndex] as HTMLElement).focus(); this.input.value = options[nextIndex] .querySelector('[data-hs-combo-box-value]') .getAttribute('data-hs-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( '.hs-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() { HSComboBox.ensureGlobalHandlers(); if (window.$hsComboBoxCollection) { window.$hsComboBoxCollection = window.$hsComboBoxCollection.filter( ({ element }) => document.contains(element.el), ); } document .querySelectorAll('[data-hs-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-hs-combo-box'); const options: IComboBoxOptions = data ? JSON.parse(data) : {}; new HSComboBox(el, options); } }); } private static ensureGlobalHandlers() { if (typeof window === 'undefined') return; if (!window.$hsComboBoxCollection) window.$hsComboBoxCollection = []; if (HSComboBox.globalListenersInitialized) return; HSComboBox.globalListenersInitialized = true; window.addEventListener('click', (evt) => { const evtTarget = evt.target; HSComboBox.closeCurrentlyOpened(evtTarget as HTMLElement); }); } 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-hs-combo-box].active')) { const currentlyOpened = window.$hsComboBoxCollection.filter((el) => el.element.isOpened) || null; if (currentlyOpened) { currentlyOpened.forEach((el) => { el.element.close(); }); } } } } export default HSComboBox;