Files
datascape/assets/search-suggest.js
T
luxickandClaude Opus 5.5 a55a1c8ed4 Render search suggestions on the server
The typeahead requests /_search via htmx (debounced, newer requests
replace in-flight ones) and gets rendered rows with the match
highlighting done in Go. Rows are links, so picking in the header is
plain navigation; the editor's link dialog reads data-name instead. The
editor's [[ autocomplete keeps the JSON response.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:00:08 +02:00

151 lines
5.3 KiB
JavaScript

// search-suggest.js — instant typeahead dropdown.
//
// Exposes window.attachSuggestions(inputEl, opts) used by both the header
// search box and the editor's "Insert link" modal. Fetching is htmx: the input
// requests /_search as you type (debounced; a newer request replaces an
// in-flight one) and the server renders the rows (assets/search/suggest.html).
// This file owns the dropdown's open/close lifecycle and keyboard handling.
//
// opts:
// onPick({name}) — optional; called when the user selects a row.
// Without it rows are plain links and navigate.
// showFooter (bool) — show the "Show all N matches" footer row
// container (Element) — optional parent (defaults to inputEl.parentNode)
(function () {
var seq = 0;
function attachSuggestions(inputEl, opts) {
if (!inputEl) return;
opts = opts || {};
var host = opts.container || inputEl.parentNode;
if (!host) return;
host.classList.add('suggest-host');
var dropdown = document.createElement('div');
dropdown.className = 'suggest-dropdown';
dropdown.id = 'suggest-' + (++seq);
host.appendChild(dropdown);
// htmx sends the input's value under its name.
if (!inputEl.name) inputEl.name = 'q';
inputEl.setAttribute('hx-get', '/_search' + (opts.showFooter ? '?footer' : ''));
inputEl.setAttribute('hx-trigger', 'input changed delay:100ms');
inputEl.setAttribute('hx-sync', 'this:replace');
inputEl.setAttribute('hx-target', '#' + dropdown.id);
htmx.process(inputEl);
var activeIdx = -1;
function rows() {
return dropdown.querySelectorAll('.suggest-row');
}
function isOpen() {
return dropdown.classList.contains('is-open');
}
function open() {
if (dropdown.firstElementChild) dropdown.classList.add('is-open');
}
function close() {
dropdown.classList.remove('is-open');
setActive(-1);
}
function setActive(idx) {
var rs = rows();
if (rs[activeIdx]) rs[activeIdx].classList.remove('is-active');
activeIdx = idx;
if (rs[idx]) {
rs[idx].classList.add('is-active');
try { rs[idx].scrollIntoView({ block: 'nearest' }); } catch (e) {}
}
}
function moveActive(delta) {
var n = rows().length;
if (n === 0) return;
var next = activeIdx + delta;
if (next < 0) next = n - 1;
if (next >= n) next = 0;
setActive(next);
}
// pick activates a row: onPick for data rows when given, otherwise
// (and always for the footer) the row's link.
function pick(row) {
close();
if (opts.onPick && !row.classList.contains('suggest-footer')) {
opts.onPick({ name: row.dataset.name });
} else {
row.querySelector('a').click();
}
}
dropdown.addEventListener('htmx:afterSwap', function () {
activeIdx = -1;
if (document.activeElement === inputEl && dropdown.firstElementChild) {
open();
} else {
close();
}
});
// mousedown keeps focus in the input so blur doesn't close the
// dropdown before the click lands.
dropdown.addEventListener('mousedown', function (e) { e.preventDefault(); });
dropdown.addEventListener('click', function (e) {
var row = e.target.closest('.suggest-row');
if (!row) return;
if (opts.onPick && !row.classList.contains('suggest-footer')) {
e.preventDefault();
pick(row);
} else {
close(); // the link navigates
}
});
inputEl.addEventListener('focus', open);
inputEl.addEventListener('blur', close);
inputEl.addEventListener('keydown', function (e) {
if (!isOpen()) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
moveActive(1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
moveActive(-1);
} else if (e.key === 'Escape') {
e.preventDefault();
close();
} else if (e.key === 'Enter') {
// Without an active row: native submit (full results page).
var row = rows()[activeIdx];
if (row) {
e.preventDefault();
pick(row);
}
} else if (e.key === 'Tab') {
if (rows().length === 0) return;
e.preventDefault();
moveActive(e.shiftKey ? -1 : 1);
}
});
return {
close: close,
destroy: function () {
if (dropdown.parentNode) dropdown.parentNode.removeChild(dropdown);
host.classList.remove('suggest-host');
},
};
}
window.attachSuggestions = attachSuggestions;
document.addEventListener('DOMContentLoaded', function () {
attachSuggestions(document.querySelector('header .search-input'), { showFooter: true });
});
})();