Forms
Forms
Luc UI does not own form state — React Hook Form, TanStack Form or a reducer all work. It owns the part every product otherwise rebuilds: one field anatomy, one submit lifecycle, one error summary, and inputs that agree with the rest of the system.
import { Form, FormActions, FormSection, SubmitButton } from "@luc/ui/forms";Examples
Field anatomy
Label, description, control and message. The field owns the ids and the ARIA wiring, so a control can never be labelled by accident or by hand.
Ticker or venue-qualified symbol.
Settlement currency, whole units.
Value date, not trade date.
<Field label="Instrument" description="Ticker or venue-qualified symbol.">
<Input placeholder="BTC-USD" />
</Field>
<Field label="Notional" description="Settlement currency, whole units.">
<NumberInput placeholder="0" formatOptions={{ style: "decimal", maximumFractionDigits: 0 }} />
</Field>States
Six states, one message slot: description, validating, success, error, disabled, read-only. Only one of them is ever rendered, so the field never stacks four lines of text.
Helper text sits at --fg-muted, not disabled grey.
Checking…
Handle is available
Above the desk limit of 100
Locked by the workspace policy.
Computed from the instrument.
<Field label="Default" description="Helper text sits at --fg-muted, not disabled grey.">
<Input placeholder="Instrument" />
</Field>
<Field label="Validating" validating>
<Input defaultValue="acme" />
</Field>
<Field label="Verified" success="Handle is available">
<Input defaultValue="acme-research" />
</Field>
<Field label="Invalid" error="Above the drift threshold">
<Input defaultValue="140" />
</Field>Groups and sections
A real fieldset for a set of controls that answer one question; a responsive grid for fields that stand alone.
Markdown is supported.
<Fieldset legend="Delivery" description="Where the daily report is sent.">
<FieldGroup columns={2}>
<Field label="Channel">
<Select defaultValue="email">
<SelectTrigger aria-label="Channel">
<SelectValue />
</SelectTrigger>
<SelectContent>…</SelectContent>
</Select>
</Field>
</FieldGroup>
</Fieldset>Units are stated, never assumed
A percentage field requires a declared scale, because 0.12 is ambiguous and a silent conversion is how a 12% allocation becomes a 0.12% one.
Stored as a fraction: 0.12 is 12%.
Stored in percent points: 12 is 12%.
canonical allocation value: 0.12 (a fraction, not percent points)
<Field label="Target allocation" description="Stored as a fraction: 0.12 is 12%.">
<PercentageInput scale="fraction" value={allocation} onValueChange={setAllocation} />
</Field>
<Field label="Fee" description="Stored in percent points: 12 is 12%.">
<PercentageInput scale="percent" value={12} />
</Field>Specialised inputs
Password, one-time codes and tag lists are LUC components too — same shell, same focus language, same field wiring.
Stored locally; never sent by this demo.
Paste the whole code — it fills every slot.
Enter or comma commits; paste a list to add several.
<Field label="API secret" description="Stored locally; never sent by this demo.">
<PasswordInput autoComplete="new-password" placeholder="••••••••••••" />
</Field>
<Field label="Confirmation code" description="Paste the whole code — it fills every slot.">
<OtpInput length={6} groups={[3, 3]} />
</Field>One submit lifecycle
idle → submitting → success | error. The button owns the pending state, the form owns the outcome, and nothing here needs a form library.
<Form onSubmit={save}>
<FormSection title="Profile" description="How the workspace is identified in reports." columns={2}>
<Field label="Workspace" required>
<Input defaultValue="Macro research" required />
</Field>
<Field label="Analyst">
<Input placeholder="Name" />
</Field>
</FormSection>
<Button type="submit">Save preferences</Button>
</Form>Usage
import { Form, FormActions, SubmitButton } from "@luc/ui/forms";<Form onSubmit={save}>
<Field label="Run name">
<Input defaultValue="Momentum v3" />
</Field>
<FormActions>
<SubmitButton loadingText="Saving">Save</SubmitButton>
</FormActions>
</Form>Return a promise from `onSubmit` and the shell owns `submitting` and the error; return a status instead when the outcome is yours to decide.
API
Read from the published TypeScript API, so a prop that is not listed here does not exist and a default that is listed is the one the compiler sees.
| Export | Kind | Signature |
|---|---|---|
Form | 5 documented props | ({ children, className, onSubmit, onError, errors, error, errorMessage, noValidate, ...props }: FormProps) => import("@types/react").JSX.Element |
FormActions | 2 documented props | ({ align, sticky, className, ...props }: FormActionsProps) => import("@types/react").JSX.Element |
FormErrorSummary | 3 documented props | ({ className, always, title, errors: providedErrors, ...props }: FormErrorSummaryProps) => import("@types/react").JSX.Element | null |
SubmitButton | 8 documented props | ({ loadingText, children, disabled, ...props }: SubmitButtonProps) => import("@types/react").JSX.Element |
useFieldIdPrefix | export | (explicit?: string) => string |
useLucForm | export | () => FormStatusValue |
Form props
Type: FormProps
Values the compiler reads from the declaration.
| Prop | Type | Default | Notes |
|---|---|---|---|
error? | ReactNode | — | Form-level message. Rendered in the summary, never as a toast-only error. |
errorMessage? | ReactNode | — | Message used when a submit rejects without one of its own. |
errors? | FormErrorItem[] | undefined | — | Field errors for `FormErrorSummary`. Derived by the caller's form engine. |
onError? | ((error: unknown) => void) | undefined | — | Called after a submit rejects, for logging or analytics. |
onSubmit? | ((event: FormEvent<HTMLFormElement>) => void | FormStatus | Promise<void | FormStatus>) |… | — | Submit handler. Returning a promise puts the form into `submitting`; throwing (or rejecting) sets `error` and the summary. Return a `FormStatus` to decide the outcome yourself — a… |
FormActions props
Type: FormActionsProps
Values the compiler reads from the declaration.
| Prop | Type | Default | Notes |
|---|---|---|---|
align? | "start" | "end" | "between" | undefined | — | |
sticky? | boolean | undefined | — | Pins the row to the bottom of a scrolling container (a Sheet or a long settings page). Sticky rows are opaque, per the system overlay rule. |
FormErrorSummary props
Type: FormErrorSummaryProps
Values the compiler reads from the declaration.
| Prop | Type | Default | Notes |
|---|---|---|---|
always? | boolean | undefined | — | Render even when no submit has been attempted (e.g. an edit form). |
errors? | FormErrorItem[] | undefined | — | Explicit error list. Form engines with their own error tree (React Hook Form, TanStack Form) pass theirs here instead of pushing into the context. |
title? | string | undefined | — |
SubmitButton props
Type: SubmitButtonProps
Values the compiler reads from the declaration.
| Prop | Type | Default | Notes |
|---|---|---|---|
block? | boolean | undefined | — | |
leadingIcon? | ReactNode | — | |
loadingLabel? | string | undefined | — | |
loadingText? | ReactNode | — | Shown while submitting. Defaults to the button's own label. |
size? | "sm" | "md" | "lg" | null | undefined | — | |
tactile? | boolean | undefined | — | Press ink and pointer light. On by default — a filled control that answers the finger is the family's signature — and switchable off for print, dense toolbars or a surface that al… |
trailingIcon? | ReactNode | — | |
variant? | "danger" | "primary" | "accent" | "secondary" | "outline" | "ghost" | null | undefined | — |
Guidelines
Do
- Return a promise from `onSubmit` and let the shell own the pending state — `SubmitButton` already reads it.
- Keep your form engine, whatever it is, and let `errors` carry its result into the summary.
- Group a long form with `FormSection`, so the summary can name the part an error came from.
Don't
- Track `isSubmitting` beside the form: `useLucForm` publishes the same status the submit button is reading.
- Report a failed save in a toast alone — after pressing the button, the reader looks back at the form.
- Rely on native validation: the shell turns it off by default so the messages come from `Field`.
The lifecycle, in one place
- idle → submitting
- A promise from `onSubmit` sets the status; `SubmitButton` shows it and stays focusable.
- success
- The resolved promise returns the form to rest — and the page decides what to say about it.
- error
- A rejection or a returned status fills the summary from `errors` and `errorMessage`.
Accessibility
The summary appears after a failed submit rather than while the reader is typing, it is announced as an alert, and each row is a button that moves focus to the field that caused it. It deliberately does not take focus on mount, so it never competes with a form engine that focuses the first invalid control.
Related