// Tiny DOM helpers: element builder, toasts and a confirm dialog. /** Build an element. Props: class, on handlers, booleans, attributes. */ export function h(tag, props, ...kids) { const el = document.createElement(tag); for (const [k, v] of Object.entries(props ?? {})) { if (v == null || v === false) continue; if (k === 'class') el.className = v; else if (k.startsWith('on') && typeof v === 'function') el.addEventListener(k.slice(2).toLowerCase(), v); else if (v === true) el.setAttribute(k, ''); else el.setAttribute(k, v); } el.append(...kids.flat(Infinity).filter((c) => c != null && c !== false)); return el; } export function clear(el) { el.replaceChildren(); return el; } export function toast(message, kind = 'info') { const host = document.getElementById('toasts'); const t = h('div', { class: `toast ${kind}`, role: kind === 'err' ? 'alert' : null }, message); host.append(t); setTimeout(() => { t.classList.add('out'); setTimeout(() => t.remove(), 400); }, kind === 'err' ? 8000 : 4500); } /** Resolve true if the user confirms. */ export function confirmDialog({ title, body, confirmText = 'CONFIRM', cancelText = 'ABORT', danger = false }) { const dlg = document.getElementById('dialog'); return new Promise((resolve) => { const finish = (v) => { dlg.close(); resolve(v); }; clear(dlg).append( h('div', { class: `panel dialog ${danger ? 'danger' : ''}` }, h('div', { class: 'panel-title' }, title), h('div', { class: 'panel-body' }, ...[].concat(body).map((p) => h('p', null, p))), h('div', { class: 'actions' }, h('button', { class: 'btn', type: 'button', onclick: () => finish(false) }, cancelText), h('button', { class: `btn ${danger ? 'danger' : 'primary'}`, type: 'button', onclick: () => finish(true) }, confirmText), ), ), ); dlg.oncancel = () => resolve(false); dlg.showModal(); }); } /** * A modal dialog around caller-built content (form fields and so on). Returns * { result, confirm, close }: `result` resolves true on the confirm button and * false on cancel or Escape; the caller can enable/disable `confirm` as it * validates, and reads its own fields after `result` resolves. */ export function customDialog({ title, content, confirmText = 'OK', cancelText = 'CANCEL', danger = false }) { const dlg = document.getElementById('dialog'); let resolve; const result = new Promise((r) => { resolve = r; }); const finish = (v) => { if (dlg.open) dlg.close(); resolve(v); }; const confirm = h('button', { class: `btn ${danger ? 'danger' : 'primary'}`, type: 'button', onclick: () => finish(true) }, confirmText); clear(dlg).append( h('div', { class: `panel dialog ${danger ? 'danger' : ''}` }, h('div', { class: 'panel-title' }, title), h('div', { class: 'panel-body stack' }, ...content), h('div', { class: 'actions' }, h('button', { class: 'btn', type: 'button', onclick: () => finish(false) }, cancelText), confirm), ), ); dlg.oncancel = () => resolve(false); dlg.showModal(); return { result, confirm, close: finish }; } export function download(filename, bytes, type = 'application/octet-stream') { const url = URL.createObjectURL(new Blob([bytes], { type })); const a = h('a', { href: url, download: filename }); document.body.append(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000); } export function pad(n, width, ch = '0') { return String(n).padStart(width, ch); } export function plural(n, one, many = `${one}S`) { return `${n} ${n === 1 ? one : many}`; }