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 -
matchpicks 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.
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
- Walk outward from the element that was right-clicked.
- The first bound container found is asked whether it wants the gesture.
- It declines if
enabledis false,excludematches,matchfinds nothing, oronBeforeOpenreturnsfalse. - On a decline the walk continues outward, so the next menu up gets its chance.
- 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.
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.
| Key | Does |
|---|---|
Shift + F10 | Open against the focused element |
Menu | Open against the focused element |
↑ / ↓ | Move between items, skipping disabled ones |
Home / End | First / last item |
→ / ← | Open / close a submenu |
Enter / Space | Choose the focused item |
Esc | Close one level, then the menu |
| Any letter | Typeahead to the first item starting with it |
Shift + right-click | Pass 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.
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
| Option | Type | Default | Description |
|---|---|---|---|
items | Array | Function | Observable | [] | Items, or a resolver called with the delegated target |
match | string | null | Delegation selector; null covers the whole container |
exclude | string | null | Regions that decline and fall through outward |
enabled | boolean | Function | true | False declines and falls through |
inherit | 'append' | 'prepend' | false | 'append' | Whether ancestor menus' items are merged in |
priority | number | 0 | Tie-break when two menus bind the same element |
exclusive | boolean | false | This menu owns its region; nothing bound deeper is offered the click |
Behaviour
| Option | Type | Default | Description |
|---|---|---|---|
nativeOnShift | boolean | true | Shift+right-click gets the browser menu |
closeOnSelect | boolean | true | Close after an item is chosen |
closeOnEscape | boolean | true | Close on Esc |
closeOnClickOutside | boolean | true | Close on outside mousedown |
closeOnScroll | boolean | true | Close on scroll - the anchor point has moved |
longPress | number | false | 500 | Touch long-press duration in ms |
Presentation
| Option | Type | Default | Description |
|---|---|---|---|
className | string | '' | Extra class on the menu root |
minWidth | string | '200px' | Minimum menu width |
maxWidth | string | '320px' | Maximum menu width |
maxHeight | string | '60vh' | Height before the menu scrolls |
offset | [number, number] | [2, 2] | Offset from the cursor point |
flip | boolean | true | Flip across the cursor rather than open off screen |
animation | boolean | true | Whether to transition on open and close |
animationDuration | number | 120 | Transition duration in ms |
transition | string | 'scale' | scale, fade, slide, none |
easing | string | cubic-bezier(…) | Any CSS easing |
accent | string | null | Preset key or any CSS colour |
surface | string | null | Overrides the panel background |
radius | string | null | none|sm|md|lg|xl or a CSS length |
shadow | string | null | none|sm|md|lg|xl |
opacity | number | null | 20-100; translucent with a blurred backdrop |
density | string | 'comfortable' | comfortable or compact |
itemTemplate | Function | null | Custom item renderer returning HTML |
submenuDelay | number | 150 | Hover grace before a submenu opens |
render | Function | null | Render your own panel instead of an item list |
Accessibility
| Option | Type | Default | Description |
|---|---|---|---|
keyboardTrigger | boolean | true | Shift+F10 and the Menu key open the menu |
typeahead | boolean | true | Jump to an item by typing |
ariaLabel | string | 'Context menu' | Label announced for the menu |
Callbacks
| Callback | Signature | Description |
|---|---|---|
onBeforeOpen | (ctx) => boolean | Return false to decline and fall through outward |
onOpen | (ctx) => void | Fired once the menu is on screen |
onClose | (ctx) => void | Fired after dismissal |
onSelect | (item, ctx) => void | Fired before the item's own action |
Item schema
| Key | Type | Description |
|---|---|---|
label | string | Item text |
icon | string | Domma icon name |
value | any | Carried through to onSelect |
action | Function | Called with the delegated target and the context |
disabled | boolean | Function | Greyed but still shown |
visible | boolean | Function | Omitted entirely when false |
danger | boolean | Destructive styling |
shortcut | string | Hint text only - no key is bound for you |
submenu | Array | Function | Nested items, unlimited depth |
type | string | item, divider, header, checkbox, radio |
checked | boolean | Function | For 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
| Method | Description |
|---|---|
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
| Member | Description |
|---|---|
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.