Form
A plain form that posts with no script, and FormAction, whose action answers with errors that land on the matching fields.
split - size not measured yet
Form
split - size not measured yetA plain form that posts with no script, and FormAction, whose action answers with errors that land on the matching fields.
The code
form.tsx
import { Button } from 'topclassui/button'
import { Field } from 'topclassui/field'
import { Input } from 'topclassui/input'
import { Form } from 'topclassui/form'
/** The subscribe form of "Your first component": it posts to /subscribe with no script, and the browser asks for the email first. */
export default function Demo() {
return (
<Form action="/subscribe" method="post" className="flex max-w-sm flex-col gap-4">
<Field name="email" label="Email" hint="We never share it" required>
{(control) => <Input {...control} type="email" autoComplete="email" fullWidth />}
</Field>
<Button type="submit">Subscribe</Button>
</Form>
)
}
A server Form cannot show what its action returns: hand each Field its error yourself, or use FormAction in a client file.
Import from the package
topclassui/form
import { Form } from 'topclassui/form'Install once:npm i topclassui@beta zod
Examples
Formadds the flat export and theclassNamemerge, nothing else: nonoValidate, so the browser's ownrequiredcheck runs before hydration and after.FormActionruns React'suseActionState:action(state, formData)returns the next state, whoseerrorsare keyed by field name, as Zod'sfieldErrors.- React resets the form when an action settles, so an action that returns errors returns the submitted values too, and the function child gives them back as defaults.
- With no script a client function cannot post: pass
permalinkwith a Server Action.
basic
basic.tsx
import { Button } from 'topclassui/button'
import { Field } from 'topclassui/field'
import { Input } from 'topclassui/input'
import { Form } from 'topclassui/form'
/** The subscribe form of "Your first component": it posts to /subscribe with no script, and the browser asks for the email first. */
export default function Demo() {
return (
<Form action="/subscribe" method="post" className="flex max-w-sm flex-col gap-4">
<Field name="email" label="Email" hint="We never share it" required>
{(control) => <Input {...control} type="email" autoComplete="email" fullWidth />}
</Field>
<Button type="submit">Subscribe</Button>
</Form>
)
}
server-errors
server-errors.tsx
import { Button } from 'topclassui/button'
import { Field } from 'topclassui/field'
import { Input } from 'topclassui/input'
import { Textarea } from 'topclassui/textarea'
import { Form } from 'topclassui/form'
/** What an action answered, as a server page hands it back: each message keyed by name, and what was sent. */
const ANSWER = {
errors: { email: 'This email is already subscribed', message: ['Write at least 20 characters', 'Leave out the links'] },
values: { email: 'taken@example.com', message: 'Hi, see example.com' },
}
/** Server Fields given the errors an action returned: each lands on its own field, and the values come back as defaults. */
export default function Demo() {
return (
<Form action="/contact" method="post" className="flex max-w-sm flex-col gap-4">
<Field name="email" label="Email" error={ANSWER.errors.email} required>
{(control) => <Input {...control} type="email" autoComplete="email" defaultValue={ANSWER.values.email} fullWidth />}
</Field>
<Field name="message" label="Message" hint="What can we help with?" error={ANSWER.errors.message}>
{(control) => <Textarea {...control} defaultValue={ANSWER.values.message} fullWidth />}
</Field>
<Button type="submit">Send</Button>
</Form>
)
}
form-action
form-action.tsx
'use client'
import { Button } from 'topclassui/button'
import { FieldLive } from 'topclassui/field'
import { Input } from 'topclassui/input'
import { FormAction, type FormActionState } from 'topclassui/form'
interface State extends FormActionState {
values?: { name: string, email: string }
message?: string
}
/** A text entry of the form, or '' for a missing one or a file. */
const text = (formData: FormData, name: string) => { const value = formData.get(name); return typeof value === 'string' ? value : '' }
/** Stands in for a Server Action: it answers a moment later, refuses taken@example.com and hands back what was sent. */
async function subscribe(_state: State, formData: FormData): Promise<State> {
const values = { name: text(formData, 'name'), email: text(formData, 'email') }
await new Promise((done) => { setTimeout(done, 200) })
if (values.email === 'taken@example.com') return { errors: { email: 'This email is already subscribed' }, values }
return { message: `Subscribed ${values.email}` }
}
/** FormAction in a client file: the error lands on its FieldLive, and the function child gives the fields back what was typed. */
export default function Demo() {
return (
<FormAction action={subscribe} className="flex max-w-sm flex-col gap-4">
{(state, pending) => (
<>
<FieldLive name="name" label="Name">
{(control) => <Input {...control} autoComplete="name" defaultValue={state.values?.name} fullWidth />}
</FieldLive>
<FieldLive name="email" label="Email" hint="Try taken@example.com" required>
{(control) => <Input {...control} type="email" autoComplete="email" defaultValue={state.values?.email} fullWidth />}
</FieldLive>
<Button type="submit" loading={pending}>Subscribe</Button>
<p role="status" className="text-sm text-body">{state.message}</p>
</>
)}
</FormAction>
)
}
In Arabic, right to left
rtl.tsx
import { Button } from 'topclassui/button'
import { Field } from 'topclassui/field'
import { Input } from 'topclassui/input'
import { TopClassProvider } from 'topclassui/provider'
import { Form } from 'topclassui/form'
/** Arabic, inside TopClassProvider as the root layout would mount it: the same subscribe form, read from the right. */
export default function Demo() {
return (
<TopClassProvider direction="rtl" locale="ar">
<div dir="rtl" lang="ar">
<Form action="/subscribe" method="post" className="flex max-w-sm flex-col gap-4">
<Field name="name" label="الاسم">
{(control) => <Input {...control} autoComplete="name" fullWidth />}
</Field>
<Field name="email" label="البريد الإلكتروني" hint="لن نشاركه مع أحد" required>
{(control) => <Input {...control} type="email" autoComplete="email" fullWidth />}
</Field>
<Button type="submit">اشترك</Button>
</Form>
</div>
</TopClassProvider>
)
}
Props
No props of its own: it takes the attributes of the element it renders.
Keyboard and accessibility
- After an answer with errors the focus goes to the first control with
aria-invalid="true", never on the first render. - The form carries
aria-busywhile the action is pending.
Right to left
Nothing of its own mirrors: the fields inside it read from the right, through their own logical classes.
Server and client
Renders on the server; the interactive part is a client component that ships only when it is used.
Files: index.tsx, client.tsx.
On the chip: split - size not measured yet.