Link menu
A click on an arrow selects the link and opens its menu. By default the menu has one action,
“Remove dependency”, which calls the chart’s own removeLink. The linkMenu option changes both
halves of that:
linkMenu.render(context)returns the content of the menu: the same kinds of content as a tooltip (text sections with buttons, a DOM node,{ node, dispose },{ html }), ornullfor no menu. With@ganttlane/reactthe same is therenderLinkMenuprop, which returns aReactNode.linkMenu.onRemove(link)is what the remove action calls, with the record of the link. Give your own to ask first, or to remove the link from your own data (in controlled data the chart cannot do it for you).
This example adds three kinds of action to the menu: change the type of the link, change its lag, and remove it, and shows what a menu says about a link that cannot be edited.
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. The plan has no weekends (see `options.ts`), so a task can be moved// by a day without landing on a day off.export const tasks: Task[] = [ { id: 'brief', name: 'Brief', start: '2026-03-02', end: '2026-03-04', progress: 100 }, { id: 'design', name: 'Design', start: '2026-03-05', end: '2026-03-10', progress: 70 }, { id: 'build', name: 'Build', start: '2026-03-11', end: '2026-03-20', progress: 30 }, { id: 'docs', name: 'Docs', start: '2026-03-14', end: '2026-03-18', progress: 10 }, { id: 'review', name: 'Review', start: '2026-03-21', end: '2026-03-22', progress: 0 }, { id: 'qa', name: 'QA', start: '2026-03-25', end: '2026-03-26', progress: 0 }, { id: 'launch', name: 'Launch', type: 'milestone', start: '2026-03-29' },];
// Links of three types, with different gaps. A link without a `lag` keeps the gap its tasks have:// Docs starts three days after Build starts, QA two days after Review ends.export const links: Link[] = [ { source: 'brief', target: 'design' }, // finish to start, no gap { source: 'design', target: 'build' }, { source: 'build', target: 'docs', type: 'SS' }, // start to start, a gap of three days { source: 'docs', target: 'review', type: 'FF' }, // finish to finish, a gap of four days { source: 'build', target: 'review' }, { source: 'review', target: 'qa' }, // finish to start, a gap of two days // The launch date is a promise: this link cannot be removed from the UI. { source: 'qa', target: 'launch', editable: false },];import type { GanttOptions } from '@ganttlane/dom';
export const options = { // The menu a click on an arrow opens is `linkMenu`: `render` is its content and `onRemove` what // its remove action calls. Both close over the example's own chart and status line, so they are // in `vanilla.ts` (`linkMenu`) and in `react.tsx` (`renderLinkMenu`, `linkMenu.onRemove`).
// No days off, so that moving a task by a day (the "Lag" actions) never lands on a weekend. calendar: { weekends: [] }, layout: { rowHeight: 40, barHeight: 26 }, viewMode: 'week', scroll: { initial: 'first-task' },} satisfies Partial<GanttOptions>;import '@ganttlane/dom/styles.css';
import { createGantt, type Gantt, type LinkMenuContext } from '@ganttlane/dom';
import { links, tasks } from './data';import { actionsFor, lagText, type MenuAction, TYPE_NAMES } from './helpers';import { options } from './options';
export function mount(container: HTMLElement): () => void { const root = document.createElement('div'); const toolbar = document.createElement('div'); toolbar.setAttribute('role', 'toolbar'); const chart = document.createElement('div'); chart.style.height = '360px'; const status = document.createElement('p'); status.textContent = 'Click an arrow to open its menu.'; root.append(toolbar, chart, status); container.append(root);
// Run an action of the menu, and say what happened. The menu has closed by the time it runs. const act = (action: MenuAction): void => { void action.run(gantt).then((message) => { status.textContent = message; }); };
const gantt: Gantt = createGantt(chart, { tasks, links, ...options, linkMenu: { // The content of the menu: text sections, and one button for each action. Returning `null` // would open no menu. render: (context: LinkMenuContext) => { const { link, source, target, canRemove } = context; const title = `${source?.name ?? String(link.source)} → ${target?.name ?? String(link.target)}`; const subtitle = `${TYPE_NAMES[link.type]} · ${lagText(link)}`; // `canRemove` is false when links are not editable (`editing.links`) or this one is fixed // (`link.editable: false`): then the menu only tells. if (!canRemove) { return { title, subtitle, details: 'This dependency cannot be edited.' }; } return { title, subtitle, actions: [ ...actionsFor(link).map((action) => ({ label: action.label, onSelect: () => act(action), })), // `remove()` is `linkMenu.onRemove`, below, on the link as it is now. { label: 'Remove dependency', onSelect: () => context.remove() }, ], }; }, // What the remove action calls. The default is the chart's own `removeLink`; this one also // says so, and it is where you would confirm, or remove the link from your own data. onRemove: (link) => { const names = `${gantt.getTask(link.source)?.name ?? String(link.source)} → ${gantt.getTask(link.target)?.name ?? String(link.target)}`; void gantt.removeLink(link.id).then((result) => { status.textContent = result.status === 'deleted' ? `Removed ${names}. Reset brings it back.` : `${names} stayed (${result.status}).`; }); }, }, });
const controller = new AbortController(); const reset = document.createElement('button'); reset.type = 'button'; reset.textContent = 'Reset'; reset.addEventListener( 'click', () => { gantt.setTasks(tasks); gantt.setLinks(links); status.textContent = 'The plan is back as it was.'; }, { signal: controller.signal }, ); const label = document.createElement('label'); const editable = document.createElement('input'); editable.type = 'checkbox'; editable.checked = true; editable.addEventListener( 'change', () => { // `editing` is taken whole by `setOptions`. With links off, `canRemove` is false. gantt.setOptions({ editing: { links: editable.checked } }); status.textContent = editable.checked ? 'Links can be edited: the menu has its actions.' : 'Links cannot be edited: the menu only tells.'; }, { signal: controller.signal }, ); label.append(editable, ' Links can be edited'); toolbar.append(reset, label);
return () => { controller.abort(); gantt.destroy(); root.remove(); };}import '@ganttlane/react/styles.css';import './styles.css';
import { Gantt, type GanttHandle, type LinkMenuContext } from '@ganttlane/react';import { useRef, useState } from 'react';
import { links, tasks } from './data';import { actionsFor, lagText, type MenuAction, TYPE_NAMES } from './helpers';import { options } from './options';
// The menu is a component: it sees your context providers and your state, and may use hooks.// Keep the logic here and return `<LinkMenu … />` from `renderLinkMenu`.function LinkMenu({ context, onAction,}: { context: LinkMenuContext; onAction: (action: MenuAction) => void;}) { const { link, source, target, canRemove, api } = context; return ( <div className="link-menu"> <div className="link-menu__title"> {source?.name ?? String(link.source)} → {target?.name ?? String(link.target)} </div> <div className="link-menu__details"> {TYPE_NAMES[link.type]} · {lagText(link)} </div> {/* `canRemove` is false when links are not editable (`editing.links`) or this one is fixed (`link.editable: false`): then the menu only tells. */} {canRemove ? ( <div className="link-menu__actions"> {actionsFor(link).map((action) => ( <button key={action.label} type="button" role="menuitem" className="link-menu__action" onClick={() => { api.hide(); // a button of your own does not close the menu; `api.hide()` does onAction(action); }} > {action.label} </button> ))} {/* `remove()` is `linkMenu.onRemove`, below, on the link as it is now. */} <button type="button" role="menuitem" className="link-menu__action" onClick={() => { context.remove(); }} > Remove dependency </button> </div> ) : ( <div className="link-menu__details">This dependency cannot be edited.</div> )} </div> );}
export default function Example() { const chart = useRef<GanttHandle>(null); const [status, setStatus] = useState('Click an arrow to open its menu.'); const [editable, setEditable] = useState(true); const { viewMode, ...rest } = options;
// Run an action of the menu, and say what happened. The menu has closed by the time it runs. const act = (action: MenuAction): void => { const handle = chart.current; if (handle) void action.run(handle).then(setStatus); };
return ( <> <div role="toolbar"> <button type="button" onClick={() => { chart.current?.setTasks(tasks); chart.current?.setLinks(links); setStatus('The plan is back as it was.'); }} > Reset </button> <label> <input type="checkbox" checked={editable} onChange={(event) => { setEditable(event.target.checked); setStatus( event.target.checked ? 'Links can be edited: the menu has its actions.' : 'Links cannot be edited: the menu only tells.', ); }} />{' '} Links can be edited </label> </div> <Gantt ref={chart} style={{ height: 360 }} // In React `tasks`, `links` and `viewMode` are controlled props, and `defaultTasks`, // `defaultLinks` and `defaultViewMode` are where an uncontrolled chart starts (there is // no `dataMode` prop). defaultTasks={tasks} defaultLinks={links} defaultViewMode={viewMode} {...rest} // With links off, `canRemove` is false. editing={{ links: editable }} // The content of the menu, as a ReactNode (`linkMenu.render` for React). renderLinkMenu={(context) => <LinkMenu context={context} onAction={act} />} linkMenu={{ // What the remove action calls. The default is the chart's own `removeLink`; this one // also says so, and it is where you would confirm, or remove the link from your data. onRemove: (link) => { const names = `${chart.current?.getTask(link.source)?.name ?? String(link.source)} → ${chart.current?.getTask(link.target)?.name ?? String(link.target)}`; void chart.current?.removeLink(link.id).then((result) => { setStatus( result.status === 'deleted' ? `Removed ${names}. Reset brings it back.` : `${names} stayed (${result.status}).`, ); }); }, }} /> <p>{status}</p> </> );}import type { Gantt, LinkRecord, LinkType } from '@ganttlane/dom';
/** The methods of the chart that the actions of the menu call. */export type LinkEditor = Pick< Gantt, 'addLink' | 'getLink' | 'getTask' | 'removeLink' | 'updateTask'>;
/** One custom action of the menu: its label, and what it does with the chart. */export interface MenuAction { readonly label: string; /** Runs the action; resolves to a sentence for the status line. */ readonly run: (editor: LinkEditor) => Promise<string>;}
const LINK_TYPES: readonly LinkType[] = ['FS', 'SS', 'FF', 'SF'];
/** What each type of link says. */export const TYPE_NAMES: Record<LinkType, string> = { FS: 'finish to start', SS: 'start to start', FF: 'finish to finish', SF: 'start to finish',};
const plural = (days: number): string => `${String(days)} ${Math.abs(days) === 1 ? 'day' : 'days'}`;
/** * The lag of a link in words. A link without a `lag` of its own keeps the gap its tasks have, and * the record says what that is (`lag.days`, negative for an overlap). * * @param link - The record of a link. * @returns For example `'lag of 2 days'`, `'no lag'`, `'a lead of 1 day'`. */export function lagText(link: LinkRecord): string { const days = Math.round((link.lag?.days ?? 0) * 10) / 10; if (days === 0) return 'no lag'; return days > 0 ? `lag of ${plural(days)}` : `a lead of ${plural(-days)}`;}
/** The name of a task, or its id when the chart has no task of that id. */function nameOf(editor: LinkEditor, id: LinkRecord['source']): string { return editor.getTask(id)?.name ?? String(id);}
/** Why an operation of the chart did not do what it was asked: its status, or its reason. */function why(result: { status: string; reason?: string }): string { return result.reason ?? result.status;}
/** * Gives a link another type. The chart has no "change the type" call: a link is added with the new * type, and the old one removed once that worked, so a refusal leaves the old link where it was. * The new link has no `lag`, so it keeps the gap that its new anchors have. */async function changeType(editor: LinkEditor, link: LinkRecord, type: LinkType): Promise<string> { const names = `${nameOf(editor, link.source)} → ${nameOf(editor, link.target)}`; const added = await editor.addLink({ source: link.source, target: link.target, type }); if (added.status !== 'created') return `${names} was not changed (${why(added)}).`; const removed = await editor.removeLink(link.id); if (removed.status !== 'deleted') return `${names}: the old link stayed (${why(removed)}).`; const created = editor.getLink(added.id); return `${names} is now ${TYPE_NAMES[type]}${created ? `, ${lagText(created)}` : ''}.`;}
/** * Changes the lag of a link by moving its successor. A link with no `lag` of its own keeps whatever * gap there is, so the gap is changed where it lives: the successor starts a day later (or * earlier), and what follows it moves along. */async function shiftSuccessor(editor: LinkEditor, link: LinkRecord, days: number): Promise<string> { const target = editor.getTask(link.target); if (!target?.start) return 'The successor has no bar to move.'; // A `Date` is read through the local clock: `setDate` keeps the time of day across a DST change. const start = new Date(target.start); start.setDate(start.getDate() + days); const result = await editor.updateTask(link.target, { start }); if (result.status !== 'committed') return `${target.name} was not moved (${why(result)}).`; const now = editor.getLink(link.id); return `${target.name} moved ${plural(Math.abs(days))} ${days > 0 ? 'later' : 'earlier'}${now ? `: ${lagText(now)}` : ''}.`;}
/** * The custom actions of the menu of a link: one for every other type, and two for the lag. The * lag cannot be taken below nothing here, which keeps a finish-to-start link from overlapping. * * @param link - The record of the link the menu is about. * @returns The actions, in the order the menu shows them. */export function actionsFor(link: LinkRecord): MenuAction[] { const actions: MenuAction[] = LINK_TYPES.filter((type) => type !== link.type).map((type) => ({ label: `Make it ${TYPE_NAMES[type]}`, run: (editor: LinkEditor) => changeType(editor, link, type), })); actions.push({ label: 'Lag +1 day', run: (editor) => shiftSuccessor(editor, link, 1) }); if ((link.lag?.days ?? 0) >= 1) { actions.push({ label: 'Lag −1 day', run: (editor) => shiftSuccessor(editor, link, -1) }); } return actions;}/* * The menu of the React example. The vanilla example returns text sections and lets the chart draw * them; a React menu is your own markup, so it brings the few rules it needs. It lives inside * `div.gantt__popover`, which the chart styles with the popover tokens, and these rules use the * same ones, so the menu follows the theme. */.gantt__popover .link-menu { min-width: 190px;}
.gantt__popover .link-menu__title { font-weight: 600;}
.gantt__popover .link-menu__details { color: var(--gantt-header-text-color);}
.gantt__popover .link-menu__actions { display: flex; flex-direction: column; gap: 4px; margin-block-start: 6px;}
.gantt__popover .link-menu__action { padding: 2px 8px; font: inherit; text-align: start; color: var(--gantt-link-selected-color); background: transparent; border: var(--gantt-border-width) solid var(--gantt-popover-border-color); border-radius: calc(var(--gantt-popover-radius) / 2); cursor: pointer;}
.gantt__popover .link-menu__action:is(:hover, :focus-visible) { background: var(--gantt-header-background-color);}
.gantt__popover .link-menu__action:focus-visible { outline: var(--gantt-focus-ring-width) solid var(--gantt-focus-ring-color); outline-offset: 1px;}Files
data.ts: seven tasks and seven links of three types, with different gaps. The last one (QA to Launch) iseditable: false.options.ts: no weekends, row heights, the first scroll.helpers.ts: the actions (actionsFor), shared by both flavours.vanilla.ts:linkMenu.renderreturning text sections, andlinkMenu.onRemove.react.tsxandstyles.css:renderLinkMenureturning a<LinkMenu>component, and the few rules its markup needs.
Options
| Option | Value | What it does |
|---|---|---|
linkMenu.render |
a function (vanilla) | The content of the menu: sections of text and buttons, or null for no menu |
renderLinkMenu |
a function (React) | The same, returning a ReactNode; replaces linkMenu.render |
linkMenu.onRemove |
a function | What the remove action calls (default: the chart’s removeLink) |
editing.links |
true; the checkbox: false |
Whether the UI may draw and remove links. With false the context’s canRemove is false |
link.editable |
false on QA to Launch |
A link that cannot be removed from the UI |
calendar.weekends |
[] |
No days off, so a task moved by a day never lands on a weekend |
layout.rowHeight, layout.barHeight |
40, 26 |
Rows a little shorter than the default |
viewMode |
'week' |
The scale of the timeline (React: defaultViewMode) |
scroll.initial |
'first-task' |
Opens on the plan |
What to try
Click the arrow from Build to Docs (start to start): the menu names the link, says what it is (“start to start · lag of 3 days”) and offers the other three types, a day more or less of lag, and Remove. Make it finish to finish, then click it again. Choose “Lag +1 day”: Docs, and what follows it, move a day later, and the status line says what the lag is now. Remove a link, then press Reset. Click the last arrow, QA to Launch: it is fixed, so its menu only tells. Untick “Links can be edited” and click any arrow: the same. Escape, a click outside or a scroll closes a menu; Tab moves between its items.
Notes
- The context.
renderis called with{ link, source, target, canRemove, remove(), locale, api }.linkis the record of the link (type,lag.days,editable, …),sourceandtargetthe records of its tasks,remove()callsonRemoveand closes the menu, andapihasgetTask,getLinkandhide(). It is called again when the link, its tasks, the locale or the strings change while the menu is open. canRemoveasks again each time it is read: it is true whenlinkMenu.onRemoveis set,editing.linksis on, and the link is editable. A menu with actions should show them only then; this one shows text instead. Turningediting.linksoff under an open menu closes it.- There is no “change the type” call. A link has no setter: this example adds a link of the new
type, and removes the old one once the add worked (
addLink, thenremoveLink), so that a refusal (a duplicate, a cycle, a hook that says no) leaves the old link in place. The new link has nolag, so it keeps the gap that its new anchors have. Each call is its own change for the before-hooks, and its own entry forhistory. - The lag is the gap. A link without a
lagkeeps whatever gap its tasks have, so “Lag +1 day” moves the successor (updateTask(target, { start })), and propagation moves what follows it. A link with an explicitlagis a minimum instead: moving the successor closer reports a conflict. “Lag −1 day” is only offered while there is a gap to take from. - The status line is the application’s. The actions return a sentence from the result of the
call (
'committed','rejected'with a reason,'busy'), because a menu that cannot say why nothing happened is worse than none. - React. The menu is drawn through a portal into the popover and sees your context providers. The
popover does not know your buttons, so call
api.hide()(orremove(), which closes it) from them; text sections in vanilla close the menu by themselves. Give your buttonsrole="menuitem": the menu isrole="menu", and the focus goes to its first button when it opens. - The wording of the default menu (
Remove dependency, the title) isstrings.removeLinkandstrings.linkTitle; a menu of your own writes its own.