Lattice Grid Buy a licence

api reference

Editing and selection: JavaScript Data Grid API

grid.edit and validation, grid.selection and ranges, grid.history, column permissions, and your own menu items.

API reference › Editing and selection

grid.edit

MethodReturnsDescription
start(key, colId)voidOpen an edit session. The row must be rendered.
stop(cancel?, opts?)objectCommit or discard. Pass { value, key, colId } to write a value.
undo() / redo()voidDepth from edit.undoDepth.
setCells(writes, type?)numberWrite many cells as one undoable step. Returns how many landed.
pasteInto(anchor, text, extent?)numberPaste tab-separated text, using Excel's tiling rules.
previewPaste(anchor, text, extent?)objectCompute what a paste would change without committing: { changes, rejected }. The engine behind edit.pastePreview.
pastePreviewbooleanWhether a bulk paste is previewed before it commits (edit.pastePreview).
settle(id, ok, reason?)booleanReport the outcome of an optimistic write. Only needed with edit.confirm: 'manual'; the id arrives on cell:pending.
pending()OpenWrite[]Writes still awaiting an outcome. Empty unless edit.commit is set.
status(key, colId)'pending' | nullWhether a cell has a write in flight.
addRow(row)string | nullAppend a row optimistically and persist it (over a source declaring mutate.append). Returns the client temp key; on the server key it fires row:confirmed after rekeying selection, expansion, focus and in-flight cell edits. null when append is unavailable.
deleteRow(key)string | nullDelete a row optimistically and persist it (over a source declaring mutate.delete). Tombstones then confirms, or restores on refusal. null when delete is unavailable.
deleteRows(keys?, opts?)string[] | PromiseThe user-gesture delete (what the Delete key and the "Delete row" menu item call), through the cancellable beforeDelete event - on a memory grid as well as a remote one. Off until config.rowDelete. Keys default to the selection; returns the keys removed (empty on a veto or when disabled), or a Promise when a handler deferred.
settleRow(id, ok, reason?, reconcile?)booleanReport the outcome of a structural op. Only needed with edit.confirm: 'manual'; the id arrives on row:pending.
rowStatus(key)'pending' | nullWhether a row has an append/delete in flight.
pendingRows()OpenRowOp[]Structural ops still awaiting an outcome. Empty unless the source can append or delete.

Appending and deleting rows. Over a remote source whose adapter declares mutate.append/mutate.delete, grid.edit.addRow and grid.edit.deleteRow are the structural counterparts of the cell edit path. An appended row shows at once under a client temp key; when the server hands back the real key the row is rekeyed everywhere the grid tracks it - the source row, selection, expansion, focus and any in-flight cell edits all follow - and row:confirmed fires. A delete tombstones the row immediately and either purges it on confirmation or restores it on refusal. The example drives both against a mock adapter, and shows the rekey moving a selection:

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createPushdownSource } = await import('../packages/core/src/source/pushdown.js');

// A mock adapter that persists append and delete. append returns the server key.
let nextId = 1;
const adapter = {
  name: 'mock',
  capabilities: { sort: true, mutate: { append: true, delete: true, returning: 'key' } },
  async execute() { return { rows: [], total: 0 }; },
  async mutate(op) {
    if (op.kind === 'append') return { ok: true, keys: [`srv-${nextId++}`] };
    return { ok: true };   // delete confirmed
  },
};

const grid = createHeadlessGrid({
  rowKey: 'id',
  columns: [{ field: 'id' }, { field: 'name' }],
  source: createPushdownSource({ adapter, edit: true }),
  selection: 'multiple',
  edit: true,
});

// The structural lifecycle events, bridged onto the grid's own bus.
const fired = [];
grid.on('row:pending', (e) => fired.push(`pending:${e.kind}`));
grid.on('row:confirmed', (e) => fired.push(`confirmed:${e.kind}`));
grid.on('row:reverted', (e) => fired.push(`reverted:${e.kind}`));
grid.on('row:conflict', () => fired.push('conflict'));

// Append: shows immediately under a temp key, then rekeys to the server key.
const temp = grid.edit.addRow({ name: 'Ada' });
grid.selection.set([temp]);                  // select the optimistic row
await new Promise((r) => setTimeout(r, 0));   // let mutate resolve; row:confirmed fires
const movedTo = grid.selection.keys()[0];    // selection followed the rekey

// Delete: tombstones then confirms.
grid.edit.deleteRow(movedTo);
await new Promise((r) => setTimeout(r, 0));
const gone = grid.rows.byKey(movedTo) === undefined;

grid.destroy();
// fired: pending:append, confirmed:append, pending:delete, confirmed:delete
return `${movedTo} selected; ${gone ? 'deleted' : 'still-there'}`;

