hyperhive/frontend/packages/shared/src/modal.js
iris a588ed42ce frontend: add <hive-btn> component, use it for dialog buttons
mara: 'wait the common styles is literally just the button stuff? pls
make a button component now as part of this pr and replace the usage
in the modal. replacing all usages and finally removing the btn styles
from common css is a follow up then.'

<hive-btn> (hive-btn.js) is a customized built-in <button is="hive-btn">
with its own shadow root -- extending HTMLButtonElement keeps every
native button behaviour (click/keyboard activation, :disabled, form
participation) instead of re-implementing it on a generic wrapper.
Shadow root holds only an adopted stylesheet + a <slot>, so the
button's light-DOM content (label, or asyncBtn's swapped-in spinner
span) renders through unchanged -- slotted content stays styled by the
light-DOM cascade, so the global .spinner class still applies.
Variants (cancel/confirm/danger) are a 'variant' attribute, not a CSS
class, since they're a semantic prop of the component.

Customized built-ins aren't supported in Safari/WebKit -- fine here,
the project targets recent Firefox only (same reasoning as the
original custom-elements pilot).

Wired into modal.js: HiveDialog's buttons now render as
<button is="hive-btn" variant="...">, replacing the old
component-common.css .btn copy -- deleted that file + component-
styles.js entirely (their sole purpose was giving dialog buttons a
.btn look, which hive-btn now owns properly). dom.js's el() gained
 support so it can create customized built-ins the same way it
creates everything else. hive-dialog.css dropped the now-dead
.cancel/.confirm/.confirm.danger rules.

Per mara's scoping: NOT touching the other .btn consumers across the
app (dashboard/agent submit buttons, form() helper, etc.) or removing
.btn from dashboard/common.css / agent/agent.css in this PR -- that
migration + cleanup is an explicit follow-up.

Verified with a full frontend build (grepped bundled JS for hive-btn/
variant to confirm it inlines); nix fmt clean.
2026-07-29 12:55:54 +02:00

293 lines
13 KiB
JavaScript

// modal.js — reusable themed modal/dialog component, 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 two shadow-DOM custom elements (`<hive-dialog>`,
// `<hive-toast>`), plus `<hive-btn>` (hive-btn.js) 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: shadow-DOM-vs-light-DOM tradeoffs live atop
// the `<hive-dialog>`/`<hive-toast>` classes below, the customized-
// built-in choice lives in hive-btn.js). Other `.btn` consumers across
// the app stay on the light-DOM `.btn` class for now — migrating them
// is a separate follow-up.
//
// `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.
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('button', {
type: 'button', is: 'hive-btn',
...(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);
// 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;
}
// <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
// 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;
}