preline
Version:
Preline UI is an open-source set of prebuilt UI components based on the utility-first Tailwind CSS framework.
1,293 lines (1,078 loc) • 36.1 kB
text/typescript
/*
* HSComboBox
* @version: 3.1.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,
isParentOrElementHidden,
} from "../../utils";
import { IComboBox, IComboBoxItemAttr, IComboBoxOptions } from "./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;
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;
private readonly input: HTMLInputElement | null;
private readonly output: HTMLElement | null;
private readonly itemsWrapper: HTMLElement | null;
private items: HTMLElement[] | [];
private tabs: HTMLElement[] | [];
private readonly toggle: HTMLElement | null;
private readonly toggleClose: HTMLElement | null;
private readonly toggleOpen: HTMLElement | null;
private outputPlaceholder: HTMLElement | null;
private outputLoader: HTMLElement | null;
private value: string | null;
private selected: string | null;
private currentData: {} | {}[] | null;
private groups: any[] | null;
private selectedGroup: string | null;
isOpened: boolean;
isCurrent: boolean;
private animationInProcess: boolean;
private isSearchLengthExceeded = false;
private onInputFocusListener: () => void;
private onInputInputListener: (evt: InputEvent) => void;
private onToggleClickListener: () => void;
private onToggleCloseClickListener: () => void;
private onToggleOpenClickListener: () => void;
constructor(el: HTMLElement, options?: IComboBoxOptions, events?: {}) {
super(el, options, events);
// Data parameters
const data = el.getAttribute("data-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-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.preventClientFiltering = options?.preventClientFiltering ?? (!!concatOptions?.apiSearchQuery || !!concatOptions?.apiSearchPath);
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.currentData = null;
this.isOpened = false;
this.isCurrent = false;
this.animationInProcess = false;
this.selectedGroup = "all";
this.init();
}
private inputFocus() {
if (!this.isOpened) {
this.setResultAndRender();
this.open();
}
}
private inputInput(evt: InputEvent) {
const val = (evt.target as HTMLInputElement).value.trim();
if (val.length <= this.minSearchLength) this.setResultAndRender("");
else this.setResultAndRender(val);
if (this.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() {
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 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 = "") {
// TODO:: test the plugin with below code added.
let _value = this.preventVisibility ? this.input.value : value;
this.setResults(_value);
if (
this.apiSearchQuery || this.apiSearchPath || this.apiSearchDefaultPath
) this.itemsFromJson();
if (_value === "") this.isSearchLengthExceeded = true;
else this.isSearchLengthExceeded = false;
}
private setResults(val: string) {
this.value = val;
this.resultItems();
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() {
return this.items.length
? this.items.some((el: HTMLElement) => el.style.display === "block")
: false;
}
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() {
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((evt: InputEvent) =>
this.inputInput(evt)
);
this.input.addEventListener("input", this.onInputInputListener);
}
private async buildItems() {
this.output.role = "listbox";
this.output.tabIndex = -1;
this.output.ariaOrientation = "vertical";
if (this.apiUrl) await this.itemsFromJson();
else {
if (this.itemsWrapper) this.itemsWrapper.innerHTML = "";
else this.output.innerHTML = "";
this.itemsFromHtml();
}
if (this?.items.length && this.items[0].classList.contains("selected")) {
this.currentData = JSON.parse(
this.items[0].getAttribute("data-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) {
items.forEach((item: never, index: number) => {
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 {
// Try to parse as JSON array first
const fields = JSON.parse(valueAttr);
if (Array.isArray(fields)) {
// If it's an array, join all field values
value = fields
.map((field) => this.getNestedProperty(item, field))
.filter(Boolean)
.join(" ");
} else {
// If not an array, treat as single field
value = this.getNestedProperty(item, valueAttr);
}
} catch (e) {
// If parsing fails, treat as single field
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 {
// Try to parse as JSON array first
const fields = JSON.parse(valueAttr);
if (Array.isArray(fields)) {
// If it's an array, join all field values
value = fields
.map((field) => this.getNestedProperty(item, field))
.filter(Boolean)
.join(" ");
} else {
// If not an array, treat as single field
value = this.getNestedProperty(item, valueAttr);
}
} catch (e) {
// If parsing fails, treat as single field
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) => {
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]")
.textContent,
JSON.parse((newItem as HTMLElement)
.getAttribute("data-hs-combo-box-item-stored-data")),
);
this.setSelectedByValue(this.valuesBySelector(newItem));
});
}
this.appendItemsToWrapper(newItem);
});
}
private groupDefaultRender() {
this.groups.forEach((el) => {
const title = htmlToElement(this.groupingTitleTemplate);
title.setAttribute("data-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) return false;
this.buildOutputLoader();
try {
const query = `${this.apiQuery}`;
let searchQuery;
let searchPath;
let url = this.apiUrl;
if (!this.apiSearchQuery && this.apiSearchPath) {
if (this.apiSearchDefaultPath && this.value === "") {
searchPath = `/${this.apiSearchDefaultPath}`;
} else {
searchPath = `/${this.apiSearchPath}/${this.value.toLowerCase()}`;
}
if (this.apiSearchPath || this.apiSearchDefaultPath) {
url += searchPath;
}
} else {
searchQuery = `${this.apiSearchQuery}=${this.value.toLowerCase()}`;
if (this.apiQuery && this.apiSearchQuery) {
url += `?${searchQuery}&${query}`;
} else if (this.apiQuery) {
url += `?${query}`;
} else if (this.apiSearchQuery) {
url += `?${searchQuery}`;
}
}
const res = await fetch(url, this.apiHeaders);
let items = await res.json();
if (this.apiDataPart) {
items = items[this.apiDataPart];
}
if (this.apiSearchQuery || this.apiSearchPath) {
this.items = [];
}
if (this.itemsWrapper) {
this.itemsWrapper.innerHTML = "";
} else {
this.output.innerHTML = "";
}
if (this.groupingType === "tabs") {
this.setApiGroups(items);
this.groupTabsRender();
this.jsonItemsRender(items);
} else if (this.groupingType === "default") {
this.setApiGroups(items);
this.groups.forEach((el) => {
const title = htmlToElement(this.groupingTitleTemplate);
title.setAttribute("data-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.length <= this.minSearchLength ? "" : this.input.value,
);
} catch (err) {
console.error(err);
this.buildOutputPlaceholder();
}
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;
}
// 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;
}
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;
}
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;
}
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 = [];
window.$hsComboBoxCollection = window.$hsComboBoxCollection.filter(
({ element }) => element.el !== this.el,
);
}
// Static methods
static getInstance(target: HTMLElement | string, isInstance?: boolean) {
const elInCollection = window.$hsComboBoxCollection.find(
(el) =>
el.element.el ===
(typeof target === "string"
? document.querySelector(target)
: target),
);
return elInCollection
? isInstance ? elInCollection : elInCollection.element
: null;
}
static autoInit() {
if (!window.$hsComboBoxCollection) {
window.$hsComboBoxCollection = [];
window.addEventListener("click", (evt) => {
const evtTarget = evt.target;
HSComboBox.closeCurrentlyOpened(evtTarget as HTMLElement);
});
document.addEventListener(
"keydown",
(evt) => HSComboBox.accessibility(evt),
);
}
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);
}
});
}
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]").textContent;
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]",
).textContent
: null,
JSON.parse(
(evt.target as HTMLElement).getAttribute(
"data-hs-combo-box-item-stored-data",
),
) ?? 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;