Skip to main contentSkip to navigation

A labelled text input from @luxfi/ui/read. It renders the input itself, so the label always names a control.

Field is the control a Lux surface takes an entry in — an address, a quantity, a search term. It renders a label and an input together and binds them, which an Input on its own leaves to the caller.

A label reaches a control by id: htmlFor names the element carrying that id and nothing else. A field component that accepts the control as a child has to put the id somewhere it can reach, and what it can reach is its own wrapper, so the label ends up pointing at a <div> and the input inside keeps no name at all. The source records the state of the exchange screens this came from: twenty-five controls with no accessible name — no label, no aria-label, no placeholder — on addresses, quantities and settlement contracts, where the wrong entry loses money and a name read aloud is the thing that catches it.

Whoever holds the id has to be whoever renders the input, so Field renders the input. There is no arrangement of props that produces a label bound to nothing.

Loading...

Naming the control

id names the input. Pass one and Field puts it on the input and points the label at it; pass none and it generates one with useId and does the same. The help and error paragraphs take ids derived from it, so every part of a field carries a name that came from one place.

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 { Field, type FieldProps } 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 { useState } from 'react'
import { Field } from '@luxfi/ui/read'

export function Recipient() {
  const [to, setTo] = useState('')
  const amiss = to !== '' && !/^0x[0-9a-f]{40}$/i.test(to)

  return (
    <Field
      label="Recipient"
      help="An address on this network."
      placeholder="0x…"
      value={to}
      onChangeText={setTo}
      error={amiss ? 'That is not an address on this network.' : undefined}
    />
  )
}

Help and error

Both are tied to the input through aria-describedby, so a screen reader reads them with the field rather than stranding them beside it. The attribute is a list of ids in the order they are read: help, then error, then whatever the caller passed as its own aria-describedby, which is kept rather than replaced.

error is a sentence about what is wrong, and passing one also sets aria-invalid on the input and draws its border in the refusal colour. invalid is the same state without a sentence — an answer that fails a pattern, where the field is wrong and there is nothing to add. aria-invalid is omitted from FieldProps, so validity is whatever error and invalid say and cannot be set to disagree with them.

The optional mark

optional draws the word beside the label. It is asked for rather than derived from required, because the mark is a claim about the form it sits on: a screen where nothing is required marks nothing, and !required would mark every field.

A value, not a reading

The rest of @luxfi/ui/read takes a reading — reading, read, or failed with a reason — because what those components draw comes from a chain and an indexer, where "there is nothing" and "we could not ask" are a fact and a fabrication that look alike once both have been flattened into an empty list. A field's value does not come from there. It comes from the person in front of it, an empty entry is an empty entry, and there is no third thing for the type to carry. The reading, and the components that take one, are in Table.

Field ships in the same package because a surface that draws a reading also takes entries against it, and Table uses Field for its own search box.

Props

FieldProps is the engine's InputProps less aria-invalid, plus what follows. Anything not listed here reaches the engine's Input — placeholder, type, inputMode, value, disabled, readOnly and the rest.

PropTypeDescription
labelReactNodeWhat this control is for, read out with it. Required.
helpReactNodeWhat a person needs to know before answering.
errorReactNodeWhat is wrong with the answer. Saying so is also saying the control is invalid.
invalidbooleanWrong without a sentence about it.
optionalbooleanDraws optional beside the label.
onChange(e: React.ChangeEvent<HTMLInputElement>) => voidWhat a person typed. Declared here rather than inherited: the engine's InputProps is written for two platforms and omits a web change event, and a caller inheriting nothing got a parameter widened to any without a word from the compiler.
onChangeText(text: string) => voidThe same thing, already unwrapped.
idstringNames the input, and the help and error paragraphs beneath it. Generated when absent.

How it is drawn

Style props on gui elements — YStack, Label, Input, Paragraph — with colours taken from paint, the package's one table of token names. A field has its own family of them: the engine paints an input from its theme row, and the rows installed here resolve $background and $color to a near-white box holding near-black text, which is correct on paper and invisible on a dark surface. So the input reads --color-input-fg, --color-input-bg, --color-input-bg-readonly and the border tokens beside them, while the label and the notes read the text ladder. Those are separate values for the same roles, and a caller should not reach across. A read-only field keeps that ladder and changes ground; a disabled one draws its label and its text in the disabled ink. The label is a block with a stated leading, so it is a twenty-four pixel target on one line and grows on two. See gui.hanzo.ai for the engine and Theming for the token layer.

The other Field

@luxfi/ui exports a second component under this name, from the root and from @luxfi/ui/field. That one accepts a control as a child — an Input or an InputGroup — injects the id into it, and can float the label into the box. It is the design system's field; this one is the exchange's. The subpath is what selects between them.

What it does not do

It does not validate. Whether an answer is wrong is the caller's finding, handed in as error or invalid; there is no schema, no resolver, no submit and no form state. It takes text and only text: a select, a checkbox, a textarea or a group of controls is not this component, and the position the source takes is that a control which does not exist yet can have its own part rather than a hole in this one. It draws no required mark — optional is the only word it sets beside a label. And it reads nothing: the value comes from a person, so there is no fetching, retrying or caching here, and no reading for the type to carry.