Lattice Grid Buy a licence

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 levelColumn levelWhat opens on that column
not setnot setthe built-in menu
a buildernot setthe builder's result
a buildera builderthe column's builder, handed the grid builder's result
a builderan arraythe array - the grid level still ran, and was replaced
a builder, or not setfalsenothing - the column suppresses, and no other column is affected
falsenot setnothing - the grid-level off stands, as it always has
falsean arraythe column's array. The column opts back in: grid-level false is a default, not a lock
falsea builderthe builder's result, handed the built-in items as its defaults. The column opts back in
falsetruethe 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.