Data

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.

Name
Salary
Team (read only)
Hoang Kowalski
$127,691.00
Design
Bruno Nguyen
$105,146.00
Growth
Bruno Dubois
$86,538.00
Design
Farid Haddad
$76,443.00
Growth
Jonas Yilmaz
$129,812.00
Core
Quyen Tanaka
$72,011.00
Growth
6 rows
<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.

page 1 of 3
Name
Salary
Hoang Kowalski
$127,691.00
Bruno Nguyen
$105,146.00
Bruno Dubois
$86,538.00
9 rows
1–3 of 9
// 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.

no row open
Name
Role
Salary
Team (read only)
Hoang Kowalski
Manager
$127,691.00
Design
Bruno Nguyen
Support
$105,146.00
Growth
Bruno Dubois
Manager
$86,538.00
Design
Farid Haddad
Analyst
$76,443.00
Growth
Jonas Yilmaz
Support
$129,812.00
Core
5 rows
<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.

text
number
select
selectMenu
checkbox
date
time
rating
tags
textarea
Hoang Kowalski
$127,691.00
Design
Poland
Mar 6, 2024
09:00
svelte,typescript
Started on the platform team.
Bruno Nguyen
$105,146.00
Growth
Brazil
Mar 6, 2024
09:00
svelte,typescript
Started on the platform team.
Bruno Dubois
$86,538.00
Design
Nigeria
Feb 5, 2021
09:00
svelte,typescript
Started on the platform team.
Farid Haddad
$76,443.00
Growth
Brazil
May 4, 2022
09:00
svelte,typescript
Started on the platform team.
Jonas Yilmaz
$129,812.00
Core
Vietnam
Nov 13, 2024
09:00
svelte,typescript
Started on the platform team.
5 rows
// 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.

Name (zod)
Email (valibot)
Salary (validate)
Team (parse)
Hoang Kowalski
hoang.kowalski1@example.com
$127,691.00
Design
Bruno Nguyen
bruno.nguyen2@example.com
$105,146.00
Growth
Bruno Dubois
bruno.dubois3@example.com
$86,538.00
Design
Farid Haddad
farid.haddad4@example.com
$76,443.00
Growth
Jonas Yilmaz
jonas.yilmaz5@example.com
$129,812.00
Core
5 rows
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.

Name
Status
Salary (active rows only)
Hoang Kowalski
invited
$127,691.00
Bruno Nguyen
invited
$105,146.00
Bruno Dubois
active
$86,538.00
Farid Haddad
suspended
$76,443.00
Jonas Yilmaz
active
$129,812.00
Quyen Tanaka
suspended
$72,011.00
6 rows
// 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.

canUndo: no canRedo: no
Name
Team
Salary
Hoang Kowalski
Design
$127,691.00
Bruno Nguyen
Growth
$105,146.00
Bruno Dubois
Design
$86,538.00
Farid Haddad
Growth
$76,443.00
Jonas Yilmaz
Core
$129,812.00
Quyen Tanaka
Growth
$72,011.00
6 rows
  • 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
Name
Role
Country
Hoang Kowalski
Manager
Poland
Bruno Nguyen
Support
Brazil
Bruno Dubois
Manager
Nigeria
Farid Haddad
Analyst
Brazil
Jonas Yilmaz
Support
Vietnam
Quyen Tanaka
Analyst
France
6 rows
// 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.

Name
Team (custom editor)
Hoang Kowalski
Design
Bruno Nguyen
Growth
Bruno Dubois
Design
Farid Haddad
Growth
Jonas Yilmaz
Core
5 rows
<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

OptionDefault
mode'cell'
commitOnBlurtrue

Column Fields

PropertyDefault
editablefalse
editor'text'
schema-
validate-
parseby type

Editing State

What getEditing(grid) exposes. Most of it is also merged into grid.api, where every entry is optional.

MemberDescription
activeThe cell being edited, in cell mode
rowEditIdThe row being edited, in row mode
draftThe value in the open editor
draftsOne draft per column, during a row edit
errorValidation message for the open cell
rowErrorsValidation messages of a rejected row commit
canUndo / canRedoWhether 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

KeysAction
Enter or F2Opens the editor on the focused cell
A printable keyOpens a text, number or textarea editor on that character
EnterCommits and closes, except where the editor claims the key. It moves down but never turns the page
Ctrl or Cmd + EnterCommits without leaving the cell. The way out of tags and textarea
TabCommits and moves to the next cell
EscapeDiscards the edit and closes the editor
Ctrl or Cmd + ZUndoes the last transaction
Ctrl+Shift+Z or Ctrl+YRedoes it
Ctrl or Cmd + VPastes 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