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.

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, and a failed submit jumps focus to the first invalid field. Go ahead — submit it empty.

Plan tier
Operating states
0 of 51

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 · SelectorField · GeoSelectorField
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.
SubmitButton · FormError
SubmitButton disables until canSubmit and spins while submitting; FormError surfaces a form-level (non-field) submission error.

Usage

A form, end to endtsx
import { Form, 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><SubmitButton>Create</SubmitButton></form.AppForm>
    </Form>
  )
}
The geo field (from /geo)tsx
// 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.
  • /input — the text input the TextField binds.
  • /selector — the option picker SelectorField binds.
  • /geo-selector — the map picker GeoSelectorField binds (ships from /geo).