feat: add autocomplete dropdown UI with keyboard nav
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 11s
Mirror to GitHub / mirror (push) Failing after 3s
Tests / test (push) Successful in 54s

- 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:
Franz Kafka 2026-03-22 00:20:43 +00:00
parent 7a2ca8672e
commit e90f6c0876
3 changed files with 176 additions and 1 deletions

View file

@ -20,6 +20,123 @@
<footer>
<p>Powered by <a href="https://git.ashisgreat.xyz/penal-colony/kafka">kafka</a> — a privacy-respecting, open metasearch engine</p>
</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>
</html>
{{end}}