sveltelean Table
Versionv0.2.0 GitHub

Setting onEdit on the table and editable on a column is all the renderer needs. The editor is a native input or select chosen by editor; it is not a nested form, so a table inside a form never submits it. Enter saves, Escape cancels, and focus returns to the cell in every case. The table never mutates the row: the event carries the previous and the new value, and the application decides what to do with them.

@svelte-lean/table/editing holds the pure helpers: applyEdit() returns a new array with an immutable copy of the edited row, setValue() writes a dot path while refusing prototype-mutating segments, and createHistory() is a bounded undo stack the caller owns. The entry point measures 707 B gzip and a build without editing helpers renders only parseEdit from it, asserted by the table-basic fixture.

the edit flow
// 1. The renderer parses the editor text (parseEdit or the column's parse)
// 2. and validates it (the column's validate); an error stays in the editor.
// 3. onEdit receives { row, rowId, columnId, previous, value } and may return a promise.
// 4. The application commits: applyEdit() returns a new array with one new row object.
async function edit(event: EditEvent<Person>) {
	await api.patch(event.rowId, { [event.columnId]: event.value }); // the editor waits
	rows = applyEdit(rows, event, columns, (row) => row.id);
}

Cell editing and history

Validate cell edits, persist them in the application and add undo only when needed.

Editing and history
Amara Okafor
Active
$4,501
2026-01-07
Jonas Lindqvist
Active
$12,420
2026-06-18
Mei Tanaka
Away
$20,339
2026-02-01
Rafael Duarte
Invited
$28,258
2026-07-12
Selin Aydın
Active
$36,177
2026-03-23
Noah Brenner
Active
$44,096
2026-08-06

Editing.svelte
<script lang="ts">
	import { DataTable, type Column, type EditEvent } from '@svelte-lean/table';
	import { applyEdit, createHistory } from '@svelte-lean/table/editing';
	import { money, people, type Person } from '$lib/demo/data';

	let rows = $state(people(6));
	const history = createHistory(rows); // caller-owned undo, not part of the renderer
	let tick = $state(0);
	const columns: Column<Person>[] = [
		{ id: 'name', header: 'Member', editable: true },
		{
			id: 'status',
			header: 'Status',
			editable: true,
			editor: 'select',
			options: ['Active', 'Away', 'Invited'].map((value) => ({ label: value, value }))
		},
		{
			id: 'revenue',
			header: 'Revenue',
			align: 'end',
			editable: (row) => row.status !== 'Invited',
			editor: 'number',
			validate: (value) => (Number(value) < 0 ? 'Revenue cannot be negative.' : undefined),
			format: (value) => money.format(Number(value))
		},
		{ id: 'joined', header: 'Joined', editable: true, editor: 'date' }
	];

	// The table hands over a parsed, validated event; the application commits an immutable copy.
	function edit(event: EditEvent<Person>) {
		rows = history.commit(applyEdit(rows, event, columns, (row) => row.id));
		tick++;
	}
</script>

<button type="button" data-slean="button" data-variant="outline" disabled={tick < 0 || !history.canUndo}
	onclick={() => { rows = history.undo(); tick++; }}>Undo</button>
<button type="button" data-slean="button" data-variant="outline" disabled={tick < 0 || !history.canRedo}
	onclick={() => { rows = history.redo(); tick++; }}>Redo</button>

<DataTable data={rows} {columns} getRowId={(row) => row.id} onEdit={edit} pagination={false} label="Members" />
Member is a text editor, Status a select, Revenue a number editor that rejects negative values and is read-only for invited members, Joined a date editor. Double-click a cell, press Enter or F2 on it, or use its edit button. Undo and Redo are Button primitives over createHistory().
  • A rejected value shows the message from validate inside the editor and keeps it open.
  • While onEdit is pending the editor is disabled; an error it throws is shown the same way.
  • Columns with a function accessor cannot be written by applyEdit(); use an application setter.

Keyboard

With onEdit set, the body follows the grid pattern: one cell carries tabindex="0", the others -1, and the per-cell edit buttons are not in the Tab order. The full grid keyboard model is on the Data Grid page.

KeyWhenResult
Taboutside the bodyEnters the body on the last cell reached, or the first cell.
Enter/F2on an editable cellOpens the editor with the current value and focuses it.
Enterin the editorParses, validates and saves; focus returns to the cell.
Escapein the editorCancels; focus returns to the cell.
ArrowLeft/ArrowRight/ArrowUp/ArrowDownon a cellMoves to the neighbouring cell by structure.

API

NameOfTypeDefaultDescription
onEditEvents and composition(event: EditEvent<T>) => void | Promise<void>undefinedReceives a parsed and validated cell edit; enables editing and cell focus. The table never mutates rows.
editableEditingboolean | ((row: T) => boolean)falseCell editing for the column or per row; needs onEdit on the table.
editorEditing'text' | 'number' | 'date' | 'select''text'Native input type of the editor.
optionsEditing{ label: string; value: string }[][]Options of the select editor.
parseEditing(value: string, row: T) => unknownparseEditConverts the editor text to the domain value.
validateEditing(value: unknown, row: T) => string | undefinedundefinedReturns an error message that blocks onEdit and is shown in the editor.
editTableMessagesstring'Edit'Prefix of the per-cell edit button label.
saveTableMessagesstring'Save'Editor button.
cancelTableMessagesstring'Cancel'Editor button.
EditEvent<T>Events and positions{ row: T; rowId: RowId; columnId: string; previous: unknown; value: unknown }–The parsed edit passed to onEdit and produced by pasteRange().
@svelte-lean/table/editingEntry pointsapplyEdit · setValue · parseEdit · createHistory · editEvent–Immutable edit application and a caller-owned undo history.