// 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(); });
}