287 lines
10 KiB
JavaScript
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 }),
|
|
);
|
|
}
|