Editing
Cell and row editing with ten editors, schema validation, transactions, undo and redo, and clipboard paste.
Basic Usage
Mark a column editable and it opens on double-click, or on Enter and F2 once the cell has focus. The editor defaults to 'text';
Salary below names editor: 'number' instead. Team declares nothing, so it never opens.
Enter commits and closes, Tab commits and moves to the next cell, Escape discards. Clicking away commits too, because commitOnBlur defaults to true.
<script lang="ts">
import { DataGrid, type ColumnDef } from '@sv5ui/datagrid';
// editable marks a column; the editor defaults to 'text'
const columns: ColumnDef<Person>[] = [
{ id: 'name', header: 'Name', editable: true, flex: 1 },
{ id: 'salary', header: 'Salary', editable: true, editor: 'number',
align: 'right', width: 150, type: 'currency' },
{ id: 'team', header: 'Team (read only)', width: 130 }
];
</script>
<!-- The shorthand registers the feature; editing={{ ... }} configures it -->
<DataGrid data={people} {columns} {getRowId} editing />The edit writes back into the grid's own data, so the rows above stay changed while you are on this page.
Committing Is Not Navigating
Enter commits the cell and moves down, but it stops at the end of the page. Turning the page there
would take the row just edited off the screen and put the caret somewhere nobody was looking:
the key was pressed to save what was typed, not to go anywhere.
Arrow keys still cross, being a request to go somewhere rather than the tail of one to write
something. The grid below pages every three rows: edit the third row and press Enter,
then press ArrowDown from the same cell, and watch the page counter.
// Enter commits and moves down. On the last row of a page it commits and
// stays: turning the page would take the row just edited off the screen.
const grid = createDataGrid<Person>({
data: people,
columns,
getRowId,
features: [editing(), pagination({ pageSize: 3 })]
});
// Arrow keys are unaffected, being a request to go somewhere rather than
// the tail of one to write something: ArrowDown from the last row of a
// page turns it, and the focused cell is what the page follows.
// Under rowModel: 'server' the grid holds one page and the rows behind the
// boundary are not there at all, so nothing is clamped: the same commit
// simply has nowhere to move to.Row Mode
A row edit opens every editable cell of one row at once and commits them as a single transaction, so one invalid cell blocks the whole row and one undo takes all of it back.
mode decides what a gesture opens. Under 'row' a
double-click, Enter and F2 all open
the whole row, which is what the grid below does.
One ring is drawn around the row, and the fields inside it share it: a text field marks its
focus with a tint and a bar rather than a border of its own, while a Select or a date field
already draws both and is left to. The slots are cellEditorInRow and cellEditorInRowWidget, both themeable.
From code it is startRowEdit(rowId), then commitRow() or cancelRow(). Those three name the shape they want and ignore the mode, so a grid in cell mode can
still open one row deliberately.
<script lang="ts">
import { createDataGrid, DataGrid, editing, getEditing } from '@sv5ui/datagrid';
// 'cell' is the default: one cell at a time. 'row' opens every editable
// cell of a row together and commits them as one transaction, and it is
// what a double-click, Enter and F2 open under that mode.
const grid = createDataGrid<Person>({
data: people,
columns,
getRowId,
features: [editing({ mode: 'row' })]
});
// Which row is open, or null
let rowEditId = $derived(getEditing(grid)?.rowEditId ?? null);
</script>
<Button label="Edit first row" onclick={() => getEditing(grid)?.startRowEdit('1')} />
<Button label="Commit" onclick={() => getEditing(grid)?.commitRow()} />
<Button label="Cancel" onclick={() => getEditing(grid)?.cancelRow()} />
<DataGrid {grid} />The Ten Editors
editor takes a name, or an object when the editor needs options. Each one is the sv5ui component of that
name, so it arrives with the keyboard behaviour and the theme it has everywhere else. Scroll the
grid sideways to reach all ten.
Opening an editor hands it the keyboard: a text field selects what is there, a date lands on
its first segment, a select drops its list open. The value the editor writes keeps its own
shape, so tags stores a string[] and checkbox a
boolean.
// A string names the editor; an object adds its options.
const columns: ColumnDef<Row>[] = [
{ id: 'name', editable: true }, // 'text' by default
{ id: 'salary', editable: true, editor: 'number' },
{ id: 'active', editable: true, editor: 'checkbox' },
{ id: 'joinedAt', editable: true, editor: 'date' },
{ id: 'shift', editable: true, editor: 'time' },
{ id: 'rating', editable: true, editor: 'rating' },
{ id: 'skills', editable: true, editor: 'tags' }, // writes a string[]
{ id: 'notes', editable: true, editor: 'textarea' },
// select and selectMenu take their list from the definition
{
id: 'team',
editable: true,
editor: {
type: 'select',
options: [
{ label: 'Core', value: 'Core' },
{ label: 'Platform', value: 'Platform' }
]
}
},
{ id: 'country', editable: true, editor: { type: 'selectMenu', options: countries } }
];Validation
A commit is checked before it is written. If it fails, the editor stays open, the message hangs under the cell, and the live region announces it, so nothing is lost and nothing half lands.
schema takes any standard-schema library, so zod, valibot, yup and joi all work without an adapter. validate is the hand-written alternative: return a message, or null when the value is fine. It wins over schema when a column declares both. parse runs
first and settles what the row will store.
Without one, the column type decides. Text arrives from places that have no types to offer,
the clipboard among them, so a number, currency or percent column parses what it is handed rather than storing "42" where every other row holds a number. Text that is not a number is left alone for validation to
refuse, rather than stored as NaN, which
is why the Salary column below can ask for typeof value === 'number' and mean it.
Try it: type two letters into Name, an address without an at sign into Email, or a salary under 40,000. Type lowercase into Team and watch parse upper-case it on the way in.
import * as v from 'valibot';
import { z } from 'zod';
const columns: ColumnDef<Person>[] = [
// schema takes any standard-schema library: zod, valibot, yup, joi
{
id: 'name',
editable: true,
schema: z.string().min(3, 'At least 3 characters')
},
{
id: 'email',
editable: true,
schema: v.pipe(v.string(), v.email('Not an email address'))
},
// validate is the hand-written alternative: a message, or null when valid
{
id: 'salary',
editable: true,
editor: 'number',
validate: (value) =>
typeof value === 'number' && value >= 40000 ? null : 'Must be at least 40,000'
},
// parse runs first and settles the shape the row will store
{
id: 'team',
editable: true,
parse: (input) => String(input ?? '').trim().toUpperCase(),
validate: (value) => (String(value).length > 0 ? null : 'Required')
}
];
// An invalid commit is blocked: the editor stays open, the message hangs
// under the cell, and the live region announces it. validate wins over
// schema when a column declares both.Deciding Per Row
editable takes a predicate as well as a flag, and it is asked per cell with the row, the pipeline node
and the resolved value. Salary below opens only where Status is active:
double-click an invited or suspended row and nothing happens, on any of double-click, Enter or F2.
A row edit asks the same question: it drafts only the cells the predicate allows, so a locked cell stays out of the transaction rather than committing its old value back.
One more thing can close a cell without the predicate saying so. A cellValue gate that substitutes a value makes that cell read-only, whatever editable says: an editor opened on a value the reader is not being shown would seed the substitute and
commit it over the data behind it. A cell the gate hands back unchanged is editable as ever.
// editable takes a predicate as well as a flag. It is asked per cell, with
// the row, the pipeline node and the resolved value.
{
id: 'salary',
header: 'Salary (active rows only)',
editable: ({ row }) => row.status === 'active',
editor: 'number'
}
// The full context:
// { row: TRow, node: RowNode<TRow>, value: unknown }
//
// A cell it refuses does not open on double-click, Enter or F2, and a row
// edit skips it: startRowEdit only drafts the cells the predicate allows.Undo, Redo and Transactions
Every commit is pushed onto an undo stack. Ctrl+Z undoes and Ctrl+Shift+Z or Ctrl+Y redoes from anywhere in the grid; canUndo and canRedo say
whether a button of your own should be enabled.
applyEdits writes many cells as one transaction. The button below raises three salaries at once, and a single
Undo takes all three back. Each cell still goes through that column's parse and validation, and one invalid cell rejects the whole batch rather than writing part of it.
An undo reports itself as an edit: the log below shows the reverse write as its own cellEdited event, which is what a grid syncing to a server needs to hear.
- No edits yet.
<script lang="ts">
import { createDataGrid, DataGrid, editing, getEditing } from '@sv5ui/datagrid';
const grid = createDataGrid<Person>({
data: people,
columns,
getRowId,
features: [editing()]
});
let history = $derived(getEditing(grid));
// Every write reports itself, undo and redo included, so a consumer
// syncing to a server sees a reverted change as an edit of its own.
grid.events.on('cellEdited', ({ rowId, columnId, oldValue, newValue }) => {
log(`row ${rowId} . ${columnId}: ${oldValue} -> ${newValue}`);
});
// Many rows in one transaction: one undo takes all of them back. Every
// cell goes through the column's parse and validation, and one invalid
// cell rejects the whole batch rather than writing part of it.
function raiseLowSalaries() {
const edits = grid.data
.filter((person) => person.salary < 100000)
.map((person) => ({
rowId: getRowId(person),
changes: { salary: Math.round(person.salary * 1.1) }
}));
getEditing(grid)?.applyEdits(edits);
}
</script>
<Button label="Undo" disabled={!history?.canUndo} onclick={() => history?.undo()} />
<Button label="Redo" disabled={!history?.canRedo} onclick={() => history?.redo()} />
<Button label="Raise every salary under 100k by 10%" onclick={raiseLowSalaries} />
<DataGrid {grid} />
<!-- Ctrl+Z undoes and Ctrl+Shift+Z or Ctrl+Y redoes, from anywhere in the
grid, without a button of your own. -->Pasting From a Spreadsheet
A paste spreads from the focused cell, right and down, so a block copied out of a spreadsheet lands where the caret is. Cells falling on a non-editable column or past the last row are dropped rather than shifting the rest along.
It arrives as a real paste event on the grid rather than a Ctrl+V binding, so right-click paste works too and nothing asks for clipboard permission. The whole paste
goes through applyEdits: one transaction, one undo, every cell parsed and validated on the way in.
Click a cell in the first column below, then use the button, which pastes the two tab-separated rows shown beside it.
Ada Lovelace Engineer England// Tab-separated rows, the shape a spreadsheet puts on the clipboard.
const tsv = 'Ada Lovelace\tEngineer\tEngland\nAlan Turing\tAnalyst\tEngland';
// Spreads from the focused cell, right and down. Cells landing on a
// non-editable column or past the last row are dropped rather than
// shifting the rest.
getEditing(grid)?.pasteText(tsv);
// Ctrl+V on a focused cell does the same with the real clipboard.
// It goes through applyEdits, so the whole paste is one transaction,
// one undo, and every cell is parsed and validated on the way in.Writing an Editor
When none of the ten fits, an editor snippet replaces what is rendered while keeping the rest of the flow. It receives value, row, node, setValue, commit, cancel and error: setValue updates the draft, commit validates
and writes it.
The one below writes the team in a single click. Validation, undo and the cellEdited event all still apply, because the snippet only replaced the control.
<script lang="ts">
import { DataGrid, type EditorContext } from '@sv5ui/datagrid';
</script>
<!-- The snippet owns the control; the context owns the flow. -->
{#snippet teamEditor({ value, setValue, commit, cancel }: EditorContext<Person>)}
{#each ['Core', 'Platform', 'Design'] as team (team)}
<Button
label={team}
variant={value === team ? 'solid' : 'outline'}
onclick={() => { setValue(team); commit(); }}
/>
{/each}
<Button icon="lucide:x" label="Cancel" onclick={cancel} />
{/snippet}
<DataGrid
data={people}
{getRowId}
editing
columns={[
{ id: 'name', header: 'Name', flex: 1 },
{
id: 'team',
header: 'Team',
editable: true,
// type still names the family the grid falls back to; editor
// replaces what is rendered.
editor: { type: 'text', editor: teamEditor }
}
]}
/>
<!-- The context: value, row, node, setValue, commit, cancel, error.
setValue updates the draft, commit validates and writes it, cancel
discards. Escape still closes the editor, since that binding lives on
the grid rather than inside the editor. -->editing() Options
| Option | Default |
|---|---|
mode | 'cell' |
commitOnBlur | true |
Column Fields
| Property | Default |
|---|---|
editable | false |
editor | 'text' |
schema | - |
validate | - |
parse | by type |
Editing State
What getEditing(grid) exposes. Most of it is also merged into grid.api, where every entry is optional.
| Member | Description |
|---|---|
active | The cell being edited, in cell mode |
rowEditId | The row being edited, in row mode |
draft | The value in the open editor |
drafts | One draft per column, during a row edit |
error | Validation message for the open cell |
rowErrors | Validation messages of a rejected row commit |
canUndo / canRedo | Whether the stack has a step |
beginEdit(rowId, columnId) | Opens whatever mode says, which is what a double-click, Enter and F2 call |
startEdit(rowId, columnId) | Opens one cell, whatever mode says |
commit() | Validates and writes the open cell. A promise only when the schema is async |
cancel() | Discards the open cell |
startRowEdit(rowId) | Opens every editable cell of a row |
commitRow() / cancelRow() | Writes or discards the whole row |
applyEdits(edits) | Many cells as one transaction. One invalid cell rejects the batch |
pasteText(text) | Spreads tab-separated text from the focused cell |
undo() / redo() | Walks the transaction stack |
editableAt(node, def) | The same question the grid asks before opening a cell |
Keyboard
| Keys | Action |
|---|---|
Enter or F2 | Opens the editor on the focused cell |
A printable key | Opens a text, number or textarea editor on that character |
Enter | Commits and closes, except where the editor claims the key. It moves down but never turns the page |
Ctrl or Cmd + Enter | Commits without leaving the cell. The way out of tags and textarea |
Tab | Commits and moves to the next cell |
Escape | Discards the edit and closes the editor |
Ctrl or Cmd + Z | Undoes the last transaction |
Ctrl+Shift+Z or Ctrl+Y | Redoes it |
Ctrl or Cmd + V | Pastes tab-separated text from the focused cell. Bound to the paste event rather than to the keystroke, so a right-click paste works too and no clipboard permission is asked for |