Context Menu
Right-click menus that bind to a container, delegate to its children, and nest without overriding each other

Context Menu

A context menu binds to a container once and covers everything inside it. You do not attach a handler per row: the menu resolves which descendant was right-clicked and hands it to your items, so content rendered later is covered automatically.

Menus nest. Putting one inside another does not override the outer menu - the inner one shadows it for the region it covers, and by default appends the outer menu's items beneath its own.

Key Features

  • Container binding - one menu for a whole table, list or page region
  • Delegation - match picks the row, and your items receive it
  • Graceful nesting - innermost wins, by DOM depth, never by load order
  • Inheritance - ancestor items appended, prepended, or left out entirely
  • Keyboard access - Shift+F10 and the Menu key, arrows, typeahead, Escape
  • Touch - long-press opens the same menu
  • Native escape hatch - Shift+right-click still gets the browser's own menu

Basic Usage


const menu = Domma.elements.contextMenu('#my-container', {
    items: [
        { label: 'Edit',   icon: 'edit',  action: (target) => edit(target) },
        { label: 'Delete', icon: 'trash', danger: true, action: remove },
        { type: 'divider' },
        { label: 'Copy', icon: 'copy', shortcut: 'Ctrl+C', action: copy }
    ]
});

Getting Started

A working right-click menu in three steps

Step 1 - Mark the region

Any element can host a menu. Give it something the menu can bind to.


<div id="notes" class="dm-context-menu-host">
    <div data-note-id="1">First note</div>
    <div data-note-id="2">Second note</div>
</div>

.dm-context-menu-host is optional. It sets cursor: context-menu so people can tell a menu is there before they try.

Step 2 - Bind the menu


Domma.elements.contextMenu('#notes', {
    match: '[data-note-id]',
    items: [
        { label: 'Open', icon: 'eye',   action: (note) => open(note.dataset.noteId) },
        { label: 'Delete', icon: 'trash', danger: true }
    ]
});

Step 3 - Vary the items per row

Pass a function instead of an array and it is called with the row that was clicked.


items: (note) => [
    { label: `Open note ${note.dataset.noteId}`, icon: 'eye' },
    { label: 'Unlock', visible: note.dataset.locked === 'true' },
    { label: 'Delete', danger: true, disabled: note.dataset.locked === 'true' }
]

Live Demo

Right-click anywhere in the panel below. Try a card, then the gap between cards.

Page region - right-click here for the page menu.

Quarterly Report

Right-click me

Design Notes

Locked - some items disable

Roadmap

Right-click me

What happened

  • Nothing yet - right-click something above.

Nesting and the cascade

The demo above has two menus: one on the page region, one on the card grid inside it. Right-click a card and you get the card's items with the page's appended beneath. Right-click the gap between cards and only the page menu opens, because the inner menu's match found nothing and declined.


// Outer - the page
Domma.elements.contextMenu('#demo-page', {
    items: [
        { label: 'Page settings', icon: 'settings' },
        { label: 'View source',   icon: 'code' }
    ]
});

// Inner - shadows the page menu, but only over the cards
Domma.elements.contextMenu('#demo-collection', {
    match: '[data-entry-id]',
    items: (entry) => [
        { label: `Open "${entry.dataset.title}"`, icon: 'eye' },
        { label: 'Delete', icon: 'trash', danger: true,
          disabled: entry.dataset.locked === 'true' }
    ]
});

How the winner is chosen

  1. Walk outward from the element that was right-clicked.
  2. The first bound container found is asked whether it wants the gesture.
  3. It declines if enabled is false, exclude matches, match finds nothing, or onBeforeOpen returns false.
  4. On a decline the walk continues outward, so the next menu up gets its chance.
  5. If nothing claims it, the browser's own menu opens.

DOM depth decides, never script order. Every menu shares one document listener, so a menu registered first cannot shadow one registered later, and rebundling cannot change which menu wins.

Regions that must not be taken over

Depth normally decides, which is wrong for a component that owns its region and must not be shadowed by application menus. exclusive: true inverts the rule for that menu: once it encloses the click and accepts it, nothing bound deeper is offered the gesture. Declare it on the menu being protected, rather than as a guard on every menu that might collide with it.


Domma.elements.contextMenu('#data-grid', {
    exclusive: true,
    render: (ctx) => openFilterPanel(ctx)
});

// Bound deeper, and never reached inside the grid:
Domma.elements.contextMenu('[data-row-id]', { items: [...] });

It is not a veto: an exclusive menu that declines steps aside completely and the inner menus answer as normal. It says nothing about menus outside it, which still inherit under the usual rules.

Controlling inheritance


inherit: 'append'    // default - own items, then the ancestors' beneath
inherit: 'prepend'   // ancestors' items first
inherit: false       // stand alone; ancestor items stay out

Submenus

Right-click the panel below and hover Export.

Right-click for a menu with a submenu

items: [
    { label: 'Rename', icon: 'edit' },
    { type: 'divider' },
    {
        label: 'Export', icon: 'download',
        submenu: [
            { label: 'CSV',  action: () => exportAs('csv') },
            { label: 'JSON', action: () => exportAs('json') },
            { label: 'PDF',  action: () => exportAs('pdf') }
        ]
    }
]

Keyboard and touch

Right-click has no keyboard equivalent, so a context menu that only answers the mouse is unreachable for anyone who does not use one. This component opens from the keyboard by default.

KeyDoes
Shift + F10Open against the focused element
MenuOpen against the focused element
/ Move between items, skipping disabled ones
Home / EndFirst / last item
/ Open / close a submenu
Enter / SpaceChoose the focused item
EscClose one level, then the menu
Any letterTypeahead to the first item starting with it
Shift + right-clickPass through to the browser's own menu

