From e6239134a461e6b271c68851fcf6cf4140973313 Mon Sep 17 00:00:00 2001 From: mat Date: Thu, 21 Dec 2023 02:11:21 -0600 Subject: [PATCH] better search suggestions --- src/web/assets/index.html | 2 +- src/web/assets/script.js | 136 ++++++++++++++++++++++++++++++++------ src/web/assets/style.css | 27 ++++++-- 3 files changed, 137 insertions(+), 28 deletions(-) diff --git a/src/web/assets/index.html b/src/web/assets/index.html index 335edca..2557b10 100644 --- a/src/web/assets/index.html +++ b/src/web/assets/index.html @@ -12,7 +12,7 @@

metasearch

-
+
diff --git a/src/web/assets/script.js b/src/web/assets/script.js index ec87e9f..587c81c 100644 --- a/src/web/assets/script.js +++ b/src/web/assets/script.js @@ -1,37 +1,129 @@ const searchInputEl = document.getElementById("search-input"); -// add a datalist after the search input -const datalistEl = document.createElement("datalist"); -datalistEl.id = "search-input-datalist"; -searchInputEl.setAttribute("list", datalistEl.id); -searchInputEl.insertAdjacentElement("afterend", datalistEl); +// add an element with search suggestions after the search input +const suggestionsEl = document.createElement("div"); +suggestionsEl.id = "search-input-suggestions"; +suggestionsEl.style.visibility = "hidden"; +searchInputEl.insertAdjacentElement("afterend", suggestionsEl); -// update the datalist options on input -searchInputEl.addEventListener("input", async (e) => { - const value = e.target.value; +let lastValue = ""; +async function updateSuggestions() { + 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( (res) => res.json() ); const options = res[1]; - datalistEl.innerHTML = ""; - options.forEach((option) => { - const optionEl = document.createElement("option"); - optionEl.value = option; - datalistEl.appendChild(optionEl); - }); -}); + renderSuggestions(options); +} + +function renderSuggestions(options) { + 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) => { + // 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 - if ( - e.key.match(/^[a-z0-9]$/i) && - !searchInputEl.matches(":focus") && - !e.ctrlKey && - !e.metaKey - ) { + if (e.key.match(/^[a-z0-9]$/i)) { 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); diff --git a/src/web/assets/style.css b/src/web/assets/style.css index 8d2fbde..bc5f754 100644 --- a/src/web/assets/style.css +++ b/src/web/assets/style.css @@ -21,7 +21,7 @@ main { background-color: #0d1017; min-height: 100%; } -@media screen and (max-width: 80rem) { +@media screen and (max-width: 74rem) { /* small screens */ .results-container { margin: 0 auto; @@ -36,11 +36,11 @@ input { font-size: inherit; padding: 0.25rem; } -input:focus { +input:focus-visible { outline: none; border-color: #e6b450; } -:focus { +:focus-visible { outline: 1px solid #e6b450; } input[type="submit"] { @@ -62,8 +62,9 @@ a:visited { height: 100%; justify-content: center; margin: 0 auto; - width: fit-content; + padding: 0 0.5em; text-align: center; + max-width: 30em; } h1 { margin-top: 0; @@ -79,6 +80,22 @@ h1 { max-width: 30em; 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 { @@ -181,7 +198,7 @@ h1 { max-width: 30rem; margin-left: 42rem; } -@media screen and (max-width: 80rem) { +@media screen and (max-width: 74rem) { /* small screens */ .infobox { position: static;