Pure `nix fmt` output from the commit before this one — no hand edits. 203 files: 52 md, 42 tsx, 32 js, 32 css, 21 ts, 13 html, 8 json, 3 mjs. Reproduce with `nix develop -c nix fmt` on the parent commit; the result should be byte-identical to this tree. None of the 13 `.prettierignore` entries appears here — verified by intersecting the changed-file list against the ignore file, with a control proving the intersection finds a match when one exists.
201 lines
7.4 KiB
JavaScript
201 lines
7.4 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;
|
|
}
|