// modal.js — reusable themed modal/dialog component for the operator // dashboard. An in-theme replacement for the browser's native // `confirm()` / `alert()` overlays so destructive actions and prompts match // the dashboard chrome instead of a jarring OS dialog. // // `openDialog` is the general primitive (any title/message/content + a row of // buttons); `themedConfirm` is a thin cancel/confirm wrapper with optional // checkboxes built on top of it. Styling lives in `dashboard.css` under the // `.tc-*` classes. import { el } from './common.js'; // openDialog({ title, message, content, buttons, danger, dismissable }) // → Promise resolving to the clicked button's `value`, or `null` when the // dialog is dismissed (Escape, backdrop click, or a button whose value is // null). `content` is an optional DOM node rendered between the message // and the buttons (checkboxes, custom fields, …). `buttons` is // `[{ label, value, danger?, class?, autofocus? }]`, rendered // right-aligned. Initial focus: the `autofocus` button if any, else — for // a `danger` dialog — the first non-destructive button (so a stray Enter // can't fire the destructive path), else the last button. export function openDialog(opts = {}) { const { title = '', message = '', content = null, buttons = [{ label: 'ok', value: true }], danger = false, dismissable = true, } = opts; return new Promise((resolve) => { let settled = false; function done(value) { if (settled) return; settled = true; document.removeEventListener('keydown', onKey, true); backdrop.remove(); resolve(value); } function onKey(e) { if (dismissable && e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); done(null); } } const btnEls = buttons.map((b) => { const btn = el('button', { type: 'button', class: 'btn tc-btn' + (b.class ? ' ' + b.class : '') + (b.danger ? ' tc-danger' : ''), }, b.label); btn.addEventListener('click', () => done(b.value)); return { spec: b, btn }; }); // Give the dialog an accessible name: label it by its title if present, // else by its message, via `aria-labelledby` (a11y — role=dialog needs a // name). Only the labelling element carries the id. const labelId = 'tc-dlg-' + Math.random().toString(36).slice(2, 9); const titleEl = title ? el('div', { class: 'tc-title', id: labelId }, title) : null; const messageEl = message ? el('div', title ? { class: 'tc-message' } : { class: 'tc-message', id: labelId }, message) : null; const boxAttrs = { class: 'tc-box', role: 'dialog', 'aria-modal': 'true' }; if (titleEl || messageEl) boxAttrs['aria-labelledby'] = labelId; const box = el('div', boxAttrs, titleEl, messageEl, content || null, el('div', { class: 'tc-actions' }, ...btnEls.map((b) => b.btn))); const backdrop = el('div', { class: 'tc-backdrop' }, box); backdrop.addEventListener('click', (e) => { if (dismissable && e.target === backdrop) done(null); }); document.addEventListener('keydown', onKey, true); document.body.append(backdrop); const focusTarget = btnEls.find((b) => b.spec.autofocus) || (danger ? btnEls.find((b) => !b.spec.danger) : null) || btnEls[btnEls.length - 1]; if (focusTarget) focusTarget.btn.focus(); }); } // themedConfirm({ title, message, danger, confirmLabel, cancelLabel, checkboxes }) // → Promise. `null` = cancelled; otherwise an // object of the checkbox states keyed by `name` (`{}` when there are none). // Example: // const r = await themedConfirm({ message: `stop ${n}?`, danger: true, // confirmLabel: '■ stop', checkboxes: [{ name: 'graceful', label: '…' }] }); // if (!r) return; // cancelled // doStop(r.graceful); export function themedConfirm(opts = {}) { const { title = '', message = '', danger = false, confirmLabel = 'confirm', cancelLabel = 'cancel', checkboxes = [], } = opts; const boxes = checkboxes.map((cb) => { const input = el('input', { type: 'checkbox', class: 'tc-check', name: cb.name }); if (cb.checked) input.checked = true; const row = el('label', { class: 'tc-checkrow' }, input, el('span', { class: 'tc-check-label' }, cb.label || cb.name)); return { input, row }; }); const content = boxes.length ? el('div', { class: 'tc-checks' }, ...boxes.map((b) => b.row)) : null; return openDialog({ title, message, content, danger, buttons: [ { label: cancelLabel, value: null, class: 'tc-cancel', autofocus: danger }, { label: confirmLabel, value: 'confirm', danger, class: 'tc-confirm', autofocus: !danger }, ], }).then((v) => { if (v !== 'confirm') return null; const out = {}; for (let i = 0; i < boxes.length; i++) out[checkboxes[i].name] = boxes[i].input.checked; return out; }); } // themedPrompt({ title, message, label, placeholder, value, confirmLabel, cancelLabel }) // → Promise. Themed replacement for window.prompt(): a // resizable