Engineering Journal
Table Formatter
Table Formatter

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

2026-05-22

TLDR: One contextmenu listener on the sheet tab bar checks event.target.closest('.sp-option') and event.target.closest('button.accordion') to branch into two separate action sets. Position the menu at clientX/Y with position: fixed for mobile compatibility.

Repo: tools/table-formatter

The Setup

TAFNE's sheet tab bar contains two types of interactive elements:

Both support right-click. The actions available differ by type: The context menu is a single <ul> element that gets repositioned and repopulated on each right-click.

The Event Listener

A single listener on the sheet bar container handles both element types:

sheetBar.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 type = tab ? 'sheet' : 'section'; showContextMenu(e.clientX, e.clientY, target, type); });

e.target.closest() walks up the DOM from the click target. Right-clicking a text label inside .sp-option still resolves to the .sp-option ancestor. Right-clicking the chevron icon inside button.accordion still resolves to the button.

If neither match, the right-click is on empty tab bar space and the menu is suppressed.


Building the Menu Items

function buildMenuItems(type) {
    if (type === 'sheet') {
        return [
            { label: 'Rename', action: 'rename' },
            { label: 'Add Sheet Before', action: 'addBefore' },
            { label: 'Add Sheet After', action: 'addAfter' },
            { label: 'Delete Sheet', action: 'delete', danger: true }
        ];
    }
    if (type === 'section') {
        return [
            { label: 'Rename Section', action: 'rename' },
            { label: 'Add Section', action: 'addSection' },
            { label: 'Delete Section', action: 'delete', danger: true }
        ];
    }
    return [];
}

The rename action is shared by both types but operates on different data. The handler checks type again at execution:

function executeAction(action, target, type) {
    if (action === 'rename') {
        if (type === 'sheet') renameSheet(target);
        else renameSection(target);
    }
    if (action === 'addBefore') insertSheetBefore(target);
    if (action === 'addAfter') insertSheetAfter(target);
    if (action === 'addSection') insertSection(target);
    if (action === 'delete') {
        if (type === 'sheet') deleteSheet(target);
        else deleteSection(target);
    }
}

Positioning for Mobile

Desktop context menus use position: absolute relative to a container. On mobile, the container may be inside a transform, a sticky element, or a scroll context that makes absolute positioning unreliable.

Using position: fixed and clientX/clientY bypasses all containing block complications:

function showContextMenu(x, y, target, type) {
    menu.innerHTML = '';
    buildMenuItems(type).forEach(item => {
        const li = document.createElement('li');
        li.textContent = item.label;
        if (item.danger) li.classList.add('menu-item-danger');
        li.addEventListener('click', () => {
            executeAction(item.action, target, type);
            hideMenu();
        });
        menu.appendChild(li);
    });

menu.style.position = 'fixed'; menu.style.left = x + 'px'; menu.style.top = y + 'px'; menu.style.display = 'block';

// clamp to viewport const rect = menu.getBoundingClientRect(); if (rect.right > window.innerWidth) { menu.style.left = (x - rect.width) + 'px'; } if (rect.bottom > window.innerHeight) { menu.style.top = (y - rect.height) + 'px'; } }

The viewport clamp runs after the menu is displayed (so getBoundingClientRect returns real dimensions) and flips the menu left or up when it would overflow the screen edge.


Dismissal

The menu closes on any click outside it, any scroll, or any focus change:

document.addEventListener('click', hideMenu);
document.addEventListener('scroll', hideMenu, true);
document.addEventListener('focusout', (e) => {
    if (!menu.contains(e.relatedTarget)) hideMenu();
});

The true on the scroll listener captures scroll events from child elements that might not bubble. Sheet tabs inside a scroll container can scroll without the event reaching document.


Tradeoffs

closest() traverses up the DOM on every right-click. For a tab bar with dozens of elements, this is fast enough to be imperceptible. For a very large virtual list, a data attribute on the element itself would be faster.

The menu is a single shared DOM node. Rapid right-clicks before the previous menu dismisses will recycle the same node. The menu items from the previous right-click are cleared on buildMenuItems. This is intentional, not a bug.

danger styling is visual only. The delete action has no confirmation dialog. Users who accidentally delete a sheet have no undo at the tab-bar level (undo lives in the table history stack, not the sheet list). A confirmation step for delete would improve safety.

Read this post in the full Engineering Journal →