Plan editing
The same plan with editing on: drag a bar or its edges, drag the diamond to set percent, drag a dot onto another bar to draw a link. A phase moves its whole subtree in one callback, successors slide forward over weekends, a cycle is refused, and Ctrl+Z replays it backwards. A log panel lists every write-back the view asks for. (requires @svgrid/enterprise)
A live, editable Svelte 5 data grid example from the SvGrid gallery (Gantt). See the SvGrid documentation for the full API.
About this example
An editable project plan in the Svelte 5 data grid: drag a Gantt bar to move it, drag either edge to resize, drag the diamond on the fill to set percent complete, and drag a bar's end handle onto another bar to draw a dependency. Dragging a phase moves its whole subtree in one callback, moving a predecessor slides its successors forward over weekends and holidays, a link that would close a cycle is refused with a flash, and Ctrl+Z replays the callbacks backwards. A log panel lists every write-back the view asks for, because the view never mutates a row itself.
The same plan as demo 474, with editable on. Every gesture writes back through a callback and nothing else: the view never mutates a row, so the log panel on the right is the complete record of what it asked for.
- Drag a bar to move it. Drag a PHASE and its whole subtree goes with it, in one
onTaskMovecarrying the batch. - Drag either edge to resize; drag the diamond on the fill to set percent complete in 5% steps.
- Drag the dot at a bar's end onto another bar to draw a link. The edges you drag from and drop on pick FS / SS / FF / SF. A link that would close a cycle flashes red instead.
- Moving a predecessor slides its successors forward, skipping weekends and the one holiday below (
respectWorkingTime). - Click a bar for the drawer; right-click for the menu;
Ctrl+ZandCtrl+Shift+Zundo and redo, replaying the callbacks so the data follows.
Imports, features and API used
Imports: @svgrid/grid, @svgrid/enterprise
Columns: name (Task), owner (Owner), start (Start), end (Finish)
Frequently asked questions
Does dragging a bar change my data?
No. The view moves its own overlay and fires onTaskMove with the new dates; your handler decides whether to write them to the row. Every gesture in this demo is listed in the log panel for that reason.
What happens when I drag a phase?
Its whole subtree moves by the same amount and one onTaskMove fires carrying the batch in its subtree field, so the write-back is a single pass rather than one callback per child.
How is a dependency drawn?
Drag the dot at a bar's start or end onto another bar. The edge you drag from and the half you drop on choose finish-to-start, start-to-start, finish-to-finish or start-to-finish. A link that would close a cycle flashes red and is not added.
Related documentation
Related articles
- Paste from Excel into a Svelte Data Grid - How to wire up clipboard paste so users can drop a copied Excel or Google Sheets block directly into SvGrid - TSV parsing, type coercion, validation, and row growth all covered.
- A Fill Handle (Drag to Fill) in SvGrid - Build a working spreadsheet-style fill handle on top of SvGrid's cell selection and editing - pointer tracking, range highlighting, series fill, and undo/redo integration all covered.
- An Editable Select / Dropdown Cell in SvGrid - Build a dropdown cell editor in SvGrid using a Svelte 5 snippet - constrain user input to a fixed option set while keeping sort, filter, and undo working on the underlying value.
Source code (475-gantt-editing.svelte)
<script lang="ts">
/**
* 475. Plan editing (Enterprise Gantt)
* ------------------------------------
* The same plan as demo 474, with `editable` on. Every gesture writes back
* through a callback and nothing else: the view never mutates a row, so the
* log panel on the right is the complete record of what it asked for.
*
* - Drag a bar to move it. Drag a PHASE and its whole subtree goes with
* it, in one `onTaskMove` carrying the batch.
* - Drag either edge to resize; drag the diamond on the fill to set
* percent complete in 5% steps.
* - Drag the dot at a bar's end onto another bar to draw a link. The
* edges you drag from and drop on pick FS / SS / FF / SF. A link that
* would close a cycle flashes red instead.
* - Moving a predecessor slides its successors forward, skipping weekends
* and the one holiday below (`respectWorkingTime`).
* - Click a bar for the drawer; right-click for the menu; `Ctrl+Z` and
* `Ctrl+Shift+Z` undo and redo, replaying the callbacks so the data
* follows.
*/
import { SvGrid, type ColumnDef, type GanttConfig, type GanttDependency } from '@svgrid/grid'
import { enableGanttView } from '@svgrid/enterprise'
enableGanttView()
type Task = {
id: string
name: string
owner: string
start: string
end?: string
progress: number
parentId: string | null
milestone?: boolean
color?: string
}
const COLOR = { plan: '#7c3aed', build: '#2563eb', ship: '#16a34a' } as const
const MILESTONE = '#f59e0b'
const pad = (n: number) => String(n).padStart(2, '0')
const iso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
const base = new Date()
base.setHours(0, 0, 0, 0)
base.setDate(base.getDate() - ((base.getDay() + 6) % 7) - 7)
const at = (d: number) => {
const x = new Date(base)
x.setDate(x.getDate() + d)
return iso(x)
}
/**
* The inclusive finish of a task that starts `from` days from the anchor
* (a Monday) and runs `days` WORKING days: every task starts on a weekday
* and ends on one, the way a real plan is typed in.
*/
const wd = (from: number, days: number) => {
let d = from
for (let left = days; left > 1; left--) {
d += d % 7 === 4 ? 3 : 1 // Friday -> Monday
}
return at(d)
}
let seq = 100
let rows = $state<Task[]>([
{ id: 'p1', name: 'Plan', owner: 'Priya', start: at(0), progress: 0, parentId: null, color: COLOR.plan },
{ id: 't1', name: 'Scope & estimates', owner: 'Priya', start: at(0), end: wd(0, 4), progress: 100, parentId: 'p1', color: COLOR.plan },
{ id: 't2', name: 'Tech design', owner: 'Marco', start: at(7), end: wd(7, 4), progress: 60, parentId: 'p1', color: COLOR.plan },
{ id: 'p2', name: 'Build', owner: 'Sven', start: at(14), progress: 0, parentId: null, color: COLOR.build },
{ id: 't3', name: 'Service layer', owner: 'Sven', start: at(14), end: wd(14, 5), progress: 30, parentId: 'p2', color: COLOR.build },
{ id: 't4', name: 'Client', owner: 'Mia', start: at(16), end: wd(16, 8), progress: 10, parentId: 'p2', color: COLOR.build },
{ id: 't5', name: 'QA pass', owner: 'Lena', start: at(28), end: wd(28, 5), progress: 0, parentId: 'p2', color: COLOR.build },
{ id: 'p3', name: 'Ship', owner: 'Omar', start: at(35), progress: 0, parentId: null, color: COLOR.ship },
{ id: 't6', name: 'Release notes', owner: 'Lena', start: at(35), end: wd(35, 4), progress: 0, parentId: 'p3', color: COLOR.ship },
{ id: 'm1', name: 'Go live', owner: '-', start: at(42), progress: 0, parentId: null, milestone: true, color: MILESTONE },
])
let dependencies = $state<GanttDependency[]>([
{ id: 'd1', from: 't1', to: 't2' },
{ id: 'd2', from: 't2', to: 't3' },
// The client starts two days after the service layer has: start-to-start.
{ id: 'd7', from: 't3', to: 't4', type: 'SS', lag: 2 },
{ id: 'd3', from: 't3', to: 't5' },
{ id: 'd4', from: 't4', to: 't5' },
{ id: 'd5', from: 't5', to: 't6' },
{ id: 'd6', from: 't6', to: 'm1' },
])
// One working day off, to show cascades stepping over it.
const holidays = [at(24)]
const columns: ColumnDef<any, Task>[] = [
{ field: 'name', header: 'Task', width: 190, editorType: 'text' },
{ field: 'owner', header: 'Owner', width: 90, editorType: 'text' },
{ field: 'start', header: 'Start', width: 110 },
{ field: 'end', header: 'Finish', width: 110 },
]
// ---- the log: every callback the view fires, newest first ----------------
type Entry = { id: number; what: string; detail: string }
let log = $state<Entry[]>([])
let logSeq = 0
const fmt = (d: Date) => d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
function note(what: string, detail: string) {
log = [{ id: ++logSeq, what, detail }, ...log].slice(0, 40)
}
const byId = (id: string) => rows.find((r) => r.id === id)
/** Write a span back to a row. The view asked; this is the app agreeing. */
function writeSpan(row: Task, start: Date, end: Date) {
row.start = iso(start)
// `end` is exclusive, and these rows store an inclusive date-only finish.
const last = new Date(end.getTime() - 86400000)
row.end = iso(last < start ? start : last)
}
const ganttCfg: GanttConfig<any, Task> = {
startField: 'start',
endField: 'end',
titleField: 'name',
progressField: 'progress',
parentField: 'parentId',
milestoneField: 'milestone',
colorField: 'color',
tableColumns: ['name', 'owner', '__duration', '__progress'],
dependencies,
holidays,
respectWorkingTime: true,
zoom: 'week',
weekStartsOn: 1,
editable: true,
history: true,
drawer: true,
tooltip: true,
onTaskMove: (e) => {
writeSpan(e.row, e.start, e.end)
for (const s of e.subtree ?? []) writeSpan(s.row, s.start, s.end)
note(
'onTaskMove',
`${e.row.name} -> ${fmt(e.start)}${e.subtree?.length ? ` (+${e.subtree.length} in subtree)` : ''}`,
)
},
onTaskResize: (e) => {
writeSpan(e.row, e.start, e.end)
note('onTaskResize', `${e.row.name} ${e.edge} edge -> ${fmt(e.start)} to ${fmt(e.end)}`)
},
onProgressChange: (e) => {
e.row.progress = e.progress
note('onProgressChange', `${e.row.name} -> ${e.progress}%`)
},
onDependenciesChange: (moves) => {
for (const m of moves) {
const r = byId(m.id)
if (r) writeSpan(r, m.start, m.end)
}
note('onDependenciesChange', `${moves.length} task${moves.length === 1 ? '' : 's'} rescheduled`)
},
onDependencyAdd: (dep) => {
dependencies = [...dependencies, dep]
note('onDependencyAdd', `${dep.from} -> ${dep.to} (${dep.type ?? 'FS'})`)
},
onDependencyRemove: (id) => {
dependencies = dependencies.filter((d) => d.id !== id)
note('onDependencyRemove', id)
},
onTaskAdd: (start, end, parentId) => {
const id = `n${++seq}`
rows = [
...rows,
{
id,
name: 'New task',
owner: 'TBD',
start: iso(start),
end: iso(new Date(end.getTime() - 86400000)),
progress: 0,
parentId: parentId ?? null,
color: COLOR.build,
},
]
note('onTaskAdd', `${id} on ${fmt(start)}${parentId ? ` under ${parentId}` : ''}`)
},
onTaskDelete: (row) => {
rows = rows.filter((r) => r.id !== row.id)
note('onTaskDelete', row.name)
},
onTaskCommit: (e) => {
Object.assign(e.row, e.values)
note('onTaskCommit', `${e.row.name}: ${Object.keys(e.values).join(', ')}`)
},
}
// `dependencies` is reassigned when a link is drawn, so the config has to see
// the new array rather than the one captured above.
const cfg = $derived({ ...ganttCfg, dependencies })
// The same rows as a plain table: the Gantt is one view of the grid.
let view = $state<'gantt' | 'table'>('gantt')
</script>
<section class="ge">
<header class="ge-head">
<div class="ge-title">
<strong>Editable plan</strong>
<span class="ge-sub">
Drag a bar or its edges, drag the diamond to set percent, drag a dot onto another bar to link.
Ctrl+Z undoes. Nothing here mutates a row until a callback below says so.
</span>
</div>
<div class="ge-seg" role="tablist" aria-label="View">
<button class="ge-seg-btn" role="tab" aria-selected={view === 'gantt'} class:ge-on={view === 'gantt'} onclick={() => (view = 'gantt')}>Gantt</button>
<button class="ge-seg-btn" role="tab" aria-selected={view === 'table'} class:ge-on={view === 'table'} onclick={() => (view = 'table')}>Table</button>
</div>
</header>
<div class="ge-body">
<div class="ge-chart">
{#if view === 'gantt'}
<SvGrid
columnResize
data={rows}
columns={columns}
getRowId={(r) => r.id}
containerHeight="100%"
gantt={cfg}
/>
{:else}
<SvGrid columnResize data={rows} columns={columns} getRowId={(r) => r.id} containerHeight="100%" sortable fitColumns />
{/if}
</div>
<aside class="ge-log">
<div class="ge-log-head">
<span>Callbacks</span>
{#if log.length}
<button class="ge-clear" onclick={() => (log = [])}>Clear</button>
{/if}
</div>
{#if !log.length}
<p class="ge-empty">Drag something. Every write-back the view asks for shows up here.</p>
{:else}
<ol class="ge-entries">
{#each log as e (e.id)}
<li class="ge-entry">
<code class="ge-what">{e.what}</code>
<span class="ge-detail">{e.detail}</span>
</li>
{/each}
</ol>
{/if}
</aside>
</div>
</section>
<style>
.ge {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
border: 1px solid var(--sg-border, #e5e7eb);
border-radius: 12px;
overflow: hidden;
background: var(--sg-bg, #fff);
}
.ge-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; padding: 10px 14px; border-bottom: 1px solid var(--sg-border, #e5e7eb); }
.ge-seg { display: inline-flex; flex: none; border: 1px solid var(--sg-border, #e5e7eb); border-radius: 8px; overflow: hidden; }
.ge-seg-btn { padding: 5px 12px; border: 0; background: transparent; color: inherit; font: inherit; font-size: 0.8rem; cursor: pointer; }
.ge-seg-btn:hover { background: color-mix(in srgb, var(--sg-fg, #1f2937) 6%, transparent); }
.ge-on { background: var(--sg-accent, #4f46e5); color: #fff; }
.ge-on:hover { background: var(--sg-accent, #4f46e5); }
.ge-title { display: flex; flex-direction: column; gap: 2px; }
.ge-sub { font-size: 0.78rem; color: var(--sg-muted, #6b7280); }
.ge-body { flex: 1 1 auto; display: flex; min-height: 0; }
.ge-chart { flex: 1 1 auto; min-width: 0; padding: 8px; }
.ge-log {
flex: 0 0 230px;
display: flex;
flex-direction: column;
min-height: 0;
border-left: 1px solid var(--sg-border, #e5e7eb);
background: color-mix(in srgb, var(--sg-fg, #1f2937) 3%, transparent);
}
.ge-log-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 9px 12px;
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--sg-muted, #9ca3af);
border-bottom: 1px solid var(--sg-border, #e5e7eb);
}
.ge-clear {
border: 0;
background: transparent;
color: var(--sg-accent, #4f46e5);
font: inherit;
font-size: 0.7rem;
cursor: pointer;
text-transform: none;
letter-spacing: 0;
}
.ge-empty { margin: 0; padding: 14px 12px; font-size: 0.78rem; color: var(--sg-muted, #6b7280); }
.ge-entries { margin: 0; padding: 6px 0; list-style: none; overflow-y: auto; }
.ge-entry {
display: flex;
flex-direction: column;
gap: 1px;
padding: 6px 12px;
border-bottom: 1px solid color-mix(in srgb, var(--sg-border, #e5e7eb) 50%, transparent);
}
.ge-what { font-size: 0.72rem; font-weight: 600; color: var(--sg-accent, #4f46e5); }
.ge-detail { font-size: 0.75rem; color: var(--sg-fg, #374151); }
@media (max-width: 767px) {
.ge { min-width: 860px; flex-shrink: 0; }
}
</style>More Gantt examples
- Project plan - A 14-week release as a Gantt: phases nest their tasks and draw a rolled-up summary bar, finish-to-start links draw as arrows, and a violated one turns red. Collapse a phase and its summary keeps the span. Toggle to the Table - same grid rows, just a view. The renderer ships in @svgrid/enterprise.
- Critical path & baselines - A fit-out schedule with the planning layer on: the chain with no slack ringed in red with its arrows, a Slack column for everything else, baseline ghosts that redden where the plan has drifted, and two pinned tasks where a cascade stops at the constraint instead of running past it.
- Resource load & folded axis - A field-service quarter with a load strip under the chart: one row per crew, one bar per column, red where a crew is booked past its capacity - the specialist is one person with two sign-offs in the same week. Capacity is per resource, so a two-van crew takes two jobs at once. Fold the weekends out and the quarter fits in the width a month used to take.
- One plan, four views: Grid, Gantt, Scheduler, Kanban - One <SvGrid>, one array, four renderers behind a switch: the plain table with inline editing, the Gantt with phases and links, a resource timeline with a lane per owner, and a Kanban of status lanes. Every view writes back through a callback to the same rows - change a status in the grid and the card moves lane, drag a bar in the Gantt and the timeline follows - and a line under the switcher names the last write.
- Product roadmap - a year in quarters - The Gantt as a roadmap: three teams, their initiatives across four quarters, and the releases that cut across them as milestones. A `task` snippet draws the owner and a status chip inside each bar, colour is by status, a custom tooltip carries the effort and a summary, the header chips filter by status, and a month / quarter / year zoom ladder drills in with Ctrl+wheel.