The built-in delete gesture. deleteRow above only fires beforeDelete on a remote source. For the common case - letting a user delete rows of a memory grid with the Delete key or a "Delete row" menu item, confirmed through the same beforeDelete hook - set config.rowDelete: true. It is off by default because deleting data on a keystroke is destructive; every deletion still flows through beforeDelete, so a handler can confirm or veto it with preventDefault(reason). grid.edit.deleteRows is the programmatic entry.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');

const grid = createHeadlessGrid({
  columns: [{ field: 'id' }],
  rows: [{ id: 'a' }, { id: 'b' }, { id: 'c' }],
  rowKey: 'id',
  selection: 'multiple',
  rowDelete: true,   // opt in to the Delete-key / menu gesture and the API
});

// A confirm hook that vetoes deleting row 'b' but allows the rest.
grid.on('beforeDelete', (e) => { if (e.rows.includes('b')) e.preventDefault('kept'); });

grid.edit.deleteRows(['a']);   // allowed - 'a' is removed
grid.edit.deleteRows(['b']);   // vetoed - 'b' stays, delete:cancelled fires

const count = grid.rows.count();
grid.destroy();
return `${count} rows`;   // 2 rows: 'b' and 'c'

Previewing a bulk paste. A paste can rewrite dozens of cells at once, and one that lands somewhere unexpected looks exactly like one that worked. Set edit.pastePreview: true and a paste into more than one cell opens a confirm/cancel dialog first, listing every cell that changes (old → new) and every cell a commit would reject. It is off by default, so existing paste behaviour is unchanged. previewPaste computes that same diff without any UI:

const { createHeadlessGrid } = await import('../packages/core/src/index.js');

const grid = createHeadlessGrid({
  // A per-row rule: the middle row is locked, so a paste over it is refused.
  columns: [{ field: 'a', edit: { enabled: (p) => p.row.data.locked !== true } }],
  rows: [
    { id: '0', a: 'A0', locked: false },
    { id: '1', a: 'A1', locked: true },
    { id: '2', a: 'A2', locked: false },
  ],
  rowKey: 'id',
  // Opt in: off by default, so a plain paste is unaffected.
  edit: { enabled: true, pastePreview: true },
});

// What *would* happen - nothing is committed yet.
const preview = grid.edit.previewPaste({ key: '0', colId: 'a' }, 'X\nY\nZ');

// Confirming is setCells of the changes - the ordinary paste path.
grid.edit.setCells(preview.changes.map((c) => ({ key: c.key, colId: c.colId, value: c.newValue })), 'paste');

const changed = preview.changes.filter((c) => c.changed).length;
return `${changed} change, ${preview.rejected.length} rejected`;   // rows 0 and 2 change; row 1 refused

grid.validation

Declarative column validation. Where edit.validate is an imperative function you write, this is the same job said as data: required, min/max, minLength/maxLength, pattern, oneOf, and a crossField predicate, declared per column in validation. Each rule is checked against a new value before it is written, riding the cancellable beforeEdit before-event: a failing value cancels the commit so no cell is written, marks the cell with the grid's ordinary invalid state (an accessible error, not only a red border), and fires validation:failed. A corrected value clears the mark and, where you are watching, fires validation:cleared. The cancellation carries reason: 'validation:<code>', so a host logging cancellations can tell a validation veto from any other.

Only a user-initiated edit is gated, the same contract beforeEdit itself keeps: a host API write (grid.edit.setCells) and a remote/router-applied delta are the authority and do not self-veto. A grid whose columns declare no rules wires no gate and keeps the byte-for-byte synchronous edit path.

// Declared per column, config-time.
createGrid(el, {
  columns: [
    { field: 'name', edit: true, validation: { required: true, minLength: 2 } },
    { field: 'age',  type: 'number', edit: true, validation: { min: 0, max: 120 } },
    { field: 'code', edit: true, validation: { pattern: '^[A-Z]{3}$', messages: { pattern: 'Three capitals.' } } },
  ],
});

grid.validation.check('age', 200);          // { code: 'max', message: 'Must be at most 120.' } - records nothing
grid.validation.errorFor('r1', 'age');       // the recorded error for a cell, or null
grid.validation.errors();                    // every cell that currently holds an error
grid.validation.define('age', { min: 18 }); // set or replace a column's rules at runtime
grid.validation.clear('r1', 'age');          // drop a mark by hand
const { createHeadlessGrid } = await import('../packages/core/src/index.js');

const grid = createHeadlessGrid({
  columns: [{ field: 'id' }, { field: 'age', type: 'number', edit: true, validation: { min: 0, max: 120 } }],
  rowKey: 'id',
  rows: [{ id: 'r1', age: 30 }],
});

