Advanced: data
A stateful table, a step wizard, markdown, a masonry wall, a guided tour, list-to-list transfer, a filter bar and the kanban. Each one solves a whole screen.
DataTable<T>
A stateful data table built on top of the headless Table. Adds client-side search, click-to-sort columns, and pagination, delegating all markup to the underlying Table.
import { DataTable, type DataTableColumn } from "tempest-react-sdk";
interface User {
id: number;
name: string;
email: string;
role: string;
}
const columns: DataTableColumn<User>[] = [
{ key: "name", header: "Name", sortable: true },
{ key: "email", header: "Email" },
{ key: "role", header: "Role", sortable: true, align: "right" },
];
<DataTable
data={users}
columns={columns}
searchable
pageSize={10}
initialSort={{ key: "name", direction: "asc" }}
rowKey={(row) => row.id}
emptyMessage="No users found"
/>;
| Prop | Type | Default | Description |
|---|---|---|---|
data |
T[] |
— | Full dataset; sort/filter/pagination happen client-side |
columns |
DataTableColumn<T>[] |
— | Column definitions |
pageSize |
number |
10 |
Rows per page |
searchable |
boolean |
false |
Render a search input above the table |
searchKeys |
(keyof T)[] |
— | Keys to search; default = string/number columns |
initialSort |
DataTableSort<T> |
— | Initial sort applied before any header interaction |
rowKey |
(row: T, index) => string \| number |
index | Stable key extractor for rows |
emptyMessage |
ReactNode |
— | Content shown when no rows match |
DataTableColumn<T> = { key: keyof T; header: ReactNode; render?: (row: T) => ReactNode; sortable?: boolean; align?: TableAlign; priority?: TablePriority; width?: string | number }. DataTableSort<T> = { key: keyof T; direction: "asc" | "desc" }.
Behavior
Clicking a sortable header cycles asc → desc → unsorted. Search matches a case-insensitive substring across searchKeys (or every string/number column when omitted). Pagination is hidden when the result fits on a single page.
Wizard
Multi-step flow: an indicator, one body at a time, and navigation that respects per-step validation. Stepper draws the indicator; Wizard owns the part every app was rewriting — the active index, the async gate before advancing, disabled/pending buttons and the completion call.
import { Button, FormField, Input, Wizard, useZodForm } from "tempest-react-sdk";
import { FormProvider } from "react-hook-form";
import { z } from "zod";
const schema = z.object({
name: z.string().min(2, "Enter a name"),
email: z.string().email("Invalid email"),
zip: z.string().min(5, "Incomplete ZIP"),
});
export function SteppedSignup() {
const form = useZodForm(schema, { defaultValues: { name: "", email: "", zip: "" } });
return (
<FormProvider {...form}>
<Wizard
onComplete={form.handleSubmit((values) => console.log(values))}
steps={[
{
id: "details",
label: "Details",
description: "Who the customer is",
validate: () => form.trigger(["name", "email"]),
content: (
<>
<FormField name="name" label="Name" required><Input /></FormField>
<FormField name="email" label="Email" required><Input type="email" /></FormField>
</>
),
},
{
id: "address",
label: "Address",
validate: () => form.trigger(["zip"]),
content: <FormField name="zip" label="ZIP" required><Input /></FormField>,
},
{
id: "review",
label: "Review",
content: ({ back }) => (
<>
<pre>{JSON.stringify(form.getValues(), null, 2)}</pre>
<Button variant="ghost" onClick={back}>Fix something</Button>
</>
),
},
]}
/>
</FormProvider>
);
}
| Prop | Type | Default | Description |
|---|---|---|---|
steps |
WizardStep[] |
— | The flow's steps. |
activeIndex |
number |
— | Controlled index. |
defaultActiveIndex |
number |
0 |
Initial index (uncontrolled). |
onStepChange |
(index, step) => void |
— | Called on every step change. |
onComplete |
() => void \| Promise<void> |
— | Called when the last step passes validation. |
nextLabel |
string |
"Next" |
Advance button label. |
backLabel |
string |
"Back" |
Back button label. |
finishLabel |
string |
"Finish" |
Label on the last step. |
optionalLabel |
string |
"(optional)" |
Suffix for an optional step in the indicator — override to localize. |
clickableSteps |
boolean |
false |
Allows jumping by clicking the indicator. |
renderActions |
(controls: WizardControls) => ReactNode |
— | Replaces the default button row. |
WizardStep = { id, label, description?, content, validate?, optional? } — content accepts a ReactNode or a function receiving the controls.
WizardControls = { activeIndex, step, validating, isFirst, isLast, next, back, goTo }.
Only the active step is mounted
Uncommitted input in a step you leave is lost unless the state lives outside (react-hook-form's FormProvider, a store, a parent useState) — which is where it belongs anyway, since the last step usually submits everything at once.
An async validate comes with a pending state
While the promise runs, the advance button is in loading and Back is disabled. A validate that throws counts as "not allowed": a gate wired to a network check should not strand the user on a half-advanced flow when the request fails.
clickableSteps is false on purpose
A wizard exists because order matters. With clickableSteps, jumping back is free (going back never blocks), but jumping forward validates every step crossed — the first gate that fails stops the jump right there.
Markdown
When to use: rendering text that came from people — a comment, a ticket description, release notes, a message body.
A Markdown subset: headings, paragraphs, lists (nested and ordered), blockquote, fenced code (through CodeBlock), thematic break, GFM pipe tables with alignment, and the usual inline set (**strong**, *em*, `code`, ~~del~~, links, images, autolinks, hard breaks).
import { Markdown } from "tempest-react-sdk";
<Markdown source={comment.body} linkProps={{ target: "_blank", rel: "noreferrer" }} />;
| Prop | Type | Default | What it does |
|---|---|---|---|
source |
string |
— | The Markdown. |
headingOffset |
number |
2 |
Level the document's # becomes. |
highlightCode |
boolean |
true |
Fenced code through CodeBlock (copy, line numbers). |
showLineNumbers |
boolean |
false |
Line numbers in fenced code. |
linkProps |
AnchorHTMLAttributes |
— | Extra props on every link. |
The safety is structural, not a promise about escaping
dangerouslySetInnerHTML does not exist in this component. The parser produces a node tree and the renderer turns it into React elements — and a React child can only be text. So <script>alert(1)</script> in a comment renders as the characters somebody typed, and so does <img src=x onerror=...>.
This is not "sanitized HTML": it is text. Which is why there is no sanitizer here and no allowed-tags list — there is no path for markup to enter.
URLs go through a scheme allowlist, not a blocklist
Links accept http, https, mailto, tel, sms and relative. Images accept the same plus raster data:image/ (png/jpeg/gif/webp/avif) — data:image/svg+xml is deliberately out: an SVG is a document, it carries <script> and event handlers.
[click](javascript:alert(1)) renders "click" as text: the link goes, the words stay. A blocklist would have to enumerate javascript:, JaVaScRiPt:, java\tscript:, \u0001javascript: — and would miss the one nobody thought of. An allowlist carries no such debt.
It is a subset, and that is the chosen ceiling
No embedded HTML, footnotes, definition lists, reference links ([a][b]) or task lists. If your case needs full CommonMark with plugins, reach for react-markdown + remark directly — that is 40 KB and a plugin chain the whole SDK does not pay for. The scope here is what a user comment uses.
# becomes h2 by default
A comment rendered inside a page whose h1 is the page title must not emit a second h1. headingOffset shifts the whole scale, and the component never goes past h6, so the document outline stays valid.
A wide table scrolls in its own box, and the box is reachable
The tab stop appears only while the overflow is real — a scroll area with nothing focusable inside is unreachable by keyboard, and adding the stop unconditionally would pollute the tab order with one entry per table.
Masonry
When to use: cards of uneven height with no order between them — a notes wall, a photo gallery, dashboard cards.
Measures the cards and deals each one into the shortest column, so the bottom edge is as even as the content allows.
import { Masonry, Card } from "tempest-react-sdk";
<Masonry items={notes} itemKey={(note) => note.id} columns={{ 0: 1, 640: 2, 1024: 3 }}>
{(note) => <Card title={note.title}>{note.body}</Card>}
</Masonry>;
| Prop | Type | Default | What it does |
|---|---|---|---|
items |
T[] |
— | What to lay out. |
children |
(item: T, index: number) => ReactNode |
— | Renders one card. |
columns |
number \| Record<number, number> |
{ 0: 1, 640: 2, 1024: 3 } |
A fixed number, or a width → columns map. |
itemKey |
(item: T, index: number) => string \| number |
index | Stable key per item. |
gap |
string |
--tempest-space-4 |
Space between cards. |
Why this is not one line of CSS
CSS columns breaks a card across the column boundary, and grid-auto-flow: dense keeps every row at the height of its tallest cell — which is exactly the ragged bottom edge people reach for masonry to avoid. Both are one line of CSS and neither does this job.
Reading order goes down the column, not across the row
Card 2 sits below card 1, not beside it. Which is why this layout is for independent items: a list where item 2 must follow item 1 wants a grid, not this. If order matters to your content, do not use masonry — not here, not in plain CSS.
The breakpoint map is about the container, not the viewport
A masonry inside a drawer or a two-column page is narrower than the window, and a media query would give it three columns at 300px wide. A ResizeObserver is what makes { 0: 1, 640: 2 } mean "of this container", which is the only useful reading.
Shortest column, not round-robin
index % columns is the obvious approach and produces ragged columns the moment items differ in height — which is the only reason to use masonry at all.
An image that loads later is re-measured
Every card is observed individually: a height measured at mount is wrong in exactly the case of an image still downloading. The first paint weights every card as 1 (so it is never blank) and the measured pass re-deals.
Tour
When to use: introducing a screen — first-run onboarding, a feature that moved, a flow nobody finds on their own.
Dims the page, highlights one element at a time and explains it. The highlighted element stays clickable.
import { Tour } from "tempest-react-sdk";
import { useState } from "react";
export function Orders() {
const [open, setOpen] = useState(!storage.get("tour-orders-v1"));
return (
<>
{/* … the screen … */}
<Tour
open={open}
steps={[
{ target: "#new-order", title: "Start here", body: "Every order begins with this button." },
{ target: "[data-tour='filters']", body: "And filter by period here.", placement: "right" },
]}
onClose={() => setOpen(false)}
onFinish={() => storage.set("tour-orders-v1", true)}
/>
</>
);
}
| Prop | Type | Default | What it does |
|---|---|---|---|
steps |
TourStep[] |
— | The stops, in order. |
open |
boolean |
— | Controlled by the app. |
onClose |
() => void |
— | Esc, the close button, "skip", or a click on the dim. |
onFinish |
() => void |
— | After the last step, before onClose. |
index / onIndexChange |
number / (i) => void |
internal | The app drives the current step, if it wants to. |
spotlightPadding |
number |
4 |
Space kept clear around the highlighted element. |
locale |
"pt-BR" \| "en" |
"pt-BR" |
Labels. |
TourStep = { target?, title?, body, placement? } · placement ∈ "top" | "bottom" | "left" | "right" | "center".
The highlighted element stays clickable — and that is what makes a coachmark useful
The dim is four rectangles around the target, not one overlay with a box-shadow hole. Because a shadow is not hit-testable: a hole made that way would block nothing — the rest of the page would stay clickable and the target would not. Four rectangles are the other way round, which is what "press this button" needs.
The target is a selector, not a ref
So a tour can be declared as data — in a config file, from the backend, next to the copy — without every screen threading refs up to whoever renders the tour.
A step whose target is missing shows centred, it does not vanish
That is the real case: a feature hidden by permission, a button that only exists with data. Dropping the step would hide its message silently, and skipping to the next could skip the whole tour.
Keyboard: arrows walk, Esc leaves, focus goes to the card
The card is role="dialog" + aria-modal, named by its title and described by its body, with a focus trap (useFocusTrap) and a visible "Step 2 of 5". Esc is handled on the card, not on window: a tour opened over a modal does not close both.
The card flips when it does not fit — and centres when nothing fits
It tries the preferred side, then the opposite one (which keeps the reading relationship with the target; jumping to a side would move the card across the screen for no visible reason), then the others. A card half off-screen is worse than a card in the middle — and that happens for real when the target is taller than the viewport.
Persisting 'already seen' is the app's business
The component takes open and emits onClose/onFinish. Writing the flag is one line in the app (storage.set) and would be a wrong default here — the key is versioned, scoped per user, and sometimes lives on the backend.
Transfer
When to use: picking a subset of a catalogue — a profile's permissions, cities on a route, members of a group, columns of a report.
Two panes, four move controls, a search box on each side. Controlled by the ids on the right; both panes are derived.
import { Transfer, type TransferItem } from "tempest-react-sdk";
import { useState } from "react";
const PERMISSIONS: TransferItem[] = [
{ id: "orders.read", label: "Read orders" },
{ id: "orders.create", label: "Create orders" },
{ id: "audit.read", label: "Read audit log", disabled: true },
];
export function ProfilePermissions() {
const [permissions, setPermissions] = useState<string[]>([]);
return (
<Transfer
items={PERMISSIONS}
value={permissions}
onChange={setPermissions}
sourceTitle="Available"
targetTitle="On the profile"
/>
);
}
| Prop | Type | Default | What it does |
|---|---|---|---|
items |
TransferItem[] |
— | The whole catalogue. Both panes come from it. |
value |
string[] |
— | Ids on the right. Controlled. |
onChange |
(value: string[]) => void |
— | Next value, always in catalogue order. |
sourceTitle / targetTitle |
ReactNode |
"Disponíveis" / "Selecionados" |
Each pane's heading. |
searchable |
boolean |
true past 8 items |
A search box on each pane. |
renderItem |
(item, side) => ReactNode |
item.label |
Custom row body. |
height |
string |
"16rem" |
Height of each pane's scroll area. |
locale |
"pt-BR" \| "en" |
"pt-BR" |
Labels and announcements. |
disabled |
boolean |
false |
Blocks every move. |
TransferItem = { id, label, searchText?, disabled?, data? }
Only the right-hand ids are state — the panes are derived
Storing two lists looks simpler and drifts the first time the catalogue changes underneath: a permission removed on the server lingers in whichever pane held it, and an id present on both sides is a bug nobody can see. With a single value, items is in charge: anything that left the catalogue simply disappears from both panes.
The move-all button respects the filter
Filtering by sao and clicking "move all" moves what you are looking at, not the whole pane. Moving the rows the filter hid is the kind of surprise that makes people stop trusting the button — and it was a real bug, caught by a test before the merge.
Search folds accents, both ways
sao finds "São Paulo" and so does são. For a PT-BR audience that is not a refinement: a plain includes would miss half the searches.
A disabled row does not move by any path
The check lives in applyMove, not in each of the four buttons — it is a mandatory permission, a locked seat. Which is why » moves "everything movable", not "everything".
Checks are cleared after a move
Otherwise the next click on the opposite button sends it all back, and the component looks like it is undoing itself.
The controls sit in the middle by grid order, but come last in the DOM
A keyboard reaching the buttons before it has seen what they move would have to go back; a screen reader would read "move checked to the right" with no idea what is checked. Each pane is a region named by its heading, and each move is announced in a role="status".
FilterBar
When to use: filtering an admin list — orders by status and period, users by role, invoices by due date.
Chips for the applied filters, plus a small editor to add another. Filters are combined with AND, flat.
import {
FilterBar,
filtersFromSearchParams,
filtersToSearchParams,
type Filter,
type FilterField,
} from "tempest-react-sdk";
const FIELDS: FilterField[] = [
{ name: "title", label: "Title", type: "text" },
{ name: "total", label: "Total", type: "number" },
{ name: "createdAt", label: "Created", type: "date" },
{
name: "status",
label: "Status",
type: "select",
options: [
{ value: "paid", label: "Paid" },
{ value: "sent", label: "Sent" },
],
},
];
export function Orders() {
// The set comes from the URL, so a shared link opens with the same filters.
const [filters, setFilters] = useState<Filter[]>(() =>
filtersFromSearchParams(new URLSearchParams(location.search), FIELDS),
);
const { data } = useQuery({
queryKey: ["orders", filters],
queryFn: () => api.get(`/orders?${filtersToSearchParams(filters)}`),
});
return <FilterBar fields={FIELDS} value={filters} onChange={setFilters} />;
}
| Prop | Type | Default | What it does |
|---|---|---|---|
fields |
FilterField[] |
— | Fields that can be filtered. |
value |
Filter[] |
— | Applied filters. Controlled. |
onChange |
(filters: Filter[]) => void |
— | Next set, combined with AND. |
actions |
ReactNode |
— | Next to the controls — "save this view", a counter. |
locale |
"pt-BR" \| "en" |
"pt-BR" |
Labels and descriptions. |
FilterField = { name, label, type, options?, operators?, placeholder? } · type ∈ "text" | "number" | "date" | "select" | "boolean"
Filter = { field, operator, value? } · operator ∈ eq · ne · contains · gt · gte · lt · lte · between · in · empty · notEmpty
Exported helpers: applyFilters, filtersToQueryParams, filtersToSearchParams, filtersFromSearchParams, describeFilter, operatorsFor.
Applying the filters
FilterBar produces Filter[]; evaluating them is yours. The SDK ships both ends, and you pick one by list size, not by taste.
Whole list in memory — applyFilters runs the eleven operators:
import { FilterBar, applyFilters, type Filter } from "tempest-react-sdk";
export function Orders({ orders }: { orders: Order[] }) {
const [filters, setFilters] = useState<Filter[]>([]);
const visible = useMemo(() => applyFilters(orders, filters), [orders, filters]);
return (
<>
<FilterBar fields={FIELDS} value={filters} onChange={setFilters} />
<DataTable data={visible} columns={COLUMNS} />
</>
);
}
Server-paginated listing — filtersToQueryParams, which speaks the tempest-fastapi-sdk dialect:
const params = filtersToQueryParams(filters);
params.set("page", String(page));
params.set("page_size", "20");
const { data } = useQuery({
queryKey: ["orders", filters, page],
queryFn: () => api.get(`/orders?${params}`),
});
| Operator | Param sent |
|---|---|
eq |
field (or field__iexact on the name column) |
ne |
field__ne |
contains |
field__icontains |
gt gte lt lte |
field__gt … field__lte |
between |
field__between twice, low value first |
in |
field__in, once per value |
empty / notEmpty |
field__isnull=true / =false |
The dialect is the default, not a law
The table above is the tempest-fastapi-sdk dialect. A different backend passes
options rather than reimplementing the encoder:
// The searchable column is `razao_social`, and `ne` is spelled the Django way.
const params = filtersToQueryParams(filters, {
substringColumns: ["razao_social"],
operatorSuffix: { ne: "__exclude" },
});
substringColumns— the columns whoseeqis emitted ascolumn__iexact. Default["name"], which is whatbuild_filter_conditionspecial-cases. Pass[]when your backend treats no column that way and aneqshould stay bare.operatorSuffix— merged over the default, so an override names only the operators that differ.
Through v0.44.0 both were closed module constants. A project whose column was
called nome or titulo could not get the treatment, and one without the
special case got an __iexact nobody asked for.
The backend must declare every key, or the filter fails silently
BasePaginationFilterSchema.get_conditions() only forwards fields the subclass declares. A status__ne the schema never mentions is dropped by FastAPI before the repository sees it — no error, no filtering, and the full list comes back looking like "the filter didn't take". Declare status__ne: str | None = None on the filter schema for every operator the screen offers.
applyFilters and the backend disagree on two points, deliberately
ne matches rows with no value. In SQL, column <> 'x' is NULL for a NULL column and the row drops out. On the client, "is not paid" also shows the orders with no status at all — which is what the chip promises.
empty matches blank text. __isnull on the server only matches NULL; a column storing "" instead of NULL answers differently on each side.
If one screen alternates between both modes, pick one per field and stay there.
eq is case-sensitive; contains is not
That is the alignment with the server: eq becomes WHERE column = value, and a case-insensitive client would quietly disagree with it. contains becomes icontains and is insensitive on both sides — and since it is the default operator for text fields, the friendly behaviour is what you get without asking.
Dates compare by day, and between is inclusive at both ends
A row stamped 2026-03-05T13:00:00Z matches eq 2026-03-05, and an inverted between (later date first) is normalised instead of matching nothing — someone who picked the end date first meant the range, not an empty list.
Flat AND, not a tree with OR — and that is the chosen ceiling
Nested groups ((a OR b) AND c) are a different component: they need a tree UI with a per-node operator and a different serialization. Trying to be both produces a builder that is clumsy at the 95% case — "status is paid, created after March, title contains nota". If you need nested OR, what you want is a real query builder, and it does not fit behind this API.
The filter set fits in the URL — and comes back from it
filtersToSearchParams writes status=eq:paid&total=between:10|90; filtersFromSearchParams reads it back, and there is a round-trip test. A filter set that cannot survive a reload is one people re-enter every time they open a link somebody sent them.
Whatever does not parse is dropped, not guessed at
A hand-edited URL is the normal way this input arrives. An operator the field does not offer (total=contains:1), an unknown field, a between with one end — all discarded. Rendering a chip the backend cannot evaluate would show a list that does not match what the chip claims.
The chip reads in words, and it is the same text a screen reader hears
"Status is Paid" — with the option's label, not its key (paid). The remove button uses the same sentence in its aria-label ("Remove filter: Status is Paid"), because a chip that says one thing to a sighted user and another to a screen reader is two different truths.
The input follows the field, not the operator
A date field gets a date picker even under between (two of them). Typing a date into a text box is the fastest way to produce a filter the backend cannot parse.
An incomplete filter only disables Apply
It is not an error to shout about — it is a half-filled form. Changing the operator clears the value, because a value carried across operators produces filters nobody meant to write.
Kanban
When to use it: a board of columns whose cards move between stages — backlog, sales pipeline, work orders by status.
Reorders within a column and moves across columns, by pointer or keyboard. The drag machine is useSortable — the board reimplements none of it.
import { applyKanbanMove, Kanban, type KanbanColumn } from "tempest-react-sdk";
import { useState } from "react";
export function BacklogBoard() {
const [columns, setColumns] = useState<KanbanColumn[]>([
{ id: "todo", title: "To do", cards: [{ id: "1", content: "Fix login" }] },
{ id: "doing", title: "Doing", cards: [] },
{ id: "done", title: "Done", cards: [], locked: true },
]);
return (
<Kanban
label="Backlog"
columns={columns}
onMove={(move) => setColumns((current) => applyKanbanMove(current, move))}
/>
);
}
| Prop | Type | Default | What it does |
|---|---|---|---|
columns |
KanbanColumn[] |
— | Columns with their cards, in display order. |
onMove |
(move: KanbanMove) => void |
— | Called once per committed move. You apply it. |
renderCard |
(card, column) => ReactNode |
the card content | Customizes the card body. |
label |
string |
"Quadro" |
Accessible name of the board. |
emptyLabel |
ReactNode |
"Nenhum card" |
Text for an empty column. |
cardRoleDescription |
string |
keyboard hint | Announced per card — override to localize. |
disabled |
boolean |
false |
Blocks all dragging. |
KanbanColumn = { id, title, cards, locked? } · KanbanCard = { id, content } · KanbanMove = { cardId, fromColumn, toColumn, toIndex }.
applyKanbanMove(columns, move) is the reducer that applies a move returning new arrays — exported because every consumer needs the same one, and it is where the off-by-one lives.
A locked column refuses drops but still lets cards leave
That is a "Done" column which takes no new work, but whose cards can still be pulled back.
A keyboard move can only target a position that holds a card
The move walks the index space of existing cards, so dropping into an empty column works by pointer but not by keyboard. That is a limitation of the current implementation, not a design choice: until column-switch keys land, the way around is to move into a column that already has a card and then reorder.
ARIA: one listbox per column, not one per board
Each column that has cards is a listbox named by its title, containing only options. A single board-wide listbox does not survive the markup a board needs — listbox requires option/group children and the column header in between breaks that ownership. An empty column is not marked as a listbox (zero options fails aria-required-children), and the header is a div, not a <header>: outside a sectioning element every <header> becomes a banner landmark — with three columns, three duplicate banners.
Recap
- Data:
DataTable<T>wraps the headlessTablewith client-side search, sort, and pagination. - All share the same controlled/uncontrolled patterns, expose keyboard A11y, and import from
tempest-react-sdk.