Link editing
The user draws a dependency from a round handle that appears at either end of a bar when it is
hovered, and removes one from the menu that a click on its arrow opens. The chart reports each with
onLinkCreate and onLinkDelete, tells you what it refused with onEditRefused, and changes
nothing it should not: a link that would close a cycle, repeat one, or link a task to itself is
never created.
pnpm add @ganttlane/dom # vanillapnpm add @ganttlane/react react react-dom # Reactnpm install @ganttlane/dom # vanillanpm install @ganttlane/react react react-dom # Reactyarn add @ganttlane/dom # vanillayarn add @ganttlane/react react react-dom # Reactbun add @ganttlane/dom # vanillabun add @ganttlane/react react react-dom # Reactimport type { Link, Task } from '@ganttlane/core';
// 2026-03-02 is a Monday. Docs and Review have no links yet: draw some.export const tasks: Task[] = [ { id: 'design', name: 'Design', start: '2026-03-02', end: '2026-03-06', progress: 100 }, { id: 'api', name: 'API', start: '2026-03-09', end: '2026-03-13', progress: 60 }, { id: 'ui', name: 'UI', start: '2026-03-09', end: '2026-03-17', progress: 30 }, { id: 'docs', name: 'Docs', start: '2026-03-16', end: '2026-03-19' }, { id: 'integrate', name: 'Integrate', start: '2026-03-18', end: '2026-03-24' }, { id: 'qa', name: 'QA', start: '2026-03-25', end: '2026-03-27' }, { id: 'review', name: 'Review', start: '2026-03-30', end: '2026-03-31' }, { id: 'launch', name: 'Launch', type: 'milestone', start: '2026-04-01' },];
export const links: Link[] = [ { source: 'design', target: 'api' }, { source: 'design', target: 'ui' }, { source: 'api', target: 'integrate' }, { source: 'ui', target: 'integrate' }, { source: 'integrate', target: 'qa' }, // `editable: false`: the menu of this link does not offer to remove it (the click still selects). { source: 'qa', target: 'launch', editable: false },];import type { GanttOptions } from '@ganttlane/dom';
export const options = { // Drawing links from a bar's round handles and removing them from the menu of a link. `false` // turns both off (the other flags of `editing` are on unless they are set to `false`). editing: { links: true }, // The type a link drawn by the user gets: 'FS' (finish to start, the default), 'SS', 'FF' or // 'SF'. It is not asked for while drawing: change it here, and the next links have it. scheduling: { defaultLinkType: 'FS' }, viewMode: 'week', scroll: { initial: 'first-task' }, // Smaller rows, so that all eight tasks are in view at once. layout: { rowHeight: 36, barHeight: 24 },} satisfies Partial<GanttOptions>;import '@ganttlane/dom/styles.css';
import { createGantt, type Gantt, type LinkType, type TaskId } from '@ganttlane/dom';
import { links, tasks } from './data';import { LINK_TYPES, linkText, refusalText } from './helpers';import { options } from './options';
export function mount(container: HTMLElement): () => void { const controller = new AbortController(); const root = document.createElement('div'); const toolbar = document.createElement('div'); toolbar.setAttribute('role', 'toolbar'); const chart = document.createElement('div'); chart.style.height = '370px'; const status = document.createElement('p'); status.textContent = 'Hover a bar, then drag from a round handle onto another bar, or click the handle and then the bar. Click a link to remove it.'; root.append(toolbar, chart, status); container.append(root);
// The handlers read `gantt`, which they are passed to: give it a type. const gantt: Gantt = createGantt(chart, { tasks, links, ...options, // A link was drawn: the caller's `Link` as written, and its id. onLinkCreate: (link, id) => { const record = gantt.getLink(id); // the link as the chart has it: its type is always set status.textContent = `Created ${linkText(record ?? link, nameOf)}.`; }, // A link was removed, from the menu or by `removeLink`: the link as it was. onLinkDelete: (record) => { status.textContent = `Removed ${linkText(record, nameOf)}.`; }, // A click on the arrow of a link, which selects it and opens its menu. onLinkClick: (record) => { const next = record.editable ? 'Choose "Remove dependency" in its menu.' : 'It has editable: false, so its menu has nothing to offer.'; status.textContent = `Selected ${linkText(record, nameOf)}. ${next}`; }, // A link that was not drawn: a task that cannot be linked, itself, a cycle, a duplicate. onEditRefused: (refusal) => { status.textContent = refusalText(refusal, nameOf); }, }); const nameOf = (id: TaskId): string => gantt.getTask(id)?.name ?? String(id);
// `editing: { links }` turns drawing and removing links on and off. const editable = document.createElement('input'); editable.type = 'checkbox'; editable.checked = true; editable.addEventListener( 'change', () => { gantt.setOptions({ editing: { links: editable.checked } }); status.textContent = editable.checked ? 'Links can be drawn and removed again.' : 'editing.links is false: no handles on the bars, and no menu on the links.'; }, { signal: controller.signal }, ); const editableLabel = document.createElement('label'); editableLabel.append(editable, ' editing.links');
// The type of the links drawn from now on (`scheduling.defaultLinkType`). const type = document.createElement('select'); type.setAttribute('aria-label', 'Type of new links'); for (const value of LINK_TYPES) { const option = document.createElement('option'); option.textContent = value; type.append(option); } type.addEventListener( 'change', () => { gantt.setOptions({ scheduling: { defaultLinkType: type.value as LinkType } }); status.textContent = `New links are ${type.value} links.`; }, { signal: controller.signal }, ); const typeLabel = document.createElement('label'); typeLabel.append('New links are ', type);
toolbar.append(editableLabel, typeLabel);
return () => { controller.abort(); gantt.destroy(); root.remove(); };}import '@ganttlane/react/styles.css';
import { Gantt, type GanttHandle, type TaskId } from '@ganttlane/react';import { useRef, useState } from 'react';
import { links, tasks } from './data';import { LINK_TYPES, linkText, refusalText } from './helpers';import { options } from './options';
export default function Example() { const chart = useRef<GanttHandle>(null); const [editable, setEditable] = useState(true); const [type, setType] = useState<(typeof LINK_TYPES)[number]>('FS'); const [status, setStatus] = useState( 'Hover a bar, then drag from a round handle onto another bar, or click the handle and then the bar. Click a link to remove it.', ); // `viewMode` is a controlled prop in React: `defaultViewMode` is where the chart starts, and // then the chart owns it. `tasks` and `links` are controlled too: `defaultTasks` and // `defaultLinks` are where the chart starts, and the user's links are then the chart's own. const { viewMode, ...rest } = options; const nameOf = (id: TaskId): string => chart.current?.getTask(id)?.name ?? String(id);
return ( <> <div role="toolbar"> <label> <input type="checkbox" checked={editable} onChange={(event) => { setEditable(event.target.checked); setStatus( event.target.checked ? 'Links can be drawn and removed again.' : 'editing.links is false: no handles on the bars, and no menu on the links.', ); }} />{' '} editing.links </label> <label> New links are{' '} <select aria-label="Type of new links" value={type} onChange={(event) => { setType(event.target.value as (typeof LINK_TYPES)[number]); setStatus(`New links are ${event.target.value} links.`); }} > {LINK_TYPES.map((value) => ( <option key={value}>{value}</option> ))} </select> </label> </div> <Gantt ref={chart} style={{ height: 370 }} defaultTasks={tasks} defaultLinks={links} {...rest} defaultViewMode={viewMode} // `editing.links` and the default type of the links the user draws come from state. editing={{ links: editable }} scheduling={{ ...options.scheduling, defaultLinkType: type }} // A link was drawn: the caller's `Link` as written, and its id. onLinkCreate={(link, id) => { const record = chart.current?.getLink(id); // the link as the chart has it setStatus(`Created ${linkText(record ?? link, nameOf)}.`); }} // A link was removed, from the menu or by `removeLink`: the link as it was. onLinkDelete={(record) => { setStatus(`Removed ${linkText(record, nameOf)}.`); }} // A click on the arrow of a link, which selects it and opens its menu. onLinkClick={(record) => { const next = record.editable ? 'Choose "Remove dependency" in its menu.' : 'It has editable: false, so its menu has nothing to offer.'; setStatus(`Selected ${linkText(record, nameOf)}. ${next}`); }} // A link that was not drawn: a task that cannot be linked, itself, a cycle, a duplicate. onEditRefused={(refusal) => { setStatus(refusalText(refusal, nameOf)); }} /> <p>{status}</p> </> );}import type { EditRefusedPayload, Link, LinkRecord, LinkType, TaskId } from '@ganttlane/dom';
/** The types a link can have; the chart's default for new ones is `'FS'`. */export const LINK_TYPES: readonly LinkType[] = ['FS', 'SS', 'FF', 'SF'];
/** `Design → API (FS)`: a link as a line of text, from its ends and its type. */export function linkText( link: Pick<Link | LinkRecord, 'source' | 'target' | 'type'>, nameOf: (id: TaskId) => string,): string { return `${nameOf(link.source)} → ${nameOf(link.target)} (${link.type ?? 'FS'})`;}
/** Why a link was not drawn, in words (`onEditRefused` reasons that a link gesture can have). */const REASONS: Record<EditRefusedPayload['reason'], string> = { permission: 'one of the tasks cannot be linked', busy: 'the chart is busy', 'unknown-task': 'the task is gone', self: 'a task cannot depend on itself', cycle: 'it would close a cycle', duplicate: 'that link exists already',};
/** * A line for the status: what `onEditRefused` reported. * * @param refusal - The payload of the event. * @param nameOf - The name of a task from its id. * @returns The line. */export function refusalText(refusal: EditRefusedPayload, nameOf: (id: TaskId) => string): string { const { gesture, id, target, reason } = refusal; const ends = target === undefined ? nameOf(id) : `${nameOf(id)} → ${nameOf(target)}`; return `Refused (${reason}): ${gesture} ${ends}: ${REASONS[reason]}.`;}Files
data.ts: eight tasks and six links; one link haseditable: false.options.ts:editing.links, the type of the links the user draws, the layout.helpers.ts: the lines of the status, and the four link types.vanilla.tsandreact.tsx: the handlers, the checkbox forediting.linksand the select for the type of new links.
Options
| Option | Value | What it does |
|---|---|---|
editing.links |
true, toggled by the box |
Draw links from the handles and remove them from the menu; false turns both off |
scheduling.defaultLinkType |
'FS', set by the select |
The type of a link the user draws: 'FS', 'SS', 'FF' or 'SF' |
link.editable |
false on QA to Launch |
The menu of that link does not offer to remove it |
onLinkCreate |
a function (in the example) | (link, id): a link was drawn, or added by addLink |
onLinkDelete |
a function (in the example) | (record): a link was removed, from the menu or by removeLink |
onLinkClick |
a function (in the example) | (record, event): the arrow of a link was clicked; the link is now selected |
onEditRefused |
a function (in the example) | A link that was not drawn, with the reason |
viewMode |
'week' |
The scale of the timeline (React: defaultViewMode) |
scroll.initial |
'first-task' |
Opens on the plan |
layout.rowHeight, .barHeight |
36, 24 |
Smaller rows, so that every task is in view |
What to try
- Draw by dragging. Hover Docs: a round handle shows on each side of the bar. Press the one at
the right end, drag onto QA and let go:
Created Docs → QA (FS). Nothing moves; a link without alagkeeps the gap it has. - Draw by clicking twice. Click the handle (the link is now armed and follows the pointer),
then click the target bar.
Escape, a click on empty space or a click outside the chart cancels it. - Remove. Click the arrow of a link and choose Remove dependency. The arrow from QA to Launch
has
editable: false: it is selected, and its menu has nothing to offer. - Be refused. Draw a link from a bar onto itself (
self), onto a task it already points at (duplicate), and from Launch back to Design (cycle: Design leads to Launch through the chain). Nothing is created, and the line under the chart says why. - Change the type. Pick
SSin the select and draw another link: it is a start-to-start link, drawn from the start of the source to the start of the target. A link of another type between the same two tasks is not a duplicate. - Turn it off. Untick
editing.links: the handles go and the links have no menu.
Notes
- Both handles make the same link. The handle you press only says where the line starts; the
type is
scheduling.defaultLinkType('FS'unless you set it), and the link is from the task you pressed on to the task you released on. - What
onEditRefusedsays for links.{ gesture: 'link-create', id, target, reason, at }:idis the source,reasonis'self','cycle','duplicate'or'permission'(a task islinkable: false, orediting.linksis off). While the pointer is over a target that would be refused the cursor isnot-allowed, and the event comes on release. - The data is the chart’s own. The chart is uncontrolled here, so it appends the link to its
links and removes it again by itself. If your state owns the links, take them from
onLinkCreateandonLinkDelete(React:onLinksChangegives the whole list), as Controlled data does. - From code.
addLink(link)andremoveLink(id)run the same checks and, foraddLink, theonBeforeLinkCreatehook, and return a result: see Programmatic updates and Before-hooks. - The menu is
linkMenu: itsrenderand itsonRemovecan be replaced, for a confirmation or your own buttons. By defaultonRemoveis the chart’s ownremoveLink. - React.
editingandschedulingare ordinary props, so the checkbox and the select are state.tasks,linksandviewModeare the controlled ones:defaultTasksanddefaultLinksare where the chart starts, and the links the user draws are then the chart’s own.