let failed = 0;
grid.on('validation:failed', () => { failed++; });
grid.on('validation:cleared', () => {});

// A user edit that breaks the rule is refused: the cell is not written.
grid.edit.start('r1', 'age'); grid.edit.stop(false, { value: 999 });
const blocked = grid.rows.byKey('r1').data.age;              // still 30
const why = grid.validation.errorFor('r1', 'age').code;      // 'max'

// A valid value writes through and clears the mark.
grid.edit.start('r1', 'age'); grid.edit.stop(false, { value: 40 });
const now = grid.rows.byKey('r1').data.age;                  // 40

grid.destroy();
return [blocked, why, now, failed].join('|');

grid.selection

MethodReturnsDescription
keys()string[]Selected row keys.
rows()Row[]
all()Row[]Including rows selected but currently filtered out.
set(keys)voidReplace the selection. In mode: 'single', only the first key of the array is kept; the rest are dropped, they are not an error.
clear()void
ranges()Range[]Cell ranges, for spreadsheet-style selection.
setRange(range)voidReplace every range with one.
addRange(range)voidAdd a range without discarding the others, the API form of ctrl-click. Becomes the anchor extendRange grows.
startRange(rowIndex, colId, opts)voidBegin a range at a cell. opts.additive keeps the existing ranges.
extendRange(rowIndex, colId)voidExtend the newest range, keeping its anchor.
corner(){ row, colId } | nullBottom-right cell of the newest range, where the fill handle sits.
inRange(rowIndex, colId)booleanIs a cell inside any selected range?
cells(){ key, colId }[]Every cell in the selected ranges.
statistics()object | nullEverything summary() reports plus median, quartiles, deviation, distinct and outliers: over the selected cells, so a rectangle spanning three columns is one set of numbers. Null with nothing selected.
summary()objectcount, sum, min, max, avg over the range.

grid.history

Undo across the whole grid, not only edits. Sorts, filters, column moves, grouping, an applied view and a restore all record an entry, and each carries a label written for a button: "sort by Region", not "sort".

MethodReturnsDescription
undo()object | nullThe entry that was undone.
redo()object | null
canUndo() / canRedo()boolean
peek(direction?)object | nullWhat the next undo or redo would do, so a control can name it before it is pressed.
list()object[]The timeline, newest first.
transaction(label, fn)object | nullGroup several changes into one entry. Nested transactions join the outer one.
clear()void

A multi-cell paste is one entry, not one per cell. An AI plan is one entry however many actions it contains, labelled with what it did.

grid.permissions

Four levels per column, resolved from configuration or a callback. They are the four corners of read × write rather than a ladder:

LevelVisibleReadableEditableFor
hidden, , , Absent from the grid, the tool panel, exports, the clipboard, saved state, the filter model and formula references.
readyesyes, No editor opens; paste, fill and range-clear skip it.
writeOnlyyes, yesA secret: an API key a user may rotate but never read. The cell shows a mask and the editor opens empty, and a formula in another cell cannot reference it.
writeyesyesyesThe default, so the feature is opt-in.
permissions: 'read'                                  // blanket
permissions: { salary: 'read', ssn: 'hidden' }       // map; '*' sets the default
permissions: (column, ctx) => ctx.context.role === 'admin' ? 'write' : 'read'
permissions: { default: 'read', columns: { name: 'write' }, resolve }

grid.permissions.setContext({ role: 'clerk' });      // re-resolves everything

For three of the four this is a usability control, not a security boundary. Anything the grid can render it has already loaded, and devtools reaches it. writeOnly is the exception, and the reason it is worth having: nothing in the grid needs the value, so your server can send null for that field and the column still works: at which point the secret is genuinely not on the page. Enforce everything else on the server; permittedColumns and permittedExport are pure and dependency-free so the same policy can run there.

Your own menu items and buttons

The cell menu's function form is handed the cell that was clicked and the built-in items, so adding one entry does not mean reproducing the other thirteen.

createGrid(el, {
  contextMenu: (params, defaults) => [
    ...defaults,
    { separator: true },
    {
      name: `Open ${params.value} in CRM`,
      action: (ctx) => open(`/crm/${ctx.data.accountId}`),
    },
  ],
});

params and the action's argument carry the same cell: { key, colId, value, row, data, column, index, grid }, where data is your original row object. Return the array you want shown: add, remove, reorder or replace. Returning an empty array suppresses the menu; returning nothing at all leaves the defaults alone, so a missing return cannot silently delete the menu.

