preline
Version:
Preline UI is an open-source set of prebuilt UI components based on the utility-first Tailwind CSS framework.
1,119 lines (937 loc) • 31.5 kB
text/typescript
/*
* HSComboBox
* @version: 2.5.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 {
isEnoughSpace,
debounce,
dispatch,
afterTransition,
htmlToElement,
isParentOrElementHidden,
} from '../../utils';
import {
IComboBox,
IComboBoxOptions,
IComboBoxItemAttr,
} from '../combobox/interfaces';
import HSBasePlugin from '../base-plugin';
import { ICollectionItem } from '../../interfaces';
import { COMBO_BOX_ACCESSIBILITY_KEY_SET } from '../../constants';
class HSComboBox extends HSBasePlugin<IComboBoxOptions> implements IComboBox {
gap: number;
viewport: string | HTMLElement | null;
preventVisibility: boolean;
apiUrl: string | null;
apiDataPart: string | null;
apiQuery: string | null;
apiSearchQuery: 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;
isOpenOnFocus: boolean;
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 groups: any[] | null;
private selectedGroup: string | null;
isOpened: boolean;
isCurrent: boolean;
private animationInProcess: boolean;
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.apiUrl = concatOptions?.apiUrl ?? null;
this.apiDataPart = concatOptions?.apiDataPart ?? null;
this.apiQuery = concatOptions?.apiQuery ?? null;
this.apiSearchQuery = concatOptions?.apiSearchQuery ?? 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-none 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-[3px] border-current border-t-transparent text-blue-600 rounded-full 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.isOpenOnFocus = concatOptions?.isOpenOnFocus ?? false;
// 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.isOpened = false;
this.isCurrent = false;
this.animationInProcess = false;
this.selectedGroup = 'all';
this.init();
}
private init() {
this.createCollection(window.$hsComboBoxCollection, this);
this.build();
}
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 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.itemsFromJson();
}
private buildInput() {
if (this.isOpenOnFocus) {
this.input.addEventListener('focus', () => {
if (!this.isOpened) {
this.setResultAndRender();
this.open();
}
});
}
this.input.addEventListener(
'input',
debounce((evt: InputEvent) => {
this.setResultAndRender((evt.target as HTMLInputElement).value);
if (this.input.value !== '') this.el.classList.add('has-value');
else this.el.classList.remove('has-value');
if (!this.isOpened) this.open();
}),
);
}
private buildItems() {
this.output.role = 'listbox';
this.output.tabIndex = -1;
this.output.ariaOrientation = 'vertical';
if (this.apiUrl) this.itemsFromJson();
else {
if (this.itemsWrapper) this.itemsWrapper.innerHTML = '';
else this.output.innerHTML = '';
this.itemsFromHtml();
}
}
private setResults(val: string) {
this.value = val;
this.resultItems();
if (this.hasVisibleItems()) this.destroyOutputPlaceholder();
else this.buildOutputPlaceholder();
}
private isItemExists(obj: never): boolean {
return this.items.some((el: HTMLElement) => {
const groupField =
el.getAttribute('data-hs-combo-box-output-item-group-field') ?? null;
const params =
JSON.parse(el.getAttribute('data-hs-combo-box-output-item')) ?? null;
let group = null;
if (groupField && params?.group?.name) group = obj[groupField];
return Array.from(
el.querySelectorAll('[data-hs-combo-box-search-text]'),
).some((elI: HTMLElement) => {
const equality =
params?.group?.name && group
? group === params.group.name &&
elI.getAttribute('data-hs-combo-box-search-text') ===
obj[elI.getAttribute('data-hs-combo-box-output-item-field')]
: elI.getAttribute('data-hs-combo-box-search-text') ===
obj[elI.getAttribute('data-hs-combo-box-output-item-field')];
return equality;
});
});
}
private isTextExists(el: HTMLElement, val: string[]): boolean {
const lowerCased = val.map((v) => v.toLowerCase());
return Array.from(
el.querySelectorAll('[data-hs-combo-box-search-text]'),
).some((elI: HTMLElement) =>
lowerCased.includes(
elI.getAttribute('data-hs-combo-box-search-text').toLowerCase(),
),
);
}
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 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 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 destroyOutputLoader() {
if (this.outputLoader) this.outputLoader.remove();
this.outputLoader = null;
}
private async itemsFromJson() {
this.buildOutputLoader();
try {
const query = `${this.apiQuery}`;
const searchQuery = `${this.apiSearchQuery}=${this.value.toLowerCase()}`;
let url = this.apiUrl;
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);
let items = await res.json();
if (this.apiDataPart) {
items = items[this.apiDataPart];
}
if (this.apiSearchQuery) {
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-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);
});
} else {
this.jsonItemsRender(items);
}
this.setResults(this.input.value);
} catch (err) {
console.error(err);
}
this.destroyOutputLoader();
}
private jsonItemsRender(items: any) {
items.forEach((item: never, index: number) => {
// TODO:: test without checking below
// if (this.isItemExists(item)) return false;
const newItem = htmlToElement(this.outputItemTemplate);
newItem
.querySelectorAll('[data-hs-combo-box-output-item-field]')
.forEach((el) => {
const value =
item[el.getAttribute('data-hs-combo-box-output-item-field')];
const hideIfEmpty = el.hasAttribute(
'data-hs-combo-box-output-item-hide-if-empty',
);
el.textContent = value ?? '';
if (!value && hideIfEmpty) (el as HTMLElement).style.display = 'none';
});
newItem
.querySelectorAll('[data-hs-combo-box-search-text]')
.forEach((el) => {
el.setAttribute(
'data-hs-combo-box-search-text',
item[el.getAttribute('data-hs-combo-box-output-item-field')] ?? '',
);
});
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) => {
el.setAttribute(attr.attr, item[attr.valueFrom]);
});
});
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]')
.getAttribute('data-hs-combo-box-search-text'),
);
this.setSelectedByValue(this.valuesBySelector(newItem));
});
}
this.appendItemsToWrapper(newItem);
});
}
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;
}
public setCurrent() {
if (window.$hsComboBoxCollection.length) {
window.$hsComboBoxCollection.map((el) => (el.element.isCurrent = false));
this.isCurrent = true;
}
}
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 sortItems() {
const compareFn = (i1: HTMLElement, i2: HTMLElement) => {
const a = i1
.querySelector('[data-hs-combo-box-value]')
.getAttribute('data-hs-combo-box-search-text');
const b = i2
.querySelector('[data-hs-combo-box-value]')
.getAttribute('data-hs-combo-box-search-text');
if (a < b) {
return -1;
} else if (a > b) {
return 1;
}
return 0;
};
return this.items.sort(compareFn);
}
private itemRender(item: HTMLElement) {
const val = item
.querySelector('[data-hs-combo-box-value]')
.getAttribute('data-hs-combo-box-search-text');
if (this.itemsWrapper) this.itemsWrapper.append(item);
else this.output.append(item);
if (!this.preventSelection) {
item.addEventListener('click', () => {
this.close(val);
this.setSelectedByValue(this.valuesBySelector(item));
});
}
}
private plainRender(items: HTMLElement[]) {
items.forEach((item: HTMLElement) => {
this.itemRender(item);
});
}
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 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 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 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.toggle.addEventListener('click', () => {
if (this.isOpened) this.close();
else this.open(this.toggle.getAttribute('data-hs-combo-box-toggle'));
});
}
private buildToggleClose() {
this.toggleClose.addEventListener('click', () => this.close());
}
private buildToggleOpen() {
this.toggleOpen.addEventListener('click', () => this.open());
}
private setSelectedByValue(val: string[]) {
this.items.forEach((el) => {
if (this.isTextExists(el, val))
(el as HTMLElement).classList.add('selected');
else (el as HTMLElement).classList.remove('selected');
});
}
private setValue(val: string) {
this.selected = val;
this.value = val;
this.input.value = val;
this.fireEvent('select', this.el);
dispatch('select.hs.combobox', this.el, this.value);
}
private setItemsVisibility() {
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 hasVisibleItems() {
return this.items.length
? this.items.some((el: HTMLElement) => el.style.display === 'block')
: false;
}
private appendItemsToWrapper(item: HTMLElement) {
if (this.itemsWrapper) {
this.itemsWrapper.append(item);
} else {
this.output.append(item);
}
}
private buildOutputPlaceholder() {
if (!this.outputPlaceholder)
this.outputPlaceholder = htmlToElement(this.outputEmptyTemplate);
this.appendItemsToWrapper(this.outputPlaceholder);
}
private destroyOutputPlaceholder() {
if (this.outputPlaceholder) this.outputPlaceholder.remove();
this.outputPlaceholder = null;
}
private resultItems() {
if (!this.items.length) return false;
this.setItemsVisibility();
this.setSelectedByValue([this.selected]);
}
// Public methods
private setValueAndOpen(val: string) {
this.value = val;
if (this.items.length) {
this.setItemsVisibility();
}
}
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;
}
private setValueAndClear(val: string | null) {
if (val) this.setValue(val);
else this.setValue(this.selected);
if (this.outputPlaceholder) this.destroyOutputPlaceholder();
}
public close(val?: string | null) {
if (this.animationInProcess) return false;
if (this.preventVisibility) {
this.setValueAndClear(val);
if (this.input.value !== '') this.el.classList.add('has-value');
else this.el.classList.remove('has-value');
return false;
}
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);
this.animationInProcess = false;
});
if (this.input.value !== '') this.el.classList.add('has-value');
else this.el.classList.remove('has-value');
this.isOpened = 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`;
}
}
// 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 = [];
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);
}
});
if (window.$hsComboBoxCollection) {
window.addEventListener('click', (evt) => {
const evtTarget = evt.target;
HSComboBox.closeCurrentlyOpened(evtTarget as HTMLElement);
});
document.addEventListener('keydown', (evt) =>
HSComboBox.accessibility(evt),
);
}
}
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();
});
}
}
}
// Accessibility methods
private static 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 static 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');
}
static accessibility(evt: KeyboardEvent) {
const target = window.$hsComboBoxCollection.find((el) =>
el.element.preventVisibility ? el.element.isCurrent : el.element.isOpened,
);
if (
target &&
COMBO_BOX_ACCESSIBILITY_KEY_SET.includes(evt.code) &&
!evt.metaKey
) {
switch (evt.code) {
case 'Escape':
evt.preventDefault();
this.onEscape();
break;
case 'ArrowUp':
evt.preventDefault();
evt.stopImmediatePropagation();
this.onArrow();
break;
case 'ArrowDown':
evt.preventDefault();
evt.stopImmediatePropagation();
this.onArrow(false);
break;
case 'Home':
evt.preventDefault();
evt.stopImmediatePropagation();
this.onStartEnd();
break;
case 'End':
evt.preventDefault();
evt.stopImmediatePropagation();
this.onStartEnd(false);
break;
case 'Enter':
evt.preventDefault();
this.onEnter(evt);
break;
default:
break;
}
}
}
static onEscape() {
const target = window.$hsComboBoxCollection.find(
(el) => !el.element.preventVisibility && el.element.isOpened,
);
if (target) {
target.element.close();
target.element.input.blur();
}
}
static onArrow(isArrowUp = true) {
const target = window.$hsComboBoxCollection.find((el) =>
el.element.preventVisibility ? el.element.isCurrent : el.element.isOpened,
);
if (target) {
const output = target.element.itemsWrapper ?? target.element.output;
if (!output) return false;
const items = HSComboBox.getPreparedItems(isArrowUp, output) as Element[];
const current = output.querySelector(
'.hs-combo-box-output-item-highlighted',
);
let currentItem = null;
if (!current)
items[0].classList.add('hs-combo-box-output-item-highlighted');
let currentInd = items.findIndex((el: any) => el === current);
if (currentInd + 1 < items.length) currentInd++;
currentItem = items[currentInd] as HTMLButtonElement;
HSComboBox.setHighlighted(current, currentItem, target.element.input);
}
}
static onStartEnd(isStart = true) {
const target = window.$hsComboBoxCollection.find((el) =>
el.element.preventVisibility ? el.element.isCurrent : el.element.isOpened,
);
if (target) {
const output = target.element.itemsWrapper ?? target.element.output;
if (!output) return false;
const items = HSComboBox.getPreparedItems(isStart, output) as Element[];
const current = output.querySelector(
'.hs-combo-box-output-item-highlighted',
);
if (items.length)
HSComboBox.setHighlighted(
current,
items[0] as HTMLButtonElement,
target.element.input,
);
}
}
static onEnter(evt: Event) {
const target = evt.target;
const opened = window.$hsComboBoxCollection.find(
(el) =>
!isParentOrElementHidden(el.element.el) &&
(evt.target as HTMLElement).closest('[data-hs-combo-box]') ===
el.element.el,
);
const link: HTMLAnchorElement = opened.element.el.querySelector(
'.hs-combo-box-output-item-highlighted a',
);
if ((target as HTMLElement).hasAttribute('data-hs-combo-box-input')) {
opened.element.close();
(target as HTMLInputElement).blur();
} else {
if (!opened.element.preventSelection) {
opened.element.setSelectedByValue(
opened.element.valuesBySelector(evt.target as HTMLElement),
);
}
if (opened.element.preventSelection && link) {
window.location.assign(link.getAttribute('href'));
}
opened.element.close(
!opened.element.preventSelection
? (evt.target as HTMLElement)
.querySelector('[data-hs-combo-box-value]')
.getAttribute('data-hs-combo-box-search-text')
: null,
);
}
}
}
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;