Developer Experience (DX)
Next, let's look at how developers use the Field. It has one API for every control, with the names the controls already use, it's typed end to end, and it does its work through attributes, so none of the controls needs to know it's there.
import { useState } from "react";
import { Field } from "@/components/Field";
import { Input } from "@/components/Input";
export function Newsletter() {
const [email, setEmail] = useState("");
const [error, setError] = useState("");
return (
<Field id="email" label="Email address" hint="We'll only send you the newsletter" error={error} required>
<Input
type="email"
autoComplete="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
onBlur={() => setError(validateEmail(email))}
/>
</Field>
);
}
validateEmail stands in for your own check, which returns an error message, or an empty string when the address looks right.
Consistent
The Field's props are the ones the controls already have, or would have if they rendered their own label. These names mean the same thing everywhere in Open Components:
| Name | Kind | What it means |
|---|---|---|
id | Prop | The control's id, which its label, hint and error point at |
label | Prop | The text that names the control |
hint | Prop | Help that describes the control, the same as the Radio Group's |
error | Prop | What's wrong and how to fix it, which makes the control invalid while it's set, the same as the Radio Group's |
required | Prop | The control's native required |
disabled | Prop | The control can't be used |
size | Prop | xs, sm, md, lg or xl, with the same sizes everywhere |
The Input, the Textarea, the Checkbox and the Switch don't have a label, hint or error prop, so that each of them stays one control that fits any layout, and the Field puts them together. The Field also leaves a few props out, on purpose:
- There's no
optionalprop, or a marker forrequired. Which fields to mark is a decision about the whole form, so it's words you put in thelabel, like "Company (optional)". - There's no
invalidprop. An invalid control needs a message that says what to fix, soerroris what makes it invalid. - There's no
labelPosition. Where the label goes follows from the control, as under Controls, so every switch and every text control in a product looks the same. - There's no
nameorvalue, and nothing to bind. They belong to the control, which the form reads.
Type-safe
Props and slots are typed, so your editor can autocomplete them and your type checker (tsc, vue-tsc, svelte-check, ngc or astro check) catches mistakes like size="medium".
| Prop | Type | Default | Description |
|---|---|---|---|
id | string | The control's id. Without it, the control's own id, then its name | |
label | string | Names a text control or a Switch, as its <label> | |
hint | string | Help people need before they fill the control in | |
error | string | What's wrong and how to fix it. While it's set, the control is invalid | |
required | boolean | false | Sets the control's native required, except on a Checkbox Group |
disabled | boolean | false | Disables the control |
size | "xs" | "sm" | "md" | "lg" | "xl" | The text size, which also goes to a component control. Without it, the control's own |
| Slot | Description |
|---|---|
default | One control: an Input, a Textarea, a Checkbox, a Switch, a Checkbox Group, a Radio Group or a native <input>, <textarea> or <select> |
The Field fires no events of its own, since it holds no state. Listen to the control, as you would without a Field. We also export FieldProps, which comes in handy for data-driven forms:
import type { FieldProps } from "@/components/Field";
const fields: (FieldProps & { name: string })[] = [
{ name: "email", label: "Email address", required: true },
{ name: "company", label: "Company (optional)" },
];
Composable
The Field doesn't render the control. You do, and the Field adds what links it to its label, hint and error: an id, aria-describedby, aria-invalid, required and disabled, which every control already puts on its native element, and size, which every control already takes as a prop. None of them needs a prop, a context or any code that knows about the Field, and their own pages still hold inside one.
How the Field hands them over follows each framework's idiom. In React and Vue, it adds them to the one element you put inside it. In Angular, the Field is a directive on its own <div>, which finds the native control inside it and sets them there, so give the native control its id, since a static attribute stays on the Field's own element. Vanilla writes them out.
In Svelte, Solid and Astro, whose children have already rendered by the time the Field could change them, it hands them to a snippet or a function, which spreads them onto the control. The Field can't read that control's props, so give the Field its id, and spread what it hands you after your own attributes only where you want the Field's to win.
What you set yourself is kept, with a few rules for when you and the Field both set the same thing:
- The Field's
idwins over the control's, and the control'sid, then itsname, are used when the Field has none. aria-describedbyis joined: the Field's hint and error first, then whatever you set on the control.aria-invalidcomes from the Field while it has an error, and is yours otherwise. A Textarea over its limit stays invalid either way.sizefrom the Field wins over the control's, and the Field takes the control's own when it has none. A native<input>,<textarea>or<select>doesn't get it, since its ownsizeis a number of characters or rows.- Around a Radio Group, the Field's
hintanderrorwin over the group's own. It's best to set each one in one place.
Forms
The Field adds no element that a form reads, so the control's name and value reach the form the way they always do, and your form library binds the control, not the Field. That also means the label, the hint and the error are in the server's markup, and the form works before your JavaScript loads.
The error is yours to set, from whatever decides it: your own checks as people leave a control, the browser's validity on submit, or a response from your server. You'll find the whole flow, with focus and a status message on a failed submit, under Check values when people leave or submit.
With other components
The Field works with every form control in Open Components, and sets the same things on each one, in its own way:
| Control | What the Field sets |
|---|---|
| Input | id, aria-describedby, aria-invalid, required and disabled on its <input>, and its size prop |
| Textarea | The same, on its <textarea>, which adds its count to the description |
| Checkbox | The same, on its <input>. No label, since it has its own |
| Switch | The same, on its <input> |
| Checkbox Group | id, aria-describedby, aria-invalid and disabled on its <fieldset>, and its size prop. Never required |
| Radio Group | Its hint, error, required, disabled and size props |
The Select, on our Roadmap, will work with the Field the same way.
Controllable
The Field has no state of its own. Its label, hint and error are props, so it shows exactly what you pass it, and the control keeps its value, bound or not, as on its own page. When the error goes away, the control's invalid state and description go with it, in the same render, so the three can't disagree.
DX Checklist
| Rule | Level | Scope | Requirement | Basis | Check |
|---|---|---|---|---|---|
field/consistent-names | Should | Component | Its props use the names the controls use, like hint, error, required, disabled and size, with the same meanings | Open Components | Review |
field/typed | Should | Component | Its props and slot are typed, and FieldProps is exported | Open Components | Type check |
field/controls-unchanged | Must | Component | It works with every form control through the attributes they already take, without a prop, a context or code of theirs that knows about it | Open Components | Unit test |
field/own-attributes | Should | Component | An aria-describedby set on the control is kept after the Field's, and an aria-invalid set on it is kept while the Field has no error | Open Components | Unit test |
field/one-control | Should | Usage | A Field holds exactly one control | Open Components | Unit test |
field/stateless | Should | Component | It holds no state of its own, so it shows exactly the label, hint and error it's given | Open Components | Review |
FAQ
<select> or a control from another library never would. Attributes are what every control already takes, and they're in the server's markup, so the label works before any script runs.legend is a required prop, which the Radio Group's own page and checklist rely on, and making it optional would change its API. The legend stays on the group, as the Checkbox Group's stays in its slot, and the Field warns you in development if you give it a label around either.