Files
Halcyon/web/static/dom.js
T
2026-09-20 14:16:41 +02:00

98 lines
3.6 KiB
JavaScript

// Tiny DOM helpers: element builder, toasts and a confirm dialog.
/** Build an element. Props: class, on<Event> 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}`;
}