From 841c697301998ad6e57ad0f775df016a19a358ef Mon Sep 17 00:00:00 2001 From: iris Date: Mon, 27 Jul 2026 19:17:13 +0200 Subject: [PATCH] frontend: convert themed dialogs to light-DOM custom elements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. 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. 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. --- frontend/packages/shared/src/modal.css | 13 ++- frontend/packages/shared/src/modal.js | 138 +++++++++++++++++-------- 2 files changed, 103 insertions(+), 48 deletions(-) diff --git a/frontend/packages/shared/src/modal.css b/frontend/packages/shared/src/modal.css index 6478c9d4..aad63efd 100644 --- a/frontend/packages/shared/src/modal.css +++ b/frontend/packages/shared/src/modal.css @@ -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 `` / `` 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 diff --git a/frontend/packages/shared/src/modal.js b/frontend/packages/shared/src/modal.js index d24d7189..1e860f08 100644 --- a/frontend/packages/shared/src/modal.js +++ b/frontend/packages/shared/src/modal.js @@ -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 (``, +// ``) — 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; +// — 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; } +// — 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 `` 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; }