refactor(console): module-scope listeners + form-level event delegation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
25016b0ff6
commit
97a4e51f8a
1 changed files with 96 additions and 46 deletions
|
|
@ -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 => ({
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
||||
}[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 => ({
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
||||
}[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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue