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 guideEditing › 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 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.