// Small DOM helpers and widgets used by every page. // h creates an element. attrs may contain class, style, on // handlers, dataset, value/checked/disabled and any other attribute. export function h(tag, attrs, ...children) { const el = document.createElement(tag); if (attrs) { for (const [k, v] of Object.entries(attrs)) { if (v === undefined || v === null || v === false) continue; if (k === "class") el.className = v; else if (k === "style" && typeof v === "object") Object.assign(el.style, v); else if (k.startsWith("on") && typeof v === "function") el.addEventListener(k.slice(2), v); else if (k === "dataset") Object.assign(el.dataset, v); else if (k === "value") el.value = v; else if (k === "checked" || k === "disabled" || k === "selected" || k === "multiple" || k === "required" || k === "readOnly") el[k] = !!v; else el.setAttribute(k, v === true ? "" : v); } } append(el, children); return el; } function append(el, children) { for (const c of children.flat(Infinity)) { if (c === null || c === undefined || c === false) continue; el.append(c instanceof Node ? c : document.createTextNode(String(c))); } } export function clear(el) { while (el.firstChild) el.removeChild(el.firstChild); return el; } export function mount(el, ...children) { clear(el); append(el, children); return el; } // ---- formatting export function fmtTime(iso) { if (!iso) return ""; const d = new Date(iso); if (isNaN(d)) return iso; return d.toLocaleString(undefined, { year: "numeric", month: "short", day: "2-digit", hour: "2-digit", minute: "2-digit", hourCycle: "h23" }); } export function relTime(iso) { if (!iso) return ""; const diff = (Date.now() - new Date(iso).getTime()) / 1000; const s = Math.abs(diff); if (s < 60) return "just now"; let n; if (s < 3600) n = Math.floor(s / 60) + " min"; else if (s < 86400) n = Math.floor(s / 3600) + " h"; else if (s < 86400 * 30) n = plural(Math.floor(s / 86400), "day"); else return fmtTime(iso); return diff > 0 ? n + " ago" : "in " + n; // a future time (an expiry) } export function time(iso) { return h("span", { title: fmtTime(iso), class: "nowrap" }, relTime(iso)); } export function plural(n, word, words) { return n + " " + (n === 1 ? word : (words || word + "s")); } export function badge(text, kind) { return h("span", { class: "badge" + (kind ? " " + kind : "") }, text); } // ---- toasts export function toast(message, kind = "ok", ms = 3500) { const box = document.getElementById("toasts"); const t = h("div", { class: "toast " + kind, onclick: () => t.remove() }, message); // A repeated message replaces the earlier one instead of stacking. for (const old of [...box.children]) if (old.textContent === t.textContent) old.remove(); box.append(t); while (box.children.length > 3) box.firstChild.remove(); setTimeout(() => t.remove(), ms); } // ---- modal dialogs // modal shows a dialog. buttons: [{label, kind, value, onclick}]. // onclick may return false to keep the dialog open, or a promise. export function modal(title, body, buttons = [{ label: "Close", value: null }], opts = {}) { return new Promise((resolve) => { const back = h("div", { class: "modal-back" }); const close = (v) => { back.remove(); document.removeEventListener("keydown", onKey); resolve(v); }; const onKey = (e) => { if (e.key === "Escape") close(null); }; document.addEventListener("keydown", onKey); const btns = buttons.map((b) => h("button", { class: b.kind || "", type: "button", onclick: async (e) => { if (b.onclick) { e.target.disabled = true; try { const r = await b.onclick(); if (r === false) return; } finally { e.target.disabled = false; } } close(b.value === undefined ? b.label : b.value); }, }, b.label)); const dialog = h("div", { class: "modal" + (opts.wide ? " wide" : ""), role: "dialog" }, h("h2", null, title), body, h("div", { class: "buttons" }, btns)); back.append(dialog); back.addEventListener("mousedown", (e) => { if (e.target === back && !opts.sticky) close(null); }); document.body.append(back); const first = dialog.querySelector("input, select, textarea"); if (first) first.focus(); }); } export async function confirmDialog(title, message, okLabel = "OK", kind = "primary") { const r = await modal(title, h("p", null, message), [ { label: "Cancel", value: false }, { label: okLabel, kind, value: true }, ]); return r === true; } // ---- forms // field returns a label + control pair for .form-grid layouts. export function field(label, control, help) { // The label points at the control itself, also when it is wrapped. const target = control.matches && !control.matches("input, select, textarea") ? control.querySelector("input, select, textarea") || control : control; const id = target.id || ("f" + Math.random().toString(36).slice(2, 9)); target.id = id; return [h("label", { for: id }, label), control, help ? h("div", { class: "help" }, help) : null]; } export function input(attrs = {}) { return h("input", { type: "text", autocomplete: "off", spellcheck: "false", ...attrs }); } export function select(options, value, attrs = {}) { const s = h("select", attrs); for (const o of options) { const [v, label] = Array.isArray(o) ? o : [o, o]; s.append(h("option", { value: v, selected: String(v) === String(value) }, label)); } return s; } export function textarea(value, attrs = {}) { return h("textarea", { spellcheck: "false", ...attrs, value: value || "" }); } export function checkbox(label, checked, attrs = {}) { const c = h("input", { type: "checkbox", checked, ...attrs }); const l = h("label", { class: "check" }, c, label); l.input = c; return l; } // lines splits a textarea's value into non-empty trimmed lines. export function lines(text) { return text.split(/\r?\n/).map((s) => s.trim()).filter((s) => s !== ""); } // ---- tables // table renders rows with columns [{label, render(row) => node|string, class}]. export function table(columns, rows, opts = {}) { if (!rows.length) return h("div", { class: "empty" }, opts.empty || "Nothing here yet."); const thead = h("thead", null, h("tr", null, columns.map((c) => h("th", { class: c.class || "" }, c.label)))); const tbody = h("tbody"); rows.forEach((row, i) => { const tr = h("tr", { class: opts.rowClass ? opts.rowClass(row, i) : "" }); for (const c of columns) { const v = c.render(row, i); tr.append(h("td", { class: c.tdClass ? (typeof c.tdClass === "function" ? c.tdClass(row, i) : c.tdClass) : "" }, v)); } tbody.append(tr); }); return h("div", { class: "table-wrap" }, h("table", null, thead, tbody)); } // ---- problems and errors export function problemsList(problems) { if (!problems || !problems.length) return null; return h("ul", { class: "problems" }, problems.map((p) => h("li", { class: p.severity }, h("strong", null, p.severity + ": "), p.message, p.object ? h("span", { class: "where" }, p.object) : null))); } // errorBox shows an API error with its problems. export function errorBox(err) { if (!err) return null; const d = err.details || {}; let problems = d.problems || []; // Don't repeat a single problem that is already the headline. if (problems.length === 1 && err.message.includes(problems[0].message)) problems = []; return h("div", { class: "msg err" }, h("div", null, err.message), problemsList(problems)); } export function copyBox(value) { const inp = input({ value, readOnly: true }); const btn = h("button", { type: "button", class: "small", onclick: async () => { try { await navigator.clipboard.writeText(value); toast("Copied"); } catch { inp.select(); document.execCommand("copy"); toast("Copied"); } } }, "Copy"); return h("div", { class: "copy-box" }, inp, btn); } // debounce delays fn until calls stop for ms. export function debounce(fn, ms = 250) { let t; return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); }; } // backLink is the "← Zones" link above a page's heading, back to the // page it was opened from. export function backLink(label, href) { return h("nav", { class: "back-link", "aria-label": "Back" }, h("a", { href }, "← ", label)); } // safeBack returns a "back" address taken from the URL if it is one of // the UI's own (#/...), else null: it ends up in an href. export function safeBack(v) { return v && /^#\/[A-Za-z0-9\/?=&%._~+-]*$/.test(v) ? v : null; } // readOnly locks a form for someone who may look but not change: every // control is disabled, and the editors' add and remove buttons hidden. export function readOnly(el) { for (const c of el.querySelectorAll("input, select, textarea")) c.disabled = true; for (const b of el.querySelectorAll("button")) b.classList.add("hidden"); return el; }