Engineering Journal
Table Formatter
Table Formatter

Building a Right-Click Context Menu That Knows What You Clicked

2026-05-14

TLDR

Attaching individual contextmenu 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 StrategyMemory FootprintDynamic Element HandlingViewport Boundary Handling
Per-Element BindingHigh (grows with tab count)Requires re-binding on element addManual calculation per element
Delegated Container BindingMinimal (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.
Read this post in the full Engineering Journal →