# 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`](/docs/grid/cell-renderers) hook. Here are all four at once, one to a
column:

### Example: 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.

Source: https://pretable.ai/examples/cell-presentations.md

```tsx CellPresentationsGrid.tsx
"use client";

import { PretableSurface } from "@pretable/react";

import { columns } from "./columns";
import { positions, type Position } from "./data";

const VIEWPORT_HEIGHT = 400;

export function CellPresentationsGrid() {
  return (
    <div>
      <p style={{ margin: "0 0 8px", fontSize: 13 }}>
        <strong>Position</strong> is a <code>PretableEntity</code>,{" "}
        <strong>Day P&amp;L</strong> a <code>PretableDelta</code>,{" "}
        <strong>Settlement</strong> a <code>PretableStatus</code>, and{" "}
        <strong>Flag</strong> a <code>PretableBadge</code> — none of the four
        speaks in colour alone.
      </p>
      <PretableSurface<Position>
        ariaLabel="Positions"
        columns={columns}
        getRowId={(row) => row.id}
        rows={positions}
        viewportHeight={VIEWPORT_HEIGHT}
      />
    </div>
  );
}
```

```tsx columns.tsx
import {
  numberFormats,
  PretableBadge,
  PretableDelta,
  PretableEntity,
  PretableStatus,
  type PretableColumn,
} from "@pretable/react";

import type { Position } from "./data";

export const columns: PretableColumn<Position>[] = [
  {
    id: "symbol",
    header: "Position",
    widthPx: 170,
    render: ({ row }) => (
      <PretableEntity primary={row.symbol} secondary={row.name} />
    ),
  },
  {
    id: "dayPnl",
    header: "Day P&L",
    type: "number",
    widthPx: 130,
    numberFormat: numberFormats.money({
      currency: "USD",
      signDisplay: "always",
    }),
    render: ({ row, formattedValue }) => (
      <PretableDelta value={row.dayPnl}>{formattedValue}</PretableDelta>
    ),
  },
  {
    id: "settlementState",
    header: "Settlement",
    widthPx: 130,
    render: ({ row }) => (
      <PretableStatus tone={row.settled ? "positive" : "warning"}>
        {row.settlementState}
      </PretableStatus>
    ),
  },
  {
    id: "flag",
    header: "Flag",
    widthPx: 90,
    render: ({ row }) => (
      <PretableBadge tone={row.flag === "risk" ? "negative" : "warning"}>
        {row.flag}
      </PretableBadge>
    ),
  },
];
```

```ts data.ts
export interface Position {
  id: string;
  symbol: string;
  name: string;
  dayPnl: number;
  settled: boolean;
  settlementState: string;
  flag: "risk" | "watch";
}

/**
 * Deliberately decorrelated: settlement state, day P&L direction, and flag
 * each vary independently across rows, so no single column's tone can be
 * read off another's.
 */
export const positions: Position[] = [
  {
    id: "p1",
    symbol: "NVDA",
    name: "NVIDIA Corp.",
    dayPnl: 8420.5,
    settled: true,
    settlementState: "Settled",
    flag: "watch",
  },
  {
    id: "p2",
    symbol: "TSLA",
    name: "Tesla, Inc.",
    dayPnl: -3190.25,
    settled: false,
    settlementState: "Pending",
    flag: "risk",
  },
  {
    id: "p3",
    symbol: "AAPL",
    name: "Apple Inc.",
    dayPnl: 1205.1,
    settled: true,
    settlementState: "Settled",
    flag: "risk",
  },
  {
    id: "p4",
    symbol: "META",
    name: "Meta Platforms, Inc.",
    dayPnl: -640.75,
    settled: true,
    settlementState: "Settled",
    flag: "watch",
  },
  {
    id: "p5",
    symbol: "MSFT",
    name: "Microsoft Corp.",
    dayPnl: 0,
    settled: false,
    settlementState: "Pending",
    flag: "watch",
  },
  {
    id: "p6",
    symbol: "AMZN",
    name: "Amazon.com, Inc.",
    dayPnl: 2755.4,
    settled: false,
    settlementState: "Pending",
    flag: "risk",
  },
];
```


**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>
    ),
  },
];
```

| 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.

```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>
  ),
};
```

| 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.

```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>
  ),
};
```

| 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.

```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} />
  ),
};
```

| 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](/docs/grid/cell-renderers) — the `render` hook these are
  returned from, and the memoization contract around it.
- [Custom rendering](/docs/grid/custom-rendering) — grid-level renderers and
  wrappers.
- [Token reference](/docs/theming/token-reference) — the semantic colour tokens
  `grid.css` draws these from.
