// Shared client-side news search over /search.json (no dependencies, no modules). // Used by the header search (field + mobile reveal) and /search (full results). // Focused-empty state per Baymard autocomplete guidance: recent searches (local // only — localStorage, no network) plus the newest articles, never a void. (function () { const RKEY = 'ctao-recent-searches'; function recents() { try { return JSON.parse(localStorage.getItem(RKEY) || '[]'); } catch { return []; } } function remember(q) { q = (q || '').trim(); if (q.length < 2) return; try { const r = [q, ...recents().filter((x) => x.toLowerCase() !== q.toLowerCase())].slice(0, 4); localStorage.setItem(RKEY, JSON.stringify(r)); } catch { /* private mode — recents are a nicety */ } } window.newsSearch = function (opts) { const input = document.getElementById(opts.input); const list = document.getElementById(opts.list); const status = document.getElementById(opts.status); // Null-safe: a page may render only some search surfaces — bail silently // (returning a no-op) so one missing input can't kill later inits. if (!input || !list || !status) return function () {}; let index = null; let timer; async function ensureIndex() { if (index) return index; try { index = await (await fetch('/search.json')).json(); } catch { // Network hiccup: say so instead of failing silently; index stays // null so the next keystroke retries. status.textContent = 'Search is unavailable right now.'; return null; } return index; } // First title match wrapped in (Starlight search pattern) — built // from text nodes, never innerHTML. function highlight(text, q) { const i = text.toLowerCase().indexOf(q); if (i < 0) return [text]; const mark = document.createElement('mark'); mark.textContent = text.slice(i, i + q.length); return [text.slice(0, i), mark, text.slice(i + q.length)]; } function label(text) { const li = document.createElement('li'); li.className = 'suggest-label'; li.setAttribute('aria-hidden', 'true'); // the status line announces it li.textContent = text; return li; } function row(children, href, q) { const li = document.createElement('li'); const a = document.createElement('a'); a.href = href; if (q) a.dataset.q = q; a.append(...children); li.append(a); return li; } // Dropdowns close with a "Press Enter for all results" row — a real list // element (was a CSS ::after), aria-hidden like the group labels so // screen-reader exposure stays consistent (the status line announces). function hint() { if (!opts.dismiss || !list.firstChild) return; const li = document.createElement('li'); li.className = 'suggest-hint'; li.setAttribute('aria-hidden', 'true'); li.textContent = 'Press Enter for all results'; list.append(li); } // Focused-empty state: up to 4 recent queries, topped up to 5 rows with // the newest articles from the same index. async function idle() { if (input.value.trim().length >= 2) return; // Build off-DOM, swap atomically — clearing before the await shrinks the // page mid-flight and the browser clamps the scroll position ("jumps up"). const frag = document.createDocumentFragment(); const r = recents(); if (r.length) { frag.append(label('Recent')); for (const term of r) frag.append(row([term], '/search?q=' + encodeURIComponent(term), term)); } const idx = await ensureIndex(); if (!idx) return; const fresh = idx.slice(0, Math.max(1, 5 - r.length)); if (input.value.trim().length >= 2) return; // typed meanwhile — run() owns the list frag.append(label('Latest news')); for (const p of fresh) { const li = row([p.title], '/news/' + encodeURIComponent(p.slug)); const small = document.createElement('small'); small.textContent = p.date; li.append(small); frag.append(li); } list.replaceChildren(frag); hint(); status.textContent = opts.hint || (r.length ? 'Showing recent searches and latest news' : 'Showing latest news'); } async function run() { const q = input.value.trim().toLowerCase(); if (q.length < 2) { list.replaceChildren(); idle(); return; } const idx = await ensureIndex(); if (!idx) { list.replaceChildren(); return; } const hits = idx .filter((p) => (p.title + ' ' + p.description + ' ' + p.category).toLowerCase().includes(q)) .slice(0, opts.limit); const unit = opts.unit || 'result'; status.textContent = hits.length ? hits.length + ' ' + unit + (hits.length === 1 ? '' : 's') + ' for “' + input.value.trim() + '”' : 'No results for “' + input.value.trim() + '” — try a shorter or different term.'; // Off-DOM build + atomic swap (no transient page-height collapse) const frag = document.createDocumentFragment(); for (const p of hits) { const li = row(highlight(p.title, q), '/news/' + encodeURIComponent(p.slug)); const small = document.createElement('small'); small.textContent = p.date; li.append(small); frag.append(li); } // Dropdown only: an empty list would collapse (.suggest:empty) and the // panel would just vanish — show the no-results line sighted users too // (the status line above is sr-only in the header; /search shows a // visible notice instead). if (!hits.length && opts.dismiss) { const li = document.createElement('li'); li.className = 'suggest-hint'; li.setAttribute('aria-hidden', 'true'); // the status line announces it li.textContent = 'No results for “' + input.value.trim() + '”'; frag.append(li); } list.replaceChildren(frag); hint(); } input.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(run, 150); }); input.addEventListener('focus', () => { if (input.value.trim().length < 2) idle(); }); // Remember successful queries: Enter (form submit) and suggestion clicks if (input.form) input.form.addEventListener('submit', () => remember(input.value)); list.addEventListener('click', (e) => { const a = e.target.closest && e.target.closest('a'); remember((a && a.dataset.q) || input.value); }); // Dropdown dismiss (opt-in — /search keeps its results list). The // suggestion list is a NON-MODAL popup (combobox-style; plain // Tab-reachable links, no roving arrow-key focus): interacting outside // the field+list closes it but the interaction is NOT swallowed — // unlike the modal header panels, which scrim-dismiss in Base.astro. if (opts.dismiss) { const box = input.closest('form') || input.parentElement; const hide = () => { clearTimeout(timer); list.replaceChildren(); status.textContent = ''; }; document.addEventListener('pointerdown', (e) => { if (list.firstChild && !box.contains(e.target)) hide(); }); // relatedTarget may be null mid-click on our own links — pointerdown covers that path box.addEventListener('focusout', (e) => { if (e.relatedTarget && !box.contains(e.relatedTarget)) hide(); }); input.addEventListener('keydown', (e) => { if (e.key === 'Escape' && list.firstChild) { hide(); e.stopPropagation(); } // panel Escape stays a second step }); } return run; // callers may run() immediately (e.g. /search?q=…) }; })();