hyperhive/frontend/packages/shared/src/modal.js
iris 841c697301 frontend: convert themed dialogs to light-DOM custom elements
Pilot for the components-split proposal (mara wants a look at using
custom elements now that we're recent-Firefox-only). Picked the
themed dialog system as the first candidate: most self-contained of
our existing de-facto reusable components (transient, imperative call
sites, no external render-tree coupling), and shared between the
dashboard and per-agent UI already.

<hive-dialog> replaces the manually-built tc-backdrop/tc-box tree in
openDialog — connectedCallback renders, the keydown listener and
click-outside-to-dismiss are owned by the element instead of a
closure, and the outcome is reported via a hive-dialog-close
CustomEvent rather than a hand-rolled resolve callback threaded
through the DOM tree.

<hive-toast> replaces the toast div themedToast built inline —
connectedCallback starts the auto-dismiss timer,
disconnectedCallback clears it (previously a closure-captured
setTimeout handle with no explicit cleanup on early removal).

Both are light DOM (no shadow root) — styling stays exactly where it
already lived, in modal.css's .tc-* classes, imported globally by
both packages' base stylesheets. This was the deliberate call for a
first pilot: shadow DOM would need every shared stylesheet
re-imported per instance (CSS custom properties pierce shadow
boundaries for theming, but plain class rules like .btn don't), which
is real migration cost. Light DOM validates the pattern (lifecycle
encapsulation, less manual event bookkeeping) without paying that
cost; shadow DOM is a drop-in upgrade to these same two classes if a
later pilot wants real style encapsulation.

Public API unchanged (openDialog/themedConfirm/themedPrompt/
themedToast) — every existing call site across dashboard + agent
keeps working with no changes. Verified with a full frontend build.
2026-07-27 20:17:26 +02:00

247 lines
10 KiB
JavaScript

// 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 light-DOM custom elements (`<hive-dialog>`,
// `<hive-toast>`) — no shadow root, so styling stays exactly where it
// already lived: the `.tc-*` classes in `modal.css`, imported globally by
// both packages' base stylesheets. Light DOM was the deliberate call for
// this first custom-element pilot (see the frontend components-split
// discussion on the forge issue tracker): it buys lifecycle
// encapsulation (`connectedCallback`/`disconnectedCallback` own the
// keydown listener / auto-dismiss timer instead of hand-rolled
// add/removeEventListener bookkeeping in a closure) without paying the
// shadow-DOM cost of re-importing every shared stylesheet per instance.
// If a later pilot wants real style encapsulation, shadow DOM is a
// drop-in upgrade to these same two classes — the public functional API
// below (`openDialog`/`themedConfirm`/`themedPrompt`/`themedToast`)
// wouldn't need to change either way. (Pilot for the frontend
// components-split proposal — see the forge issue tracker for context.)
//
// `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';
// <hive-dialog> — 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.
class HiveDialog extends HTMLElement {
connectedCallback() {
const {
title = '', message = '', content = null,
buttons = [{ label: 'ok', value: true }],
danger = false, dismissable = true,
} = this._opts || {};
this.className = 'tc-backdrop';
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) => {
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)));
this.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, …). `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 | { [name]: bool }>. `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<string | null>. Themed replacement for window.prompt(): a
// resizable <textarea> dialog that resolves to the entered string on confirm,
// or null on cancel. Chat-box key behaviour: **Enter submits**, **Shift+Enter
// inserts a newline** — so short answers are one keystroke while multi-line
// reasons (e.g. an approval deny note) are still possible. Escape cancels
// (via openDialog).
export function themedPrompt(opts = {}) {
const {
title = '', message = '', label = '', placeholder = '', value = '',
confirmLabel = 'ok', cancelLabel = 'cancel',
} = opts;
const input = el('textarea', { class: 'tc-input tc-textarea', rows: '3', placeholder });
if (value) input.value = value;
// Enter submits (clicks the confirm button mounted by openDialog);
// Shift+Enter falls through to the textarea's default newline insert.
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
input.closest('.tc-box')?.querySelector('.tc-confirm')?.click();
}
});
const content = el('div', { class: 'tc-promptfield' },
label ? el('label', { class: 'tc-promptlabel' }, label) : null,
input);
const result = openDialog({
title,
message,
content,
buttons: [
{ label: cancelLabel, value: '__cancel__', class: 'tc-cancel' },
{ label: confirmLabel, value: '__ok__', class: 'tc-confirm', autofocus: true },
],
}).then((v) => (v === '__ok__' ? input.value : null));
// Prefer focusing the field over the OK button once the dialog has mounted.
setTimeout(() => input.focus(), 0);
return result;
}
// <hive-toast> — one transient notification entry. Owns its own
// auto-dismiss timer via connectedCallback/disconnectedCallback (cleared
// on removal so a toast dismissed early by click doesn't leave a stray
// timer), rather than the closure-captured timer handle the pre-custom-
// element version used. Callers set `._message`/`._opts` before append —
// same reason as `<hive-dialog>` above.
class HiveToast extends HTMLElement {
connectedCallback() {
const { type = 'info', duration } = this._opts || {};
const ms = duration != null ? duration : (type === 'error' ? 8000 : 4000);
this.className = 'tc-toast tc-toast-' + type;
this.setAttribute('role', type === 'error' ? 'alert' : 'status');
this.textContent = this._message || '';
let removed = false;
this._remove = () => {
if (removed) return;
removed = true;
this.classList.add('tc-toast-out');
setTimeout(() => this.remove(), 200);
};
this.addEventListener('click', this._remove);
if (ms > 0) this._timer = setTimeout(this._remove, ms);
}
disconnectedCallback() {
clearTimeout(this._timer);
}
}
customElements.define('hive-toast', HiveToast);
// themedToast(message, { type, duration }) — non-blocking transient
// notification; a lighter alternative to a modal for feedback that needs no
// decision (errors, validation, status). Stacks in a fixed top-right
// container, auto-dismisses after `duration` ms (errors linger longer), and
// can be clicked to dismiss early. `type` ∈ {'info','error','ok'}.
export function themedToast(message, opts = {}) {
let container = document.getElementById('tc-toasts');
if (!container) {
container = el('div', { id: 'tc-toasts', class: 'tc-toasts' });
document.body.append(container);
}
const toast = document.createElement('hive-toast');
toast._message = message;
toast._opts = opts;
container.append(toast);
return toast;
}