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:
parent
f7b19c9d56
commit
395c9a6df2
7 changed files with 272 additions and 203 deletions
|
|
@ -1,91 +0,0 @@
|
|||
/* hive-agent-menu.css — scoped stylesheet for the <hive-agent-menu>
|
||||
shadow-DOM custom element (hive-agent-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 exactly the rules the old light-DOM `.agent-menu`
|
||||
wrapper div carried (flex:none, position:relative, ...) — the host
|
||||
element now plays that structural role directly, positioned after
|
||||
.card-body in .container-row's flex row (see dashboard.css). Its
|
||||
position:relative anchors the shadow tree's absolute-positioned
|
||||
.agent-menu-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.
|
||||
|
||||
The hover-reveal opacity crosses the shadow boundary via the
|
||||
`--menu-btn-opacity` custom property (custom properties inherit
|
||||
through shadow boundaries): dashboard.css sets it to 1 on
|
||||
`.container-row:hover hive-agent-menu`; hive-agent-menu.js also sets
|
||||
it directly on the host's inline style while its own dropdown is
|
||||
open, since "am I open" is component-internal state a CSS selector
|
||||
out in the light DOM can't see. */
|
||||
|
||||
:host {
|
||||
flex: none;
|
||||
position: relative;
|
||||
align-self: flex-start;
|
||||
margin-top: 0.3em;
|
||||
}
|
||||
.agent-menu-btn {
|
||||
display: block;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--subtext0);
|
||||
font-size: 1.1em;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0.1em 0.4em;
|
||||
border-radius: 4px;
|
||||
opacity: var(--menu-btn-opacity, 0);
|
||||
transition: opacity 120ms, background 120ms, color 120ms;
|
||||
}
|
||||
.agent-menu-btn:hover,
|
||||
.agent-menu-btn:focus-visible {
|
||||
background: color-mix(in srgb, var(--purple) 10%, transparent);
|
||||
color: var(--purple);
|
||||
outline: none;
|
||||
}
|
||||
.agent-menu-btn:focus-visible {
|
||||
outline: 1px solid var(--purple);
|
||||
}
|
||||
.agent-menu-dropdown {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 2px);
|
||||
z-index: 50;
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--purple-dim);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0.3em 0;
|
||||
min-width: 10em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.agent-menu-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--fg);
|
||||
font-family: inherit;
|
||||
font-size: 0.88em;
|
||||
letter-spacing: 0.04em;
|
||||
text-align: left;
|
||||
padding: 0.4em 0.9em;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.agent-menu-item:hover {
|
||||
background: var(--border);
|
||||
color: var(--purple);
|
||||
}
|
||||
.agent-menu-sep {
|
||||
height: 1px;
|
||||
background: var(--purple-dim);
|
||||
margin: 0.3em 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
|
@ -1,66 +1,30 @@
|
|||
// hive-agent-menu.js — <hive-agent-menu>, the per-agent "⋮" context-menu
|
||||
// shadow-DOM custom element. One instance per agent card, appended after
|
||||
// custom element. One instance per agent card, appended after
|
||||
// .card-body in the container row's flex layout (swarm.js's
|
||||
// buildContainerLi via buildAgentMenu). The host element itself plays the
|
||||
// structural role the old light-DOM `.agent-menu` wrapper div played
|
||||
// (flex:none, position:relative — see hive-agent-menu.css's `:host` rule),
|
||||
// so its shadow tree's absolute-positioned dropdown anchors off it same as
|
||||
// before.
|
||||
// buildContainerLi via buildAgentMenu).
|
||||
//
|
||||
// Callers set `._opts = { c, forgeBase }` before appending — custom
|
||||
// elements can't take constructor args via `document.createElement`
|
||||
// (same convention `<hive-dialog>` uses, see modal.js).
|
||||
//
|
||||
// At most one instance's dropdown is open at a time. `openInstances`
|
||||
// tracks every instance currently open (a Set, though in practice it never
|
||||
// holds more than one) so `closeAll()` can close each one via its own
|
||||
// `.close()` instance method rather than reaching into another instance's
|
||||
// shadow internals from outside. `closeAllAgentMenus()` is exported for
|
||||
// swarm.js's buildAgentTree to call before it replaces the container tree
|
||||
// DOM (the previous dropdown element would otherwise be a stale
|
||||
// reference).
|
||||
//
|
||||
// The document-level click/keydown listeners below are registered once at
|
||||
// module scope (not per-instance, and not per open/close) — shadow-DOM
|
||||
// event retargeting means `e.target` for a click that lands inside one
|
||||
// instance's shadow tree, once the event bubbles past that instance's own
|
||||
// host, is retargeted to that host rather than the actual element clicked.
|
||||
// `e.composedPath()` sees the real, unretargeted path, so it's what's used
|
||||
// here to test "did this click land inside any open menu's shadow tree" —
|
||||
// same reasoning as `<hive-dialog>`'s backdrop-click check.
|
||||
// Deliberately a *light-DOM* element with no shadow root of its own: all
|
||||
// the generic dropdown-menu mechanics (shadow attach, open/close state,
|
||||
// positioning, singleton coordination, outside-click/Escape) now live in
|
||||
// the shared `<hive-menu>` component (@hive/shared/hive-menu.js) — this
|
||||
// element's whole job is building the agent-specific trigger button +
|
||||
// item list and handing them to an internal `<hive-menu>` via its
|
||||
// `._opts = { trigger, content }` contract, same convention `<hive-
|
||||
// dialog>` uses (custom elements can't take constructor args via
|
||||
// `document.createElement`). Its own shadow root would've bought nothing
|
||||
// once `<hive-menu>` owns shadow/positioning/open-close, so it doesn't
|
||||
// have one — which means its `.agent-menu-*` classes need a stylesheet
|
||||
// that actually reaches them. Since `<hive-menu>` slots this element's
|
||||
// nodes in rather than moving them into its own shadow root (see
|
||||
// hive-menu.js's header for why), they stay in the light DOM the whole
|
||||
// way down — reachable by dashboard.css's ordinary global rules (there's
|
||||
// no hive-agent-menu.css anymore; its old rules moved there, minus the
|
||||
// pure positioning rule which is now generic and lives in `<hive-menu>`'s
|
||||
// own shadow-scoped `.menu-dropdown`).
|
||||
|
||||
import { el } from '@hive/shared/dom.js';
|
||||
import { themedConfirm, themedToast } from '@hive/shared/modal.js';
|
||||
import { attachShadowCss } from '@hive/shared/shadow-css.js';
|
||||
import agentMenuCss from './hive-agent-menu.css';
|
||||
|
||||
const openInstances = new Set();
|
||||
|
||||
function closeAll() {
|
||||
for (const inst of [...openInstances]) inst.close();
|
||||
}
|
||||
|
||||
// Exported for swarm.js's buildAgentTree — see module header.
|
||||
export function closeAllAgentMenus() {
|
||||
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 swarm.js's own
|
||||
// selection-clear Escape handler doesn't also fire while a menu is open.
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && openInstances.size) {
|
||||
closeAll();
|
||||
e.stopImmediatePropagation();
|
||||
}
|
||||
}, true);
|
||||
import '@hive/shared/hive-menu.js'; // registers <hive-menu> — side-effect import
|
||||
|
||||
// Single-agent POST helper shared by all menu items.
|
||||
async function agentMenuPost(actionPath, name, body, graceful) {
|
||||
|
|
@ -86,7 +50,6 @@ async function agentMenuPost(actionPath, name, body, graceful) {
|
|||
class HiveAgentMenu extends HTMLElement {
|
||||
connectedCallback() {
|
||||
const { c, forgeBase } = this._opts || {};
|
||||
const root = attachShadowCss(this, agentMenuCss);
|
||||
|
||||
const btn = el('button', {
|
||||
type: 'button',
|
||||
|
|
@ -96,9 +59,9 @@ class HiveAgentMenu extends HTMLElement {
|
|||
'aria-haspopup': 'menu',
|
||||
'aria-expanded': 'false',
|
||||
}, '⋮');
|
||||
const dropdown = el('ul', { class: 'agent-menu-dropdown', hidden: true, role: 'menu' });
|
||||
this._btn = btn;
|
||||
this._dropdown = dropdown;
|
||||
const dropdown = el('ul', { class: 'agent-menu-dropdown', role: 'menu' });
|
||||
|
||||
const close = () => this._menu.close();
|
||||
|
||||
const menuItem = (label, opts) => {
|
||||
const li = el('li', { role: 'presentation' });
|
||||
|
|
@ -108,7 +71,7 @@ class HiveAgentMenu extends HTMLElement {
|
|||
role: 'menuitem',
|
||||
}, label);
|
||||
item.addEventListener('click', async () => {
|
||||
this.close();
|
||||
close();
|
||||
let graceful = false;
|
||||
if (opts.confirm) {
|
||||
const r = await themedConfirm({
|
||||
|
|
@ -139,7 +102,7 @@ class HiveAgentMenu extends HTMLElement {
|
|||
role: 'menuitem',
|
||||
title: title || '',
|
||||
}, label);
|
||||
a.addEventListener('click', () => this.close());
|
||||
a.addEventListener('click', close);
|
||||
li.append(a);
|
||||
return li;
|
||||
};
|
||||
|
|
@ -210,35 +173,9 @@ class HiveAgentMenu extends HTMLElement {
|
|||
dropdown.append(menuSep(), li);
|
||||
}
|
||||
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const wasOpen = openInstances.has(this);
|
||||
closeAll();
|
||||
if (!wasOpen) this.open();
|
||||
});
|
||||
|
||||
root.append(btn, dropdown);
|
||||
}
|
||||
|
||||
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._btn.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._btn.setAttribute('aria-expanded', 'false');
|
||||
this.style.removeProperty('--menu-btn-opacity');
|
||||
openInstances.delete(this);
|
||||
this._menu = document.createElement('hive-menu');
|
||||
this._menu._opts = { trigger: btn, content: dropdown };
|
||||
this.append(this._menu);
|
||||
}
|
||||
}
|
||||
customElements.define('hive-agent-menu', HiveAgentMenu);
|
||||
|
|
|
|||
|
|
@ -279,17 +279,90 @@ body.dashboard-shell {
|
|||
}
|
||||
|
||||
/* ── per-agent three-dot context menu ─────────────────────────────────────
|
||||
Positioned after .card-body in the flex row. The menu itself is the
|
||||
<hive-agent-menu> shadow-DOM custom element (agent-menu/hive-agent-menu.js
|
||||
+ .css) — its own shadow-scoped stylesheet carries the button/dropdown/
|
||||
item/separator rules and the `:host` rules that play the structural
|
||||
role this light-DOM wrapper used to play. The one rule that still has
|
||||
to live out here is the hover reveal: a light-DOM descendant-combinator
|
||||
selector can't reach into the shadow tree, so it's relayed across the
|
||||
shadow boundary via the `--menu-btn-opacity` custom property (custom
|
||||
properties inherit through shadow boundaries) instead — the
|
||||
component's own JS forces the same variable to 1 while its dropdown is
|
||||
open (see hive-agent-menu.js's open()/close()). */
|
||||
Positioned after .card-body in the flex row. `<hive-agent-menu>`
|
||||
(agent-menu/hive-agent-menu.js) is a light-DOM element with no shadow
|
||||
root of its own — the generic dropdown mechanics (shadow attach,
|
||||
open/close, positioning) live in the shared `<hive-menu>` component
|
||||
instead (@hive/shared/hive-menu.js), which slots this element's
|
||||
trigger/content nodes into position rather than moving them into its
|
||||
own shadow root. That keeps these nodes in the ordinary light DOM the
|
||||
whole way down, so this ordinary global stylesheet reaches them
|
||||
directly — see hive-agent-menu.js's header for the full reasoning.
|
||||
`hive-agent-menu` itself just needs the flex/alignment role the old
|
||||
light-DOM `.agent-menu` wrapper div played in this row; the
|
||||
`position: relative` an absolutely-positioned dropdown needs now lives
|
||||
on `<hive-menu>`'s own `:host` instead, since that's the element whose
|
||||
shadow tree the dropdown is actually positioned within. */
|
||||
hive-agent-menu {
|
||||
flex: none;
|
||||
align-self: flex-start;
|
||||
margin-top: 0.3em;
|
||||
}
|
||||
.agent-menu-btn {
|
||||
display: block;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--subtext0);
|
||||
font-size: 1.1em;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0.1em 0.4em;
|
||||
border-radius: 4px;
|
||||
opacity: var(--menu-btn-opacity, 0);
|
||||
transition: opacity 120ms, background 120ms, color 120ms;
|
||||
}
|
||||
.agent-menu-btn:hover,
|
||||
.agent-menu-btn:focus-visible {
|
||||
background: color-mix(in srgb, var(--purple) 10%, transparent);
|
||||
color: var(--purple);
|
||||
outline: none;
|
||||
}
|
||||
.agent-menu-btn:focus-visible {
|
||||
outline: 1px solid var(--purple);
|
||||
}
|
||||
/* Positioning (`position`/`top`/`right`/`z-index`) is generic and lives
|
||||
on `<hive-menu>`'s own `.menu-dropdown` wrapper instead — this is just
|
||||
the item list's visual chrome. */
|
||||
.agent-menu-dropdown {
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--purple-dim);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0.3em 0;
|
||||
min-width: 10em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.agent-menu-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--fg);
|
||||
font-family: inherit;
|
||||
font-size: 0.88em;
|
||||
letter-spacing: 0.04em;
|
||||
text-align: left;
|
||||
padding: 0.4em 0.9em;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.agent-menu-item:hover {
|
||||
background: var(--border);
|
||||
color: var(--purple);
|
||||
}
|
||||
.agent-menu-sep {
|
||||
height: 1px;
|
||||
background: var(--purple-dim);
|
||||
margin: 0.3em 0;
|
||||
padding: 0;
|
||||
}
|
||||
/* Hover reveal: `--menu-btn-opacity` is forced to 1 while a hover
|
||||
selector (or the trigger's own open state, set by <hive-menu>) applies
|
||||
— inherits down through `hive-agent-menu` → `<hive-menu>` → the
|
||||
trigger button same as any custom property, shadow trees included. */
|
||||
.container-row:hover hive-agent-menu {
|
||||
--menu-btn-opacity: 1;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@ import { themedConfirm, themedToast } from '@hive/shared/modal.js';
|
|||
import {
|
||||
containersState, questionsState,
|
||||
} from './state.js';
|
||||
import { closeAllAgentMenus } from './agent-menu/hive-agent-menu.js';
|
||||
import { closeAllMenus } from '@hive/shared/hive-menu.js';
|
||||
import './agent-menu/hive-agent-menu.js'; // registers <hive-agent-menu> — side-effect import
|
||||
|
||||
// Context-window badge thresholds. Preferred source is each container's
|
||||
// `context_window_tokens` from /api/state (the real window for the model
|
||||
|
|
@ -185,12 +186,15 @@ document.addEventListener('click', (e) => {
|
|||
// stopped; rebuild + destroy/purge always shown.
|
||||
// The button is CSS-invisible until the row is hovered (or menu is
|
||||
// open) so it doesn't clutter quiet rows.
|
||||
// Rendering + all interaction/coordination logic lives in the
|
||||
// <hive-agent-menu> shadow-DOM custom element (./agent-menu/hive-agent-menu.js,
|
||||
// imported above for its `closeAllAgentMenus` export and its
|
||||
// customElements.define side effect); this is a thin wrapper matching
|
||||
// <hive-dialog>'s `._opts`-before-append convention, since a custom
|
||||
// element created via `document.createElement` can't take constructor args.
|
||||
// Rendering + agent-specific interaction lives in the <hive-agent-menu>
|
||||
// custom element (./agent-menu/hive-agent-menu.js, imported above for its
|
||||
// customElements.define side effect); the generic dropdown mechanics
|
||||
// (open/close, positioning, singleton coordination — closed via
|
||||
// `closeAllMenus` below) live in the shared `<hive-menu>` component it
|
||||
// composes internally (@hive/shared/hive-menu.js). This is a thin
|
||||
// wrapper matching <hive-dialog>'s `._opts`-before-append convention,
|
||||
// since a custom element created via `document.createElement` can't
|
||||
// take constructor args.
|
||||
function buildAgentMenu(c, forgeBase) {
|
||||
const menu = document.createElement('hive-agent-menu');
|
||||
menu._opts = { c, forgeBase };
|
||||
|
|
@ -226,7 +230,7 @@ function derivePortConflicts(containers) {
|
|||
function buildAgentTree(containers) {
|
||||
// Close any open context menu before replacing the DOM tree — the
|
||||
// previous dropdown element would otherwise be a stale reference.
|
||||
closeAllAgentMenus();
|
||||
closeAllMenus();
|
||||
const byName = new Map();
|
||||
for (const c of containers) byName.set(c.name, c);
|
||||
const children = new Map(); // parent_name -> [child_name, ...]
|
||||
|
|
|
|||
|
|
@ -18,7 +18,8 @@
|
|||
"./forms.js": "./src/forms.js",
|
||||
"./dom.js": "./src/dom.js",
|
||||
"./modal.js": "./src/modal.js",
|
||||
"./shadow-css.js": "./src/shadow-css.js"
|
||||
"./shadow-css.js": "./src/shadow-css.js",
|
||||
"./hive-menu.js": "./src/hive-menu/hive-menu.js"
|
||||
},
|
||||
"files": [
|
||||
"src/"
|
||||
|
|
|
|||
32
frontend/packages/shared/src/hive-menu/hive-menu.css
Normal file
32
frontend/packages/shared/src/hive-menu/hive-menu.css
Normal 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;
|
||||
}
|
||||
113
frontend/packages/shared/src/hive-menu/hive-menu.js
Normal file
113
frontend/packages/shared/src/hive-menu/hive-menu.js
Normal 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);
|
||||
Loading…
Reference in a new issue