The swarm lives in the swarm UI; this tab lists the hive's agents. The tab is 4G3NTS at #agents, its pane and count pill follow, and the module that renders the containers is agents.js. Comments and docs that named SW4RM or swarm.js follow.
131 lines
4.9 KiB
JavaScript
131 lines
4.9 KiB
JavaScript
// hive-menu.js — <hive-menu>, the generic dropdown-menu shadow-DOM custom
|
|
// element behind any "click a trigger, get a positioned dropdown" UI
|
|
// (currently just <hive-agent-menu>'s per-agent "⋮" menu). Owns
|
|
// open/close state, trigger + dropdown positioning, singleton
|
|
// close-on-open coordination across every `<hive-menu>` instance in the
|
|
// app, and the document-level outside-click/Escape listeners. Doesn't
|
|
// know or care what's inside the trigger/dropdown — opaque DOM nodes the
|
|
// caller hands over via `._opts = { trigger, content }` before appending
|
|
// (same `._opts`-before-append convention `<hive-dialog>` uses).
|
|
//
|
|
// `trigger`/`content` are appended as *light-DOM* children, projected
|
|
// into the shadow template via named `<slot>`s rather than moved into
|
|
// the shadow root. Load-bearing: it keeps the caller's own class-scoped
|
|
// styling applying to the nodes it built — a `<style>` only styles
|
|
// elements within the same tree it's part of, and slotted content keeps
|
|
// the tree membership of wherever it's actually a light-DOM child, so
|
|
// re-parenting into this element's own shadow root would make the
|
|
// caller's classes go dark.
|
|
//
|
|
// `openInstances` tracks every open instance app-wide (a deliberate
|
|
// widening from the old per-agent-menu-only coordination) so
|
|
// `closeAll()` can close each via its own `.close()`. `closeAllMenus()`
|
|
// force-closes everything before tearing down DOM one might anchor off
|
|
// of (e.g. agents.js before replacing the container tree).
|
|
//
|
|
// The document-level listeners below, registered once: shadow-DOM
|
|
// retargeting means `e.target` for a click inside a shadow tree gets
|
|
// retargeted past that instance's host, so `e.composedPath()` (includes
|
|
// slotted content) tests "did this land inside any open menu" — same
|
|
// reasoning as `<hive-dialog>`'s backdrop-click check.
|
|
|
|
import { el } from "../dom.js";
|
|
import { attachShadowCss } from "../shadow-css.js";
|
|
import hiveMenuCss from "./hive-menu.css";
|
|
|
|
const openInstances = new Set();
|
|
|
|
function closeAll() {
|
|
for (const inst of [...openInstances]) inst.close();
|
|
}
|
|
|
|
// Exported for any caller that needs to force-close every open menu —
|
|
// see module header.
|
|
export function closeAllMenus() {
|
|
closeAll();
|
|
}
|
|
|
|
// Close on any click outside every currently-open instance.
|
|
document.addEventListener(
|
|
"click",
|
|
(e) => {
|
|
if (!openInstances.size) return;
|
|
const path = e.composedPath();
|
|
for (const inst of [...openInstances]) {
|
|
if (!path.includes(inst)) inst.close();
|
|
}
|
|
},
|
|
true,
|
|
);
|
|
// Close on Escape. stopImmediatePropagation so a caller's own
|
|
// selection-clear Escape handler (e.g. agents.js's) doesn't also fire
|
|
// while a menu is open.
|
|
document.addEventListener(
|
|
"keydown",
|
|
(e) => {
|
|
if (e.key === "Escape" && openInstances.size) {
|
|
closeAll();
|
|
e.stopImmediatePropagation();
|
|
}
|
|
},
|
|
true,
|
|
);
|
|
|
|
class HiveMenu extends HTMLElement {
|
|
connectedCallback() {
|
|
// A custom element's connectedCallback fires again on a same-document
|
|
// *move* (insertBefore/append repositioning an already-connected node
|
|
// runs the removal + insertion steps for its whole subtree), not just
|
|
// on a fresh mount — agents.js's row-fingerprint cache reuses/reorders
|
|
// existing <li> subtrees, which can move an already-initialised
|
|
// <hive-menu> without ever detaching it from the document. Guard
|
|
// against re-running setup (attachShadow throws if the host already
|
|
// has a shadow root) on that reconnect.
|
|
if (this.shadowRoot) return;
|
|
|
|
const { trigger, content } = this._opts || {};
|
|
const root = attachShadowCss(this, hiveMenuCss);
|
|
|
|
// Project the caller's opaque nodes via named slots — see module
|
|
// header for why this has to be slotting, not a shadow-root append.
|
|
trigger.slot = "trigger";
|
|
content.slot = "content";
|
|
this.append(trigger, content);
|
|
|
|
const dropdown = el("div", { class: "menu-dropdown", hidden: true });
|
|
dropdown.append(el("slot", { name: "content" }));
|
|
root.append(el("slot", { name: "trigger" }), dropdown);
|
|
|
|
this._trigger = trigger;
|
|
this._dropdown = dropdown;
|
|
|
|
trigger.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
const wasOpen = openInstances.has(this);
|
|
closeAll();
|
|
if (!wasOpen) this.open();
|
|
});
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
// Guards against a stale entry if the element is removed from the DOM
|
|
// (row re-render, tab switch) while its dropdown was still open.
|
|
openInstances.delete(this);
|
|
}
|
|
|
|
open() {
|
|
this._dropdown.hidden = false;
|
|
this._trigger.setAttribute("aria-expanded", "true");
|
|
this.style.setProperty("--menu-btn-opacity", "1");
|
|
openInstances.add(this);
|
|
}
|
|
|
|
close() {
|
|
if (!openInstances.has(this)) return;
|
|
this._dropdown.hidden = true;
|
|
this._trigger.setAttribute("aria-expanded", "false");
|
|
this.style.removeProperty("--menu-btn-opacity");
|
|
openInstances.delete(this);
|
|
}
|
|
}
|
|
customElements.define("hive-menu", HiveMenu);
|