Building a Right-Click Context Menu That Knows What You Clicked
TLDR
Attaching individualcontextmenu listeners to every tab or accordion button increases memory usage and breaks when adding dynamic elements. Utilizing a single delegated contextmenu listener on the parent container alongside e.target.closest() dynamically dispatches action sets based on element types while clamping menu coordinates within viewport bounds.
| Event Listener Strategy | Memory Footprint | Dynamic Element Handling | Viewport Boundary Handling |
|---|---|---|---|
| Per-Element Binding | High (grows with tab count) | Requires re-binding on element add | Manual calculation per element |
| Delegated Container Binding | Minimal (Single parent listener) | 100% Automatic via closest() | Centralized clientX/Y Clamping |
Mapping context menu listeners to individual DOM nodes creates handler overhead and memory leaks
When UI bars support multiple interactive element types, such as individual sheet tabs (.sp-option) and expandable section headers (button.accordion), binding separate context menu listeners to each node creates unnecessary event overhead.
Elements added dynamically also require explicit listener attachment, increasing code complexity and edge-case bug risks.
Delegated container listeners resolve click targets dynamically and simplify viewport clamping
We attached a single contextmenu listener to the tab bar container, using e.target.closest() to identify target element types and position a fixed floating menu:
VIEWPORT CLAMPING GEOMETRY:
┌────────────────────────────────────────────────────────┐
│ │
│ │
│ (clientX, Y) │
│ ● │
│ ┌────────┴─────┐│
│ │ Context Menu ││
│ │ Width: 160px ││
│ │ Height: 100px││
│ └──────────────┘│ <-- Right boundary overflow!
└────────────────────────────────────────────────────────┘
CLAMPED PLACEMENT (Shifted left / up): ┌────────────────────────────────────────────────────────┐ │ │ │ ┌──────────────┐ │ │ │ Context Menu │ │ │ │ (Shifted) │ │ │ └────────┬─────┘ │ │ ● │ │ (clientX, Y) │ │ │ └────────────────────────────────────────────────────────┘
Here is the implementation of delegated context menu attachment and viewport boundary clamping:
// Delegated right-click context menu handler
export function bindTabContextMenu(containerEl, menuEl) {
containerEl.addEventListener('contextmenu', (e) => {
e.preventDefault();
const tab = e.target.closest('.sp-option'); const accordion = e.target.closest('button.accordion'); const target = tab || accordion; if (!target) return;
const targetType = tab ? 'sheet' : 'section'; renderContextMenuItems(menuEl, target, targetType); positionFixedMenu(menuEl, e.clientX, e.clientY); }); }
function positionFixedMenu(menuEl, clientX, clientY) { menuEl.style.position = 'fixed'; menuEl.style.left = ${clientX}px; menuEl.style.top = ${clientY}px; menuEl.style.display = 'block';
// Clamp within viewport bounds to prevent off-screen overflow const rect = menuEl.getBoundingClientRect(); if (rect.right > window.innerWidth) menuEl.style.left = ${clientX - rect.width}px; if (rect.bottom > window.innerHeight) menuEl.style.top = ${clientY - rect.height}px; }
Dismissal listeners attached to click and scroll events ensure the floating menu closes cleanly when users interact elsewhere on the page.
Rule of thumb: Delegate context menu listeners to a shared container and resolve targets via closest() to avoid event leaks and simplify viewport boundary adjustments.