Skip to main contentSkip to navigation

A table that takes a reading rather than an array, so a query that failed is not drawn as a venue with nothing in it.

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.

Loading...

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.

ReadingWhat the table draws
readingPlaceholder 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 rowsThe rows.
read, with noneThe head, and the caller's empty sentence in the room the rows would have taken.
failedAn 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-query
import '@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>

PropTypeDescription
namestringPlural noun for what the table lists. Names the scrolling region for a screen reader.
columnsColumn<R>[]The columns, in order.
id(row: R) => stringRow identity, stable across a re-read. An open detail follows its row through a sort or a search.
rowsRows<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.
briefbooleanA summary of a list rather than the list: a filled band head, and nothing sorts.
find(row: R) => stringThe text a row is found by. Given, a search box appears.
querystringWhat the reader is looking for, where the field that asks is not this table's.
action(row: R) => ReactNodeWhere 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>

PropTypeDescription
keystringReact's key, and the field a sort names.
headReactNodeThe heading.
cell(row: R) => ReactNodeWhat this column shows for a row.
numericbooleanRight edge, mono face, tabular figures, so a column of numbers reads down.
sort(row: R) => RankHow this column orders rows. Absent, the column does not sort.

Answer

PropTypeDescription
titleReactNodeThe statement.
alertbooleanrole="alert" and a left rule in the refusal colour.
childrenReactNodeThe note under the title.

Nothing

PropTypeDescription
airSpaceTokensHow 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.
childrenReactNodeThe 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.