Task list beside the chart
A classic gantt tool has a grid on the left: the task names, indented by their depth, with a column of dates, and the bars to the right of it on the same rows. The library has one, the task list. This example draws its own instead, for when you want your own markup: the chart tells you where its rows are, and a list you draw yourself lines up with them. This one has
- a label for every visible row, at the row’s height and place, indented by depth, with the dates as a second column, under a header as tall as the chart’s;
- toggles on the groups (
expandandcollapse), and keys: Up and Down move, Left and Right shut and open a group, Enter selects and scrolls to the bar (role="tree", witharia-expandedandaria-level); - a click on a name that selects the task and scrolls the chart to its bar (
select,scrollToTask), and a click on a bar that selects its row in the list (onTaskClick); - hover both ways: a name lights its row in the chart, and a bar (or its row) lights its name;
- dates that follow a drag (
onTasksChange), and rows that still line up after a change of the row height, the view mode, the theme, or what is collapsed.
The list is plain markup that reads the chart’s public API. Nothing here is part of the library.
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';
// A plan of thirty tasks in four groups (Plan, Build, Test, Launch), nested two levels deep: a// group holds tasks and sub-groups, a sub-group holds tasks. `parentId` makes the tree, and the// rows are the tasks in pre-order (a group, then what is in it). That is more rows than the demo// shows at once, so the list scrolls. 2026-03-02 is a Monday; a task with an `end` ends on that// day (inclusive).//// A group has dates of its own that cover its children, and `followParent` ties a child to its// group: drag a group and the children follow, and the list shows their new dates.export const tasks: Task[] = [ // ---- Plan ---- { id: 'plan', name: 'Plan', start: '2026-03-02', end: '2026-03-20', progress: 100 }, { id: 'research', name: 'Research', parentId: 'plan', start: '2026-03-02', end: '2026-03-06', progress: 100, followParent: true, }, { id: 'scope', name: 'Scope', parentId: 'plan', start: '2026-03-09', end: '2026-03-10', progress: 100, followParent: true, }, { id: 'architecture', name: 'Architecture', parentId: 'plan', start: '2026-03-11', end: '2026-03-20', progress: 100, followParent: true, }, { id: 'system-design', name: 'System design', parentId: 'architecture', start: '2026-03-11', end: '2026-03-17', progress: 100, followParent: true, }, { id: 'data-model', name: 'Data model', parentId: 'architecture', start: '2026-03-16', end: '2026-03-19', progress: 100, followParent: true, }, { id: 'api-contract', name: 'API contract', parentId: 'architecture', start: '2026-03-18', end: '2026-03-20', progress: 100, followParent: true, },
// ---- Build ---- { id: 'build', name: 'Build', start: '2026-03-23', end: '2026-05-01', progress: 45 }, { id: 'backend', name: 'Backend', parentId: 'build', start: '2026-03-23', end: '2026-04-17', progress: 80, followParent: true, }, { id: 'data-layer', name: 'Data layer', parentId: 'backend', start: '2026-03-23', end: '2026-04-03', progress: 100, followParent: true, }, { id: 'services', name: 'Services', parentId: 'backend', start: '2026-04-06', end: '2026-04-14', progress: 75, followParent: true, }, { id: 'endpoints', name: 'Endpoints', parentId: 'backend', start: '2026-04-15', end: '2026-04-17', progress: 40, followParent: true, }, { id: 'frontend', name: 'Frontend', parentId: 'build', start: '2026-03-30', end: '2026-04-24', progress: 50, followParent: true, }, { id: 'components', name: 'Components', parentId: 'frontend', start: '2026-03-30', end: '2026-04-10', progress: 100, followParent: true, }, { id: 'screens', name: 'Screens', parentId: 'frontend', start: '2026-04-08', end: '2026-04-22', progress: 50, followParent: true, }, { id: 'state-handling', name: 'State handling', parentId: 'frontend', start: '2026-04-15', end: '2026-04-24', progress: 10, followParent: true, }, { id: 'integration', name: 'Integration', parentId: 'build', start: '2026-04-20', end: '2026-05-01', progress: 0, followParent: true, },
// ---- Test ---- { id: 'test', name: 'Test', start: '2026-04-27', end: '2026-05-15' }, { id: 'unit-tests', name: 'Unit tests', parentId: 'test', start: '2026-04-27', end: '2026-05-06', followParent: true, }, { id: 'end-to-end-tests', name: 'End-to-end tests', parentId: 'test', start: '2026-05-04', end: '2026-05-12', followParent: true, }, { id: 'performance', name: 'Performance', parentId: 'test', start: '2026-05-06', end: '2026-05-15', followParent: true, }, { id: 'load-test', name: 'Load test', parentId: 'performance', start: '2026-05-06', end: '2026-05-12', followParent: true, }, { id: 'profiling', name: 'Profiling', parentId: 'performance', start: '2026-05-11', end: '2026-05-15', followParent: true, }, { id: 'bug-fixes', name: 'Bug fixes', parentId: 'test', start: '2026-05-11', end: '2026-05-15', followParent: true, },
// ---- Launch ---- { id: 'launch', name: 'Launch', start: '2026-05-18', end: '2026-05-29' }, { id: 'docs', name: 'Docs', parentId: 'launch', start: '2026-05-18', end: '2026-05-22', followParent: true, }, { id: 'guides', name: 'Guides', parentId: 'docs', start: '2026-05-18', end: '2026-05-21', followParent: true, }, { id: 'api-reference', name: 'API reference', parentId: 'docs', start: '2026-05-19', end: '2026-05-22', followParent: true, }, { id: 'release-candidate', name: 'Release candidate', parentId: 'launch', start: '2026-05-25', end: '2026-05-27', followParent: true, }, { id: 'release', name: 'Release', type: 'milestone', parentId: 'launch', start: '2026-05-29', followParent: true, },];
export const links: Link[] = [ { source: 'research', target: 'scope' }, { source: 'scope', target: 'system-design' }, { source: 'api-contract', target: 'data-layer' }, { source: 'data-layer', target: 'services' }, { source: 'services', target: 'endpoints' }, { source: 'endpoints', target: 'integration' }, { source: 'integration', target: 'end-to-end-tests' }, { source: 'bug-fixes', target: 'release-candidate' }, { source: 'release-candidate', target: 'release' },];import type { GanttOptions } from '@ganttlane/dom';
export const options = { // One column a week: the plan is a few screens wide, so the chart scrolls sideways. viewMode: 'week', // Every day is a working day, so a bar dropped on a weekend is no conflict to explain. calendar: { weekends: [] }, layout: { // The chart is as tall as its rows and does not scroll vertically itself: the box that holds // the list and the chart does, so the two move as one. The renderer finds that box (the // nearest ancestor that scrolls) and keeps the header stuck to the top of it. height: 'auto', // The list draws its rows from these (`getRowMetrics`, `snapshot.layoutOptions`), so they can // change later with `setOptions({ layout })` and the two still line up. rowHeight: 36, barHeight: 22, headerRowHeight: 28, }, // Zebra stripes for each group with its children; the list draws the same bands. display: { rowStripes: 'group' }, timeline: { padding: '0d' }, scroll: { initial: 'first-task' },} 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 { BAR_MARGIN, describeChange, describeExpanded, type ListRow, listRows, ROW_HEIGHTS, THEMES, treeAction, VIEW_MODES,} from './helpers';import { options } from './options';
/** A new element with a class, and children. */function element<K extends keyof HTMLElementTagNameMap>( tag: K, className: string, ...children: (Node | string)[]): HTMLElementTagNameMap[K] { const node = document.createElement(tag); node.className = className; node.append(...children); return node;}
/** Sets an attribute, or removes it for `undefined`; touches the DOM only when the value differs. */function setAttr(node: Element, name: string, value: string | undefined): void { if (value === undefined) { if (node.hasAttribute(name)) node.removeAttribute(name); } else if (node.getAttribute(name) !== value) { node.setAttribute(name, value); }}
/** Sets the text of a node; touches the DOM only when it differs. */function setText(node: Node, text: string): void { if (node.textContent !== text) node.textContent = text;}
export function mount(container: HTMLElement): () => void { const controller = new AbortController(); const { signal } = controller;
// ---- The markup ---- // // .task-list-scroller the box that scrolls vertically: the host of the chart // .task-list-content // .task-list the list: a header and a tree of rows // .task-list-chart the chart, as tall as its rows, and the hover band const root = element('div', 'task-list-demo'); const toolbar = element('div', ''); toolbar.setAttribute('role', 'toolbar'); const scroller = element('div', 'task-list-scroller'); const content = element('div', 'task-list-content'); const header = element('div', 'task-list__header'); header.setAttribute('aria-hidden', 'true'); header.append( element('span', 'task-list__header-name', 'Task'), element('span', 'task-list__header-dates', 'Dates'), ); const tree = element('div', 'task-list__tree'); tree.setAttribute('role', 'tree'); tree.setAttribute('aria-label', 'Tasks'); const list = element('div', 'task-list', header, tree); const chartColumn = element('div', 'task-list-chart'); const chart = element('div', 'task-list-chart__gantt'); const band = element('div', 'task-list__band'); band.hidden = true; chartColumn.append(chart, band); content.append(list, chartColumn); scroller.append(content); const status = element('p', ''); status.textContent = 'Click a name to scroll to its bar, or a bar to select its row. Drag a bar and the Dates follow.'; root.append(toolbar, scroller, 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 click on a bar does not select it: that is up to you. Here it selects its task, and // `sync` shows it in the list. onTaskClick: (record) => { gantt.select([record.id]); }, onTasksChange: (changeSet) => { status.textContent = describeChange(changeSet, nameOf); }, onExpandedChange: (keys) => { status.textContent = describeExpanded(keys, nameOf); }, }); const nameOf = (id: TaskId): string => gantt.getTask(id)?.name ?? String(id);
// ---- The list ----
/** The rows as drawn, the element of each task and the task of each element. */ let rows: readonly ListRow[] = []; const rowElements = new Map<TaskId, HTMLElement>(); const taskOf = new WeakMap<Element, TaskId>(); /** The row that is the tab stop of the tree (roving tabindex), and the selection it followed. */ let active: TaskId | undefined; let followed: readonly TaskId[] = []; /** The row the pointer is on in the list (the chart's own hover is in its snapshot). */ let pointerOn: TaskId | undefined; /** What `sync` last drew the rows and the sizes from. */ let seenRows: unknown; let seenLayout: unknown;
/** Draws the row of one task, or the one it already has, from the values in `row`. */ function paint(row: ListRow): HTMLElement { let node = rowElements.get(row.id); if (!node) { node = element('div', 'task-list__row'); node.setAttribute('role', 'treeitem'); node.dataset.taskId = String(row.id); node.tabIndex = -1; const toggle = element('span', 'task-list__toggle'); toggle.setAttribute('aria-hidden', 'true'); toggle.dataset.toggle = ''; node.append(toggle, element('span', 'task-list__name'), element('span', 'task-list__dates')); rowElements.set(row.id, node); taskOf.set(node, row.id); } node.style.setProperty('--depth', String(row.depth)); setAttr(node, 'aria-level', String(row.depth + 1)); setAttr(node, 'aria-setsize', String(row.setSize)); setAttr(node, 'aria-posinset', String(row.position)); // `aria-expanded` is for groups only: a leaf has no state to report. setAttr(node, 'aria-expanded', row.isGroup ? String(row.isExpanded) : undefined); setAttr(node, 'data-group', row.isGroup ? '' : undefined); setAttr(node, 'data-stripe', String(row.stripe)); const [, name, dates] = node.children; if (name && dates) { setText(name, row.name); setText(dates, row.dates); setAttr(name, 'title', row.name); } return node; }
/** Puts the rows in the tree in order, keeping the elements (and the focus) that stay. */ function place(): void { const present = new Set(rows.map((row) => row.id)); for (const [id, node] of rowElements) { if (present.has(id)) continue; node.remove(); rowElements.delete(id); } let next: Element | null = tree.firstElementChild; for (const row of rows) { const node = paint(row); if (node === next) next = next.nextElementSibling; else tree.insertBefore(node, next); } }
/** * Brings the list in line with the chart. It runs for every change of the snapshot, and a scroll * frame, a hover and a drag preview are each one, so it starts by comparing: what the chart * returns keeps its identity until it changes, and a new object means work to do. */ function sync(): void { const snapshot = gantt.core.getSnapshot();
// The sizes are the chart's: the list never has a number of its own. const layout = gantt.getLayoutMetrics(); if (layout !== seenLayout) { seenLayout = layout; root.style.setProperty('--task-list-row-height', `${String(layout.rowHeight)}px`); root.style.setProperty('--task-list-header-height', `${String(layout.headerHeight)}px`); }
// The rows change with what is collapsed and with the data (a name, a date): then // `getRows` returns a new array. const current = gantt.getRows(); if (current !== seenRows) { seenRows = current; rows = listRows(current); place(); }
// The selection, wherever it was made (a bar, the list, `select`): the tab stop follows it. const selected = snapshot.selection.tasks; if (selected !== followed) { followed = selected; active = selected.at(-1) ?? active; } if (active === undefined || !rowElements.has(active)) active = rows[0]?.id; const hovered = pointerOn ?? snapshot.hoveredRow; for (const row of rows) { const node = rowElements.get(row.id); if (!node) continue; setAttr(node, 'aria-selected', String(selected.includes(row.id))); setAttr(node, 'data-hovered', row.id === hovered ? '' : undefined); setAttr(node, 'tabindex', row.id === active ? '0' : '-1'); }
// The band over the chart's row: where `getRowMetrics` says the row of the hovered task lies. const lying = hovered === undefined ? undefined : gantt.getRowMetrics(hovered); band.hidden = lying === undefined; if (lying) band.style.setProperty('--task-list-band-y', `${String(layout.headerHeight + lying.y)}px`); } // Called after every change of the snapshot: also what is selected and hovered, not only the rows. gantt.core.subscribe(sync); sync();
/** A click on a name, or Enter on a row: select the task and scroll the chart to its bar. */ function choose(id: TaskId): void { active = id; gantt.select([id]); // Sideways to the bar; a row that is in view stays where it is. gantt.scrollToTask(id, { align: 'center', alignY: 'nearest', behavior: 'smooth' }); }
/** The id of the row an event happened in. */ const rowOf = (event: Event): { id: TaskId; node: Element } | undefined => { const node = event.target instanceof Element ? event.target.closest('.task-list__row') : null; const id = node ? taskOf.get(node) : undefined; return node && id !== undefined ? { id, node } : undefined; };
tree.addEventListener( 'click', (event) => { const hit = rowOf(event); if (!hit) return; const row = rows.find((candidate) => candidate.id === hit.id); // The chevron opens and shuts a group; the rest of the row selects the task. if ( row?.isGroup && event.target instanceof Element && event.target.closest('[data-toggle]') ) { active = hit.id; if (row.isExpanded) gantt.collapse(hit.id); else gantt.expand(hit.id); } else { choose(hit.id); } }, { signal }, );
tree.addEventListener( 'keydown', (event) => { const hit = rowOf(event); if (!hit || event.altKey || event.ctrlKey || event.metaKey) return; const action = treeAction(event.key, rows, hit.id); if (!action) return; // The arrows and Space would scroll the box otherwise. event.preventDefault(); switch (action.type) { case 'focus': // Focusing a row scrolls the box to it, and the chart with it. rowElements.get(action.id)?.focus(); break; case 'expand': gantt.expand(action.id); break; case 'collapse': gantt.collapse(action.id); break; case 'activate': choose(action.id); break; } }, { signal }, );
// The tab stop is the row that has the focus. tree.addEventListener( 'focusin', (event) => { const hit = rowOf(event); if (!hit || hit.id === active) return; active = hit.id; sync(); }, { signal }, );
// Hover: a mouse over a name lights the row of the chart (the band), and the other way round // (`sync` reads the chart's hover from its snapshot). tree.addEventListener( 'pointerover', (event) => { if (event.pointerType !== 'mouse') return; pointerOn = rowOf(event)?.id; sync(); }, { signal }, ); tree.addEventListener( 'pointerleave', () => { pointerOn = undefined; sync(); }, { signal }, );
// ---- The toolbar ----
const addButton = (label: string, onClick: () => void): void => { const button = element('button', '', label); button.type = 'button'; button.addEventListener('click', onClick, { signal }); toolbar.append(button); };
const addSelect = <T extends string | number>( label: string, values: readonly T[], selected: T, onChange: (value: T) => void, ): void => { const field = element('label', ''); const select = element('select', ''); for (const value of values) { // `new Option()` does not exist in happy-dom, where the examples are tested. const item = document.createElement('option'); item.value = item.textContent = String(value); item.selected = value === selected; select.append(item); } select.addEventListener( 'change', () => { const value = values.find((candidate) => String(candidate) === select.value); if (value !== undefined) onChange(value); }, { signal }, ); field.append(`${label} `, select); toolbar.append(field); };
addButton('Expand all', () => gantt.expandAll()); addButton('Collapse all', () => gantt.collapseAll()); addSelect('View', VIEW_MODES, options.viewMode, (viewMode) => gantt.setViewMode(viewMode)); // The rows get taller or shorter, and the list follows: it reads the sizes from the snapshot. addSelect('Row height', ROW_HEIGHTS, options.layout.rowHeight, (rowHeight) => { gantt.setOptions({ layout: { rowHeight, barHeight: rowHeight - BAR_MARGIN } }); }); addSelect('Theme', THEMES, 'auto', (theme) => gantt.setOptions({ theme }));
return () => { controller.abort(); // Also ends the subscription of `sync`. gantt.destroy(); root.remove(); };}import '@ganttlane/react/styles.css';import './styles.css';
import { type GanttInstance, type TaskId, useGantt, useGanttState } from '@ganttlane/react';import { type CSSProperties, type KeyboardEvent, type MouseEvent, useMemo, useRef, useState,} from 'react';
import { links, tasks } from './data';import { BAR_MARGIN, describeChange, describeExpanded, listRows, ROW_HEIGHTS, type Theme, THEMES, treeAction, VIEW_MODES,} from './helpers';import { options } from './options';
interface ListProps { gantt: GanttInstance; /** The task whose row is lit: the pointer is on its name, or on its row in the chart. */ hovered: TaskId | undefined; onPointer: (id: TaskId | undefined) => void;}
/** * The list. It reads the chart's snapshot with `useGanttState`, and renders again only when the * slice it reads changes: not while the chart scrolls. */function TaskList({ gantt, hovered, onPointer }: ListProps) { // The rows change with what is collapsed and with the data (a name, a date): then `getRows` // returns a new array. It keeps its identity while the chart scrolls, hovers and selects, so the // list is built again only when it changes. const current = useGanttState(gantt, () => gantt.getRows()); const rows = useMemo(() => listRows(current), [current]); // The sizes are the chart's: the list never has a number of its own. const { rowHeight, headerHeight } = useGanttState(gantt, () => gantt.getLayoutMetrics()); const selected = useGanttState(gantt, (snapshot) => snapshot.selection.tasks);
// The tab stop of the tree (roving tabindex): the row that last had the focus, or was selected. const [focused, setFocused] = useState<TaskId | undefined>(); const [followed, setFollowed] = useState(selected); const nodes = useRef(new Map<TaskId, HTMLElement>()); // The tab stop follows the selection, wherever it was made (a bar, the list, `select`). Set // while rendering, which React allows for state of the same component. if (followed !== selected) { setFollowed(selected); setFocused(selected.at(-1)); } const active = rows.some((row) => row.id === focused) ? focused : rows[0]?.id;
/** A click on a name, or Enter on a row: select the task and scroll the chart to its bar. */ const choose = (id: TaskId): void => { setFocused(id); gantt.select([id]); // Sideways to the bar; a row that is in view stays where it is. gantt.scrollToTask(id, { align: 'center', alignY: 'nearest', behavior: 'smooth' }); };
const onClick = (event: MouseEvent, id: TaskId, isGroup: boolean, isExpanded: boolean): void => { // The chevron opens and shuts a group; the rest of the row selects the task. if (isGroup && event.target instanceof Element && event.target.closest('[data-toggle]')) { setFocused(id); if (isExpanded) gantt.collapse(id); else gantt.expand(id); } else { choose(id); } };
const onKeyDown = (event: KeyboardEvent, id: TaskId): void => { if (event.altKey || event.ctrlKey || event.metaKey) return; const action = treeAction(event.key, rows, id); if (!action) return; // The arrows and Space would scroll the box otherwise. event.preventDefault(); switch (action.type) { case 'focus': // Focusing a row scrolls the box to it, and the chart with it. nodes.current.get(action.id)?.focus(); break; case 'expand': gantt.expand(action.id); break; case 'collapse': gantt.collapse(action.id); break; case 'activate': choose(action.id); break; } };
return ( <div className="task-list" style={ { '--task-list-row-height': `${String(rowHeight)}px`, '--task-list-header-height': `${String(headerHeight)}px`, } as CSSProperties } > <div className="task-list__header" aria-hidden="true"> <span className="task-list__header-name">Task</span> <span className="task-list__header-dates">Dates</span> </div> <div role="tree" aria-label="Tasks" className="task-list__tree"> {rows.map((row) => ( <div key={row.id} ref={(node) => { if (node) nodes.current.set(row.id, node); else nodes.current.delete(row.id); }} role="treeitem" className="task-list__row" data-task-id={row.id} data-group={row.isGroup ? '' : undefined} data-stripe={row.stripe} data-hovered={row.id === hovered ? '' : undefined} style={{ '--depth': row.depth } as CSSProperties} tabIndex={row.id === active ? 0 : -1} aria-level={row.depth + 1} aria-setsize={row.setSize} aria-posinset={row.position} // `aria-expanded` is for groups only: a leaf has no state to report. aria-expanded={row.isGroup ? row.isExpanded : undefined} aria-selected={selected.includes(row.id)} onClick={(event) => onClick(event, row.id, row.isGroup, row.isExpanded)} onKeyDown={(event) => onKeyDown(event, row.id)} onFocus={() => setFocused(row.id)} onPointerEnter={(event) => { if (event.pointerType === 'mouse') onPointer(row.id); }} onPointerLeave={() => onPointer(undefined)} > <span className="task-list__toggle" data-toggle="" aria-hidden="true" /> <span className="task-list__name" title={row.name}> {row.name} </span> <span className="task-list__dates">{row.dates}</span> </div> ))} </div> </div> );}
/** The row of the chart that is lit: where `getRowMetrics` says the hovered task's row lies. */function Band({ gantt, hovered }: { gantt: GanttInstance; hovered: TaskId | undefined }) { const { rowHeight, headerHeight } = useGanttState(gantt, () => gantt.getLayoutMetrics()); // Read from the snapshot's layout: the row moves with the rows of the chart. const y = useGanttState(gantt, (snapshot) => hovered === undefined ? undefined : snapshot.layout.getRowMetrics(hovered)?.y, ); return ( <div className="task-list__band" hidden={y === undefined} style={ { '--task-list-row-height': `${String(rowHeight)}px`, '--task-list-band-y': `${String(headerHeight + (y ?? 0))}px`, } as CSSProperties } /> );}
export default function Example() { const [rowHeight, setRowHeight] = useState<number>(options.layout.rowHeight); const [theme, setTheme] = useState<Theme>('auto'); const [pointerOn, setPointerOn] = useState<TaskId | undefined>(); const [status, setStatus] = useState( 'Click a name to scroll to its bar, or a bar to select its row. Drag a bar and the Dates follow.', ); const nameOf = (id: TaskId): string => gantt.getTask(id)?.name ?? String(id);
// `viewMode` is a controlled prop in React: the chart would show the one your state holds. The // select changes it through the instance, so the chart starts from `defaultViewMode` and owns it. const { viewMode, layout, ...rest } = options;
const gantt = useGantt({ ...rest, defaultViewMode: viewMode, defaultTasks: tasks, defaultLinks: links, // In React the props are the truth: the row height and the theme are state, passed from here. layout: { ...layout, rowHeight, barHeight: rowHeight - BAR_MARGIN }, theme, // A click on a bar does not select it: that is up to you. Here it selects its task, and the // list shows it. onTaskClick: (record) => gantt.select([record.id]), onTasksChange: (changeSet) => setStatus(describeChange(changeSet, nameOf)), onExpandedChange: (keys) => setStatus(describeExpanded(keys, nameOf)), }); // Destructured, which keeps `react-hooks/refs` quiet about the ref callback. const { setContainer, slots } = gantt; const mode = useGanttState(gantt, (snapshot) => snapshot.viewMode.id); // The row the pointer is on in the chart, over a bar or over empty space in its row. const inChart = useGanttState(gantt, (snapshot) => snapshot.hoveredRow); const hovered = pointerOn ?? inChart;
return ( <div className="task-list-demo"> <div role="toolbar"> <button type="button" onClick={() => gantt.expandAll()}> Expand all </button> <button type="button" onClick={() => gantt.collapseAll()}> Collapse all </button> <label> View{' '} <select value={mode} onChange={(event) => gantt.setViewMode(event.target.value)}> {VIEW_MODES.map((id) => ( <option key={id}>{id}</option> ))} </select> </label> <label> Row height{' '} <select value={rowHeight} onChange={(event) => setRowHeight(Number(event.target.value))}> {ROW_HEIGHTS.map((height) => ( <option key={height}>{height}</option> ))} </select> </label> <label> Theme{' '} <select value={theme} onChange={(event) => setTheme(THEMES.find((id) => id === event.target.value) ?? 'auto')} > {THEMES.map((id) => ( <option key={id}>{id}</option> ))} </select> </label> </div> <div className="task-list-scroller"> <div className="task-list-content"> <TaskList gantt={gantt} hovered={hovered} onPointer={setPointerOn} /> <div className="task-list-chart"> <div ref={setContainer} /> {slots} <Band gantt={gantt} hovered={hovered} /> </div> </div> </div> <p>{status}</p> </div> );}import type { ChangeSet, GanttRow, TaskId, TaskRecord } from '@ganttlane/dom';
/** The view modes the toolbar offers. */export const VIEW_MODES = ['day', 'week', 'month'] as const;
/** The row heights the toolbar offers, in px. */export const ROW_HEIGHTS = [28, 36, 44] as const;
/** The themes the toolbar offers (`theme` option). */export const THEMES = ['auto', 'light', 'dark'] as const;export type Theme = (typeof THEMES)[number];
/** A bar is this much shorter than its row, so that the bars of two rows never touch. */export const BAR_MARGIN = 14;
/** What the list shows of one row of the chart: plain values, so two of them can be compared. */export interface ListRow { readonly id: TaskId; readonly name: string; /** Nesting level, `0` for a root. */ readonly depth: number; /** Whether the task has children, so that the row has a toggle. */ readonly isGroup: boolean; /** Whether its children are shown. */ readonly isExpanded: boolean; /** The zebra stripe the chart draws under this row (`display.rowStripes: 'group'`). */ readonly stripe: 0 | 1; /** The secondary column: the dates, or the date of a milestone. */ readonly dates: string; /** For `aria-setsize` and `aria-posinset`: how many siblings the row has, and its place. */ readonly setSize: number; readonly position: number;}
// Day first, as `9–17 Mar` is: `en-GB` writes a date as `23 Mar`.const monthDay = new Intl.DateTimeFormat('en-GB', { day: 'numeric', month: 'short' });const month = new Intl.DateTimeFormat('en-GB', { month: 'short' });
/** * The dates of a task as a short range: `9–17 Mar` within a month, `28 Mar – 2 Apr` across two. * The dates of a record are `Date`s with the task's own wall clock, so they are read as local. * * @param record - The record of a task. * @returns The text for the dates column; empty for a row without a bar. */export function rangeText(record: TaskRecord): string { const { start, end } = record; if (start === undefined || end === undefined) return ''; if (record.type === 'milestone') return monthDay.format(start); if (start.getFullYear() === end.getFullYear() && start.getMonth() === end.getMonth()) { return start.getDate() === end.getDate() ? monthDay.format(start) : `${String(start.getDate())}–${String(end.getDate())} ${month.format(start)}`; } return `${monthDay.format(start)} – ${monthDay.format(end)}`;}
/** * The rows of the list: the rows the chart shows, in its order, with what each one needs. * * `getRows()` is what the chart draws: the visible tasks in pre-order, so the list never works out * the tree or what is collapsed itself. Build the list again only when it is another array than * last time: it keeps its identity through scrolling, hovering and selecting, and is new when a * group opens or shuts, and when the data changes. * * @param rows - `gantt.getRows()`. * @returns One entry per row of the chart, in the same order. */export function listRows(rows: readonly GanttRow[]): ListRow[] { return rows.map((row) => ({ id: row.id, name: row.name, depth: row.depth, isGroup: row.hasChildren, isExpanded: row.isExpanded, stripe: row.groupStripe, dates: rangeText(row.record), setSize: row.setSize, position: row.position, }));}
/** What a key does in the list, by the pattern of an ARIA tree. */export type TreeAction = | { readonly type: 'focus'; readonly id: TaskId } | { readonly type: 'expand'; readonly id: TaskId } | { readonly type: 'collapse'; readonly id: TaskId } | { readonly type: 'activate'; readonly id: TaskId };
/** * What a key does to the row that has the focus: Up and Down move, Home and End go to the ends, * Right opens a group (or steps into it once it is open), Left shuts one (or steps out to the * group it is in), Enter and Space select the task and scroll to it. * * @param key - `KeyboardEvent.key`. * @param rows - The rows of the list. * @param current - The row that has the focus. * @returns The action, or `undefined` when the key means nothing here. */export function treeAction( key: string, rows: readonly ListRow[], current: TaskId,): TreeAction | undefined { const index = rows.findIndex((row) => row.id === current); const row = rows[index]; if (row === undefined) return undefined; const focusAt = (to: number): TreeAction | undefined => { const target = rows[Math.min(Math.max(to, 0), rows.length - 1)]; return target === undefined ? undefined : { type: 'focus', id: target.id }; }; switch (key) { case 'ArrowDown': return focusAt(index + 1); case 'ArrowUp': return focusAt(index - 1); case 'Home': return focusAt(0); case 'End': return focusAt(rows.length - 1); case 'ArrowRight': if (!row.isGroup) return undefined; return row.isExpanded ? focusAt(index + 1) : { type: 'expand', id: row.id }; case 'ArrowLeft': { if (row.isGroup && row.isExpanded) return { type: 'collapse', id: row.id }; // The group a row is in is the nearest row above it that is less deep. const parent = rows.slice(0, index).findLast((above) => above.depth < row.depth); return parent === undefined ? undefined : { type: 'focus', id: parent.id }; } case 'Enter': case ' ': return { type: 'activate', id: row.id }; default: return undefined; }}
/** A length of time in days with its sign: `+2d`, `-1d`. */function signedDays(ms: number): string { const days = Math.round(ms / (24 * 60 * 60 * 1000)); return `${days > 0 ? '+' : ''}${String(days)}d`;}
/** * Says what a drag changed, for the status line. * * @param changeSet - What `onTasksChange` reports. Its dates are in core's floating milliseconds. * @param nameOf - The name of a task, from the chart. * @returns A sentence. */export function describeChange(changeSet: ChangeSet, nameOf: (id: TaskId) => string): string { const [first, ...others] = changeSet.changes; if (first === undefined) return 'Nothing changed.'; const start = first.after.start - first.before.start; const end = first.after.endExclusive - first.before.endExclusive; const how = start === end ? signedDays(start) : `start ${signedDays(start)}, end ${signedDays(end)}`; const also = others.length === 0 ? '' : ` ${String(others.length)} more moved with it.`; return `${nameOf(first.id)}: ${how}.${also} The Dates column follows.`;}
/** * Says which groups are open, for the status line. * * @param keys - What `onExpandedChange` reports: `'all'` or the ids of the open groups. * @param nameOf - The name of a task, from the chart. * @returns A sentence. */export function describeExpanded( keys: 'all' | readonly TaskId[], nameOf: (id: TaskId) => string,): string { if (keys === 'all') return 'Open: every group.'; return keys.length === 0 ? 'Open: none.' : `Open: ${keys.map(nameOf).join(', ')}.`;}/* * The task list beside the chart: your own markup, so your own styles. * * .task-list-demo the example: a toolbar, the box below, a status line * .task-list-scroller the one box that scrolls vertically, and so scrolls both columns * .task-list-content the two columns side by side * .task-list the list: a sticky header, then the tree of rows * .task-list-chart the chart's column; the chart is as tall as its rows (`layout.height`) * * Sizes come from the chart (`--task-list-row-height`, `--task-list-header-height`, set from its * snapshot), never from numbers written here, so the rows line up whatever `layout` says. */
/* * COLOURS. The chart declares its colours on `.gantt` and a sibling cannot inherit them, so the * list has a small palette of its own, with the values of the chart's default theme, and follows * the theme of the chart: `color-scheme` is what `light-dark()` reads, and it comes from the * `data-theme` of the `.gantt` inside (the site and `setOptions({ theme })` set it there). */.task-list-demo { container: task-list-demo / inline-size; color-scheme: light;}
.task-list-demo:has(.gantt[data-theme='dark']) { color-scheme: dark;}
.task-list-demo:has(.gantt[data-theme='auto']) { color-scheme: light dark;}
.task-list-demo { --task-list-background: light-dark(#fff, #15171c); --task-list-foreground: light-dark(#1b2230, #e4e8ef); --task-list-muted: light-dark(#465163, #a9b3c3); --task-list-border: light-dark(#d3d9e1, #333a46); --task-list-header-background: light-dark(#f5f6f8, #1c1f26); --task-list-stripe: light-dark(rgb(15 23 42 / 3.5%), rgb(255 255 255 / 3.5%)); --task-list-accent: light-dark(#2563eb, #6ea0ff); --task-list-focus: light-dark(#2563eb, #7aa7ff);}
/* THE BOX THAT SCROLLS. */.task-list-scroller { position: relative; height: 400px; overflow-x: hidden; overflow-y: auto; border: 1px solid var(--task-list-border, #d3d9e1); border-radius: 0.375rem; /* The row band (below) and the sticky headers are ordered inside the box, not against the page. */ isolation: isolate;}
.task-list-content { display: flex; /* Both columns as tall as the taller one, so the list's border runs down beside the chart. */ align-items: stretch;}
.task-list-chart { position: relative; flex: 1 1 0; /* Without this a flex item is as wide as its content, and the chart would widen the page. */ min-width: 0;}
/* THE LIST. */.task-list { position: sticky; /* Sticky to the left too: the list stays where it is even if the box ever scrolls sideways. */ inset-inline-start: 0; z-index: 3; box-sizing: border-box; flex: none; width: 15rem; color: var(--task-list-foreground); background: var(--task-list-background); border-inline-end: 1px solid var(--task-list-border); font: 12px / 1.2 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;}
.task-list__header { position: sticky; top: 0; z-index: 2; box-sizing: border-box; display: flex; align-items: center; gap: 4px; /* The height of the chart's header rows: the first row of the list starts where the chart's does. */ height: var(--task-list-header-height, 0); padding-inline: 8px; color: var(--task-list-muted); background: var(--task-list-header-background); border-block-end: 1px solid var(--task-list-border); font-weight: 600; white-space: nowrap;}
.task-list__header-name { flex: 1 1 0; min-width: 0;}
.task-list__header-dates,.task-list__dates { flex: none; width: 5.5rem; text-align: end;}
.task-list__row { box-sizing: border-box; display: flex; align-items: center; gap: 4px; height: var(--task-list-row-height, 0); /* Each level of the tree is indented by the same step. */ padding-inline: calc(8px + var(--depth, 0) * 12px) 8px; cursor: pointer; white-space: nowrap; outline: none; /* A row that gets the focus scrolls into view below the sticky header, not under it. */ scroll-margin-top: var(--task-list-header-height, 0);}
.task-list__row[data-stripe='1'] { background-color: var(--task-list-stripe);}
.task-list__row:is([data-hovered], :hover) { background-image: linear-gradient( color-mix(in srgb, var(--task-list-accent) 9%, transparent), color-mix(in srgb, var(--task-list-accent) 9%, transparent) );}
.task-list__row[aria-selected='true'] { background-image: linear-gradient( color-mix(in srgb, var(--task-list-accent) 18%, transparent), color-mix(in srgb, var(--task-list-accent) 18%, transparent) );}
.task-list__row:focus-visible { outline: 2px solid var(--task-list-focus); outline-offset: -2px;}
.task-list__row[data-group] .task-list__name { font-weight: 600;}
.task-list__name { flex: 1 1 0; min-width: 0; overflow: hidden; text-overflow: ellipsis;}
.task-list__dates { color: var(--task-list-muted); font-size: 11px; font-variant-numeric: tabular-nums;}
/* The toggle: a chevron that turns when the group is open. A leaf keeps the room, empty. */.task-list__toggle { position: relative; flex: none; width: 14px; height: 14px;}
.task-list__row[data-group] .task-list__toggle::before { content: ''; position: absolute; inset: 3px 4px 3px 2px; border: solid var(--task-list-muted); border-width: 0 2px 2px 0; transform: translate(25%, 0) rotate(-45deg) scale(0.9); transition: transform 120ms ease;}
.task-list__row[data-group][aria-expanded='true'] .task-list__toggle::before { transform: translate(0, -25%) rotate(45deg) scale(0.9);}
.task-list__row[data-group] .task-list__toggle:hover::before { border-color: var(--task-list-foreground);}
/* * The band: the row the pointer is on, drawn across the chart (the list draws its own). It is * placed with `getRowMetrics` and never takes the pointer. */.task-list__band { position: absolute; inset-inline: 0; top: 0; z-index: 1; height: var(--task-list-row-height, 0); background: color-mix(in srgb, var(--task-list-accent, #2563eb) 9%, transparent); transform: translateY(var(--task-list-band-y, 0)); pointer-events: none;}
.task-list__band[hidden] { display: none;}
/* On a narrow page the list is the names only. */@container task-list-demo (max-width: 30rem) { .task-list { width: 8.5rem; }
.task-list__header-dates, .task-list__dates { display: none; }}
@media (prefers-reduced-motion: reduce) { .task-list__toggle::before { transition: none; }}Files
data.ts: thirty tasks in four groups, nested two levels deep, with a few links.options.ts:layout(the sizes, andheight: 'auto'), the stripes and the view.helpers.ts: the list’s rows fromgetRows(), the dates text, and what each key does (treeAction).styles.css: the list’s own styles, in the sizes the chart reports.vanilla.ts: the markup, the list drawn fromgantt.core.getSnapshot()and kept bygantt.core.subscribe, the events and the toolbar.react.tsx: the same withuseGanttanduseGanttState.
Options
| Option | Value | What it does |
|---|---|---|
layout.height |
'auto' |
The chart is as tall as its rows and does not scroll vertically: the box around the list and the chart does, header stuck |
layout.rowHeight, barHeight, headerRowHeight |
36, 22, 28 |
The sizes the list reads back (snapshot.layout.rows); the Row height select changes them with setOptions({ layout }) |
display.rowStripes |
'group' |
Zebra stripes for each group; the list draws the same bands from Row.groupStripe |
viewMode |
'week' |
The scale (React: defaultViewMode); the View select calls setViewMode |
theme |
'auto' |
The Theme select; the list follows the data-theme of the chart’s root (see Notes) |
calendar.weekends |
[] |
Every day is a working day, so a bar dropped on a weekend is no conflict to explain |
scroll.initial |
'first-task' |
Opens on the plan |
onTaskClick |
a function (in the example) | A click on a bar selects its task: select([id]) |
onTasksChange |
a function (in the example) | One ChangeSet per drag; the status line says what moved, and the list shows the new dates |
onExpandedChange |
a function (in the example) | The open groups after expand, collapse, expandAll and collapseAll, from the list, a button or code |
What the list reads
| From the chart | Used for |
|---|---|
gantt.getRows() |
The visible rows in order: name, depth, hasChildren, isExpanded, the stripes, record |
gantt.getLayoutMetrics() |
rowHeight and headerHeight, which become --task-list-row-height and -header-height |
gantt.getRowMetrics(id) |
Where a row lies, for the band that lights the hovered row across the chart |
snapshot.selection.tasks |
The selected rows (aria-selected), wherever the selection was made |
snapshot.hoveredRow |
The task whose bar, or whose row, the pointer is on in the chart |
gantt.core.subscribe(listener) |
Called after every change of the snapshot: how the list hears of new rows, names and dates |
What to try
- Scroll. Wheel over the names or over the chart: one box scrolls, and the rows stay together. The headers stay at the top, and so does the list when the chart scrolls sideways.
- Click a name far down the list (Release): the chart scrolls sideways to its bar and selects it; the box does not move. Click a bar: its name is selected.
- Open and shut. Click the chevron of Build, or press Left with the focus on it: its nine tasks leave the list and the chart together. Collapse all and Expand all are buttons of the page, and the list follows them the same way.
- Drag a bar, say Research, to the right: its dates change, and so do the dates of what it pushed (the links).
- Change the Row height, the View or the Theme: the rows still line up.
- Use the keyboard. Tab to the list, then Up, Down, Home, End, Left and Right. Enter selects the row and scrolls the chart to its bar. A row you move to scrolls into view below the header.
Notes
- One box scrolls both, and why. With
layout.height: 'auto'the chart is as tall as its rows and the nearest ancestor that scrolls (here.task-list-scroller) scrolls it, while the header of the chart sticks to the top of that box. The list is a second column in the same box with a sticky header of its own, so wheel, touch, scrollbar and keyboard move both with no code and nothing to keep in step. The other design is a chart of a fixed height with its own scroller, and a list that is synced to it: the chart tells the list where it is (on('scroll'),y), which works, but there is no call to scroll the chart from the list, so the list would have to write thescrollTopof the chart’s.gantt__scrollerby itself, and each side would move the other from ascrollevent, which arrives after the browser has already moved the first. - The trade-offs of one box. The sideways scrollbar belongs to the chart’s scroller, which is as
tall as its rows, so it is under the last row (invisible with overlay scrollbars; sideways wheel
and touch work anywhere over the chart, and a click on a name scrolls to the bar). The chart
draws the rows in view and five more on each side after a scroll, as a chart of a fixed height
does, so a fling much faster than that can show an empty chart row for a frame. And the box is
yours: it needs a height, and nothing between it and the chart may clip (
overflow: hidden). - Why
alignY: 'nearest'.scrollToTaskscrolls on both axes, and a click on a visible name should not move the row away from the pointer.'nearest'leaves a row that is in view where it is. - When the list renders.
subscriberuns for every change of the snapshot: a scroll frame, a hover and a drag preview are each one.getRows()andgetLayoutMetrics()keep their identity until they change, so the list compares them and rebuilds only then. What is left for every snapshot is a few attributes. It draws every row: for thousands, draw only the rows ofgantt.getRowWindow()(what the chart itself draws), as the built-in list does. - Colours. The chart declares its colours on
.gantt, which a list beside it cannot inherit. This list has a small palette of its own (light-dark(), the values of the default theme) and takes itscolor-schemefrom thedata-themeof the chart’s root (:has()): the theme select, the site’s toggle andprefers-color-scheme('auto') all reach the list that way. The classgantt-scopeis the other way: it gives an element every--gantt-*token. - Hover. A chart on its own lights no row, so when the pointer is on a name the example lays a
band (
.task-list__band) over the chart’s row, atheaderHeight + getRowMetrics(id).y.setHoveredRowwithdisplay.rowHoveris the other way, which the built-in list uses. The other way round,snapshot.hoveredRownames the task of the pointer’s row even where there is no bar (onTaskHoverChangenames bars only), so the whole row of the chart lights its name. - React.
useGanttanduseGanttState, not<Gantt ref>. The list is drawn from the chart’s snapshot, so it needs the chart while it renders:useGanttreturns it at once (the core exists during the render, so the list also renders on a server), while arefisnulluntil the chart is committed, which leaves the first render of the list empty. AndsetContainerputs the chart in the box beside the list, in the column you want, without thedivthat<Gantt>adds.useGanttStaterenders the list again only when the slice it reads changes: not while the chart scrolls.viewModeis a controlled prop, so the select callssetViewModeon the instance and the chart starts fromdefaultViewMode; the row height and the theme are state, passed tolayoutandtheme, because in React the props are the truth.