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.
124 lines
4.9 KiB
JavaScript
124 lines
4.9 KiB
JavaScript
// 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) => {
|
|
// `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);
|