hyperhive/frontend/packages/shared/src/modal.js
iris edf1c5a17f 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).
2026-07-31 21:53:28 +02:00

163 lines
7.2 KiB
JavaScript

// 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.
//
// 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.
//
// 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-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
// 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, …) — built by the caller
// with `el()` and appended into the dialog's shadow root once mounted,
// same as any other node (a JS-created element isn't bound to a
// document/shadow-root until it's actually appended somewhere).
// `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);
});
}
// themedConfirm({ title, message, danger, confirmLabel, cancelLabel, checkboxes })
// → Promise<null | { [name]: bool }>. `null` = cancelled; otherwise an
// object of the checkbox states keyed by `name` (`{}` when there are none).
// Example:
// const r = await themedConfirm({ message: `stop ${n}?`, danger: true,
// confirmLabel: '■ stop', checkboxes: [{ name: 'graceful', label: '…' }] });
// if (!r) return; // cancelled
// doStop(r.graceful);
export function themedConfirm(opts = {}) {
const {
title = '', message = '', danger = false,
confirmLabel = 'confirm', cancelLabel = 'cancel', checkboxes = [],
} = opts;
const boxes = checkboxes.map((cb) => {
const input = el('input', { type: 'checkbox', class: 'check', name: cb.name });
if (cb.checked) input.checked = true;
const row = el('label', { class: 'checkrow' },
input, el('span', {}, cb.label || cb.name));
return { input, row };
});
const content = boxes.length
? el('div', { class: 'checks' }, ...boxes.map((b) => b.row))
: null;
return openDialog({
title,
message,
content,
danger,
buttons: [
{ label: cancelLabel, value: null, class: 'cancel', autofocus: danger },
{ label: confirmLabel, value: 'confirm', danger, class: 'confirm', autofocus: !danger },
],
}).then((v) => {
if (v !== 'confirm') return null;
const out = {};
for (let i = 0; i < boxes.length; i++) out[checkboxes[i].name] = boxes[i].input.checked;
return out;
});
}
// themedPrompt({ title, message, label, placeholder, value, confirmLabel, cancelLabel })
// → Promise<string | null>. Themed replacement for window.prompt(): a
// resizable <textarea> dialog that resolves to the entered string on confirm,
// or null on cancel. Chat-box key behaviour: **Enter submits**, **Shift+Enter
// inserts a newline** — so short answers are one keystroke while multi-line
// reasons (e.g. an approval deny note) are still possible. Escape cancels
// (via openDialog).
export function themedPrompt(opts = {}) {
const {
title = '', message = '', label = '', placeholder = '', value = '',
confirmLabel = 'ok', cancelLabel = 'cancel',
} = opts;
const input = el('textarea', { class: 'input textarea', rows: '3', placeholder });
if (value) input.value = value;
// Enter submits (clicks the confirm button mounted by openDialog);
// Shift+Enter falls through to the textarea's default newline insert.
// `closest()`/`querySelector()` stay within the shadow tree the input
// lives in, so this resolves to the dialog's own `.box`/confirm button
// without leaking across instances. The confirm button is selected by
// its `variant` attribute now (hive-btn.js), not a CSS class.
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
input.closest('.box')?.querySelector('[variant="confirm"]')?.click();
}
});
const content = el('div', { class: 'promptfield' },
label ? el('label', { class: 'promptlabel' }, label) : null,
input);
const result = openDialog({
title,
message,
content,
buttons: [
{ label: cancelLabel, value: '__cancel__', class: 'cancel' },
{ label: confirmLabel, value: '__ok__', class: 'confirm', autofocus: true },
],
}).then((v) => (v === '__ok__' ? input.value : null));
// Prefer focusing the field over the OK button once the dialog has mounted.
setTimeout(() => input.focus(), 0);
return result;
}
// 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'}.
// The stack container is a plain positioning wrapper (not itself a
// component — no theming, no encapsulation need), so it's styled with a
// one-off inline style rather than a stylesheet.
export function themedToast(message, opts = {}) {
let container = document.getElementById('tc-toasts');
if (!container) {
container = document.createElement('div');
container.id = 'tc-toasts';
Object.assign(container.style, {
position: 'fixed',
top: '1em',
right: '1em',
zIndex: '1100',
display: 'flex',
flexDirection: 'column',
gap: '0.5em',
maxWidth: 'min(28em, 92vw)',
pointerEvents: 'none',
});
document.body.append(container);
}
const toast = document.createElement('hive-toast');
toast._message = message;
toast._opts = opts;
container.append(toast);
return toast;
}