feat(console): add vanilla autocomplete dropdown module
This commit is contained in:
parent
d8dd2d23d2
commit
40961eacdd
1 changed files with 183 additions and 0 deletions
183
l4d2web/l4d2web/static/js/console-autocomplete.js
Normal file
183
l4d2web/l4d2web/static/js/console-autocomplete.js
Normal 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 => ({
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
||||
}[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));
|
||||
Loading…
Reference in a new issue