/** * Generic autocomplete lookup field. */ import { getJson } from '../core/app-http.js'; import { resolveHost } from '../core/app-dom.js'; const EMPTY_API = Object.freeze({ destroy: () => {} }); /** * @param {HTMLElement} container */ export function initLookupField(container) { if (!(container instanceof HTMLElement)) { return EMPTY_API; } const url = container.dataset.lookupUrl || ''; const minChars = parseInt(container.dataset.lookupMinChars || '2', 10); const debounceMs = parseInt(container.dataset.lookupDebounce || '300', 10); const valueKey = container.dataset.lookupValueKey || 'value'; const displayKey = container.dataset.lookupDisplayKey || 'label'; const placeholder = container.dataset.lookupPlaceholder || ''; const initialDisplay = container.dataset.lookupInitialDisplay || ''; const initialValue = container.dataset.lookupInitialValue || ''; if (!url) { return EMPTY_API; } const hiddenValue = container.querySelector('[data-lookup-value]'); const hiddenDisplay = container.querySelector('[data-lookup-display-value]'); const listenerController = new AbortController(); const on = (target, type, handler, options = {}) => { if (!target || typeof target.addEventListener !== 'function') { return; } target.addEventListener(type, handler, { ...options, signal: listenerController.signal }); }; const cleanupNodes = []; const clearNode = (el) => { while (el.firstChild) { el.removeChild(el.firstChild); } }; container.classList.add('app-lookup-field'); const wrap = document.createElement('div'); wrap.className = 'app-lookup-field-input-wrap'; const input = document.createElement('input'); input.type = 'text'; input.className = 'app-lookup-field-input'; input.placeholder = placeholder; input.setAttribute('role', 'combobox'); input.setAttribute('aria-expanded', 'false'); input.setAttribute('aria-autocomplete', 'list'); input.setAttribute('autocomplete', 'off'); const indicator = document.createElement('div'); indicator.className = 'app-lookup-field-indicator'; wrap.appendChild(input); wrap.appendChild(indicator); const dropdown = document.createElement('ul'); dropdown.className = 'app-lookup-field-dropdown'; dropdown.setAttribute('role', 'listbox'); dropdown.hidden = true; container.appendChild(wrap); container.appendChild(dropdown); cleanupNodes.push(wrap, dropdown); let results = []; let activeIndex = -1; let debounceTimer = null; let blurTimer = null; let selected = false; let activeRequestController = null; if (initialValue && initialDisplay) { input.value = initialDisplay; selected = true; input.dataset.lookupSelected = ''; showClear(); } function showSpinner() { clearNode(indicator); const spinner = document.createElement('div'); spinner.className = 'app-lookup-field-spinner'; indicator.appendChild(spinner); } function showClear() { clearNode(indicator); const button = document.createElement('button'); button.type = 'button'; button.className = 'app-lookup-field-clear'; button.setAttribute('aria-label', 'Clear'); const icon = document.createElement('i'); icon.className = 'bi bi-x-lg'; button.appendChild(icon); on(button, 'click', clearSelection); indicator.appendChild(button); } function hideIndicator() { clearNode(indicator); } function openDropdown() { dropdown.hidden = false; input.setAttribute('aria-expanded', 'true'); } function closeDropdown() { dropdown.hidden = true; input.setAttribute('aria-expanded', 'false'); activeIndex = -1; } function setActive(index) { const options = dropdown.querySelectorAll('.app-lookup-field-option'); options.forEach((opt, i) => { opt.setAttribute('aria-selected', i === index ? 'true' : 'false'); }); activeIndex = index; const active = options[index]; if (active) { active.scrollIntoView({ block: 'nearest' }); } } function selectItem(index) { const item = results[index]; if (!item) { return; } input.value = item[displayKey] || ''; input.dataset.lookupSelected = ''; selected = true; if (hiddenValue) hiddenValue.value = item[valueKey] || ''; if (hiddenDisplay) hiddenDisplay.value = item[displayKey] || ''; closeDropdown(); showClear(); container.dispatchEvent(new CustomEvent('lookup:select', { detail: item, bubbles: true, })); } function clearSelection() { input.value = ''; delete input.dataset.lookupSelected; selected = false; if (hiddenValue) hiddenValue.value = ''; if (hiddenDisplay) hiddenDisplay.value = ''; hideIndicator(); input.focus(); container.dispatchEvent(new CustomEvent('lookup:clear', { bubbles: true })); } function renderResults(items) { results = items; activeIndex = -1; clearNode(dropdown); if (items.length === 0) { const msg = document.createElement('li'); msg.className = 'app-lookup-field-message'; msg.textContent = container.dataset.lookupEmptyText || 'No results'; dropdown.appendChild(msg); openDropdown(); return; } items.forEach((item, index) => { const li = document.createElement('li'); li.className = 'app-lookup-field-option'; li.setAttribute('role', 'option'); li.setAttribute('aria-selected', 'false'); li.dataset.index = String(index); const label = document.createElement('span'); label.className = 'app-lookup-field-option-label'; label.textContent = item[displayKey] || ''; li.appendChild(label); if (item.meta) { const meta = document.createElement('span'); meta.className = 'app-lookup-field-option-meta'; meta.textContent = item.meta; li.appendChild(meta); } on(li, 'mousedown', (event) => { event.preventDefault(); selectItem(index); }); on(li, 'mouseenter', () => { setActive(index); }); dropdown.appendChild(li); }); openDropdown(); } function renderError() { clearNode(dropdown); const msg = document.createElement('li'); msg.className = 'app-lookup-field-message'; msg.textContent = container.dataset.lookupErrorText || 'Search failed'; dropdown.appendChild(msg); openDropdown(); } async function fetchResults(query) { if (activeRequestController) { activeRequestController.abort(); } activeRequestController = new AbortController(); showSpinner(); try { const separator = url.includes('?') ? '&' : '?'; const data = await getJson(`${url}${separator}q=${encodeURIComponent(query)}`, { signal: activeRequestController.signal, }); hideIndicator(); const items = Array.isArray(data) ? data : (data?.data || []); renderResults(items); } catch (error) { if (error instanceof Error && error.name === 'AbortError') { return; } hideIndicator(); renderError(); } } on(input, 'input', () => { if (selected) { selected = false; delete input.dataset.lookupSelected; if (hiddenValue) hiddenValue.value = ''; if (hiddenDisplay) hiddenDisplay.value = ''; } const query = input.value.trim(); if (query.length < minChars) { closeDropdown(); hideIndicator(); return; } window.clearTimeout(debounceTimer); debounceTimer = window.setTimeout(() => { void fetchResults(query); }, debounceMs); }); on(input, 'keydown', (event) => { if (dropdown.hidden) return; const options = dropdown.querySelectorAll('.app-lookup-field-option'); if (!options.length) return; if (event.key === 'ArrowDown') { event.preventDefault(); setActive(activeIndex < options.length - 1 ? activeIndex + 1 : 0); } else if (event.key === 'ArrowUp') { event.preventDefault(); setActive(activeIndex > 0 ? activeIndex - 1 : options.length - 1); } else if (event.key === 'Enter') { event.preventDefault(); if (activeIndex >= 0) { selectItem(activeIndex); } } else if (event.key === 'Escape') { event.preventDefault(); closeDropdown(); } }); on(input, 'focus', () => { if (input.value.trim().length >= minChars && !selected && results.length > 0) { openDropdown(); } }); on(input, 'blur', () => { window.clearTimeout(blurTimer); blurTimer = window.setTimeout(() => closeDropdown(), 150); }); return { input, clearSelection, destroy: () => { listenerController.abort(); if (activeRequestController) { activeRequestController.abort(); } window.clearTimeout(debounceTimer); window.clearTimeout(blurTimer); cleanupNodes.forEach((node) => { if (node.parentNode === container) { container.removeChild(node); } }); container.classList.remove('app-lookup-field'); delete container.dataset.lookupInitialized; }, }; } export function initLookupFields(root = document, options = {}) { const host = resolveHost(root); const selector = String(options.selector || '[data-app-lookup]').trim() || '[data-app-lookup]'; const elements = Array.from(host.querySelectorAll(selector)); if (elements.length === 0) { return EMPTY_API; } const instances = []; elements.forEach((el) => { if (el.dataset.lookupInitialized) { return; } el.dataset.lookupInitialized = '1'; instances.push(initLookupField(el)); }); return { destroy: () => { instances.forEach((instance) => { if (instance && typeof instance.destroy === 'function') { instance.destroy(); } }); }, }; }