Something went wrong. Try again.
Something went wrong. Try again.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677// 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 ? `<img class="ta-av" src="${esc(a.avatar)}" alt="" loading="lazy">` : `<span class="ta-av ta-blank">${esc(name[0] || '?').toUpperCase()}</span>`; return `<li class="ta-item" role="option" data-i="${i}"> ${av} <span class="ta-text"><span class="ta-name">${esc(name)}</span> <span class="ta-handle">@${esc(a.handle)}</span></span> </li>`; }).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(); });}