export interface AutocompleteConfig { autoselect: boolean; autocomplete?: string; wrapperClassName: string; menuClassName: string; optionClassName: string; customInputClassName: string; displayMenu: string; onConfirm?: (value: unknown) => void; tClearButton: () => string; tAssistiveHint: () => string; } export const defaultConfig: AutocompleteConfig = { autoselect: true, wrapperClassName: "", menuClassName: "", optionClassName: "", customInputClassName: "", displayMenu: "overlay", tClearButton: () => "Vymazať text", tAssistiveHint: () => "Pre prezeranie výsledkov automatického dopĺňania použite šípky nahor a nadol a pre výber konkrétneho výsledku použite tlačidlo Enter. Pri dotykových zariadeniach použite dotyk alebo potiahnutie prstom do strany.", }; const addClassToken = (className: string, token: string) => { const classTokens = className.split(/\s+/).filter(Boolean); if (classTokens.includes(token)) { return className; } return [...classTokens, token].join(" "); }; const isHtmlElement = (element: Element | null): element is HTMLElement => { if (!element) { return false; } const htmlElementConstructor = element.ownerDocument?.defaultView?.HTMLElement; return htmlElementConstructor ? element instanceof htmlElementConstructor : element.nodeType === Node.ELEMENT_NODE; }; export default class Autocomplete { private element: HTMLElement; private config: AutocompleteConfig; private instance: any; private teardownClearButton: (() => void) | null; constructor(element: HTMLElement, config?: Partial) { this.element = element; this.config = { ...defaultConfig, ...config }; this.onPageshow = this.onPageshow.bind(this); this.onClearButtonClick = this.onClearButtonClick.bind(this); this.instance = null; this.teardownClearButton = null; (this.element as any).ODS_Autocomplete = this; // Initialize asynchronously to handle dynamic imports this.init().catch(() => undefined); return this; } private async init(): Promise { const configAttr = this.element.getAttribute("data-autocomplete-config"); let elementConfig: Partial = {}; if (configAttr) { try { elementConfig = JSON.parse(configAttr); } catch { // ignore parse error } } this.config = { ...this.config, ...elementConfig }; const customInputClassName = this.config.customInputClassName || ""; const customInputClassTokens = customInputClassName .split(/\s+/) .filter(Boolean); const wrapperClassTokens = this.config.wrapperClassName .split(/\s+/) .filter(Boolean); const hasSearchIcon = customInputClassTokens.includes("input--search-icon") || wrapperClassTokens.includes("autocomplete__wrapper--search-icon"); const hasSearchIconWithPlaceholder = customInputClassTokens.includes("input--search-icon-with-placeholder") || wrapperClassTokens.includes( "autocomplete__wrapper--search-icon-with-placeholder", ); const isInvalid = this.element.classList.contains("is-invalid"); this.config.customInputClassName = customInputClassTokens .filter( (token) => token !== "input--search-icon" && token !== "input--search-icon-with-placeholder", ) .join(" "); if (isInvalid) { this.config.customInputClassName = addClassToken( this.config.customInputClassName, "is-invalid", ); } if (hasSearchIcon) { this.config.wrapperClassName = addClassToken( this.config.wrapperClassName, "autocomplete__wrapper--search-icon", ); } if (hasSearchIconWithPlaceholder) { this.config.wrapperClassName = addClassToken( this.config.wrapperClassName, "autocomplete__wrapper--search-icon-with-placeholder", ); } // Dynamic import to avoid SSR issues const accessibleAutocomplete = ( await import("@orangesk/accessible-autocomplete") ).default; if (this.element.tagName === "SELECT") { accessibleAutocomplete.enhanceSelectElement({ selectElement: this.element, ...this.config, }); } else { accessibleAutocomplete({ element: this.element, ...this.config, }); } this.initEnhancedControls(hasSearchIconWithPlaceholder); window.addEventListener("pageshow", this.onPageshow); } private getWrapper(): HTMLElement | null { if (this.element.tagName === "SELECT") { const rootElement = this.element.previousElementSibling; if (!isHtmlElement(rootElement)) { return null; } if (rootElement.classList.contains("autocomplete__wrapper")) { return rootElement; } return rootElement.querySelector(".autocomplete__wrapper"); } return this.element.querySelector(".autocomplete__wrapper"); } private getInput(): HTMLInputElement | null { return this.getWrapper()?.querySelector(".autocomplete__input") ?? null; } private initEnhancedControls( hasSearchIconWithPlaceholder: boolean, retries = 10, ): void { if (!this.getInput()) { if (retries > 0) { this.element.ownerDocument.defaultView?.setTimeout( () => this.initEnhancedControls( hasSearchIconWithPlaceholder, retries - 1, ), 0, ); } return; } this.syncInputState(); if (hasSearchIconWithPlaceholder) { this.initClearButton(); } } private syncInputState(): void { const input = this.getInput(); if (!input) { return; } if (this.element.classList.contains("is-invalid")) { input.classList.add("is-invalid"); input.setAttribute("aria-invalid", "true"); } if (this.element instanceof HTMLSelectElement && this.element.disabled) { input.disabled = true; } } private initClearButton(): void { this.teardownClearButton?.(); const wrapper = this.getWrapper(); const input = this.getInput(); if (!wrapper || !input) { return; } const clearButton = document.createElement("button"); clearButton.type = "button"; clearButton.className = "autocomplete__clear"; clearButton.setAttribute("aria-label", this.config.tClearButton()); const updateClearButton = () => { clearButton.hidden = input.disabled || input.value.length === 0; }; const scheduleUpdateClearButton = () => { this.element.ownerDocument.defaultView?.setTimeout(updateClearButton, 0); }; clearButton.addEventListener("mousedown", this.preventDefault); clearButton.addEventListener("keydown", this.stopPropagation); clearButton.addEventListener("click", this.onClearButtonClick); input.addEventListener("input", updateClearButton); input.addEventListener("change", updateClearButton); wrapper.addEventListener("click", scheduleUpdateClearButton); wrapper.addEventListener("keyup", scheduleUpdateClearButton); updateClearButton(); wrapper.appendChild(clearButton); this.teardownClearButton = () => { clearButton.removeEventListener("mousedown", this.preventDefault); clearButton.removeEventListener("keydown", this.stopPropagation); clearButton.removeEventListener("click", this.onClearButtonClick); input.removeEventListener("input", updateClearButton); input.removeEventListener("change", updateClearButton); wrapper.removeEventListener("click", scheduleUpdateClearButton); wrapper.removeEventListener("keyup", scheduleUpdateClearButton); clearButton.remove(); }; } private preventDefault(event: Event): void { event.preventDefault(); } private stopPropagation(event: Event): void { event.stopPropagation(); } private onClearButtonClick(): void { const input = this.getInput(); if (!input) { return; } input.value = ""; input.dispatchEvent(new Event("input", { bubbles: true })); input.dispatchEvent(new Event("change", { bubbles: true })); if (this.element instanceof HTMLSelectElement) { const emptyOption = Array.from(this.element.options).find( (option) => option.value === "", ); if (emptyOption) { emptyOption.selected = true; } else { this.element.selectedIndex = -1; } this.element.dispatchEvent(new Event("change", { bubbles: true })); } this.config.onConfirm?.(""); input.focus(); } private onPageshow(e: PageTransitionEvent): void { if (e.persisted && window) { window.location.reload(); } } public destroy(): void { this.teardownClearButton?.(); this.teardownClearButton = null; window.removeEventListener("pageshow", this.onPageshow); (this.element as any).ODS_Autocomplete = null; } public update(): void { this.destroy(); this.init().catch(() => undefined); } static getInstance(el: HTMLElement): Autocomplete | null { return el && (el as any).ODS_Autocomplete ? (el as any).ODS_Autocomplete : null; } }