diff --git a/assets/search-suggest.js b/assets/search-suggest.js index a15f996..87d0194 100644 --- a/assets/search-suggest.js +++ b/assets/search-suggest.js @@ -1,63 +1,18 @@ // 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. Owns: debounced fetching, -// request ordering, DOM creation, keyboard handling, open/close lifecycle. +// 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(result) — called when the user selects a row -// onShowAll(query) — optional; called when the footer row activates +// 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 DEBOUNCE_MS = 100; - var MIN_QUERY_LEN = 2; - - function escapeHTML(s) { - return String(s) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); - } - - function tokenize(s) { - return s.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean); - } - - // highlight bolds the substring spans in `name` that match any of the - // query tokens (case-insensitive). Overlapping/adjacent spans merge. - // Returns a safe HTML string. - function highlight(name, tokens) { - if (!tokens.length) return escapeHTML(name); - var lower = name.toLowerCase(); - var spans = []; - tokens.forEach(function (t) { - if (!t) return; - var idx = lower.indexOf(t); - if (idx >= 0) spans.push([idx, idx + t.length]); - }); - if (!spans.length) return escapeHTML(name); - spans.sort(function (a, b) { return a[0] - b[0]; }); - var merged = [spans[0].slice()]; - for (var i = 1; i < spans.length; i++) { - var last = merged[merged.length - 1]; - if (spans[i][0] <= last[1]) { - last[1] = Math.max(last[1], spans[i][1]); - } else { - merged.push(spans[i].slice()); - } - } - var out = ''; - var cursor = 0; - merged.forEach(function (sp) { - out += escapeHTML(name.slice(cursor, sp[0])); - out += '' + escapeHTML(name.slice(sp[0], sp[1])) + ''; - cursor = sp[1]; - }); - out += escapeHTML(name.slice(cursor)); - return out; - } + var seq = 0; function attachSuggestions(inputEl, opts) { if (!inputEl) return; @@ -68,237 +23,116 @@ var dropdown = document.createElement('div'); dropdown.className = 'suggest-dropdown'; + dropdown.id = 'suggest-' + (++seq); host.appendChild(dropdown); - function makeRow(cls, tabbable) { - var tr = document.createElement('tr'); - tr.className = cls; - if (tabbable) tr.setAttribute('tabindex', '0'); - var td = document.createElement('td'); - tr.appendChild(td); - return { tr: tr, td: td }; + // 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'); } - var state = { - results: [], - total: 0, - query: '', - activeIdx: -1, - open: false, - reqSeq: 0, - debounceTimer: null, - blurTimer: null, - }; - - function rowCount() { - var n = state.results.length; - if (state.results.length === 0 && state.query.length >= MIN_QUERY_LEN) { - return 0; // "no matches" row is non-interactive - } - if (opts.showFooter && state.total > state.results.length) n += 1; - return n; - } - - function isFooterIdx(idx) { - return opts.showFooter - && state.total > state.results.length - && idx === state.results.length; - } - - function render() { - dropdown.textContent = ''; - if (!state.open) { - dropdown.classList.remove('is-open'); - return; - } - var table = document.createElement('table'); - table.className = 'data-table'; - var tbody = document.createElement('tbody'); - table.appendChild(tbody); - - var tokens = tokenize(state.query); - if (state.results.length === 0) { - var empty = makeRow('is-empty', false); - empty.td.textContent = 'No matches'; - tbody.appendChild(empty.tr); - } else { - state.results.forEach(function (r, i) { - var row = makeRow('suggest-row', true); - row.tr.setAttribute('data-idx', String(i)); - var nameEl = document.createElement('span'); - nameEl.className = 'suggest-name'; - nameEl.innerHTML = highlight(r.name, tokens); - var pathEl = document.createElement('span'); - pathEl.className = 'suggest-path'; - pathEl.textContent = '/' + r.path; - row.td.appendChild(nameEl); - row.td.appendChild(pathEl); - if (i === state.activeIdx) row.tr.classList.add('is-active'); - row.tr.addEventListener('mousedown', function (e) { - // mousedown (not click) so the input doesn't blur-close - // the dropdown before the pick handler fires. - e.preventDefault(); - pick(i); - }); - tbody.appendChild(row.tr); - }); - if (opts.showFooter && state.total > state.results.length) { - var footer = makeRow('suggest-row suggest-footer', true); - footer.td.textContent = 'Show all ' + state.total + ' matches'; - var footerIdx = state.results.length; - if (state.activeIdx === footerIdx) footer.tr.classList.add('is-active'); - footer.tr.addEventListener('mousedown', function (e) { - e.preventDefault(); - pickFooter(); - }); - tbody.appendChild(footer.tr); - } - } - dropdown.appendChild(table); - dropdown.classList.add('is-open'); - } - - function pick(idx) { - var r = state.results[idx]; - if (!r) return; - close(); - if (opts.onPick) opts.onPick(r); - } - - function pickFooter() { - close(); - if (opts.onShowAll) { - opts.onShowAll(state.query); - } else if (inputEl.form) { - inputEl.form.submit(); - } else { - window.location.href = '/?q=' + encodeURIComponent(state.query); - } + function isOpen() { + return dropdown.classList.contains('is-open'); } function open() { - state.open = true; - render(); + if (dropdown.firstElementChild) dropdown.classList.add('is-open'); } function close() { - state.open = false; - state.activeIdx = -1; - render(); + dropdown.classList.remove('is-open'); + setActive(-1); } - function fetchResults(query) { - var seq = ++state.reqSeq; - fetch('/_search?q=' + encodeURIComponent(query), { - credentials: 'same-origin', - headers: { 'Accept': 'application/json' }, - }).then(function (r) { - if (!r.ok) throw new Error('HTTP ' + r.status); - return r.json(); - }).then(function (resp) { - if (seq !== state.reqSeq) return; // stale - state.results = resp.results || []; - state.total = resp.total || 0; - state.query = resp.query || query; - state.activeIdx = -1; - open(); - }).catch(function () { - if (seq !== state.reqSeq) return; - state.results = []; - state.total = 0; - close(); - }); - } - - function onInput() { - var q = inputEl.value.trim(); - state.query = q; - if (state.debounceTimer) clearTimeout(state.debounceTimer); - if (q.length < MIN_QUERY_LEN) { - state.reqSeq++; // invalidate any in-flight response - state.results = []; - state.total = 0; - close(); - return; + 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) {} } - state.debounceTimer = setTimeout(function () { - fetchResults(q); - }, DEBOUNCE_MS); } function moveActive(delta) { - var n = rowCount(); + var n = rows().length; if (n === 0) return; - var next = state.activeIdx + delta; + var next = activeIdx + delta; if (next < 0) next = n - 1; if (next >= n) next = 0; - state.activeIdx = next; - render(); - // Keep the active row in view. - var active = dropdown.querySelector('tr.is-active'); - if (active && active.scrollIntoView) { - try { active.scrollIntoView({ block: 'nearest' }); } catch (e) {} + 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(); } } - function activateCurrent() { - if (state.activeIdx < 0) return false; - if (isFooterIdx(state.activeIdx)) { - pickFooter(); - return true; - } - pick(state.activeIdx); - return true; - } - - inputEl.addEventListener('input', onInput); - inputEl.addEventListener('focus', function () { - if (state.blurTimer) { - clearTimeout(state.blurTimer); - state.blurTimer = null; - } - if (inputEl.value.trim().length >= MIN_QUERY_LEN - && (state.results.length || state.query)) { + dropdown.addEventListener('htmx:afterSwap', function () { + activeIdx = -1; + if (document.activeElement === inputEl && dropdown.firstElementChild) { open(); + } else { + close(); } }); - inputEl.addEventListener('blur', function () { - // Delay so click/mousedown on a row still resolves. - state.blurTimer = setTimeout(close, 150); + + // 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') { - if (!state.open) return; e.preventDefault(); moveActive(1); } else if (e.key === 'ArrowUp') { - if (!state.open) return; e.preventDefault(); moveActive(-1); } else if (e.key === 'Escape') { - if (!state.open) return; e.preventDefault(); close(); } else if (e.key === 'Enter') { - if (state.open && state.activeIdx >= 0) { + // Without an active row: native submit (full results page). + var row = rows()[activeIdx]; + if (row) { e.preventDefault(); - activateCurrent(); + pick(row); } - // else: native form submit behaviour (full results page) } else if (e.key === 'Tab') { - if (!state.open || rowCount() === 0) return; + if (rows().length === 0) return; e.preventDefault(); moveActive(e.shiftKey ? -1 : 1); } }); - // Click outside the host closes the dropdown. - document.addEventListener('mousedown', function (e) { - if (!state.open) return; - if (host.contains(e.target)) return; - close(); - }); - return { close: close, destroy: function () { @@ -310,14 +144,7 @@ window.attachSuggestions = attachSuggestions; - // Auto-bind to the header search input. Header search submits the form - // for the "show all" action; we route to a navigate-on-pick handler. document.addEventListener('DOMContentLoaded', function () { - var input = document.querySelector('header .search-input'); - if (!input) return; - attachSuggestions(input, { - showFooter: true, - onPick: function (r) { window.location.href = r.url; }, - }); + attachSuggestions(document.querySelector('header .search-input'), { showFooter: true }); }); })(); diff --git a/assets/search/suggest.html b/assets/search/suggest.html new file mode 100644 index 0000000..3b324b9 --- /dev/null +++ b/assets/search/suggest.html @@ -0,0 +1,8 @@ +{{/* Search typeahead dropdown (/_search, htmx requests). Rows are links, so a +pick in the header is plain navigation; the editor's link dialog intercepts +the click and reads data-name. Empty output closes the dropdown. */}} +{{define "suggest"}}{{if .Show}}
| {{.Highlighted}}/{{.Path}} |
| No matches |
| Show all {{.Total}} matches |