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.
This commit is contained in:
iris 2026-07-27 19:17:13 +02:00 committed by mara
commit 841c697301
2 changed files with 103 additions and 48 deletions

View file

@ -1,9 +1,12 @@
/* modal.css themed dialog component styles.
Pairs with modal.js (themedConfirm / themedPrompt / themedToast). The
dialogs are raised from the shared `bindAsyncForms` data-async /
data-confirm handler (forms.js), used by both the dashboard and the
per-agent UI, so both packages' base stylesheets (common.css / agent.css)
@import this to keep the styles present wherever a dialog can fire.
Pairs with modal.js (themedConfirm / themedPrompt / themedToast),
whose `<hive-dialog>` / `<hive-toast>` custom elements are styled
entirely by class selectors here (light DOM, no shadow root see the
design note atop modal.js). The dialogs are raised from the shared
`bindAsyncForms` data-async / data-confirm handler (forms.js), used by
both the dashboard and the per-agent UI, so both packages' base
stylesheets (common.css / agent.css) @import this to keep the styles
present wherever a dialog can fire.
(Previously these rules lived in dashboard.css, so dialogs rendered
unstyled on standalone dashboard pages such as /core, and the per-agent
UI never had them at all a themed-dialogs consistency fix moved them

View file

@ -4,45 +4,62 @@
// 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. Styling lives in `modal.css` under the
// `.tc-*` classes (imported by both packages' base stylesheets).
// checkboxes built on top of it.
import { el } from './dom.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;
// <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 || {};
return new Promise((resolve) => {
this.className = 'tc-backdrop';
let settled = false;
function done(value) {
const done = (value) => {
if (settled) return;
settled = true;
document.removeEventListener('keydown', onKey, true);
backdrop.remove();
resolve(value);
}
function onKey(e) {
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', {
@ -68,18 +85,36 @@ export function openDialog(opts = {}) {
messageEl,
content || null,
el('div', { class: 'tc-actions' }, ...btnEls.map((b) => b.btn)));
this.append(box);
const backdrop = el('div', { class: 'tc-backdrop' }, box);
backdrop.addEventListener('click', (e) => {
if (dismissable && e.target === backdrop) done(null);
this.addEventListener('click', (e) => {
if (dismissable && e.target === this) 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();
}
}
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);
});
}
@ -164,32 +199,49 @@ export function themedPrompt(opts = {}) {
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 = {}) {
const { type = 'info', duration } = opts;
const ms = duration != null ? duration : (type === 'error' ? 8000 : 4000);
let container = document.getElementById('tc-toasts');
if (!container) {
container = el('div', { id: 'tc-toasts', class: 'tc-toasts' });
document.body.append(container);
}
const toast = el('div', {
class: 'tc-toast tc-toast-' + type,
role: type === 'error' ? 'alert' : 'status',
}, message);
let removed = false;
const remove = () => {
if (removed) return;
removed = true;
toast.classList.add('tc-toast-out');
setTimeout(() => toast.remove(), 200);
};
toast.addEventListener('click', remove);
const toast = document.createElement('hive-toast');
toast._message = message;
toast._opts = opts;
container.append(toast);
if (ms > 0) setTimeout(remove, ms);
return toast;
}