Skip to content

Context Menu Plugin

The Context Menu plugin adds a customizable right-click menu to your grid cells. Build anything from simple copy/paste actions to complex nested menus with conditional visibility, icons, and keyboard shortcuts.

import '@toolbox-web/grid/features/context-menu';

Define your menu items as an array—each item has an id, label, and action callback that receives context about the clicked cell (row data, column info, cell value, etc.). Add separators between groups of actions for visual clarity.

import { queryGrid } from '@toolbox-web/grid';
const grid = queryGrid('tbw-grid');
function removeRow(rowIndex: number) {
grid.rows = grid.rows.filter((_, i) => i !== rowIndex);
}
grid.gridConfig = {
columns: [
{ field: 'name', header: 'Name' },
{ field: 'email', header: 'Email' },
{ field: 'status', header: 'Status' }
],
features: {
contextMenu: {
items: [
{ id: 'copy', name: 'Copy Cell', action: (ctx) => navigator.clipboard.writeText(String(ctx.value)) },
{ id: 'sep1', name: '', separator: true },
{ id: 'delete', name: 'Delete Row', action: (ctx) => removeRow(ctx.rowIndex) },
],
},
},
};

Right-click any cell to see the context menu.

Nested menu items for complex actions.

Show/hide items based on context.

Label each section of the menu with what it acts on. Right-click a single row, then Ctrl/⌘-click to select several rows and right-click again — the selected rows group appears only when it has items. See Group Headers.

Right-click any cell to see the context menu. Items from FilteringPlugin, VisibilityPlugin, and PinnedColumnsPlugin are automatically contributed alongside custom items.

Plugins like FilteringPlugin, VisibilityPlugin, and PinnedColumnsPlugin can contribute their own items to the context menu automatically.

OptionTypeDefaultDescription
itemsContextMenuItem[]Copy + Export CSVMenu items to display

When used with the SelectionPlugin, the context menu reports the selection in params.selectedRows and syncs it to the cell the menu was opened on. Every way of opening the menu syncs identically — right-click, touch long-press, Shift + F10, the ☰ Menu key, or a synthesised contextmenu — so keyboard, touch, and assistive-technology users act on exactly the selection a mouse user would.

Opened on…Behavior
A selected row (row mode)Multi-selection preserved; selectedRows = all selected rows
An unselected row (row mode)Selects only that row
A cell inside a range (range mode)Range preserved; selectedRows = every row the range covers
A cell outside every range (range mode)Range collapses to that cell; selectedRows = [rowIndex]
Any cell (cell mode)Cell selection moves to that cell; selectedRows = [rowIndex]
A cell in a selected column (mode: [..., 'column'])Column selection preserved; selectedRows = [rowIndex]
A cell with no SelectionPlugin loadedselectedRows = [rowIndex]
A headerselectedRows = []

This uses the plugin query system for loose coupling — no hard dependency on SelectionPlugin.

features: {
contextMenu: {
items: [
{
id: 'edit',
name: 'Edit',
hidden: (params) => params.column.editable !== true,
},
{
id: 'delete',
name: 'Delete',
disabled: (params) => params.row.locked === true,
},
],
},
},

Menus that mix actions of different scope — the clicked cell, the whole row, a selection of rows — are easier to scan when each section is labelled. Give a separator a label to turn it into a group header: a divider with the label inline.

features: {
contextMenu: {
items: (params) => [
{ id: 'cell', name: '', separator: true, label: 'Cell' },
{ id: 'copy', name: 'Copy value', action: (p) => copy(p.value) },
{ id: 'row', name: '', separator: true, label: 'Row' },
{ id: 'edit', name: 'Edit row', action: (p) => edit(p.row) },
{ id: 'rows', name: '', separator: true, label: `${params.selectedRows.length} selected rows` },
{ id: 'delete', name: 'Delete selected', hidden: (p) => p.selectedRows.length < 2, action: (p) => remove(p.selectedRows) },
],
},
},
─ Cell ────────────
Copy value
─ Row ─────────────
Edit row
  • A plain separator (separator: true without label) still renders a bare divider.
  • Group headers are not focusable or clickable and are skipped by the arrow keys.
  • Empty groups collapse automatically. When every item after a header is hidden, the header is dropped too — no need to compute hidden for the header yourself. A header may lead the menu (plain separators may not), and a plain separator directly before a header is absorbed by it.
  • hidden (static or a function) works on headers like any other item, and headers work inside subMenu as well.

