Add search suggestions UI

This commit is contained in:
Ted Pier 2026-07-07 18:45:23 -07:00
parent 6373ac7814
commit 9b62062eba
4 changed files with 173 additions and 6 deletions

View File

@ -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;
}
}
#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;
}

116
assets/js/suggestions.js Normal file
View File

@ -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");
}
}
});

View File

@ -7,12 +7,13 @@
<% else %>
<form class="pure-form" action="/search" method="get">
<% end %>
<fieldset>
<fieldset id="search-container">
<input type="search" id="searchbox" autocorrect="off"
autocapitalize="none" spellcheck="false" <% if autofocus %>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)) } %>">
<div id="suggestions"></div>
</fieldset>
<button type="submit" id="searchbutton" aria-label="<%= I18n.translate(locale, "search") %>">
<i class="icon ion-ios-search"></i>

View File

@ -176,6 +176,7 @@
</div>
<script src="/js/handlers.js?v=<%= ASSET_COMMIT %>"></script>
<script src="/js/themes.js?v=<%= ASSET_COMMIT %>"></script>
<script src="/js/suggestions.js?v=<%= ASSET_COMMIT %>"></script>
<% if env.get? "user" %>
<script src="/js/sse.js?v=<%= ASSET_COMMIT %>"></script>
<script id="notification_data" type="application/json">