Skip to content

Commit 8af442a

Browse files
committed
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
1 parent e457685 commit 8af442a

2 files changed

Lines changed: 180 additions & 142 deletions

File tree

assets/js/fastsearch.js

Lines changed: 179 additions & 141 deletions
Original file line numberDiff line numberDiff line change
@@ -1,156 +1,194 @@
11
import * as params from '@params';
22

3-
let fuse; // holds our search engine
4-
let resList = document.getElementById('searchResults');
5-
let sInput = document.getElementById('searchInput');
6-
let first, last, current_elem = null
7-
let resultsAvailable = false;
8-
9-
// load our search index
10-
window.onload = function () {
11-
let xhr = new XMLHttpRequest();
12-
xhr.onreadystatechange = function () {
13-
if (xhr.readyState === 4) {
14-
if (xhr.status === 200) {
15-
let data = JSON.parse(xhr.responseText);
16-
if (data) {
17-
// fuse.js options; check fuse.js website for details
18-
let options = {
19-
distance: 100,
20-
threshold: 0.4,
21-
ignoreLocation: true,
22-
keys: [
23-
'title',
24-
'permalink',
25-
'summary',
26-
'content'
27-
]
28-
};
29-
if (params.fuseOpts) {
30-
options = {
31-
isCaseSensitive: params.fuseOpts.iscasesensitive ?? false,
32-
includeScore: params.fuseOpts.includescore ?? false,
33-
includeMatches: params.fuseOpts.includematches ?? false,
34-
minMatchCharLength: params.fuseOpts.minmatchcharlength ?? 1,
35-
shouldSort: params.fuseOpts.shouldsort ?? true,
36-
findAllMatches: params.fuseOpts.findallmatches ?? false,
37-
keys: params.fuseOpts.keys ?? ['title', 'permalink', 'summary', 'content'],
38-
location: params.fuseOpts.location ?? 0,
39-
threshold: params.fuseOpts.threshold ?? 0.4,
40-
distance: params.fuseOpts.distance ?? 100,
41-
ignoreLocation: params.fuseOpts.ignorelocation ?? true
42-
}
43-
}
44-
fuse = new Fuse(data, options); // build the index from the json file
45-
}
46-
} else {
47-
console.log(xhr.responseText);
48-
}
49-
}
3+
const resList = document.getElementById('searchResults');
4+
const sInput = document.getElementById('searchInput');
5+
const searchBox = document.getElementById('searchbox');
6+
7+
let fuse;
8+
let currentElement = null;
9+
let firstResult = null;
10+
let lastResult = null;
11+
12+
const defaultFuseOptions = {
13+
distance: 100,
14+
threshold: 0.4,
15+
ignoreLocation: true,
16+
keys: ['title', 'permalink', 'summary', 'content']
17+
};
18+
19+
const buildFuseOptions = () => {
20+
if (!params.fuseOpts) {
21+
return defaultFuseOptions;
22+
}
23+
24+
return {
25+
isCaseSensitive: params.fuseOpts.iscasesensitive ?? false,
26+
includeScore: params.fuseOpts.includescore ?? false,
27+
includeMatches: params.fuseOpts.includematches ?? false,
28+
minMatchCharLength: params.fuseOpts.minmatchcharlength ?? 1,
29+
shouldSort: params.fuseOpts.shouldsort ?? true,
30+
findAllMatches: params.fuseOpts.findallmatches ?? false,
31+
keys: params.fuseOpts.keys ?? defaultFuseOptions.keys,
32+
location: params.fuseOpts.location ?? 0,
33+
threshold: params.fuseOpts.threshold ?? defaultFuseOptions.threshold,
34+
distance: params.fuseOpts.distance ?? defaultFuseOptions.distance,
35+
ignoreLocation: params.fuseOpts.ignorelocation ?? defaultFuseOptions.ignoreLocation
36+
};
37+
};
38+
39+
const debounce = (fn, delay) => {
40+
let timeout;
41+
return (...args) => {
42+
clearTimeout(timeout);
43+
timeout = window.setTimeout(() => fn(...args), delay);
5044
};
51-
xhr.open('GET', "../index.json");
52-
xhr.send();
53-
}
54-
55-
function activeToggle(ae) {
56-
document.querySelectorAll('.focus').forEach(function (element) {
57-
// rm focus class
58-
element.classList.remove("focus")
59-
});
60-
if (ae) {
61-
ae.focus()
62-
document.activeElement = current_elem = ae;
63-
ae.parentElement.classList.add("focus")
64-
} else {
65-
document.activeElement.parentElement.classList.add("focus")
45+
};
46+
47+
const reset = () => {
48+
currentElement = null;
49+
firstResult = null;
50+
lastResult = null;
51+
resList.innerHTML = '';
52+
sInput.value = '';
53+
sInput.focus();
54+
};
55+
56+
const setActiveResult = (element) => {
57+
document.querySelectorAll('.focus').forEach((item) => item.classList.remove('focus'));
58+
59+
if (!element) {
60+
return;
61+
}
62+
63+
element.focus();
64+
element.parentElement?.classList.add('focus');
65+
currentElement = element;
66+
};
67+
68+
const renderResults = (results) => {
69+
if (!Array.isArray(results) || results.length === 0) {
70+
resList.innerHTML = '';
71+
firstResult = lastResult = currentElement = null;
72+
return;
73+
}
74+
75+
const fragment = document.createDocumentFragment();
76+
77+
for (const result of results) {
78+
const li = document.createElement('li');
79+
const titleText = document.createTextNode(result.item.title);
80+
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
81+
svg.setAttribute('width', '24');
82+
svg.setAttribute('height', '24');
83+
svg.setAttribute('viewBox', '0 0 24 24');
84+
svg.setAttribute('fill', 'none');
85+
svg.setAttribute('stroke', 'currentColor');
86+
svg.setAttribute('stroke-width', '2');
87+
svg.setAttribute('stroke-linecap', 'round');
88+
svg.setAttribute('stroke-linejoin', 'round');
89+
svg.classList.add('feather', 'feather-chevrons-right');
90+
91+
svg.innerHTML = '<polyline points="13 17 18 12 13 7"></polyline><polyline points="6 17 11 12 6 7"></polyline>';
92+
93+
const link = document.createElement('a');
94+
link.className = 'entry-link';
95+
link.href = result.item.permalink;
96+
link.setAttribute('aria-label', result.item.title);
97+
98+
li.appendChild(titleText);
99+
li.appendChild(svg);
100+
li.appendChild(link);
101+
fragment.appendChild(li);
66102
}
67-
}
68-
69-
function reset() {
70-
resultsAvailable = false;
71-
resList.innerHTML = sInput.value = ''; // clear inputbox and searchResults
72-
sInput.focus(); // shift focus to input box
73-
}
74-
75-
// execute search as each character is typed
76-
sInput.onkeyup = function (e) {
77-
// run a search query (for "term") every time a letter is typed
78-
// in the search box
79-
if (fuse) {
80-
let results;
81-
if (params.fuseOpts) {
82-
results = fuse.search(this.value.trim(), { limit: params.fuseOpts.limit }); // the actual query being run using fuse.js along with options
83-
} else {
84-
results = fuse.search(this.value.trim()); // the actual query being run using fuse.js
103+
104+
resList.innerHTML = '';
105+
resList.appendChild(fragment);
106+
firstResult = resList.firstElementChild;
107+
lastResult = resList.lastElementChild;
108+
};
109+
110+
const performSearch = () => {
111+
if (!fuse) {
112+
return;
113+
}
114+
115+
const query = sInput.value.trim();
116+
if (!query) {
117+
renderResults([]);
118+
return;
119+
}
120+
121+
const searchOptions = params.fuseOpts?.limit ? { limit: params.fuseOpts.limit } : undefined;
122+
const results = searchOptions ? fuse.search(query, searchOptions) : fuse.search(query);
123+
renderResults(results);
124+
};
125+
126+
const initSearch = async () => {
127+
if (!sInput || !resList) {
128+
return;
129+
}
130+
131+
sInput.disabled = false;
132+
sInput.focus();
133+
134+
try {
135+
const response = await fetch('../index.json');
136+
if (!response.ok) {
137+
throw new Error(`Search index load failed: ${response.status}`);
85138
}
86-
if (results.length !== 0) {
87-
// build our html if result exists
88-
let resultSet = ''; // our results bucket
89-
90-
for (let item in results) {
91-
resultSet +=
92-
`<li>` +
93-
`${results[item].item.title}` +
94-
`<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>` +
95-
`<a class="entry-link" href="${results[item].item.permalink}" aria-label="${results[item].item.title}"></a>` +
96-
`</li>`
97-
}
98-
99-
resList.innerHTML = resultSet;
100-
resultsAvailable = true;
101-
first = resList.firstChild;
102-
last = resList.lastChild;
103-
} else {
104-
resultsAvailable = false;
105-
resList.innerHTML = '';
139+
140+
const data = await response.json();
141+
if (data) {
142+
fuse = new Fuse(data, buildFuseOptions());
106143
}
144+
} catch (error) {
145+
console.error(error);
107146
}
108-
}
147+
};
148+
149+
window.addEventListener('load', initSearch);
109150

110-
sInput.addEventListener('search', function (e) {
111-
// clicked on x
112-
if (!this.value) reset()
113-
})
151+
sInput?.addEventListener('input', debounce(performSearch, 150));
114152

115-
// kb bindings
116-
document.onkeydown = function (e) {
117-
let key = e.key;
118-
let ae = document.activeElement;
153+
sInput?.addEventListener('search', () => {
154+
if (!sInput.value) {
155+
reset();
156+
}
157+
});
119158

120-
let inbox = document.getElementById("searchbox").contains(ae)
159+
document.addEventListener('keydown', (event) => {
160+
const { key } = event;
161+
const active = document.activeElement;
162+
const isInSearchBox = searchBox?.contains(active);
163+
164+
if (key === 'Escape') {
165+
reset();
166+
return;
167+
}
121168

122-
if (ae === sInput) {
123-
let elements = document.getElementsByClassName('focus');
124-
while (elements.length > 0) {
125-
elements[0].classList.remove('focus');
169+
if (!firstResult || !isInSearchBox) {
170+
return;
171+
}
172+
173+
if (key === 'ArrowDown') {
174+
event.preventDefault();
175+
176+
if (active === sInput) {
177+
setActiveResult(firstResult.querySelector('.entry-link'));
178+
} else if (active?.parentElement !== lastResult) {
179+
setActiveResult(active?.parentElement?.nextElementSibling?.querySelector('.entry-link'));
126180
}
127-
} else if (current_elem) ae = current_elem;
128-
129-
if (key === "Escape") {
130-
reset()
131-
} else if (!resultsAvailable || !inbox) {
132-
return
133-
} else if (key === "ArrowDown") {
134-
e.preventDefault();
135-
if (ae == sInput) {
136-
// if the currently focused element is the search input, focus the <a> of first <li>
137-
activeToggle(resList.firstChild.lastChild);
138-
} else if (ae.parentElement != last) {
139-
// if the currently focused element's parent is last, do nothing
140-
// otherwise select the next search result
141-
activeToggle(ae.parentElement.nextSibling.lastChild);
181+
} else if (key === 'ArrowUp') {
182+
event.preventDefault();
183+
184+
if (active?.parentElement === firstResult) {
185+
setActiveResult(sInput);
186+
} else if (active !== sInput) {
187+
setActiveResult(active?.parentElement?.previousElementSibling?.querySelector('.entry-link'));
142188
}
143-
} else if (key === "ArrowUp") {
144-
e.preventDefault();
145-
if (ae.parentElement == first) {
146-
// if the currently focused element is first item, go to input box
147-
activeToggle(sInput);
148-
} else if (ae != sInput) {
149-
// if the currently focused element is input box, do nothing
150-
// otherwise select the previous search result
151-
activeToggle(ae.parentElement.previousSibling.lastChild);
189+
} else if (key === 'ArrowRight') {
190+
if (active?.matches?.('.entry-link')) {
191+
active.click();
152192
}
153-
} else if (key === "ArrowRight") {
154-
ae.click(); // click on active link
155193
}
156-
}
194+
});

layouts/_default/search.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ <h1>{{- (printf "%s&nbsp;" .Title ) | htmlUnescape -}}
2121
</header>
2222

2323
<div id="searchbox" class="searchbox">
24-
<input id="searchInput" autofocus placeholder="{{ .Params.placeholder | default (printf "%s " .Title) }}"
24+
<input id="searchInput" disabled placeholder="{{ .Params.placeholder | default (printf "%s " .Title) }}"
2525
aria-label="search" type="search" autocomplete="off" maxlength="64">
2626
<ul id="searchResults" class="searchResults" aria-label="search results"></ul>
2727
</div>

0 commit comments

Comments
 (0)