Improve serach function with quick suggestions
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
// 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, '>')
|
||||
.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 += '<strong>' + escapeHTML(name.slice(sp[0], sp[1])) + '</strong>';
|
||||
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; },
|
||||
});
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user