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...

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.

ReadingWhat the table draws
readingPlaceholder 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 rowsThe rows.
read, with noneThe caller's empty answer, in the caller's words.
failedAn 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-query
import '@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>

PropTypeDescription
namestringPlural noun for what the table lists. Names the region, the search box and the failure.
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.
empty{ title: string; note?: ReactNode }What nothing means here. Required.
find(row: R) => stringThe text a row is found by. Given, a search box appears.
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.

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.