developer guide
Custom Context Menu and Buttons in a Data Grid
Your own items sit alongside the built-in ones on the cell menu, the header menu and a menu declared on a single column, and your own buttons sit on the tool rail. Each action is handed what it was opened on, so it can act on that cell, that column or that selected range.
Developer guide › Editing › Custom Context Menu and Buttons in a Data Grid
Your own menu items and buttons
The cell menu's function form is handed the cell that was clicked and the built-in items. Adding one entry does not mean reproducing the other thirteen.
An item that acts on the cell it was opened on
createGrid(el, {
contextMenu: (params, defaults) => [
...defaults,
{ separator: true },
{
name: `Open ${params.value} in CRM`,
action: (ctx) => window.open(`/crm/${ctx.data.accountId}`),
},
],
});
params and the action's argument are the same shape:
{ key, colId, value, row, data, column, index, grid }. data is your
original row object, so an item can reach fields the grid never displayed.
An item with your own icon
createGrid(el, {
contextMenu: (params, defaults) => [
...defaults,
{ separator: true },
// A registered sprite name - the built-in items use these.
{ name: 'Download', icon: 'download', action: () => save(params.data) },
// A single character or emoji, rendered as text.
{ name: 'Star', icon: '★', action: () => star(params.data) },
// Your own markup - a Font Awesome glyph, an inline SVG, an image.
// It is inserted into the icon slot as an element, at the same trust
// as the item's action, and never into the label.
{ name: 'Export', icon: '<i class="fa-light fa-file-export"></i>', action: exportRow },
],
});
A MenuItem's icon accepts three forms, told apart automatically so
existing definitions keep working: a registered sprite name
('download'), a single character or emoji ('↑'),
or author-supplied element markup
('<i class="fa-light fa-download"></i>'). Markup is rendered as an
element rather than shown as text - the misbehaviour it replaces - and is written only into
the icon slot, so a definition can never inject markup into the label. It is trusted like the
item's action: a menu definition is code you wrote, not user data.
Handed the defaults, rather than replacing them. A builder that had to
return every item in order to append one would be written once as a copy of the built-ins and
would then drift from them, the copy keeps the menu it was forked from, and stops gaining
whatever the grid adds later. Spreading defaults costs one line and never goes
stale.
Return the array you want shown: add, remove, reorder, or replace outright. An empty array
suppresses the menu deliberately. Returning nothing leaves the defaults alone, because
a missing return is a typo and deleting the whole menu is a harsh reading of
one.
Declaring a menu on the column itself
A cell menu can also be declared on the column, with
contextMenu on the column definition. It takes the same shapes the grid-level
option takes, plus a bare array for the common “just these items here” case:
boolean | MenuItem[] | (params, defaults) => items.
Each column's menu logic beside the column it is about
createGrid(el, {
columns: [
{ field: 'account' },
// Just these items, here.
{ field: 'owner', contextMenu: [{ name: 'Reassign', action: reassign }] },
// Or the built-ins plus one, the same form the grid-level option takes.
{
field: 'amount',
contextMenu: (params, defaults) => [
...defaults,
{ separator: true },
{ name: `Reprice ${params.value}`, action: (ctx) => reprice(ctx.data) },
],
},
// And nothing at all on a column nobody should act on from here.
{ field: 'nationalId', contextMenu: false },
],
});
This adds no power the grid-level option did not have - params.colId and
params.column always let one callback branch by column. What it adds is
locality: the menu for a column is declared where the column is, instead of
collecting into one growing switch a long way from the thing it is about.
The three levels compose as a chain
The built-in items go in first, then the grid-level contextMenu, then the
column's own - each handed the previous level's result as its
defaults. A column that wants one extra item writes one extra item; it
never has to restate Paste, Clear and Fill down, nor whatever the grid-level builder just
added.
Built-ins → grid → column, executed
const { createTestDom, flushFrames } = await import('../packages/dom/src/renderer/testdom.js');
const { createGrid } = await import('../packages/dom/src/index.js');
const { root } = createTestDom({ width: 600, height: 300 });
let handedToTheColumn = [];
const grid = createGrid(root, {
rowKey: 'id',
rows: [{ id: 1, account: 'Acme', amount: 120 }],
columns: [
{ field: 'account' },
{
field: 'amount',
contextMenu: (params, defaults) => {
handedToTheColumn = defaults.map((d) => d.name);
return [...defaults, { name: 'from the column', action() {} }];
},
},
],
contextMenu: (params, defaults) => [...defaults, { name: 'from the grid', action() {} }],
});
flushFrames();
const row = grid.rows.get(0);
grid.emit('cell:contextmenu', {
row, key: row.key, index: 0, colId: 'amount',
column: grid.columns.get('amount'), value: 120,
event: { clientX: 10, clientY: 10, preventDefault() {} },
});
flushFrames();
// The column builder was handed the grid builder's output, not the raw
// built-ins: 'from the grid' is already in its `defaults`.
const chained = handedToTheColumn.includes('from the grid');
const shown = [...root.querySelectorAll('.lat-menu__item')]
.map((i) => String(i.textContent).trim());
grid.destroy();
return chained && shown.includes('from the column') ? 'grid|column' : 'broken';
Suppression follows the same order, and the more specific level wins.
contextMenu: false on a column is a statement about that column and no
other. Equally, a column may declare a menu on a grid whose contextMenu is
false - which is how you say “no menu anywhere except here”.
contextMenu: true on a column means “whatever came before”, so it
restores the built-in menu on a grid that turned it off.
| Grid level | Column level | What opens on that column |
|---|---|---|
| not set | not set | the built-in menu |
| a builder | not set | the builder's result |
| a builder | a builder | the column's builder, handed the grid builder's result |
| a builder | an array | the array - the grid level still ran, and was replaced |
| a builder, or not set | false | nothing - the column suppresses, and no other column is affected |
false | not set | nothing - the grid-level off stands, as it always has |
false | an array | the column's array. The column opts back in: grid-level false is a default, not a lock |
false | a builder | the builder's result, handed the built-in items as its defaults. The column opts back in |
false | true | the built-in menu. The column opts back in and asks for the defaults |
contextMenu: false on the grid is a default, not a lock. If
you set it as a safety property - a read-only grid, a screen where nobody should be
able to copy or clear from a right-click - be aware that a column declaring its own
contextMenu will still open one, because the more specific level wins in
both directions. That is deliberate: a read-only grid with one actionable column is a
real shape, and it is the only way to say “no menu anywhere except here”. But it
does mean grid-level false does not guarantee that no cell menu can open
anywhere - only that none opens unless a column asks for one. If you need the absolute
guarantee, do not declare contextMenu on any column.
A chain, not a replacement. If the column level replaced the grid level,
every column that wanted one extra item would have to restate everything the grid-level
builder does - and would then stop tracking it the first time it changed. This is the
same rule columnMenu already follows for the header: you are handed what came
before so you can add to it rather than reproduce it.
A range, and rows that belong to no column
On a multi-column selection, the column you right-clicked decides. Not the intersection of the selected columns' menus, which silently drops items; not their union, which offers actions that are wrong for most of the selection. The clicked column is the one the user pointed at, and it is the one that answers - the built-in range actions (Copy, Clear, Fill down) still act on the whole range as they always did.
A row with no owning column falls back to the grid-level menu. Group rows, pivot group rows and full-width rows do not belong to one column, so there is no column-level declaration to consult; the chain simply has one fewer link and the grid-level menu stands. Nothing errors and nothing silently shows an empty menu.
Every route honours the column: a right-click, and the keyboard's
Shift+F10 or Context Menu key on the focused cell.
The menu is a role="menu" of role="menuitem"s that takes focus and
closes on Escape wherever it was opened from.
Trust is unchanged. A MenuItem is the same object it always
was, including icon markup being trusted at the same level as
action. Declaring one on a column changes where it is written, not who
is trusted to write it: a column definition is your code, exactly as a grid config is.
A column preset or columnDefaults may supply contextMenu too, and
the column's own declaration outranks both - so a house rule like “no cell menu on
anything tagged sensitive” is written once.
columnMenu takes the same function form, for both routes into a column's menu:
the header's 3-dot button and a right-click on the heading. Its params is
{ colId, column, grid }, and the same rules apply: spread the defaults, return
an empty array to suppress, return nothing to leave them alone.
An item that appears on some columns and not others
createGrid(el, {
columns: [{ field: 'jan', title: 'Jan', context: { month: 1 } }],
columnMenu: (params, defaults) => {
// Your own keys are on the definition you wrote.
const month = params.column.def.context?.month;
if (!month) return defaults;
return [...defaults, { separator: true },
{ name: 'Select quarter', action: () => selectQuarter(month) }];
},
});
Your properties are on column.def, not on the column itself.
column is the grid's resolved interpretation of your definition and carries only
keys the grid understands; column.def is the object you wrote, untouched. Keeping
them apart means an application property can never collide with one the grid adds in a later
version, and you do not have to maintain a lookup table keyed by column id alongside the
columns themselves.
Chart a selected range
rangeChart turns a selected cell range into a chart - the spreadsheet gesture. It
is off by default; set it and the cell menu offers Chart selection, with
Alt+F1 as the keyboard route, whenever the selected range has a numeric
column to plot. The leading text column becomes the categories and the numeric columns beside
it become the measures; a hidden or unreadable column is never charted, and the chart is bound
to the band of rows the rectangle covers.
The DOM layer draws no charts - the charts module is optional and the page loads it - so
rangeChart carries the handler that draws. A function, or an object with
onChart, is called (grid, range); it typically calls
chartRange from modules/charts, which derives the chart from the
range and returns the live Chart.
Wiring the gesture to the charts module
import { chartRange } from '@toclocoinc/lattice-grid/modules/charts';
createGrid(el, {
columns, rows,
rangeChart(grid, range) {
// One numeric column → a bar; several → a grouped bar. Null when the
// range has nothing to measure, so guard before using it.
const chart = chartRange(grid, { container: '#chart', range });
if (chart) chart.update({ scheme: 'colourblind' });
},
});
Why a handler rather than a flag that just draws. The charts module is
optional by design - a page that never charts never loads it - so the DOM layer cannot draw a
chart itself without pulling the whole drawing surface into every bundle. Handing the drawing
back to the page keeps that promise, and it is the same seam createChart already
uses: the grid is handed to the charts module, never imported by it.
A button of your own on the rail
createGrid(el, {
toolPanel: {
side: 'left',
// A string names a built-in; an object is yours. Order is respected, so
// yours can sit between built-ins rather than only after them.
actions: ['undo', 'redo', {
name: 'sync',
title: 'Sync to the server',
icon: 'restore',
run: ({ grid, keys, cells }) => api.sync(keys),
enabled: () => grid.state.modified(),
}],
},
});
title and icon may each be a function, re-read on every repaint, for
a control whose meaning changes: that is how maximise becomes restore. enabled is
a predicate rather than a flag, so a button that cannot do anything greys itself out instead of
doing nothing when clicked.