feat(console): add vanilla autocomplete dropdown module

This commit is contained in:
mwiegand 2026-05-17 17:45:31 +02:00
parent d8dd2d23d2
commit 40961eacdd
No known key found for this signature in database

View file

@ -0,0 +1,183 @@
// console-autocomplete.js
// Vanilla dropdown autocomplete for [data-console-form] inputs.
// Reads ranked completions from window.__rankVocab (loaded via
// vocab-rank.bundle.js). Owns: Tab, Shift+Tab, Esc, mouse events.
// Leaves: ArrowUp, ArrowDown, Enter (console-history.js owns those).
//
// First-token only: the dropdown is hidden as soon as the cursor
// is past the first space in the input.
const VOCAB_URL = "/static/data/srccfg-vocab.json";
const MAX_RENDERED = 8;
let vocabPromise = null;
function loadVocab() {
if (vocabPromise) return vocabPromise;
vocabPromise = fetch(VOCAB_URL, { credentials: "same-origin" })
.then(r => r.ok ? r.json() : Promise.reject(new Error("vocab fetch failed: " + r.status)))
.catch(err => { console.warn("[console-autocomplete] vocab load failed", err); return null; });
return vocabPromise;
}
function firstTokenSlice(value, caret) {
// Returns the substring [0, end-of-first-token) if the caret is
// within the first token; otherwise null.
const spaceIdx = value.indexOf(" ");
if (spaceIdx === -1) {
return { token: value, from: 0, to: value.length };
}
if (caret > spaceIdx) return null;
return { token: value.slice(0, spaceIdx), from: 0, to: spaceIdx };
}
function bindConsoleAutocomplete(form) {
if (form.dataset.consoleAutocompleteBound === "true") return;
form.dataset.consoleAutocompleteBound = "true";
const input = form.querySelector("input[name='command']");
if (!input) return;
// --- Dropdown DOM (created lazily on first show) ---
let dropdown = null;
let items = []; // current ranked items
let highlightIdx = 0; // index of currently-highlighted row
let vocab = null;
function ensureDropdown() {
if (dropdown) return dropdown;
dropdown = document.createElement("div");
dropdown.className = "console-autocomplete-dropdown";
dropdown.setAttribute("role", "listbox");
dropdown.style.display = "none";
document.body.appendChild(dropdown);
return dropdown;
}
function position() {
if (!dropdown) return;
const rect = input.getBoundingClientRect();
dropdown.style.left = `${rect.left + window.scrollX}px`;
dropdown.style.top = `${rect.bottom + window.scrollY}px`;
dropdown.style.minWidth = `${rect.width}px`;
}
function close() {
if (!dropdown) return;
dropdown.style.display = "none";
items = [];
highlightIdx = 0;
}
function render() {
ensureDropdown();
if (items.length === 0) { close(); return; }
const rows = items.slice(0, MAX_RENDERED).map((e, i) => {
const selected = i === highlightIdx ? " aria-selected='true'" : "";
const kindClass = e.kind === "command" ? "kind-command" : "kind-cvar";
const desc = e.desc ? `<span class="console-autocomplete-desc">${escapeHtml(e.desc)}</span>` : "";
return `<div class="console-autocomplete-row ${kindClass}"${selected} role="option" data-idx="${i}"><span class="console-autocomplete-name">${escapeHtml(e.name)}</span>${desc}</div>`;
}).join("");
dropdown.innerHTML = rows;
dropdown.style.display = "block";
position();
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
}[c]));
}
function acceptHighlighted() {
if (items.length === 0) return;
const chosen = items[highlightIdx];
const slice = firstTokenSlice(input.value, input.selectionStart || 0);
if (!slice) return;
const before = input.value.slice(0, slice.from);
const after = input.value.slice(slice.to);
input.value = before + chosen.name + after;
// Place caret at end of inserted name
const caret = before.length + chosen.name.length;
input.setSelectionRange(caret, caret);
recompute();
}
function recompute() {
if (!vocab) return;
const slice = firstTokenSlice(input.value, input.selectionStart || 0);
if (!slice || !slice.token) { close(); return; }
items = window.__rankVocab(slice.token, vocab);
if (items.length === 0) { close(); return; }
highlightIdx = 0;
render();
}
// --- Lazy vocab fetch on first focus ---
input.addEventListener("focus", async () => {
if (!vocab) {
vocab = await loadVocab();
}
}, { once: true });
input.addEventListener("input", () => {
if (!vocab) return; // fetch may not have resolved yet; next input will recompute
recompute();
});
input.addEventListener("keydown", (event) => {
if (event.key === "Tab" && !event.shiftKey) {
if (items.length > 0) {
event.preventDefault();
acceptHighlighted();
}
} else if (event.key === "Tab" && event.shiftKey) {
if (items.length > 0) {
event.preventDefault();
highlightIdx = (highlightIdx - 1 + Math.min(items.length, MAX_RENDERED))
% Math.min(items.length, MAX_RENDERED);
render();
}
} else if (event.key === "Escape") {
if (dropdown && dropdown.style.display !== "none") {
event.preventDefault();
close();
}
}
// ArrowUp/ArrowDown/Enter intentionally NOT handled here.
});
input.addEventListener("blur", () => {
// Delay close so a click on a dropdown row can fire first.
setTimeout(close, 100);
});
// Mouse click on a row → accept that row.
document.addEventListener("mousedown", (event) => {
if (!dropdown || dropdown.style.display === "none") return;
const row = event.target.closest(".console-autocomplete-row");
if (!row || !dropdown.contains(row)) return;
event.preventDefault();
highlightIdx = parseInt(row.dataset.idx, 10) || 0;
acceptHighlighted();
input.focus();
});
// HTMX form submission clears the input; close on submit.
form.addEventListener("htmx:beforeRequest", close);
// Reposition on resize/scroll while dropdown is open.
window.addEventListener("resize", () => { if (dropdown && dropdown.style.display !== "none") position(); });
window.addEventListener("scroll", () => { if (dropdown && dropdown.style.display !== "none") position(); }, true);
}
function bindAll(root) {
if (!root) return;
const scope = root.matches && root.matches("[data-console-form]") ? [root] : [];
if (root.querySelectorAll) {
root.querySelectorAll("[data-console-form]").forEach((el) => scope.push(el));
}
scope.forEach(bindConsoleAutocomplete);
}
document.addEventListener("DOMContentLoaded", () => bindAll(document));
document.addEventListener("htmx:load", (event) => bindAll(event.detail.elt));