137 lines
6.2 KiB
JavaScript
137 lines
6.2 KiB
JavaScript
// 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) index = await (await fetch('/search.json')).json();
|
|
return index;
|
|
}
|
|
// First title match wrapped in <mark> (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;
|
|
list.innerHTML = '';
|
|
const r = recents();
|
|
if (r.length) {
|
|
list.append(label('Recent'));
|
|
for (const term of r) list.append(row([term], '/search?q=' + encodeURIComponent(term), term));
|
|
}
|
|
const fresh = (await ensureIndex()).slice(0, Math.max(1, 5 - r.length));
|
|
if (input.value.trim().length >= 2) return; // typed meanwhile — run() owns the list
|
|
list.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);
|
|
list.append(li);
|
|
}
|
|
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.innerHTML = ''; idle(); return; }
|
|
list.innerHTML = '';
|
|
const idx = await ensureIndex();
|
|
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.';
|
|
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);
|
|
list.append(li);
|
|
}
|
|
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). A suggestion
|
|
// list is a NON-MODAL combobox popup (ARIA combobox pattern): 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.innerHTML = ''; 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=…)
|
|
};
|
|
})();
|