preline
Version:
Preline UI is an open-source set of prebuilt UI components based on the utility-first Tailwind CSS framework.
1,395 lines (1,144 loc) • 38.4 kB
text/typescript
/*
* HSComboBox
* @version: 4.2.0
* @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<IComboBoxOptions> 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 ??
`<div class="cursor-pointer py-2 px-4 w-full text-sm text-gray-800 hover:bg-gray-100 rounded-lg focus:outline-hidden focus:bg-gray-100 dark:bg-neutral-900 dark:hover:bg-neutral-800 dark:text-neutral-200 dark:focus:bg-neutral-800" data-hs-combo-box-output-item>
<div class="flex justify-between items-center w-full">
<span data-hs-combo-box-search-text></span>
<span class="hidden hs-combo-box-selected:block">
<svg class="shrink-0 size-3.5 text-blue-600 dark:text-blue-500" xmlns="http:.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
</span>
</div>
</div>`;
this.outputEmptyTemplate =
concatOptions?.outputEmptyTemplate ??
`<div class="py-2 px-4 w-full text-sm text-gray-800 rounded-lg dark:bg-neutral-900 dark:text-neutral-200">Nothing found...</div>`;
this.outputLoaderTemplate =
concatOptions?.outputLoaderTemplate ??
`<div class="flex justify-center items-center py-2 px-4 text-sm text-gray-800 rounded-lg bg-white dark:bg-neutral-900 dark:text-neutral-200">
<div class="animate-spin inline-block size-6 border-3 border-current border-t-transparent text-blue-600 rounded-[999px] dark:text-blue-500" role="status" aria-label="loading">
<span class="sr-only">Loading...</span>
</div>
</div>`;
this.groupingType = concatOptions?.groupingType ?? null;
this.groupingTitleTemplate =
concatOptions?.groupingTitleTemplate ??
(this.groupingType === 'default'
? `<div class="block mb-1 text-xs font-semibold uppercase text-blue-600 dark:text-blue-500"></div>`
: `<button type="button" class="py-2 px-3 inline-flex items-center gap-x-2 text-sm font-semibold whitespace-nowrap rounded-lg border border-transparent bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 disabled:pointer-events-none"></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-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<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.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(
`<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-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: (evt: KeyboardEvent) => this.onEnter(evt),
onSpace: () => this.onEnter(),
onEsc: () => {
if (this.isOpened) {
this.close();
if (this.input) this.input.focus();
if (this.preventVisibility) return false;
}
},
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(evt?: KeyboardEvent): 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,
);
const anchor =
highlighted.tagName === 'A'
? (highlighted as HTMLAnchorElement)
: (highlighted.querySelector(
'a[href]',
) as HTMLAnchorElement | null);
if (anchor?.href) {
if (evt?.metaKey || evt?.ctrlKey) {
window.open(anchor.href, '_blank', 'noopener');
} else {
window.location.href = anchor.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) {
this.isOpened = true;
this.el.classList.add('active');
if (window.HSAccessibilityObserver && this.accessibilityComponent) {
window.HSAccessibilityObserver.updateComponentState(
this.accessibilityComponent,
true,
);
}
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;