Patterns
Form Card
A card for a settings form, with one row for each setting and a footer that saves.
Patterns
A card for a settings form, with one row for each setting and a footer that saves.
import * as React from 'react'
import { FormActions, FormCard, FormRow, Input, toast } from 'ferry-ui'
const INITIAL = { name: 'Billing portal', email: 'billing@example.com' }
export default function FormCardHero() {
const [saved, setSaved] = React.useState(INITIAL)
const [draft, setDraft] = React.useState(INITIAL)
const [saving, setSaving] = React.useState(false)
const dirty = draft.name !== saved.name || draft.email !== saved.email
function save(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
setSaving(true)
// Stands for a request to the server.
window.setTimeout(() => {
setSaved(draft)
setSaving(false)
toast.success('Settings saved')
}, 1000)
}
return (
<FormCard
className="mx-auto w-full max-w-2xl"
title="Project"
description="These settings apply to each member of the project."
onSubmit={save}
footer={<FormActions dirty={dirty} saving={saving} onReset={() => setDraft(saved)} />}
>
<FormRow label="Name" description="The name shows in the list of projects." htmlFor="project-name">
<Input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} />
</FormRow>
<FormRow label="Billing email" description="The app sends the invoices to this address." htmlFor="project-email">
<Input type="email" value={draft.email} onChange={(event) => setDraft({ ...draft, email: event.target.value })} />
</FormRow>
</FormCard>
)
}FormCard for a settings form. Each FormRow holds one setting.noValidate. Check the values in onSubmit.tone="destructive" card at the bottom of the page.Import the parts and put them together.
import { ActionRow, Button, FormActions, FormCard, FormRow } from 'ferry-ui'
<FormCard title="" description="" footer={<FormActions dirty={false} />}>
<FormRow label="" description="" htmlFor="">
{/* one control */}
</FormRow>
</FormCard>
<FormCard asDiv>
<ActionRow title="" description="" action={<Button />} />
</FormCard>| Part | Role |
|---|---|
FormCard | The card. It holds the header, the rows and the footer. |
FormRow | One setting. The label is on the left and the control is on the right. |
FormActions | The content of the footer: a status, Cancel and Save. |
ActionRow | One action. The text is on the left and one button is on the right. |
Pass the message to error on the row. After a submit that fails, set invalid on FormActions. Save stays enabled. The user corrects the field and submits again.
import * as React from 'react'
import { FormActions, FormCard, FormRow, Input, toast } from 'ferry-ui'
const SLUG = /^[a-z0-9-]+$/
export default function FormCardValidation() {
const [saved, setSaved] = React.useState('billing-portal')
const [slug, setSlug] = React.useState('Billing portal')
const [submitted, setSubmitted] = React.useState(false)
const error = submitted && !SLUG.test(slug) ? 'Use lowercase letters, digits and dashes only.' : undefined
function save(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
// The form has `noValidate`: the code does the validation on submit.
setSubmitted(true)
if (!SLUG.test(slug)) return
setSaved(slug)
setSubmitted(false)
toast.success('Settings saved')
}
return (
<FormCard
className="mx-auto w-full max-w-2xl"
title="Workspace"
onSubmit={save}
footer={<FormActions dirty={slug !== saved} invalid={error !== undefined} />}
>
<FormRow label="Slug" description="The slug is a part of the workspace URL." htmlFor="workspace-slug" error={error}>
<Input mono value={slug} onChange={(event) => setSlug(event.target.value)} />
</FormRow>
</FormCard>
)
}With htmlFor, the row gives the id and the ARIA attributes to one control. For a Select, pass a function and spread control on the trigger.
For a group, leave out htmlFor. The control object then has aria-labelledby.
The defaults for new invoices.
import {
FormCard,
FormRow,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
ToggleGroup,
ToggleGroupItem,
} from 'ferry-ui'
export default function FormCardComposite() {
return (
<FormCard asDiv className="mx-auto w-full max-w-2xl" title="Invoices" description="The defaults for new invoices.">
<FormRow label="Currency" description="The currency of each new invoice." htmlFor="invoice-currency">
{/* With `htmlFor`: `control` has the id and the ids of the description and of the error. */}
{(control) => (
<Select defaultValue="eur">
<SelectTrigger {...control} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value="eur">Euro (EUR)</SelectItem>
<SelectItem value="usd">US dollar (USD)</SelectItem>
<SelectItem value="gbp">Pound sterling (GBP)</SelectItem>
</SelectContent>
</Select>
)}
</FormRow>
<FormRow label="Payment terms" description="The time that a customer has to pay an invoice.">
{/* No `htmlFor`: `control` also has `aria-labelledby`, which names the group. */}
{(control) => (
<ToggleGroup {...control} type="single" variant="outline" defaultValue="30">
<ToggleGroupItem value="14">14 days</ToggleGroupItem>
<ToggleGroupItem value="30">30 days</ToggleGroupItem>
<ToggleGroupItem value="60">60 days</ToggleGroupItem>
</ToggleGroup>
)}
</FormRow>
</FormCard>
)
}With asDiv, the card is a <div> and not a form. Use it for values that the user copies and for settings that save on a change. headerActions puts a badge or a small button at the right of the header.
Use these values to call the API from your server.
import { CopyField, FormCard, FormRow, SecretField, StatusBadge } from 'ferry-ui'
export default function FormCardReadOnly() {
return (
<FormCard
asDiv
className="mx-auto w-full max-w-2xl"
title="API access"
description="Use these values to call the API from your server."
headerActions={<StatusBadge tone="success" label="Active" size="sm" />}
>
<FormRow label="Project ID" htmlFor="api-project-id">
<CopyField value="prj_7Hq2kLx9Vd3mN4" what="project ID" />
</FormRow>
<FormRow label="Secret key" description="Keep this key on the server." htmlFor="api-secret-key">
<SecretField value="sk_demo_4f9a2c7e1b8d3f6a" what="secret key" />
</FormRow>
<FormRow label="Plan">
<span className="text-sm text-foreground">Pro</span>
</FormRow>
</FormCard>
)
}Set layout to vertical to put a wide control below its label. controlClassName sets the width of the control column.
import { FormCard, FormRow, Input, Textarea } from 'ferry-ui'
export default function FormCardLayout() {
return (
<FormCard asDiv className="mx-auto w-full max-w-2xl" title="Invoices">
<FormRow
label="Payment terms"
description="The number of days that a customer has to pay."
htmlFor="terms-days"
controlClassName="max-w-24"
>
<Input type="number" defaultValue="30" className="tabular" />
</FormRow>
<FormRow
layout="vertical"
label="Footer note"
description="The text at the bottom of each invoice."
htmlFor="invoice-note"
>
<Textarea defaultValue="Thank you for your order. Send your questions to billing@example.com." />
</FormRow>
</FormCard>
)
}FormActions enables Save only while dirty is true. Set saving while the request is in progress. Pass onReset to show Cancel.
hint shows a note while the form has no change. saveLabel replaces the text of Save.
In a card with asDiv, pass onSave. In a form, Save submits the form.
import * as React from 'react'
import { FormActions, FormCard, FormRow, Input, toast } from 'ferry-ui'
export default function FormCardFooter() {
const [saved, setSaved] = React.useState('500')
const [limit, setLimit] = React.useState('500')
return (
// The card is a `<div>`: Save calls `onSave`, it does not submit a form.
<FormCard
asDiv
className="mx-auto w-full max-w-2xl"
title="Usage limits"
footer={
<FormActions
dirty={limit !== saved}
hint="The limit applies to new requests only"
saveLabel="Apply limit"
onReset={() => setLimit(saved)}
onSave={() => {
setSaved(limit)
toast.success('Limit applied')
}}
/>
}
>
<FormRow label="Requests per minute" htmlFor="limit-requests" controlClassName="max-w-32">
<Input type="number" value={limit} onChange={(event) => setLimit(event.target.value)} className="tabular" />
</FormRow>
</FormCard>
)
}An ActionRow has no value to save. Its button runs the action on a click.
The sessions and the data of your account.
Sign out everywhere
Export your data
import { ActionRow, Button, FormCard, FormRow, Switch, toast } from 'ferry-ui'
import { Download, LogOut } from 'lucide-react'
export default function FormCardActionRows() {
return (
<FormCard asDiv className="mx-auto w-full max-w-2xl" title="Security" description="The sessions and the data of your account.">
<FormRow
label="Two-factor authentication"
description="The app asks for a code when you sign in."
htmlFor="security-two-factor"
>
<Switch defaultChecked />
</FormRow>
<ActionRow
title="Sign out everywhere"
description="This stops each session but this one."
action={
<Button icon={<LogOut />} onClick={() => toast.success('Other sessions signed out')}>
Sign out other sessions
</Button>
}
/>
<ActionRow
title="Export your data"
description="You get a file with your projects and your invoices."
action={
<Button icon={<Download />} onClick={() => toast.success('Export requested')}>
Request export
</Button>
}
/>
</FormCard>
)
}With tone="destructive", the card has a red border and its action rows have a red tint. Open a Confirm Dialog from each destructive button.
Actions that freeze or remove this project.
Archive project
Delete project
import { ActionRow, Button, ConfirmDialog, FormCard, toast } from 'ferry-ui'
export default function FormCardDangerZone() {
return (
<FormCard
asDiv
tone="destructive"
className="mx-auto w-full max-w-2xl"
title="Danger zone"
description="Actions that freeze or remove this project."
>
<ActionRow
title="Archive project"
description="The project becomes read-only. You can restore it later."
action={<Button onClick={() => toast.success('Project archived')}>Archive project</Button>}
/>
<ActionRow
title="Delete project"
description="This deletes the project, its invoices and its API keys. You cannot undo this."
action={
<ConfirmDialog
trigger={<Button variant="destructive">Delete project</Button>}
title="Delete project “Billing portal”?"
description="The invoices and the API keys of this project go with it. You cannot undo this."
confirmLabel="Delete project"
onConfirm={() => {
toast.success('Project deleted')
}}
/>
}
/>
</FormCard>
)
}htmlFor, the row connects the label, the description and the error to the control. Do not set id, aria-describedby or aria-invalid on the control.role="alert".FormCard also accepts each attribute of the <form> element. With asDiv, these attributes go to the card.
| Prop | Type | Default |
|---|---|---|
title | ReactNode | - |
Optional header title (14px medium | ||
description | ReactNode | - |
Secondary line under the title (13px, lighter). | ||
headerActions | ReactNode | - |
Compact controls at the right of the header row (a docs link, a small button, a badge). | ||
footer | ReactNode | - |
Footer row, right-aligned on a tinted strip — usually | ||
asDiv | boolean | false |
Render a | ||
tone | "default" | "destructive" | "neutral" | neutral |
| ||
FormRow also accepts each attribute of the <div> element.
| Prop | Type | Default |
|---|---|---|
labelRequired | ReactNode | - |
Field name (14px medium). Rendered as a | ||
childrenRequired | ReactNode | ((control: FieldControlProps, meta: FieldRenderMeta) => ReactNode) | - |
The control(s): Input, Select, Switch, Textarea, CopyField, custom widgets. - A single control element (a component such as | ||
description | ReactNode | - |
Help text under the label (13px, lighter). With | ||
htmlFor | string | - |
Id of the control this row labels (becomes the label's | ||
error | ReactNode | - |
Validation error under the control (13px destructive, | ||
layout | "horizontal" | "vertical" | horizontal |
| ||
controlClassName | string | - |
Classes for the control column (e.g. | ||
FormActions accepts only the props of this table.
| Prop | Type | Default |
|---|---|---|
dirtyRequired | boolean | - |
The form differs from its saved state: enables Save and Cancel and shows the "unsaved changes" status. | ||
saving | boolean | false |
A save is in flight: spinner on Save, Cancel disabled. | ||
invalid | boolean | false |
The last submit failed validation: appends | ||
onReset | (() => void) | - |
Shows a Cancel button that restores the saved values. Omit it to hide Cancel. | ||
onSave | (() => void) | - |
Called by the Save button instead of submitting the form. Only needed when the card is not a | ||
saveLabel | ReactNode | Save changes |
Save button text (default "Save changes"). Name the outcome when it helps ("Apply limits", "Update plan"). | ||
cancelLabel | ReactNode | Cancel |
Cancel button text (default "Cancel"). | ||
unsavedLabel | ReactNode | Unsaved changes |
Status shown on the left while | ||
invalidMessage | ReactNode | fix the highlighted fields |
Appended to the unsaved status when | ||
hint | ReactNode | - |
Neutral note shown on the left while pristine, from | ||
children | ReactNode | - |
Extra content on the left, after the status (e.g. an "Also notify the team" checkbox). | ||
ActionRow also accepts each attribute of the <div> element.
| Prop | Type | Default |
|---|---|---|
titleRequired | ReactNode | - |
What the action does, as a short name (14px medium): "Delete project", "Transfer ownership". | ||
actionRequired | ReactNode | - |
The control on the right, usually one | ||
description | ReactNode | - |
Consequences, under the title (13px, lighter): what changes, what is kept, whether it can be undone. | ||
tone | "default" | "destructive" | "neutral" | - |
| ||