Grid Cell presentations
Cell presentations
Opt-in presentational components for signed changes, states, badges and entities, returned from a column's render hook.
@pretable/react ships four small readings of a value — PretableDelta,
PretableStatus, PretableBadge and PretableEntity. Each is styled by
@pretable/ui's grid.css and returned from a column's
render hook. Here are all four at once, one to a
column:
A positions grid using all four presentation components together — PretableEntity for the symbol and name, PretableDelta for day P&L, PretableStatus for settlement, and PretableBadge for a risk or watch flag.
They are opt-in, never automatic. Each of those four columns asked for its presentation by name. The grid did not infer that Day P&L is a change rather than a quantity, or that Settlement is a state rather than a label — only you know that, so only you ask for it.
None of them speaks in colour alone, which is a thing to check by covering the hue rather than by taking the claim on faith. Cover it and the Day P&L column still has its direction markers, Settlement still has "Settled" and "Pending" beside the dots, and Flag still reads "risk" or "watch" inside the chips. About 8% of men cannot reliably separate the red from the green these use, and a printed or greyscale grid has no hue at all — so a presentation that spoke only in colour would simply not say anything to those readers.
All four are purely presentational: no state, no effects, no measurement. They render inside a virtualized body where the visible rows re-render on every scroll frame and on every streamed update, so anything that hooked, measured or memoized here would do it thousands of times a second for no gain.
The shared span contract
Every one of these props interfaces extends
Omit<HTMLAttributes<HTMLSpanElement>, "children">, so any other span attribute
you pass — className, title, aria-*, an event handler — spreads onto the
rendered <span>. Those props are not listed in the tables below; the tables
carry each component's own props.
The data-pretable-* attributes each component writes are its contract with
grid.css, not an input. They are applied after the spread, so a caller
cannot override them with something the value disagrees with.
PretableDelta
A signed numeric change: your formatted text, tinted by direction and prefixed with a direction marker.
import {
numberFormats,
PretableDelta,
type PretableColumn,
} from "@pretable/react";
interface Position extends Record<string, unknown> {
id: string;
symbol: string;
name: string;
dayPnl: number;
settled: boolean;
settlementState: string;
flag: "risk" | "watch";
}
const columns: PretableColumn<Position>[] = [
{
id: "dayPnl",
header: "Day P&L",
numberFormat: numberFormats.money({
currency: "USD",
signDisplay: "always",
}),
render: ({ row, formattedValue }) => (
<PretableDelta value={row.dayPnl}>{formattedValue}</PretableDelta>
),
},
];| Prop | Type | Required | Description |
|---|---|---|---|
value | number | yes | The signed number the direction is read from. It is not rendered — pass the display text as children. |
children | ReactNode | no | The already-formatted text to display. |
value is read for its sign and nothing else. Formatting is locale- and
currency-dependent, so it stays your decision: the component never calls
toLocaleString or toFixed on value, and never invents a string. Pass the
display text as children — above, formattedValue is the string the column's
numberFormat already produced.
The direction is up when value > 0, down when value < 0, and flat
otherwise. Both comparisons are explicit so that the third case exists: zero is
not a rise, and neither is -0 (what Math.round(-0.2) yields) nor NaN
(which compares false against everything). Painting any of those as a movement
would assert something the data does not contain, so all three render flat —
the MSFT row in the grid above holds exactly 0, and it takes the flat marker
rather than a caret.
The marker is an element rather than a ▲ / ▼ character, and flat gets a
minus rather than a third caret — the glyphs would re-render in whatever font
the active theme picked.
PretableStatus
A state: a coloured dot followed by its label.
import { PretableStatus, type PretableColumn } from "@pretable/react";
const settlementColumn: PretableColumn<Position> = {
id: "settlementState",
header: "Settlement",
render: ({ row }) => (
<PretableStatus tone={row.settled ? "positive" : "warning"}>
{row.settlementState}
</PretableStatus>
),
};| Prop | Type | Required | Description |
|---|---|---|---|
tone | PretableStatusTone | yes | Which state the row is in. neutral draws a dimmed dot. |
children | ReactNode | no | The state's label. |
PretableStatusTone is "positive", "negative", "warning", "info" or
"neutral".
children is optional to the type checker and required in practice. The dot is
drawn with content: "", so the label is the only part of a status that a
reader who is not separating hues — or any screen reader at all — can perceive.
A status rendered without one conveys its state by colour alone: invisible in
greyscale, unreadable to a colour-blind reader, and silent to a screen reader.
The component warns on the console when children is undefined, null or
"". That warning is not gated on a build flag — the package ships no
process.env reference at all — because a misconfiguration that survives to
production is exactly the one still worth reporting.
It fires once per process, not once per cell: these render on every scroll
frame, so a warning per render would be a firehose. The practical consequence is
that a second offending column is silent, so fix the one you are shown and check
the rest yourself. Note also that false and [] render nothing and do not
warn.
PretableBadge
A short label in a chip: a category, a flag, a state that is a noun rather than a measurement.
import { PretableBadge, type PretableColumn } from "@pretable/react";
const flagColumn: PretableColumn<Position> = {
id: "flag",
header: "Flag",
render: ({ row }) => (
<PretableBadge tone={row.flag === "risk" ? "negative" : "warning"}>
{row.flag}
</PretableBadge>
),
};| Prop | Type | Required | Description |
|---|---|---|---|
tone | PretableBadgeTone | no | Which tone the label takes. Omit it for a plain chip in the cell's own ink. |
children | ReactNode | no | The badge's label. A chip with nothing in it says nothing. |
The chip never tints its own fill, which is a contrast decision rather than a
stylistic one — see the rule in @pretable/ui's grid.css. Tinting the fill
with the label's own hue costs contrast on the pair and drops every tone below
4.5:1, so tone rides on the label's colour instead.
A toned chip also draws a small currentColor dot before its label. Dropping
the tinted fill fixed the contrast but cost something real: with every chip on
the same neutral ground, the only tone channel left was the label ink, and
scanning a column for "which rows are flagged" became reading rather than
spotting. The dot restores that as shape and colour at no cost to the label,
since a dot is a graphical object and owes 3:1 rather than 4.5:1. An untoned
chip draws no dot.
The dot is drawn in currentColor, so it is the same hue as the label. That
makes it a peripheral channel, not a greyscale one: it tells you at a glance
which rows carry a tone, but in greyscale it renders the same grey as the label,
and the label is still what distinguishes one tone from another.
So a toned chip and a PretableStatus both show a dot, and the chip is what
separates them — compare the Flag and Settlement columns above. A badge is a
noun the row is — a category, a flag — and reads as a bounded chip; a status
is a state the row is in, and reads as a dot beside plain text.
PretableBadgeTone is "positive", "negative", "warning" or "info".
There is deliberately no neutral member: a badge with no tone is the
neutral one, and it is what the prop's absence already produces. A second
spelling of the same state would be a value the stylesheet has no rule for. When
there is no tone the component leaves the tone attribute off entirely, and the
base rule is the neutral badge.
PretableEntity
An identity: a primary line with a quieter one beneath it, the shape almost every grid's first column takes.
import { PretableEntity, type PretableColumn } from "@pretable/react";
const symbolColumn: PretableColumn<Position> = {
id: "symbol",
header: "Position",
render: ({ row }) => (
<PretableEntity primary={row.symbol} secondary={row.name} />
),
};| Prop | Type | Required | Description |
|---|---|---|---|
primary | ReactNode | yes | The identifying line — a ticker, an ID, a name. |
secondary | ReactNode | no | The qualifying line beneath it. |
This component takes no children; both lines are props.
Omit secondary and the second element is not rendered at all — an empty one
still claims a line box and would grow every row in the column. The check is an
explicit undefined / null test rather than a falsy guard, because 0 and
"" are values a secondary line legitimately holds (a count, a code) and a
falsy guard would silently drop them.
The secondary line is subordinated by a token and a type size, never by an opacity — a translucent secondary cannot reach 4.5:1 and still read as secondary, which is how every hand-rolled version of this pattern has failed.
See also
- Cell renderers — the
renderhook these are returned from, and the memoization contract around it. - Custom rendering — grid-level renderers and wrappers.
- Token reference — the semantic colour tokens
grid.cssdraws these from.