Events log
A chart reports what happens to it through events: each is an on<Event> option and a listener
added with gantt.on(name, handler) (React: a prop). This example gives the chart one handler for
every event, and writes a line for each into a log, newest first, with the time and what the event
carried. The boxes above the chart show and hide the kinds of event, and the buttons make the ones
that a mouse cannot.
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. Design is locked, so dragging it is refused (`onEditRefused`); Build is// a parent with two children, so it can be collapsed (`onExpandedChange`).export const tasks: Task[] = [ { id: 'design', name: 'Design', start: '2026-03-02', end: '2026-03-06', progress: 100, locked: true, }, { id: 'build', name: 'Build', start: '2026-03-09', end: '2026-03-20', progress: 30 }, { id: 'backend', name: 'Backend', parentId: 'build', start: '2026-03-09', end: '2026-03-17', progress: 45, }, { id: 'frontend', name: 'Frontend', parentId: 'build', start: '2026-03-11', end: '2026-03-20', progress: 20, }, { id: 'docs', name: 'Docs', start: '2026-03-16', end: '2026-03-19' }, { id: 'qa', name: 'QA', start: '2026-03-23', end: '2026-03-25' }, { id: 'launch', name: 'Launch', type: 'milestone', start: '2026-03-26' },];
export const links: Link[] = [ { source: 'design', target: 'build' }, { source: 'build', target: 'qa' }, { source: 'qa', target: 'launch' },];
/** * A task whose end is before its start: the data has a problem, which `onIssues` reports * (`end-before-start`). The example adds it with `setTasks` and takes it away again. */export const broken: Task = { id: 'broken', name: 'Broken', start: '2026-03-12', end: '2026-03-05',};import type { GanttOptions } from '@ganttlane/dom';
export const options = { // `historyChange` is only emitted when the history is on (it is off by default). history: true, // The timeline reaches beyond the plan on both sides: scroll the chart sideways for `scroll`. viewMode: 'week', scroll: { initial: 'first-task' }, // Smaller rows, so that all 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 Unsubscribe } from '@ganttlane/dom';
import { broken, links, tasks } from './data';import { append, type Entry, type GroupId, GROUPS, lineText, loggingHandlers } from './helpers';import { options } from './options';
const VIEW_MODES = ['day', 'week', 'month'] as const;
/** A line of the log. */function item(entry: Entry): HTMLElement { const element = document.createElement('li'); element.dataset.group = entry.group; const time = document.createElement('span'); time.textContent = entry.time; const name = document.createElement('code'); name.textContent = entry.name; const text = document.createElement('span'); text.textContent = lineText(entry); element.append(time, name, text); return element;}
export function mount(container: HTMLElement): () => void { const controller = new AbortController(); const { signal } = controller; const root = document.createElement('div'); const actions = document.createElement('div'); actions.setAttribute('role', 'toolbar'); actions.setAttribute('aria-label', 'Make events happen'); const session = document.createElement('div'); session.setAttribute('role', 'toolbar'); session.setAttribute('aria-label', 'Edit session, data and listeners'); const filters = document.createElement('div'); filters.setAttribute('role', 'group'); filters.setAttribute('aria-label', 'Show these events'); filters.className = 'events-filters'; const chart = document.createElement('div'); chart.style.height = '330px'; const list = document.createElement('ol'); list.className = 'events-log'; list.setAttribute('role', 'log'); list.setAttribute('aria-label', 'Events'); list.tabIndex = 0; const status = document.createElement('p'); root.append(actions, session, filters, chart, list, status); container.append(root);
// ---- the log: every event of the chart ends in `log`, and the filter decides what is shown ---- let entries: Entry[] = []; const shown = new Set<GroupId>(GROUPS.filter((group) => group.hidden !== true).map((g) => g.id)); let frame: number | undefined; const render = (): void => { frame = undefined; const visible = entries.filter((entry) => shown.has(entry.group)); list.replaceChildren(...visible.map(item)); status.textContent = `Showing ${visible.length} of ${entries.length} events.`; }; // Scrolling and hovering make many events: draw the log once per frame. const schedule = (): void => { frame ??= requestAnimationFrame(render); };
const gantt: Gantt = createGantt(chart, { tasks, links, ...options, // One handler for each event of the chart: see helpers.ts. ...loggingHandlers((group, name, text) => { entries = append(entries, group, name, text); schedule(); }), });
// ---- the filter ---- for (const { id, label } of GROUPS) { const input = document.createElement('input'); input.type = 'checkbox'; input.checked = shown.has(id); input.addEventListener( 'change', () => { if (input.checked) shown.add(id); else shown.delete(id); render(); }, { signal }, ); const wrapper = document.createElement('label'); wrapper.append(input, ` ${label}`); filters.append(wrapper); }
// ---- what makes the events happen ---- const addButton = ( toolbar: HTMLElement, label: string, onClick: () => void, ): HTMLButtonElement => { const button = document.createElement('button'); button.type = 'button'; button.textContent = label; button.addEventListener('click', onClick, { signal }); toolbar.append(button); return button; };
// `viewModeChange`, `rangeChange`, and a `scroll` that comes with them. const viewMode = document.createElement('select'); viewMode.setAttribute('aria-label', 'View mode'); for (const id of VIEW_MODES) { const option = document.createElement('option'); option.textContent = id; viewMode.append(option); } viewMode.value = options.viewMode; viewMode.addEventListener( 'change', () => { gantt.setViewMode(viewMode.value); }, { signal }, ); const viewModeLabel = document.createElement('label'); viewModeLabel.append('View mode ', viewMode); actions.append(viewModeLabel);
addButton(actions, 'Select Build', () => { gantt.select(['build']); // selectionChange }); addButton(actions, 'Clear selection', () => { gantt.clearSelection(); }); addButton(actions, 'Collapse', () => { gantt.collapseAll(); // expandedChange }); addButton(actions, 'Expand', () => { gantt.expandAll(); }); addButton(actions, 'Undo', () => { gantt.undo(); // tasksChange (reason 'undo') and historyChange }); addButton(actions, 'Redo', () => { gantt.redo(); });
// An edit session: `dirtyChange` when the first change is made and when it ends, and // `editRollback` when it is rolled back. addButton(session, 'Begin edit', () => { gantt.edit.begin(); }); addButton(session, 'Commit', () => { gantt.edit.commit(); }); addButton(session, 'Rollback', () => { gantt.edit.rollback(); });
// `issues`: data with a problem in it, and the same data with the problem gone. const breakData = addButton(session, 'Break data', () => { const current = gantt.core.getSnapshot().state.tasks; const without = current.filter((task) => task.id !== broken.id); const adding = breakData.textContent === 'Break data'; gantt.setTasks(adding ? [...without, broken] : without); breakData.textContent = adding ? 'Fix data' : 'Break data'; });
// `error`: a listener that throws. `onError` catches what it threw, and the log shows it; // select something to make it throw. let failing: Unsubscribe | undefined; const failButton = addButton(session, 'Add a failing listener', () => { if (failing === undefined) { failing = gantt.on('selectionChange', () => { throw new Error('This listener always throws'); }); } else { failing(); failing = undefined; } failButton.textContent = failing === undefined ? 'Add a failing listener' : 'Remove it'; });
addButton(session, 'Clear the log', () => { entries = []; render(); });
render();
return () => { controller.abort(); if (frame !== undefined) cancelAnimationFrame(frame); gantt.destroy(); root.remove(); };}import '@ganttlane/react/styles.css';import './styles.css';
import { Gantt, type GanttHandle } from '@ganttlane/react';import { useRef, useState } from 'react';
import { broken, links, tasks } from './data';import { append, type Entry, type GroupId, GROUPS, lineText, loggingHandlers } from './helpers';import { options } from './options';
const VIEW_MODES = ['day', 'week', 'month'] as const;
export default function Example() { const chart = useRef<GanttHandle>(null); // ---- the log: every event of the chart ends in `log`, and the filter decides what is shown ---- const [entries, setEntries] = useState<readonly Entry[]>([]); const [shown, setShown] = useState<ReadonlySet<GroupId>>( new Set(GROUPS.filter((group) => group.hidden !== true).map((group) => group.id)), ); const visible = entries.filter((entry) => shown.has(entry.group)); // Whether the data has the broken task in it, and the listener that throws (to remove it again). const [isBroken, setIsBroken] = useState(false); const failing = useRef<(() => void) | undefined>(undefined); const [isFailing, setIsFailing] = useState(false); // `viewMode` is a controlled prop in React: `defaultViewMode` is where the chart starts, and // `setViewMode` of the handle changes it from there. const { viewMode, ...rest } = options;
const toggle = (id: GroupId): void => { setShown((current) => { const next = new Set(current); if (!next.delete(id)) next.add(id); return next; }); };
const breakData = (): void => { const handle = chart.current; if (!handle) return; const without = handle.core.getSnapshot().state.tasks.filter((task) => task.id !== broken.id); handle.setTasks(isBroken ? without : [...without, broken]); setIsBroken(!isBroken); };
// `error`: a listener that throws. `onError` catches what it threw, and the log shows it; // select something to make it throw. const toggleFailing = (): void => { if (failing.current === undefined) { failing.current = chart.current?.on('selectionChange', () => { throw new Error('This listener always throws'); }); } else { failing.current(); failing.current = undefined; } setIsFailing(failing.current !== undefined); };
return ( <> <div role="toolbar" aria-label="Make events happen"> <label> View mode {/* `viewModeChange`, `rangeChange`, and a `scroll` that comes with them. */} <select defaultValue={options.viewMode} onChange={(event) => chart.current?.setViewMode(event.target.value)} > {VIEW_MODES.map((id) => ( <option key={id}>{id}</option> ))} </select> </label> {/* `selectionChange` */} <button type="button" onClick={() => chart.current?.select(['build'])}> Select Build </button> <button type="button" onClick={() => chart.current?.clearSelection()}> Clear selection </button> {/* `expandedChange` */} <button type="button" onClick={() => chart.current?.collapseAll()}> Collapse </button> <button type="button" onClick={() => chart.current?.expandAll()}> Expand </button> {/* `tasksChange` (reason 'undo') and `historyChange` */} <button type="button" onClick={() => chart.current?.undo()}> Undo </button> <button type="button" onClick={() => chart.current?.redo()}> Redo </button> </div> {/* An edit session: `dirtyChange` when the first change is made and when it ends, and `editRollback` when it is rolled back. */} <div role="toolbar" aria-label="Edit session, data and listeners"> <button type="button" onClick={() => chart.current?.edit.begin()}> Begin edit </button> <button type="button" onClick={() => chart.current?.edit.commit()}> Commit </button> <button type="button" onClick={() => chart.current?.edit.rollback()}> Rollback </button> {/* `issues`: data with a problem in it, and the same data with the problem gone. */} <button type="button" onClick={breakData}> {isBroken ? 'Fix data' : 'Break data'} </button> <button type="button" onClick={toggleFailing}> {isFailing ? 'Remove it' : 'Add a failing listener'} </button> <button type="button" onClick={() => setEntries([])}> Clear the log </button> </div> <div role="group" aria-label="Show these events" className="events-filters"> {GROUPS.map(({ id, label }) => ( <label key={id}> <input type="checkbox" checked={shown.has(id)} onChange={() => toggle(id)} /> {label} </label> ))} </div> <Gantt ref={chart} style={{ height: 330 }} // `tasks` and `links` are controlled props in React: these are where the chart starts. defaultTasks={tasks} defaultLinks={links} {...rest} defaultViewMode={viewMode} // One handler for each event of the chart: see helpers.ts. They are props in React. {...loggingHandlers((group, name, text) => { setEntries((current) => append(current, group, name, text)); })} /> {/* eslint-disable-next-line jsx-a11y-x/no-noninteractive-tabindex -- a scrolling region has to take focus, or the keyboard cannot scroll it */} <ol className="events-log" role="log" aria-label="Events" tabIndex={0}> {visible.map((entry) => ( <li key={entry.id} data-group={entry.group}> <span>{entry.time}</span> <code>{entry.name}</code> <span>{lineText(entry)}</span> </li> ))} </ol> <p> Showing {visible.length} of {entries.length} events. </p> </> );}import type { GanttOptions } from '@ganttlane/dom';
/** The kinds of event the filter shows and hides. */export type GroupId = | 'pointer' | 'hover' | 'changes' | 'links' | 'view' | 'scroll' | 'selection' | 'refused' | 'issues' | 'edit';
/** The groups of the filter, with what is in each; a group that is `hidden` starts unchecked. */export const GROUPS: readonly { id: GroupId; label: string; hidden?: boolean }[] = [ { id: 'pointer', label: 'Clicks' }, { id: 'hover', label: 'Hover', hidden: true }, { id: 'changes', label: 'Changes' }, { id: 'links', label: 'Links' }, { id: 'view', label: 'View and range' }, { id: 'scroll', label: 'Scroll' }, { id: 'selection', label: 'Selection' }, { id: 'refused', label: 'Refused' }, { id: 'issues', label: 'Issues and errors' }, { id: 'edit', label: 'Dirty and history' },];
/** A line of the log. */export interface Entry { /** Grows with every entry. */ readonly id: number; /** The time it was logged, `12:03:04.123`. */ readonly time: string; readonly group: GroupId; /** The name of the event, `taskClick`. */ readonly name: string; /** What the event carried. */ readonly text: string; /** How many events of this name it stands for: a run of `scroll` events is one line. */ readonly count: number;}
/** The log: every event of the chart calls this. */export type Log = (group: GroupId, name: string, text: string) => void;
/** The most lines the log keeps; the oldest go first. */const KEEP = 200;
const pad = (value: number, length = 2): string => String(value).padStart(length, '0');
/** A time of day with milliseconds, in the visitor's time zone. */function clock(date: Date): string { const [h, m, s] = [date.getHours(), date.getMinutes(), date.getSeconds()].map((n) => pad(n)); return `${h ?? ''}:${m ?? ''}:${s ?? ''}.${pad(date.getMilliseconds(), 3)}`;}
/** * The log with one more event, newest first. A `scroll` that follows a `scroll` takes the place of * it (the line counts them), since one drag of a scrollbar is a hundred events. * * @param entries - The log so far. * @param group - The group of the event. * @param name - The name of the event. * @param text - What the event carried. * @returns A new list. */export function append( entries: readonly Entry[], group: GroupId, name: string, text: string,): Entry[] { const [last, ...older] = entries; const time = clock(new Date()); if (last?.name === 'scroll' && name === 'scroll') { return [{ ...last, time, text, count: last.count + 1 }, ...older]; } const entry: Entry = { id: (last?.id ?? 0) + 1, time, group, name, text, count: 1 }; return [entry, ...entries].slice(0, KEEP);}
/** What a line of the log says: the text of the event, and how many of them it stands for. */export const lineText = ({ text, count }: Entry): string => count > 1 ? `${text} (×${count})` : text;
/** A date as `2026-03-16`. */const day = (date: Date): string => date.toLocaleDateString('en-CA');
/** * A handler for every event of a chart, each of which says what it carried. They are the * `on<Event>` options, and `gantt.on(name, handler)` takes the same functions. * * @param log - Where the lines go. * @returns The handlers: spread them into the options of the chart. */export function loggingHandlers(log: Log) { return { // ---- pointer ---- onTaskClick: (record) => { log('pointer', 'taskClick', record.name); }, onTaskDoubleClick: (record) => { log('pointer', 'taskDoubleClick', record.name); }, onLinkClick: (link) => { log('pointer', 'linkClick', link.id); }, onTaskHoverChange: (record) => { log('hover', 'taskHoverChange', record === null ? 'the pointer left the bars' : record.name); },
// ---- changes: one `tasksChange` for the action, then one convenience event per task ---- onTasksChange: (changeSet) => { const ids = changeSet.changes.map((change) => String(change.id)).join(', '); log('changes', 'tasksChange', `${changeSet.reason}: ${ids}`); }, onTaskDatesChange: (record, dates) => { log('changes', 'taskDatesChange', `${record.name}: ${day(dates.start)} to ${day(dates.end)}`); }, onTaskProgressChange: (record, progress) => { log('changes', 'taskProgressChange', `${record.name}: ${Math.round(progress)}%`); },
// ---- links ---- onLinkCreate: (_link, id) => { log('links', 'linkCreate', id); }, onLinkDelete: (link) => { log('links', 'linkDelete', link.id); },
// ---- view, range, rows ---- onViewModeChange: (viewMode) => { log('view', 'viewModeChange', viewMode.id); }, onRangeChange: ({ start, end, reason }) => { log('view', 'rangeChange', `${reason}: ${day(start)} to ${day(end)}`); }, onExpandedChange: (expandedKeys) => { log( 'view', 'expandedChange', expandedKeys === 'all' ? 'all' : `[${expandedKeys.join(', ')}]`, ); }, onScroll: ({ x, y, date }) => { log('scroll', 'scroll', `x ${Math.round(x)}, y ${Math.round(y)}, left edge ${day(date)}`); },
// ---- selection ---- onSelectionChange: (selectedKeys, selectedLink) => { log( 'selection', 'selectionChange', `tasks [${selectedKeys.join(', ')}], link ${selectedLink ?? 'none'}`, ); },
// ---- refusals, problems, errors ---- onEditRefused: ({ gesture, id, target, reason, at }) => { const to = target === undefined ? '' : ` to ${String(target)}`; log('refused', 'editRefused', `${gesture} ${String(id)}${to}: ${reason}, at ${at}`); }, onIssues: ({ source, issues }) => { log( 'issues', 'issues', `${source}: ${issues.map((issue) => issue.code).join(', ') || 'none'}`, ); }, onError: ({ error, source, name }) => { const message = error instanceof Error ? error.message : String(error); log('issues', 'error', `a ${source} (${name}) threw: ${message}`); },
// ---- edit session and history ---- onDirtyChange: ({ isDirty }) => { log('edit', 'dirtyChange', `isDirty ${String(isDirty)}`); }, onEditRollback: ({ changes, links }) => { const text = `${changes.length} tasks, links +${links.added.length} −${links.removed.length}`; log('edit', 'editRollback', text); }, onHistoryChange: ({ canUndo, canRedo }) => { log('edit', 'historyChange', `canUndo ${String(canUndo)}, canRedo ${String(canRedo)}`); }, } satisfies Partial<GanttOptions>;}/* * Your own styles for what the example builds around the chart: the filter and the log. Colours * come from the text colour of the page, so that they fit a light and a dark page alike. */.events-filters { display: flex; flex-wrap: wrap; gap: 0.25rem 0.875rem; margin-block-end: 0.5rem; font-size: 0.8125rem;}
.events-log { box-sizing: border-box; height: 10.5rem; margin: 0.5rem 0 0; padding: 0.25rem 0; overflow-y: auto; border: 1px solid color-mix(in srgb, currentColor 25%, transparent); border-radius: 0.375rem; background: color-mix(in srgb, currentColor 6%, transparent); list-style: none; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.75rem; line-height: 1.5;}
.events-log li { display: grid; grid-template-columns: 6.5rem 10rem minmax(0, 1fr); gap: 0 0.5rem; padding: 0 0.75rem;}
.events-log li > :first-child { opacity: 0.6;}
.events-log code { padding: 0; background: none; color: inherit; font-weight: 600;}
/* A long line of text wraps inside its own column. */.events-log li > :last-child { overflow-wrap: anywhere;}Files
data.ts: seven tasks (Design is locked, Build has two children) and three links.options.ts:history, the view mode, the first scroll and the layout.helpers.ts:loggingHandlers, one handler for each of the twenty events, and the log itself.styles.css: your own styles for the filter and the log.vanilla.tsandreact.tsx: the buttons, the filter and the list.
Options
| Option | Value | What it does |
|---|---|---|
on<Event> |
twenty functions (loggingHandlers) |
One handler for each event; gantt.on(name, handler) takes the same functions |
history |
true |
Without it the history is off, and historyChange would never come |
viewMode |
'week' |
The scale of the timeline (React: defaultViewMode) |
scroll.initial |
'first-task' |
Opens on the plan; the timeline reaches beyond it on both sides, so the chart can be scrolled |
layout.rowHeight, .barHeight |
36, 24 |
Smaller rows, so that every row is in view |
Events
| Group (filter) | Event | What makes it fire |
|---|---|---|
| Clicks | taskClick |
A click on a bar (a press and release that was not a drag); a double click reports it twice |
| Clicks | taskDoubleClick |
A double click on a bar, after its second taskClick |
| Clicks | linkClick |
A click on the arrow of a link; the link is selected, so a selectionChange comes just before it |
| Hover | taskHoverChange |
The pointer comes to a bar, or leaves the bars; unchecked by default, since it is frequent |
| Changes | tasksChange |
One per committed change set: a drag, updateTask, an undo, a redo; it lists every task that moved |
| Changes | taskDatesChange |
The same change, once per task whose dates changed, after tasksChange |
| Changes | taskProgressChange |
The same, once per task whose progress changed: drag the progress handle |
| Links | linkCreate |
A link drawn with the handles, addLink, an undo of a removal or a redo of a creation |
| Links | linkDelete |
A link removed from its menu, removeLink, or an undo of a creation |
| View and range | viewModeChange |
setViewMode: the select |
| View and range | rangeChange |
The range of the timeline changed, with a reason: 'initial' at the start, 'view-mode', or a data, option or drag change |
| View and range | expandedChange |
expand, collapse, expandAll, collapseAll: the two buttons |
| Scroll | scroll |
The timeline scrolled: the wheel, the scrollbar, a drag near an edge, a view mode switch. A run is one line |
| Selection | selectionChange |
select, clearSelection, selectLink, or a click on a link; only when the selection is different |
| Refused | editRefused |
A gesture the chart refused: drag Design, which is locked (Read-only) |
| Issues and errors | issues |
A problem in the data, the options or a change set: Break data; when the list changes, also back to none |
| Issues and errors | error |
A listener or a hook threw: Failing listener, then select something. Without onError it would be rethrown |
| Dirty and history | dirtyChange |
An edit session got its first change, or ended: Begin edit, drag a bar, then Commit or Rollback |
| Dirty and history | editRollback |
edit.rollback(): what to revert, for controlled data (Edit session) |
| Dirty and history | historyChange |
canUndo or canRedo flipped: the first drag, Undo, Redo (Undo and redo) |
What to try
- Click a bar, double-click it, drag it. A drag is
tasksChangeandtaskDatesChange(read the log from the bottom up), and the first one alsohistoryChange, since it madecanUndotrue. It is never ataskClick. - Drag Design:
editRefused, and nothing else. Drag the progress handle of Backend:taskProgressChange. - Draw a link from Docs to Launch, then click its arrow:
linkClickandselectionChange. - Choose another view mode, then scroll the chart sideways:
viewModeChangeandrangeChangewith thescrollthey bring, and onescrollline with a count for the whole run of a scrollbar drag. - Begin edit, drag a bar (
dirtyChange), Rollback (editRollback,dirtyChange). - Break data adds a task whose end is before its start:
issuesnames the problem, and the history is gone (historyChange), because replacing the tasks clears it. - Untick a group to hide its lines (they are still collected: tick it again to see them), and tick
Hover for
taskHoverChange.
Notes
- A payload per event.
taskClick,taskDoubleClickandtaskHoverChangeget theTaskRecordand the DOM event;tasksChangeaChangeSet;linkCreatetheLinkand its id;linkDeleteandlinkClickaLinkRecord;selectionChange(selectedKeys, selectedLink);viewModeChangetheViewMode;rangeChange{ start, end, reason };scroll{ x, y, date }.helpers.tsturns each into a line, which is also the shortest way to see the types. - The order of a drag.
dirtyChangeandhistoryChangeare emitted as the data changes, before the events of the action that caused them, which come after in this order:issues,tasksChange,taskDatesChangeandtaskProgressChange. - One handler, or many. The
on<Event>option is one handler per event and can be swapped withsetOptionsat no cost;gantt.on(name, handler)adds listeners and returns the function that removes them, as Failing listener shows. - React. The handlers are props, spread from
loggingHandlers; they are always current, so a new function on every render is no change at all.viewModeis a controlled prop, and the example usesdefaultViewModeand the handle’ssetViewMode.