A fee tier, a price range, a hook: a handful of values the screen already knows,
where what a person needs is the values side by side with what is true about
each. Choice puts all of them on screen at once and lets a keyboard move
between them.
Why the whole set stays on screen
Every field on a Lux surface takes text, which is what Field says about itself
and why it owns an input. A tier is not text. Typed into a box it becomes a
number to look up rather than a decision to read.
A menu would show one answer and hide the rest, and here the rest are the argument — a tier with no market beside a tier with one is the fact somebody is deciding on. So the options are a radio group: all of them visible, each carrying its own sentence.
Nothing is chosen on arrival
chosen is T | null, and null is the state this arrives in. No option is
preselected, because a default here would be this software choosing the fee
somebody pays for as long as their position exists.
null is not the first option. A control that arrived preselected would report
an answer nobody gave, which is the same substitution Rows and Figure refuse
for a value nobody read — and it is as hard to see here, because a preselected
radio looks exactly like a chosen one.
If chosen names a value that is not in options, nothing draws as selected
and the first option takes the tab stop. The component does not correct the
caller.
Where the reading goes
Choice takes no reading. Unlike Table and Tally it has no at to switch
on, and that is deliberate: the options are values the screen already knows —
the protocol's tiers are not read from a chain — while what is true about each
one is read.
So the reading is per option, in note, and @luxfi/ui/read already publishes
the type for one value.
type Figure =
| { at: 'reading' }
| { at: 'failed'; why: string }
| { at: 'read'; it: string | null }note is a ReactNode, so the caller renders that distinction itself. What the
note must not do is flatten it: a tier whose depth has not come back is not a
tier with no depth, and an outstanding read drawn as $0 reports an empty market
that nobody has measured. read carrying null is the one case a zero would be
honest about, and it is also the case only the caller can put into words — the
same demand Table makes with its required empty.
import { Choice, Mark, paint, type Figure } from '@luxfi/ui/read'
import { YStack } from '@luxfi/ui/gui'
const note = (depth: Figure) => {
switch (depth.at) {
case 'reading':
return <YStack width={88} height={8} borderRadius={4} backgroundColor={paint.pending} />
case 'failed':
return <Mark paint={paint.bad}>depth not read — {depth.why}</Mark>
case 'read':
return depth.it ?? 'no pools opened yet'
}
}
const options = tiers.map((tier) => ({
value: tier,
label: `${tier}%`,
note: note(depth[tier]),
}))The keyboard
Focus moves the way a radio group's does. Tab reaches the group once — the chosen option, or the first while nothing is chosen — and the arrows move within it, so a keyboard does not have to walk past four options to leave a question it has answered.
| Key | Where it lands |
|---|---|
ArrowDown, ArrowRight | the next option, wrapping past the last |
ArrowUp, ArrowLeft | the previous option, wrapping past the first |
Home, End | the first, the last |
Any other key is left alone, so typing and tabbing still work. Selection and focus move together, which is what a radio group promises and what separates it from a list: the arrows answer the question rather than browse it.
The rule is a pure function of the key, the index and the count, exported from the module and tested on its own, because getting it wrong is invisible: the selection still changes, so the control looks like it works, and what breaks is a keyboard reaching the last option — the one with the answer somebody wanted.
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 { Choice, type Option } 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 { Choice, type Option } from '@luxfi/ui/read'const [tier, setTier] = useState<Tier | null>(null)
<Choice
label="Fee tier"
help="Charged on every swap and paid to whoever holds the position."
options={options}
chosen={tier}
onChoose={setTier}
/>label names the group for a screen reader and help describes it, wired
through aria-labelledby and aria-describedby against ids the component
generates. Both take a ReactNode.
Props
| Prop | Type | Required |
|---|---|---|
label | ReactNode | no |
labelledBy | string | no |
help | ReactNode | no |
options | readonly Option<T>[] | yes |
chosen | T | null | yes |
onChoose | (value: T) => void | yes |
columns | number | no |
Option<T extends string> is what one answer is made of.
| Field | Type | Required |
|---|---|---|
value | T | yes |
label | ReactNode | yes |
note | ReactNode | no |
value is the identity React keys on and the argument onChoose receives.
label is what the answer is, in a word or two. note is what is true about it
— a spacing, a verdict from the registry, a price.
The question is named once. label draws it above the options; labelledBy
points the group at a heading already on the screen and draws nothing, so a
group under its own heading carries no second copy of the heading's words. One
or the other, never both — a second copy is where the two come apart.
columns defaults to two, and it is a cap rather than a track count. Each card
asks for a flex-basis of about 100 / columns percent over a 180-pixel floor
and is free to grow, so a phone gets one column, a wider screen gets two, and no
breakpoint decides when.
Selection is drawn twice over: a one-pixel outline in the text colour, and a
ring-and-dot mark built from two boxes rather than an icon. Hovering an unchosen
card lights the same outline, so the mark is what actually reports the answer and
the edge is only where the pointer is. Colour comes from paint — --color-text-primary,
--color-text-secondary, --color-border-divider — and the focus ring is the
design system's --ring.
What it does not do
Choice holds nothing. It has no internal selection, no default, and no memory
between renders: chosen comes in and onChoose goes out, and a caller that
ignores the second sees a control that never changes. It does not read, retry,
or know that a note is outstanding — the note is already-rendered markup by the
time it arrives, and a caller that draws a missing reading as a zero gets exactly
that on screen. It is not a native input, so a form does not carry its value
and there is no name to submit; the state is the caller's. It cannot disable an
option, take more than one answer, or hide any of them, which is the whole
premise: an option worth excluding is a set worth changing, and a set too long to
show is a menu, not a choice.