Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/frontend/packages/shared/src/hive-menu/hive-menu.js
iris e455964cb3 dashboard: the SW4RM tab is the agents tab
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.
2026-10-15 09:50:49 +02:00

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);