frontend: split hive-agent-menu's generic dropdown mechanics into a shared hive-menu component

<hive-agent-menu> bundled two concerns: the agent-specific trigger/item
list, and generic "click a trigger, get a positioned dropdown" mechanics
(shadow attach, open/close, singleton close-on-open coordination,
outside-click/Escape handling). Pulled the latter out into a new
@hive/shared/hive-menu.js (<hive-menu>), following the established
per-component-directory + ._opts-before-append shadow-DOM pattern
(<hive-dialog>). <hive-agent-menu> now just builds the "⋮" trigger and
the action list and hands them to an internal <hive-menu> instance.

<hive-menu> takes ownership of every <hive-menu> instance in the app for
singleton coordination (closeAllMenus, renamed from closeAllAgentMenus)
— a deliberate widening from the old per-agent-menu-only tracking, since
the mechanism was never agent-specific to begin with.

The one subtlety worth spelling out: <hive-menu> projects the caller's
opaque trigger/content nodes via named <slot>s rather than moving them
into its own shadow root. That's load-bearing, not cosmetic — if it
re-parented them into its own shadow tree instead, <hive-agent-menu>'s
own classes (.agent-menu-btn, .agent-menu-item, ...) would stop applying,
since a <style> only styles elements within the same shadow tree/document
it's part of, and only slotting (not re-parenting) keeps the caller's
nodes in the caller's own tree for styling purposes. That in turn made
<hive-agent-menu>'s own shadow root redundant once it wasn't the thing
positioning or owning open/close state anymore, so it's dropped in favor
of a plain light-DOM element styled by dashboard.css (already the one
page it renders on) — hive-agent-menu.css is gone, its rules folded into
dashboard.css's per-agent-menu section, minus the positioning rules that
moved into hive-menu.css as the new generic `.menu-dropdown` wrapper.

Verified with a standalone esbuild bundle + a cached nix chromium driven
over raw CDP (no puppeteer/playwright/python3 available): hover-reveal
opacity, dropdown open/close/positioning, outside-click/Escape dismissal,
and cross-instance singleton coordination all behave identically to
before the split.
This commit is contained in:
iris 2026-07-31 22:53:36 +02:00 committed by mara
commit 395c9a6df2
7 changed files with 272 additions and 203 deletions

View file

@ -0,0 +1,32 @@
/* hive-menu.css scoped stylesheet for the generic <hive-menu>
shadow-DOM custom element (hive-menu.js). Loaded as raw text at build
time (esbuild's `text` loader) and appended as a <style> element
inside the shadow root see @hive/shared/shadow-css.js's header
comment for why a plain <style> tag and not adoptedStyleSheets.
`:host` carries only the one truly generic requirement: it's the
containing block for the shadow tree's absolutely-positioned dropdown
a shadow host is the containing block for its own shadow tree's
positioned descendants, exactly like any other positioned ancestor in
the flat tree. Any *layout* role the host plays in a particular
caller's own flex row (e.g. <hive-agent-menu>'s placement in
`.container-row`) is caller-specific and lives in the caller's own
stylesheet instead, not here.
`.menu-dropdown` is the generic positioning box wrapping the caller's
opaque `content` node (projected in via `<slot name="content">` see
hive-menu.js). Only position/z-index/visibility live here; the
button/item-row *visual* styling (colors, fonts, hover states) is
presentational content the caller's own trigger/content nodes carry,
so it lives in the caller's own stylesheet `<hive-menu>` never sees
those class names, only the opaque nodes handed to it. */
:host {
position: relative;
}
.menu-dropdown {
position: absolute;
right: 0;
top: calc(100% + 2px);
z-index: 50;
}

View file

@ -0,0 +1,113 @@
// 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. swarm.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. swarm.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() {
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);