import type { AutocompleteApi as CoreAutocompleteApi, BaseItem } from "@algolia/autocomplete-core"; import { createAutocomplete } from "@algolia/autocomplete-core"; import type { AttributeType } from "../entities/fattribute.js"; import { bindAutocompleteInput, createHeadlessPanelController, registerHeadlessAutocompleteCloser, withHeadlessSourceDefaults } from "./autocomplete_core.js"; import server from "./server.js"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- interface NameItem extends BaseItem { name: string; } interface InitAttributeNameOptions { /** The element where the user types */ $el: JQuery; attributeType?: AttributeType | (() => AttributeType); open: boolean; /** Called when the user selects a value or the panel closes */ onValueChange?: (value: string) => void; } // --------------------------------------------------------------------------- // Instance tracking // --------------------------------------------------------------------------- interface ManagedInstance { autocomplete: CoreAutocompleteApi; panelEl: HTMLElement; cleanup: () => void; } const instanceMap = new WeakMap(); function renderItems( panelEl: HTMLElement, items: NameItem[], activeItemId: number | null, onSelect: (item: NameItem) => void, onActivate: (index: number) => void, onDeactivate: () => void ): void { panelEl.innerHTML = ""; if (items.length === 0) { panelEl.style.display = "none"; return; } const list = document.createElement("ul"); list.className = "aa-core-list"; items.forEach((item, index) => { const li = document.createElement("li"); li.className = "aa-core-item"; if (index === activeItemId) { li.classList.add("aa-core-item--active"); } li.textContent = item.name; li.addEventListener("mousemove", () => { if (activeItemId === index) { return; } onActivate(index); }); li.addEventListener("mouseleave", (event) => { const relatedTarget = event.relatedTarget; if (relatedTarget instanceof HTMLElement && li.contains(relatedTarget)) { return; } onDeactivate(); }); li.addEventListener("mousedown", (e) => { e.preventDefault(); // prevent input blur e.stopPropagation(); onSelect(item); }); list.appendChild(li); }); panelEl.appendChild(list); } // --------------------------------------------------------------------------- // Attribute name autocomplete — new (autocomplete-core, headless) // --------------------------------------------------------------------------- function initAttributeNameAutocomplete({ $el, attributeType, open, onValueChange }: InitAttributeNameOptions) { const inputEl = $el[0] as HTMLInputElement; const syncQueryFromInputValue = (autocomplete: CoreAutocompleteApi) => { autocomplete.setQuery(inputEl.value || ""); }; // Already initialized — just open if requested if (instanceMap.has(inputEl)) { if (open) { const inst = instanceMap.get(inputEl)!; syncQueryFromInputValue(inst.autocomplete); inst.autocomplete.setIsOpen(true); inst.autocomplete.refresh(); } return; } const panelController = createHeadlessPanelController({ inputEl }); const { panelEl } = panelController; let isPanelOpen = false; let hasActiveItem = false; const autocomplete = createAutocomplete({ openOnFocus: true, defaultActiveItemId: 0, shouldPanelOpen() { return true; }, getSources({ query }) { return [ withHeadlessSourceDefaults({ sourceId: "attribute-names", getItems() { const type = typeof attributeType === "function" ? attributeType() : attributeType; return server .get(`attribute-names/?type=${type}&query=${encodeURIComponent(query)}`) .then((names) => names.map((name) => ({ name }))); }, getItemInputValue({ item }) { return item.name; }, onSelect({ item }) { inputEl.value = item.name; autocomplete.setQuery(item.name); autocomplete.setIsOpen(false); onValueChange?.(item.name); }, }), ]; }, onStateChange({ state }) { isPanelOpen = state.isOpen; hasActiveItem = state.activeItemId !== null; // Render items const collections = state.collections; const items = collections.length > 0 ? (collections[0].items as NameItem[]) : []; const activeId = state.activeItemId ?? null; if (state.isOpen && items.length > 0) { renderItems( panelEl, items, activeId, (item) => { inputEl.value = item.name; autocomplete.setQuery(item.name); autocomplete.setIsOpen(false); onValueChange?.(item.name); }, (index) => { autocomplete.setActiveItemId(index); }, () => { autocomplete.setActiveItemId(null); } ); panelController.startPositioning(); } else { panelController.hide(); } if (!state.isOpen) { panelController.hide(); } }, }); const unregisterGlobalCloser = registerHeadlessAutocompleteCloser(() => { autocomplete.setIsOpen(false); panelController.hide(); }); const cleanupInputBindings = bindAutocompleteInput({ inputEl, autocomplete, onInput(e, handlers) { handlers.onChange(e as any); }, onFocus(e, handlers) { syncQueryFromInputValue(autocomplete); handlers.onFocus(e as any); }, onBlur() { // Delay to allow mousedown on panel items setTimeout(() => { autocomplete.setIsOpen(false); panelController.hide(); onValueChange?.(inputEl.value); }, 50); }, onKeyDown(e, handlers) { if (e.key === "Enter") { if (isPanelOpen && hasActiveItem) { // Prevent the enter key from propagating to parent dialogs // (which might interpret it as "submit" or "save and close") e.stopPropagation(); } else if (!isPanelOpen) { // Panel is closed. Do not pass Enter to autocomplete-core // so native HTML form submit handlers can run. return; } } handlers.onKeyDown(e as any); } }); const cleanup = () => { unregisterGlobalCloser(); cleanupInputBindings(); panelController.destroy(); }; instanceMap.set(inputEl, { autocomplete, panelEl, cleanup }); if (open) { syncQueryFromInputValue(autocomplete); autocomplete.setIsOpen(true); autocomplete.refresh(); panelController.startPositioning(); } } // --------------------------------------------------------------------------- // Label value autocomplete (headless autocomplete-core) // --------------------------------------------------------------------------- interface LabelValueInitOptions { $el: JQuery; open: boolean; nameCallback?: () => string; onValueChange?: (value: string) => void; } function initLabelValueAutocomplete({ $el, open, nameCallback, onValueChange }: LabelValueInitOptions) { const inputEl = $el[0] as HTMLInputElement; const syncQueryFromInputValue = (autocomplete: CoreAutocompleteApi) => { autocomplete.setQuery(inputEl.value || ""); }; if (instanceMap.has(inputEl)) { if (open) { const inst = instanceMap.get(inputEl)!; syncQueryFromInputValue(inst.autocomplete); inst.autocomplete.setIsOpen(true); inst.autocomplete.refresh(); } return; } const panelController = createHeadlessPanelController({ inputEl }); const { panelEl } = panelController; let isPanelOpen = false; let hasActiveItem = false; let isSelecting = false; let cachedAttributeName = ""; let cachedAttributeValues: NameItem[] = []; const handleSelect = (item: NameItem) => { isSelecting = true; inputEl.value = item.name; inputEl.dispatchEvent(new Event("input", { bubbles: true })); autocomplete.setQuery(item.name); autocomplete.setIsOpen(false); onValueChange?.(item.name); isSelecting = false; setTimeout(() => { // Preserve the legacy contract: several consumers still commit the // selected value from their existing Enter key handlers instead of // listening to the autocomplete selection event directly. inputEl.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", code: "Enter", keyCode: 13, which: 13, bubbles: true, cancelable: true })); }, 0); }; const autocomplete = createAutocomplete({ openOnFocus: true, defaultActiveItemId: null, shouldPanelOpen() { return true; }, getSources({ query }) { return [ withHeadlessSourceDefaults({ sourceId: "attribute-values", async getItems() { const attributeName = nameCallback ? nameCallback() : ""; if (!attributeName.trim()) { return []; } if (attributeName !== cachedAttributeName || cachedAttributeValues.length === 0) { cachedAttributeName = attributeName; const values = await server.get(`attribute-values/${encodeURIComponent(attributeName)}`); cachedAttributeValues = values.map((name) => ({ name })); } const q = query.toLowerCase(); return cachedAttributeValues.filter((attr) => attr.name.toLowerCase().includes(q)); }, getItemInputValue({ item }) { return item.name; }, onSelect({ item }) { handleSelect(item); }, }), ]; }, onStateChange({ state }) { isPanelOpen = state.isOpen; hasActiveItem = state.activeItemId !== null; const collections = state.collections; const items = collections.length > 0 ? (collections[0].items as NameItem[]) : []; const activeId = state.activeItemId ?? null; if (state.isOpen && items.length > 0) { renderItems( panelEl, items, activeId, handleSelect, (index) => { autocomplete.setActiveItemId(index); }, () => { autocomplete.setActiveItemId(null); } ); panelController.startPositioning(); } else { panelController.hide(); } if (!state.isOpen) { panelController.hide(); } }, }); const unregisterGlobalCloser = registerHeadlessAutocompleteCloser(() => { autocomplete.setIsOpen(false); panelController.hide(); }); const cleanupInputBindings = bindAutocompleteInput({ inputEl, autocomplete, onInput(e, handlers) { if (!isSelecting) { handlers.onChange(e as any); } }, onFocus(e, handlers) { const attributeName = nameCallback ? nameCallback() : ""; if (attributeName !== cachedAttributeName) { cachedAttributeName = ""; cachedAttributeValues = []; } syncQueryFromInputValue(autocomplete); handlers.onFocus(e as any); }, onBlur() { setTimeout(() => { autocomplete.setIsOpen(false); panelController.hide(); onValueChange?.(inputEl.value); }, 50); }, onKeyDown(e, handlers) { if (e.key === "Enter") { if (isPanelOpen && hasActiveItem) { e.stopPropagation(); } else if (!isPanelOpen) { return; // Let native submit form bubbling happen } } handlers.onKeyDown(e as any); } }); const cleanup = () => { unregisterGlobalCloser(); cleanupInputBindings(); panelController.destroy(); }; instanceMap.set(inputEl, { autocomplete, panelEl, cleanup }); if (open) { syncQueryFromInputValue(autocomplete); autocomplete.setIsOpen(true); autocomplete.refresh(); panelController.startPositioning(); } } export function destroyAutocomplete($el: JQuery | HTMLElement) { const inputEl = $el instanceof HTMLElement ? $el : $el[0] as HTMLInputElement; const instance = instanceMap.get(inputEl); if (instance) { instance.cleanup(); instanceMap.delete(inputEl); } } export default { initAttributeNameAutocomplete, destroyAutocomplete, initLabelValueAutocomplete, };