Table is the list a Lux surface draws — markets, tokens, pools, holdings,
activity, positions. It differs from an ordinary table in what it accepts: not
rows, but a reading, which is what the caller knows about the rows at the moment
of render.
The distinction it exists for is that on a surface reporting what a chain and an indexer say, "there is nothing" and "we could not ask" are a fact and a fabrication. Once both have been flattened into an empty array they are the same value, and the reader has no way to separate them. A table that draws a refused query as zero rows reports an empty venue, and nothing about it looks wrong.
Switch the reading above the table to see what each outcome draws.
The reading
type Rows<R> =
| { at: 'reading' }
| { at: 'read'; rows: R[] }
| { at: 'failed'; why: string }Empty is not a fourth constructor. It is read carrying no rows: we asked, and
the answer was nothing. failed carries the reason and cannot be spelled as an
empty array, which is what puts the rows-plus-a-loading-flag mistake out of
reach — with that pair, [] is the same value whether the answer was nothing or
the question never got out.
Each outcome draws something different.
| Reading | What the table draws |
|---|---|
reading | Placeholder bars in the shape of the columns, hidden from the accessibility tree, with Reading {name}… in a live region for a screen reader. |
read, with rows | The rows. |
read, with none | The caller's empty answer, in the caller's words. |
failed | An alert titled The {name} could not be read, carrying why. |
The placeholder rows are as tall as the lines that will replace them and hold still, so the layout does not resize when the read lands.
Installation
npm install @luxfi/ui @hanzo/gui @hanzo/ui @hanzo/design @tanstack/react-queryimport '@luxfi/ui/tokens.css'
import '@hanzo/design/styles.css'
import { AppProvider } from '@luxfi/ui/provider'
import { Table, Answer, type Column, type Rows } from '@luxfi/ui/read'AppProvider goes once at the root of the app and mounts the gui engine and a
query client. The two sheets are where the names resolve: colour from
@luxfi/ui/tokens.css, spacing, elevation and measure from @hanzo/design.
Usage
import { Table, type Column, type Rows } from '@luxfi/ui/read'
type Market = { id: string; pair: string; price: bigint; volume: bigint }
const columns: Column<Market>[] = [
{ key: 'pair', head: 'Market', cell: (m) => m.pair, sort: (m) => m.pair },
{
key: 'price',
head: 'Price',
cell: (m) => quote(m.price),
numeric: true,
sort: (m) => m.price,
},
{
key: 'volume',
head: '24h volume',
cell: (m) => quote(m.volume),
numeric: true,
sort: (m) => m.volume,
},
]
export function Markets({ rows }: { rows: Rows<Market> }) {
return (
<Table
name="markets"
columns={columns}
id={(m) => m.id}
rows={rows}
empty={{
title: 'No market has opened yet',
note: 'The first listing will appear here.',
}}
find={(m) => m.pair}
action={(m) => <Link href={`/market/${m.id}`}>Trade</Link>}
/>
)
}The caller is what turns a query into a reading, and it is the only place the mapping can go wrong.
const q = useQuery({ queryKey: ['markets'], queryFn: readMarkets })
const rows: Rows<Market> = q.isPending
? { at: 'reading' }
: q.isError
? { at: 'failed', why: String(q.error) }
: { at: 'read', rows: q.data }What name names
name is a plural noun in the reader's words — markets, pools,
positions. It labels the scrolling region for a screen reader, labels the
search box, and titles the failure, so those three cannot drift apart as the
page is edited.
empty is required for a related reason. "No results" over an empty table is
the sentence a reader mistakes for a broken page, and only the caller knows
whether nothing means nobody has opened a market yet or this wallet holds none.
Sorting
A column sorts when it carries sort, and that one prop says both that the
heading is a control and what the control orders on. There is no way to declare
a sortable column without saying what it sorts.
sort returns a Rank — string | number | bigint | null — which is
deliberately not what cell draws. A cell renders a badge, an address, a price
with a currency on it; none of those compare. bigint is in the union because
chain quantities arrive as bigint and Number(1n << 64n) is lossy; compare
uses < across the two number kinds directly, where subtracting them would
throw.
null is the reading that does not exist, and it is not zero: a market with no
resting bid is not a market bid at nothing. A null rank sorts last whichever
way the column points, so a descending column does not gather every hole at the
top where it would read as the largest value in the venue.
Rows arrive in the order the caller read them and stay there — a table that opens sorted by volume has published an opinion about what matters, in the place a reader takes for the natural order. Clicking a heading cycles ascending, descending, and back to the given order, so that order stays reachable rather than being lost for the session. The sort is a copy, so the caller's array is never reordered, and it is stable, so ties keep the order they arrived in.
Searching
find returns the text a row is found by; given, a search box appears above the
table. It appears only over rows there are — a search box above an empty answer
offers a way to narrow nothing. A search that matches none of the rows says so
in its own words and states that every row read is still there, which is a
different sentence from the venue being empty.
Actions and detail
action renders in a trailing column with no heading and nothing to sort on,
because an action is not a value — a link into the thing, a button that prepares
a call.
detail is what a row opens to: a chart, a set of terms, anything too tall for
a cell and too particular to be a column every row pays for. It rides in the
same trailing cell as the action rather than taking a column of its own, and
what it opens is a row of its own beneath the row it belongs to, which is what
keeps a screen reader inside the table and a keyboard on the control it pressed.
detail.name is the word on every one of these controls; detail.label is what
this row's is called, since a page of controls that all read "Chart" is a page a
screen reader reads out as a dozen identical links.
Answer
Answer is the statement the table makes when it has no rows to draw, and it is
exported because a screen can fail before there is a table to fail in — an
endpoint nothing was configured with is the same shape of statement, and writing
it a second way would give one page two voices for "here is why there is
nothing".
import { Answer } from '@luxfi/ui/read'
<Answer alert title="The pools could not be read">
Nothing is drawn here because the request failed — not because there is
nothing. {why}
</Answer>alert is the whole difference between a failure and an empty answer: it sets
role="alert" and a left edge in the refusal colour. The two cannot be spelled
alike by accident.
Props
TableProps<R>
| Prop | Type | Description |
|---|---|---|
name | string | Plural noun for what the table lists. Names the region, the search box and the failure. |
columns | Column<R>[] | The columns, in order. |
id | (row: R) => string | Row identity, stable across a re-read. An open detail follows its row through a sort or a search. |
rows | Rows<R> | The reading. |
empty | { title: string; note?: ReactNode } | What nothing means here. Required. |
find | (row: R) => string | The text a row is found by. Given, a search box appears. |
action | (row: R) => ReactNode | Where a row leads, or what can be done to it. Trailing column. |
detail | { name: string; label: (row: R) => string; of: (row: R) => ReactNode } | What a row opens to, and what its control is called. |
Column<R>
| Prop | Type | Description |
|---|---|---|
key | string | React's key, and the field a sort names. |
head | ReactNode | The heading. |
cell | (row: R) => ReactNode | What this column shows for a row. |
numeric | boolean | Right edge, mono face, tabular figures, so a column of numbers reads down. |
sort | (row: R) => Rank | How this column orders rows. Absent, the column does not sort. |
Answer
| Prop | Type | Description |
|---|---|---|
title | ReactNode | The statement. |
alert | boolean | role="alert" and a left rule in the refusal colour. |
children | ReactNode | The note under the title. |
How it is drawn
The rest of @luxfi/ui is style props on gui boxes. This is not: rows and cells
are real tr and td elements carrying style objects, because a screen reader
needs them to say "row 4, price" rather than reading forty numbers in a line,
and a gui box is a flex container whose display union has no table-cell in
it. The parts the engine does have are the engine's — the search field, the two
answers, the sort control — and the colours are token names from
@luxfi/ui/tokens.css rather than literals. See
gui.hanzo.ai for the engine, and
Theming for the token layer.
What it does not do
It does not read anything. Fetching, retrying, polling and caching belong to the
caller, and so does the mapping from a query onto Rows<R> — a caller that
reports a failure as { at: 'read', rows: [] } produces exactly the fabrication
the type exists to prevent, and nothing in the component can catch that. Sorting
and searching are client-side over the rows handed in, so a venue larger than
one response needs its own paging above this component; there is no pagination,
no virtualization, no column resizing, no row selection and no server-side sort.
It does not choose an order, and it does not write the sentence for an empty
answer: both of those are opinions about the data, and the caller is the only
one holding enough to have them.