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:

Cell presentations

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.

.md

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.

tsx
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>
    ),
  },
];
PropTypeRequiredDescription
valuenumberyesThe signed number the direction is read from. It is not rendered — pass the display text as children.
childrenReactNodenoThe 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.

tsx
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>
  ),
};
PropTypeRequiredDescription
tonePretableStatusToneyesWhich state the row is in. neutral draws a dimmed dot.
childrenReactNodenoThe 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.

tsx
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>
  ),
};
PropTypeRequiredDescription
tonePretableBadgeTonenoWhich tone the label takes. Omit it for a plain chip in the cell's own ink.
childrenReactNodenoThe 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.

tsx
import { PretableEntity, type PretableColumn } from "@pretable/react";
 
const symbolColumn: PretableColumn<Position> = {
  id: "symbol",
  header: "Position",
  render: ({ row }) => (
    <PretableEntity primary={row.symbol} secondary={row.name} />
  ),
};
PropTypeRequiredDescription
primaryReactNodeyesThe identifying line — a ticker, an ID, a name.
secondaryReactNodenoThe 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 render hook these are returned from, and the memoization contract around it.
  • Custom rendering — grid-level renderers and wrappers.
  • Token reference — the semantic colour tokens grid.css draws these from.