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
|
|
@ -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}}
|
||||
|
|
|
|||
|
|
@ -3,11 +3,12 @@
|
|||
<div class="index">
|
||||
<div class="title"><h1>kafka</h1></div>
|
||||
<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
|
||||
hx-get="/search" hx-target="#results" hx-trigger="keyup changed delay:500ms" hx-include="this">
|
||||
<button type="submit">Search</button>
|
||||
</form>
|
||||
<div id="autocomplete-dropdown"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="results"></div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue