2026-08-13 16:23:18 +04:00

287 lines
10 KiB
JavaScript

/* ==========================================================================
UI builders. Plain DOM, no framework - the dashboard is small enough that a
framework would cost more than it saves, and this keeps the payload tiny for
a phone on the robot's Wi-Fi.
========================================================================== */
import { confirmDialog } from './core.js';
/** el('div.card', {id:'x'}, child, child) - tag string supports .class and #id */
export function el(spec, props = {}, ...children) {
const [tagPart, ...classParts] = String(spec).split('.');
const [tag, id] = tagPart.split('#');
const node = document.createElement(tag || 'div');
if (id) node.id = id;
if (classParts.length) node.className = classParts.join(' ');
for (const [key, value] of Object.entries(props || {})) {
if (value === null || value === undefined || value === false) continue;
if (key === 'class') node.className = `${node.className} ${value}`.trim();
else if (key === 'text') node.textContent = value;
else if (key === 'html') node.innerHTML = value;
else if (key === 'style' && typeof value === 'object') Object.assign(node.style, value);
else if (key === 'dataset') Object.assign(node.dataset, value);
else if (key.startsWith('on') && typeof value === 'function') {
node.addEventListener(key.slice(2).toLowerCase(), value);
} else if (key in node && key !== 'list' && typeof value !== 'object') {
try { node[key] = value; } catch { node.setAttribute(key, value); }
} else {
node.setAttribute(key, value === true ? '' : value);
}
}
append(node, children);
return node;
}
export function append(parent, children) {
for (const child of children.flat(4)) {
if (child === null || child === undefined || child === false) continue;
parent.appendChild(child instanceof Node ? child : document.createTextNode(String(child)));
}
return parent;
}
export function clear(node) {
while (node.firstChild) node.removeChild(node.firstChild);
return node;
}
/**
* Replace a node's children, dropping null / undefined / false entries.
*
* The native replaceChildren() stringifies anything that is not a Node, so a
* conditional child written as `cond ? el(...) : null` renders the literal word
* "null" on the page. This keeps the same forgiving semantics as el().
*/
export function setChildren(node, ...children) {
clear(node);
append(node, children);
return node;
}
export function icon(name, cls = 'ico') {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('class', cls);
svg.setAttribute('aria-hidden', 'true');
const use = document.createElementNS('http://www.w3.org/2000/svg', 'use');
use.setAttribute('href', `#i-${name}`);
svg.appendChild(use);
return svg;
}
/* -- Page ----------------------------------------------------------------- */
export function pageHead(title, description, actions = []) {
return el('div.page-head', {},
el('div', {},
el('h1', { text: title }),
description ? el('p', { text: description }) : null,
),
actions.length ? el('div.page-head-actions', {}, ...actions) : null,
);
}
/* -- Card ----------------------------------------------------------------- */
export function card(title, { sub, actions = [], flush = false, foot } = {}, ...body) {
const head = title
? el('div.card-head', {},
el('h3', { text: title }),
sub ? el('span.sub', { text: sub }) : null,
actions.length ? el('div.card-head-actions', {}, ...actions) : null,
)
: null;
return el('section.card', {},
head,
el('div.card-body', { class: flush ? 'flush' : '' }, ...body),
foot ? el('div.card-foot', {}, foot) : null,
);
}
/* -- Buttons -------------------------------------------------------------- */
export function button(label, onClick, { style = '', iconName, size = '', disabled = false,
confirm = null, title = '', block = false } = {}) {
const classes = ['btn'];
if (style === 'primary') classes.push('btn-primary');
else if (style === 'danger') classes.push('btn-danger');
else if (style === 'warn') classes.push('btn-warn');
else if (style === 'ghost') classes.push('btn-ghost');
if (size === 'sm') classes.push('btn-sm');
if (block) classes.push('btn-block');
const node = el('button', { class: classes.join(' '), disabled, title, type: 'button' },
iconName ? icon(iconName) : null,
label ? el('span', { text: label }) : null,
);
node.addEventListener('click', async () => {
if (confirm) {
const ok = await confirmDialog('Confirm', confirm, { danger: style === 'danger' });
if (!ok) return;
}
onClick?.(node);
});
return node;
}
/* -- Fields --------------------------------------------------------------- */
export function field(label, control, hint) {
return el('label.field', {},
label ? el('span', { text: label, style: { fontSize: '12px', fontWeight: '550', color: 'var(--text-2)' } }) : null,
control,
hint ? el('span.hint', { text: hint }) : null,
);
}
export function input(props = {}) {
return el('input.input', { type: 'text', ...props });
}
export function textarea(props = {}) {
return el('textarea.textarea', props);
}
export function select(options, { value, onChange, ...rest } = {}) {
const node = el('select.select', rest);
for (const option of options) {
const opt = typeof option === 'object' ? option : { value: option, label: String(option) };
node.appendChild(el('option', { value: opt.value, text: opt.label ?? String(opt.value) }));
}
if (value !== undefined && value !== null) node.value = String(value);
if (onChange) node.addEventListener('change', () => onChange(node.value, node));
return node;
}
export function toggle(label, checked, onChange) {
const box = el('input', { type: 'checkbox', checked });
box.addEventListener('change', () => onChange?.(box.checked));
return el('label.switch', {}, box, el('span.switch-track'), el('span.switch-label', { text: label }));
}
export function range({ min = 0, max = 1, step = 0.01, value = 0, unit = '',
precision = 2, onInput, onChange } = {}) {
const slider = el('input', { type: 'range', min, max, step, value });
const readout = el('span.range-value', { text: `${Number(value).toFixed(precision)}${unit}` });
slider.addEventListener('input', () => {
const v = Number(slider.value);
readout.textContent = `${v.toFixed(precision)}${unit}`;
onInput?.(v);
});
slider.addEventListener('change', () => onChange?.(Number(slider.value)));
const wrap = el('div.range', {}, slider, readout);
wrap.setValue = (v) => {
slider.value = v;
readout.textContent = `${Number(v).toFixed(precision)}${unit}`;
};
wrap.getValue = () => Number(slider.value);
return wrap;
}
export function segmented(options, value, onChange) {
const node = el('div.segmented', { role: 'group' });
const buttons = [];
for (const option of options) {
const opt = typeof option === 'object' ? option : { value: option, label: String(option) };
const btn = el('button', { type: 'button', text: opt.label,
'aria-pressed': String(opt.value === value) });
btn.addEventListener('click', () => {
buttons.forEach((b) => b.setAttribute('aria-pressed', String(b === btn)));
onChange?.(opt.value);
});
buttons.push(btn);
node.appendChild(btn);
}
node.setValue = (v) => {
options.forEach((option, i) => {
const optValue = typeof option === 'object' ? option.value : option;
buttons[i].setAttribute('aria-pressed', String(optValue === v));
});
};
return node;
}
/* -- Display -------------------------------------------------------------- */
export function stat(label, value, { unit = '', sub = '', tone = 'default', spark = null } = {}) {
return el('div.stat', { dataset: { tone } },
el('div.stat-label', { text: label }),
el('div.stat-value', {}, String(value), unit ? el('span.unit', { text: unit }) : null),
sub ? el('div.stat-sub', { text: sub }) : null,
spark,
);
}
export function badge(text, tone = 'default') {
return el('span.badge', { text, dataset: { tone } });
}
export function meter(fraction, tone = 'default') {
const pct = Math.max(0, Math.min(1, fraction || 0)) * 100;
return el('div.meter', {}, el('div.meter-fill', { dataset: { tone }, style: { width: `${pct}%` } }));
}
export function note(text, level = 'default', iconGlyph = 'ⓘ') {
return el('div.note', { dataset: { level } },
el('span.note-icon', { text: iconGlyph }),
el('div', { text }),
);
}
export function kv(pairs) {
const list = el('dl.kv');
for (const [key, value] of pairs) {
if (value === undefined) continue;
list.appendChild(el('dt', { text: key }));
list.appendChild(value instanceof Node ? el('dd', {}, value) : el('dd', { text: String(value ?? '—') }));
}
return list;
}
export function table(columns, rows, { empty = 'No data' } = {}) {
if (!rows.length) return emptyState(empty);
const head = el('tr');
for (const column of columns) {
head.appendChild(el('th', { text: column.label,
style: column.align === 'right' ? { textAlign: 'right' } : {} }));
}
const body = el('tbody');
for (const row of rows) {
const tr = el('tr');
for (const column of columns) {
const value = column.get ? column.get(row) : row[column.key];
const cls = column.align === 'right' ? 'num' : (column.key === 'name' ? 'name' : '');
tr.appendChild(value instanceof Node
? el('td', { class: cls }, value)
: el('td', { class: cls, text: value === null || value === undefined ? '—' : String(value) }));
}
body.appendChild(tr);
}
return el('div.table-wrap', {}, el('table.data', {}, el('thead', {}, head), body));
}
export function emptyState(title, detail, action) {
return el('div.empty', {},
el('div.empty-title', { text: title }),
detail
? el('div', { text: detail, style: { maxWidth: '52ch', whiteSpace: 'pre-line' } })
: null,
action,
);
}
export function keyHint(keys, description) {
return el('div.row.tight', { style: { fontSize: '11.5px', color: 'var(--text-3)' } },
el('div.keys', {}, ...keys.map((k) => el('kbd', { text: k }))),
el('span', { text: description }),
);
}