refactor(fastsearch): improve search initialization and result rendering

- refactored search index loading to use async/await for better readability
- improved result rendering logic to handle empty results and focus management
- added debounce to search input for performance optimization
- defined default options for Fuse.js to streamline configuration
This commit is contained in:
Aditya Telange
2026-05-02 19:15:38 +05:30
parent e457685198
commit 8af442a6a7
2 changed files with 182 additions and 144 deletions
+168 -130
View File
@@ -1,156 +1,194 @@
import * as params from '@params'; import * as params from '@params';
let fuse; // holds our search engine const resList = document.getElementById('searchResults');
let resList = document.getElementById('searchResults'); const sInput = document.getElementById('searchInput');
let sInput = document.getElementById('searchInput'); const searchBox = document.getElementById('searchbox');
let first, last, current_elem = null
let resultsAvailable = false;
// load our search index let fuse;
window.onload = function () { let currentElement = null;
let xhr = new XMLHttpRequest(); let firstResult = null;
xhr.onreadystatechange = function () { let lastResult = null;
if (xhr.readyState === 4) {
if (xhr.status === 200) { const defaultFuseOptions = {
let data = JSON.parse(xhr.responseText);
if (data) {
// fuse.js options; check fuse.js website for details
let options = {
distance: 100, distance: 100,
threshold: 0.4, threshold: 0.4,
ignoreLocation: true, ignoreLocation: true,
keys: [ keys: ['title', 'permalink', 'summary', 'content']
'title', };
'permalink',
'summary', const buildFuseOptions = () => {
'content' if (!params.fuseOpts) {
] return defaultFuseOptions;
}; }
if (params.fuseOpts) {
options = { return {
isCaseSensitive: params.fuseOpts.iscasesensitive ?? false, isCaseSensitive: params.fuseOpts.iscasesensitive ?? false,
includeScore: params.fuseOpts.includescore ?? false, includeScore: params.fuseOpts.includescore ?? false,
includeMatches: params.fuseOpts.includematches ?? false, includeMatches: params.fuseOpts.includematches ?? false,
minMatchCharLength: params.fuseOpts.minmatchcharlength ?? 1, minMatchCharLength: params.fuseOpts.minmatchcharlength ?? 1,
shouldSort: params.fuseOpts.shouldsort ?? true, shouldSort: params.fuseOpts.shouldsort ?? true,
findAllMatches: params.fuseOpts.findallmatches ?? false, findAllMatches: params.fuseOpts.findallmatches ?? false,
keys: params.fuseOpts.keys ?? ['title', 'permalink', 'summary', 'content'], keys: params.fuseOpts.keys ?? defaultFuseOptions.keys,
location: params.fuseOpts.location ?? 0, location: params.fuseOpts.location ?? 0,
threshold: params.fuseOpts.threshold ?? 0.4, threshold: params.fuseOpts.threshold ?? defaultFuseOptions.threshold,
distance: params.fuseOpts.distance ?? 100, distance: params.fuseOpts.distance ?? defaultFuseOptions.distance,
ignoreLocation: params.fuseOpts.ignorelocation ?? true ignoreLocation: params.fuseOpts.ignorelocation ?? defaultFuseOptions.ignoreLocation
}
}
fuse = new Fuse(data, options); // build the index from the json file
}
} else {
console.log(xhr.responseText);
}
}
}; };
xhr.open('GET', "../index.json"); };
xhr.send();
}
function activeToggle(ae) { const debounce = (fn, delay) => {
document.querySelectorAll('.focus').forEach(function (element) { let timeout;
// rm focus class return (...args) => {
element.classList.remove("focus") clearTimeout(timeout);
}); timeout = window.setTimeout(() => fn(...args), delay);
if (ae) { };
ae.focus() };
document.activeElement = current_elem = ae;
ae.parentElement.classList.add("focus")
} else {
document.activeElement.parentElement.classList.add("focus")
}
}
function reset() { const reset = () => {
resultsAvailable = false; currentElement = null;
resList.innerHTML = sInput.value = ''; // clear inputbox and searchResults firstResult = null;
sInput.focus(); // shift focus to input box lastResult = null;
}
// execute search as each character is typed
sInput.onkeyup = function (e) {
// run a search query (for "term") every time a letter is typed
// in the search box
if (fuse) {
let results;
if (params.fuseOpts) {
results = fuse.search(this.value.trim(), { limit: params.fuseOpts.limit }); // the actual query being run using fuse.js along with options
} else {
results = fuse.search(this.value.trim()); // the actual query being run using fuse.js
}
if (results.length !== 0) {
// build our html if result exists
let resultSet = ''; // our results bucket
for (let item in results) {
resultSet +=
`<li>` +
`${results[item].item.title}` +
`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-chevrons-right"><polyline points="13 17 18 12 13 7"></polyline><polyline points="6 17 11 12 6 7"></polyline></svg>` +
`<a class="entry-link" href="${results[item].item.permalink}" aria-label="${results[item].item.title}"></a>` +
`</li>`
}
resList.innerHTML = resultSet;
resultsAvailable = true;
first = resList.firstChild;
last = resList.lastChild;
} else {
resultsAvailable = false;
resList.innerHTML = ''; resList.innerHTML = '';
} sInput.value = '';
} sInput.focus();
} };
sInput.addEventListener('search', function (e) { const setActiveResult = (element) => {
// clicked on x document.querySelectorAll('.focus').forEach((item) => item.classList.remove('focus'));
if (!this.value) reset()
})
// kb bindings if (!element) {
document.onkeydown = function (e) { return;
let key = e.key; }
let ae = document.activeElement;
let inbox = document.getElementById("searchbox").contains(ae) element.focus();
element.parentElement?.classList.add('focus');
currentElement = element;
};
if (ae === sInput) { const renderResults = (results) => {
let elements = document.getElementsByClassName('focus'); if (!Array.isArray(results) || results.length === 0) {
while (elements.length > 0) { resList.innerHTML = '';
elements[0].classList.remove('focus'); firstResult = lastResult = currentElement = null;
return;
} }
} else if (current_elem) ae = current_elem;
if (key === "Escape") { const fragment = document.createDocumentFragment();
reset()
} else if (!resultsAvailable || !inbox) { for (const result of results) {
return const li = document.createElement('li');
} else if (key === "ArrowDown") { const titleText = document.createTextNode(result.item.title);
e.preventDefault(); const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
if (ae == sInput) { svg.setAttribute('width', '24');
// if the currently focused element is the search input, focus the <a> of first <li> svg.setAttribute('height', '24');
activeToggle(resList.firstChild.lastChild); svg.setAttribute('viewBox', '0 0 24 24');
} else if (ae.parentElement != last) { svg.setAttribute('fill', 'none');
// if the currently focused element's parent is last, do nothing svg.setAttribute('stroke', 'currentColor');
// otherwise select the next search result svg.setAttribute('stroke-width', '2');
activeToggle(ae.parentElement.nextSibling.lastChild); svg.setAttribute('stroke-linecap', 'round');
svg.setAttribute('stroke-linejoin', 'round');
svg.classList.add('feather', 'feather-chevrons-right');
svg.innerHTML = '<polyline points="13 17 18 12 13 7"></polyline><polyline points="6 17 11 12 6 7"></polyline>';
const link = document.createElement('a');
link.className = 'entry-link';
link.href = result.item.permalink;
link.setAttribute('aria-label', result.item.title);
li.appendChild(titleText);
li.appendChild(svg);
li.appendChild(link);
fragment.appendChild(li);
} }
} else if (key === "ArrowUp") {
e.preventDefault(); resList.innerHTML = '';
if (ae.parentElement == first) { resList.appendChild(fragment);
// if the currently focused element is first item, go to input box firstResult = resList.firstElementChild;
activeToggle(sInput); lastResult = resList.lastElementChild;
} else if (ae != sInput) { };
// if the currently focused element is input box, do nothing
// otherwise select the previous search result const performSearch = () => {
activeToggle(ae.parentElement.previousSibling.lastChild); if (!fuse) {
return;
} }
} else if (key === "ArrowRight") {
ae.click(); // click on active link const query = sInput.value.trim();
if (!query) {
renderResults([]);
return;
} }
}
const searchOptions = params.fuseOpts?.limit ? { limit: params.fuseOpts.limit } : undefined;
const results = searchOptions ? fuse.search(query, searchOptions) : fuse.search(query);
renderResults(results);
};
const initSearch = async () => {
if (!sInput || !resList) {
return;
}
sInput.disabled = false;
sInput.focus();
try {
const response = await fetch('../index.json');
if (!response.ok) {
throw new Error(`Search index load failed: ${response.status}`);
}
const data = await response.json();
if (data) {
fuse = new Fuse(data, buildFuseOptions());
}
} catch (error) {
console.error(error);
}
};
window.addEventListener('load', initSearch);
sInput?.addEventListener('input', debounce(performSearch, 150));
sInput?.addEventListener('search', () => {
if (!sInput.value) {
reset();
}
});
document.addEventListener('keydown', (event) => {
const { key } = event;
const active = document.activeElement;
const isInSearchBox = searchBox?.contains(active);
if (key === 'Escape') {
reset();
return;
}
if (!firstResult || !isInSearchBox) {
return;
}
if (key === 'ArrowDown') {
event.preventDefault();
if (active === sInput) {
setActiveResult(firstResult.querySelector('.entry-link'));
} else if (active?.parentElement !== lastResult) {
setActiveResult(active?.parentElement?.nextElementSibling?.querySelector('.entry-link'));
}
} else if (key === 'ArrowUp') {
event.preventDefault();
if (active?.parentElement === firstResult) {
setActiveResult(sInput);
} else if (active !== sInput) {
setActiveResult(active?.parentElement?.previousElementSibling?.querySelector('.entry-link'));
}
} else if (key === 'ArrowRight') {
if (active?.matches?.('.entry-link')) {
active.click();
}
}
});
+1 -1
View File
@@ -21,7 +21,7 @@
</header> </header>
<div id="searchbox" class="searchbox"> <div id="searchbox" class="searchbox">
<input id="searchInput" autofocus placeholder="{{ .Params.placeholder | default (printf "%s " .Title) }}" <input id="searchInput" disabled placeholder="{{ .Params.placeholder | default (printf "%s " .Title) }}"
aria-label="search" type="search" autocomplete="off" maxlength="64"> aria-label="search" type="search" autocomplete="off" maxlength="64">
<ul id="searchResults" class="searchResults" aria-label="search results"></ul> <ul id="searchResults" class="searchResults" aria-label="search results"></ul>
</div> </div>