/* ========================================================================== 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 }), ); }