The same option is accepted on a column definition, so a column's menu is declared where the column is rather than as one more branch inside a single grid-level callback. It takes the same shapes plus a bare array for the common “these items here too” case: boolean | MenuItem[] | (params, defaults) => items.

createGrid(el, {
  columns: [
    { field: 'owner', contextMenu: [{ name: 'Reassign', action: reassign }] },   // appended after the grid's items
    { field: 'amount', contextMenu: (p, defaults) => [...defaults, { name: 'Reprice', action: reprice }] },
    { field: 'ref', contextMenu: () => [{ name: 'Copy reference', action: copyRef }] },   // only this: ignore defaults
    { field: 'nationalId', contextMenu: false },   // no menu on this column, others unaffected
  ],
});

The three levels compose as a chain: built-in defaults, then the grid-level contextMenu, then the column's - each handed the previous result as its defaults, so a column adding one item never restates the built-ins. The array form chains too: contextMenu: [items] on a column is exactly contextMenu: (p, defaults) => [...defaults, ...items], so the built-ins and every grid-level item stay and the column's items follow them, in the order written. To replace a column's menu instead, use the function form and ignore defaults: contextMenu: () => items. (Earlier releases let an array replace the grid-level items; the two forms now agree.) Suppression follows the same order and the more specific level wins: false on a column is a statement about that column alone. The reverse holds too, and is worth knowing before you rely on grid-level contextMenu: false as a safety property: a column that declares its own contextMenu opens one anyway. Grid-level false is a default, not a lock - it is what makes “no menu anywhere except here” expressible. On a right-click inside a multi-column selection the clicked column's menu is the one that opens - not the intersection, which loses items, and not the union, which offers actions wrong for most of the selection. A group row, pivot group row or full-width row belongs to no column, so the chain has one link fewer and the grid-level menu stands. The keyboard routes (Shift+F10 and the Context Menu key) honour the column exactly as the pointer does. See the guide for the full table of combinations.

The empty tail of a row is the row's. When the columns do not fill the grid's width, each row has an empty area to the right of the last column. A right-click there opens the grid's menu for that row, never the browser's, exactly as a right-click on a group row does: there is no column under the pointer, so the column link is missing from the chain and the grid-level menu stands, and cell:contextmenu (and so a builder's params) carries colId: null, column: undefined and value: undefined with the row, key and index filled in. The built-in items that act on a cell - Paste, Clear, Fill down, Edit cell - are not offered there (there is no cell for them to act on); the row and grid items are. A builder that reads params.column should expect it to be absent there. The area below the last row belongs to no row and keeps the browser's menu. On 1.54 and earlier a right-click in the tail fell through to the browser's menu, which looked as though the grid had none; on those versions give one column layout: { flex: 1 } so the cells reach the edge and there is no tail to click.

Expand all and Collapse all are built-in row/grid items: present, in this order, on the row context menu and on every column's header menu (the 3-dot button and a right-click on the heading) whenever the grid is grouped, hidden rather than disabled otherwise. Each drives the public grid.rows.expandAll() / collapseAll(), is matched by its translated name like any other built-in item (catalogue keys menu.expandAll / menu.collapseAll), and passes through the same contextMenu / columnMenu chain above.

columnMenu takes the same form for the header's menu: both the 3-dot button and a right-click on a heading. Its params is { colId, column, grid }. Anything of your own that you put on a column definition is on column.def, so an item can appear on some columns and not others.

createGrid(el, {
  columns: [{ field: 'jan', title: 'Jan', context: { month: 1 } }],
  columnMenu: (params, defaults) => {
    // Your own keys live 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) }];
  },
});

The rail takes host buttons the same way. A string names a built-in and an object is yours, placed where it appears in the list rather than appended after the built-ins.

createGrid(el, {
  toolPanel: {
    side: 'left',
    actions: ['undo', 'redo', {
      name: 'sync',
      title: 'Sync to the server',        // or a function, re-read on every repaint
      icon: 'restore',
      run: ({ grid, keys, cells }) => api.sync(keys),
      enabled: () => grid.history.canUndo(),
    }],
  },
});

An icon naming a sprite the registry does not have draws the blank glyph and logs a [lattice] warning once, naming the icon and how to register it - it does not fail silently as an empty, still-clickable button.

grid.form

The row edit form, a drawer or dialog holding one control per field. Present whether or not rowForm is configured; without it every method declines rather than throwing, so a caller need not guard. See Editing a row on a form.

MethodReturnsDescription
open(key)booleanOpen a row by key. False if there is no such row, or no form is configured.
close()voidClose without saving. Focus returns to where it was.
save()booleanCommit the fields and close. False if a validator refused, or there is nothing to save.
isOpen()booleanWhether the panel is showing.