Search documentation

Search all pages, headings and text, in the current package or topic or in all documentation.

Columns API

The column definition, column types and every column meta field.

@workspace/active-grid 11.0.0Status unknowncontent differs from the recorded release 11.0.0baseline ce081dc3

The package's own reference

The text below is the package's own API reference, included unchanged. The complete list of exports, generated from the source, is on All exports.

GridColumnDef, GridColumnMeta, GridColumnTypes, GridColumnDefaults, createColumnHelper, ColumnHelper, and the GridDefaults boundary. For tutorial-style how-to see ../guides/columns.md.

createColumnHelper#

typescript
import {  createColumnHelper,  type ColumnHelper,  type CellContext,  type GridColumnDef,} from "@workspace/active-grid";
const ch = createColumnHelper<MyRowType>();
const columns: GridColumnDef<MyRowType>[] = [  ch.accessor("id", { header: "ID", type: "text" }),  ch.accessor((row) => row.user?.name ?? "", {    id: "userName",    header: "User",    cell: (info: CellContext<MyRowType, string>) => <strong>{info.getValue()}</strong>,  }),];

Type-safe column builder, and the only construction path that infers a column's value type. accessor(key, def) resolves the value from the field the key names, so info.getValue(), meta.valueFormatter and every other value callback are typed without an annotation and without (info: any).

The key must name a field of the row, or a dotted path to one. A key naming no field is a compile error, in this path and in the object-literal path alike.

Returns a ColumnHelper<TData>. Its three builders produce the same column kinds an object literal produces, over the same row type: accessor() a value-reading column, display() a column with no value, group() a header group whose children are column definitions at every depth.

Canonical usage rules live here and in ../guides/columns.md. The helper implements the neutral public column contract; it does not expose an engine feature registry.

Where inference works, and where an annotation is still needed#

PatternInference
ch.accessor('name', {…})✓ the key gives the value type
ch.accessor('nested.field', {…})✓ a dotted path runs through the row
ch.accessor('name', {cell: (info) => …})✓ info.getValue() is the column's value type
ch.accessor((row) => …, {id: 'x'})✓ the value type is what the function returns
{accessorKey: 'name', …} in an array annotation✗ the value type is the default; annotate when in doubt

The last row is the only one that remains, and the reason is under The value type: one array annotation covers columns with different value types, so the default of TValue is broad. For one strict column, name the type: GridColumnDef<Row, number>. For a whole strict grid, use the column helper.

GridColumnDef#

typescript
import {type GridColumnDef} from '@workspace/active-grid';

A column definition. It is a union of three kinds, and that is what makes an invalid definition invalid:

KindRecognised byHas a cell value
accessor-key columnaccessorKey, naming a row fieldyes
accessor-functionaccessorFn, plus an id/headeryes
group or displayid or a string headerno

A group column is the third kind with columns; a display column is the third kind without it. They are one kind on purpose: with columns optional, two separate kinds would let a definition whose children are invalid pass as a display column carrying a spare property.

Reading a definition back#

Because it is a union, accessorKey is not reachable on GridColumnDef<Row>: two of the three kinds do not have it. A reader narrows first, and the package publishes the two predicates for it:

typescript
import {hasAccessorKey, hasAccessorFn} from '@workspace/active-grid';
const status = columns.find(  (c) => hasAccessorKey(c) && c.accessorKey === 'status',);

A local column as {accessorKey?: string} is not the convention. It is an assertion in a position where a test is available, and it keeps compiling after the field it names has moved.

The value type#

GridColumnDef<Row> leaves TValue at its default, and the default is deliberately broad. One array annotation holds columns whose values are a string, a number and nothing at all, and every value position in a definition (cell, header, footer, meta.valueFormatter, …) is a callback PARAMETER, which strictFunctionTypes checks contravariantly. A narrow default would therefore refuse every column that knows what it holds — including everything createColumnHelper builds.

What the broad default does not relax, and each of these is a compile error:

  • an unknown meta key, and a meta value of the wrong type;
  • an accessorKey naming no field of the row;
  • a nested entry of columns that is not a column definition;
  • a primitive where a definition belongs.

A nested columns value that is not a list, or a nested entry that is not an object, is also refused at runtime with an error naming its position, for a caller the compiler does not see.

Name the type for a strict single column: GridColumnDef<Row, string> refuses a renderer shaped for a number. Use createColumnHelper for a strict whole grid — it infers each column's value type, so nothing has to be named.

