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
+181 -143
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); distance: 100,
if (data) { threshold: 0.4,
// fuse.js options; check fuse.js website for details ignoreLocation: true,
let options = { keys: ['title', 'permalink', 'summary', 'content']
distance: 100, };
threshold: 0.4,
ignoreLocation: true, const buildFuseOptions = () => {
keys: [ if (!params.fuseOpts) {
'title', return defaultFuseOptions;
'permalink', }
'summary',
'content' return {
] isCaseSensitive: params.fuseOpts.iscasesensitive ?? false,
}; includeScore: params.fuseOpts.includescore ?? false,
if (params.fuseOpts) { includeMatches: params.fuseOpts.includematches ?? false,
options = { minMatchCharLength: params.fuseOpts.minmatchcharlength ?? 1,
isCaseSensitive: params.fuseOpts.iscasesensitive ?? false, shouldSort: params.fuseOpts.shouldsort ?? true,
includeScore: params.fuseOpts.includescore ?? false, findAllMatches: params.fuseOpts.findallmatches ?? false,
includeMatches: params.fuseOpts.includematches ?? false, keys: params.fuseOpts.keys ?? defaultFuseOptions.keys,
minMatchCharLength: params.fuseOpts.minmatchcharlength ?? 1, location: params.fuseOpts.location ?? 0,
shouldSort: params.fuseOpts.shouldsort ?? true, threshold: params.fuseOpts.threshold ?? defaultFuseOptions.threshold,
findAllMatches: params.fuseOpts.findallmatches ?? false, distance: params.fuseOpts.distance ?? defaultFuseOptions.distance,
keys: params.fuseOpts.keys ?? ['title', 'permalink', 'summary', 'content'], ignoreLocation: params.fuseOpts.ignorelocation ?? defaultFuseOptions.ignoreLocation
location: params.fuseOpts.location ?? 0,
threshold: params.fuseOpts.threshold ?? 0.4,
distance: params.fuseOpts.distance ?? 100,
ignoreLocation: params.fuseOpts.ignorelocation ?? true
}
}
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") const reset = () => {
} else { currentElement = null;
document.activeElement.parentElement.classList.add("focus") firstResult = null;
lastResult = null;
resList.innerHTML = '';
sInput.value = '';
sInput.focus();
};
const setActiveResult = (element) => {
document.querySelectorAll('.focus').forEach((item) => item.classList.remove('focus'));
if (!element) {
return;
} }
}
function reset() { element.focus();
resultsAvailable = false; element.parentElement?.classList.add('focus');
resList.innerHTML = sInput.value = ''; // clear inputbox and searchResults currentElement = element;
sInput.focus(); // shift focus to input box };
}
// execute search as each character is typed const renderResults = (results) => {
sInput.onkeyup = function (e) { if (!Array.isArray(results) || results.length === 0) {
// run a search query (for "term") every time a letter is typed resList.innerHTML = '';
// in the search box firstResult = lastResult = currentElement = null;
if (fuse) { return;
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 const fragment = document.createDocumentFragment();
} else {
results = fuse.search(this.value.trim()); // the actual query being run using fuse.js for (const result of results) {
const li = document.createElement('li');
const titleText = document.createTextNode(result.item.title);
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('width', '24');
svg.setAttribute('height', '24');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('fill', 'none');
svg.setAttribute('stroke', 'currentColor');
svg.setAttribute('stroke-width', '2');
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);
}
resList.innerHTML = '';
resList.appendChild(fragment);
firstResult = resList.firstElementChild;
lastResult = resList.lastElementChild;
};
const performSearch = () => {
if (!fuse) {
return;
}
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}`);
} }
if (results.length !== 0) {
// build our html if result exists
let resultSet = ''; // our results bucket
for (let item in results) { const data = await response.json();
resultSet += if (data) {
`<li>` + fuse = new Fuse(data, buildFuseOptions());
`${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>` + } catch (error) {
`<a class="entry-link" href="${results[item].item.permalink}" aria-label="${results[item].item.title}"></a>` + console.error(error);
`</li>` }
} };
resList.innerHTML = resultSet; window.addEventListener('load', initSearch);
resultsAvailable = true;
first = resList.firstChild; sInput?.addEventListener('input', debounce(performSearch, 150));
last = resList.lastChild;
} else { sInput?.addEventListener('search', () => {
resultsAvailable = false; if (!sInput.value) {
resList.innerHTML = ''; 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();
} }
} }
} });
sInput.addEventListener('search', function (e) {
// clicked on x
if (!this.value) reset()
})
// kb bindings
document.onkeydown = function (e) {
let key = e.key;
let ae = document.activeElement;
let inbox = document.getElementById("searchbox").contains(ae)
if (ae === sInput) {
let elements = document.getElementsByClassName('focus');
while (elements.length > 0) {
elements[0].classList.remove('focus');
}
} else if (current_elem) ae = current_elem;
if (key === "Escape") {
reset()
} else if (!resultsAvailable || !inbox) {
return
} else if (key === "ArrowDown") {
e.preventDefault();
if (ae == sInput) {
// if the currently focused element is the search input, focus the <a> of first <li>
activeToggle(resList.firstChild.lastChild);
} else if (ae.parentElement != last) {
// if the currently focused element's parent is last, do nothing
// otherwise select the next search result
activeToggle(ae.parentElement.nextSibling.lastChild);
}
} else if (key === "ArrowUp") {
e.preventDefault();
if (ae.parentElement == first) {
// if the currently focused element is first item, go to input box
activeToggle(sInput);
} else if (ae != sInput) {
// if the currently focused element is input box, do nothing
// otherwise select the previous search result
activeToggle(ae.parentElement.previousSibling.lastChild);
}
} else if (key === "ArrowRight") {
ae.click(); // click on active link
}
}
+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>