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.
Installation
Section titled “Installation”import '@toolbox-web/grid/features/context-menu';Basic Usage
Section titled “Basic Usage”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) }, ], }, },};import '@toolbox-web/grid-react/features/context-menu';import { DataGrid } from '@toolbox-web/grid-react';import type { GridConfig } from '@toolbox-web/grid-react';
function MyGrid({ data, onDelete }) { const gridConfig: 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) => onDelete(ctx.rowIndex) }, ], }, }, };
return <DataGrid rows={data} gridConfig={gridConfig} style={{ height: '400px' }} />;}<script setup>import '@toolbox-web/grid-vue/features/context-menu';import { TbwGrid } from '@toolbox-web/grid-vue';import type { GridConfig } from '@toolbox-web/grid-vue';
const data = [ { name: 'Alice', email: 'alice@example.com', status: 'active' }, { name: 'Bob', email: 'bob@example.com', status: 'inactive' },];
const gridConfig: 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) => console.log('Delete row:', ctx.rowIndex) }, ], }, },};</script>
<template> <TbwGrid :rows="data" :grid-config="gridConfig" style="height: 400px" /></template>import '@toolbox-web/grid-angular/features/context-menu';import { Component, output } from '@angular/core';import { Grid } from '@toolbox-web/grid-angular';import type { GridConfig } from '@toolbox-web/grid-angular';import type { ContextMenuItem } from '@toolbox-web/grid/plugins/context-menu';
@Component({ selector: 'app-my-grid', imports: [Grid], template: ` <tbw-grid [rows]="rows" [gridConfig]="gridConfig" style="height: 400px; display: block;"> </tbw-grid> `,})export class MyGridComponent { deleteRow = output<number>(); rows = [...];
gridConfig: GridConfig = { columns: [ { field: 'name', header: 'Name' }, { field: 'email', header: 'Email' }, { field: 'status', header: 'Status' } ], features: { contextMenu: { items: [ { id: 'copy', name: 'Copy Cell', action: (ctx: any) => navigator.clipboard.writeText(String(ctx.value)) }, { id: 'sep1', name: '', separator: true }, { id: 'delete', name: 'Delete Row', action: (ctx: any) => this.deleteRow.emit(ctx.rowIndex) }, ] as ContextMenuItem[], }, }, };}Default Context Menu
Section titled “Default Context Menu”<tbw-grid style="height: 350px;"></tbw-grid>import '@toolbox-web/grid';import { queryGrid } from '@toolbox-web/grid';
import '@toolbox-web/grid/features/context-menu';
// Sample data for context menu demosconst sampleData = [ { id: 1, name: 'Alice', email: 'alice@example.com', status: 'active' }, { id: 2, name: 'Bob', email: 'bob@example.com', status: 'pending' }, { id: 3, name: 'Carol', email: 'carol@example.com', status: 'active' }, { id: 4, name: 'Dan', email: 'dan@example.com', status: 'inactive' },];const columns = [ { field: 'id', header: 'ID', type: 'number' }, { field: 'name', header: 'Name' }, { field: 'email', header: 'Email' }, { field: 'status', header: 'Status' },];// Default menu itemsconst defaultMenuItems = [ { id: 'copy', name: 'Copy Row', icon: '📋', shortcut: 'Ctrl+C', action: (params: ContextMenuParams) => console.log('Copy', params.row), }, { id: 'edit', name: 'Edit Row', icon: '✏️', action: (params: ContextMenuParams) => console.log('Edit', params.row) }, { id: 'sep1', name: '', separator: true }, { id: 'duplicate', name: 'Duplicate', icon: '📄', action: (params: ContextMenuParams) => console.log('Duplicate', params.row), }, { id: 'sep2', name: '', separator: true }, { id: 'delete', name: 'Delete', icon: '🗑️', cssClass: 'danger', action: (params: ContextMenuParams) => console.log('Delete', params.row), },];
const grid = queryGrid('tbw-grid');
grid.gridConfig = { columns, features: { contextMenu: { items: defaultMenuItems } }, }; grid.rows = sampleData;Right-click any cell to see the context menu.
With Submenus
Section titled “With Submenus”<tbw-grid style="height: 350px;"></tbw-grid>import '@toolbox-web/grid';import { queryGrid } from '@toolbox-web/grid';
import '@toolbox-web/grid/features/context-menu';
// Sample data for context menu demosconst sampleData = [ { id: 1, name: 'Alice', email: 'alice@example.com', status: 'active' }, { id: 2, name: 'Bob', email: 'bob@example.com', status: 'pending' }, { id: 3, name: 'Carol', email: 'carol@example.com', status: 'active' }, { id: 4, name: 'Dan', email: 'dan@example.com', status: 'inactive' },];const columns = [ { field: 'id', header: 'ID', type: 'number' }, { field: 'name', header: 'Name' }, { field: 'email', header: 'Email' }, { field: 'status', header: 'Status' },];const grid = queryGrid('tbw-grid');
const menuItems = [ { id: 'view', name: 'View', icon: '👁️', action: () => console.log('View') }, { id: 'edit', name: 'Edit', icon: '✏️', action: () => console.log('Edit') }, { id: 'sep1', name: '', separator: true }, { id: 'export', name: 'Export', icon: '📤', subMenu: [ { id: 'csv', name: 'As CSV', action: () => console.log('Export CSV') }, { id: 'json', name: 'As JSON', action: () => console.log('Export JSON') }, { id: 'excel', name: 'As Excel', action: () => console.log('Export Excel') }, ], }, { id: 'share', name: 'Share', icon: '🔗', subMenu: [ { id: 'email', name: 'Email', icon: '📧', action: () => console.log('Share Email') }, { id: 'slack', name: 'Slack', icon: '💬', action: () => console.log('Share Slack') }, ], }, ];
grid.gridConfig = { columns, features: { contextMenu: { items: menuItems } }, }; grid.rows = sampleData;Nested menu items for complex actions.
Conditional Items
Section titled “Conditional Items”<tbw-grid style="height: 350px;"></tbw-grid>import '@toolbox-web/grid';import { queryGrid } from '@toolbox-web/grid';
import '@toolbox-web/grid/features/context-menu';
// Sample data for context menu demosconst sampleData = [ { id: 1, name: 'Alice', email: 'alice@example.com', status: 'active' }, { id: 2, name: 'Bob', email: 'bob@example.com', status: 'pending' }, { id: 3, name: 'Carol', email: 'carol@example.com', status: 'active' }, { id: 4, name: 'Dan', email: 'dan@example.com', status: 'inactive' },];const columns = [ { field: 'id', header: 'ID', type: 'number' }, { field: 'name', header: 'Name' }, { field: 'email', header: 'Email' }, { field: 'status', header: 'Status' },];const grid = queryGrid('tbw-grid');
const menuItems = [ { id: 'activate', name: 'Activate', icon: '✅', disabled: (params) => (params.row)?.status === 'active', action: (p) => console.log('Activate', p.row), }, { id: 'deactivate', name: 'Deactivate', icon: '⏸️', disabled: (params) => (params.row)?.status !== 'active', action: (p) => console.log('Deactivate', p.row), }, { id: 'sep1', name: '', separator: true }, { id: 'delete', name: 'Delete', icon: '🗑️', cssClass: 'danger', action: (p) => console.log('Delete', p.row) }, ];
grid.gridConfig = { columns, features: { contextMenu: { items: menuItems } }, }; grid.rows = sampleData;Show/hide items based on context.
Grouped Menu
Section titled “Grouped Menu”<tbw-grid style="height: 350px;"></tbw-grid>import '@toolbox-web/grid';import { queryGrid } from '@toolbox-web/grid';
import '@toolbox-web/grid/features/context-menu';import '@toolbox-web/grid/features/selection';
const sampleData = [ { id: 1, name: 'Alice', email: 'alice@example.com', status: 'active' }, { id: 2, name: 'Bob', email: 'bob@example.com', status: 'pending' }, { id: 3, name: 'Carol', email: 'carol@example.com', status: 'active' }, { id: 4, name: 'Dan', email: 'dan@example.com', status: 'inactive' },];const columns = [ { field: 'id', header: 'ID', type: 'number' }, { field: 'name', header: 'Name' }, { field: 'email', header: 'Email' }, { field: 'status', header: 'Status' },];const grid = queryGrid('tbw-grid');
// The "selected rows" group only makes sense for a multi-row selection. Its// items hide themselves otherwise, and the now-empty header is dropped automatically.const singleRow = (p: ContextMenuParams) => p.selectedRows.length < 2;
const items = (params: ContextMenuParams): ContextMenuItem[] => [ { id: 'cell', name: '', separator: true, label: 'Cell' }, { id: 'copy-cell', name: 'Copy value', icon: '📋', action: (p) => console.log('Copy', p.value) }, { id: 'reset-cell', name: 'Reset value', icon: '↺', action: (p) => console.log('Reset', p.field) }, { id: 'row', name: '', separator: true, label: 'Row' }, { id: 'edit-row', name: 'Edit row', icon: '✏️', action: (p) => console.log('Edit', p.row) }, { id: 'history', name: 'Show history', icon: '🕘', action: (p) => console.log('History', p.row) }, { id: 'rows', name: '', separator: true, label: `${params.selectedRows.length} selected rows` }, { id: 'export-rows', name: 'Export selected', icon: '📤', hidden: singleRow, action: (p) => console.log('Export', p.selectedRows), }, { id: 'delete-rows', name: 'Delete selected', icon: '🗑️', cssClass: 'danger', hidden: singleRow, action: (p) => console.log('Delete', p.selectedRows), },];
grid.gridConfig = { columns, features: { selection: 'row', contextMenu: { items } },};grid.rows = sampleData;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.
Plugin-Contributed Items
Section titled “Plugin-Contributed Items”Right-click any cell to see the context menu. Items from FilteringPlugin, VisibilityPlugin, and PinnedColumnsPlugin are automatically contributed alongside custom items.
<tbw-grid style="height: 350px;"></tbw-grid> import '@toolbox-web/grid';import { queryGrid } from '@toolbox-web/grid';import '@toolbox-web/grid/features/context-menu';import '@toolbox-web/grid/features/filtering';import '@toolbox-web/grid/features/pinned-columns';import '@toolbox-web/grid/features/shell';import '@toolbox-web/grid/features/visibility';
const grid = queryGrid('tbw-grid'); if (grid) { grid.gridConfig = { columns: [ { field: 'id', header: 'ID', type: 'number', width: 80 }, { field: 'name', header: 'Name', sortable: true }, { field: 'email', header: 'Email', sortable: true }, { field: 'status', header: 'Status', sortable: true }, ], features: { shell: true, filtering: true, visibility: true, pinnedColumns: true, contextMenu: { items: [ { id: 'copy-row', name: 'Copy Row', icon: '📋', action: (params) => { console.log('Copy row:', params.row); }, }, { id: 'highlight', name: 'Highlight Row', icon: '🔆', action: (params) => { const rowEl = params.rowElement; if (rowEl) { rowEl.style.background = 'light-dark(#fff3cd, #4a3f00)'; setTimeout(() => { rowEl.style.background = ''; }, 2000); } }, }, ], }, }, };
grid.rows = [ { id: 1, name: 'Alice Johnson', email: 'alice@example.com', status: 'active' }, { id: 2, name: 'Bob Smith', email: 'bob@example.com', status: 'pending' }, { id: 3, name: 'Carol Davis', email: 'carol@example.com', status: 'active' }, { id: 4, name: 'Dan Wilson', email: 'dan@example.com', status: 'inactive' }, { id: 5, name: 'Eve Brown', email: 'eve@example.com', status: 'active' }, { id: 6, name: 'Frank Miller', email: 'frank@example.com', status: 'pending' }, ]; }Plugins like FilteringPlugin, VisibilityPlugin, and PinnedColumnsPlugin can contribute their own items to the context menu automatically.
Configuration Options
Section titled “Configuration Options”| Option | Type | Default | Description |
|---|---|---|---|
items | ContextMenuItem[] | Copy + Export CSV | Menu items to display |
TypeScript Interfaces
Section titled “TypeScript Interfaces”ContextMenuItem— Menu item definition (id, label, icon, action, submenus, etc.)ContextMenuParams— Context passed to action callbacks (row, column, value, selection, etc.)ContextMenuConfig— Plugin configuration options
Selection Sync
Section titled “Selection Sync”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 loaded | selectedRows = [rowIndex] |
| A header | selectedRows = [] |
This uses the plugin query system for loose coupling — no hard dependency on SelectionPlugin.
Conditional Items
Section titled “Conditional Items”features: { contextMenu: { items: [ { id: 'edit', name: 'Edit', hidden: (params) => params.column.editable !== true, }, { id: 'delete', name: 'Delete', disabled: (params) => params.row.locked === true, }, ], },},Group Headers
Section titled “Group Headers”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: truewithoutlabel) 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
hiddenfor 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 insidesubMenuas well.
Events
Section titled “Events”| Event | Detail | Description |
|---|---|---|
context-menu-open | { params, items } | Menu opened |
Styling
Section titled “Styling”The context menu supports CSS custom properties for theming. Override these on tbw-grid or a parent container:
CSS Custom Properties
Section titled “CSS Custom Properties”| Property | Default | Description |
|---|---|---|
--tbw-context-menu-bg | var(--tbw-color-panel-bg) | Menu background |
--tbw-context-menu-fg | var(--tbw-color-fg) | Menu text color |
--tbw-context-menu-border | var(--tbw-color-border) | Menu border |
--tbw-context-menu-hover | var(--tbw-color-row-hover) | Item hover background |
--tbw-context-menu-group-header-fg | var(--tbw-context-menu-muted, var(--tbw-color-fg-muted)) | Group header label color |
--tbw-context-menu-group-header-size | var(--tbw-font-size-xs) | Group header label size |
--tbw-menu-min-width | 10rem | Minimum menu width |
--tbw-menu-item-padding | 0.375rem 0.75rem | Menu item padding |
--tbw-menu-item-gap | 0.5rem | Gap between icon and label |
--tbw-font-size-sm | 0.9285em | Menu font size |
--tbw-font-size-xs | 0.7857em | Shortcut text size |
--tbw-icon-size | 1em | Icon width |
Example
Section titled “Example”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;}CSS Classes
Section titled “CSS Classes”The menu uses these class names for advanced customization:
| Class | Element |
|---|---|
.tbw-context-menu | Menu container |
.tbw-context-menu-item | Menu item row |
.tbw-context-menu-item.disabled | Disabled item |
.tbw-context-menu-item.danger | Danger/delete action |
.tbw-context-menu-icon | Item icon container |
.tbw-context-menu-label | Item label text |
.tbw-context-menu-shortcut | Keyboard shortcut |
.tbw-context-menu-separator | Divider line |
.tbw-context-menu-separator.tbw-context-menu-group-header | Group header (labelled divider) |
.tbw-context-menu-group-label | Group header label text |
Accessibility
Section titled “Accessibility”The popup follows the WAI-ARIA Menu pattern:
- The popup is
role="menu"; items arerole="menuitem"(tabindex="-1", focus moved between them by the keyboard), dividers arerole="separator", group headers arerole="separator"named byaria-label(the visible label), and disabled items carryaria-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).
Touch input
Section titled “Touch input”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-press | What opens | When |
|---|---|---|
| A header cell | Column header menu | Once the header-menu plugin ships (tracked in #270) |
| A row | Touch selection mode | SelectionPlugin is registered with mode: 'row' |
| A cell | Cell range painting | SelectionPlugin is registered with mode: 'cell' or 'range' |
| A row or cell | This menu | None 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.
See Also
Section titled “See Also”- Selection — Row and cell selection
- Editing — Inline cell editing
- Clipboard — Copy/paste cells
- Touch input — Long-press and gesture reference