// Attaches an autocomplete dropdown to a text input. Debounced, keyboard- // navigable, and race-safe (stale responses are dropped). `search(q)` returns // an array of {did, handle, displayName, avatar}; `onPick(actor)` fires on // selection (click or Enter). const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); export function attachTypeahead(input, listEl, { search, onPick, minChars = 2, delay = 160 } = {}) { let items = [], active = -1, seq = 0, timer, blurTimer; const close = () => { listEl.hidden = true; listEl.innerHTML = ''; items = []; active = -1; input.setAttribute('aria-expanded', 'false'); }; const highlight = () => { [...listEl.children].forEach((li, i) => li.classList.toggle('active', i === active)); if (active >= 0) listEl.children[active]?.scrollIntoView({ block: 'nearest' }); }; const render = () => { listEl.innerHTML = items.map((a, i) => { const name = a.displayName?.trim() || a.handle; const av = a.avatar ? `` : `${esc(name[0] || '?').toUpperCase()}`; return `
  • ${av} ${esc(name)} @${esc(a.handle)}
  • `; }).join(''); listEl.hidden = false; input.setAttribute('aria-expanded', 'true'); }; const pick = (i) => { const a = items[i]; if (!a) return; input.value = a.handle; close(); onPick?.(a); }; const run = () => { const q = input.value.trim().replace(/^@/, ''); if (q.length < minChars) return close(); const my = ++seq; search(q).then((res) => { if (my !== seq || document.activeElement !== input) return; // stale or blurred items = res || []; active = -1; items.length ? render() : close(); }).catch(() => {}); }; input.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(run, delay); }); input.addEventListener('keydown', (e) => { if (listEl.hidden) return; if (e.key === 'ArrowDown') { e.preventDefault(); active = Math.min(active + 1, items.length - 1); highlight(); } else if (e.key === 'ArrowUp') { e.preventDefault(); active = Math.max(active - 1, 0); highlight(); } else if (e.key === 'Enter' && active >= 0) { e.preventDefault(); pick(active); } else if (e.key === 'Escape') { close(); } }); // mousedown (not click) so selection beats the input's blur. listEl.addEventListener('mousedown', (e) => { const li = e.target.closest('[data-i]'); if (li) { e.preventDefault(); pick(+li.dataset.i); } }); input.addEventListener('blur', () => { blurTimer = setTimeout(close, 120); }); input.addEventListener('focus', () => { clearTimeout(blurTimer); if (items.length) render(); }); }