frontend: one component = one dir for hive-btn/hive-dialog/hive-toast
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 <hive-btn> 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).
This commit is contained in:
parent
44651544a8
commit
edf1c5a17f
8 changed files with 172 additions and 169 deletions
|
|
@ -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);
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
/* hive-dialog.css — scoped stylesheet for the <hive-dialog> 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 <hive-btn> elements with their own shadow root. */
|
||||
|
||||
:host {
|
||||
93
frontend/packages/shared/src/hive-dialog/hive-dialog.js
Normal file
93
frontend/packages/shared/src/hive-dialog/hive-dialog.js
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
// hive-dialog.js — <hive-dialog>, 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
|
||||
// `<hive-btn>`) since the dialog's own buttons are `<hive-btn>` elements.
|
||||
|
||||
import { el } from '../dom.js';
|
||||
import { attachShadowCss } from '../shadow-css.js';
|
||||
import '../hive-btn/hive-btn.js'; // registers <hive-btn> — 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);
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
/* hive-toast.css — scoped stylesheet for the <hive-toast> 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;
|
||||
37
frontend/packages/shared/src/hive-toast/hive-toast.js
Normal file
37
frontend/packages/shared/src/hive-toast/hive-toast.js
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
// hive-toast.js — <hive-toast>, 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 `<hive-dialog>`. 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 `<slot>`, 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);
|
||||
|
|
@ -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 (`<hive-dialog>`,
|
||||
// `<hive-toast>`, and `<hive-btn>` 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 (`<hive-dialog>`, `<hive-toast>`,
|
||||
// `<hive-btn>`) 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 <hive-btn> — 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;
|
||||
}
|
||||
|
||||
// <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. 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 <hive-dialog> — side-effect import
|
||||
import './hive-toast/hive-toast.js'; // registers <hive-toast> — 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;
|
||||
}
|
||||
|
||||
// <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. 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 `<slot>`, 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
|
||||
|
|
|
|||
15
frontend/packages/shared/src/shadow-css.js
Normal file
15
frontend/packages/shared/src/shadow-css.js
Normal file
|
|
@ -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
|
||||
// `<button>`.
|
||||
export function attachShadowCss(host, cssText, shadowInit = {}) {
|
||||
const root = host.attachShadow({ mode: 'open', ...shadowInit });
|
||||
const sheet = new CSSStyleSheet();
|
||||
sheet.replaceSync(cssText);
|
||||
root.adoptedStyleSheets = [sheet];
|
||||
return root;
|
||||
}
|
||||
Loading…
Reference in a new issue