refactor(console): module-scope listeners + form-level event delegation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mwiegand 2026-05-17 19:05:27 +02:00
parent 25016b0ff6
commit 97a4e51f8a
No known key found for this signature in database

View file

@ -6,15 +6,25 @@
//
// First-token only: the dropdown is hidden as soon as the cursor
// is past the first space in the input.
//
// Listeners are delegated to the form (not the input) so the input
// element can be swapped via HTMX without breaking autocomplete.
// Document- and window-level handlers are module-scoped and operate
// on the currently-open dropdown via the activeBinding pointer.
const VOCAB_URL = "/static/data/srccfg-vocab.json";
const MAX_RENDERED = 8;
// Module-scoped state.
let vocabPromise = null;
let vocab = null;
let activeBinding = 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)))
.then(v => { vocab = v; return v; })
.catch(err => { console.warn("[console-autocomplete] vocab load failed", err); return null; });
return vocabPromise;
}
@ -30,18 +40,45 @@ function firstTokenSlice(value, caret) {
return { token: value.slice(0, spaceIdx), from: 0, to: spaceIdx };
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
}[c]));
}
// Module-scoped document/window listeners installed once.
// Operate on whichever binding has its dropdown currently open.
document.addEventListener("mousedown", (event) => {
if (!activeBinding) return;
const dropdown = activeBinding.getDropdown();
if (!dropdown || dropdown.style.display === "none") return;
const row = event.target.closest(".console-autocomplete-row");
if (!row || !dropdown.contains(row)) return;
event.preventDefault();
activeBinding.acceptRow(row);
});
window.addEventListener("resize", () => {
if (activeBinding) activeBinding.position();
});
window.addEventListener("scroll", () => {
if (activeBinding) activeBinding.position();
}, true);
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) ---
// Per-binding state.
let dropdown = null;
let items = []; // current ranked items
let highlightIdx = 0; // index of currently-highlighted row
let vocab = null;
let items = [];
let highlightIdx = 0;
let focusHandledOnce = false;
function getInput() {
return form.querySelector("input[name='command']");
}
function ensureDropdown() {
if (dropdown) return dropdown;
@ -54,7 +91,8 @@ function bindConsoleAutocomplete(form) {
}
function position() {
if (!dropdown) return;
const input = getInput();
if (!input || !dropdown) return;
const rect = input.getBoundingClientRect();
dropdown.style.left = `${rect.left + window.scrollX}px`;
dropdown.style.top = `${rect.bottom + window.scrollY}px`;
@ -62,10 +100,14 @@ function bindConsoleAutocomplete(form) {
}
function close() {
if (!dropdown) return;
if (!dropdown) {
if (activeBinding === binding) activeBinding = null;
return;
}
dropdown.style.display = "none";
items = [];
highlightIdx = 0;
if (activeBinding === binding) activeBinding = null;
}
function render() {
@ -79,38 +121,41 @@ function bindConsoleAutocomplete(form) {
}).join("");
dropdown.innerHTML = rows;
dropdown.style.display = "block";
activeBinding = binding;
position();
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
}[c]));
}
function acceptHighlighted() {
if (items.length === 0) return;
const input = getInput();
if (!input) return;
const chosen = items[highlightIdx];
const slice = firstTokenSlice(input.value, input.selectionStart || 0);
if (!slice) return;
// If the first token is already exactly the chosen name, accepting it
// would be a no-op; close the dropdown so Tab feels responsive.
if (slice.token === chosen.name) { close(); 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 acceptRow(rowEl) {
highlightIdx = parseInt(rowEl.dataset.idx, 10) || 0;
acceptHighlighted();
const input = getInput();
if (input) input.focus();
}
function recompute() {
if (!vocab) return;
if (typeof window.__rankVocab !== "function") {
console.warn("[console-autocomplete] window.__rankVocab unavailable — vocab-rank.bundle.js failed to load?");
return;
}
const input = getInput();
if (!input) return;
const slice = firstTokenSlice(input.value, input.selectionStart || 0);
if (!slice || !slice.token) { close(); return; }
items = window.__rankVocab(slice.token, vocab);
@ -119,22 +164,41 @@ function bindConsoleAutocomplete(form) {
render();
}
// --- Lazy vocab fetch on first focus ---
input.addEventListener("focus", async () => {
if (!vocab) {
vocab = await loadVocab();
// If the user already typed during the fetch, rank now so the
// dropdown doesn't appear to lag a keystroke behind on cold load.
if (vocab && document.activeElement === input) recompute();
}
}, { once: true });
// Binding object exposed to module-scope listeners.
// getDropdown() returns the current dropdown reference, which is
// assigned lazily by ensureDropdown(); the getter is needed because
// the dropdown variable's binding changes after construction.
const binding = {
getDropdown: () => dropdown,
position,
close,
acceptRow,
};
input.addEventListener("input", () => {
if (!vocab) return; // fetch may not have resolved yet; next input will recompute
// Form-level event delegation. event.target.matches gates each
// handler to the command input, so the input element can be
// swapped via HTMX without rebinding.
form.addEventListener("focusin", async (event) => {
if (!event.target.matches('input[name="command"]')) return;
if (focusHandledOnce) return;
focusHandledOnce = true;
if (!vocab) {
await loadVocab();
// If the user typed during the fetch, rank now so the dropdown
// doesn't appear to lag a keystroke behind on cold load.
if (vocab && document.activeElement === event.target) recompute();
}
});
form.addEventListener("input", (event) => {
if (!event.target.matches('input[name="command"]')) return;
if (!vocab) return; // fetch may not have resolved yet
recompute();
});
input.addEventListener("keydown", (event) => {
form.addEventListener("keydown", (event) => {
if (!event.target.matches('input[name="command"]')) return;
if (event.key === "Tab" && !event.shiftKey) {
if (items.length > 0) {
event.preventDefault();
@ -156,28 +220,14 @@ function bindConsoleAutocomplete(form) {
// ArrowUp/ArrowDown/Enter intentionally NOT handled here.
});
input.addEventListener("blur", () => {
form.addEventListener("focusout", (event) => {
if (!event.target.matches('input[name="command"]')) return;
// 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) {