Right-click on cells or headers to open the context menu.

Event Log:
EventDetailDescription
context-menu-open{ params, items }Menu opened

The context menu supports CSS custom properties for theming. Override these on tbw-grid or a parent container:

PropertyDefaultDescription
--tbw-context-menu-bgvar(--tbw-color-panel-bg)Menu background
--tbw-context-menu-fgvar(--tbw-color-fg)Menu text color
--tbw-context-menu-bordervar(--tbw-color-border)Menu border
--tbw-context-menu-hovervar(--tbw-color-row-hover)Item hover background
--tbw-context-menu-group-header-fgvar(--tbw-context-menu-muted, var(--tbw-color-fg-muted))Group header label color
--tbw-context-menu-group-header-sizevar(--tbw-font-size-xs)Group header label size
--tbw-menu-min-width10remMinimum menu width
--tbw-menu-item-padding0.375rem 0.75remMenu item padding
--tbw-menu-item-gap0.5remGap between icon and label
--tbw-font-size-sm0.9285emMenu font size
--tbw-font-size-xs0.7857emShortcut text size
--tbw-icon-size1emIcon width
tbw-grid {
/* Custom context menu styling */
--tbw-context-menu-bg: #2d2d2d;
--tbw-context-menu-fg: #ffffff;
--tbw-context-menu-border: #444444;
--tbw-context-menu-hover: #3d3d3d;
--tbw-menu-item-padding: 0.5rem 1rem;
}

The menu uses these class names for advanced customization:

ClassElement
.tbw-context-menuMenu container
.tbw-context-menu-itemMenu item row
.tbw-context-menu-item.disabledDisabled item
.tbw-context-menu-item.dangerDanger/delete action
.tbw-context-menu-iconItem icon container
.tbw-context-menu-labelItem label text
.tbw-context-menu-shortcutKeyboard shortcut
.tbw-context-menu-separatorDivider line
.tbw-context-menu-separator.tbw-context-menu-group-headerGroup header (labelled divider)
.tbw-context-menu-group-labelGroup header label text

The popup follows the WAI-ARIA Menu pattern:

  • The popup is role="menu"; items are role="menuitem" (tabindex="-1", focus moved between them by the keyboard), dividers are role="separator", group headers are role="separator" named by aria-label (the visible label), and disabled items carry aria-disabled="true" and are skipped.
  • ↑ / ↓ move between items (wrapping), Enter / Space activates, and Esc closes. Clicking outside or scrolling also closes it (see Accessibility guide).

The plugin also respects the keyboard-open path: Shift + F10 and the dedicated ☰ Menu key open the menu at the focused cell with focus moved to the first item, and sync the selection exactly as a right-click on that cell does (see Selection Sync), so the menu is fully operable from the keyboard (SC 2.1.1 Keyboard).

On a touch device the menu opens with a long-press, with no extra configuration. This is not a polyfill: browsers already synthesise a native contextmenu event from a long-press, exactly as they do from a right-click, so the same listener serves both.

Because a long-press is an overloaded gesture, the grid resolves it in a fixed order:

You long-pressWhat opensWhen
A header cellColumn header menuOnce the header-menu plugin ships (tracked in #270)
A rowTouch selection modeSelectionPlugin is registered with mode: 'row'
A cellCell range paintingSelectionPlugin is registered with mode: 'cell' or 'range'
A row or cellThis menuNone of the above applies

The grid offers a long-press to its plugins after 400 ms, and only suppresses the browser’s contextmenu if one of them claims it. So this menu needs no opt-in — it opens whenever nothing else wanted the gesture.

See the Touch input guide for the full gesture table.