better search suggestions

This commit is contained in:
mat 2023-12-21 02:11:21 -06:00
parent fbd12e8556
commit e6239134a4
3 changed files with 137 additions and 28 deletions

View File

@ -12,7 +12,7 @@
<body> <body>
<div class="main-container"> <div class="main-container">
<h1>metasearch</h1> <h1>metasearch</h1>
<form action="/search" method="get"> <form action="/search" method="get" class="search-form">
<input type="text" name="q" placeholder="Search" id="search-input" autofocus onfocus="this.select()" autocomplete="off"> <input type="text" name="q" placeholder="Search" id="search-input" autofocus onfocus="this.select()" autocomplete="off">
<input type="submit" value="Search"> <input type="submit" value="Search">
</form> </form>

View File

@ -1,37 +1,129 @@
const searchInputEl = document.getElementById("search-input"); const searchInputEl = document.getElementById("search-input");
// add a datalist after the search input // add an element with search suggestions after the search input
const datalistEl = document.createElement("datalist"); const suggestionsEl = document.createElement("div");
datalistEl.id = "search-input-datalist"; suggestionsEl.id = "search-input-suggestions";
searchInputEl.setAttribute("list", datalistEl.id); suggestionsEl.style.visibility = "hidden";
searchInputEl.insertAdjacentElement("afterend", datalistEl); searchInputEl.insertAdjacentElement("afterend", suggestionsEl);
// update the datalist options on input let lastValue = "";
searchInputEl.addEventListener("input", async (e) => { async function updateSuggestions() {
const value = e.target.value; const value = searchInputEl.value;
if (value.trim() === "") {
renderSuggestions([]);
return;
}
if (value === lastValue) {
suggestionsEl.style.visibility = "visible";
return;
}
lastValue = value;
const res = await fetch(`/autocomplete?q=${encodeURIComponent(value)}`).then( const res = await fetch(`/autocomplete?q=${encodeURIComponent(value)}`).then(
(res) => res.json() (res) => res.json()
); );
const options = res[1]; const options = res[1];
datalistEl.innerHTML = ""; renderSuggestions(options);
options.forEach((option) => { }
const optionEl = document.createElement("option");
optionEl.value = option; function renderSuggestions(options) {
datalistEl.appendChild(optionEl); if (options.length === 0) {
}); suggestionsEl.style.visibility = "hidden";
}); return;
}
suggestionsEl.style.visibility = "visible";
suggestionsEl.innerHTML = "";
options.forEach((option) => {
const optionEl = document.createElement("div");
optionEl.textContent = option;
optionEl.className = "search-input-suggestion";
suggestionsEl.appendChild(optionEl);
optionEl.addEventListener("click", () => {
searchInputEl.value = option;
searchInputEl.focus();
searchInputEl.form.submit();
});
});
}
let focusedSuggestionIndex = -1;
let focusedSuggestionEl = null;
function clearFocusedSuggestion() {
if (focusedSuggestionEl) {
focusedSuggestionEl.classList.remove("focused");
focusedSuggestionEl = null;
focusedSuggestionIndex = -1;
}
}
function focusSelectionIndex(index) {
clearFocusedSuggestion();
focusedSuggestionIndex = index;
focusedSuggestionEl = suggestionsEl.children[focusedSuggestionIndex];
focusedSuggestionEl.classList.add("focused");
searchInputEl.value = focusedSuggestionEl.textContent;
}
// if the user starts typing but they don't have focus on the input, focus it
document.addEventListener("keydown", (e) => { document.addEventListener("keydown", (e) => {
// if it's focused then use different keybinds
if (searchInputEl.matches(":focus")) {
if (e.key === "ArrowDown") {
e.preventDefault();
if (focusedSuggestionIndex === -1) {
focusSelectionIndex(0);
} else if (focusedSuggestionIndex < suggestionsEl.children.length - 1) {
focusSelectionIndex(focusedSuggestionIndex + 1);
} else {
focusSelectionIndex(0);
}
} else if (e.key === "ArrowUp") {
e.preventDefault();
if (focusedSuggestionIndex === -1) {
focusSelectionIndex(suggestionsEl.children.length - 1);
} else if (focusedSuggestionIndex > 0) {
focusSelectionIndex(focusedSuggestionIndex - 1);
} else {
focusSelectionIndex(suggestionsEl.children.length - 1);
}
}
return;
}
// if the user starts typing but they don't have focus on the input, focus it
// no modifier keys
if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) {
return;
}
// must be a letter or number // must be a letter or number
if ( if (e.key.match(/^[a-z0-9]$/i)) {
e.key.match(/^[a-z0-9]$/i) &&
!searchInputEl.matches(":focus") &&
!e.ctrlKey &&
!e.metaKey
) {
searchInputEl.focus(); searchInputEl.focus();
} }
// right arrow key focuses it at the end
else if (e.key === "ArrowRight") {
searchInputEl.focus();
searchInputEl.setSelectionRange(
searchInputEl.value.length,
searchInputEl.value.length
);
}
// left arrow key focuses it at the beginning
else if (e.key === "ArrowLeft") {
searchInputEl.focus();
searchInputEl.setSelectionRange(0, 0);
}
}); });
// update the input suggestions on input
searchInputEl.addEventListener("input", () => {
clearFocusedSuggestion();
updateSuggestions();
});
// and on focus
searchInputEl.addEventListener("focus", updateSuggestions);

View File

@ -21,7 +21,7 @@ main {
background-color: #0d1017; background-color: #0d1017;
min-height: 100%; min-height: 100%;
} }
@media screen and (max-width: 80rem) { @media screen and (max-width: 74rem) {
/* small screens */ /* small screens */
.results-container { .results-container {
margin: 0 auto; margin: 0 auto;
@ -36,11 +36,11 @@ input {
font-size: inherit; font-size: inherit;
padding: 0.25rem; padding: 0.25rem;
} }
input:focus { input:focus-visible {
outline: none; outline: none;
border-color: #e6b450; border-color: #e6b450;
} }
:focus { :focus-visible {
outline: 1px solid #e6b450; outline: 1px solid #e6b450;
} }
input[type="submit"] { input[type="submit"] {
@ -62,8 +62,9 @@ a:visited {
height: 100%; height: 100%;
justify-content: center; justify-content: center;
margin: 0 auto; margin: 0 auto;
width: fit-content; padding: 0 0.5em;
text-align: center; text-align: center;
max-width: 30em;
} }
h1 { h1 {
margin-top: 0; margin-top: 0;
@ -79,6 +80,22 @@ h1 {
max-width: 30em; max-width: 30em;
flex: 1; flex: 1;
} }
#search-input-suggestions {
position: absolute;
text-align: left;
margin-top: calc(1.9em + 1px);
background: #0f131a;
padding: 0.1em 0 0.3em 0;
border: 1px solid #234;
border-top: transparent;
}
.search-input-suggestion {
cursor: pointer;
padding: 0 0.3em;
}
.search-input-suggestion.focused {
background: #234;
}
/* search result */ /* search result */
.search-result { .search-result {
@ -181,7 +198,7 @@ h1 {
max-width: 30rem; max-width: 30rem;
margin-left: 42rem; margin-left: 42rem;
} }
@media screen and (max-width: 80rem) { @media screen and (max-width: 74rem) {
/* small screens */ /* small screens */
.infobox { .infobox {
position: static; position: static;