components_context_menu.js
/**
* Base class for all positioned context menus.
*
* Handles root element creation, DOM mounting, viewport-clamped positioning,
* outside-click dismissal, and a shared item factory.
*
* Subclass usage:
* 1. Call `super(title, onDismiss)` at the top of the subclass constructor.
* 2. Build content onto `this.root`.
* 3. Call `this._mount(x, y)` at the end of the subclass constructor.
* 4. Override `close()` if extra cleanup is needed, calling `super.close()`.
*
* Stacking usage:
* primaryMenu.stack(secondaryMenu)
* Merges secondaryMenu's title and items below primaryMenu's content. The
* combined result is a single mounted menu. Returns `this` for chaining.
*/
export class ContextMenu {
/**
* @param {string} title - Label shown at the top of the menu identifying what was right-clicked.
* @param {function(): void} [onDismiss] - Called when the menu is dismissed via outside click.
* @param {{message: string, href: (string|undefined)}|null} [info] - When provided, an info-widget icon is
* prepended to the title row. `message` is the hover tooltip text; `href` opens a docs page on click.
*/
constructor(title, onDismiss, info = null) {
this._onDismiss = onDismiss ?? (() => {});
this._childMenus = [];
this.root = document.createElement('div');
this.root.className = 'ctx-menu';
const titleEl = document.createElement('div');
titleEl.className = 'ctx-title';
if (info) {
const widget = document.createElement('info-widget');
widget.setAttribute('message', info.message);
if (info.href) widget.setAttribute('href', info.href);
titleEl.appendChild(widget);
}
titleEl.appendChild(document.createTextNode(title));
this.root.appendChild(titleEl);
}
/**
* Appends the menu to the document body, positions it at (x, y), and
* starts listening for outside clicks. Call this at the end of the subclass
* constructor once all content has been built.
* @param {number} x - Preferred left position in viewport pixels.
* @param {number} y - Preferred top position in viewport pixels.
*/
_mount(x, y) {
document.body.appendChild(this.root);
this.#position(x, y);
this.#bindOutsideClick();
}
/** Removes the menu from the DOM. Override to add cleanup; call super.close(). */
close() {
this.root.remove();
for (const child of this._childMenus) child.close();
}
/**
* Merges `childMenu` below this menu as a stacked section. The child's title
* and items are appended to this root separated by a divider. The child is
* detached from the DOM and its lifecycle (close, dismiss) is taken over by
* this menu. Returns `this` for chaining.
* @param {ContextMenu} childMenu - A mounted ContextMenu instance to stack below this one.
* @returns {ContextMenu} this
*/
stack(childMenu) {
childMenu.root.remove();
this.root.appendChild(Object.assign(document.createElement('div'), { className: 'ctx-sep ctx-sep--before-header' }));
while (childMenu.root.firstChild) {
this.root.appendChild(childMenu.root.firstChild);
}
this._childMenus.push(childMenu);
const prevDismiss = this._onDismiss;
this._onDismiss = () => { prevDismiss(); childMenu._onDismiss(); };
// Re-clamp now that the menu is taller.
requestAnimationFrame(() => {
const r = this.root.getBoundingClientRect();
const left = parseFloat(this.root.style.left);
const top = parseFloat(this.root.style.top);
if (left + r.width > window.innerWidth) this.root.style.left = (window.innerWidth - r.width - 8) + 'px';
if (top + r.height > window.innerHeight) this.root.style.top = (window.innerHeight - r.height - 8) + 'px';
});
return this;
}
/**
* Creates a standard menu item element.
* @param {string} iconHtml - Inner HTML for the icon cell (character or SVG/icon span).
* @param {string} label - Display label text.
* @param {string|null} kbd - Keyboard shortcut hint; pass null to omit.
* @param {function|null} onClick - Click handler; pass null for disabled items.
* @param {string|null} [disabledReason] - If set, the item is dimmed and shows this as a tooltip.
* @returns {HTMLElement}
*/
makeItem(iconHtml, label, kbd, onClick, disabledReason = null) {
const item = document.createElement('div');
item.className = 'ctx-item';
const iconSpan = document.createElement('span');
iconSpan.className = 'ctx-icon';
iconSpan.innerHTML = iconHtml;
const inner = document.createElement('span');
inner.className = 'ctx-item-inner';
inner.textContent = label;
item.appendChild(iconSpan);
item.appendChild(inner);
if (kbd != null) {
const kbdSpan = document.createElement('span');
kbdSpan.className = 'ctx-kbd';
kbdSpan.textContent = kbd;
item.appendChild(kbdSpan);
}
if (disabledReason) {
item.title = disabledReason;
item.style.opacity = '0.4';
item.style.cursor = 'default';
} else if (onClick) {
item.addEventListener('click', onClick);
}
return item;
}
/**
* Positions the menu at (x, y), then clamps it to stay within the viewport.
* @param {number} x - Preferred left position in viewport pixels.
* @param {number} y - Preferred top position in viewport pixels.
*/
#position(x, y) {
this.root.style.left = x + 'px';
this.root.style.top = y + 'px';
requestAnimationFrame(() => {
const r = this.root.getBoundingClientRect();
if (x + r.width > window.innerWidth) this.root.style.left = (window.innerWidth - r.width - 8) + 'px';
if (y + r.height > window.innerHeight) this.root.style.top = (window.innerHeight - r.height - 8) + 'px';
});
}
/** Registers a mousedown listener that dismisses the menu on outside clicks. */
#bindOutsideClick() {
setTimeout(() => {
const outside = (e) => {
if (!document.body.contains(this.root)) {
// Root was stacked into another menu — deactivate silently.
document.removeEventListener('mousedown', outside);
return;
}
if (!this.root.contains(e.target)) {
this.close();
this._onDismiss();
document.removeEventListener('mousedown', outside);
}
};
document.addEventListener('mousedown', outside);
}, 0);
}
}