Autocomplete existing tags in the upload and manage forms

A small shared widget (static/tagcomplete.js) completes the tag after the last
comma from the existing tag list, keeping earlier tags intact. It is wired to
every comma-separated tag input: the upload form, and the manage page's per-row
edit field and bulk add/remove fields.

Suggestions come from /tags.json, which the generator already writes on every
build and nginx serves statically, so no server change is needed; the list is
fetched once, cached, and invalidated after an edit that may add a tag. Keyboard
navigation (up/down, enter/tab, escape) and click selection are supported.
This commit is contained in:
puttaalu committed 2026-10-18 22:39:13 +02:00
1 parent bf75c9fe8e
commit c4aa345914
4 files changed
+183 -3

No files matched your search

+23
View File
@@ -476,3 +476,26 @@ body {
font-family: var(--serif);
margin-top: 16px;
}
/* Tag autocomplete dropdown (tagcomplete.js), shared by upload and manage. */
.tagac-menu {
position: absolute;
z-index: 50;
background: var(--bg1);
border: 1px solid var(--bg2);
max-height: 220px;
overflow-y: auto;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4);
}
.tagac-item {
padding: 7px 11px;
font-family: var(--serif);
font-size: 14px;
color: var(--fg2);
cursor: pointer;
}
.tagac-item.sel,
.tagac-item:hover {
background: var(--bg2);
color: var(--fg);
}
+152
View File
@@ -0,0 +1,152 @@
// Per-token tag autocomplete for comma-separated tag inputs, shared by the
// upload and manage pages. Suggestions come from /tags.json (regenerated on
// every build), so new tags appear on the next load. Only the token after the
// last comma is completed; earlier tags in the field are left untouched.
//
// Usage: add data-tagcomplete to an <input>, or call TagComplete.attach(input)
// for inputs created dynamically. TagComplete.invalidate() drops the cached
// tag list after an edit that may have introduced new tags.
(function () {
let tags = null; // cached list of tag names
let inflight = null;
const menu = document.createElement("div");
menu.className = "tagac-menu";
menu.style.display = "none";
menu.setAttribute("role", "listbox");
document.body.appendChild(menu);
let items = []; // currently shown suggestions
let sel = -1; // highlighted index
function loadTags() {
if (tags) return Promise.resolve(tags);
if (inflight) return inflight;
inflight = fetch("/tags.json", { headers: { Accept: "application/json" } })
.then((r) => (r.ok ? r.json() : []))
.then((list) => {
tags = (list || []).map((t) => t.tag);
return tags;
})
.catch(() => {
tags = [];
return tags;
});
return inflight;
}
// The token being typed is the text after the last comma.
function lastToken(value) {
return value.slice(value.lastIndexOf(",") + 1).replace(/^\s+/, "");
}
function replaceLastToken(value, tag) {
const i = value.lastIndexOf(",");
const head = i < 0 ? "" : value.slice(0, i + 1) + " ";
return head + tag + ", ";
}
function presentTags(value) {
return value
.split(",")
.map((s) => s.trim().toLowerCase())
.filter(Boolean);
}
function hide() {
menu.style.display = "none";
sel = -1;
items = [];
}
function highlight() {
[...menu.children].forEach((c, i) => c.classList.toggle("sel", i === sel));
if (sel >= 0 && menu.children[sel]) {
menu.children[sel].scrollIntoView({ block: "nearest" });
}
}
function render(input) {
const token = lastToken(input.value).toLowerCase();
if (!token) {
hide();
return;
}
const present = new Set(presentTags(input.value));
items = (tags || [])
.filter((t) => t.toLowerCase().includes(token) && !present.has(t.toLowerCase()))
.slice(0, 8);
if (items.length === 0) {
hide();
return;
}
sel = 0;
menu.innerHTML = "";
items.forEach((t, idx) => {
const row = document.createElement("div");
row.className = "tagac-item" + (idx === 0 ? " sel" : "");
row.setAttribute("role", "option");
row.textContent = t;
// mousedown (not click) so it fires before the input's blur.
row.addEventListener("mousedown", (e) => {
e.preventDefault();
choose(input, idx);
});
menu.appendChild(row);
});
const r = input.getBoundingClientRect();
menu.style.left = window.scrollX + r.left + "px";
menu.style.top = window.scrollY + r.bottom + "px";
menu.style.width = r.width + "px";
menu.style.display = "block";
}
function choose(input, idx) {
if (idx < 0 || idx >= items.length) return;
input.value = replaceLastToken(input.value, items[idx]);
hide();
input.focus();
input.dispatchEvent(new Event("input", { bubbles: true }));
}
function attach(input) {
if (!input || input.dataset.tagacBound) return;
input.dataset.tagacBound = "1";
input.setAttribute("autocomplete", "off");
input.addEventListener("focus", () => loadTags().then(() => render(input)));
input.addEventListener("input", () => loadTags().then(() => render(input)));
input.addEventListener("keydown", (e) => {
if (menu.style.display === "none" || items.length === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
sel = (sel + 1) % items.length;
highlight();
} else if (e.key === "ArrowUp") {
e.preventDefault();
sel = (sel - 1 + items.length) % items.length;
highlight();
} else if (e.key === "Enter" || e.key === "Tab") {
if (sel >= 0) {
e.preventDefault();
choose(input, sel);
}
} else if (e.key === "Escape") {
hide();
}
});
// Delay so a mousedown on the menu is processed before hiding.
input.addEventListener("blur", () => setTimeout(hide, 120));
}
window.TagComplete = {
attach,
invalidate() {
tags = null;
inflight = null;
},
};
document.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll("input[data-tagcomplete]").forEach(attach);
});
})();