// 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. // // opts: // onPick(result) — called when the user selects a row // onShowAll(query) — optional; called when the footer row activates // 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; } 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'; host.appendChild(dropdown); 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 tokens = tokenize(state.query); if (state.results.length === 0) { var empty = document.createElement('div'); empty.className = 'suggest-row is-empty'; empty.textContent = 'No matches'; dropdown.appendChild(empty); } else { state.results.forEach(function (r, i) { var row = document.createElement('button'); row.type = 'button'; row.className = 'suggest-row'; row.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.appendChild(nameEl); row.appendChild(pathEl); if (i === state.activeIdx) row.classList.add('is-active'); row.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); }); dropdown.appendChild(row); }); if (opts.showFooter && state.total > state.results.length) { var footer = document.createElement('button'); footer.type = 'button'; footer.className = 'suggest-row suggest-footer'; footer.textContent = 'Show all ' + state.total + ' matches'; var footerIdx = state.results.length; if (state.activeIdx === footerIdx) footer.classList.add('is-active'); footer.addEventListener('mousedown', function (e) { e.preventDefault(); pickFooter(); }); dropdown.appendChild(footer); } } 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 open() { state.open = true; render(); } function close() { state.open = false; state.activeIdx = -1; render(); } 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; } state.debounceTimer = setTimeout(function () { fetchResults(q); }, DEBOUNCE_MS); } function moveActive(delta) { var n = rowCount(); if (n === 0) return; var next = state.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('.suggest-row.is-active'); if (active && active.scrollIntoView) { try { active.scrollIntoView({ block: 'nearest' }); } catch (e) {} } } 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)) { open(); } }); inputEl.addEventListener('blur', function () { // Delay so click/mousedown on a row still resolves. state.blurTimer = setTimeout(close, 150); }); inputEl.addEventListener('keydown', function (e) { 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) { e.preventDefault(); activateCurrent(); } // else: native form submit behaviour (full results page) } else if (e.key === 'Tab') { if (!state.open || rowCount() === 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 () { if (dropdown.parentNode) dropdown.parentNode.removeChild(dropdown); host.classList.remove('suggest-host'); }, }; } 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; }, }); }); })();