On touch, a long press of longPress ms (500 by default) opens the same menu. Set longPress: false to leave touch alone.

Theming and transitions

Right-click each panel below to compare. Every themeable value is a custom property with the theme token as its fallback, so a menu given no styling follows the active theme and only what you pass is overridden.

Accent + slide

danger, radius lg, slide

Translucent

opacity 80, shadow xl, fade

Compact

compact density, no transition


Domma.elements.contextMenu('#panel', {
    accent: 'danger',        // or '#ff8800', or any CSS colour
    radius: 'lg',
    shadow: 'xl',
    opacity: 85,             // translucent, with a blurred backdrop
    density: 'compact',
    transition: 'slide',     // scale | fade | slide | none
    easing: 'ease-out',
    animationDuration: 180
});

The accent tints the border and the hover wash, never the label. A named colour cannot be guaranteed readable against whatever surface the menu lands on - --dm-danger on --dm-surface is 1.02:1 on admin-smooth-steel - so labels keep the one pairing every theme guarantees and the accent carries the meaning. For the same reason opacity mixes the background with transparent rather than fading the element, which would take the text down with it.

Transitions are CSS-driven, and prefers-reduced-motion drops them regardless of what was configured.

Panels that are not item lists

Some menus cannot be expressed as items - a live attribute editor, a filter builder, anything carrying its own inputs. render lets one join the cascade anyway.


Domma.elements.contextMenu('[data-ctx]', {
    enabled: (wrapper) => hasModel(wrapper),   // declines, falls through outward
    render: (ctx) => openMyPanel(ctx.x, ctx.y, ctx.container, ctx.target)
});

Arbitration is unchanged: enabled, exclude, match and onBeforeOpen decide whether this menu claims the gesture, and render runs only once it has. Return an element for Domma to position and dismiss, or nothing to manage the panel yourself.

Options

Targeting and cascade

OptionTypeDefaultDescription
itemsArray | Function | Observable[]Items, or a resolver called with the delegated target
matchstringnullDelegation selector; null covers the whole container
excludestringnullRegions that decline and fall through outward
enabledboolean | FunctiontrueFalse declines and falls through
inherit'append' | 'prepend' | false'append'Whether ancestor menus' items are merged in
prioritynumber0Tie-break when two menus bind the same element
exclusivebooleanfalseThis menu owns its region; nothing bound deeper is offered the click

Behaviour

OptionTypeDefaultDescription
nativeOnShiftbooleantrueShift+right-click gets the browser menu
closeOnSelectbooleantrueClose after an item is chosen
closeOnEscapebooleantrueClose on Esc
closeOnClickOutsidebooleantrueClose on outside mousedown
closeOnScrollbooleantrueClose on scroll - the anchor point has moved
longPressnumber | false500Touch long-press duration in ms

Presentation

OptionTypeDefaultDescription
classNamestring''Extra class on the menu root
minWidthstring'200px'Minimum menu width
maxWidthstring'320px'Maximum menu width
maxHeightstring'60vh'Height before the menu scrolls
offset[number, number][2, 2]Offset from the cursor point
flipbooleantrueFlip across the cursor rather than open off screen
animationbooleantrueWhether to transition on open and close
animationDurationnumber120Transition duration in ms
transitionstring'scale'scale, fade, slide, none
easingstringcubic-bezier(…)Any CSS easing
accentstringnullPreset key or any CSS colour
surfacestringnullOverrides the panel background
radiusstringnullnone|sm|md|lg|xl or a CSS length
shadowstringnullnone|sm|md|lg|xl
opacitynumbernull20-100; translucent with a blurred backdrop
densitystring'comfortable'comfortable or compact
itemTemplateFunctionnullCustom item renderer returning HTML
submenuDelaynumber150Hover grace before a submenu opens
renderFunctionnullRender your own panel instead of an item list

Accessibility

OptionTypeDefaultDescription
keyboardTriggerbooleantrueShift+F10 and the Menu key open the menu
typeaheadbooleantrueJump to an item by typing
ariaLabelstring'Context menu'Label announced for the menu

Callbacks

CallbackSignatureDescription
onBeforeOpen(ctx) => booleanReturn false to decline and fall through outward
onOpen(ctx) => voidFired once the menu is on screen
onClose(ctx) => voidFired after dismissal
onSelect(item, ctx) => voidFired before the item's own action

Item schema

KeyTypeDescription
labelstringItem text
iconstringDomma icon name
valueanyCarried through to onSelect
actionFunctionCalled with the delegated target and the context
disabledboolean | FunctionGreyed but still shown
visibleboolean | FunctionOmitted entirely when false
dangerbooleanDestructive styling
shortcutstringHint text only - no key is bound for you
submenuArray | FunctionNested items, unlimited depth
typestringitem, divider, header, checkbox, radio
checkedboolean | FunctionFor checkbox and radio items

Dividers left stranded by hidden items are collapsed automatically, and a menu whose every item resolves away does not open at all - the click falls through to the next menu outward.

Methods

MethodDescription
open(x, y, target)Open at a viewport point - for a "..." button that should show the same menu
close()Close the menu
refresh()Rebuild the open menu in place, keeping its position
isOpen()Whether this menu is open
setItems(items)Replace the items
enable() / disable()Arm or suppress the menu
destroy()Close, deregister and detach

Statics

MemberDescription
contextMenu.closeAll()Close whichever menu is open
contextMenu.active()The open instance, or null
contextMenu.registry(el)The resolution chain for an element, innermost first

registry(el) is the debugging tool for a cascade that is not behaving: it shows exactly which menus enclose an element, in the order they will be offered the gesture.