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>
151 lines
5.3 KiB
JavaScript
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 });
|
|
});
|
|
})();
|