developer guide
Editing
Inline and row editing, validation, optimistic writes with rollback, cell ranges, the fill handle, and formulas typed into a cell.
Developer guide › Editing
Editing
Turning it on
edit: { enabled: true }, // double-click, the default
edit: { enabled: true, start: 'single' },// single click
editBar: true, // a spreadsheet-style input above the grid
columns: [
{ field: 'notes', edit: true },
{ field: 'statusId', edit: { editor: 'select' } },
{ field: 'quality', edit: { editor: 'rating', props: { max: 5, allowHalf: true } } },
{ field: 'circuitId', edit: false },
]
The editor is chosen from the type unless you name one. Enter commits and steps down, Tab commits and steps across, Escape cancels. A validator can refuse a value:
Validation
{ field: 'capacity', edit: {
validate: (p) => p.value > 0 || 'Capacity must be positive',
}}
Optimistic writes and rollback
The grid has always written optimistically without calling it that: an edit lands in the
model and is painted before anything else happens. What edit.commit adds is
durability: whether the write reached your server, and what to put back when it
did not.
Nothing changes unless you ask for it. With no commit hook
the grid behaves exactly as before: the value is written, history is recorded,
cell:changed fires, and there is no pending state to think about. Subscribe to
cell:changed, fire your request and ignore the result: that keeps working and
costs nothing.
The usual case, the promise is the answer
edit: {
enabled: true,
commit: async ({ key, colId, value }) => {
const res = await fetch(`/api/rows/${key}`, {
method: 'PATCH',
body: JSON.stringify({ [colId]: value }),
});
if (!res.ok) throw new Error(await res.text()); // throw → rolled back
},
}
Resolving confirms the write; throwing rolls it back and fires cell:reverted
with your error message as reason. A synchronous hook works too: returning
normally confirms, throwing reverts.
When the answer arrives elsewhere
edit: {
enabled: true,
confirm: 'manual', // the return value is ignored
commit: ({ id, key, colId, value }) => {
socket.send(JSON.stringify({ id, key, colId, value }));
},
}
socket.onmessage = (m) => {
const { id, ok, reason } = JSON.parse(m.data);
grid.edit.settle(id, ok, reason);
};
The mode is declared, never guessed. A websocket or event-sourced backend
acknowledges on a different channel from the one the write went out on, so there is no
promise to resolve. confirm: 'manual' says so explicitly. The grid does not infer
it from what commit returns, because then a synchronous hook that happens to
return nothing would leave every cell pending for ever with nothing in your code that looks
wrong. If a write does stay pending, you get a console warning naming the cell: tune the
threshold with pendingTimeout.
The states a write moves through
| State | Means | Event |
|---|---|---|
| pending | Applied and painted, not yet acknowledged. | cell:pending |
| confirmed | The server accepted it. Nothing is written back. | cell:confirmed |
| reverted | The server refused it; the cell is rolled back. | cell:reverted |
| superseded | A newer edit replaced it while it was in flight. | either, with superseded: true |
Rollback goes to the last confirmed value, not the previous one. This is
the part that is easy to get wrong by hand. Suppose a cell holding 1 is edited to
2, then to 3, then to 4, all before any answer comes
back. If the second write fails, restoring “the value before it” would put back
2, a value the server never held, and one the user has since replaced twice.
So each cell remembers the newest value a confirmation has actually vouched for, and a write
that a later edit has superseded reports its failure without writing anything back. You will
see cell:reverted with applied: false for those.
The rejected value travels on the event as rejected, so you can offer a retry
rather than losing what the user typed.
Two behaviours worth knowing. An unconfirmed edit goes through the normal pipeline, so if it changes a sorted or filtered column the row moves immediately and moves back if the write fails. And undo of an in-flight edit issues a compensating write, a fresh write back to the previous value, itself tracked: rather than pretending to cancel a request that has already gone out.
Asking what is outstanding
grid.edit.pending(); // [{ id, key, colId, value, before, state, age }]
grid.edit.status('r1', 'cap'); // 'pending' | null
Pending cells are marked with --lattice-pending-background and rolled-back ones
flash --lattice-reverted-background; restyle either through the tokens. Both use
the same highlight model as everything else, so the marks survive scrolling, sorting and row
recycling.
Selection and ranges
Row selection and cell ranges are separate answers to separate questions, which records versus which values. Dragging across cells does not tick row checkboxes, and selecting rows does not build a range.
Cell ranges are on by default; row selection is not. Set
selection: 'single' or 'multiple' to turn rows on: until you do,
selection.set() accepts the call and keys() comes back empty.
Both
selection: 'multiple' // rows are off until you ask
// Rows, which records
grid.selection.set(['r1', 'r2']);
grid.selection.keys(); // the selected row keys
grid.selection.rows(); // the row wrappers
grid.selection.all(); // select everything that passes the filter
grid.selection.clear();
// Cells, which values
grid.selection.setRange({ startRow: 0, endRow: 9, columns: ['cap', 'margin'] });
grid.selection.cells(); // [{ key, colId }, …]
grid.selection.summary(); // count, sum, min, max, avg over the range
grid.selection.clearRange();
// Several blocks at once: ctrl-click, Ctrl+Shift+Arrow, or from the API
grid.selection.addRange({ startRow: 20, endRow: 29, columns: ['cap'] });
grid.selection.extendRange(34, 'cap'); // grows the block just added
The checkbox column
A column of checkboxes, with select-all in the header
selection: { mode: 'multiple', checkbox: true, headerCheckbox: true }
checkbox: true adds a narrow column of checkboxes at the start of every row,
pinned so it does not scroll away. headerCheckbox: true puts a select-all box in
its heading, which shows three states: unchecked when nothing is selected, checked when
everything is, and the native indeterminate mark when some are. Clicking it selects
everything when it is not already full, and clears when it is: including from the
indeterminate state, where the intent is "select the rest".
"Everything" means every row the filter currently shows, not every row loaded. The column is
generated rather than declared: it does not appear in columns.visible(), in a
saved view, in an export or in the tool panel's visibility list, and it disappears when the
option is turned off. grid.selection.headerState() returns the same tri-state the
header shows, for building your own control.
Selected rows carry aria-selected and the class
lat-row--selected, which the theme styles.
Ctrl+Shift+Arrow is the keyboard form of ctrl-dragging. The first press opens a block at the focused cell without discarding what is already selected; the presses after it extend that block, so holding the chord draws one rectangle rather than a new one per key repeat. A plain Shift+Arrow goes back to extending a single block, and clicking anywhere ends the run.
What multiple ranges do and do not support. Painting, cells()
and the status-bar summary all work over the union of the selected blocks. Copy is narrower,
because tab-separated text is a rectangle: blocks stacked over the same columns, or joined
over the same rows, copy fine, a diagonal pair has no rectangular form, so
rangeText() returns '' and clipboard:copy reports
reason: 'discontiguous' rather than emitting misaligned rows. Filling is
narrower still: with more than one block selected there is no single source to extend, so the
fill handle is hidden and fillTo and fillDown decline.
Turning it off
selection: 'none' // neither
selection: { ranges: false } // rows only, no drag-select
Filling a series
Dragging the fill handle continues what it can recognise rather than repeating the block. Detection is per column, so dragging three columns down continues three independent series.
What is recognised
1, 2, 3 // → 4, 5, 6 constant difference, two or more values
5, 10, 15 // → 20, 25 any step, including negative and fractional
2026-01-01 … // → the next day, week or whatever the gap is
15 Jan, 15 Feb // → 15 Mar whole months, holding the day of month
31 Jan, 28 Feb // → 31 Mar, 30 Apr month ends stay on the month end
'x', 'y' // → x, y, x, y nothing recognised, so the block repeats
7 // → 7, 7, 7 one value is a copy, not a series
Months step as months. 15 January to 15 February is thirty-one days, and continuing in days would land on 18 March and drift further every step. A month step holds the date the user picked, and clamps where the month is short: 31 January plus a month is 28 February, not 3 March.
Month ends are their own case. 31 Jan, 28 Feb is a month
series whose second value has already been clamped, so the two share no day of month and the
day-preserving rule cannot see it. Where every source value is the last day of its own month,
the fill stays on the month end: 31 March, 30 April, 31 May, and 29 February in a leap year.
Values that share a day of month keep the day-preserving answer, so 30 Apr, 30 Jun
still gives 30 August rather than the 31st.
One limit worth knowing. Filling upwards is not supported; the handle extends downwards only.
Supplying your own series
selection: {
fill: ({ source, target, direction }) => target.map((t, i) => nextCode(source, i)),
}
A domain series (order codes, fiscal periods, seat numbers) is not something the grid
can infer, so selection.fill takes precedence when you supply it. It must return
one value per target row; anything else is ignored in favour of the built-in detection,
rather than being partly applied.
Formulas
A leading = in a numeric cell is a formula. Type
=quantity * unitPrice and the grid stores 119.88.
What a user can type
=5 + 5
=quantity * unitPrice // by field name
=[Unit Price] * 1.2 // by title, when it has spaces
=ROUND(quantity * unitPrice, 2)
=IF(quantity > 10, "bulk", "single")
=SUM(readings) // an array property on the row
=MAX(readings) - MIN(readings)
References name columns, not cells. A spreadsheet can say A1
because its rows do not move. A grid sorts, filters, groups, pages and virtualises, so the row
at position 1 is a different row a moment later and a formula written against it would silently
change meaning. quantity * price means the same thing wherever the row goes.
No eval, no new Function. This is text a
user typed into a cell. Handing it to the JavaScript engine would let anyone who can
edit a cell read your cookies, call your API with your credentials, or post the grid's contents
anywhere. It is a hand-written tokeniser and recursive-descent parser, and the only callable
things are the built-in functions: constructor, globalThis and
constructor.constructor("return 1")() all simply fail to resolve.
The result is stored, not the expression. A formula is a way of
entering a value, exactly like 1,200 or (50) or
12%. It commits as one undo step, fires one cell:changed, and passes
through the column's own validation.
The result is stored, not the formula. The expression is evaluated once, at the moment you commit it, and what lands in the cell is a value like any other, so it does not recalculate when a cell it referred to changes later. For a value that must stay in step with its inputs, use a computed column, which is re-evaluated whenever its dependencies move.
Adding your own functions
createGrid(el, {
formulaFunctions: {
MARGIN: ([revenue, cost]) => (revenue - cost) / revenue,
BAND: ([value]) => (value > 1000 ? 'A' : 'B'),
},
});
// Or evaluate one yourself, anywhere.
import { evaluateFormula } from '@toclocoinc/lattice-grid';
const r = evaluateFormula('=a * b', { data: { a: 6, b: 7 } });
r.ok ? r.value : r.error; // 42
A formula that cannot be read rejects the edit and the cell keeps its old value, the same as any other unparseable text. Failures are returned rather than thrown: this runs on the commit path, where an exception would abandon the commit half-done.
Bare arithmetic is deliberately not a formula. 2-1 is a
plausible product code and 1/2 a plausible date. A reader that evaluated either on
a guess would have to guess wrong sometimes, and the wrong answer is not a visible error but a
plausible number: 2*3 stored as 23 looks like data. Arithmetic
without a leading = is refused outright, so the cell keeps what it had rather than
taking a number nobody typed.
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.
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.
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.
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.
Row reorder
rowReorder: true puts a drag handle in the first visible column and lets a user
move rows with it, or with Alt+Shift+↑/↓.
{ column: 'name' } puts the handle somewhere else.
Reordering a list, and saving the result
createGrid(element, {
columns,
rows,
rowReorder: true,
});
grid.on('row:moved', ({ key, from, to }) => {
// rows.data() is the new order in full.
api.saveOrder(grid.rows.data().map((r, i) => ({ id: r.id, order: i })));
});
The order is your data, not a view of it. A move reorders the array you gave the grid and tells you it happened; writing it somewhere permanent is yours, because only you know where the order lives. A grid that rearranged rows on screen and stopped there would look finished and lose the order on the next load, which is worse than not offering the feature.
If the save fails, move it back: grid.rows.move(key, from).
It refuses while a sort, filter or grouping is active, and says why out loud
rather than springing the row back in silence. The reason is that dropping between two
visible rows says nothing about where the row belongs in the underlying array: under a
filter there may be hidden rows between them, and under a sort the displayed order is
something the grid computed rather than something the data says. Rather than pick an
interpretation and put the row somewhere you did not ask for, the move is declined.
rows.move() returns { moved: false, reason } so you can handle it
yourself.
Moving rows between grids
A row can be dragged out of one grid and into another, a picker beside a basket, an inbox beside a queue, an available list beside an assigned one.
A one-way drag, from a catalogue into a basket
// Sends, never receives. rowReorder draws the handle a drag starts from.
createGrid(left, { columns, rows,
rowReorder: true,
rowTransfer: { receive: false, group: 'order' },
});
// Receives, never sends. Draws no handles at all.
createGrid(right, { columns, rows,
rowTransfer: { send: false, group: 'order' },
});
mode: 'copy' on the sending grid leaves the row where it was. group
restricts exchange to grids sharing the same name, so two unrelated grids on a page do not
accept each other's rows.
| Event | Fired on | Carries |
|---|---|---|
| row:received | the target | { data, at, rejected } |
| row:sent | the source, on a move | { key, data, mode } |
| row:copied | the source, on a copy | { key, data, mode } |
Off by default, and both ends have to agree. Rows leaving a grid is a data change you have to want: a grid that quietly let its rows be dragged away would lose one to a mis-drag, and there is no gesture a user would think to try to get it back. A one-way relationship is a declaration on both grids rather than a convention, the sender refuses to receive, and the receiver never starts a drag.
The target adds before the source removes. If the add is refused: a duplicate key, most likely: nothing is removed, so a rejected transfer loses no data. The other order would delete a row and then discover it had nowhere to go.
The row object is cloned, not shared. Two grids holding the same object would edit each other's rows through it, which is the sort of coupling nobody goes looking for when a cell changes in a grid they were not touching.
A grid is highlighted while a dragged row is over it only when it would actually accept the drop. Marking one that is going to refuse promises a placement that will not happen. A refusal is announced rather than left silent.
Picking up a row shows it, wherever the pointer goes. The row being dragged dims in its own grid, and a small label naming it follows the pointer for as long as the drag is held: over the gap between two grids, over one that is about to refuse the drop, anywhere the row's own dimming cannot reach. Both clear on release, and a handle press never also starts a range selection underneath it.
Editing a row on a form
Double-clicking a row opens it in a panel (a right-hand drawer or a centred dialog) with one control per field, a Save and a Cancel. It is the shape almost every application built on a grid ends up wanting, and until now the shape they had to build themselves.
The grid's own columns, in a drawer
createGrid(element, {
columns, rows, rowKey: 'id',
editable: true,
rowForm: true,
});
That is the whole of it for the common case: the form is the row, edited with the same
editors, types, formats and lookups the cells use. Where the record has more to it than the
grid shows, give a load function, and then say which fields and in what order,
because nothing in the grid knows the shape of something it has never seen.
A fuller record, in a dialog
rowForm: {
mode: 'dialog', // 'drawer' is the default
title: ({ data }) => 'Edit ' + data.name,
load: ({ key }) => fetch(`/api/orders/${key}`).then((r) => r.json()),
fields: [
{ field: 'name', label: 'Name' },
{ field: 'ref', label: 'Reference' }, // not a column
{ field: 'notes', label: 'Notes', editor: 'textarea' },
{ field: 'score', label: 'Score', editor: 'rating', props: { max: 5 } },
],
}
Which editor a field gets
Every editor is available on a form, including your own from the module registry, the form builds its controls through the same call a cell does, so a field gets the same editor, type, formatting and lookup its column would have given it. A field named after a column borrows that column outright and needs nothing further.
A field the grid has never seen (or one you want entered differently from its cell) says so
on the field itself. editor names it, and type, props
and lookup configure it exactly as they would on a column. Overriding the control
does not change where the value goes: a field still writes back only if it maps to a column.
A picker opens when it is asked to. A popup editor, a date, a dropdown, a tree, a colour, a code panel: is its panel: in a cell it opens the moment the cell does, which is right, because the user has just asked to edit that one cell. A form builds every field at once, so on a form the field shows the current value on a control and the panel opens over it when clicked. Choosing puts the panel away again and updates the control.
The panel opens before the record arrives. A click that does nothing for half a second reads as a click that was missed, and the user clicks again. So the panel appears immediately with a loading state and fills in when the record lands.
A failure keeps the panel open and offers a retry inside it. Closing would discard the intent and leave the user nothing to act on but the row they already double-clicked.
And a load that never answers is a failure too. A promise that neither
resolves nor rejects is what a dropped request looks like from the page; left alone it spins
until the user gives up, which reads as an application that has hung rather than a request
that failed. After timeout milliseconds, two seconds unless you say otherwise,
the form stops waiting and shows the same message and retry as any other failure. Set
timeout: false to wait indefinitely, which is right only where your own loader
already has a limit and would rather report that one. A record that turns up after the form
gave up on it is discarded rather than dropped into a panel the user may have moved on
from.
The fields scroll and the heading and buttons do not, so Save stays reachable on a record with forty fields. If a validator refuses one of them, the form stays open, the field is marked, and it is scrolled into view and focused: on a long form the offending field is otherwise nowhere near the button that was just pressed.
Save collects the changed fields, writes the ones that map to columns, and announces the lot.
Where the record actually lives is not something the grid can know, so persisting is yours: a
field that came from load and is not a column is reported in
unmapped and not written, since inventing a column for it would put data in the
grid that the grid was never asked to show. Save is disabled while there is nothing to save.
| Member | Does |
|---|---|
| form.open(key) | Open a row by key. Returns false if there is no such row. |
| form.close() | Close without saving. |
| form.save() | Commit the fields and close. Returns false if a validator refused, or if there is nothing to save. |
| form.isOpen() | Whether the panel is showing. |
| form:opened | Fired with { key, row }. |
| form:saved | Fired with { key, values, changed, unmapped }. |
| form:closed | Fired with { key }. |
| form:error | Fired with { key, error, timedOut } when a load fails or runs out of time. |
The form takes the double click. On an editable grid that gesture also
opens a cell editor, and the two cannot both own it, a form that quietly did nothing where a
cell happened to be editable would be worse than no form. So where rowForm is
configured, double-clicking a row opens the form and the cell editor stays reachable by
Enter or by typing into the cell. Set trigger: false to leave opening entirely to
form.open() and keep double-click for cells.
Putting the form in your own element
A drawer and a dialog both sit over the grid. Give container an element of your
own and the form is built there instead, a sidebar beside the grid, a panel below it, a
column in a layout you already have. It fills what it is given, so the size and position are
yours.
A sidebar the application owns
createGrid(element, {
columns, rows, rowKey: 'id', editable: true,
rowForm: { container: '#record-panel' }, // or the element itself, or a function
});
A selector is resolved when the form opens, not when the grid is configured, because a grid is routinely built before the layout around it exists. A container that cannot be found falls back to opening over the grid: better a form in the wrong place than a double-click that appears to do nothing.
A form in your own container is not modal. It sits beside the grid rather than over it, so it takes nothing away: it is announced as a region rather than a dialog, and Tab moves out of it into the rest of your page instead of being trapped. Claiming otherwise would tell a screen reader user the page had gone away when it plainly has not. Escape still closes it, and it still takes focus when it opens.
Over the grid, the panel is a modal dialog: it takes focus when it opens, traps Tab while it
is showing, closes on Escape, and returns focus to whatever had it before. Its width can be
set with width.