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 crosshair across one chart, or across several
cursor: true puts a hairline on a cartesian chart that follows the pointer, snaps
to the nearest reading rather than the nearest pixel, is labelled at the axis in
the axis's own format, and opens a tooltip listing every series at that x. The object form
adds y (a horizontal hairline), snap: 'none', labels,
tooltip: 'single' and the two that make it a dashboard tool:
group and rowHover.
Charts that share a group share the cursor by value. Hover one
at 14:05 and every other chart in the group puts its hairline at 14:05 - on its own
scale, with its own axis label - and a chart that does not cover 14:05 shows nothing, not
its nearest edge. The charts may be bound to different grids and have different ranges. Only
the chart under the pointer opens its tooltip, so tooltips never stack up across a dashboard;
groupTooltips: 'all' on a chart makes it open its own tooltip too while it
follows the group ('pointer', the default, keeps it to the hairline).
zoom: true inside the cursor option carries one chart's zoom to the others that
also set it; it is off unless asked for. rowHover: true moves the cursor to the x
of the grid row under the pointer, so hovering a row of the table points at the same moment on
the chart.
Two charts of one incident, and a table that points into them
const cpu = createChart({
grid: metrics, container: '#cpu', type: 'line', x: 'at', y: 'cpu',
cursor: { group: 'incident', rowHover: true },
});
const errors = createChart({
grid: logs, container: '#errors', type: 'bar', x: 'at', y: 'errors',
cursor: { group: 'incident', tooltip: 'single' },
});
cpu.on('cursor:move', ({ x, values, source }) => status.set(x, values, source));
cpu.cursor.set(new Date('2026-10-01T14:05:00Z')); // errors follows
Why by value. A cursor shared by pixel points at two different moments the instant either chart is resized or zoomed, which is worse than none. The group carries what the reader pointed at - a category or an instant - and each chart finds it in its own scale. The arrow keys, Home, End and Esc drive the cursor while the chart has focus, and every deliberate move is read out through a polite live region as the tooltip's text.
Letting a reader draw on a chart
A chart's annotations state a standard the author of the dashboard chose - a
target, a spec limit, a mean. annotate is the other half: it gives whoever is
reading the chart a rail of tools to state an observation on it. Trend line,
horizontal and vertical levels, parallel channel, Fibonacci retracement, arrow, text, callout,
note, select/move and delete, on any cartesian type (and on combo and candlestick).
annotate: true is the whole rail on the left. The object form narrows it:
tools picks which buttons appear, always in the canonical rail order so two
charts never read differently; rail is 'left', 'top' or
'none' (the drawing tools and the pointer behaviour without the strip, for a host
driving them from its own toolbar); and readOnly paints what is in the state and
refuses every change - including building the rail, because a strip of buttons that do nothing
is worse than no strip at all. An individual drawing may carry readOnly of its
own, so a published annotation can be looked at and not moved.
A rail for a reader, and the same drawings read-only elsewhere
const chart = createChart({
grid, container: '#plot', type: 'line', x: 'day', y: 'price',
annotate: { tools: ['select', 'trendLine', 'fibonacci', 'delete'], rail: 'left' },
});
chart.on('annotation:added', ({ item }) => save(item));
// The same drawings on the wallboard beside it, which nobody may change.
board.setState(chart.state()); // plain JSON; survives JSON.stringify
board.update({ annotate: { readOnly: true } });
Why the anchors are readings and never pixels. A drawing stored in pixels
slides off the reading it was made about the first time anything changes the plot - a resize, a
rolling window advancing, a zoom, a theme that changes a font and the axis gutter with it. So
an anchor is { x, y } in the chart's own data space and the pixels are recomputed
from the live scales on every draw. On a category axis the pointer resolves to the nearest
band, so a line lands on the category the reader was pointing at; on a horizontal bar the
projection follows the swapped axes, so the same drag anchors to the same readings either way
round.
The point-to-point measure tool
measure is a fourth measuring tool: measure: true on the
spec, a measure button on the rail, or the M key with the chart focused.
Drag between two points for a band with Δx (a duration such as 3 d 4 h, a count
of categories, or the numeric difference), Δy in the y axis's own format, the percentage
change and the slope. Ends snap to data points (measure: { snap: false } to turn that
off), a dual-axis chart measures against the axis under the start point, and a measurement is
transient until pinned with Enter, a click on it or pin: true. The full description,
the keyboard model and a runnable example are in the chart guide in API.html.
Measurers: a span with a readout
measure-x, measure-y and measure-xy are
three more tools on the same rail, usable on their own with
annotate: { tools: ['measure-x', 'measure-y', 'measure-xy'] }. Each is a drawn item
like any other - anchored in data, with handles, movable, deletable, in chart.state() - that also carries a readout: measure-x a bar or point count and the
elapsed time between its two x anchors, measure-y the signed change and percentage
between its two y anchors, and measure-xy's rectangle both at once. On a candlestick
the count is the candle count; on every other cartesian type it is the point count. The readout is
never stored on the item - it is recomputed from the chart's currently bound data on every
draw, so it can never read a span of bars the chart no longer has - but it rides along on the
annotation:added, annotation:changed and annotation:removed
events as a readout field, so a host can show it in its own panel rather than reading
the chart's own painted label.
A volume figure joins the measure-x/measure-xy readout when one can be
found: name the column explicitly with annotate: { measure: { volume: 'vol' } }, or,
on a candlestick bound with a fifth measure beyond open, high, low and close, it is read as the
volume column with nothing to configure at all.
A price chart with the three measurers on their own
const chart = createChart({
grid, container: '#candles', type: 'candlestick', x: 'at',
measures: [
{ col: 'open', fn: 'last' }, { col: 'high', fn: 'max' },
{ col: 'low', fn: 'min' }, { col: 'close', fn: 'last' }, { col: 'vol' },
],
annotate: { tools: ['measure-x', 'measure-y', 'measure-xy'] },
});
chart.on('annotation:added', ({ item, readout }) => {
// readout: { count: 8, countUnit: 'bars', elapsedMs: …, elapsed: { unit: 'hours', count: 7 }, volume: 1240 }
panel.show(readout);
});
The readout's own arithmetic, run on its own
const { measureReadout } = await import('../packages/modules/charts/annotate-measure.js');
const categories = Array.from({ length: 8 }, (unused, i) => new Date(Date.UTC(2026, 0, 1, i)).toISOString());
const readout = measureReadout(
'measure-x',
[{ x: categories[0], y: 0 }, { x: categories[7], y: 0 }],
{ categories, band: true, timeAxis: true, candles: true, volumeSeries: null },
);
return `${readout.count} ${readout.countUnit}, ${readout.elapsed.count} ${readout.elapsed.unit}`;
Why the readout is never stored on the item. An item is its anchors, so a state saved a week ago and restored today still parses; a bar count written in at draw time would read the span as it was then, and a candle a rolling window has since dropped, or a filter has since hidden, would leave a stale figure sitting beside data that no longer backs it. So the readout is arithmetic over the anchors and whatever the chart is bound to right now - the same discipline the anchors themselves follow against pixels.
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.