// Combobox.js — Reusable dropdown-suggestion + free-text input component. // // Enhances an existing element with a dropdown panel that merges static // `presets` with asynchronously fetched options (`fetchOptions`). The input // remains a free-text field — selecting a dropdown option is optional, the // user can always type an arbitrary value. // // Zero dependencies: pure DOM manipulation. Exported on `window.Combobox` // so non-module callers can instantiate it, and as a named ES module export // for callers that import it directly. // // Usage: // const box = new Combobox(inputEl, { // presets: ['masterpiece', 'best quality'], // fetchOptions: async (q) => await fetchSuggestions(q), // placeholder: 'Type a value…', // onSelect: (value) => console.log('chose', value), // }); // box.updatePresets(['new', 'presets']); // box.setValue('masterpiece'); const DEBOUNCE_MS = 300; export class Combobox { /** * @param {HTMLInputElement} inputElement Existing to enhance. * @param {Object} options * @param {string[]} [options.presets=[]] Static preset values shown in dropdown. * @param {(inputValue: string) => Promise} [options.fetchOptions] * Async function returning dynamic suggestions for the current input. * @param {string} [options.placeholder] Placeholder text for the empty state. * @param {(value: string) => void} [options.onSelect] Callback when an option is chosen. */ constructor(inputElement, options = {}) { if (!inputElement || inputElement.tagName !== 'INPUT') { console.error('Combobox: expected an element'); return; } this.input = inputElement; this.presets = Array.isArray(options.presets) ? [...options.presets] : []; this.fetchOptions = typeof options.fetchOptions === 'function' ? options.fetchOptions : null; this.placeholder = options.placeholder || ''; this.onSelect = typeof options.onSelect === 'function' ? options.onSelect : null; // Internal state this._isOpen = false; this._activeIndex = -1; this._renderedOptions = []; // current visible option strings (de-duplicated, merged) this._fetchToken = 0; // guards against out-of-order async fetch results this._fetchTimer = null; this._suppressInputOpen = false; // guards setValue() from reopening the dropdown this._buildDropdown(); this._bindEvents(); } // ---- public API ---- /** * Replace the preset list. Re-renders the dropdown if it is open. * @param {string[]} presets * @returns {void} */ updatePresets(presets) { this.presets = Array.isArray(presets) ? [...presets] : []; if (this._isOpen) { this._refresh(); } } /** * Set the input value programmatically without triggering the dropdown * or firing synthetic events. * @param {string} value * @returns {void} */ setValue(value) { const prev = this._suppressInputOpen; this._suppressInputOpen = true; this.input.value = value ?? ''; this._suppressInputOpen = prev; if (this._isOpen) { this._refresh(); } } // ---- build ---- _buildDropdown() { const panel = document.createElement('div'); panel.className = 'lm-combobox-panel'; panel.setAttribute('role', 'listbox'); panel.style.display = 'none'; // Append to so the panel is never clipped by an overflow:hidden // ancestor; positioning is recomputed on each open. document.body.appendChild(panel); this.panel = panel; if (this.placeholder) { this.input.setAttribute('placeholder', this.placeholder); } this.input.setAttribute('autocomplete', 'off'); this.input.setAttribute('role', 'combobox'); this.input.setAttribute('aria-autocomplete', 'list'); this.input.setAttribute('aria-expanded', 'false'); } // ---- event wiring ---- _bindEvents() { this.input.addEventListener('focus', () => { if (this._suppressInputOpen) return; this._open(); }); this.input.addEventListener('input', () => { if (this._suppressInputOpen) return; this._open(); // no-op if already open this._refresh(); // re-filter by current input value this._scheduleFetch(); }); this.input.addEventListener('keydown', (event) => this._onKeyDown(event)); // Click an option (delegated) this.panel.addEventListener('click', (event) => { const item = event.target.closest('.lm-combobox-option'); if (!item) return; const value = item.dataset.value; if (value !== undefined) { this._choose(value); } }); // Hover updates the active highlight so keyboard + mouse stay in sync. this.panel.addEventListener('mouseover', (event) => { const item = event.target.closest('.lm-combobox-option'); if (!item) return; const idx = Number(item.dataset.index); if (!Number.isNaN(idx)) { this._setActiveIndex(idx); } }); // Click outside closes the dropdown. this._outsideClickHandler = (event) => { if (this._isOpen && !this.input.contains(event.target) && !this.panel.contains(event.target)) { this._close(); } }; document.addEventListener('mousedown', this._outsideClickHandler); // Reposition on viewport changes while open. this._resizeHandler = () => { if (this._isOpen) this._position(); }; window.addEventListener('resize', this._resizeHandler); window.addEventListener('scroll', this._resizeHandler, true); } // ---- keyboard ---- _onKeyDown(event) { if (!this._isOpen) { if (event.key === 'ArrowDown') { event.preventDefault(); this._open(); this._setActiveIndex(0); } return; } switch (event.key) { case 'ArrowDown': event.preventDefault(); this._setActiveIndex(this._activeIndex + 1); break; case 'ArrowUp': event.preventDefault(); this._setActiveIndex(this._activeIndex - 1); break; case 'Enter': // Only intercept Enter to pick an option when one is actively // highlighted; otherwise let the input's default behavior // (form submit / free-text commit) proceed. if (this._activeIndex >= 0 && this._activeIndex < this._renderedOptions.length) { event.preventDefault(); this._choose(this._renderedOptions[this._activeIndex]); } break; case 'Escape': event.preventDefault(); this._close(); this.input.focus(); break; case 'Tab': // Allow normal tab navigation; just close the panel. this._close(); break; } } // ---- open / close ---- _open() { if (this._isOpen) return; this._isOpen = true; this.panel.style.display = 'block'; this.input.setAttribute('aria-expanded', 'true'); // On open, render ALL presets — do not filter by the current input // value. Filtering on the input event is handled separately. this._render(this.presets); this._position(); } _close() { if (!this._isOpen) return; this._isOpen = false; this.panel.style.display = 'none'; this.input.setAttribute('aria-expanded', 'false'); this._activeIndex = -1; this._cancelFetch(); } _position() { const rect = this.input.getBoundingClientRect(); const panelHeight = this.panel.offsetHeight; const viewportHeight = window.innerHeight; const spaceBelow = viewportHeight - rect.bottom; const spaceAbove = rect.top; // Flip above the input when there is more room there. const placeAbove = spaceBelow < panelHeight && spaceAbove > spaceBelow; const top = placeAbove ? rect.top + window.scrollY - panelHeight : rect.bottom + window.scrollY; this.panel.style.top = `${Math.max(0, top)}px`; this.panel.style.left = `${rect.left + window.scrollX}px`; this.panel.style.minWidth = `${rect.width}px`; } // ---- rendering ---- /** Render a list of strings into the panel. */ _render(items) { this._renderedOptions = items; this.panel.innerHTML = ''; if (items.length === 0) { const empty = document.createElement('div'); empty.className = 'lm-combobox-empty'; empty.textContent = this.placeholder ? this.placeholder : 'No options'; this.panel.appendChild(empty); this._activeIndex = -1; return; } const fragment = document.createDocumentFragment(); items.forEach((opt, idx) => { const item = document.createElement('div'); item.className = 'lm-combobox-option'; item.setAttribute('role', 'option'); item.dataset.value = opt; item.dataset.index = String(idx); item.textContent = opt; if (idx === this._activeIndex) { item.classList.add('is-active'); } fragment.appendChild(item); }); this.panel.appendChild(fragment); if (this._activeIndex >= items.length) { this._setActiveIndex(items.length - 1); } } /** Filter presets by current input value and re-render. */ _refresh() { const value = this.input.value; const filtered = this._filterPresets(value); const merged = this._mergeUnique(filtered, this._fetchedOptions || []); this._render(merged); } _filterPresets(value) { const v = (value || '').toLowerCase(); if (!v) return [...this.presets]; return this.presets.filter((p) => String(p).toLowerCase().startsWith(v)); } _mergeUnique(...lists) { const seen = new Set(); const out = []; for (const list of lists) { for (const item of list) { const key = String(item); if (!seen.has(key)) { seen.add(key); out.push(key); } } } return out; } _setActiveIndex(idx) { const max = this._renderedOptions.length - 1; const clamped = Math.max(-1, Math.min(max, idx)); this._activeIndex = clamped; // Update DOM classes without full re-render. const items = this.panel.querySelectorAll('.lm-combobox-option'); items.forEach((el, i) => { el.classList.toggle('is-active', i === clamped); }); // Scroll the active item into view inside the panel. if (clamped >= 0 && items[clamped]) { items[clamped].scrollIntoView({ block: 'nearest' }); } } /** * Remove the panel from the DOM and detach event listeners. * Call this before discarding the Combobox instance. */ destroy() { this._close(); if (this.panel && this.panel.parentNode) { this.panel.parentNode.removeChild(this.panel); } document.removeEventListener('mousedown', this._outsideClickHandler); window.removeEventListener('resize', this._resizeHandler); window.removeEventListener('scroll', this._resizeHandler, true); } _choose(value) { this.input.value = value; this._close(); if (typeof this.onSelect === 'function') { this.onSelect(value); } // Re-focus without reopening the dropdown. this._suppressInputOpen = true; this.input.focus(); this._suppressInputOpen = false; } // ---- async fetch (debounced) ---- _scheduleFetch() { if (!this.fetchOptions) return; this._cancelFetch(); this._fetchTimer = setTimeout(() => { this._fetchTimer = null; this._runFetch(); }, DEBOUNCE_MS); } _cancelFetch() { if (this._fetchTimer) { clearTimeout(this._fetchTimer); this._fetchTimer = null; } this._fetchToken++; // invalidate any in-flight result } async _runFetch() { if (!this.fetchOptions) return; const token = this._fetchToken; const value = this.input.value; let results; try { results = await this.fetchOptions(value); } catch (err) { console.error('Combobox fetchOptions error:', err); results = []; } // Stale guard: a newer fetch or close superseded this one. if (token !== this._fetchToken || !this._isOpen) return; this._fetchedOptions = Array.isArray(results) ? results : []; this._refresh(); } } // Expose for non-module callers (templates load via