Skip to main contentSkip to navigation

One answer out of a few named ones, each carrying what was read about it.

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.

Loading...

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.

KeyWhere it lands
ArrowDown, ArrowRightthe next option, wrapping past the last
ArrowUp, ArrowLeftthe previous option, wrapping past the first
Home, Endthe 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-query
import '@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

PropTypeRequired
labelReactNodeyes
helpReactNodeno
optionsreadonly Option<T>[]yes
chosenT | nullyes
onChoose(value: T) => voidyes
columnsnumberno

Option<T extends string> is what one answer is made of.

FieldTypeRequired
valueTyes
labelReactNodeyes
noteReactNodeno

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.

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.