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.

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 · 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

A form, end to endtsx
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>
  )
}
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).
  • /field-patterns — the typed text fields (email, password, phone…) built over TextField.
  • /date-picker — the calendar primitives the date fields bind.