@exodus/datapoints-react
v2.1.0
Published
Headless React components for provenance-backed datapoint reports: unstyled, data-attribute driven, built on the @exodus/datapoints contract
Downloads
436
Maintainers
Keywords
Readme
@exodus/datapoints-react
Headless React components for provenance-backed datapoint reports. Renders the
@exodus/datapoints report payload — prose with value pills and a verify table
with per-datapoint audit panels — with zero styles.
Every visual state is exposed as a data-* attribute, so any design system
styles it with plain CSS.
Install
npm install @exodus/datapoints-reactReact 19 is a peer dependency. @exodus/datapoints (the payload contract)
comes along as a regular dependency.
Rendering a report
import { DataReport } from '@exodus/datapoints-react';
function Report({ payload, sourceText }: { payload: unknown; sourceText?: string }) {
return (
<DataReport.Root payload={payload} text={sourceText}>
<DataReport.Prose />
<DataReport.Table />
</DataReport.Root>
);
}payload is validated with the contract's reportPayloadSchema; an invalid
payload renders nothing and calls onInvalid. text is the placeholder
source ({{dp:id}} / {{lit:text}}) — when provided, DataReport.Prose
renders it with clickable value pills (onPillClick on the root receives the
datapoint id). When the prop is absent, the root falls back to the payload's
own optional text field, so a persisted payload that carries its source
renders prose and charts without the caller threading the text through.
DataReport.Prose renders a markdown subset so a report can be structured:
paragraphs (blank-line separated, soft breaks kept), bullet lists (- / * ),
GFM tables (header, |---| separator, rows), **bold** and *italic*. A value pill works
anywhere text does, including inside a table cell. Out of scope on purpose:
column alignment colons are accepted but not carried through, pipes cannot be
escaped, and anything else renders as plain text. The root is a
<div data-report-prose> (previously a <p>; a breaking change for consumers selecting on the tag); paragraphs are
<p data-prose-paragraph>, lists <ul data-prose-list>, tables
<table data-prose-table>.
Charts
A report puts series datapoints (value is an ordered list of { label, value }
points) on a chart with a fenced block the gateway has already validated:
```chart
kind: line
title: Monthly MTU
series:
- {{dp:custom_mtu_monthly_1a2b3c4d}}
```DataReport.Prose lifts the block out of the prose and renders it with Chart
(or StatTiles for kind: stat), looking each id up in the payload. Points
align by index and the first series supplies the category labels. A block that
fails to parse renders as plain prose rather than disappearing. A series id the
payload lacks, or that resolves to a scalar datapoint, is not drawn: the
<figure> carries data-missing with the space-separated ids and the legend
keeps an entry per missing id marked data-missing (no swatch, no
data-series-index), the same hook [data-value-pill][data-missing] offers.
Outside a chart, a series placeholder renders its formatted summary as an
ordinary pill, and the audit panel for a series row lists every point under
<ol data-series-points>.
Kinds and options
| Block | Rendering |
| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| kind: line | one [data-series-line] path and [data-point] markers per series, overlaid |
| kind: bar | one rect[data-point] per point per series, grouped per category from a zero baseline |
| kind: area | line plus a path[data-area] polygon closed along the baseline |
| kind: stat | StatTiles: a [data-stat-grid] of [data-stat-tile] KPI tiles instead of an svg |
| stacked: true | bars share one slot and areas nest; each series is offset by the cumulative total of the series listed before it, and the domain covers the stacked totals |
| orientation: horizontal | bars grow rightward from the zero baseline, categories run down the y axis, value ticks along the x axis |
| layout: multiples | one [data-chart-panel] per series in a [data-chart-panels] wrapper, each with its own svg, domain and unit; a [data-panel-title] replaces the legend |
| target: {{dp:id}} | a [data-target] reference line at the scalar value (it extends the domain), with the formatted value as an end label and a [data-legend-target] entry |
Figure attributes mirror the spec: data-chart-kind, data-chart-layout,
data-chart-stacked and data-chart-orientation are present when set.
Chart and StatTiles are also exported on their own:
import { Chart, StatTiles } from '@exodus/datapoints-react';
<Chart
spec={{ kind: 'bar', title: 'Revenue', stacked: true, series: [revenue.id, cost.id] }}
series={[
{ id: revenue.id, unit: revenue.unit, points: revenue.value },
{ id: cost.id, unit: cost.unit, points: cost.value },
]}
target={{ id: goal.id, value: goal.value, formatted: goal.formatted }}
onLegendClick={(id) => openAudit(id)}
/>;
<StatTiles
title="Headline"
entries={[{ id: mtu.id, datapoint: mtu }]}
onTileClick={(id) => openAudit(id)}
/>;The SVG is hand-drawn with no dependencies and no colors or sizes of its own:
it carries a viewBox (640×240) and scales to whatever width the consumer
gives it. Each series' stroke/fill is var(--chart-series-N, currentColor)
(N from 1), so a consumer sets up to eight series colors once:
[data-chart] {
--chart-series-1: var(--accent);
--chart-series-2: teal;
}
[data-chart] svg {
width: 100%;
height: auto;
}
[data-chart-kind='line'] [data-series-line],
[data-chart-kind='area'] [data-series-line] {
stroke-width: 2;
}
[data-chart-kind='area'] [data-area] {
fill-opacity: 0.18;
}
[data-target] line {
stroke: gray;
stroke-dasharray: 4 3;
}
[data-chart-panels] {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
[data-stat-grid] {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr));
}
[data-sparkline] {
width: 6rem;
height: 2rem;
color: var(--chart-series-1);
}
[data-axis] text {
font-size: 10px;
fill: gray;
}Vocabulary: <figure data-chart data-chart-kind> wraps <figcaption data-chart-title>,
a <div data-plot> holding the <svg role="img"> (labelled by the title or the
series ids) and, while a point is hovered, the tooltip, and <ul data-chart-legend>. Inside the svg: <g data-axis="y"> and <g data-axis="x">
hold [data-tick] entries; value ticks carry data-value, a [data-gridline] and
the value formatted with the series unit, category labels carry data-label and
are thinned to at most twelve on the x axis. Which axis carries values follows
the orientation. Each series is <g data-series data-series-index data-datapoint-id>
with a [data-series-line] path on line and area charts, a [data-area] polygon on
area charts, and one [data-point data-label data-value] marker or bar per point,
each with a <title> ("label · formatted value"; a stacked shape reports its own
value, not the running total). <g data-target data-value data-datapoint-id> holds
the reference line and its <text> label. Legend entries are
<button data-legend-item data-datapoint-id data-series-index> with a
[data-legend-swatch] and the datapoint id without its hash (data-missing in
place of the index and swatch, or data-legend-target for the target); clicking
one calls the root's onPillClick, so the same audit flow opens for a chart as
for a pill.
Interaction
Charts answer the cursor and the keyboard without a dependency, and expose every state as data attributes so the consumer decides what it looks like:
- A transparent
<rect data-hover-overlay>covers the plot. Moving or pressing a pointer over it snaps to the nearest point (line and area by point spacing, bars by slot; horizontal bars read the row from y). The<svg>and, for a single plot, the figure carrydata-hover-index; each[data-chart-panel]of a multiples chart hovers on its own. - While hovered the svg draws
<line data-crosshair>through the point (vertical, or horizontal for horizontal bars) and every marker or bar at that index getsdata-hovered. - A
<div data-tooltip role="status" aria-live="polite">follows the svg inside[data-plot], referenced by the svg'saria-describedby. It holds[data-tooltip-label](the point label) and one<div data-tooltip-row data-series-index data-datapoint-id>per series with a[data-legend-swatch],[data-tooltip-name](the id without its hash) and[data-tooltip-value](the point formatted with the series unit); a target adds a row withdata-tooltip-target. The tooltip's only inline style is two custom properties,--tooltip-xand--tooltip-y, the anchor as fractions of the svg box (the point x and the plot top; the plot middle and the row y for horizontal bars), so CSS positions it relative to[data-plot]. - The svg is focusable (
tabindex="0"): ArrowLeft/ArrowRight step the index, Home/End jump, Escape clears, and losing focus clears. Pointer leave clears. - Hovering or focusing a legend entry sets
data-highlight-serieson the figure anddata-dimmedon every other[data-series]; leaving or blurring clears it. Clicking still callsonPillClick.
Stat tiles have no hover state; the sparkline is decoration for the value.
[data-plot] {
position: relative;
}
[data-hover-overlay] {
cursor: crosshair;
}
[data-crosshair] {
stroke: gray;
stroke-dasharray: 2 2;
}
circle[data-point][data-hovered] {
r: 5;
}
[data-series][data-dimmed] {
opacity: 0.3;
transition: opacity 150ms;
}
[data-tooltip] {
position: absolute;
top: calc(var(--tooltip-y) * 100%);
left: clamp(6rem, var(--tooltip-x) * 100%, calc(100% - 6rem));
transform: translateX(-50%);
pointer-events: none;
}Stat tiles: <div data-chart data-chart-kind="stat" data-stat-grid> holds an
optional [data-chart-title] and one <article data-stat-tile data-datapoint-id>
per entry wrapping a <button data-stat-open> with [data-stat-label] (the
datapoint description, else its id without the hash) and [data-stat-value]
(a scalar's formatted, or a series' last value formatted with its unit). A
series tile adds <svg data-sparkline> (a [data-series-line] path and a
[data-sparkline-last] marker, no axes) and [data-stat-span] with the first
and last labels. A missing id renders the tile with data-missing and no value.
Tiles never derive numbers the payload does not carry.
The first cell of every expandable row carries a [data-row-caret] glyph
(› collapsed, ⌄ expanded) so the affordance exists unstyled; hide or
restyle it through the attribute.
The datapoint cell carries a ReviewMark: an inline SVG (shield for
human-reviewed, warning triangle for ai-reviewed) with the explanation in
its title, so it reads correctly with no CSS; style it through
[data-review-mark][data-tier].
Rows expand on click into an audit panel rendered as a <dl data-audit-content>
of <dt data-audit-label> / <dd data-audit-value> pairs, so it is legible
unstyled: description, freshness ("valid for 5m · retrieved 4:02 PM"), the SQL
query or formula, the review tier (with the reviewer's reasons for custom
queries and a data-ai-reviewed hook on every custom datapoint), and the
formula with its inputs for derived values.
Collapsed regions stay mounted so they can animate. That means the audit
content (query, formula, verdict) is always present in the rendered HTML; hidden
and inert are presentation, not access control, so do not rely on the collapsed
state to keep audit detail out of the DOM. Audit rows carry
[data-collapsible], get hidden + inert while collapsed and
data-expanded while open, and aria-controls always resolves.
The collapsible's only child is an unstyled <div> wrapping the audit content,
so a consumer can clip it without fighting the content's padding or border.
Unstyled, hidden keeps them out of the way; to animate, override [hidden]
and transition the wrapper:
[data-audit-row][hidden] {
display: table-row;
}
[data-collapsible] {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 200ms;
}
[data-collapsible] > * {
overflow: hidden;
min-height: 0;
}
[data-collapsible][data-expanded] {
grid-template-rows: 1fr;
}onExpandedChange(expandedIds, changed) receives { datapointId, expanded }
for the row that toggled, so a consumer can scroll the right region into view.
Styling
Everything is addressable through data attributes:
[data-report-table] tr[data-tier='ai-reviewed'] {
/* caution tint */
}
[data-report-table] tr[data-stale] [data-retrieved] {
color: orange;
}
[data-value-pill] {
font-variant-numeric: tabular-nums;
}
tr[data-expanded] {
background: color-mix(in srgb, currentColor 6%, transparent);
}Staleness is computed client-side on a live interval from each datapoint's
retrievedAt and freshness; a value past its freshness window gets
data-stale.
Columns
<DataReport.Table
columns={[
{ key: 'datapoint' },
{ key: 'value', render: (datapoint) => <strong>{datapoint.formatted}</strong> },
{ key: 'custom', id: 'unit', header: 'Unit', render: (datapoint) => datapoint.unit },
]}
/>Built-in columns: datapoint, value, type, source, retrieved (that
order is the default). A built-in column's render overrides its default
cell; custom columns add new ones.
Reference
| Export | Purpose |
| ------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| DataReport.Root | Validation, context, expansion state (expandedIds/onExpandedChange for controlled mode) |
| DataReport.Prose | Placeholder text with value pills |
| DataReport.Table / .Row / .AuditPanel | The verify table |
| Chart | Headless SVG line/bar/area chart over series datapoints (ChartProps, ChartSeries, ChartTarget) |
| StatTiles | Headless KPI tiles for scalar and series datapoints (StatTilesProps, StatEntry) |
| ReviewMark, REVIEW_MARK_TITLES | Tier icon with tooltip text, for custom cells |
| useRelativeTime | Live relative/absolute time + staleness for custom cells |
| DEFAULT_COLUMNS, ColumnConfig | Column configuration |
