// modal.js — reusable themed modal/dialog component, shared by the // dashboard and the per-agent UI. An in-theme replacement for the // browser's native `confirm()` / `alert()` overlays so destructive // actions and prompts match each page's chrome instead of a jarring OS // dialog. // // Implemented as two shadow-DOM custom elements (``, // ``), plus `` (hive-btn.js) for the dialog's own // buttons — real per-component style encapsulation, CSS in real `.css` // files imported as raw text (see each component's own header comment // for the design rationale: shadow-DOM-vs-light-DOM tradeoffs live atop // the ``/`` classes below, the customized- // built-in choice lives in hive-btn.js). Other `.btn` consumers across // the app stay on the light-DOM `.btn` class for now — migrating them // is a separate follow-up. // // `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. import { el } from './dom.js'; import './hive-btn.js'; // registers — side-effect import, no named export needed import dialogCss from './hive-dialog.css'; import toastCss from './hive-toast.css'; // Attach an open shadow root to `host`, adopt `ownCssText` (a scoped // stylesheet built fresh per call from a real .css file's contents, // imported as raw text), and return the shadow root for the caller to // populate. function attachShadow(host, ownCssText) { const root = host.attachShadow({ mode: 'open' }); const sheet = new CSSStyleSheet(); sheet.replaceSync(ownCssText); root.adoptedStyleSheets = [sheet]; return root; } // — the backdrop + box custom element behind `openDialog`. // Not exported; constructed and configured by `openDialog` only. Callers // set `._opts` before `append()`ing it (custom elements can't take // constructor args when created via `document.createElement`), then the // element renders itself in `connectedCallback` and reports the outcome // via a `hive-dialog-close` CustomEvent (`detail` = the resolved value) // rather than exposing a resolve/reject pair directly — that keeps the // element a normal DOM node with a normal event contract instead of a // bespoke Promise-ish object. The element itself *is* the backdrop // (`:host` carries the fixed-position/centering rules); the box, title, // message, content, and buttons all render inside its shadow root. class HiveDialog extends HTMLElement { connectedCallback() { const { title = '', message = '', content = null, buttons = [{ label: 'ok', value: true }], danger = false, dismissable = true, } = this._opts || {}; const root = attachShadow(this, dialogCss); let settled = false; const done = (value) => { if (settled) return; settled = true; document.removeEventListener('keydown', onKey, true); this.dispatchEvent(new CustomEvent('hive-dialog-close', { detail: value })); this.remove(); }; const onKey = (e) => { if (dismissable && e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); done(null); } }; this._done = done; // exposed for close-on-escape-elsewhere callers, if ever needed const btnEls = buttons.map((b) => { // `b.class` names the variant ('cancel' | 'confirm'); `b.danger` // overrides it to the 'danger' look regardless (a destructive // confirm button reads as danger, not as a plain confirm). const variant = b.danger ? 'danger' : b.class; const btn = el('button', { type: 'button', is: 'hive-btn', ...(variant ? { variant } : {}), }, 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. IDs are scoped to // this shadow root, so no cross-instance collision risk even without // the random suffix — kept anyway since it costs nothing and guards // against a future shared-DOM edge case (e.g. `::part()` piercing). const labelId = 'dlg-' + Math.random().toString(36).slice(2, 9); const titleEl = title ? el('div', { class: 'title', id: labelId }, title) : null; const messageEl = message ? el('div', title ? { class: 'message' } : { class: 'message', id: labelId }, message) : null; const boxAttrs = { class: '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: 'actions' }, ...btnEls.map((b) => b.btn))); root.append(box); this.addEventListener('click', (e) => { if (dismissable && e.target === this) done(null); }); document.addEventListener('keydown', onKey, true); 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(); } } customElements.define('hive-dialog', HiveDialog); // 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, …) — built by the caller // with `el()` and appended into the dialog's shadow root once mounted, // same as any other node (a JS-created element isn't bound to a // document/shadow-root until it's actually appended somewhere). // `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 = {}) { return new Promise((resolve) => { const dlg = document.createElement('hive-dialog'); dlg._opts = opts; dlg.addEventListener('hive-dialog-close', (e) => resolve(e.detail), { once: true }); document.body.append(dlg); }); } // 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: 'check', name: cb.name }); if (cb.checked) input.checked = true; const row = el('label', { class: 'checkrow' }, input, el('span', {}, cb.label || cb.name)); return { input, row }; }); const content = boxes.length ? el('div', { class: 'checks' }, ...boxes.map((b) => b.row)) : null; return openDialog({ title, message, content, danger, buttons: [ { label: cancelLabel, value: null, class: 'cancel', autofocus: danger }, { label: confirmLabel, value: 'confirm', danger, class: '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