From edf1c5a17f02d9c311fc14335b1d8bf436288e1f Mon Sep 17 00:00:00 2001 From: iris Date: Fri, 31 Jul 2026 21:29:45 +0200 Subject: [PATCH] frontend: one component = one dir for hive-btn/hive-dialog/hive-toast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splits the shadow-DOM custom elements out of the flat shared/src layout into per-component directories: hive-btn/hive-btn.{js,css} hive-dialog/hive-dialog.{js,css} hive-toast/hive-toast.{js,css} hive-dialog and hive-toast were previously defined inline inside modal.js alongside the openDialog/themedConfirm/themedPrompt/themedToast orchestration helpers; modal.js is now a slim entry point that imports the two component modules for their customElements.define side effect and keeps only the orchestration functions, which aren't components themselves. hive-dialog.js now imports hive-btn.js directly (it's the actual consumer that creates elements), instead of modal.js importing it on hive-dialog's behalf. Pulled the identical shadow-root-plus-adopted-stylesheet boilerplate (previously duplicated between modal.js's local attachShadow() and hive-btn.js's inline version) into a shared shadow-css.js helper, attachShadowCss(host, cssText, shadowInit), used by all three components. Behaviorally identical — same attachShadow() options per component, just deduplicated. No external import paths changed: every consumer only ever imported the package-level @hive/shared/modal.js entry point, never the component internals directly, so this is fully internal to the shared package. Verified with a full frontend build (dashboard + agent bundles). --- .../shared/src/{ => hive-btn}/hive-btn.css | 0 .../shared/src/{ => hive-btn}/hive-btn.js | 6 +- .../src/{ => hive-dialog}/hive-dialog.css | 10 +- .../shared/src/hive-dialog/hive-dialog.js | 93 ++++++++++ .../src/{ => hive-toast}/hive-toast.css | 8 +- .../shared/src/hive-toast/hive-toast.js | 37 ++++ frontend/packages/shared/src/modal.js | 172 ++---------------- frontend/packages/shared/src/shadow-css.js | 15 ++ 8 files changed, 172 insertions(+), 169 deletions(-) rename frontend/packages/shared/src/{ => hive-btn}/hive-btn.css (100%) rename frontend/packages/shared/src/{ => hive-btn}/hive-btn.js (91%) rename frontend/packages/shared/src/{ => hive-dialog}/hive-dialog.css (81%) create mode 100644 frontend/packages/shared/src/hive-dialog/hive-dialog.js rename frontend/packages/shared/src/{ => hive-toast}/hive-toast.css (68%) create mode 100644 frontend/packages/shared/src/hive-toast/hive-toast.js create mode 100644 frontend/packages/shared/src/shadow-css.js diff --git a/frontend/packages/shared/src/hive-btn.css b/frontend/packages/shared/src/hive-btn/hive-btn.css similarity index 100% rename from frontend/packages/shared/src/hive-btn.css rename to frontend/packages/shared/src/hive-btn/hive-btn.css diff --git a/frontend/packages/shared/src/hive-btn.js b/frontend/packages/shared/src/hive-btn/hive-btn.js similarity index 91% rename from frontend/packages/shared/src/hive-btn.js rename to frontend/packages/shared/src/hive-btn/hive-btn.js index 18de2977..35755755 100644 --- a/frontend/packages/shared/src/hive-btn.js +++ b/frontend/packages/shared/src/hive-btn/hive-btn.js @@ -20,6 +20,7 @@ // `disabled`/`type` are likewise plain attributes on the host, mirrored // onto the inner button on connect and on every attribute change. +import { attachShadowCss } from '../shadow-css.js'; import hiveBtnCss from './hive-btn.css'; const OBSERVED = ['disabled', 'type']; @@ -34,10 +35,7 @@ class HiveBtn extends HTMLElement { this._sync(); return; // already built (e.g. re-parenting re-fires connectedCallback) } - const root = this.attachShadow({ mode: 'open', delegatesFocus: true }); - const sheet = new CSSStyleSheet(); - sheet.replaceSync(hiveBtnCss); - root.adoptedStyleSheets = [sheet]; + const root = attachShadowCss(this, hiveBtnCss, { delegatesFocus: true }); const btn = document.createElement('button'); btn.append(document.createElement('slot')); root.append(btn); diff --git a/frontend/packages/shared/src/hive-dialog.css b/frontend/packages/shared/src/hive-dialog/hive-dialog.css similarity index 81% rename from frontend/packages/shared/src/hive-dialog.css rename to frontend/packages/shared/src/hive-dialog/hive-dialog.css index ec760ddb..930cacb0 100644 --- a/frontend/packages/shared/src/hive-dialog.css +++ b/frontend/packages/shared/src/hive-dialog/hive-dialog.css @@ -1,9 +1,9 @@ /* hive-dialog.css — scoped stylesheet for the shadow-DOM - custom element (modal.js). Loaded as raw text at build time (esbuild's - `text` loader) and adopted via `adoptedStyleSheets`. `:host` styles the - element itself, which *is* the fixed-position backdrop — no separate - light-DOM backdrop div. Button styling (base look + cancel/confirm/ - danger variants) lives in hive-btn.css, not here — the dialog's + custom element (hive-dialog.js). Loaded as raw text at build time + (esbuild's `text` loader) and adopted via `adoptedStyleSheets`. `:host` + styles the element itself, which *is* the fixed-position backdrop — no + separate light-DOM backdrop div. Button styling (base look + cancel/ + confirm/danger variants) lives in hive-btn.css, not here — the dialog's buttons are elements with their own shadow root. */ :host { diff --git a/frontend/packages/shared/src/hive-dialog/hive-dialog.js b/frontend/packages/shared/src/hive-dialog/hive-dialog.js new file mode 100644 index 00000000..790b2ba3 --- /dev/null +++ b/frontend/packages/shared/src/hive-dialog/hive-dialog.js @@ -0,0 +1,93 @@ +// hive-dialog.js — , the backdrop + box shadow-DOM custom +// element behind `openDialog` (modal.js). 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. +// +// Imports `../hive-btn/hive-btn.js` for its side effect (registers +// ``) since the dialog's own buttons are `` elements. + +import { el } from '../dom.js'; +import { attachShadowCss } from '../shadow-css.js'; +import '../hive-btn/hive-btn.js'; // registers — side-effect import, no named export needed +import dialogCss from './hive-dialog.css'; + +class HiveDialog extends HTMLElement { + connectedCallback() { + const { + title = '', message = '', content = null, + buttons = [{ label: 'ok', value: true }], + danger = false, dismissable = true, + } = this._opts || {}; + + const root = attachShadowCss(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('hive-btn', { + type: 'button', + ...(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); diff --git a/frontend/packages/shared/src/hive-toast.css b/frontend/packages/shared/src/hive-toast/hive-toast.css similarity index 68% rename from frontend/packages/shared/src/hive-toast.css rename to frontend/packages/shared/src/hive-toast/hive-toast.css index c4a6d601..f13ba56f 100644 --- a/frontend/packages/shared/src/hive-toast.css +++ b/frontend/packages/shared/src/hive-toast/hive-toast.css @@ -1,8 +1,8 @@ /* hive-toast.css — scoped stylesheet for the shadow-DOM - custom element (modal.js). Loaded as raw text at build time (esbuild's - `text` loader) and adopted via `adoptedStyleSheets`. `:host` styles the - element itself, which *is* the visible toast box; `:host(.error)` etc. - switch on a plain class the host element carries (set in JS). */ + custom element (hive-toast.js). Loaded as raw text at build time + (esbuild's `text` loader) and adopted via `adoptedStyleSheets`. `:host` + styles the element itself, which *is* the visible toast box; `:host(.error)` + etc. switch on a plain class the host element carries (set in JS). */ :host { display: block; diff --git a/frontend/packages/shared/src/hive-toast/hive-toast.js b/frontend/packages/shared/src/hive-toast/hive-toast.js new file mode 100644 index 00000000..1d9bff31 --- /dev/null +++ b/frontend/packages/shared/src/hive-toast/hive-toast.js @@ -0,0 +1,37 @@ +// hive-toast.js — , one transient notification entry +// (constructed by `themedToast` in modal.js). 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 a closure-captured timer handle. Callers set `._message`/`._opts` +// before append — same reason as ``. The element itself is +// the visible toast box (`:host` carries the styling, `:host(.error)` etc. +// switch on a plain class the host carries) — the message text renders +// directly in the shadow root rather than via a ``, since there's no +// external light-DOM content to project. + +import { attachShadowCss } from '../shadow-css.js'; +import toastCss from './hive-toast.css'; + +class HiveToast extends HTMLElement { + connectedCallback() { + const { type = 'info', duration } = this._opts || {}; + const ms = duration != null ? duration : (type === 'error' ? 8000 : 4000); + const root = attachShadowCss(this, toastCss); + this.classList.add(type); + this.setAttribute('role', type === 'error' ? 'alert' : 'status'); + root.textContent = this._message || ''; + let removed = false; + this._remove = () => { + if (removed) return; + removed = true; + this.classList.add('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); diff --git a/frontend/packages/shared/src/modal.js b/frontend/packages/shared/src/modal.js index ef0a7a52..5c85b45d 100644 --- a/frontend/packages/shared/src/modal.js +++ b/frontend/packages/shared/src/modal.js @@ -1,133 +1,27 @@ -// modal.js — reusable themed modal/dialog component, shared by the +// modal.js — reusable themed modal/dialog helpers, 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 three shadow-DOM custom elements (``, -// ``, and `` 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). Other `.btn` consumers across the app stay on the -// light-DOM `.btn` class for now — migrating them is a separate -// follow-up. +// The custom elements themselves (``, ``, +// ``) each live in their own directory (one component = one +// dir: `hive-dialog/`, `hive-toast/`, `hive-btn/`) — 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). +// This file is the orchestration layer on top: `openDialog` is the +// general primitive (any title/message/content + a row of buttons); +// `themedConfirm`/`themedPrompt`/`themedToast` are thin wrappers built on +// it. It's the single entry point external packages import +// (`@hive/shared/modal.js`) — the component internals aren't exported +// individually. // -// `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. +// Other `.btn` consumers across the app stay on the light-DOM `.btn` +// class for now — migrating them is a separate follow-up. 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('hive-btn', { - type: 'button', - ...(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) => { - // `e.target` is retargeted to `this` (the host) for ANY click that - // originated inside the shadow tree, once it bubbles out to a - // listener attached on the host itself — per spec, retargeting - // applies to every ancestor outside the shadow tree, and the host - // element's own light-DOM-side listeners count as outside. So - // `e.target === this` was true for every click inside `.box` too - // (title, message, a bare checkbox row with no button to consume - // it first), not just genuine backdrop clicks — dismissing the - // whole dialog on, say, a checkbox click. `composedPath()[0]` is - // the true original target, unaffected by retargeting: it's `this` - // only when nothing inside the shadow tree was actually under the - // cursor, i.e. a real backdrop click. - if (dismissable && e.composedPath()[0] === 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); +import './hive-dialog/hive-dialog.js'; // registers — side-effect import +import './hive-toast/hive-toast.js'; // registers — side-effect import // openDialog({ title, message, content, buttons, danger, dismissable }) // → Promise resolving to the clicked button's `value`, or `null` when the @@ -235,40 +129,6 @@ 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. The element itself is the visible -// toast box (`:host` carries the styling, `:host(.error)` etc. switch on -// a plain class the host carries) — the message text renders directly in -// the shadow root rather than via a ``, since there's no external -// light-DOM content to project. -class HiveToast extends HTMLElement { - connectedCallback() { - const { type = 'info', duration } = this._opts || {}; - const ms = duration != null ? duration : (type === 'error' ? 8000 : 4000); - const root = attachShadow(this, toastCss); - this.classList.add(type); - this.setAttribute('role', type === 'error' ? 'alert' : 'status'); - root.textContent = this._message || ''; - let removed = false; - this._remove = () => { - if (removed) return; - removed = true; - this.classList.add('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 diff --git a/frontend/packages/shared/src/shadow-css.js b/frontend/packages/shared/src/shadow-css.js new file mode 100644 index 00000000..7c49e710 --- /dev/null +++ b/frontend/packages/shared/src/shadow-css.js @@ -0,0 +1,15 @@ +// attachShadowCss(host, cssText, shadowInit) — attach an open shadow root +// to `host`, adopt `cssText` as a constructed stylesheet, and return the +// root. Shared by every shadow-DOM custom element (hive-dialog, hive-toast, +// hive-btn) so the CSSStyleSheet-adoption boilerplate lives in one place +// instead of being copy-pasted per component. `shadowInit` extends the +// `attachShadow()` options past `mode: 'open'` — e.g. `hive-btn` passes +// `{ delegatesFocus: true }` so `.focus()` on the host reaches the inner +// `