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.
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 words.reading in a live region for a screen reader. |
read, with rows | The rows. |
read, with none | The head, and the caller's empty sentence in the room the rows would have taken. |
failed | An alert titled words.failed, carrying why. |
The empty answer keeps the head above it. A read that returned nothing has still told the reader what the columns of this list are, and a card dropped over them takes that away and puts a title where the list was.
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, Nothing, 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,
},
]
// The four sentences every table in the application says about itself, in one
// place. A catalog is the caller's dependency, so the words arrive as a value.
const words = {
reading: t('common.loading'),
search: t('common.search'),
none: t('common.noResults'),
failed: t('common.readFailed'),
}
export function Markets({ rows }: { rows: Rows<Market> }) {
return (
<Table
name="markets"
columns={columns}
id={(m) => m.id}
rows={rows}
words={words}
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 the table says, and what the caller says
name is a plural noun in the reader's words — markets, pools,
positions. It labels the scrolling region for a screen reader, and that is the
whole of what it does: it is prose, so it is translated, and an address built
out of it is one that moves with the language.
A row that opens is addressed by anchor, which is the table's own useId
prefix and the row's identity percent-encoded. Both halves matter. aria-controls
is a list separated by spaces, so a pair identified the way a venue writes one —
LUX / USDC — named four ids and reached none of them; and a screen holding a
summary beside the full list of the same thing has two tables that would
otherwise agree on every id in both.
empty is required, because "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.
words is the other half of that, and it goes the other way: name and empty
are what the caller says about its venue, and words is what the table says
about its own state — reading, searching and finding none, failing. Those four
are the same on every table in an application, so they are written once and
passed. They are props rather than English in the component because a surface
that reads a chain in one language and says "Loading" in another has published a
sentence nobody translated, and a component that reaches into a catalog has
taken that dependency on the caller's behalf.
A list, and a summary of one
A full list stands under a ruled row of tracked capitals. brief is the same
table as a summary: a filled band in the running register, and no sort control
in it. One prop says both, because they are one decision — a summary shows the
first few of something, so a heading that sorts would order rows the reader
cannot see the rest of.
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 under words.search. 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 draws words.none, which is a different sentence from the venue
being empty.
Which of the two a table is looking at is hiding(asked, rows.length): a query
hides rows only where there are rows to hide. With query handed in from a field
elsewhere, the reader's text outlives the switch from one list to the next, so a
tab whose venue holds nothing arrives with something already typed — and
words.none there would say markets exist and none of them answered.
query is for the screen that puts the field somewhere else. Four lists on four
tabs have one place for that box, and it is the row the tabs are on; given
query, the table draws no box of its own and filters on what it was handed.
The filtering stays here either way — the table is what knows which rows matched,
what to say when none did, and how that differs from a venue that holds none, and
moving the box out must not move any of that with it.
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.
Nothing
Nothing is the one line a list puts where its rows would be — a line, not a
card. A frame around one sentence makes the sentence the subject of the screen,
and the screen's subject is the list that is missing from it. It is exported for
the same reason Answer is: a screen can have nothing to list before there is a
table to list it in.
import { Nothing } from '@luxfi/ui/read'
<Nothing>No market has opened on this chain</Nothing>air is how deep the room around the line is, on the engine's spacing scale. It
defaults to a full list's; a summary passes a fifth of that, because it stands
five rows tall at most and the deep air would fill the rest with nothing.
Props
TableProps<R>
| Prop | Type | Description |
|---|---|---|
name | string | Plural noun for what the table lists. Names the scrolling region for a screen reader. |
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. |
words | { reading: string; search: string; none: string; failed: string } | What the table says about its own state. Required. |
empty | { title: ReactNode; note?: ReactNode } | What nothing means here. Required. |
brief | boolean | A summary of a list rather than the list: a filled band head, and nothing sorts. |
find | (row: R) => string | The text a row is found by. Given, a search box appears. |
query | string | What the reader is looking for, where the field that asks is not this table's. |
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. |
Nothing
| Prop | Type | Description |
|---|---|---|
air | SpaceTokens | How deep the room around the line is, as a rung of the spacing scale. Numbers are excluded: the engine draws one as pixels, off the scale a tenant retunes. |
children | ReactNode | The line. |
Making a reading
function useRows<R>(
of: string,
ask: () => Promise<R[]>,
why?: (e: unknown) => string,
): Rows<R>Rows is what the table takes and nothing else in the package produced one, so
every screen wrote the same effect by hand: a flag so a late answer cannot set
state on a screen somebody has left, a reset to reading when the question
changes, and a rejection turned into a sentence. useRows is those three lines,
once.
of says what the question is about — a pool id, a venue key, an address — and
the read runs again when it changes. It is a string rather than the reader
itself because a function literal is a new value on every render, and an effect
depending on one is a read that never stops.
why is how a rejection becomes the sentence under words.failed. The default
is the error's own message. A caller that can tell a refusal that answered from
a request that never completed should say so here, because those are different
failures and only one of them is about the venue.
This is a convenience over the reading, not a part of the table. Retrying,
polling, caching and paging are still the caller's, and a caller that reports a
failure as { at: 'read', rows: [] } produces exactly the fabrication the type
exists to prevent.
A moment in a row
function ago(at: Date | number, now: Date | number): readonly [number, Unit]An activity list is the one screen where a venue's condition is legible without
a chart: somebody can see in the timestamps whether anything happened today.
That only holds while the timestamps are current, so Ago works its figure out
again on a timer — a row that says 2m for an hour is worse than one that says
nothing, because it reads as fresh.
There is one timer for the page, not one per row. Fifty moments in a column each
holding their own would re-read at fifty different instants, and the column steps
raggedly one cell at a time instead of moving at once. watch(tell) is that
clock, exported for anything drawing a moment some other way — a countdown, a
line saying when a screen last read — so a second one is never started. It runs
only while something is watching it.
Which unit applies is arithmetic and lives in ago. How the unit is written is
a fact about a language and lives in figures,
which asks CLDR: English writes 10s and 1d, French 10min and 1j, Russian
10 мин with the space it puts there. So there is no s, m, h or d in
either module for anyone to translate.
const said = figures({ locale, currency })
const columns: Column<Crossing>[] = [
{ key: 'when', head: 'Time', cell: (c) => <Ago at={c.at} figures={said} /> },
]Ago renders a real time element: dateTime carries the machine-readable
moment, and the exact moment is on it as a title in the reader's own calendar
rather than in UTC.
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> — useRows above is
a convenience for the common shape of that, not the table doing it. 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.