// DOM helpers, the bottom sheet, confirmation and toasts. // h builds an element. attrs: class, text, on, dataset, aria/other // attributes; boolean true sets an empty attribute, false/null skips it. export function h(tag, attrs = {}, ...children) { const el = document.createElement(tag); for (const [k, v] of Object.entries(attrs || {})) { if (v == null || v === false) continue; if (k === 'class') el.className = v; else if (k === 'text') el.textContent = v; else if (k === 'dataset') Object.assign(el.dataset, v); else if (k.startsWith('on') && typeof v === 'function') el.addEventListener(k.slice(2), v); else if (k in el && typeof v !== 'string') el[k] = v; else el.setAttribute(k, v === true ? '' : v); } append(el, children); return el; } function append(el, children) { for (const c of children.flat(Infinity)) { if (c == null || c === false) continue; el.append(c instanceof Node ? c : document.createTextNode(String(c))); } } export function clear(el, ...children) { el.replaceChildren(); append(el, children); return el; } // Stroke icons, 24×24. Built as SVG nodes so the CSP needs no inline anything. const ICONS = { back: ['M15 18l-6-6 6-6'], more: ['M5 12h.01M12 12h.01M19 12h.01'], queueList: ['M4 6h16M4 12h16M4 18h10'], calendar: ['rect:3.5,5,17,15,2', 'M3.5 10h17M8 3v4M16 3v4'], team: ['circle:9,8,3', 'circle:17,9,2.5', 'M3 19a6 6 0 0 1 12 0M15 19a5 5 0 0 1 6-4'], shield: ['M12 3l7 3v6c0 4-3 7-7 9-4-2-7-5-7-9V6z'], check: ['M5 12.5l4.5 4.5L19 7'], checkCircle: ['M8 12.5l3 3 5-6', 'circle:12,12,9'], undo: ['M9 14L4 9l5-5', 'M4 9h10a6 6 0 0 1 0 12h-3'], user: ['circle:12,8,3.5', 'M5 20a7 7 0 0 1 14 0'], clock: ['circle:12,12,9', 'M12 7v5l3 2'], bell: ['M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15z', 'M10 20.5a2 2 0 0 0 4 0'], note: ['M5 4h14v12l-4 4H5z', 'M15 20v-4h4', 'M9 9h6M9 13h4'], archive: ['M3.5 5h17v4h-17z', 'M5 9v10h14V9', 'M10 13h4'], flag: ['M5 21V4', 'M5 4h11l-2 4 2 4H5'], trash: ['M4 7h16', 'M9 7V4h6v3', 'M6 7l1 13h10l1-13'], chevronLeft: ['M15 18l-6-6 6-6'], chevronRight: ['M9 6l6 6-6 6'], external: ['M14 4h6v6', 'M20 4l-9 9', 'M18 14v6H4V6h6'], logout: ['M15 4h4v16h-4', 'M10 17l5-5-5-5', 'M15 12H4'], }; const SVG = 'http://www.w3.org/2000/svg'; export function icon(name, cls = 'icon') { const svg = document.createElementNS(SVG, 'svg'); svg.setAttribute('viewBox', '0 0 24 24'); svg.setAttribute('aria-hidden', 'true'); svg.setAttribute('class', cls); for (const d of ICONS[name] || []) { let node; if (d.startsWith('circle:')) { const [cx, cy, r] = d.slice(7).split(','); node = document.createElementNS(SVG, 'circle'); node.setAttribute('cx', cx); node.setAttribute('cy', cy); node.setAttribute('r', r); } else if (d.startsWith('rect:')) { const [x, y, w, hgt, rx] = d.slice(5).split(','); node = document.createElementNS(SVG, 'rect'); node.setAttribute('x', x); node.setAttribute('y', y); node.setAttribute('width', w); node.setAttribute('height', hgt); if (rx) node.setAttribute('rx', rx); } else { node = document.createElementNS(SVG, 'path'); node.setAttribute('d', d); } svg.append(node); } return svg; } // ---------- sheet ---------- const sheet = () => document.getElementById('sheet'); let sheetResolve = null; // openSheet shows content in the bottom sheet (a centred dialog on desktop) // and resolves with whatever closeSheet is given, or null when dismissed. export function openSheet(build) { const dlg = sheet(); if (dlg.open) closeSheet(null); const inner = h('div', { class: 'sheet-inner' }, h('div', { class: 'sheet-grab' })); append(inner, [build()]); clear(dlg, inner); dlg.showModal(); const first = dlg.querySelector('[autofocus]'); if (first) first.focus(); return new Promise((resolve) => { sheetResolve = resolve; }); } export function closeSheet(value = null) { const dlg = sheet(); const resolve = sheetResolve; sheetResolve = null; if (dlg.open) dlg.close(); if (resolve) resolve(value); } export function sheetIsOpen() { return sheet().open; } export function initSheet() { const dlg = sheet(); // A tap on the backdrop lands on the dialog element itself. dlg.addEventListener('click', (e) => { if (e.target === dlg) closeSheet(null); }); dlg.addEventListener('cancel', (e) => { e.preventDefault(); closeSheet(null); }); } // confirm asks a yes/no question in the sheet. export function confirm({ title, text, confirmLabel = 'Confirm', danger = false }) { return openSheet(() => [ h('h2', { class: 'sheet-title', text: title }), text && h('p', { class: 'sheet-text', text }), h('div', { class: 'sheet-actions' }, h('button', { class: 'btn', type: 'button', onclick: () => closeSheet(false), text: 'Cancel' }), h('button', { class: `btn ${danger ? 'btn-danger' : 'btn-primary'}`, type: 'button', autofocus: true, onclick: () => closeSheet(true), text: confirmLabel, }), ), ]).then((v) => v === true); } // ---------- toast ---------- let toastTimer = 0; export function toast(message, kind = '') { const el = document.getElementById('toast'); el.textContent = message; el.className = `toast ${kind}`; el.hidden = false; clearTimeout(toastTimer); toastTimer = setTimeout(() => { el.hidden = true; }, kind === 'error' ? 5000 : 2500); } // ---------- misc ---------- export function badge(text, cls = '') { return h('span', { class: `badge ${cls}`, text }); } export function labelChip(k, v) { return h('span', { class: 'label', title: `${k}=${v}` }, h('span', { text: k }), h('span', { text: v })); } // One entry in a section's overview: a card that is a link, carrying the count // only that section can state. Both the Admin tab and the Team tab open on one // of these menus, and a menu item is a shape rather than a page's own idea. export function menuCard(href, label, count, note) { return h('a', { class: 'card overview-item', href }, h('div', { class: 'overview-head' }, h('strong', { text: label }), count != null && h('span', { class: 'overview-count', text: String(count) })), h('p', { class: 'muted small', text: note }), ); } export function emptyState(title, text, iconName) { return h('div', { class: 'empty' }, iconName && icon(iconName), h('strong', { text: title }), text && h('span', { text }), ); } export function spinner() { return h('div', { class: 'empty' }, h('span', { class: 'spinner' })); }