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
|
// First-token only: the dropdown is hidden as soon as the cursor
|
||||||
// is past the first space in the input.
|
// 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 VOCAB_URL = "/static/data/srccfg-vocab.json";
|
||||||
const MAX_RENDERED = 8;
|
const MAX_RENDERED = 8;
|
||||||
|
|
||||||
|
// Module-scoped state.
|
||||||
let vocabPromise = null;
|
let vocabPromise = null;
|
||||||
|
let vocab = null;
|
||||||
|
let activeBinding = null;
|
||||||
|
|
||||||
function loadVocab() {
|
function loadVocab() {
|
||||||
if (vocabPromise) return vocabPromise;
|
if (vocabPromise) return vocabPromise;
|
||||||
vocabPromise = fetch(VOCAB_URL, { credentials: "same-origin" })
|
vocabPromise = fetch(VOCAB_URL, { credentials: "same-origin" })
|
||||||
.then(r => r.ok ? r.json() : Promise.reject(new Error("vocab fetch failed: " + r.status)))
|
.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; });
|
.catch(err => { console.warn("[console-autocomplete] vocab load failed", err); return null; });
|
||||||
return vocabPromise;
|
return vocabPromise;
|
||||||
}
|
}
|
||||||
|
|
@ -30,18 +40,45 @@ function firstTokenSlice(value, caret) {
|
||||||
return { token: value.slice(0, spaceIdx), from: 0, to: spaceIdx };
|
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) {
|
function bindConsoleAutocomplete(form) {
|
||||||
if (form.dataset.consoleAutocompleteBound === "true") return;
|
if (form.dataset.consoleAutocompleteBound === "true") return;
|
||||||
form.dataset.consoleAutocompleteBound = "true";
|
form.dataset.consoleAutocompleteBound = "true";
|
||||||
|
|
||||||
const input = form.querySelector("input[name='command']");
|
// Per-binding state.
|
||||||
if (!input) return;
|
|
||||||
|
|
||||||
// --- Dropdown DOM (created lazily on first show) ---
|
|
||||||
let dropdown = null;
|
let dropdown = null;
|
||||||
let items = []; // current ranked items
|
let items = [];
|
||||||
let highlightIdx = 0; // index of currently-highlighted row
|
let highlightIdx = 0;
|
||||||
let vocab = null;
|
let focusHandledOnce = false;
|
||||||
|
|
||||||
|
function getInput() {
|
||||||
|
return form.querySelector("input[name='command']");
|
||||||
|
}
|
||||||
|
|
||||||
function ensureDropdown() {
|
function ensureDropdown() {
|
||||||
if (dropdown) return dropdown;
|
if (dropdown) return dropdown;
|
||||||
|
|
@ -54,7 +91,8 @@ function bindConsoleAutocomplete(form) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function position() {
|
function position() {
|
||||||
if (!dropdown) return;
|
const input = getInput();
|
||||||
|
if (!input || !dropdown) return;
|
||||||
const rect = input.getBoundingClientRect();
|
const rect = input.getBoundingClientRect();
|
||||||
dropdown.style.left = `${rect.left + window.scrollX}px`;
|
dropdown.style.left = `${rect.left + window.scrollX}px`;
|
||||||
dropdown.style.top = `${rect.bottom + window.scrollY}px`;
|
dropdown.style.top = `${rect.bottom + window.scrollY}px`;
|
||||||
|
|
@ -62,10 +100,14 @@ function bindConsoleAutocomplete(form) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
if (!dropdown) return;
|
if (!dropdown) {
|
||||||
|
if (activeBinding === binding) activeBinding = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
dropdown.style.display = "none";
|
dropdown.style.display = "none";
|
||||||
items = [];
|
items = [];
|
||||||
highlightIdx = 0;
|
highlightIdx = 0;
|
||||||
|
if (activeBinding === binding) activeBinding = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
|
|
@ -79,38 +121,41 @@ function bindConsoleAutocomplete(form) {
|
||||||
}).join("");
|
}).join("");
|
||||||
dropdown.innerHTML = rows;
|
dropdown.innerHTML = rows;
|
||||||
dropdown.style.display = "block";
|
dropdown.style.display = "block";
|
||||||
|
activeBinding = binding;
|
||||||
position();
|
position();
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(s) {
|
|
||||||
return String(s).replace(/[&<>"']/g, c => ({
|
|
||||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
|
||||||
}[c]));
|
|
||||||
}
|
|
||||||
|
|
||||||
function acceptHighlighted() {
|
function acceptHighlighted() {
|
||||||
if (items.length === 0) return;
|
if (items.length === 0) return;
|
||||||
|
const input = getInput();
|
||||||
|
if (!input) return;
|
||||||
const chosen = items[highlightIdx];
|
const chosen = items[highlightIdx];
|
||||||
const slice = firstTokenSlice(input.value, input.selectionStart || 0);
|
const slice = firstTokenSlice(input.value, input.selectionStart || 0);
|
||||||
if (!slice) return;
|
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; }
|
if (slice.token === chosen.name) { close(); return; }
|
||||||
const before = input.value.slice(0, slice.from);
|
const before = input.value.slice(0, slice.from);
|
||||||
const after = input.value.slice(slice.to);
|
const after = input.value.slice(slice.to);
|
||||||
input.value = before + chosen.name + after;
|
input.value = before + chosen.name + after;
|
||||||
// Place caret at end of inserted name
|
|
||||||
const caret = before.length + chosen.name.length;
|
const caret = before.length + chosen.name.length;
|
||||||
input.setSelectionRange(caret, caret);
|
input.setSelectionRange(caret, caret);
|
||||||
recompute();
|
recompute();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function acceptRow(rowEl) {
|
||||||
|
highlightIdx = parseInt(rowEl.dataset.idx, 10) || 0;
|
||||||
|
acceptHighlighted();
|
||||||
|
const input = getInput();
|
||||||
|
if (input) input.focus();
|
||||||
|
}
|
||||||
|
|
||||||
function recompute() {
|
function recompute() {
|
||||||
if (!vocab) return;
|
if (!vocab) return;
|
||||||
if (typeof window.__rankVocab !== "function") {
|
if (typeof window.__rankVocab !== "function") {
|
||||||
console.warn("[console-autocomplete] window.__rankVocab unavailable — vocab-rank.bundle.js failed to load?");
|
console.warn("[console-autocomplete] window.__rankVocab unavailable — vocab-rank.bundle.js failed to load?");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const input = getInput();
|
||||||
|
if (!input) return;
|
||||||
const slice = firstTokenSlice(input.value, input.selectionStart || 0);
|
const slice = firstTokenSlice(input.value, input.selectionStart || 0);
|
||||||
if (!slice || !slice.token) { close(); return; }
|
if (!slice || !slice.token) { close(); return; }
|
||||||
items = window.__rankVocab(slice.token, vocab);
|
items = window.__rankVocab(slice.token, vocab);
|
||||||
|
|
@ -119,22 +164,41 @@ function bindConsoleAutocomplete(form) {
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Lazy vocab fetch on first focus ---
|
// Binding object exposed to module-scope listeners.
|
||||||
input.addEventListener("focus", async () => {
|
// getDropdown() returns the current dropdown reference, which is
|
||||||
if (!vocab) {
|
// assigned lazily by ensureDropdown(); the getter is needed because
|
||||||
vocab = await loadVocab();
|
// the dropdown variable's binding changes after construction.
|
||||||
// If the user already typed during the fetch, rank now so the
|
const binding = {
|
||||||
// dropdown doesn't appear to lag a keystroke behind on cold load.
|
getDropdown: () => dropdown,
|
||||||
if (vocab && document.activeElement === input) recompute();
|
position,
|
||||||
}
|
close,
|
||||||
}, { once: true });
|
acceptRow,
|
||||||
|
};
|
||||||
|
|
||||||
input.addEventListener("input", () => {
|
// Form-level event delegation. event.target.matches gates each
|
||||||
if (!vocab) return; // fetch may not have resolved yet; next input will recompute
|
// 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();
|
recompute();
|
||||||
});
|
});
|
||||||
|
|
||||||
input.addEventListener("keydown", (event) => {
|
form.addEventListener("keydown", (event) => {
|
||||||
|
if (!event.target.matches('input[name="command"]')) return;
|
||||||
if (event.key === "Tab" && !event.shiftKey) {
|
if (event.key === "Tab" && !event.shiftKey) {
|
||||||
if (items.length > 0) {
|
if (items.length > 0) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
@ -156,28 +220,14 @@ function bindConsoleAutocomplete(form) {
|
||||||
// ArrowUp/ArrowDown/Enter intentionally NOT handled here.
|
// 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.
|
// Delay close so a click on a dropdown row can fire first.
|
||||||
setTimeout(close, 100);
|
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.
|
// HTMX form submission clears the input; close on submit.
|
||||||
form.addEventListener("htmx:beforeRequest", close);
|
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) {
|
function bindAll(root) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue