Files
blowfish/assets/js/search.js
T
Yannick Tocquet 2e7c5bb91f 🐛 Guard event.key before toLowerCase in search keydown handler
The global keydown handler calls event.key.toLowerCase() without checking
that event.key is defined. Some keydown events carry no key property
(browser autofill, IME composition, some mobile virtual keyboards), which
throws TypeError: Cannot read properties of undefined (reading
'toLowerCase') and breaks the handler for that keystroke.

Add a truthiness guard before calling toLowerCase(), matching the style
already used for the other event.key comparisons in the same handler.
No behavior change for real Cmd/Ctrl+K presses.
2026-09-03 16:34:26 +01:00

248 lines
7.7 KiB
JavaScript

var fuse;
var showButton = document.getElementById("search-button");
var showButtonMobile = document.getElementById("search-button-mobile");
var hideButton = document.getElementById("close-search-button");
var wrapper = document.getElementById("search-wrapper");
var modal = document.getElementById("search-modal");
var input = document.getElementById("search-query");
var output = document.getElementById("search-results");
var first = output.firstChild;
var last = output.lastChild;
var searchVisible = false;
var indexed = false;
var hasResults = false;
var previouslyFocused = null;
// Listen for events
showButton ? showButton.addEventListener("click", displaySearch) : null;
showButtonMobile ? showButtonMobile.addEventListener("click", displaySearch) : null;
hideButton.addEventListener("click", hideSearch);
wrapper.addEventListener("click", hideSearch);
modal.addEventListener("click", function (event) {
event.stopPropagation();
event.stopImmediatePropagation();
return false;
});
const shortcutHint = document.getElementById("search-shortcut-hint");
if (shortcutHint && !/Mac|iPhone|iPad/.test(navigator.platform)) {
shortcutHint.textContent = "Ctrl K";
}
document.addEventListener("keydown", function (event) {
// Forward slash to open search wrapper
if (event.key == "/") {
const active = document.activeElement;
const tag = active.tagName;
const isInputField = tag === "INPUT" || tag === "TEXTAREA" || active.isContentEditable;
if (!searchVisible && !isInputField) {
event.preventDefault();
displaySearch();
}
}
// Cmd+K (macOS) / Ctrl+K to toggle search wrapper
if (event.key && event.key.toLowerCase() == "k" && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
if (searchVisible) {
hideSearch();
} else {
displaySearch();
}
}
// Esc to close search wrapper
if (event.key == "Escape") {
hideSearch();
}
// Trap Tab / Shift+Tab focus inside the modal while it is open
if (event.key == "Tab" && searchVisible) {
var focusable = modal.querySelectorAll('a[href], button, input, [tabindex="0"]');
if (focusable.length > 0) {
var firstFocusable = focusable[0];
var lastFocusable = focusable[focusable.length - 1];
if (!modal.contains(document.activeElement)) {
event.preventDefault();
firstFocusable.focus();
} else if (event.shiftKey && document.activeElement == firstFocusable) {
event.preventDefault();
lastFocusable.focus();
} else if (!event.shiftKey && document.activeElement == lastFocusable) {
event.preventDefault();
firstFocusable.focus();
}
}
}
// Down arrow to move down results list
if (event.key == "ArrowDown") {
if (searchVisible && hasResults) {
event.preventDefault();
if (document.activeElement == input) {
first.focus();
} else if (document.activeElement == last) {
last.focus();
} else {
document.activeElement.parentElement.nextSibling.firstElementChild.focus();
}
}
}
// Up arrow to move up results list
if (event.key == "ArrowUp") {
if (searchVisible && hasResults) {
event.preventDefault();
if (document.activeElement == input) {
input.focus();
} else if (document.activeElement == first) {
input.focus();
} else {
document.activeElement.parentElement.previousSibling.firstElementChild.focus();
}
}
}
// Enter to get to results
if (event.key == "Enter") {
if (searchVisible && hasResults) {
event.preventDefault();
if (document.activeElement == input) {
first.focus();
} else {
document.activeElement.click();
}
}
}
});
// Update search on each keypress
input.onkeyup = function (event) {
executeQuery(this.value);
};
function displaySearch() {
if (!indexed) {
buildIndex();
}
if (!searchVisible) {
previouslyFocused = document.activeElement;
document.body.style.overflow = "hidden";
wrapper.style.visibility = "visible";
input.focus();
searchVisible = true;
}
}
function hideSearch() {
if (searchVisible) {
document.body.style.overflow = "visible";
wrapper.style.visibility = "hidden";
input.value = "";
output.innerHTML = "";
if (previouslyFocused && typeof previouslyFocused.focus === "function" && document.contains(previouslyFocused)) {
previouslyFocused.focus();
} else if (document.activeElement) {
document.activeElement.blur();
}
previouslyFocused = null;
searchVisible = false;
}
}
function fetchJSON(path, callback) {
var httpRequest = new XMLHttpRequest();
httpRequest.onreadystatechange = function () {
if (httpRequest.readyState === 4) {
if (httpRequest.status === 200) {
var data = JSON.parse(httpRequest.responseText);
if (callback) callback(data);
}
}
};
httpRequest.open("GET", path);
httpRequest.send();
}
function buildIndex() {
var baseURL = wrapper.getAttribute("data-url");
baseURL = baseURL.replace(/\/?$/, "/");
fetchJSON(baseURL + "index.json", function (data) {
var options = {
shouldSort: true,
ignoreLocation: true,
threshold: 0.0,
includeMatches: true,
keys: [
{ name: "title", weight: 0.8 },
{ name: "section", weight: 0.2 },
{ name: "summary", weight: 0.6 },
{ name: "content", weight: 0.4 },
],
};
/*var finalIndex = [];
for (var i in data) {
if(data[i].type != "users" && data[i].type != "tags" && data[i].type != "categories"){
finalIndex.push(data[i]);
}
}*/
fuse = new Fuse(data, options);
indexed = true;
});
}
function executeQuery(term) {
if (!indexed) {
buildIndex();
}
if (!fuse) {
return;
}
let results = fuse.search(term);
let resultsHTML = "";
if (results.length > 0) {
results.forEach(function (value, key) {
var html = value.item.summary;
var div = document.createElement("div");
div.innerHTML = html;
value.item.summary = div.textContent || div.innerText || "";
var title = value.item.externalUrl
? value.item.title +
'<span class="text-xs ml-2 align-center cursor-default text-neutral-400 dark:text-neutral-500">' +
value.item.externalUrl +
"</span>"
: value.item.title;
var linkconfig = value.item.externalUrl
? 'target="_blank" rel="noopener" href="' + value.item.externalUrl + '"'
: 'href="' + value.item.permalink + '"';
resultsHTML =
resultsHTML +
`<li class="mb-2">
<a class="flex items-center px-3 py-2 rounded-md appearance-none bg-neutral-100 dark:bg-neutral-700 focus:bg-primary-100 hover:bg-primary-100 dark:hover:bg-primary-900 dark:focus:bg-primary-900 focus:outline-dotted focus:outline-transparent focus:outline-2"
${linkconfig} tabindex="0">
<div class="grow">
<div class="-mb-1 text-lg font-bold">
${title}
</div>
<div class="text-sm text-neutral-500 dark:text-neutral-400">${value.item.section}<span class="px-2 text-primary-500">&middot;</span>${value.item.date ? value.item.date : ""}</span></div>
<div class="text-sm italic">${value.item.summary}</div>
</div>
<div class="ml-2 ltr:block rtl:hidden text-neutral-500">&rarr;</div>
<div class="mr-2 ltr:hidden rtl:block text-neutral-500">&larr;</div>
</a>
</li>`;
});
hasResults = true;
} else {
resultsHTML = "";
hasResults = false;
}
output.innerHTML = resultsHTML;
if (results.length > 0 && output.firstChild) {
first = output.firstChild.firstElementChild;
last = output.lastChild.firstElementChild;
}
}