Programmatic editing (start/stop)
Drive the editor from outside via api.startEditing(row, columnId) / stopEditing(cancel?). A toolbar edits the active cell, commits or cancels, and a guided-entry flow jumps to the next blank required field and opens it.
A live, editable Svelte 5 data grid example. Open the interactive demo or read the documentation.
What this example shows
Drive the editor from outside the grid. A toolbar begins editing the active cell, commits or cancels it, and runs a "guided data entry" flow that jumps to the next blank required field and opens its editor - so a form-style entry experience is built entirely on the imperative API.
Source code (176-programmatic-editing.svelte)
<!-- Documented in: docs/help/editing/start-stop-editing.md -->
<script lang="ts">
/**
* 176. Programmatic editing - api.startEditing / stopEditing
* ----------------------------------------------------------
* Drive the editor from outside the grid. A toolbar begins editing the
* active cell, commits or cancels it, and runs a "guided data entry" flow
* that jumps to the next blank required field and opens its editor - so a
* form-style entry experience is built entirely on the imperative API.
*/
import {
SvGrid,
tableFeatures,
rowSortingFeature,
type ColumnDef,
type SvGridApi,
} from '@svgrid/grid'
type Lead = {
company: string
contact: string
email: string
stage: string
}
let rows = $state<Lead[]>([
{ company: 'Northwind', contact: 'Ada Lovelace', email: '', stage: 'New' },
{ company: 'Helios', contact: '', email: '[email protected]', stage: '' },
{ company: 'Vertex', contact: 'Linus T.', email: '', stage: 'Qualified' },
{ company: '', contact: 'Grace H.', email: '[email protected]', stage: 'New' },
{ company: 'Stellar', contact: 'Tim B.', email: '[email protected]', stage: '' },
])
const features = tableFeatures({ rowSortingFeature })
const REQUIRED = ['company', 'contact', 'email', 'stage']
let api = $state<SvGridApi<typeof features, Lead> | null>(null)
let active = $state<{ rowIndex: number; columnId: string } | null>(null)
let editing = $state(false)
const columns: ColumnDef<typeof features, Lead>[] = [
{ field: 'company', header: 'Company', editorType: 'text', width: 160 },
{ field: 'contact', header: 'Contact', editorType: 'text', width: 160 },
{ field: 'email', header: 'Email', editorType: 'text', width: 220 },
{
field: 'stage',
header: 'Stage',
editorType: 'rich-select',
width: 150,
editorOptions: ['New', 'Qualified', 'Proposal', 'Won', 'Lost'],
},
]
function editActive() {
if (!api || !active) return
editing = api.startEditing(active.rowIndex, active.columnId)
}
function commit() {
if (!api) return
api.stopEditing() // default: commit
editing = false
}
function cancel() {
if (!api) return
api.stopEditing(true) // discard
editing = false
}
// Guided entry: find the next blank required cell (row-major from the active
// cell), select it, and open its editor via the API.
//
// IMPORTANT: walk the DISPLAYED rows (`api.getDisplayedRows()`), not the
// source `rows` array. `startEditing` / `selectCells` address cells by their
// position in the grid as rendered, so after a sort the displayed order
// differs from the source order - scanning the source array would open the
// wrong (already-filled) cell.
function nextBlank() {
if (!api) return
const displayed = api.getDisplayedRows()
const total = displayed.length * REQUIRED.length
if (!total) return
const start = active ? active.rowIndex * REQUIRED.length + REQUIRED.indexOf(active.columnId) + 1 : 0
for (let k = 0; k < total; k += 1) {
const flat = (start + k) % total
const r = Math.floor(flat / REQUIRED.length)
const columnId = REQUIRED[flat % REQUIRED.length]!
if (!String((displayed[r] as Record<string, unknown>)[columnId] ?? '').trim()) {
const colIndex = columns.findIndex((c) => c.field === columnId)
api.selectCells([[r, colIndex, r, colIndex]])
active = { rowIndex: r, columnId }
editing = api.startEditing(r, columnId)
return
}
}
editing = false
}
const blanks = $derived(
rows.reduce(
(n, row) =>
n + REQUIRED.filter((f) => !String((row as Record<string, unknown>)[f] ?? '').trim()).length,
0,
),
)
</script>
<section class="flex flex-col flex-1 min-h-0 gap-3">
<div class="flex flex-wrap items-center gap-2 shrink-0">
<button type="button" class="pe-btn" onclick={editActive} disabled={!active || editing}>Edit active cell</button>
<button type="button" class="pe-btn pe-primary" onclick={commit} disabled={!editing}>Commit (save)</button>
<button type="button" class="pe-btn" onclick={cancel} disabled={!editing}>Cancel (discard)</button>
<button type="button" class="pe-btn pe-accent" onclick={nextBlank}>
Next blank & edit {#if blanks > 0}<span class="pe-badge">{blanks}</span>{/if}
</button>
<span class="text-xs" style="color: var(--sg-muted);">
{#if active}Active: <code>{active.columnId}</code> · row {active.rowIndex + 1}{:else}Click a cell to make it active{/if}
{#if editing}<span style="color: #34d399;"> · editing</span>{/if}
</span>
</div>
<p class="text-sm shrink-0" style="color: var(--sg-fg);">
Everything here is <code>api.startEditing(rowIndex, columnId)</code> and
<code>api.stopEditing(cancel?)</code> - no double-click needed.
<strong>Next blank & edit</strong> walks the {blanks === 0 ? 'now-complete' : ''} required
fields and opens each empty one, a form-style flow on top of the grid.
</p>
<div class="flex-1 min-h-0">
<SvGrid responsive={true}
data={rows}
columns={columns}
features={features}
showRowNumbers={true}
enableInlineEditing={true}
enableCellSelection={true}
rowHeight={38}
containerHeight="100%"
fitColumns={true}
onApiReady={(next) => (api = next)}
onActiveCellChange={(e) => (active = { rowIndex: e.rowIndex, columnId: e.columnId })}
/>
</div>
</section>
<style>
.pe-btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 6px 12px;
border-radius: 7px;
font-size: 12.5px;
font-weight: 600;
color: var(--sg-fg);
background: var(--sg-header-bg);
border: 1px solid var(--sg-border);
cursor: pointer;
transition: border-color 120ms ease, background-color 120ms ease, opacity 120ms ease;
}
.pe-btn:hover:not(:disabled) { border-color: var(--sg-accent, #3b82f6); }
.pe-btn:disabled { opacity: 0.45; cursor: default; }
.pe-primary { background: var(--sg-accent, #3b82f6); color: var(--sg-on-accent, #fff); border-color: transparent; }
.pe-accent { border-color: var(--sg-accent, #3b82f6); color: var(--sg-accent, #3b82f6); }
.pe-badge {
display: inline-flex; min-width: 18px; justify-content: center;
padding: 0 5px; border-radius: 9px; font-size: 10px;
background: var(--sg-accent, #3b82f6); color: var(--sg-on-accent, #fff);
}
</style>Related documentation
More Editing examples
- Inline editing - Typed editors (text/number/checkbox/date) with dirty tracking + save.
- Editor types + custom slot - Built-in select / rich-select / textarea editors plus a custom `cellEditor` snippet (a range slider) for cases the built-ins do not cover.
- List + chips editors - Two built-in editors with single & multi-select: dropdown (list) and removable tokens (chips), with options or free-form.
- Async editor options - editorOptions can return a Promise - for the whole column, or per row for a cascade. The dropdown shows Loading… while the request is in flight, results are cached (per column, or per column+row) so reopening never refetches, and api.refreshEditorOptions() invalidates when the server list changes.
- Custom cell editors - Three hand-rolled editors: native colour picker bound to a tag swatch, 5-star rating, emoji feedback mood. All write back through api.setCellValue.