Skip to main content

FormItem

FormItem wraps a label, a control, an optional description and an optional message. It renders Headless UI's Field, which generates the control id and wires for, aria-labelledby and aria-describedby between the parts, so no ids are needed. Its disabled and invalid reach every ZUI control inside through context.

import FormItem from '@zuilib/components/form-item'
import Label from '@zuilib/components/label'
import Description from '@zuilib/components/description'
import Message from '@zuilib/components/message'

Basic

Put the parts in any order; the wiring is by role. The control receives the field's id, the Label its for, and the Description and Message ids join the control's aria-describedby.

Loading example

Invalid

invalid sets data-invalid on the item and its Label / Description / Message, and becomes the default invalid of every control inside, so the control gets aria-invalid and the destructive border from one prop. Message accepts a string or any { message?: string } object, so a form library's field error can be passed straight through.

Loading example

Disabled

disabled disables every Headless UI control inside (they read it from the Field) and sets data-disabled on the item and its parts. Leave it unset to inherit from an enclosing Fieldset disabled.

Loading example

With a toggle

Any Headless UI control joins the Field: a Checkbox, Switch, Select, Listbox or Combobox is labelled and described the same way.

Loading example

Props

FormItemProps extends HTMLAttributes<HTMLDivElement> with:

PropTypeDefaultDescription
childrenrequiredReactNodeLabel, control, Description, Message in any order
disabledbooleaninheritedDisables every Headless UI control inside and sets data-disabled. Unset inherits Fieldset disabled; an explicit false overrides it
invalidbooleanfalsedata-invalid on the item and its parts; the default invalid of every control inside
classNamestringMerged last after space-y-2

The module also exports useFormItem() and FormItemContext, which give a custom control { disabled, invalid } of the enclosing item (null outside one), and the FormItemContextValue type.

import {useFormItem} from '@zuilib/components/form-item'

function ColourPicker(props) {
const item = useFormItem()
const invalid = props.invalid ?? item?.invalid ?? false
// ...
}

Slots

SlotElementNotes
[data-slot="form-item"]divRoot. data-invalid when invalid, data-disabled from Headless UI when disabled
[data-slot="label"]labelFrom Label; mirrors the item's data-invalid / data-disabled. Parts: label-required, label-dirty
[data-slot="description"]pFrom Description; same mirrored state
[data-slot="message"]pFrom Message; same mirrored state
/* Consumer CSS: tighter field spacing */
[data-slot="form-item"] > * + * {
margin-top: calc(var(--spacing) * 1);
}

Accessibility

  • Renders a Headless UI Field: the control gets a generated id, the Label a matching for (and the control aria-labelledby), and each Description / Message id is appended to the control's aria-describedby.
  • invalid propagates to the control as aria-invalid="true"; Message renders with role="alert" (pass role="status" for a polite announcement).
  • disabled is applied by Headless UI to every control inside, including a Fieldset disabled further up.
  • A control that sets its own id inside a FormItem needs a matching htmlFor on the Label (set both or neither).
  • Outside a FormItem, Label, Description and Message are plain elements: associate them with htmlFor / id / aria-describedby yourself.