PropertyTypeDescription
accessorKeyDeepKeys<TData>The row field this column reads, or a dotted path to one. A name the row does not carry is a compile error.
accessorFn(row: TData) => TValueCustom accessor function.
idstringColumn identifier. Auto-derived from accessorKey or a string header if not set; a column with no derivable identifier is refused with an error.
headerstring | ((context) => ReactNode)Header content — string or render function.
cell(context) => ReactNodeCustom cell renderer.
footerstring | ((context) => ReactNode)Footer content.
columnsArray<GridColumnDef<TData>>Nested columns for a column group. Recursively this same definition, so a group child carries type, server and meta exactly as its parent does, at any depth. Until TD-31B closed it was the engine's own shape and a child carried none of them.
sizenumberInitial column width in pixels.
minSizenumberMinimum width (default: 100). A column that renders a filter control automatically gets at least 80px, so the clear and filter-menu buttons stay inside the cell -- see filters.md § The layout contract of the filter row. That floor only raises; a larger value here still applies.
maxSizenumberMaximum width (default: 800).
enableSortingbooleanWhether this column can be sorted (default: true).
enableResizingbooleanWhether this column can be resized (default: true).
enableHidingbooleanWhether this column can be hidden (default: true).
sortFnfunction or nameA comparator (rowA, rowB, columnId) => number over rows that may be generated (narrow with getIsGrouped() before reading original), or a built-in name: basic, datetime, text, textCaseSensitive, alphanumeric, alphanumericCaseSensitive, localeText (orders text in the language of the grid's locale); auto or no declaration infers one from the values. An unknown name is refused.
filterFnfunction or nameA filter function (row, columnId, filterValue, addMeta?) => boolean over a SourceRow, or a built-in name: includesString, includesStringSensitive, equalsString, equals, weakEquals, arrIncludes, arrIncludesAll, arrIncludesSome, arrHas, inNumberRange, between, betweenInclusive; auto or no declaration infers one. An unknown name is refused.
typestring | string[]Column type preset name(s) from columnTypes.
enableGroupingbooleanWhether this column can be grouped (default: true when grid-level enableGrouping is on).
aggregationFnAggregationFnOptionThe aggregate a group row shows for this column: a function (columnId, leafRows, childRows) => unknown or a built-in name: sum, min, max, extent, mean, median, unique, uniqueCount, count, auto (sums numbers, nothing otherwise). No declaration, no aggregate: a numeric column is not summed because it is numeric (DC-3); the group-row value is then absent, not null.
aggregatedCellColumnDefTemplateCustom render template for aggregated cells in group rows; receives a GeneratedCellContext.
getGroupingValue(row: TData) => unknownCustom grouping value getter. Defaults to accessorKey/accessorFn.
metaGridColumnMeta<TData, TValue>Column metadata. See below.

Sorting keeps source order for equal values, in both directions. Ascending, an explicit empty value sorts first and an absent value last; descending reverses that, unless sortUndefined declares a policy. The first direction of a toggle is inferred from the kind of value the column holds and can be overridden with sortDescFirst.

What the built-in names do beyond the obvious:

  • Unknown names. A sortFn or filterFn naming no built-in, an inherited object name such as toString included, is refused with an error that names the column and lists the valid names. An unknown aggregationFn name produces no aggregate.
  • undefined is no declaration. A sortFn, filterFn or aggregationFn set to undefined behaves exactly as an omitted one.
  • Mixed values. basic and datetime order by kind first: numbers, booleans and dates on one numeric scale, then not-a-number and invalid dates, then words, then everything else. A mixed column therefore sorts to one order whatever order its rows arrived in.
  • inNumberRange treats only present finite numbers as numbers: null, empty or numeric words, booleans and infinities fall outside every range, and 0 is kept. A blank or non-numeric end leaves that side open; reversed ends are swapped.
  • between / betweenInclusive compare a value with an end only when both are numbers, booleans or dates, or both are words; any other value is outside the range. An absent or blank end leaves that side open and reversed ends are swapped. A filter value that is not an array of ends ([low, high]) is refused with an error naming the column.
  • arrHas with a wanted value that is not an array matches nothing.

Widths are held between the column's minSize and maxSize when the grid accepts them, so persisted sizing state carries the width the grid uses. A column whose own minSize exceeds its own maxSize is refused with an error naming the column; when only one of two conflicting bounds is the column's own and the other is inherited, the column's own bound wins.

GridColumnMeta#

typescript
import {type GridColumnMeta} from '@workspace/active-grid';

Rich metadata for column behavior and appearance.

The key space is gated AND type-checked (TD-31B, 2026-09-20)#

The compiler is the first enforcer. GridColumnDef<TData> is a real union, so an unknown meta key, a meta value of the wrong type, an accessorKey naming no field and a nested entry that is not a column are each a compile error — in the object-literal path and in the createColumnHelper path alike. Until TD-31B the whole column type resolved to the top type and none of that was checked.

The gate stays, and not out of caution: lint-column-meta-contract.mjs catches what a type cannot. _hasCustomCell is a DECLARED key of this interface, because enhanceColumns has to write it; a consumer setting it by hand writes valid TypeScript and always will. That is the gate's own subject, and the two mechanisms enforce one contract rather than two.

The gate holds every column meta block against this interface. It reads the allowed set out of src/types/columns.ts on every run, so adding a property here is all that is needed to make it legal — there is no second list. It rejects four things:

RejectedWhy
an unknown keynothing reads it; it is silently dropped
a retired key (badgeVariant)removed by a decision; the message names it
an @internal keythe grid writes it — see below
filterOptions value: truethe contract says value: string; a boolean breaks the filter (TD-29)

There is no skip marker. A key the gate rejects is a key the contract does not have; the fix is to change the contract.

@internal marks framework ownership. A property whose docblock opens with @internal is written by active-grid itself and must never appear in a consumer declaration — today _hasCustomCell (set by enhanceColumns) and _serverFilterCapability (stamped by applyCapabilityContract). That tag is machine-read: an underscore-prefixed property without it makes the gate abort rather than guess at ownership.

What the gate does not check: function signatures, row types, and the closed unions (semanticType, statusTones, filterType). Those need a type checker rather than a text scanner, and the type checker now does them.

Display#

PropertyTypeDefaultDescription
labelstringundefinedDisplay label (used in menus, exports, and the accessible names of the column's filter, menu and editor controls; falls back to a string header, then the id). Declare it on a column whose header is a render function.
headerAlign'left' | 'center' | 'right''left'Header text alignment.
cellAlign'left' | 'center' | 'right''left'Cell text alignment.
headerClassNamestringundefinedAdditional CSS class on header cell.
cellClassNamestring | ((value: TValue, row: TData) => string | undefined)undefinedCSS class on body cells — static string or callback.
headerClass(context) => stringundefinedDynamic header class function.
textDirection'auto' | 'ltr' | 'rtl'undefinedGridTextDirection: the base direction of this column's text in the default renderers (ADR-052). Unset, free text is auto (its own first strong character decides) and a formatted value (semanticType) inherits the grid's direction. Declare ltr for identifiers and codes (ABC-42, an IBAN, a policy number) that must keep their order inside right-to-left text. Every run renders isolated (unicode-bidi: isolate). Changes neither the column's position nor any exported or copied value.
truncatebooleanundefinedTruncation of the default text renderer: one line, ellipsis, and the full text as title. Unset, it follows wrapText: a wrapping column does not truncate, any other column does. An explicit true or false always wins, also over wrapText. Custom cell renderers and group-row labels are not affected.
wrapTextbooleanundefinedAllow text wrapping (multi-line cells). With truncate unset, the default text renderer then renders untruncated text, so the text breaks over lines. A group row's label stays on one line: its cell carries no data-wrap, and a label too wide for its column is truncated with its full text as title.
autoHeightbooleanundefinedAuto-adjust row height to fit content. Combine with wrapText to let long text grow the row; on its own it adds no wrapping.
valueFormatter(value: TValue, data: TData) => ReactNodeundefinedFormat the raw value of a row that stands for a record. A group row has no record and never calls it; its aggregate uses semanticType or aggregatedCell (DC-1). Presentation only — DATA export never consults it (WS-04).
meta.exportValue(value: TValue, data: TData) => unknownundefinedExplicit machine value for DATA export when the raw value is not the machine value. Returns data, never localized text. See guides/export.md.
emptyValueKeystringundefinedi18n-key for an absent value: null, undefined, '' or non-finite numbers (TD-28 / F3b-9). Defaults to common.placeholder.notAvailable. Resolved via t() at render time; the absence branch runs first, so valueFormatter is never invoked for those absent values.
tooltipFieldkeyof TDataundefinedData field to use as tooltip content.
tooltipValueGetter(value: TValue, row: TData) => stringundefinedCustom tooltip-content function.

Conditional Styling#

PropertyTypeDescription
cellClassNamestring | ((value: TValue, row: TData) => string | undefined)Static CSS class string, or callback that returns classes based on value/row.
cellClassRulesRecord<string, (value: TValue, row: TData) => boolean>Map of CSS class → condition function. All classes whose condition returns true are applied.
cellStyle(value: TValue, row: TData) => CSSProperties | undefinedDynamic inline styles based on value/row.

When both cellClassName and cellClassRules are defined, their results are combined.

TSX
// cellClassName callback — positive/negative coloring with shadcn tokens{  accessorKey: 'unrealizedGain',  meta: {    filterType: 'number',    cellClassName: (value: number) =>      value > 0 ? 'text-success' : value < 0 ? 'text-destructive' : undefined,  },}
// cellClassRules — multiple independent conditions{  accessorKey: 'stock',  meta: {    filterType: 'number',    cellClassRules: {      'text-destructive font-semibold': (value: number) => value === 0,      'text-orange-500': (value: number) => value > 0 && value < 10,    },  },}

Filtering#

filterType selects a widget; it does not grant permission to filter. A filter control exists only when both answers are yes:

QuestionAnswered by
May this column be filtered?the resolved capability, column.getCanFilter()
Which widget should it render?meta.filterType

In client mode every accessor column may be filtered unless the consumer sets enableColumnFilter: false, so this reduces to the familiar rule: a filterType gives you a filter cell.

In server mode the capability comes from the server contract. applyServerContractCapabilities forces enableColumnFilter: false on any column without a server.filter mapping (or without an entry in the serverCapabilities filter map), so a meta.filterType — including one injected by a column type preset — renders nothing. That is the point: before this rule, such a column rendered a fully interactive input that the server would never honour, on a grid that does not filter locally either (manualFiltering: true). Presentation may narrow the server contract, never widen it.

Consequences worth knowing:

  • A server-mode grid with no filter contract at all shows no filter row. You do not need settings={{showFilterRow: false}} to suppress it.
  • settings.showFilterRow: true still forces the row to render, but it remains a visibility override: each cell resolves its own capability, so the row comes up empty rather than manufacturing filters the server does not allow.
  • Hiding the row never changes ServerFetchParams.filters, and never turns into filtering of the loaded block only.
  • If a server-mode column should be filterable, declare it — fieldFilter() on column.server, or the field in the resource's capability map. Failing closed on the client is the same rule that already applies to sorting.
PropertyTypeDefaultDescription
filterType'text' | 'number' | 'date' | 'select' | 'boolean'undefinedWhich filter widget to render. Requires the column to be filterable — see above.
filterOptions{label, value}[]undefinedOptions for select filters. Doubles as labelMap for type: 'badge' columns.
enableFilterOperatorsbooleanundefinedShow operator selector in filter popover.
allowedOperatorsFilterOperator[]undefinedRestrict available operators.
operatorPresetBuiltInOperatorPreset | (string & {})undefinedNamed operator preset: a built-in, or one declared in GridConfig.filtering.operatorPresets. Narrows only.
defaultFilterOperatorFilterOperatorundefinedInitial operator selection.
filterSearchablebooleanundefinedEnable search within select-filter options.
filterShowSelectAllbooleanundefinedShow "Select all" option in select filter.
booleanFilterLabels{all?, true?, false?}undefinedCustom labels for boolean-filter states.
booleanFilterVariant'text' | 'icon''text'Boolean-filter display style.
dateFilterPresetsDateFilterPreset[] | falseundefinedCustom date presets (array), or false to disable. Default: 8 built-in presets.
dateFilterMode'single' | 'range' | 'both''single'Calendar mode. 'range' enables date-range selection; 'both' shows a toggle.
dateFilterMinDatestring | DateundefinedMinimum selectable date in the Calendar.
dateFilterMaxDatestring | DateundefinedMaximum selectable date in the Calendar.

For the full operator inventory and presets see filters.md. For tutorial coverage of all filter recipes see ../guides/filters.md.

Status columns#

PropertyTypeDefaultDescription
statusTonesPartial<Record<string, GridStatusTone>>undefinedDeclares status semantics: maps each raw cell value to a generic UI-tone.

A column carries status semantics only when it says so. statusTones is that statement, and it is the only one: no filter type, enum name, colour or label turns a column into a status column, and a column called status without this map stays a categorical badge.

typescript
{  accessorKey: 'contractStatus',  header: t('contract.status'),  type: 'badge',  meta: {    filterOptions: [      {label: t('contract.status.ACTIVE'), value: 'ACTIVE'},      {label: t('contract.status.LAPSED'), value: 'LAPSED'},    ],    statusTones: {ACTIVE: 'success', LAPSED: 'danger'},  },}

The key is the raw cell value as a string ('ACTIVE', and 'true' / 'false' on a boolean column). The value is one of the five GridStatusTones: neutral, info, success, warning, danger. There is no pending — waiting without required attention is neutral, waiting that needs someone to act is warning. A value that is not in the map falls back to neutral.

statusTones and filterOptions are complementary and each has one home: filterOptions maps value → label, statusTones maps value → tone. The label of a status cell still comes from filterOptions (or from valueFormatter, or the raw value when neither is set).

The mapping belongs to the caller. active-grid renders tones; it does not know that LAPSED is bad. That is the same division as the label mapping and it is deliberate — a grid that knew domain statuses would carry the meaning of every app in the repo.

Use <StatusCell tone={...}> in a custom cell renderer instead when the cell value is not a stable key — a derived or already-translated value. A map keyed on translated text breaks in every other locale.

Booleans get no implicit status: true and false render the same categorical badge, with the yes/no text as the information. A consumer who wants true to read as success declares it: statusTones: {true: 'success', false: 'neutral'}.

Boolean columns (TD-29, 2026-08-25)#

A boolean column is one of two things, and the answer decides the declaration:

The column isDeclareRenders
a state carrying a judgementtype: 'badge' + filterOptions + statusTonesbadge with a tone
a property whose two values are peerstype: 'badge' + filterOptionsbadge, tone-less
a binary answer with no domain vocabularytype: 'boolean'the built-in yes/no badge
typescript
// State — a tone says something{accessorKey: 'isActive', type: 'badge', meta: {  filterOptions: [{label: t('common.status.active'), value: 'true'},                  {label: t('common.status.inactive'), value: 'false'}],  statusTones: {true: 'success', false: 'neutral'},}}
// Property — no tone{accessorKey: 'isSystem', type: 'badge', meta: {  filterOptions: [{label: t('common.origin.system'), value: 'true'},                  {label: t('common.origin.own'), value: 'false'}],}}

Three rules come with it.

The key space is the string 'true' / 'false'. That is what String(value) produces, so it is what filterOptions, statusTones and the select filter all look up. Writing raw booleans as filter values still resolves the cell label — an object key is coerced to text — but the select filter compares true against 'true' and returns an empty list for every choice.

Every state carries a word. A colour or an icon may reinforce it and may never replace it; that is the house rule (PAGE_STANDARDS.md §3 Information, copy and visual hierarchy, WCAG 1.4.1), and it rules out encoding one state as a bare glyph or as nothing at all.

No valueFormatter on a boolean badge column. The formatter branch runs before the badge branch, so a formatter turns type: 'badge' into a dead word and the cell renders plain text. It was a workaround for BadgeCell discarding false, and that reason disappeared with TD-27.

Whether a given field is a state or a property is a domain decision and stays with the consumer; active-grid renders tones and does not know what isActive means. The families this repository has aligned are guarded by lint-boolean-column-convergence.mjs in repo-tools.

What an accessor may return (TD-30, 2026-08-25)#

An accessor delivers a value, not a rendering. It may derive, compose and choose between fields. It may not inject the presentation of absence, and it may not apply formatting that depends on the viewer's language or presentation settings.

typescript
// ✗ presentation in the raw valueaccessorFn: (row) => row.ipAddress || t('common.placeholder.notAvailable')accessorFn: (row) => row.category?.name ?? '-'accessorFn: (row) => formatDateOnly(row.startDate, locale)
// ✓ a value; the column decides how absence readsaccessorFn: (row) => row.ipAddress ?? nullaccessorFn: (row) => row.category?.name ?? null,  meta: {emptyValueKey: 'common.placeholder.empty'}accessorKey: 'startDate', type: 'date'

Three tests for an accessor's result:

  1. Would it belong in a machine export? A dash and Not available would not; a composed name would.
  2. Does it stay identical when the viewer switches language? If not, it is presentation.
  3. Can the column still choose its own word for empty? If not, the accessor took that choice away.

Why it matters is measurable, not stylistic. With '-' or a translated word in the raw value: the DATA export carries UI text (and different bytes per language), isEmpty matches nothing, and sorting places absent rows among the real values by their translation.

Deriving is still allowed — a composed `${name} <${email}>` is a value you genuinely filter, sort and export on. A derived value should be a stable key rather than translated text: return 'ACTIVE' / 'EXPIRED' and let filterOptions supply the labels, so the export stays language-independent.

Guarded by lint-accessor-presentation.mjs in repo-tools, which checks the literal forms only (?? '-', || t('common.placeholder.…'), and formatter calls). A case whose repair needs its own design decision carries // accessor-presentation-skip: <reason> with a booked item.

Empty and falsy cell values (TD-27 / TD-28, 2026-08-25)#

false and 0 are data. A cell renders them like any other value. Nullish values, the empty string and non-finite numbers count as absent, and that set is stated once — in the internal utils/cell-value.ts — for both the cell orchestration and the primitives:

ValueCell orchestrationPrimitive called directly
nullplaceholdernothing
undefinedplaceholdernothing
''placeholdernothing — except TextCell: ''
NaNplaceholdernothing — except TextCell: "NaN"
false / truevaluevalue
0 / -0value (epoch → 1970-01-01)value
any other finite numbervaluevalue

'' is absent because that is what it means everywhere it occurs in this repository: it is a normalisation of a nullish source value, written by an accessor as (row) => row.field || ''. Until TD-28 the orchestration tested nullish only, so that accessor — and not the column — silently decided whether a missing value read as Not available or as nothing. Four defects hung on the same test, each with the screen on the wrong side of a line the export already drew correctly: Number('') === 0 produced a fabricated zero on a number, currency or percent column; formatDateOnly('') returns a translation key and leaked it into the cell; '' ? yes : no produced a fabricated "no"; and formatNumber(NaN) returns '', so a calculation error looked like an empty cell. See ADR-024.

TextCell is deliberately not part of that change. As a primitive it still renders '' as a valid empty string: a direct caller who passes an empty string asked for an empty span, and only the orchestration knows enough about a column to call that absence.

Deliberate blankness has two carriers, both explicit. A valueFormatter that returns '' runs after the absence branch, so its empty result is an authoring act and stays a genuinely blank cell — the orchestration never re-tests a formatter's output. And emptyValueKey: 'common.placeholder.empty' renders the dash where absence is the ordinary state of a column rather than a gap.

Absence gives the same answer on every layer (REVIEW-AG-001, 2026-08-26). The values above, and every non-finite number, are absent for the cell, for the two no-value filter operators, for both export modes and for the numeric aggregates — one predicate, no per-layer variation:

Layernull / undefined / ''NaN, ±Infinity0 and other finite numbers
Cellplaceholderplaceholdervalue
isEmpty / isNotEmptytrue / falsetrue / falsefalse / true
DATA exportemptyemptythe number
PRESENTATION export''''formatted
sum / mean / min / maxskippedskippedcounted
medianskippedskippedcounted

Group-row aggregation is declarative (DC-3, ratified 2026-09-22). A column shows an aggregate in a group row only when it declares aggregationFn. A number is not a quantity: an identifier, a year, a rate, a percentage, a rank or a numeric code is numeric and has no meaningful sum, so the grid does not invent one. A column that should total declares it, for example aggregationFn: 'sum'; auto remains available as an explicit choice and sums the column when its values are numbers. Without a declaration the group-row cell is empty. See DECISIONS.md ADR-038.

median skips an absent value like the other numeric aggregates: it is the median of the values that are present, and without a present value there is no result. Until the kernel cutover it returned nothing as soon as the column carried a single absent value; that difference was ratified as delta DC-2 (2026-09-22, see DECISIONS.md ADR-038).

Infinity and -Infinity are absent, like NaN (active-i18n D13, F3b-9). No product means an infinite number, and the human formatters return '' for a non-finite value, so a present cell would render blank. Until F3b-9 the cell and the presentation export showed ∞ and the aggregates counted the value.

A numeric formatter decides on presence, never on truthiness (TD-32, 2026-08-25). Because the absence branch runs first, a valueFormatter on a number, currency, percent or count column only ever receives a value that is present — so it should simply format its input:

TypeScript
// WRONG — a measured 0 disappears behind the placeholder branchconst wrong = {  accessorKey: 'amount',  type: 'currency',  meta: {    currencyCode: 'EUR',    valueFormatter: (value: number | null) => (value ? currency(value) : '-'),  },};
// RIGHT — no formatter at all; the declaration already says "money"const right = {  accessorKey: 'amount',  type: 'currency',  meta: {currencyCode: 'EUR'},};
// RIGHT — a formatter that carries a unit the declaration cannot supplyconst rightWithUnit = {  accessorKey: 'costBps',  type: 'number',  meta: {valueFormatter: (value: string) => `${value} bps`},};

0 is data (TD-27), not absence (TD-28). A truthiness test hides a measured zero in a source cell and its presentation export: an amount of zero becomes a dash. Generated rows have no record and do not call this formatter (DC-1); their aggregates use declared semantics or aggregatedCell. The DATA export is the exception and always was: by contract it consults no formatter (WS-04), so a zero has always left it as a real 0. Deliberate blankness is still available, but it has to say so: value === 0 ? '' : format(value) is a declaration, value ? … : '' is an accident. Wanting to test for absence is the same story: write value == null. lint-numeric-zero-presence.mjs enforces this and has no skip marker.

The label is looked up on the string key. BadgeCell derives String(value) and reads labelMap (auto-built from filterOptions) on that key, so a boolean column keys on 'true' / 'false' — the same shape filterOptions and statusTones already use. The key derivation is deliberately identical to the one the select filter uses, so filter and cell can never disagree about the same column.

The cell orchestration owns the placeholder; the primitives do not. For an absent value a primitive renders nothing at all, and the dash or Not available text comes from the orchestration, which is the only layer that knows meta.emptyValueKey and has a t() function.

That has one consequence worth knowing: a primitive used directly in a custom cell renderer gets no placeholder, because that renderer bypassed the orchestration. This is intended — a second, column-blind placeholder inside the primitives would compete with emptyValueKey rather than complement it. A custom renderer that wants a placeholder renders its own.

emptyValueKey selects the label, never the emptiness. Whether a value is absent is decided by isAbsentCellValue alone; emptyValueKey only chooses which word the placeholder shows. There is no metadata that turns '' back into a value, and there is deliberately none: the two carriers above already express intentional blankness, and a third would make "blank" ambiguous again.

Filtering, sorting, grouping and both export modes read the raw value and are untouched by any of this. isEmpty already counted '' as empty before TD-28, the select filter already keyed null and '' alike on '', and the DATA export already resolved '' to an empty cell. A machine value never carries a translated placeholder — see guides/export.md and ADR-022.

Currency columns (WS-01, 2026-08-14)#

PropertyTypeDefaultDescription
semanticType'currency'undefinedDeclares money semantics; set by the currency column-type preset.
currencyKeystringundefinedRow field carrying the ISO 4217 code — per-row currency identity.
currencyCodestringundefinedFixed ISO 4217 code when the whole column is one currency by domain contract.
currencyDecimalsnumberundefinedDeliberate per-column decimals override (e.g. whole dashboard amounts). Applies to body, footer, group aggregates and presentation export alike; omitted, the currency's natural minor units decide. Ignored by DATA export.

A money column carries its currency identity as data, never as a derivation from the language (PP-005/PP-006). The currency preset declares only semanticType: 'currency', and the cell orchestration resolves the identity:

typescript
// Per row: the record carries its own currency (orders in different currencies).{accessorKey: 'currentValue', type: 'currency', meta: {currencyKey: 'currency'}}
// Fixed column contract: the base currency, supplied by the host layer.{accessorKey: 'salary', type: 'currency', meta: {currencyCode: baseCurrency}}

Without a source the cell renders a bare locale number without a symbol (with a dev warning): no identity means no symbol. Footer and group aggregations of a currencyKey column carry a symbol only when all aggregated rows share one currency; a sum over different currencies is meaningless and renders as a bare number (PP-010). Server aggregations format only with a fixed currencyCode contract. The grid stays tenant-blind: it reads codes from rows or props, never from settings. For these columns the DATA export delivers the amount as a bare number plus the ISO 4217 identity in a companion column <id>_currency — never a symbol; see guides/export.md.

Number and percent columns (WS-02, 2026-08-15)#

PropertyTypeDefaultDescription
semanticType'number' | 'percent'undefinedSet by the number/threshold/count resp. percent column-type presets.
numberFormatNumberFormat (active-i18n)'decimal'Semantic precision preset for a number column (count declares 'integer').
percentSource'fraction' | 'percentage-points'see presetThe stored unit: 'fraction' = 0-1 data (0.125 means 12.5%), 'percentage-points' = 0-100 data. The percent preset declares 'fraction'.
percentPresetPercentPreset (active-i18n)'integer'Precision preset for a percent column.

The body cell and the footer and group aggregations format through the same central source (the internal helpers in utils/number-utils.ts), so a plain number column formats its cells in the locale just as its footer does. A percent column without a declared percentSource renders a bare number without a percent sign (with a dev warning): guessing the unit is the error the explicit percent API rules out — the same rule as a currency without an identity source. A column valueFormatter still wins over all these declarations in a source cell; a group or footer aggregate does not call it, because a generated row has no record (DC-1, ADR-038), and shows the aggregate through the declared semantics. The grid stays tenant-blind.

typescript
// 0-1 data: the preset already declares the fraction; only choose precision.{accessorKey: 'deviationThreshold', type: 'percent', meta: {percentPreset: 'decimal-1'}}
// 0-100 data: the column declares the source unit instead of dividing by 100.{accessorKey: 'completionRatio', type: 'percent', meta: {percentSource: 'percentage-points', percentPreset: 'decimal-1'}}

Date semantics as declaration (WS-04, 2026-08-15)#

The date and dateTime presets also declare their semantics as metadata: semanticType: 'date' (calendar day) and 'dateTime' (instant) respectively. The cell orchestration formats through the presentation the grid receives. The DATA export uses the same semantic declaration for the machine form: yyyy-MM-dd for a calendar day, ISO 8601 UTC for an instant. No preset formatter closure is needed. A custom column can set the declaration directly.

Who chooses the semantic date shape (PP-023)#

The consumer chooses the semantic presentation; the presentation context only resolves the final form. A dateFormat from an administration profile (DMY_NUMERIC, MDY_NUMERIC, ISO_NUMERIC) determines what a numeric date looks like; it does not determine whether a column should show a numeric, short textual or long textual date. The grid follows that by construction: it passes its locale value unchanged to the central formatters and never reads a field of it itself.

A measurable consequence for the bundled column types: an MDY_NUMERIC profile does not touch a textual date column, so such a column renders byte-identical to the same column under a bare language code. That is pinned in config/__tests__/column-type-presets-presentation.test.ts. The clock half of dateTime does follow the profile (timeFormat), because that is a structural convention and not a word.

The date column type carries no fixed shape. An ordinary date column inherits the normal human date shape from the presentation context the consumer passes through locale (PP-053, PP-054):

Text
1  meta.datePreset                  explicit semantics of the column2  dateShape from the presentation  the presentation owner's choice3  SHORT_TEXTUAL                    package fallback of active-i18n

active-i18n resolves levels 2 and 3: the grid simply passes no preset when the column declares none. This package therefore has no date default that could drift out of step. The grid stays owner-blind: it does not read dateShape and does not know the field exists; it knows no administration, system or tenant (PP-023, PP-058).

meta.datePreset is the declarative exception route, meant for a column with a semantic ground and not for bypassing the setting (PP-056). A repo-wide guard (lint-date-shape-inheritance) measures the ratio between inheriting and declaring places and requires a registered ground for every declaration.

A consumer that wants a different semantic shape declares it per column:

typescript
// The declarative route, next to meta.numberFormat and meta.percentPreset. Meant// to be rare: a column without a semantic ground should inherit (PP-056).{accessorKey: 'birthDate', type: 'date', meta: {datePreset: 'long'}}
// The `columnTypes` prop extends or overrides the built-in types per name;// the other built-in types stay in force.const columnTypes: GridColumnTypes<Person> = {  date: {minSize: 150, meta: {filterType: 'date', semanticType: 'date',    datePreset: 'long'}},};

The built-in column types take no presentation. All five value semantics are declarations, and the presentation enters exactly once — through the grid's effective presentation (its locale prop, else the nearest <GridDefaults>), which the cell orchestration, the footer, the group aggregates and the presentation export all four read. There is no second presentation supply that the caller would have to keep equal by hand.

meta.datePreset accepts only day-shaped presets on a calendar-day column (OP-39 (source repository: docs/platform/OPEN-PUNTEN.md)); a time-bearing preset produces a development warning and falls back to the inherited shape, because a calendar day carries no instant.

Editing#

PropertyTypeDefaultDescription
editableboolean | (row: TData) => booleanundefinedWhether this column is editable.
editType'text' | 'number' | 'date' | 'select' | 'boolean'filterType | 'text'Editor input type.
validate(value, row) => string | undefined | Promise<string | undefined>undefinedRuns at commit, before onDataCommit. Return a message to block the commit, undefined to allow it. Throwing also blocks.
editOptions{label, value}[]filterOptionsOptions for select editors.
valueParser(value: string) => unknownundefinedParse raw editor input before saving (e.g. currency string "1.000,50" → number 1000.50). Returning undefined rejects the input and blocks the commit. Runs per keystroke, so use validate for range rules.

Aggregation#

PropertyTypeDescription
aggregationFn'sum' | 'avg' | 'count' | 'min' | 'max'Aggregation function for the footer.
aggregationLabelstringLabel prefix in the footer cell (e.g., "Total: ").

Layout#

PropertyTypeDescription
flexnumberFlex grow factor for proportional sizing.
enableResizingbooleanOverride column resizing for this column.
enablePinningbooleanOverride column pinning for this column.
suppressSizeToFitbooleanExclude from auto-size calculations.
autoSizeMaxBodyWidthnumberMax width (px) body cells contribute to auto-size. Header width always wins. Overrides grid-level settings.autoSizeMaxBodyWidth.
lockedbooleanLock column width (prevent flex resizing).
headerMenuGridHeaderMenuConfigPer-column header-menu override.
suppressFilterIconbooleanHide the filter-active icon for this column.
suppressHeaderMenuButtonbooleanHide the menu button for this column.
isSystemColumnbooleanMark as system column — excluded from edge-column detection (e.g. custom row-action columns).

GridColumnTypes#

typescript
import {type GridColumnTypes} from '@workspace/active-grid';

Named column-type presets. Each key maps to a partial GridColumnDef.

The built-in types are the package default. Every grid applies them (text, id, number, threshold, boolean, date, dateTime, badge, count, currency, percent) without a prop. The columnTypes prop extends that map or overrides an entry by type name; it never replaces the map as a whole, so overriding text keeps the built-in number:

typescript
const columnTypes: GridColumnTypes<Employee> = {  currency: {    size: 120,    meta: {      cellAlign: 'right',      filterType: 'number',      // Money semantics: the identity comes per column from `currencyKey` or      // `currencyCode` (see "Currency columns"), never from the language.      semanticType: 'currency',    },  },  status: {    size: 100,    meta: {filterType: 'select'},  },};

Usage in column definitions:

typescript
const columns: GridColumnDef<Employee>[] = [  {accessorKey: 'salary', header: 'Salary', type: 'currency'},  {accessorKey: 'status', header: 'Status', type: 'status'},];

Multiple types can be applied as an array — later types override earlier ones: type: ['currency', 'rightAligned'].

GridColumnDefaults#

typescript
import {type GridColumnDefaults} from '@workspace/active-grid';

Default values applied to all columns unless overridden:

PropertyTypeDescription
sizenumberDefault column width.
minSizenumberDefault minimum width.
maxSizenumberDefault maximum width.
enableSortingbooleanDefault sorting enabled state.
enableResizingbooleanDefault resizing enabled state.
enablePinningbooleanDefault pinning enabled state.
enableHidingbooleanDefault hiding enabled state.
metaPartial<GridColumnMeta>Default metadata.

Priority order (highest wins): column-definition → column-type → column-defaults → global config.

GridDefaults#

typescript
import {  GridDefaults,  type GridDefaultsProps,  type GridConfig,  type GridDiagnostic,  type GridDiagnosticLevel,  type GridDiagnosticSink,  type PartialGridConfig,} from '@workspace/active-grid';

The one defaults boundary of the grid. It carries the presentation (locale), the translator (t), the diagnostic sink (diagnostics) and the sections of PartialGridConfig (the partial form of GridConfig) for every grid rendered below it. Every section is optional, and every field within a section is optional too. A host mounts it once per application or surface, next to the chart package's own defaults boundary:

TSX
<GridDefaults  locale={presentation}  t={t}  layout={{rowHeight: 36}}  pagination={{pageSize: 25}}  storage={{keyPrefix: 'app_grid_'}}>  <App /></GridDefaults>

Diagnostics#

diagnostics receives every diagnostic of the grids below it as a GridDiagnostic: {level: 'debug' | 'warn' | 'error', source, message, data?}, with a caught error under data.error and source naming the part (ActiveGrid, ActiveGrid.ColumnVirtualizer). Like t, it is atomic: a nearer boundary replaces it whole. Without one, warn and error go to the console and debug is dropped. An inline arrow is safe; the grid forwards to the sink of the latest render without re-creating its callbacks. The grid imports no logging package; a host routes the sink to its own logging, for instance by passing its logger's structured-diagnostic function as the sink.

A misconfiguration (rows without an id and no getRowId, an invalid serverOptions field) throws a GridConfigurationError at render, with a stable code (a GridConfigurationErrorCode: GRID_ROW_ID_MISSING, GRID_SERVER_OPTIONS_INVALID or GRID_DIRECTION_INVALID), the field, the constraint and the refused value. Its constructor takes one GridConfigurationErrorOptions object.

typescript
import {  GridConfigurationError,  type GridConfigurationErrorCode,  type GridConfigurationErrorOptions,} from '@workspace/active-grid';

Precedence: prop → nearest boundary → package default#

Text
presentation:  locale prop  →  nearest GridDefaults locale  →  configuration errortranslator:    t prop       →  nearest GridDefaults t       →  configuration errorbehaviour:     per-grid prop / option  →  nearest GridDefaults  →  package default

Presentation and translator have no package default. A grid with neither the prop nor an inherited value throws at render, naming the component and the two ways to fix it. englishGridTranslator is the package's English: a value a standalone host, fixture or example passes on purpose, never a fallback.

Every behaviour setting is a baseline, never an override. For stateful values the user can change and the grid can remember, two stronger sources sit above all three — an explicit URL state and an explicit persisted state, ordered against each other by statePriority. That applies to page size:

Text
URL / persisted  →  initialPageSize  →  GridConfig.pagination.pageSize  →  10

See grid-props.md § Initial page size for the full pagination chain, including how an invalid value is refused.

Nested and sibling boundaries#

A nested <GridDefaults> refines the enclosing one; it does not start again from the package defaults. Presentation and translator are atomic: a nearer value replaces a farther one whole and a presentation object is never merged per field. The behaviour sections merge as Merge depth describes. Sibling boundaries share nothing:

TSX
<GridDefaults locale={presentation} t={t} pagination={{pageSize: 25}}>  <GridDefaults export={{defaultFilename: 'reports'}}>    {/* pageSize is still 25, presentation and translator are inherited;        the filename is 'reports' */}  </GridDefaults></GridDefaults>

undefined means "inherit". There is no value that removes an inherited presentation or translator; a nearer boundary can only replace it.

Merge depth#

The merge is one level deep per section: an override that sets layout.rowHeight keeps the other three heights from the baseline. Anything below that is replaced, not merged — columns.autoSizeConfig and ui.headerMenu are coherent measurement settings whose half-filled variant has no meaning, and the type requires them complete. Arrays are replaced, never appended.

filtering.operatorPresets is the one exception, and it is deliberate. That map is a namespace, not a coherent setting: every key stands alone. Replacing it would mean that a boundary adding one preset silently drops all built-in ones, and with them the narrowing of every column that names operatorPreset: 'identity'. Its keys are therefore merged, with the override winning per name. Overriding a built-in preset is one key with the same name; adding one is one key with a new name. There is no way to remove a built-in preset, which is intended: removing one would break the columns that name it.

Config Sections#

layout — GridLayoutConfig#

PropertyTypeDefaultDescription
rowHeightnumber42Default row height in pixels.
headerHeightnumber48Header row height.
groupHeaderHeightnumber32Group header row height.
filterBarHeightnumber36Filter row height.

pagination — GridPaginationDefaults#

PropertyTypeDefaultDescription
pageSizenumber10Page size a grid opens at. Loses to initialPageSize and to URL/persisted state; wins over the package default of 10.
pageSizeOptionsnumber[][10, 20, 50, 100]Available page-size options. Loses to pagination.pageSizeOptions on the grid.

Both are whole numbers greater than zero. A value that is present but invalid is refused, not clamped: the chain continues to the next source and a console.warn in development names the grid.

behavior — GridBehaviorConfig#

PropertyTypeDefaultDescription
debounceMsnumber300Default debounce for global filter.
virtualizationThresholdnumber1000Row count above which virtualization is enabled.
overscannumber10Extra rows rendered above/below viewport.
maxPinnedRowsnumber10Maximum pinned rows.
enableFuzzyFilterbooleantrueEnable fuzzy matching in global search.

columns — GridColumnConfig#

PropertyTypeDefaultDescription
minWidthnumber120Minimum column width.
maxWidthnumber800Maximum column width.
autoSizeConfigGridAutoSizeConfig(see defaults)Column auto-sizing measurement settings.

GridAutoSizeConfig is handed to the measurement layer as a whole. Three of its fields — iconWidth, gap and lockIconWidth — have had no effect since LAYOUT-FIX-05: the measurement no longer reserves room for elements that are not rendered, and the lock indicator is absolutely positioned and never affects content flow. They are kept for shape compatibility, not as knobs.

filtering — GridFilteringConfig#

PropertyTypeDefaultDescription
operatorPresetsRecord<string, FilterOperator[]>6 built-in presetsNamed operator presets.

Merged per key with the built-ins (see Merge depth). A column selects one by name with meta.operatorPreset; see guides/filters.md § Operator presets for what a preset can and cannot do.

storage — GridStorageConfig#

PropertyTypeDefaultDescription
keyPrefixstring'active_grid_state_'localStorage key prefix.
versionnumber3State version. Mismatch triggers automatic clear. 3 since logical pinning (ADR-053).

export — GridExportConfig#

PropertyTypeDefaultDescription
defaultFilenamestring'active-grid-export'Default export filename.
csvSeparatorCsvDelimiter','Field separator of a CSV export.
fontsGridExportFontsnoneThe PDF export fonts of every grid below (row menu). Below a grid's own export.fonts. PDF only (ADR-054).

fonts is an export resource, never a live one: it changes no rendering and reaches neither CSV, JSON nor XLSX. Without it, the PDF draws in Helvetica and refuses text outside WinAnsi. See guides/export.md.

CsvDelimiter is exactly one of , ; \t | — a positive list, closed by the type, and re-checked at runtime because a boundary configuration can come from plain JavaScript. An invalid value is refused (never truncated or trimmed) and the export falls back to a comma with a console.warn in development.

Quoting does not depend on the separator: every CSV field is quoted unconditionally, so a value containing the separator, a quote or a newline is safe whichever separator is chosen. The formula-injection guard is unchanged and sits at the same boundary.

This setting does not reach the clipboard. A clipboard copy is TSV and joins with a tab: that is the format Excel expects on paste, while csvSeparator is about a file. The two paths deliberately share no configuration.

ui — ActiveGridUIConfig#

PropertyTypeDefaultDescription
contextMenuActiveGridMenuConfig{widthClass: 'grid-menu-popup--context', itemClass: 'grid-menu-item--context'}Row context-menu presentation.
headerMenuActiveGridMenuConfig{widthClass: 'grid-menu-popup--header', itemClass: 'grid-menu-item--header'}Column header-menu presentation.
linkComponentGridLinkComponentnone (a native <a href>)The host's link for internal links (LinkCell); see ADR-050.

A column's meta.rowMenu.className / itemClassName wins over contextMenu. The default classes are the package's own (18rem / 16rem wide, item padding 0.5rem / 0.375rem, styles/menus.css); a host passes its own class to change them. linkComponent receives {href, className, children} and nothing else, so a router link such as next/link fits it.

features — GridFeaturesConfig#

PropertyTypeDefaultDescription
enableColumnReorderingbooleanfalseGlobal default for column reordering.

This page as Markdown