Selection
A chart keeps a selection: the selected tasks (selectedKeys) and the selected link. Selected
bars and links are outlined. The tasks are selected by your code (select(ids)), and here also by a
click, with selection.onClick, and the link with a click on its arrow (or by code, with
selectLink(id)). Every change of either arrives at onSelectionChange(selectedKeys, selectedLink).
With selection.multiple, Ctrl or Cmd + click adds a task or takes it out. With keyboard.barFocus
the bars can be reached with Tab, and walked and selected from the keyboard. The panel under the
chart shows the selected tasks and link as getTask(id) and getLink(id) return them.
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. Build is a parent: its record lists its children.export const tasks: Task[] = [ { id: 'discovery', name: 'Discovery', start: '2026-03-02', end: '2026-03-06', progress: 100 }, { id: 'design', name: 'Design', start: '2026-03-09', end: '2026-03-13', progress: 80 }, { id: 'build', name: 'Build', start: '2026-03-16', end: '2026-03-27', progress: 30 }, { id: 'backend', name: 'Backend', parentId: 'build', start: '2026-03-16', end: '2026-03-24', progress: 45, }, { id: 'frontend', name: 'Frontend', parentId: 'build', start: '2026-03-18', end: '2026-03-27', progress: 20, }, { id: 'docs', name: 'Docs', start: '2026-03-23', end: '2026-03-26' }, { id: 'qa', name: 'QA', start: '2026-03-30', end: '2026-03-31' }, { id: 'launch', name: 'Launch', type: 'milestone', start: '2026-04-01' },];
export const links: Link[] = [ { source: 'discovery', target: 'design' }, { source: 'design', target: 'build' }, { source: 'build', target: 'qa' }, { source: 'docs', target: 'qa' }, { source: 'qa', target: 'launch' },];import type { GanttOptions } from '@ganttlane/dom';
export const options = { viewMode: 'week', scroll: { initial: 'first-task' }, // A click selects the bar; Ctrl or Cmd adds it or takes it out, and the keys reach the bars. selection: { onClick: true, multiple: true }, keyboard: { barFocus: true }, // Smaller rows, so that all eight rows are in view at once. layout: { rowHeight: 36, barHeight: 24 },} satisfies Partial<GanttOptions>;import '@ganttlane/dom/styles.css';import './styles.css';
import { createGantt, type Gantt, type TaskId } from '@ganttlane/dom';
import { links, tasks } from './data';import { type Field, linkFields, selectionText, taskFields } from './helpers';import { options } from './options';
/** A card of the side panel: a title and a list of labels and values. */function card(title: string, fields: readonly Field[]): HTMLElement { const element = document.createElement('div'); element.className = 'selection-card'; const heading = document.createElement('h4'); heading.textContent = title; const list = document.createElement('dl'); for (const [label, value] of fields) { const term = document.createElement('dt'); term.textContent = label; const description = document.createElement('dd'); description.textContent = value; list.append(term, description); } element.append(heading, list); return element;}
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 panel = document.createElement('div'); panel.className = 'selection-panel'; panel.setAttribute('aria-live', 'polite'); const status = document.createElement('p'); root.append(toolbar, chart, panel, status); container.append(root);
// What the chart reported last: the selected tasks, and the selected link (a click on an arrow). let selected: readonly TaskId[] = []; let selectedLink: string | undefined;
// The handlers read `gantt`, which they are passed to: give it a type. const gantt: Gantt = createGantt(chart, { tasks, links, ...options, // `select`, `selectLink`, `clearSelection` and a click on a link all end here. onSelectionChange: (keys, link) => { selected = keys; selectedLink = link; status.textContent = selectionText(keys, link); render(); }, // A dragged bar changes its record, so the panel is drawn again. onTasksChange: () => { render(); }, }); const nameOf = (id: TaskId): string => gantt.getTask(id)?.name ?? String(id);
/** The side panel: the record of each selected task, and of the selected link. */ function render(): void { const cards: HTMLElement[] = []; for (const id of selected) { const record = gantt.getTask(id); // the task as the chart computed it if (record) cards.push(card(record.name, taskFields(record, nameOf))); } const link = selectedLink === undefined ? undefined : gantt.getLink(selectedLink); if (link) cards.push(card(`${nameOf(link.source)} → ${nameOf(link.target)}`, linkFields(link))); if (cards.length === 0) { const empty = document.createElement('div'); empty.className = 'selection-empty'; empty.textContent = 'Nothing is selected. Click a bar (hold Ctrl or Cmd to add one) or the arrow of a link.'; cards.push(empty); } panel.replaceChildren(...cards); }
const addButton = (label: string, onClick: () => void): void => { const button = document.createElement('button'); button.type = 'button'; button.textContent = label; button.addEventListener('click', onClick, { signal: controller.signal }); toolbar.append(button); }; // `select(ids)` replaces the selected tasks; ids the chart does not know are left out. addButton('Select Backend and Frontend', () => { gantt.select(['backend', 'frontend']); }); addButton('Select all', () => { gantt.select(tasks.map((task) => task.id)); }); // A link is selected by its id: `source:type:target`, unless the link has an `id` of its own. addButton('Select Design → Build', () => { gantt.selectLink('design:FS:build'); }); addButton('Clear selection', () => { gantt.clearSelection(); });
gantt.select(['backend']); render();
return () => { controller.abort(); gantt.destroy(); root.remove(); };}import '@ganttlane/react/styles.css';import './styles.css';
import { type GanttInstance, type TaskId, type TaskRecord, useGantt, useGanttState,} from '@ganttlane/react';import { Fragment, useState } from 'react';
import { links, tasks } from './data';import { type Field, linkFields, selectionText, taskFields } from './helpers';import { options } from './options';
/** A card of the side panel: a title and a list of labels and values. */function Card({ title, fields }: { title: string; fields: readonly Field[] }) { return ( <div className="selection-card"> <h4>{title}</h4> <dl> {fields.map(([label, value]) => ( <Fragment key={label}> <dt>{label}</dt> <dd>{value}</dd> </Fragment> ))} </dl> </div> );}
/** Two lists of records are the same when each record is the same object (a record is frozen). */const sameRecords = (a: readonly unknown[], b: readonly unknown[]): boolean => a.length === b.length && a.every((record, index) => record === b[index]);
export default function Example() { // What `onSelectionChange` reported last (the chart starts with Backend selected). const [status, setStatus] = useState(selectionText(['backend'], undefined)); // `viewMode` is a controlled prop in React: `defaultViewMode` is where the chart starts, and // then the chart owns it. The selection has a pair of its own, `selectedKeys` (controlled) and // `defaultSelectedKeys`; here the chart owns it, and `select()` and the events are the same as // in the vanilla code. const { viewMode, ...rest } = options;
// The handlers read `gantt`, which they are passed to: give it a type. const gantt: GanttInstance = useGantt({ ...rest, defaultViewMode: viewMode, // `tasks` and `links` are controlled props: these are where the chart starts. defaultTasks: tasks, defaultLinks: links, defaultSelectedKeys: ['backend'], // `select`, `selectLink`, `clearSelection` and a click on a link all end here. onSelectionChange: (keys, link) => { setStatus(selectionText(keys, link)); }, }); // Destructured, which keeps `react-hooks/refs` quiet about the ref callback. const { setContainer, slots } = gantt;
const nameOf = (id: TaskId): string => gantt.getTask(id)?.name ?? String(id); // The records of the selected tasks and the selected link, read from the chart's snapshot: the // component renders again when the selection changes and when a dragged bar changes a record. const records = useGanttState( gantt, (snapshot) => snapshot.selection.tasks.flatMap((id): TaskRecord[] => { const record = gantt.getTask(id); return record ? [record] : []; }), sameRecords, ); const selectedLink = useGanttState(gantt, (snapshot) => snapshot.selection.link); const link = selectedLink === undefined ? undefined : gantt.getLink(selectedLink);
return ( <> <div role="toolbar"> <button type="button" onClick={() => gantt.select(['backend', 'frontend'])}> Select Backend and Frontend </button> <button type="button" onClick={() => gantt.select(tasks.map((task) => task.id))}> Select all </button> <button type="button" onClick={() => gantt.selectLink('design:FS:build')}> Select Design → Build </button> <button type="button" onClick={() => gantt.clearSelection()}> Clear selection </button> </div> {/* `useGantt` is <Gantt> without the `div`: you render the container, and the slots next to it. */} <div ref={setContainer} style={{ height: 370 }} /> {slots} <div className="selection-panel" aria-live="polite"> {records.map((record) => ( <Card key={record.id} title={record.name} fields={taskFields(record, nameOf)} /> ))} {link && ( <Card title={`${nameOf(link.source)} → ${nameOf(link.target)}`} fields={linkFields(link)} /> )} {records.length === 0 && !link && ( <div className="selection-empty"> Nothing is selected. Click a bar (hold Ctrl or Cmd to add one) or the arrow of a link. </div> )} </div> <p>{status}</p> </> );}import type { LinkRecord, TaskId, TaskRecord } from '@ganttlane/dom';
/** A label and a value of the side panel. */export type Field = readonly [label: string, value: string];
/** A date as `2026-03-16` (the dates of a record are `Date`s with the task's own wall clock). */const day = (date: Date | undefined): string => date === undefined ? '–' : date.toLocaleDateString('en-CA');
/** * What the side panel shows of a task: the record `getTask(id)` returns, as lines of text. * * @param record - The record of a task. * @param nameOf - The name of a task from its id. * @returns A label and a value for each line. */export function taskFields(record: TaskRecord, nameOf: (id: TaskId) => string): Field[] { return [ ['Dates', `${day(record.start)} to ${day(record.end)}`], ['Length', `${record.duration?.workingDays ?? 0} working days`], ['Progress', `${record.progress}%`], ['Parent', record.parentId === undefined ? '–' : nameOf(record.parentId)], ['Children', record.childIds.length === 0 ? '–' : record.childIds.map(nameOf).join(', ')], ['Conflicts', String(record.conflicts.length)], ];}
/** * What the side panel shows of a selected link: the record `getLink(id)` returns. * * @param record - The record of a link. * @returns A label and a value for each line. */export function linkFields(record: LinkRecord): Field[] { return [ ['Type', record.type], ['Gap', `${record.lag?.days ?? 0} days${record.lag?.derived === true ? ' (as it is)' : ''}`], ['Removable', record.editable ? 'yes' : 'no (editable: false)'], ];}
/** A line for the status: what `onSelectionChange` reported. */export function selectionText(keys: readonly TaskId[], link: string | undefined): string { return `selectedKeys: [${keys.join(', ')}]; selectedLink: ${link ?? 'none'}`;}/* * The side panel of the example: your own markup, so your own styles. Colours come from the text * colour of the page, so that the panel fits a light and a dark page alike. */.selection-panel { display: flex; flex-wrap: wrap; align-content: flex-start; gap: 0.5rem; height: 11rem; margin-top: 0.5rem; overflow-y: auto; font-size: 0.8125rem; line-height: 1.4;}
.selection-card { flex: 1 1 16rem; max-width: 22rem; padding: 0.5rem 0.75rem; border: 1px solid color-mix(in srgb, currentColor 25%, transparent); border-radius: 0.375rem;}
.selection-card h4 { margin: 0 0 0.25rem; font-size: 0.875rem;}
.selection-card dl { display: grid; grid-template-columns: auto 1fr; gap: 0.125rem 0.75rem; margin: 0;}
.selection-card dt { opacity: 0.7;}
.selection-card dd { margin: 0;}
.selection-empty { align-self: center; opacity: 0.7;}Files
data.ts: eight tasks, one of them a parent with two children, and five links.options.ts: the selection, the keyboard, the view mode, the first scroll and the layout.helpers.ts: the lines of the panel and the status.styles.css: your own styles for the panel.vanilla.tsandreact.tsx: the handlers, the buttons and the panel.
Options
| Option | Value | What it does |
|---|---|---|
selection.onClick |
true |
A click on a bar selects that task |
selection.multiple |
true |
With Ctrl or Cmd, a click adds the task or takes it out |
keyboard.barFocus |
true |
The bars take the keyboard focus, with one tab stop for all of them |
onSelectionChange |
a function (in the example) | (selectedKeys, selectedLink): the whole selection, after every change |
onTasksChange |
a function (vanilla) | A bar was dragged: the panel shows the new record |
defaultSelectedKeys |
['backend'] (React) |
The tasks selected at the start; the vanilla code calls select(['backend']) |
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 row is in view |
Methods
| Method | What it does |
|---|---|
select(ids) |
Replaces the selected tasks; an id the chart does not know is left out |
selectLink(id) |
Selects a link by its id (source:type:target unless the link has its own id) |
clearSelection() |
Clears the selected tasks and the selected link |
getTask(id) |
The TaskRecord of a task: dates, progress, parent, children, conflicts, … |
getLink(id) |
The LinkRecord of a link: ends, type, gap, whether it can be removed |
What to try
- Click Design: it is outlined, and the panel shows its record. The status line is what
onSelectionChangewas given. Click Docs with Ctrl or Cmd held: both are selected. Do it again: Docs is out. - Press Tab until a bar has a ring. Up, Down, Home and End walk the bars, Enter selects, and Ctrl or Cmd + Enter adds. Left and Right scroll the chart.
- Click Build: its record lists its children. Drag Build, and the panel follows: its dates change,
and its
conflictscount when it ends on a weekend. - Click the arrow from Design to Build: the link is selected, with its type and its gap, and the tasks stay selected. Click a bar again: the link is not selected any more.
- Use the buttons: Select all, Select Backend and Frontend, Select Design → Build, Clear selection.
Notes
- What a click changes. A click on a link selects it and keeps the selected tasks. A click on a
bar or on empty space clears the selected link, and the tasks follow
selection. Withoutselection.onClicka click selects no task, andonTaskClickis where you decide what it means. A drag selects nothing, with Ctrl or Cmd too. - The selection follows the data. A task that leaves the data (
setTasks) leaves the selection, andselect([...])with an id that does not exist leaves it out. It is not part of the history. - React. The selected tasks are a controlled prop of their own,
selectedKeys(defaultSelectedKeysis the uncontrolled one), with the sameonSelectionChange; the selected link is always the chart’s.useGanttState(gantt, (snapshot) => snapshot.selection)reads both. A record is frozen and the same object until its task changes, so a list of records that are compared one by one re-renders only when one of them did.