feat: add autocomplete dropdown UI with keyboard nav
- Inline JS in base.html: debounced fetch from /autocompleter on keyup - Keyboard nav: arrows to navigate, Enter to select, Esc to close - Highlight matching prefix in suggestions - Click to select and submit - Dropdown positioned absolutely below search input - Dark mode compatible via existing CSS variables
This commit is contained in:
parent
7a2ca8672e
commit
e90f6c0876
3 changed files with 176 additions and 1 deletions
|
|
@ -421,6 +421,63 @@ footer a:hover {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Autocomplete dropdown */
|
||||||
|
#search {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
#autocomplete-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: var(--color-base-background);
|
||||||
|
border: 1px solid var(--color-search-border);
|
||||||
|
border-top: none;
|
||||||
|
border-radius: 0 0 var(--radius) var(--radius);
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
z-index: 100;
|
||||||
|
max-height: 320px;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#autocomplete-dropdown.open {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.autocomplete-suggestion {
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--color-base-font);
|
||||||
|
border-bottom: 1px solid var(--color-result-border);
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.autocomplete-suggestion:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.autocomplete-suggestion:hover,
|
||||||
|
.autocomplete-suggestion.active {
|
||||||
|
background: var(--color-header-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.autocomplete-suggestion mark {
|
||||||
|
background: none;
|
||||||
|
color: var(--color-link);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.autocomplete-footer {
|
||||||
|
padding: 0.4rem 1rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-suggestion);
|
||||||
|
border-top: 1px solid var(--color-result-border);
|
||||||
|
background: var(--color-header-background);
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive */
|
/* Responsive */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
#results {
|
#results {
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,123 @@
|
||||||
<footer>
|
<footer>
|
||||||
<p>Powered by <a href="https://git.ashisgreat.xyz/penal-colony/kafka">kafka</a> — a privacy-respecting, open metasearch engine</p>
|
<p>Powered by <a href="https://git.ashisgreat.xyz/penal-colony/kafka">kafka</a> — a privacy-respecting, open metasearch engine</p>
|
||||||
</footer>
|
</footer>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var input = document.getElementById('q');
|
||||||
|
var dropdown = document.getElementById('autocomplete-dropdown');
|
||||||
|
var form = document.getElementById('search-form');
|
||||||
|
var debounceTimer = null;
|
||||||
|
var suggestions = [];
|
||||||
|
var activeIndex = -1;
|
||||||
|
var fetchController = null;
|
||||||
|
|
||||||
|
// Escape regex special chars for highlight matching
|
||||||
|
function escapeRegex(str) {
|
||||||
|
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Highlight matching prefix
|
||||||
|
function highlight(text, query) {
|
||||||
|
if (!query) return text;
|
||||||
|
var re = new RegExp('^(' + escapeRegex(query) + ')', 'i');
|
||||||
|
return text.replace(re, '<mark>$1</mark>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDropdown() {
|
||||||
|
if (suggestions.length === 0) {
|
||||||
|
dropdown.classList.remove('open');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var html = '';
|
||||||
|
for (var i = 0; i < suggestions.length; i++) {
|
||||||
|
var escaped = highlight(suggestions[i], input.value);
|
||||||
|
html += '<div class="autocomplete-suggestion" data-index="' + i + '">' + escaped + '</div>';
|
||||||
|
}
|
||||||
|
html += '<div class="autocomplete-footer">Press <kbd>↑</kbd><kbd>↓</kbd> to navigate, Enter to select, Esc to close</div>';
|
||||||
|
dropdown.innerHTML = html;
|
||||||
|
dropdown.classList.add('open');
|
||||||
|
activeIndex = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDropdown() {
|
||||||
|
dropdown.classList.remove('open');
|
||||||
|
suggestions = [];
|
||||||
|
activeIndex = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectSuggestion(index) {
|
||||||
|
if (index < 0 || index >= suggestions.length) return;
|
||||||
|
input.value = suggestions[index];
|
||||||
|
closeDropdown();
|
||||||
|
form.submit();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateActive(newIndex) {
|
||||||
|
var items = dropdown.querySelectorAll('.autocomplete-suggestion');
|
||||||
|
items.forEach(function (el) { el.classList.remove('active'); });
|
||||||
|
if (newIndex >= 0 && newIndex < items.length) {
|
||||||
|
items[newIndex].classList.add('active');
|
||||||
|
items[newIndex].scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
activeIndex = newIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchSuggestions(query) {
|
||||||
|
if (fetchController) fetchController.abort();
|
||||||
|
fetchController = new AbortController();
|
||||||
|
fetch('/autocompleter?q=' + encodeURIComponent(query), { signal: fetchController.signal })
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
suggestions = data || [];
|
||||||
|
renderDropdown();
|
||||||
|
})
|
||||||
|
.catch(function (e) {
|
||||||
|
if (e.name !== 'AbortError') suggestions = [];
|
||||||
|
dropdown.classList.remove('open');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
input.addEventListener('input', function () {
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
var q = input.value.trim();
|
||||||
|
if (q.length < 2) { closeDropdown(); return; }
|
||||||
|
debounceTimer = setTimeout(function () { fetchSuggestions(q); }, 250);
|
||||||
|
});
|
||||||
|
|
||||||
|
input.addEventListener('keydown', function (e) {
|
||||||
|
if (!dropdown.classList.contains('open')) return;
|
||||||
|
var items = dropdown.querySelectorAll('.autocomplete-suggestion');
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
updateActive(Math.min(activeIndex + 1, items.length - 1));
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
updateActive(Math.max(activeIndex - 1, -1));
|
||||||
|
} else if (e.key === 'Enter' && activeIndex >= 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
selectSuggestion(activeIndex);
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
closeDropdown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
input.addEventListener('blur', function () {
|
||||||
|
// Delay to allow click events on suggestions
|
||||||
|
setTimeout(closeDropdown, 150);
|
||||||
|
});
|
||||||
|
|
||||||
|
dropdown.addEventListener('mousedown', function (e) {
|
||||||
|
var item = e.target.closest('.autocomplete-suggestion');
|
||||||
|
if (item) {
|
||||||
|
e.preventDefault(); // prevent blur from firing before select
|
||||||
|
var idx = parseInt(item.getAttribute('data-index'), 10);
|
||||||
|
selectSuggestion(idx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}());
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,12 @@
|
||||||
<div class="index">
|
<div class="index">
|
||||||
<div class="title"><h1>kafka</h1></div>
|
<div class="title"><h1>kafka</h1></div>
|
||||||
<div id="search">
|
<div id="search">
|
||||||
<form method="GET" action="/search" role="search">
|
<form method="GET" action="/search" role="search" id="search-form">
|
||||||
<input type="text" name="q" id="q" placeholder="Search…" autocomplete="off" autofocus
|
<input type="text" name="q" id="q" placeholder="Search…" autocomplete="off" autofocus
|
||||||
hx-get="/search" hx-target="#results" hx-trigger="keyup changed delay:500ms" hx-include="this">
|
hx-get="/search" hx-target="#results" hx-trigger="keyup changed delay:500ms" hx-include="this">
|
||||||
<button type="submit">Search</button>
|
<button type="submit">Search</button>
|
||||||
</form>
|
</form>
|
||||||
|
<div id="autocomplete-dropdown"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="results"></div>
|
<div id="results"></div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue