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