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 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#
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#
| Pattern | Inference |
|---|---|
ch. | ✓ the key gives the value type |
ch. | ✓ a dotted path runs through the row |
ch. | ✓ info. is the column's value type |
ch. | ✓ the value type is what the function returns |
{accessor 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#
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:
| Kind | Recognised by | Has a cell value |
|---|---|---|
| accessor-key column | accessor, naming a row field | yes |
| accessor-function | accessor, plus an id/header | yes |
| group or display | id or a string header | no |
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:
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
metakey, and ametavalue of the wrong type; - an
accessorKeynaming no field of the row; - a nested entry of
columnsthat 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.
| Property | Type | Description |
|---|---|---|
accessor | Deep | The row field this column reads, or a dotted path to one. A name the row does not carry is a compile error. |
accessor | (row: TData) => TValue | Custom accessor function. |
id | string | Column identifier. Auto-derived from accessor or a string header if not set; a column with no derivable identifier is refused with an error. |
header | string | ((context) => React | Header content — string or render function. |
cell | (context) => React | Custom cell renderer. |
footer | string | ((context) => React | Footer content. |
columns | Array<Grid | 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. |
size | number | Initial column width in pixels. |
min | number | Minimum 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. § The layout contract of the filter row. That floor only raises; a larger value here still applies. |
max | number | Maximum width (default: 800). |
enable | boolean | Whether this column can be sorted (default: true). |
enable | boolean | Whether this column can be resized (default: true). |
enable | boolean | Whether this column can be hidden (default: true). |
sort | function or name | A comparator (row over rows that may be generated (narrow with get before reading original), or a built-in name: basic, datetime, text, text, alphanumeric, alphanumeric, locale (orders text in the language of the grid's locale); auto or no declaration infers one from the values. An unknown name is refused. |
filter | function or name | A filter function (row, column over a Source, or a built-in name: includes, includes, equals, equals, weak, arr, arr, arr, arr, in, between, between; auto or no declaration infers one. An unknown name is refused. |
type | string | string[] | Column type preset name(s) from column. |
enable | boolean | Whether this column can be grouped (default: true when grid-level enable is on). |
aggregation | Aggregation | The aggregate a group row shows for this column: a function (column or a built-in name: sum, min, max, extent, mean, median, unique, unique, 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. |
aggregated | Column | Custom render template for aggregated cells in group rows; receives a Generated. |
get | (row: TData) => unknown | Custom grouping value getter. Defaults to accessor/accessor. |
meta | Grid | 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
sortFnorfilterFnnaming no built-in, an inherited object name such astoStringincluded, is refused with an error that names the column and lists the valid names. An unknownaggregationFnname produces no aggregate. undefinedis no declaration. AsortFn,filterFnoraggregationFnset toundefinedbehaves exactly as an omitted one.- Mixed values.
basicanddatetimeorder 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. inNumberRangetreats only present finite numbers as numbers:null, empty or numeric words, booleans and infinities fall outside every range, and0is kept. A blank or non-numeric end leaves that side open; reversed ends are swapped.between/betweenInclusivecompare 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.arrHaswith 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#
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:
| Rejected | Why |
|---|---|
| an unknown key | nothing reads it; it is silently dropped |
a retired key (badge) | removed by a decision; the message names it |
an @internal key | the grid writes it — see below |
filter value: true | the 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#
| Property | Type | Default | Description |
|---|---|---|---|
label | string | undefined | Display 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. |
header | 'left' | 'center' | 'right' | 'left' | Header text alignment. |
cell | 'left' | 'center' | 'right' | 'left' | Cell text alignment. |
header | string | undefined | Additional CSS class on header cell. |
cell | string | ((value: TValue, row: TData) => string | undefined) | undefined | CSS class on body cells — static string or callback. |
header | (context) => string | undefined | Dynamic header class function. |
text | 'auto' | 'ltr' | 'rtl' | undefined | Grid: 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 (semantic) inherits the grid's direction. Declare ltr for identifiers and codes (ABC-, an IBAN, a policy number) that must keep their order inside right-to-left text. Every run renders isolated (unicode-). Changes neither the column's position nor any exported or copied value. |
truncate | boolean | undefined | Truncation of the default text renderer: one line, ellipsis, and the full text as title. Unset, it follows wrap: a wrapping column does not truncate, any other column does. An explicit true or false always wins, also over wrap. Custom cell renderers and group-row labels are not affected. |
wrap | boolean | undefined | Allow 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-, and a label too wide for its column is truncated with its full text as title. |
auto | boolean | undefined | Auto-adjust row height to fit content. Combine with wrap to let long text grow the row; on its own it adds no wrapping. |
value | (value: TValue, data: TData) => React | undefined | Format the raw value of a row that stands for a record. A group row has no record and never calls it; its aggregate uses semantic or aggregated (DC-1). Presentation only — DATA export never consults it (WS-04). |
meta. | (value: TValue, data: TData) => unknown | undefined | Explicit machine value for DATA export when the raw value is not the machine value. Returns data, never localized text. See guides/. |
empty | string | undefined | i18n-key for an absent value: null, undefined, '' or non-finite numbers (TD-28 / F3b-9). Defaults to common.. Resolved via t() at render time; the absence branch runs first, so value is never invoked for those absent values. |
tooltip | keyof TData | undefined | Data field to use as tooltip content. |
tooltip | (value: TValue, row: TData) => string | undefined | Custom tooltip-content function. |
Conditional Styling#
| Property | Type | Description |
|---|---|---|
cell | string | ((value: TValue, row: TData) => string | undefined) | Static CSS class string, or callback that returns classes based on value/row. |
cell | Record<string, (value: TValue, row: TData) => boolean> | Map of CSS class → condition function. All classes whose condition returns true are applied. |
cell | (value: TValue, row: TData) => CSSProperties | undefined | Dynamic inline styles based on value/row. |
When both cellClassName and cellClassRules are defined, their results are
combined.
// 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:
| Question | Answered by |
|---|---|
| May this column be filtered? | the resolved capability, column. |
| Which widget should it render? | meta. |
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: truestill 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()oncolumn.server, or the field in the resource's capability map. Failing closed on the client is the same rule that already applies to sorting.
| Property | Type | Default | Description |
|---|---|---|---|
filter | 'text' | 'number' | 'date' | 'select' | 'boolean' | undefined | Which filter widget to render. Requires the column to be filterable — see above. |
filter | {label, value}[] | undefined | Options for select filters. Doubles as labelMap for type: 'badge' columns. |
enable | boolean | undefined | Show operator selector in filter popover. |
allowed | Filter | undefined | Restrict available operators. |
operator | Built | undefined | Named operator preset: a built-in, or one declared in Grid. Narrows only. |
default | Filter | undefined | Initial operator selection. |
filter | boolean | undefined | Enable search within select-filter options. |
filter | boolean | undefined | Show "Select all" option in select filter. |
boolean | {all?, true?, false?} | undefined | Custom labels for boolean-filter states. |
boolean | 'text' | 'icon' | 'text' | Boolean-filter display style. |
date | Date | undefined | Custom date presets (array), or false to disable. Default: 8 built-in presets. |
date | 'single' | 'range' | 'both' | 'single' | Calendar mode. 'range' enables date-range selection; 'both' shows a toggle. |
date | string | Date | undefined | Minimum selectable date in the Calendar. |
date | string | Date | undefined | Maximum 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#
| Property | Type | Default | Description |
|---|---|---|---|
status | Partial<Record<string, Grid | undefined | Declares 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.
{ 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 is | Declare | Renders |
|---|---|---|
| a state carrying a judgement | type: 'badge' + filter + status | badge with a tone |
| a property whose two values are peers | type: 'badge' + filter | badge, tone-less |
| a binary answer with no domain vocabulary | type: 'boolean' | the built-in yes/no badge |
// 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.
// ✗ 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:
- Would it belong in a machine export? A dash and
Not availablewould not; a composed name would. - Does it stay identical when the viewer switches language? If not, it is presentation.
- 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:
| Value | Cell orchestration | Primitive called directly |
|---|---|---|
null | placeholder | nothing |
undefined | placeholder | nothing |
'' | placeholder | nothing — except Text: '' |
Na | placeholder | nothing — except Text: "Na |
false / true | value | value |
0 / -0 | value (epoch → 1970-01-01) | value |
| any other finite number | value | value |
'' 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:
| Layer | null / undefined / '' | Na, ±Infinity | 0 and other finite numbers |
|---|---|---|---|
| Cell | placeholder | placeholder | value |
is / is | true / false | true / false | false / true |
| DATA export | empty | empty | the number |
| PRESENTATION export | '' | '' | formatted |
sum / mean / min / max | skipped | skipped | counted |
median | skipped | skipped | counted |
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:
// 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)#
| Property | Type | Default | Description |
|---|---|---|---|
semantic | 'currency' | undefined | Declares money semantics; set by the currency column-type preset. |
currency | string | undefined | Row field carrying the ISO 4217 code — per-row currency identity. |
currency | string | undefined | Fixed ISO 4217 code when the whole column is one currency by domain contract. |
currency | number | undefined | Deliberate 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:
// 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)#
| Property | Type | Default | Description |
|---|---|---|---|
semantic | 'number' | 'percent' | undefined | Set by the number/threshold/count resp. percent column-type presets. |
number | Number (active-i18n) | 'decimal' | Semantic precision preset for a number column (count declares 'integer'). |
percent | 'fraction' | 'percentage- | see preset | The stored unit: 'fraction' = 0-1 data (0.125 means 12.5%), 'percentage- = 0-100 data. The percent preset declares 'fraction'. |
percent | Percent (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.
// 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):
1 meta.datePreset explicit semantics of the column2 dateShape from the presentation the presentation owner's choice3 SHORT_TEXTUAL package fallback of active-i18nactive-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:
// 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#
| Property | Type | Default | Description |
|---|---|---|---|
editable | boolean | (row: TData) => boolean | undefined | Whether this column is editable. |
edit | 'text' | 'number' | 'date' | 'select' | 'boolean' | filter | Editor input type. |
validate | (value, row) => string | undefined | Promise<string | undefined> | undefined | Runs at commit, before on. Return a message to block the commit, undefined to allow it. Throwing also blocks. |
edit | {label, value}[] | filter | Options for select editors. |
value | (value: string) => unknown | undefined | Parse 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#
| Property | Type | Description |
|---|---|---|
aggregation | 'sum' | 'avg' | 'count' | 'min' | 'max' | Aggregation function for the footer. |
aggregation | string | Label prefix in the footer cell (e.g., "Total: "). |
Layout#
| Property | Type | Description |
|---|---|---|
flex | number | Flex grow factor for proportional sizing. |
enable | boolean | Override column resizing for this column. |
enable | boolean | Override column pinning for this column. |
suppress | boolean | Exclude from auto-size calculations. |
auto | number | Max width (px) body cells contribute to auto-size. Header width always wins. Overrides grid-level settings.. |
locked | boolean | Lock column width (prevent flex resizing). |
header | Grid | Per-column header-menu override. |
suppress | boolean | Hide the filter-active icon for this column. |
suppress | boolean | Hide the menu button for this column. |
is | boolean | Mark as system column — excluded from edge-column detection (e.g. custom row-action columns). |
GridColumnTypes#
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:
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:
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#
import {type GridColumnDefaults} from '@workspace/active-grid';Default values applied to all columns unless overridden:
| Property | Type | Description |
|---|---|---|
size | number | Default column width. |
min | number | Default minimum width. |
max | number | Default maximum width. |
enable | boolean | Default sorting enabled state. |
enable | boolean | Default resizing enabled state. |
enable | boolean | Default pinning enabled state. |
enable | boolean | Default hiding enabled state. |
meta | Partial<Grid | Default metadata. |
Priority order (highest wins): column-definition → column-type → column-defaults → global config.
GridDefaults#
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:
<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.
import { GridConfigurationError, type GridConfigurationErrorCode, type GridConfigurationErrorOptions,} from '@workspace/active-grid';Precedence: prop → nearest boundary → package default#
presentation: locale prop → nearest GridDefaults locale → configuration errortranslator: t prop → nearest GridDefaults t → configuration errorbehaviour: per-grid prop / option → nearest GridDefaults → package defaultPresentation 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:
URL / persisted → initialPageSize → GridConfig.pagination.pageSize → 10See 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:
<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#
| Property | Type | Default | Description |
|---|---|---|---|
row | number | 42 | Default row height in pixels. |
header | number | 48 | Header row height. |
group | number | 32 | Group header row height. |
filter | number | 36 | Filter row height. |
pagination — GridPaginationDefaults#
| Property | Type | Default | Description |
|---|---|---|---|
page | number | 10 | Page size a grid opens at. Loses to initial and to URL/persisted state; wins over the package default of 10. |
page | number[] | [10, 20, 50, 100] | Available page-size options. Loses to pagination. 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#
| Property | Type | Default | Description |
|---|---|---|---|
debounce | number | 300 | Default debounce for global filter. |
virtualization | number | 1000 | Row count above which virtualization is enabled. |
overscan | number | 10 | Extra rows rendered above/below viewport. |
max | number | 10 | Maximum pinned rows. |
enable | boolean | true | Enable fuzzy matching in global search. |
columns — GridColumnConfig#
| Property | Type | Default | Description |
|---|---|---|---|
min | number | 120 | Minimum column width. |
max | number | 800 | Maximum column width. |
auto | Grid | (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#
| Property | Type | Default | Description |
|---|---|---|---|
operator | Record<string, Filter | 6 built-in presets | Named 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#
| Property | Type | Default | Description |
|---|---|---|---|
key | string | 'active_ | localStorage key prefix. |
version | number | 3 | State version. Mismatch triggers automatic clear. 3 since logical pinning (ADR-053). |
export — GridExportConfig#
| Property | Type | Default | Description |
|---|---|---|---|
default | string | 'active- | Default export filename. |
csv | Csv | ',' | Field separator of a CSV export. |
fonts | Grid | none | The PDF export fonts of every grid below (row menu). Below a grid's own export.. 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#
| Property | Type | Default | Description |
|---|---|---|---|
context | Active | {width | Row context-menu presentation. |
header | Active | {width | Column header-menu presentation. |
link | Grid | none (a native <a href>) | The host's link for internal links (Link); 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#
| Property | Type | Default | Description |
|---|---|---|---|
enable | boolean | false | Global default for column reordering. |