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}
editTrigger="doubleClick"
undoLimit={50}
highlightActiveCell
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"
editTrigger="doubleClick"
[undoLimit]="50"
[highlightActiveCell]="true"
(cellValueChanged)="saveChange($event)"
/>
const core = new GridCore(measurer, {
columnDefs: [{
key: "status",
label: "Status",
editable: true,
cellEditor: "select",
cellEditorParams: { values: ["On track", "At risk", "Blocked"] },
}],
editTrigger: "doubleClick",
undoLimit: 50,
highlightActiveCell: true,
});
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" },
];
Without a valueParser, text is parsed by the column's type — and this
applies to every path that carries text, not just the editors: paste, the fill
handle, Delete/Backspace, and a string given to setCellValue.
- number / currency: whatever
Number()accepts, plus the column's own display form: a currency column reads its formatter's output ($1,234.00,-1.234,56 €underformatterOptions: { locale: "de-DE", currency: "EUR" }, accounting parentheses for negatives), and a number column that setsformatterOptions.localereads that locale's grouping and decimal separators — so underde-DE,1.234,5is 1234.5 and1,234is 1.234, never 1234. Separators come from the locale, and grouping must sit where the locale puts it. Blank isnull. - date: ISO 8601 text (
2026-01-02, optionally with a time), or text in the column's display format (formatterOptions.format, e.g.DD/MM/YYYYorMMM D, YYYY), stored in the shape the cell already holds — aDatestays aDate, ISO text stays text, an epoch number stays a number. Other orders are refused:01/31/2026is not a date in aDD/MM/YYYYcolumn. - boolean:
true/false,yes/no,y/n,1/0; blank isnull. - string: verbatim.
So copying a cell and pasting it into a column of the same type and format
writes the same value back. Text the type cannot hold — "abc" into a number column — is refused: the
cell keeps its value, nothing enters undo history, and no cellValueChanged
fires, exactly as if onBeforeCellCommit had vetoed it. An editor commit
reports it as editingChanged with state: "rejected"; in a paste or fill the
cell is simply skipped and the rest of the block keeps its alignment. A custom
valueParser can refuse the same way by returning the exported REJECT.
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,
opened on the cell's local calendar date (min/max bound it). It commits
through the column's built-in date parsing, so the stored shape survives the
edit — a Date column keeps Dates, an epoch column numbers, an ISO-text
column yyyy-mm-dd text — and a cleared input commits null. 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", "fill", "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, each cell as it is displayed (its
formatted text); Ctrl/Cmd+C, api.copySelection() and the body menu's Copy /
Copy with Headers share one serializer. Group rows drop out of a selection that
also holds leaf rows unless groupRowsSelectable is on; a selection of group
rows alone copies them. Cut clears editable destinations, paste
runs each destination's parser — its valueParser, or the built-in one for its
type — skipping cells that refuse the text, and Delete/Backspace clears
contents (typed columns clear to null). A pasted block tiles across a larger
selected range when its dimensions divide evenly.
Ctrl/Cmd+V pastes through the browser's own paste event on the focused grid,
which carries the clipboard text without asking for clipboard permission, so it
works in every browser and in insecure (http:) contexts. A paste while a cell
editor or another input inside the grid has focus belongs to that input.
api.paste() and the body menu's Paste have no keyboard event to read from:
they use navigator.clipboard.readText(), which some browsers refuse or do not
offer (Firefox without the async clipboard, insecure contexts, a denied
permission). In that case nothing is pasted and the grid logs one warning
pointing at Ctrl/Cmd+V.
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.
Fill handle
The selection carries a small square on its bottom-right corner. Drag it down, up, right, or left — one direction per drag, the one the pointer travels further along — and the cells it passes show a dashed outline; release to write them as one undo step. The selection then covers source and result together, with the active cell still on a cell you chose.
What gets written follows the spreadsheet rules, decided per line (a column when filling vertically, a row when horizontally):
- Two or more numbers continue their linear trend:
10, 20→30, 40, and1, 2, 6→8, 10.5, 13. - A lone date steps one calendar day; a run of dates keeps its interval, across
month ends and DST changes. Date text in the
YYYY-MM-DDshape does the same and stays text. - Text ending in a number counts up:
Item 1→Item 2,Item 3;Item 1, Item 3→Item 5,Item 7. The last run of digits is the counter, the rest must match across the cells, and zero padding is kept (v08→v09,v10). - Weekday and month names cycle in order, long or short, keeping the casing you
typed:
Monday→Tuesday,Nov, Dec→Jan,Feb,MON→TUE. English names are always recognised, plus those of the browser's language. Quarters cycle too:Q3→Q4,Q1(Qtr1andQuarter 1likewise). - Your own sequences cycle the same way through
fillHandle: { lists }, like a spreadsheet's custom lists: withlists: [["Low", "Medium", "High"]],Low→Medium,High,Low, andlow→medium(an entry spelled as listed takes the list's spellings, soVPstaysVP). Your lists are tried before the built-in names and the counter, so one can replace the weekday order or makeItem 1cycle. - Everything else — plain text, booleans, a lone number, text that is only a number or a date with a time, a mixed line — repeats.
- Hold Ctrl/Cmd when you release to flip the choice: a lone number counts up by one, and a series — numbers, dates, or text patterns — repeats instead.
Ctrl/Cmd+D fills the selection down from its first row and Ctrl/Cmd+R fills
it right from its first column; a single cell takes the value above or to its
left. Both are copies, never series, and leave the selection where it is. The
body context menu offers the same two commands as Fill down and Fill
right whenever they would write something, and api.fillDown() /
api.fillRight() run them from outside the grid; api.canFillDown() /
api.canFillRight() answer the menu's "would write something" test, for a
toolbar button's enabled state (re-read them on selectionChanged and
cellsChanged).
Right after a drag or double-click fill, the context menu on any cell the fill covered offers the other choice: Copy cells instead where the fill stepped a series, or Fill series instead where a line that could step was copied (a lone number, for instance). Each redo is its own undo step and leaves the selection where it is; the offer lasts until another write, or any change to the rows or columns under the fill (a sort, filter, or column move), retires it.
Double-click the handle to fill the selection down to the end of the data beside it, the way a spreadsheet completes a column. The column to the left of the selection is the guide — the one to its right when the left is blank in the row just below — and the fill runs through the guide's unbroken run of non-blank rows. A group row ends the run, so a fill inside one group stays there. Copy or series is decided as for a drag, and Ctrl/Cmd flips it the same way.
fillHandle: true // default: series where possible, any direction
fillHandle: { mode: "copy" } // always repeat; Ctrl/Cmd does nothing
fillHandle: { direction: "y" } // rows only (Ctrl/Cmd+R is left to the browser)
fillHandle: false
Filling down or up stays inside one column, so values move as stored and the
valueParser never runs. Filling across columns, a value reaches a column of
the same type as stored too; into a differently typed column its displayed
text goes through that column's parser, exactly as a paste would — so a name
dragged over a number column is refused and the numbers stay. Every cell
still passes onBeforeCellCommit, and cellValueChanged reports
source: "fill". Non-editable cells, group rows, rows not yet loaded, and
refused cells are skipped without disturbing the pattern.
Series the grid cannot know — fiscal periods, business days, ordered
enumerations, codes with rules, values derived from the row — belong to
fillOperation. It is called once per target cell on every fill path (drag,
double-click, Ctrl/Cmd+D / Ctrl/Cmd+R, the menu items and redo) with the
line's source values, the cell's position in the pattern, and the value the
grid would write on its own. Return { value } to write that value as the
cell's stored value, { skipCell: true } to leave the cell untouched while it
keeps its place in the pattern, or nothing to keep the grid's value:
// Dates in the "due" column step by business days; everything else is the grid's own.
fillOperation: ({ colId, values, index, lineMode }) => {
if (colId !== "due" || lineMode !== "series") return; // keep the grid's value
const last = values[values.length - 1] as Date;
const steps = index - (values.length - 1); // 1 for the first cell past the source
const date = new Date(last);
for (let left = Math.abs(steps); left > 0;) {
date.setDate(date.getDate() + Math.sign(steps)); // negative when filling upward
if (date.getDay() !== 0 && date.getDay() !== 6) left--;
}
return { value: date };
},
A returned value does not run the column's parser — the callback already knows
the target column — but still passes onBeforeCellCommit and the no-op check;
null is the blank. The grid also consults the callback when deciding whether
a fill would write anything (to show or hide the menu's fill items), so keep it
free of side effects.
The handle needs cellSelection: true and rangeSelection, appears only while
the selection covers at least one editable column, and never on a selection
that reaches into a pinned band.
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.