Skip to main content

Grid columns

A Grid column is one vertical slice of the Grid pane. It names a Field and carries presentation only: header, width, alignment, and the cell's renderer. The Gantt's gridColumns option lists the columns in display order.

This page shows the two ways to write a column: a plain column object, and the column helper. See docs/05-consumer-api.md for the rest of the Gantt options, and CONTEXT.md for the terms.

Every example below typechecks against the built package types on each CI run (scripts/check-doc-examples.mjs).

A column is a plain object​

A column is plain data. A bare Field key is the short form. It takes its header and width from the Field's own column defaults.

import { Gantt } from 'freegantt';

new Gantt({
container,
dataset,
gridColumns: ['name', 'start', { field: 'cost', header: 'Budget', width: 96, align: 'end' }],
});

A plain object is a complete column. You never need the helper to show a column.

A renderer reads the value before formatting​

A columnRenderer paints the column's cells. It receives two readings of one cell:

  • value is the string the grid paints, from the Field's own formatValue.
  • fieldValue is the same Field value before formatting — what entry.read(field) answers.

In a plain column object, fieldValue is unknown. A column object cannot type its renderer from its own field key, so a renderer that reads fieldValue must check or cast the value.

import { Gantt } from 'freegantt';

new Gantt({
container,
dataset,
gridColumns: [
// Reads `value` only, so no type is necessary.
{ field: 'owner', columnRenderer: ({ value }) => ({ text: value.toUpperCase() }) },
// `fieldValue` is `unknown` here, so the renderer checks it.
{
field: 'cost',
columnRenderer: ({ fieldValue }) => ({ text: typeof fieldValue === 'number' ? fieldValue.toFixed(2) : '' }),
},
],
});

The column helper types the renderer​

createGridColumnHelper(dataset) reads the props type from your Dataset. Its column(field, options) types the renderer from that key: fieldValue has the type entry.read(field) answers. This includes an inline renderer, with no annotation.

import { Gantt, createGridColumnHelper } from 'freegantt';

const columnHelper = createGridColumnHelper(dataset);

new Gantt({
container,
dataset,
gridColumns: [
'name',
// fieldValue: Instant | undefined
columnHelper.column('start', {
columnRenderer: ({ fieldValue }) => ({ text: fieldValue?.toString() ?? '' }),
}),
// fieldValue: number | undefined
columnHelper.column('cost', {
header: 'Budget',
align: 'end',
columnRenderer: ({ fieldValue }) => ({ text: fieldValue?.toFixed(2) ?? '' }),
}),
],
});

The helper is optional. It adds types only. column() returns the same plain object you can write by hand, so the two forms mix freely in one gridColumns list:

import { createGridColumnHelper } from 'freegantt';

const columnHelper = createGridColumnHelper(dataset);

const typed = columnHelper.column('cost', { header: 'Budget', width: 96 });

// OR

const plain = { field: 'cost', header: 'Budget', width: 96 };

fieldValue is undefined on a row with no Entry, such as a group row, and on an Entry with no value for the Field. So its type always includes undefined.

A named renderer​

A renderer that you write once and use in more than one place takes ColumnRendererContext<TValue>. Give it to columnHelper.column(). A plain column object does not accept it, because a plain object's renderer reads unknown.

import { createGridColumnHelper, formatDate, type ColumnRendererContext, type Instant } from 'freegantt';

const columnHelper = createGridColumnHelper(dataset);

function dateCell({ fieldValue }: ColumnRendererContext<Instant>) {
return {
text: fieldValue === undefined ? '' : formatDate(fieldValue, { timeZone: dataset.timeZone, locale: 'en-US' }),
};
}

const gridColumns = [
columnHelper.column('start', { columnRenderer: dateCell }),
columnHelper.column('end', { columnRenderer: dateCell }),
];

A renderer for any value, such as the shipped meter() and image(), fits every key.

A date column's own formatter​

value above always comes from the Field's formatValue, not the renderer. start and end are ordinary date Fields, so either takes any formatValue a consumer writes — including the two shipped ones:

  • formatDateTime — the stored moment, date and clock time. The date type's default.
  • formatInclusiveDate — the last day a span covers, date only. Reads end - 1 ms, unless the span is zero-length (end === start), which shows that moment unchanged; works with no start too. Core end sets this as its default, so a Finish column already shows a date, not a half-open boundary.

Set a formatter through fields, on the Dataset, not through columnRenderer:

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 the public dateFormatter and lastCoveredInstant:

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 formatValue set on start or end this way reaches the row's tooltip too, not only the grid cell — both read through the same Field text.

A duration column​

duration's fieldValue is a Duration | undefined, and value is already the formatted text (12 d). Change that text through fields on the Dataset, the same as for start or end — not through columnRenderer. A renderer reads fieldValue for a decision, not to recompute the text:

import { createGridColumnHelper, MS, type ColumnRendererContext, type Duration } from 'freegantt';

const columnHelper = createGridColumnHelper(dataset);

function durationCell({ value, fieldValue }: ColumnRendererContext<Duration>) {
const long = fieldValue !== undefined && fieldValue.value > 5 * MS.DAY;
return { text: value, className: long ? 'long-task' : undefined };
}

const gridColumns = [columnHelper.column('duration', { columnRenderer: durationCell })];

What the helper checks​

  • A renderer for the wrong type does not compile. A ColumnRendererContext<number> renderer on the owner key, a string, is a type error.
  • A key that is neither a core key nor a key of your props type does not compile.
  • width and flex together do not compile, the same as in a plain object.

When the helper gives no benefit​

The helper types a key only when it knows the key's type. Use a plain column object for a key it does not know:

  • A plugin's own Field, such as scheduling:progress. Your props type does not name it.
  • A computed Field that your props type does not name. A compute Field adds a key, but not a type.
  • An untyped Dataset. With no props type, only the core keys have a type.
  • A renderer that reads only value. It gets nothing from the type.
import { meter } from 'freegantt';

const gridColumns = [{ field: 'scheduling:progress', header: 'Done', columnRenderer: meter() }];

A Field's text off the Grid​

gantt.formatFieldValue(entry, key) gives a Field's text with no column at all — a hidden column, a Field that never had one, or a Field you read for a status line or a CSV export. It reads through the same door a Grid cell does, so the two texts never disagree.

Reading columns back​

gantt.gridColumns and the gridColumnsChange event give back plain column objects. A renderer reads fieldValue as unknown there. So you can read the list, change it, and write it back:

gantt.gridColumns = [...gantt.gridColumns, 'cost'];

Why a helper, and not a typed column object​

A typed column object needs one type per key, in one union: a start column, a cost column, and so on. That union also needs a case for a key it does not know, such as a plugin's own key. A known key such as start then matches two cases. TypeScript does not type an inline renderer when two cases match, so the renderer's parameter becomes an implicit any.

A function call does not have this problem. The key is an argument, so TypeScript knows it before it reads the renderer. Other libraries use the same approach:

  • TanStack Table: createColumnHelper() and columnHelper.accessor(key, …). Its column definitions are plain objects too, and each helper call has a plain-object equivalent.
  • Vite: defineConfig({ … }). A plain exported object is also a valid config. The helper only adds types.