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; }