Field
When to use
Section titled “When to use”- Any form control that needs a visible label, a hint and an error message
- Validation errors that should be read out with the control
- Keeping label, description and error wired up without managing ids by hand
Not for a group of controls under one caption — use a native <fieldset> and <legend>, styled
with the fieldset classes.
Choosing between entering text:
| Component | Use it for | For example |
|---|---|---|
| Input | One line of text: a name, an email, a search | Email address, search box |
| OTP input | A code of fixed length, typed or pasted one character per box | SMS code, two-factor code, PIN |
| Textarea | Several lines of free text that may wrap | Comment, description, message |
| Combobox | Typing to find and pick from a list of known options | Choose a city, add existing tags |
| Field | Not a control: wraps any of these to tie a label, a hint and an error to it | Any form control with validation |
We send the receipt here.
Enter an email like ada@example.com.
Change it any time.
import { useState } from 'react';import { Field, Select } from '@arun-dev/ui';
const stack = { display: 'grid', gap: 'var(--space-md)', maxInlineSize: '20rem' };
export default function FieldBasics() { const [email, setEmail] = useState('ada@'); // Field validates nothing: invalid is yours — here, a simple check as you type. const invalid = email !== '' && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email);
return ( <div style={stack}> <Field.Root invalid={invalid} required> <Field.Label>Email</Field.Label> <Field.Control type="email" value={email} onChange={(event) => setEmail(event.target.value)} placeholder="you@example.com" /> <Field.Description>We send the receipt here.</Field.Description> <Field.Error>Enter an email like ada@example.com.</Field.Error> </Field.Root>
{/* Any control, through render. */} <Field.Root> <Field.Label>Plan</Field.Label> <Field.Control render={ <Select defaultValue="pro"> <option value="free">Free</option> <option value="pro">Pro</option> </Select> } /> <Field.Description>Change it any time.</Field.Description> </Field.Root>
<Field.Root disabled> <Field.Label>Company</Field.Label> <Field.Control defaultValue="Analytical Engines Ltd" /> </Field.Root> </div> );}Field ties a label, help text and an error message to one control, so a screen reader reads
them together. It generates the ids, and Field.Control puts them on the control.
Anatomy
Section titled “Anatomy”import { Field } from '@arun-dev/ui';
<Field.Root invalid={invalid} required> <Field.Label>Email</Field.Label> <Field.Control type="email" /> <Field.Description>We send the receipt here.</Field.Description> <Field.Error>Enter a valid email.</Field.Error></Field.Root>;| Part | Renders | Does |
|---|---|---|
Root | <div> | generates the ids; holds invalid, disabled and required |
Label | <label> | names the control; a press focuses it |
Control | the control | an Input by default; gets the id, aria-describedby, aria-invalid, disabled and required |
Description | <p> | help text, read out with the control |
Error | <p> | shown only while invalid, and read out with the control then |
Any control
Section titled “Any control”Field.Control renders an Input unless render gives another. The Field’s attributes are
merged onto it, so it works with every control in the system and with your own:
<Field.Control render={<Select>…</Select>} /><Field.Control render={<Textarea rows={4} />} /><Field.Control render={<Checkbox.Root />} /><Field.Control render={<Combobox.Input />} />The id is the Field’s. Don’t give the control an id of your own, or the label loses it.
Validation is yours
Section titled “Validation is yours”Field checks nothing. Set invalid from wherever you validate:
- react-hook-form:
invalid={!!fieldState.error}, and the message inField.Error. See a whole form with every control. - The browser:
requiredon the Root makes the controlrequired, so the browser stops the form and shows its own message.
A group of controls
Section titled “A group of controls”A group of controls under one caption, such as radios or checkboxes, is a native <fieldset>
with a <legend>. The browser names the group by its legend, and disabled on the fieldset
disables every control in it. Field ties a label to one control, so it is not used here; four
classes draw the group as a Field is drawn, from the same --field-* tokens:
| Class | On | Drawn as |
|---|---|---|
.fieldset | the <fieldset> | a Field, unframed |
.fieldset-legend | its <legend> | Field.Label |
.fieldset-description | a <p> inside it | Field.Description |
.fieldset-error | a <p> inside it | Field.Error |
import { useState } from 'react';import { Button, Checkbox } from '@arun-dev/ui';
const row = { display: 'inline-flex', alignItems: 'center', gap: 'var(--space-2xs)' };const options = ['Email', 'SMS', 'Push'];
// A group of controls under one caption: a native <fieldset> and <legend>, drawn as a Field is.// The error is tied to the group with aria-describedby, and shown only while it applies.export default function FieldGroup() { const [picked, setPicked] = useState<string[]>([]); const [tried, setTried] = useState(false); const invalid = tried && picked.length === 0; return ( <form style={{ display: 'grid', gap: 'var(--space-sm)', justifyItems: 'start' }} onSubmit={(event) => { event.preventDefault(); setTried(true); }} > <fieldset className="fieldset" aria-describedby={invalid ? 'field-group-hint field-group-error' : 'field-group-hint'} > <legend className="fieldset-legend">Notify me by</legend> <p id="field-group-hint" className="fieldset-description"> Pick one or more. </p> {options.map((option) => ( <label key={option} htmlFor={`field-group-${option}`} style={row}> <Checkbox.Root id={`field-group-${option}`} checked={picked.includes(option)} onCheckedChange={(checked) => setPicked((list) => checked === true ? [...list, option] : list.filter((o) => o !== option), ) } > <Checkbox.Indicator /> </Checkbox.Root> {option} </label> ))} {invalid && ( <p id="field-group-error" className="fieldset-error"> Choose at least one. </p> )} </fieldset> <Button type="submit" variant="primary"> Save </Button> </form> );}Tie the description and the error to the group yourself, with aria-describedby on the
fieldset, naming only what is shown. A RadioGroup can be the fieldset itself:
<RadioGroup.Root render={<fieldset />} className="fieldset">.
Field.Root
Section titled “Field.Root”| Prop | Type | Default | Description |
|---|---|---|---|
invalid | boolean | false | Shows the Error and marks the control aria-invalid. Yours to set: Field validates nothing. |
disabled | boolean | false | Disables the control. |
required | boolean | false | Marks the control required, so the browser's own validation reports it. |
className | string | Concatenated with the component's own classes, never replacing them. | |
render | ReactElement | Element or component to render instead of the default. Props, className, event handlers and ref are merged onto it. | |
ref | Ref<HTMLElement> | Ref to the rendered element. Merged with any ref on the render element. | |
…rest | HTMLAttributes | Anything else is spread onto the rendered element, so id, aria-*, data-* and event handlers all reach the DOM. |
Field.Control
Section titled “Field.Control”| Prop | Type | Default | Description |
|---|---|---|---|
render | ReactElement | <Input /> | The control: <Select />, <Textarea />, <Checkbox.Root />, <Combobox.Input /> or your own. Gets the id, aria-describedby, aria-invalid, disabled and required. |
Every other prop goes to the control. Field.Label, Field.Description and Field.Error take
the props of the element they render, plus render and ref.