FreeGantt — Consumer API (index)
This file points app authors at the consumer surface. It does not replace the spec.
Start here
| Document | What it is |
|---|---|
README.md | Quick start, dates/ids, and the API as it ships on the current branch |
CONTEXT.md | Glossary — one word per concept (Entry, Field, Row, Row source, Rollup, …) |
etc/freegantt.api.md | Generated TypeScript export list (api-extractor); the same public surface is also browsable as generated API docs on the Docusaurus site (pnpm docs) |
docs/09-integration-pitfalls.md | Traps real integrators hit — theme and an application's dark class, the zoom notification, overscan, the row click |
docs/06-plugin-authoring.md | Plugin authoring guide — definePlugin, the two halves, every registration seam |
docs/07-row-source-updates.md | Change one row-source setting and keep the rest — toolbar controls that do not fight each other |
docs/12-grid-columns.md | Grid columns — a plain column object, and createGridColumnHelper to type a column renderer's fieldValue |
docs/11-server-data.md | Polling a server with entries.syncAll() and entries.syncChanges() — the conflict rule, what undo/redo do across a sync, and what changes |
What a Gantt shows
A Gantt is two panes on one set of rows. The Grid pane is on the left. The Timeline pane is on the right.
A Grid column is one vertical slice of the Grid pane. It names a Field and carries presentation only — header, width, order. Declaring a Field does not put it on the Grid pane. A Grid column on the Gantt does that.
The Timeline pane paints Bars on a time scale. A bar is paint, not identity (CONTEXT.md). Item is a retired word for Bar. The time axis is not a column. The whole view is a Gantt. Chart is a retired word.
Undo, redo, and the change event
Undo and redo are ordinary commits. dataset.undo() and dataset.redo() return nothing; what
they did arrives on dataset.on('change') — the same channel a user edit uses — tagged
origin: 'undo' or 'redo'.
import { Dataset, fieldRowsOf } from 'freegantt';
const dataset = new Dataset<{ cost: number }>({
timeZone: 'Europe/Warsaw',
entries: [ /* … */ ],
fields: [{ key: 'cost', type: 'number' }], // `cost` is written below, so it is declared here
});
dataset.on('beforeChange', ({ changeSet }) => {
if (changeSet.origin === 'undo' && !confirm('Undo this step?')) return false;
// a refused undo throws MutationCancelledError and leaves history where it was
});
dataset.on('change', ({ changeSet }) => {
if (changeSet.origin === 'user') return; // a user edit, not an undo/redo
const verb = changeSet.origin === 'undo' ? 'undid' : 'redid';
for (const row of fieldRowsOf(changeSet)) {
// row: { store: 'entries', id, field, from, to }
// on undo, `from` is the value the undo replaced, `to` is the value it wrote back
console.log(`${verb} ${String(row.id)} ${row.field}: ${row.from} -> ${row.to}`);
}
// changeSet.added / changeSet.removed carry the entries an undo restored or a redo removed
});
dataset.entries.update('t1', { cost: 200 }); // origin 'user'
dataset.undo(); // origin 'undo', cost 200 -> 500
dataset.redo(); // origin 'redo', cost 500 -> 200
historyChange fires whenever canUndo or canRedo changes, so one handler can drive a
toolbar. The payload carries both answers:
dataset.on('historyChange', ({ canUndo, canRedo }) => {
undoButton.disabled = !canUndo;
redoButton.disabled = !canRedo;
});
Use historyChange, not change, for these buttons. After a sync, one undo() or redo() click
can find that every remaining step has nothing left to write. It then forgets those steps, writes
nothing, and fires no change. historyChange still fires, because canUndo changed.
historyChange fires only when one of the two answers changes. A second edit in a row fires
nothing, because canUndo was already true. A handler may not write, the same as a change
handler. Under history: false it never fires.
updated also carries plugin-store rows (store: 'plugin:…', whole-value, no field key).
fieldRowsOf(changeSet) filters to Field rows.
Undo after a sync
entries.syncAll() (a poll against a server list) and entries.syncChanges() (a poll against a
server delta) record no undo step of their own — the user's own earlier edits stay undoable across
a poll. An undo never writes over a value a sync brought in: when a sync changed a Field an undo
step wrote, that entry keeps the sync's values. See docs/11-server-data.md
for the full set of rules a poll needs.
Advanced: your own History with dataset.replay()
Most apps call undo() and redo() and never touch this. Reach for dataset.replay() only when
the app keeps its own undo stack outside the library — one stack per user, a stack a server keeps,
or an undo rule the library does not ship.
Construct the Dataset with history: false first. The library then keeps no stack of its own:
canUndo and canRedo always read false, undo() and redo() do nothing, and the Gantt's own
Undo and Redo commands turn off. Mod+Z is then free for the app's own undo.
replay(changeSet) is the write undo() and redo() use. It writes the ChangeSet you give it.
It keeps no stack of its own and moves no cursor.
changeSet.origin must be 'undo' or 'redo'. 'user' throws InvalidReplayOriginError.
replay() writes onto the store's current values, not the recorded ones. A row a sync has
already settled since the step was recorded writes nothing; the rest of the changeset still lands.
A row for a key no Field declares writes nothing. An entry a foreign write changed keeps its current
values. Pass
{ overwriteForeignWrites: true } to write the step over them instead. See
docs/11-server-data.md for the full set of rules a sync needs.
replay() renumbers every sibling group it touches, and re-rolls every parent it touches, the same
way an ordinary commit does. No extension hook runs.
A step with nothing left to write fires no event. Otherwise beforeChange fires, then change. A
veto throws MutationCancelledError and writes nothing.
import { invertChangeSet, type ChangeSet } from 'freegantt';
// A minimal History on a Dataset built with `history: false`: record every 'user' step, and undo
// the last one on a click.
const undoStack: ChangeSet[] = [];
dataset.on('change', ({ changeSet }) => {
if (changeSet.origin === 'user') undoStack.push(changeSet);
});
undoButton.addEventListener('click', () => {
const step = undoStack.pop();
if (step !== undefined) dataset.replay(invertChangeSet(step));
});
Hierarchy and rows
Sibling order
siblingIndex is a core Field: an entry's rank among the entries that share its group (its parent,
or its group under a plugin hierarchySource). entries.add() and entries.update() write it —
name a target index, or name none and the entry goes to the end of its group. Every sibling the move
passes renumbers around it in the same commit.
import { Dataset } from 'freegantt';
const dataset = new Dataset({
timeZone: 'Europe/Warsaw',
entries: [
{ id: 'foundation', parentId: 'house' },
{ id: 'framing', parentId: 'house' },
{ id: 'roofing', parentId: 'house' },
{ id: 'house' },
],
});
// moves "roofing" to the front of its group — "foundation" and "framing" shift down by one
dataset.entries.update('roofing', { siblingIndex: 0 });
Drag a row to another place
A user drags a bar vertically, or drags a grid row. Both read the same drop: before a row, into
it, or after it, and both write one undo step. Turn the gesture off with one switch, in either pane:
// at construction: new Gantt({ container, dataset, capabilities: { reorder: false } })
gantt.setCapabilityRule('reorder', false);
A handler reads move.place to see where the drop landed, and move.shiftsTime to tell a tree-only
move from a time move — move.start/move.end are present only when move.shiftsTime is true:
gantt.on('beforeEntryMove', (move) => {
if (move.place === undefined) return; // a time-only move
if (move.place.parentId !== move.currentPlace.parentId) {
return move.refuse('A day stays with its crew this week');
}
});
Lock an Entry
locked is a core Field: the app sets it. The user cannot edit, move, or delete that one row, in
either pane. The lock protects only its own row: its cells, its bar, and its own delete. The children of
a locked parent stay free. They move, resize, reorder, leave, and join.
dataset.entries.update('t2', { locked: true }); // lock it
dataset.entries.update('t2', { locked: undefined }); // unlock it
dataset.entries.get('t2')?.read('locked'); // read it back
The lock is a UI refusal, not a data one: entries.update(), add(), an EditExtender cascade,
load, sync, undo and redo all still write a locked Entry's cells. Only a grid edit, a bar drag,
a grid row drag, and a Delete refuse.
A locked parent's summary bar does not move. The Rollup still writes the parent's dates when a child moves.
Delete an Entry and clear its dates
Delete removes the Entry. It does the same on a row, a grid cell, and a bar. A bar draws one Entry,
so Delete on it never clears dates. The bar of a parent removes the parent and every Entry below it.
The remove rule can refuse. A refused Delete writes nothing and raises one info report with code
entry-remove-refused. One Delete is one undo step.
freegantt.clearDates ("Clear dates") clears start and end and keeps the Entry. It has no key.
The context menu lists it, and gantt.commands.run('freegantt.clearDates') runs it on the Selection.
It passes over an Entry with no dates of its own to clear. A lock or a beforeChange refusal stops
the whole command, and the Gantt raises one info report with code entry-clear-dates-refused.
Move the Selection in the tree
Four commands move Entries in the tree: freegantt.moveEntryUp, freegantt.moveEntryDown,
freegantt.indentEntry and freegantt.outdentEntry. The chords are Alt+ArrowUp, Alt+ArrowDown,
Alt+Shift+ArrowRight and Alt+Shift+ArrowLeft. The context menu lists them too.
The commands act on every selected Entry. Each Entry takes the step it would take if the user moved
it alone. It asks its own reorder capability, lock rule and place rule. A refused Entry stays, and
the others still move. Each Entry that moves fires its own beforeEntryMove and entryMove. A
beforeEntryMove handler may answer with a Promise. The Gantt waits for each answer in turn, then
writes every accepted move at once. If the data changes during a wait, the Gantt drops the whole
step and raises entry-move-dropped. One key press is one undo step.
The Entries move one after the other. up and indent go from the top row to the bottom row.
down and outdent go from the bottom row to the top row. Two siblings that indent together stay
siblings, and two that outdent together keep their order. Two results follow from this rule:
- At the edge of the tree, a selected Entry that cannot move lets a lower selected Entry pass it.
- A selected parent and its selected child both move. The child also moves inside the parent.
All refused Entries make one info report with code entry-step-refused. Its entryId names the
first one, and its message names them all.
To give the user a lock toggle, list 'locked' in gridColumns. The Field brings a default column:
header "Locked", centered, 80 px wide. The default grid does not show it. The cell is a checkbox. A
locked row leaves its own locked cell open, so the user can unlock it from the grid.
import { Gantt } from 'freegantt';
new Gantt({ container, dataset, gridColumns: ['name', 'locked'] });
To change the header or the width, give the column object form: { field: 'locked', header: 'Lock' }.
To show the column read-only, close it with capabilities.edit. The app still writes the Field.
import { Gantt } from 'freegantt';
new Gantt({
container,
dataset,
gridColumns: ['name', 'locked'],
capabilities: { edit: (entry, field) => (field === 'locked' ? false : undefined) },
});
A plugin can change the core lock. It wraps the lock rule, the remove rule, or the bar move rule.
It can narrow or widen each one. A plugin that calls next() leaves the core lock in force. See
"Other locks are plugins" in the plugin guide.
Dataset
fields,fieldTypes,aggregators— declare consumer Fields beside core's.{ key: 'due', type: 'date' }names a shipped type with no localfieldTypesentry. Core Fields name those types (nameistext,start/endaredate,durationisduration).currency({ code: 'EUR' })is a factory, not a seeded name:{ key: 'cost', type: currency({ code: 'EUR' }), rollUp: 'sum' }. A core Field's key cannot be redeclared (IllegalCoreFieldOverrideError) — but every core Field takes a consumer override oneditableandformatValue, and onrollUptoo where the core Field declares one of its own (start,end). Type namedateis replaceable at construction viafieldTypes— that door is construction-only.- A parent rolls up because it has children. An Entry carries no stored classification, so nothing opts a row in or out by kind.
entries.get(id)?.read(key),dataset.field(key),dataset.fields.all—readis the one value doorlocale— this Dataset's own locale (#583), fixed at construction, the same astimeZone.dataset.localereads it back. Omit it to let each caller name its own, or fall back further to the runtime's own.dataset.formatFieldValue(entry, key, locale?)— a Field's shown text, with no Gantt. See "A Field's text outside the grid" below.plugins— a plugin with adatahalf installs here
The duration Field
duration is a core computed Field. Its value is a Duration, { value, unit: 'millisecond' } —
the row's own end - start. It is undefined until the row has both dates.
A parent's start and end roll up (min, max), so a parent's duration spans its children, and
the gaps between them count.
Read it like any Field: entry.read('duration') on a row, ctx.read('duration') in a computed
Field, ctx.values('duration') in an Aggregator.
Its text comes from the duration type: whole calendar days in the Dataset's zone (12 d), else
one decimal. Override formatValue as on start or end. It takes no editable and no rollUp,
because a computed Field has no stored home.
Core declares it with the shape you use for your own computed Field:
import { diffMs, spansTime, type Duration, type Field } from 'freegantt';
// What core declares. Your own computed Field takes the same shape under your own key.
const duration: Field = {
key: 'duration',
type: 'duration',
compute: (entry): Duration | undefined =>
spansTime(entry) ? { value: diffMs(entry.end, entry.start), unit: 'millisecond' } : undefined,
column: { header: 'Duration', align: 'end', width: 100 },
};
Set a formatter through fields, on the Dataset, the same way you would on start or end:
import { Dataset, MS, type Duration } from 'freegantt';
new Dataset({
entries: [ /* … */ ],
fields: [
// Duration shows hours, not days.
{ key: 'duration', formatValue: (value: Duration | undefined) => (value === undefined ? '' : `${value.value / MS.HOUR} h`) },
],
});
A Field that sums its children
compute runs on every row, a parent included. ctx.hasChildren and ctx.leaves read the tree. A
compute Field can use them to sum its children's own values:
import { diffMs, spansTime, type Duration, type Field } from 'freegantt';
const work: Field<Duration> = {
key: 'work',
type: 'duration',
compute: (entry, ctx): Duration | undefined => {
const rows = ctx.hasChildren(entry) ? ctx.leaves(entry) : [entry];
const spans = rows.filter(spansTime);
return spans.length === 0
? undefined
: { value: spans.reduce((ms, row) => ms + diffMs(row.end, row.start), 0), unit: 'millisecond' };
},
};
A leaf answers its own span. A parent answers the sum of its leaves' spans, so the gaps between them
do not count. duration answers a different question: its parent value spans the whole envelope of
its children, gaps included. The value is never stored. There is no ChangeSet row for it, and
editable is refused — the same rule every computed Field follows.
A stored Field takes a shorter route to the same shape. sum, min and max also fold Duration
values, so a stored Field takes rollUp: 'sum' | 'min' | 'max'. { key: 'effort', type: 'duration', rollUp: 'sum' } fills a parent's cell from its children's stored effort, with no compute to
write.
Gantt
-
gridColumns— which Fields this view shows, in order.columnRendererstays on the column.createGridColumnHelper(dataset)types a renderer'sfieldValuefrom the column's key — seedocs/12-grid-columns.md.meter()andimage()are the shipped column renderers; default alt is the Field's formatted value, and{ alt: 'Logo' }is a static override for a column that is one picture. Store a URL; letformatValuereturn the caption so alt (and tooltip) speak the name, not the URL. -
A toggle column switches a
booleanField with one click, or withSpaceorEnteron the focused cell. A per-columnheaderRendererpaints that column's header:new Gantt({container,dataset, // declares { key: 'done', type: 'boolean', editable: true }gridColumns: ['name',{field: 'done',header: 'Done', // the accessible name, even with a headerRendererheaderRenderer: () => ({ text: '✔' }), // or an icon: { html: '<svg …>' }toggle: { on: { text: '☑' }, off: { text: '☐' } }, // omit for a checkbox look},],});The default write is one undo step and meets the Field's
editable,capabilities.editandbeforeChange. A closed toggle draws its value, does nothing, and carriesaria-readonly="true". A rule that changes with no write callsrulesChanged(), and the attribute follows.toggle: { onToggle: ({ entry, field, nextValue, announceEdit }) => … }replaces the write, for example to ask first, then calldataset.entries.update. The default write announcesentryEdititself. A customonTogglemust callannounceEdit()after its write, also after an async one, orentryEditlisteners never see the switch.beforeEntryEditstill fires before the callback. The library writes the ARIA:role="checkbox",aria-checked, and the columnheaderas the name. A header renderer may return only an icon or an SVG: the library adds theheaderstring as visually hidden text beside the output. -
Grid columns are fixed-width. A column takes its own
width, else its Field'scolumn.width, else--fg-column-width(120). When the set outgrows the grid pane, the pane scrolls horizontally to reach it. Give a columnflexinstead to have it share the pane's leftover room. -
The grid pane never sits wider than its columns — a splitter drag stops at the last column's edge, and a
gridWidthpast it is capped to it. Narrower is always fine: the columns overflow and the pane scrolls. Aflexcolumn lifts the cap, since it has no fixed edge. -
gridWidth: 'fitColumns'— size the grid pane to its columns and keep it there, instead of hand-computing the number. Live, and re-measured whenever the columns change. Reads back in px. A Splitter drag ends it; aflexcolumn leaves nothing to fit, so the pane keeps the width it has. -
gridResizable: false— lock the grid pane: the splitter no longer drags and shows no resize cursor, and no column paints a resizer grip, whatever its ownresizablesays. Defaulttrue. Live. Locks the gesture, not the value —gantt.gridWidth = 240andgantt.gridColumns = […]still write. NeitherbeforeGridWidthChangenorbeforeGridColumnsChangefires for a gesture that can no longer arm — this is what keeps agridWidth: 'fitColumns'pane from turning into a fixed px width on a stray drag. -
fit— how dense the time axis is.'pane'(default) fills the measured pane with the whole range;'preset'uses the showing preset's own density and ignores the pane;{ unit: 'day', widthPx: 14 }paints one day 14px wide; a barenumberis pixels per millisecond, whatzoomTo/zoomBywrite. Live, on aGanttand on aTimeScaleModelalike. -
Reach for the
{ unit, widthPx }form whenever the sentence you have is "a day tile is 14 pixels". Writing that as a number means14 / 86_400_000, which claims every day is 24 hours — false in any zone that observes DST, and false for a month or a year in every zone. The library resolves the real length through the dataset's zone, so you never own that arithmetic.incrementdefaults to 1:{ unit: 'week', increment: 2, widthPx: 90 }reads "a fortnight is 90 pixels". One density spans the whole scale, so the stated width lands on the unit at the range start and each later unit follows its own calendar length — a 23-hour day paints narrower than the days beside it. The preset'sminTickWidthPxfloor still applies, so a page that wants tiles below the shipped floor brings its own preset (harness/e2e/bar-label-fit.ts). -
zoomPresets— the stepszoomInandzoomOutmove through, finest first. Live. The default has ten steps, fromhourtoyear, withquarterAndYearbetweenmonthAndYearandyear. State your own steps by id when you create the Gantt:import { Gantt } from 'freegantt';const gantt = new Gantt({container: '#gantt',dataset,zoomPresets: ['day', 'weekAndMonth', 'monthAndYear', 'year'],});To remove one step later, filter the live list and assign it back:
gantt.zoomPresets = gantt.zoomPresets.filter((preset) => preset.id !== 'quarterAndYear');A preset outside the list still works through
gantt.preset = 'quarterAndYear'. Zoom in and zoom out do not step into it. -
overscan— the culling buffer around the visible window:verticalRowswhole rows above and below,horizontalPxpx left and right. Live. Default{ verticalRows: 2, horizontalPx: 128 }. -
rowSource— what rows are (entriestree,groupby value, orcustomresolve). Reads back resolved, and one setting changes by spreading that value — see Row source updates. -
rowSource.filterandgroupBytake theEntryalone, and read a value off it:(entry) => entry.read('team') === 'Blue'.sort.compare(a, b, fields)compares two values ofsort.field, not two Entries, and its third argument is aFieldContext— the datasettimeZone, for a comparer that needs the zone to read a date. -
collapsed,collapse(),expand(),toggleCollapse(),collapseAll(),expandAll(),collapseStateOf(id)— per-Gantt view state;collapseStateOfreads back'collapsed' | 'expanded' | 'leaf' | undefined -
Events:
beforeCollapseChange/collapseChange -
scroll— pass the sameScrollAxisinstances ({ x?, y? }) into a newGanttafterdestroy()so pane scroll survives remount (for example after the app rebuilds the Dataset). Do not copyscrollTopoff the pane. -
variants— the rules this Gantt paints rows with;bar(),summary(),diamond()are core's own shipped looks. -
gantt.variantFor(entry): ResolvedVariant— the whole variant this Gantt resolved for one row, neverentry.variant: an Entry belongs to aDataset, a variant resolves per Gantt, and two Gantts on one Dataset may answer differently for the same row.
Formatters
A Formatter is (value, ctx: FormatContext) => string — a Field's formatValue takes the same
shape plus the row it came from: (value, ctx, entry) => string. ctx carries the zone and the
locale; a Formatter never reads either off the Dataset or the Gantt directly. A missing value
gives '', never 'undefined' and never a throw. A Formatter that needs options — dateFormatter
below, currency({ code }) — is built by a factory, so the call site never carries options: build
it once, then hand the returned function to formatValue.
Dates: formatDateTime, formatInclusiveDate, lastCoveredInstant
Storage is half-open: end is the boundary after the span, not its last moment. A date-only
end you write — '2026-09-08' — always means "through that day": it stores the start of the
9th. Pass a timed string, such as '2026-09-09T00:00:00', for an exclusive end instead.
Two Field formatters ship for display, both plain formatValue functions with the signature
(value, ctx, entry) => string:
formatDateTime— a Field'sformatValue: the stored moment, date and clock time. This is thedatetype's default formatter.formatInclusiveDate— a Field'sformatValue: the last day a span covers, date only. It readsentry.startand the value it is given forend, so it works onstarttoo. Coreendsets this as its default formatter.
formatDate(value, ctx) is a Formatter itself, date only — ctx is a FormatContext, the
timeZone/locale pair the Gantt builds. formatDateTime is built the same way, from
dateFormatter with date-and-time Intl.DateTimeFormatOptions. To show other fields, build a Formatter once with
dateFormatter(options), any Intl.DateTimeFormatOptions — it is the general-purpose helper
formatDate and formatDateTime both build on. Build it once, outside a formatValue: the
returned function is a stable reference, so the shared Intl.DateTimeFormat cache reuses it.
lastCoveredInstant({ start?, end }) — the instant formatInclusiveDate builds on: end
stepped back one millisecond, unless the span is zero-length (end === start), which answers its
own end unchanged. Reach for it directly when you format an end yourself, outside a Field.
A zero-length span (a milestone, end === start) shows its own moment unchanged — end names no
day to step back from. formatInclusiveDate and lastCoveredInstant both take a missing start;
they still step end back one millisecond and show the day that lands on.
Set either formatter on start or end like any other Field override:
import { Dataset, formatDateTime } from 'freegantt';
new Dataset({
timeZone: 'Europe/Warsaw',
entries: [ /* … */ ],
fields: [{ key: 'end', formatValue: formatDateTime }], // End shows the stored moment with clock time
});
A custom format builds on dateFormatter and lastCoveredInstant — the harness planner page's
compact "02 Mar" Finish column, with no year and no clock time:
import { dateFormatter, lastCoveredInstant, type FormatContext, type Instant } from 'freegantt';
const compactDay = dateFormatter({ day: '2-digit', month: 'short' });
function compactFinish(
value: unknown,
ctx: FormatContext,
entry: { readonly start?: Instant | undefined },
): string {
if (value === undefined || value === null) return '';
return compactDay(lastCoveredInstant({ start: entry.start, end: value as Instant }), ctx);
}
A pair of dates: formatStartAndEnd, joinStartAndEnd
A start and an end read as one line through two public functions, never a hand-built ' – ':
formatStartAndEnd(pair, ctx)— a Formatter for a{ start?, end? }value: the start's own day and the last day the pair covers, both date only. Takes anEntry, aBar,gantt.visibleSpan, or any object shaped that way.joinStartAndEnd(startText, endText, separator?)— joins two texts a caller already read through each side's own FieldformatValue. Use this when start and end may carry different formatters (a tooltip, a status line); useformatStartAndEndwhen a plain date pair is enough.
Both agree on the same cases:
| Start | End | Result |
|---|---|---|
| both | both | 'Mar 2, 2026 – Mar 4, 2026' |
| set | missing | 'Mar 2, 2026 –' |
| missing | set | '– Mar 4, 2026' |
| missing | missing | '' |
| equal text | equal text | shown once: 'Mar 2, 2026' |
A row's tooltip reads each side through its own Field, so setting formatValue: formatDate on
start matches its date-only end — a one-day bar's tooltip then names its date once instead of
reading 'Mar 2, 2026, 12:00 AM – Mar 2, 2026':
import { Dataset, formatDate } from 'freegantt';
new Dataset({
entries: [ /* … */ ],
fields: [{ key: 'start', formatValue: formatDate }], // date only, matching end's own default
});
A Field's text outside the grid: gantt.formatFieldValue
gantt.formatFieldValue(entry, key) gives the text a Field shows for one Entry, through the
same door the Grid cell and the bar label read through — a status line, a CSV row, any place a
Field's own formatted text is useful outside the Grid pane. It follows this Gantt's own locale
first, then its Dataset's own locale (#583), then the runtime's own, live. It works for a Field
with no column and for a compute Field, and throws UnknownFieldError for a key no Field
declares.
const statusLine = `${entry.name} · ${gantt.formatFieldValue(entry, 'progress')}`;
const csvRow = gantt.gridColumns
.map((column) => gantt.formatFieldValue(entry, typeof column === 'string' ? column : column.field))
.join(',');
dataset.formatFieldValue(entry, key, locale?) gives the same text with no Gantt at all, for a
server-side export or a report. The zone is this Dataset's own; a missing locale reads as this
Dataset's own locale (#583), then the runtime's own. gantt.formatFieldValue reads through this
method with its own effective locale — its own locale first, then its Dataset's.
const csvRows = dataset.entries.all.map((entry) =>
['name', 'start', 'end', 'cost'].map((key) => dataset.formatFieldValue(entry, key, 'de-DE')).join(';'),
);
Naming
Use gantt.rowSource, not gantt.rows. The config names the source; Row is the derived track
(CONTEXT.md). rowSource matches the RowSource type and leaves rows free for a future getter
of resolved rows.
Published types
Field, FieldType, FieldTypeName, FieldKey, FieldContext, Aggregator, GridColumn, GridColumnInput,
RowSource, EntriesRowSource, GroupRowSource, CustomRowSource, CustomRow,
RowSourceCommon, CustomRowInput,
CollapseChange, and the hierarchy error classes re-exported from freegantt.
FieldSource retired (a Field key is the whole address) and SerializedField retired (the
library holds no save format) — neither is in etc/freegantt.api.md.
Plugins
FreeGantt takes one plugin type with two halves. definePlugin
writes it: a data half installs on a Dataset, a view-only half installs on
a Gantt. Install at construction (plugins: [...]), or reconfigure a Gantt's
plugins live (gantt.plugins = [...]). The plugin authoring guide covers both
halves, every registration seam, the registration gate, disposal, requires, and
the errors an author meets. tooltips(), contextMenu(), and
inlineEditing() are the three built-in plugins that ship with the package,
none of them loaded unless a consumer installs them.
Styling and theming
Restyling a Gantt — the --fg-* token reference, the Parts list, data-flag, and how to style
date lines and the Today line — moved to its own page:
Styling and theming.
Harness demos
Run pnpm dev and open http://localhost:5173.
| Page | Demonstrates |
|---|---|
harness/generic.html | Tree rowSource, gridColumns, field rollup (cost), live row-source switch, selection, timeline toolbar |
harness/e2e/data.html | Transactions, undo/redo, change events |
The Architecture pages map what the code does now — the files, the classes,
the call order. Run pnpm docs to read them, and everything else, as the site. The API reference is
generated from TSDoc comments via TypeDoc, so it never drifts from the source.