Forms
One form layer for everything — useHaloForm bound straight to the controlled inputs. TanStack Form lives in the UI package (the shiki model), so you get the form runtime + zod for free — nothing to install. Write a schema, grab the bound fields, and the invalid / aria-describedby / error-text wiring is already done for you.
BetaThe form layer is Beta — the binding model is settled (useHaloForm · Form · bound fields · FormActions); the field roster is still growing.
A field is just a friendly wrapper around a controlled primitive: TextField wraps Input, SelectorField wraps Selector, GeoSelectorField wraps GeoSelector (that one ships from /geo so map libraries stay out of your bundle). Each one reads its field from context and handles the meta→props mapping once — the busywork every hand-rolled form repeats.
Demo
Try it live: validation runs as you type (zod), the submit button stays off until the form is clean, a failed submit jumps focus to the first invalid field, and the action row (FormActions) keeps the primary action on the right and Cancel on the left — automatically. Go ahead — submit it empty, then dirty a field and hit Cancel.
What ui owns
This layer has one job: delete per-form boilerplate. It owns:
- useHaloForm
- TanStack Form’s useAppForm, pre-bound to the field + form components. Validators take a zod / Standard Schema directly — no adapter.
- Form
- The one canonical <form> emitter (the no-bare-form-element rule steers every other <form> here). Wires submit, sets noValidate, focuses the first invalid control after a failed submit.
- TextField · TextAreaField · SelectorField · CheckboxField · SwitchField
- Friendly wrappers that compose the unchanged controlled primitives + HelperText, mapping field meta to invalid / error text (always paired with an icon, never color alone) and the label↔control↔description ARIA wiring.
- EmailField · PasswordField · SearchField · PhoneField · URLField · NumberField
- Typed patterns over TextField — input mode, autocomplete, and format validation (isE164, scorePassword) owned once. Documented at /field-patterns.
- DateField · DateRangeField · DateTimeField
- Calendar-bound fields over the date-picker primitives. Documented at /date-picker.
- GeoSelectorField
- The map picker, bound. Ships from the /geo subpath so d3-geo stays out of non-map bundles.
- SubmitButton · FormError · FormActions
- SubmitButton disables until canSubmit and spins while submitting; FormError surfaces a form-level (non-field) submission error; FormActions is the action row that places the primary action rightmost and Cancel leftmost by construction.
- useFieldContext · getFieldError
- The escape hatch for a custom bound field: read the field from context and reuse the same meta→error mapping the shipped wrappers use.
Usage
import {
Button, Form, FormActions, useHaloForm, TextField, SelectorField, SubmitButton, z,
} from '@halo-compliance/ui'
const schema = z.object({ org: z.string().min(2), tier: z.string().min(1) })
function CreateCustomer() {
const form = useHaloForm({
defaultValues: { org: '', tier: '' },
validators: { onChange: schema },
onSubmit: ({ value }) => api.createCustomer(value),
})
return (
<Form form={form}>
<form.AppField name="org">{() => <TextField label="Organization" />}</form.AppField>
<form.AppField name="tier">{() => <SelectorField label="Tier" options={TIERS} />}</form.AppField>
<form.AppForm>
<FormActions
// Placement is by construction: secondary renders leftmost,
// the primary child rightmost.
secondary={
<Button variant="ghost" tone="secondary" type="button" onClick={() => form.reset()}>
Cancel
</Button>
}
>
<SubmitButton>Create</SubmitButton>
</FormActions>
</form.AppForm>
</Form>
)
}// GeoSelectorField ships from /geo (keeps d3-geo off non-map forms).
import { GeoSelectorField } from '@halo-compliance/ui/geo'
import { usStates } from '@halo-compliance/ui/geo/us-states'
<form.AppField name="states">
{() => <GeoSelectorField label="Operating states" source={usStates} />}
</form.AppField>Accessibility
All the wiring you'd otherwise hand-roll, handled once:
- Label + description — every field associates its label and its error/hint to the control (htmlFor / aria-labelledby + aria-describedby), so screen readers announce the name and the current error together.
- Error is never colour-only — an invalid field's HelperText pairs the danger red with a CircleX icon and sets aria-invalid on the control, so the error never relies on color alone.
- Focus on first error — a failed submit moves focus to the first invalid control, owned by Form so no consumer re-implements it.
Related
- /input — the text input the TextField binds.
- /selector — the option picker SelectorField binds.
- /geo-selector — the map picker GeoSelectorField binds (ships from /geo).
- /field-patterns — the typed text fields (email, password, phone…) built over TextField.
- /date-picker — the calendar primitives the date fields bind.