Handbook
Forms
How to build a form with fields, a settings card, validation errors and a save bar.
Handbook
How to build a form with fields, a settings card, validation errors and a save bar.
ferry-ui gives the layout and the accessibility of a form. Your code holds the values and does the validation.
Field puts a label above a control, and a hint or an error below it. It connects them with the id and the ARIA attributes. Use it in a dialog, a popover or a sign-in form.
Put one control in a Field. For a control with many parts, such as Select, pass a function as the child. Spread its control argument on the part that gets the focus.
import { Button, Field, Input, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, toast } from 'ferry-ui'
export default function StackedFields() {
return (
<form
className="flex w-full max-w-sm flex-col gap-5"
onSubmit={(event) => {
event.preventDefault()
toast.success('Invitation sent')
}}
>
<Field label="Email" hint="The member gets a link by email.">
<Input type="email" name="email" placeholder="maya@example.com" />
</Field>
<Field label="Role" hint="An admin can manage billing and members.">
{/* A control with many parts: spread `control` on the part that gets the focus. */}
{(control) => (
<Select name="role" defaultValue="member">
<SelectTrigger {...control} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="member">Member</SelectItem>
<SelectItem value="viewer">Viewer</SelectItem>
</SelectContent>
</Select>
)}
</Field>
<Field label="Team" optional>
<Input name="team" placeholder="Design" />
</Field>
<Button type="submit" variant="primary" size="md" className="self-end">
Send invitation
</Button>
</form>
)
}A Button has type="button" by default. Set type="submit" on the button that submits the form.
Form Card is a card for one subject of a settings page. A FormRow has a label on the left and a control on the right. FormActions is the footer. Its Save button stays disabled until dirty is true.
import * as React from 'react'
import {
Checkbox,
FormActions,
FormCard,
FormRow,
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
toast,
} from 'ferry-ui'
const SETTINGS = { name: 'Billing portal', currency: 'eur', digest: true }
export default function SettingsSection() {
const [saved, setSaved] = React.useState(SETTINGS)
const [draft, setDraft] = React.useState(SETTINGS)
const dirty = draft.name !== saved.name || draft.currency !== saved.currency || draft.digest !== saved.digest
return (
<FormCard
title="General"
description="These settings apply to all the members of the project."
onSubmit={(event) => {
event.preventDefault()
setSaved(draft)
toast.success('Settings saved')
}}
footer={<FormActions dirty={dirty} onReset={() => setDraft(saved)} />}
>
<FormRow label="Name" description="The name shows in the list of projects." htmlFor="settings-name">
<Input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} />
</FormRow>
<FormRow label="Currency" description="The currency of new invoices." htmlFor="settings-currency">
{(control) => (
<Select value={draft.currency} onValueChange={(currency) => setDraft({ ...draft, currency })}>
<SelectTrigger {...control} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="usd">US dollar</SelectItem>
<SelectItem value="eur">Euro</SelectItem>
<SelectItem value="gbp">Pound sterling</SelectItem>
</SelectContent>
</Select>
)}
</FormRow>
<FormRow label="Weekly digest" description="A summary by email each Monday." htmlFor="settings-digest">
<Checkbox checked={draft.digest} onCheckedChange={(checked) => setDraft({ ...draft, digest: checked === true })} />
</FormRow>
</FormCard>
)
}Set htmlFor on a FormRow. The row then gives the id to its control. Do not set id, aria-describedby or aria-invalid on the control.
ferry-ui does not validate. Your code finds the errors and gives each message to a component.
| Problem | Component |
|---|---|
| One field is not valid | error on Field or FormRow |
| The submit failed | Callout with tone="destructive", near the submit button |
| A save failed | error on SaveBar |
In this demo, submit the empty form. Then enter a name. Submit again.
import * as React from 'react'
import { Button, Callout, Field, Input } from 'ferry-ui'
export default function ValidationErrors() {
const [name, setName] = React.useState('')
const [nameError, setNameError] = React.useState<string>()
const [submitError, setSubmitError] = React.useState<string>()
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
setSubmitError(undefined)
if (name.trim() === '') {
setNameError('Enter a project name.')
return
}
setNameError(undefined)
// A real app sends the form here. In this demo, the request always fails.
setSubmitError('The server did not save the project. Try again.')
}
return (
<form noValidate onSubmit={submit} className="flex w-full max-w-sm flex-col gap-5">
<Field label="Project name" hint="The members see this name." error={nameError}>
<Input value={name} onChange={(event) => setName(event.target.value)} />
</Field>
{submitError && (
<Callout tone="destructive" size="sm">
{submitError}
</Callout>
)}
<Button type="submit" variant="primary" size="md" className="self-end">
Create project
</Button>
</form>
)
}Save Bar is the footer of an editor or of a long form. The status is on the left. The buttons are on the right. With sticky, the bar stays at the bottom of the area that scrolls.
Each customer gets this text.
import * as React from 'react'
import { Card, CardContent, CardHeader, CardTitle, Field, SaveBar, Textarea } from 'ferry-ui'
const MESSAGE = 'Thank you for your order. The invoice is in the attachment.'
export default function LongForm() {
const [saved, setSaved] = React.useState(MESSAGE)
const [message, setMessage] = React.useState(MESSAGE)
const [saving, setSaving] = React.useState(false)
const empty = message.trim() === ''
function save() {
setSaving(true)
window.setTimeout(() => {
setSaved(message)
setSaving(false)
}, 1000)
}
return (
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Invoice email</CardTitle>
</CardHeader>
<CardContent>
<Field label="Message" hint="Each customer gets this text." error={empty ? 'Enter a message.' : undefined}>
<Textarea value={message} disabled={saving} onChange={(event) => setMessage(event.target.value)} />
</Field>
</CardContent>
<SaveBar
dirty={message !== saved}
invalid={empty}
saving={saving}
hint="All changes saved"
onReset={() => setMessage(saved)}
onSave={save}
/>
</Card>
)
}Each control fits one type of choice. A Switch applies a setting immediately. A Checkbox waits for the submit.
import { Checkbox, Field, Label, RadioGroup, RadioGroupItem, Switch } from 'ferry-ui'
export default function Choices() {
return (
<div className="flex w-full max-w-sm flex-col gap-5">
{/* One value among a few options. `labelAs="span"`: a group has no single control to focus. */}
<Field label="Billing period" labelAs="span">
<RadioGroup defaultValue="monthly">
<Label>
<RadioGroupItem value="monthly" /> Monthly
</Label>
<Label>
<RadioGroupItem value="yearly" /> Yearly
</Label>
</RadioGroup>
</Field>
{/* A choice that the form saves on submit. */}
<Label>
<Checkbox defaultChecked /> Send each invoice by email
</Label>
{/* A setting that applies immediately. */}
<div className="flex items-center gap-2">
<Switch id="choices-notifications" defaultChecked />
<Label htmlFor="choices-notifications">Email notifications</Label>
</div>
</div>
)
}| Need | Use |
|---|---|
| One line of text | Input |
| Many lines of text | Textarea |
| One value among 2 to 5 options | Radio Group |
| One value among 4 to 15 options | Select |
| Options with a description or an icon | Radio Card Group |
| One value in a long list | Command in a Popover |
| A setting that applies immediately | Switch |
| A choice that the form saves on submit | Checkbox |
| A compact switch between views | Toggle Group |
| A list of keys and values | Key Value Editor |
| An action with one click, such as Delete | ActionRow in a FormCard |