From 9b62062eba59eea3663dd8c7206d025c89a0560d Mon Sep 17 00:00:00 2001 From: Ted Pier Date: Tue, 7 Jul 2026 18:45:23 -0700 Subject: [PATCH] Add search suggestions UI --- assets/css/default.css | 59 ++++++++- assets/js/suggestions.js | 116 ++++++++++++++++++ src/invidious/views/components/search_box.ecr | 3 +- src/invidious/views/template.ecr | 1 + 4 files changed, 173 insertions(+), 6 deletions(-) create mode 100644 assets/js/suggestions.js diff --git a/assets/css/default.css b/assets/css/default.css index 726a4c377..2313ca9f3 100644 --- a/assets/css/default.css +++ b/assets/css/default.css @@ -540,7 +540,8 @@ span > select { .light-theme .pure-button-primary:hover, .light-theme .pure-button-primary:focus, .light-theme .pure-button-secondary:hover, -.light-theme .pure-button-secondary:focus { +.light-theme .pure-button-secondary:focus, +.light-theme .active { color: #fff !important; border-color: rgba(0, 182, 240, 0.75) !important; background-color: rgba(0, 182, 240, 0.75) !important; @@ -571,6 +572,10 @@ span > select { border: 1px solid black; } +.light-theme #suggestions { + background: rgb(255, 255, 255); +} + @media (prefers-color-scheme: light) { .no-theme a:hover, .no-theme a:active, @@ -583,7 +588,8 @@ span > select { .no-theme .pure-button-primary:hover, .no-theme .pure-button-primary:focus, .no-theme .pure-button-secondary:hover, - .no-theme .pure-button-secondary:focus { + .no-theme .pure-button-secondary:focus, + .no-theme .active { color: #fff !important; border-color: rgba(0, 182, 240, 0.75) !important; background-color: rgba(0, 182, 240, 0.75) !important; @@ -621,6 +627,10 @@ span > select { .no-theme .error-card { border: 1px solid black; } + + .no-theme #suggestions { + background: rgb(255, 255, 255); + } } @@ -639,7 +649,8 @@ span > select { .dark-theme .pure-button-primary:hover, .dark-theme .pure-button-primary:focus, .dark-theme .pure-button-secondary:hover, -.dark-theme .pure-button-secondary:focus { +.dark-theme .pure-button-secondary:focus, +.dark-theme .active { color: #fff !important; border-color: rgb(0, 182, 240) !important; background-color: rgba(0, 182, 240, 1) !important; @@ -687,6 +698,10 @@ body.dark-theme { border: 1px solid #5e5e5e; } +.dark-theme #suggestions { + background: rgb(35, 35, 35); +} + @media (prefers-color-scheme: dark) { .no-theme a:hover, .no-theme a:active, @@ -697,7 +712,8 @@ body.dark-theme { .no-theme .pure-button-primary:hover, .no-theme .pure-button-primary:focus, .no-theme .pure-button-secondary:hover, - .no-theme .pure-button-secondary:focus { + .no-theme .pure-button-secondary:focus, + .no-theme .active { color: #fff !important; border-color: rgb(0, 182, 240) !important; background-color: rgba(0, 182, 240, 1) !important; @@ -752,6 +768,10 @@ body.dark-theme { .no-theme .error-card { border: 1px solid #5e5e5e; } + + .no-theme #suggestions { + background: rgb(35, 35, 35); + } } @@ -909,4 +929,33 @@ h1, h2, h3, h4, h5, p, padding-left: 10px; display: inline-block; vertical-align: top; -} \ No newline at end of file +} + +#search-container { + position: relative; + display: block !important; +} + +#suggestions { + position: absolute; + width: 100%; + top: calc(100% + .25em); + border: 1px solid #a0a0a0; + z-index: 200; + padding: .25em 0; + display: none; +} + +#search-container:has(:active) #suggestions:has(*), +#search-container:has(:focus) #suggestions:has(*) { + display: block; +} + +#suggestions>* { + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + padding: .25em .5em; + text-align: left; +} diff --git a/assets/js/suggestions.js b/assets/js/suggestions.js new file mode 100644 index 000000000..2091ec692 --- /dev/null +++ b/assets/js/suggestions.js @@ -0,0 +1,116 @@ +"use strict"; +const searchbox = document.getElementById("searchbox"); +const searchContainer = document.getElementById("search-container"); +const suggestions = document.getElementById("suggestions"); +let selectedSuggestion = -1; +let suggestionController; +let queuedSuggestions = 0; + +function resetSuggestions() { + suggestions.textContent = ""; + selectedSuggestion = -1; +} + +searchbox.addEventListener("input", function () { + // If there is already a request in progress, + // abort. The user has made another input, + // invalidating the old query. + if (suggestionController != undefined) { + suggestionController.abort(); + } + + // Only continue for queries of at least + // one character. + if (searchbox.value.length < 2) { + resetSuggestions(); + return; + } + + // Only make a request for suggestions after + // there is a delay of at least 150 milliseconds + // between inputs. + queuedSuggestions++; + setTimeout(async function () { + queuedSuggestions--; + if (queuedSuggestions != 0) { + return; + } + + // Create the controller which will be + // used to abort this request if another + // is made before it finishes. + suggestionController = new AbortController(); + const signal = suggestionController.signal; + + // Make the request for suggestions. + try { + const resp = await fetch( + `/api/v1/search/suggestions?q=${encodeURIComponent(searchbox.value)}`, + { signal }, + ); + const body = await resp.json(); + + // As a sanity check, ensure the + // suggestions match the query. + if (body.query != searchbox.value) { + return; + } + + // Put the results into DOM. + resetSuggestions(); + const results = body.suggestions; + for (let i = 0; i < results.length; i++) { + const s = document.createElement("a"); + s.href = `/search?q=${encodeURIComponent(results[i])}`; + s.innerText = results[i]; + suggestions.appendChild(s); + } + } catch (err) { + // Discard AbortError as it is expected. + if (err.name !== "AbortError") { + console.error(err); + } + } + }, 150); +}); + +searchbox.addEventListener("keydown", function (e) { + const currentSuggestions = suggestions.children; + + // Navigate suggestions by key. + switch (e.key) { + case "ArrowDown": + selectedSuggestion++; + e.preventDefault(); + break; + case "ArrowUp": + selectedSuggestion--; + e.preventDefault(); + break; + case "Enter": + if (selectedSuggestion != -1) { + currentSuggestions[selectedSuggestion].click(); + e.preventDefault(); + return; + } + break; + } + + // Loop around if you go beyond the end + // or before -1 (the resting position). + if (selectedSuggestion >= currentSuggestions.length) { + selectedSuggestion = -1; + } else if (selectedSuggestion < -1) { + selectedSuggestion = currentSuggestions.length - 1; + } + + // Remove .active class from all elements + // except the active one. + for (let i = 0; i < currentSuggestions.length; i++) { + if (i == selectedSuggestion) { + currentSuggestions[i].classList.add("active"); + } else if (currentSuggestions[i].classList.contains("active")) { + currentSuggestions[i].classList.remove("active"); + } + } +}); diff --git a/src/invidious/views/components/search_box.ecr b/src/invidious/views/components/search_box.ecr index 1a14475aa..bdda0ea8c 100644 --- a/src/invidious/views/components/search_box.ecr +++ b/src/invidious/views/components/search_box.ecr @@ -7,12 +7,13 @@ <% else %>
<% end %> -
+
autofocus<% end %> name="q" placeholder="<%= I18n.translate(locale, "search") %>" title="<%= I18n.translate(locale, "search") %>" value="<%= env.get?("search").try {|x| HTML.escape(x.as(String)) } %>"> +