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:
iris 2026-07-31 21:29:45 +02:00
commit edf1c5a17f
8 changed files with 172 additions and 169 deletions

View file

@ -0,0 +1,73 @@
/* hive-dialog.css scoped stylesheet for the <hive-dialog> shadow-DOM
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 {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 1em;
background: color-mix(in srgb, var(--crust) 60%, transparent);
-webkit-backdrop-filter: blur(2px);
backdrop-filter: blur(2px);
}
.box {
background: var(--bg-elev);
border: 1px solid var(--purple-dim);
box-shadow: 0 8px 40px -8px var(--crust);
padding: 1.2em 1.4em;
max-width: min(32em, 92vw);
display: flex;
flex-direction: column;
gap: 0.9em;
}
.title {
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--subtext0);
}
.message { color: var(--fg); line-height: 1.45; }
.checks { display: flex; flex-direction: column; gap: 0.4em; }
.checkrow {
display: flex;
align-items: flex-start;
gap: 0.5em;
cursor: pointer;
color: var(--subtext0);
font-size: 0.92em;
line-height: 1.35;
}
.checkrow .check { margin-top: 0.2em; flex: 0 0 auto; }
.actions {
display: flex;
justify-content: flex-end;
gap: 0.6em;
margin-top: 0.2em;
}
.promptfield { display: flex; flex-direction: column; gap: 0.4em; }
.promptlabel { color: var(--subtext0); font-size: 0.9em; }
.input {
font-family: inherit;
font-size: 1em;
width: 100%;
box-sizing: border-box;
background: var(--crust);
color: var(--fg);
border: 1px solid var(--purple-dim);
padding: 0.4em 0.6em;
}
.input:focus { outline: 1px solid var(--green); }
.textarea {
resize: vertical;
min-height: 4.5em;
line-height: 1.4;
white-space: pre-wrap;
}

View 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);