Editing and clipboard
Editable cells support typed built-in editors, framework components, parsing, navigation after commit, history, and spreadsheet-style clipboard workflows.
- React
- Angular
- Core TypeScript
const columns: ReactColDef[] = [{
key: "status",
label: "Status",
editable: true,
cellEditor: "select",
cellEditorParams: { values: ["On track", "At risk", "Blocked"] },
}];
<Grid
rowData={rows}
columnDefs={columns}
undoLimit={50}
onCellValueChanged={saveChange}
/>
columns: NgColDef[] = [{
key: "status",
label: "Status",
editable: true,
cellEditor: "select",
cellEditorParams: { values: ["On track", "At risk", "Blocked"] },
}];
<awb-grid
[rowData]="rows"
[columnDefs]="columns"
[undoLimit]="50"
(cellValueChanged)="saveChange($event)"
/>
const core = new GridCore(measurer, {
columnDefs: [{
key: "status",
label: "Status",
editable: true,
cellEditor: "select",
cellEditorParams: { values: ["On track", "At risk", "Blocked"] },
}],
undoLimit: 50,
});
api.startEditingCell({ rowId: "order-1", colId: "status" });
Built-in editors
Choose text, number, date, boolean, select, or textarea. If
cellEditor is omitted, the grid chooses from ColumnType. valueParser
converts raw text before writing; typed editors can report already-parsed values.
const columnDefs: ColDef[] = [
{ key: "name", label: "Name", editable: true, cellEditor: "text" },
{ key: "quantity", label: "Quantity", editable: true, cellEditor: "number" },
{ key: "due", label: "Due", editable: true, cellEditor: "date" },
{ key: "active", label: "Active", editable: true, cellEditor: "boolean" },
{
key: "status",
label: "Status",
editable: true,
cellEditor: "select",
cellEditorParams: { values: ["Open", "Pending", "Closed"] },
},
{ key: "notes", label: "Notes", editable: true, cellEditor: "textarea" },
];
A formatter/parser pair round-trips a display format through editing:
const column = {
key: "percent",
label: "Percent",
editable: true,
valueParser: ({ value }) => Number(value.replace("%", "")) / 100,
valueFormatter: ({ value }) => `${Math.round(value * 100)}%`,
} satisfies ColDef;
Number editor
The number editor is a free-text input with numeric augmentation — not a
native <input type="number"> — so formatted values are editable.
ArrowUp/ArrowDown step by cellEditorParams.step (default 1), clamped to
min/max and quantized to the step's decimal precision; mobile keyboards get
a decimal keypad via inputmode.
- Without a
valueParserthe raw text is parsed as a plain number on commit — clamped tomin/max,nullwhen blank or invalid. - With a
valueParserthe editor seeds with the column's formatted display value and commits the raw string through your parser, so formats the cell renders —1,234.5,45%,$50— round-trip through editing. Arrow stepping goes parse → ±step → format (12.5%steps to13%), andmin/maxonly bound stepping: commit-time validation belongs to the parser.
Date editor
The date editor is the native <input type="date"> calendar by default
(min/max bound it; commits are ISO yyyy-mm-dd strings, null when
blank). With a valueParser it switches to typed-format entry, the same
mechanism as the number editor: a text input seeded with the column's
formatted display value that commits the raw string through your parser — so
a MM/DD/YYYY formatter/parser pair lets users type 12/25/2026 directly.
ArrowUp/ArrowDown step the date by cellEditorParams.step days (default 1),
round-tripping parse → ±step days → format; a blank input steps from the
cell's original value. Validation belongs to the parser.
Editing starts with double-click, F2, Enter, or a printable character. Configure single-click editing, API-only editing, commit-on-blur, and Enter/Tab navigation with grid options:
const options = {
editTrigger: "singleClick",
suppressTypeToEdit: true,
moveAfterEdit: true,
commitOnBlur: true,
reevaluateOnEdit: true,
undoLimit: 50,
} satisfies GridOptions;
Use editTrigger: "none" with suppressKeyboardEdit: true for API-only
editing:
const cell = { rowId: "order-1", colId: "quantity" };
api.startEditingCell(cell);
api.stopEditing("42");
api.cancelEditing();
api.setCellValue(cell, "43");
Custom editors
const uppercaseEditor = (params: ICellEditorParams) => {
const input = document.createElement("input");
input.value = String(params.value ?? "").toUpperCase();
return {
init: () => {},
getGui: () => input,
getValue: () => input.value.toUpperCase(),
focus: () => input.focus(),
};
};
const column = {
key: "code",
label: "Code",
editable: true,
cellEditor: uppercaseEditor,
} satisfies ColDef;
The React and Angular bindings accept framework components in the same
cellEditor slot, with editor handles for value, focus, and parse state.
Validating commits: onBeforeCellCommit
A synchronous pre-commit hook runs before any user-initiated write — editor
commits, setCellValue, and each cell of a paste/cut/clear batch — after the
column's valueParser, so it sees { rowId, colId, data, value, oldValue, source } with value in its stored form. Return:
REJECT(exported sentinel) to veto the write: the cell keeps its old value, nothing enters undo history, nocellValueChangedfires, and an editor commit emitseditingChanged { state: "rejected" }instead;- a value to store it in place of the proposed one (coerce/clamp);
undefined(or nothing) to accept the proposed value — returnnullwhen you mean "store an empty value".
Undo/redo replay already-accepted values and skip the hook. Server-validated
edits that need an async veto should combine the sync hook (for what can be
checked locally) with readOnlyEdit below — commit nothing, validate on the
server, write back through your store.
Edits write into your row objects
Committed edits are written in place into the exact row objects you passed
as rowData — the grid does not copy on ingest or on write. If your
application store assumes immutability (Redux, NgRx, signals with reference
equality), hand the grid copies of your rows, or treat the grid's data as
grid-owned and sync changes back through onCellValueChanged.
readOnlyEdit: the application owns the write
Set readOnlyEdit: true and the grid never touches your row objects: every
write path still runs the full pipeline — valueParser,
onBeforeCellCommit, editingChanged, cellValueChanged — but the value is
not written and nothing enters undo history (there is nothing of the
grid's to undo). Treat cellValueChanged as the edit request: apply it to
your store and pass the new data back via rowData or applyTransaction.
This is the intended mode for immutable stores, replacing hand-rolled
commit-then-rollback flows.
Reacting to value changes
onCellValueChanged (and the cellValueChanged event) covers every write
path — editor commits, setCellValue, paste, cut, clear, and undo/redo — with
{ rowId, colId, value, oldValue, source }. It fires once per cell whose
stored value actually changes: committing the value a cell already holds
emits nothing (equality is SameValueZero plus Dates compared by instant —
the same rule is exported as valuesAreSame). value/oldValue are the
stored (parsed) forms, and source says what wrote the cell ("edit",
"paste", "cut", "clear", "undo", "redo"), so diff consumers (dirty
tracking, server sync, analytics) need no shadow copy of the data. Two
exceptions: under readOnlyEdit the grid writes nothing and cannot compare,
so it fires for every accepted write (the value it reports is what the grid
would have written); and undo/redo report the recorded transition, which can
be a no-op if the row data was mutated externally after the step was recorded.
Don't filter events by comparing value to oldValue yourself — on a
valueGetter column the two can be equal even though the stored field moved;
the event firing is the change signal. The editingChanged event still
fires for every commit (changed or not), additionally carries oldValue on
its committed state, and reports onBeforeCellCommit vetoes of editor
commits as state: "rejected". colId in these payloads is the public
ColDef.colId; the internal instance id rides on colInstanceId.
Clipboard and history
Copy serializes the selection as TSV. Cut clears editable destinations, paste applies each destination parser, and Delete/Backspace clears contents. A pasted block tiles across a larger selected range when its dimensions divide evenly.
Every edit, paste, cut, or batch clear that changes at least one stored value
is one undo step — a batch's entry holds only the cells that actually changed,
and a commit of an unchanged value records nothing (so Ctrl+Z always produces
a visible change). Set undoLimit: 0 to disable history.
api.undo() / api.redo() traverse it, and historyChanged (or the
onHistoryChanged option) fires whenever the stacks move, carrying
{ reason, canUndo, canRedo, undoDepth, redoDepth } — bind toolbar buttons to
it instead of polling. reason is "commit", "undo", "redo", or "clear".
Replacing rowData clears history (its entries reference rows by id) and
reports reason: "clear". getHistoryState() reads the same snapshot on demand,
for the toolbar's first render.
Programmatic writes and history
A bulk programmatic update should undo as one user action, not N:
// One undo step for the whole batch.
api.setCellValues([
{ cell: { rowId: "r1", colId: "salary" }, value: 120000 },
{ cell: { rowId: "r2", colId: "salary" }, value: 95000 },
]);
// Same, for writes spread across several calls or helpers.
api.withUndoGroup(() => {
for (const row of affected) api.setCellValue({ rowId: row.id, colId: "band" }, row.band);
});
Changes the user did not make — server pushes, op-protocol reconciliation, recomputed derived columns — should not land on their undo stack at all:
// Writes apply and emit their normal events; the undo stack is left untouched.
api.withoutUndoHistory(() => api.setCellValues(incomingOps));
Both scopes are synchronous: writes made after the callback returns (in a
promise or timer it schedules) fall outside the scope. Nested scopes inherit the
outermost mode, so a withUndoGroup helper called inside withoutUndoHistory
stays suppressed. applyTransaction never enters undo history and needs
neither.