Form
PortedA native <form> for Monet fields. A submit (a type="submit" button, or Enter in a field) first runs every field's browser checks (required, type="email" and so on). When one fails, its field shows why, focus moves to it, and nothing is sent. Give each control a name, and a label or a Field, so its error has a place. Fields and actions stack with the dialog's gap. Native <form> attributes and ref pass through.
import { useState } from "react";
import { Button } from "@etched/monet/button";
import { Input } from "@etched/monet/input";
import { Form, type FormErrors } from "@etched/monet/form";
export function Preview() {
const [errors, setErrors] = useState<FormErrors>();
return (
<Form errors={errors} onFormSubmit={(values) => setErrors(values.email === "ana@etched.ai" ? { email: "Ana Ruiz already has access." } : {})}>
<Input name="email" type="email" label="Invite by email" defaultValue="ana@etched.ai" />
<Button type="submit">Invite</Button>
</Form>
);
}Installation
import { Form } from "@etched/monet/form";Examples
Server errors in a dialog
The server's errors are keyed by field name. Each shows under its field in place of the help text, and focus moves to the first one. Changing the field clears its error. Here the server refuses the email until you change it; the field's own error still shows when the text is not an email.
import { useState } from "react";
import { Button } from "@etched/monet/button";
import { Dialog, DialogActions, DialogClose, DialogDescription, DialogHeader, DialogRoot, DialogTrigger } from "@etched/monet/dialog";
import { Input, InputArea } from "@etched/monet/input";
import { Select } from "@etched/monet/select";
import { useToastManager } from "@etched/monet/toast";
import { Form, type FormErrors } from "@etched/monet/form";
const roles = { silicon: "Silicon engineer", software: "Software engineer", operations: "Operations" };
/** A stand-in for the server: after a moment, it answers with errors keyed by field name, or none. */
const send = (errors: FormErrors) => new Promise<FormErrors>((resolve) => setTimeout(() => resolve(errors), 600));
export function ServerErrors() {
const [open, setOpen] = useState(false);
const [errors, setErrors] = useState<FormErrors>();
const [busy, setBusy] = useState(false);
const toasts = useToastManager();
const submit = async (email: string) => {
setBusy(true);
const found = await send(email === "sam.lee@gmail.com" ? { email: "Sam Lee is already in Orbit: Kai Ito referred him on Sep 12." } : {});
setBusy(false);
setErrors(found);
if (Object.keys(found).length) return;
setOpen(false);
toasts.add({ variant: "success", title: "Referral added" });
};
return (
<DialogRoot
open={open}
onOpenChange={(next) => {
setOpen(next);
// A new form starts without the last one's errors.
if (next) setErrors(undefined);
}}
dismissDisabled={busy}
>
<DialogTrigger render={<Button variant="primary">Add referral</Button>} />
<Dialog size="lg">
<DialogHeader close>Add referral</DialogHeader>
<DialogDescription>The recruiting team reviews new referrals every day.</DialogDescription>
<Form errors={errors} onFormSubmit={(values) => !busy && submit(String(values.email))}>
<Input name="name" label="Name" required defaultValue="Sam Lee" />
<Input
name="email"
type="email"
label="Email"
required
defaultValue="sam.lee@gmail.com"
description="Their personal email, not a work one."
error={{ message: "Enter an email address.", match: "typeMismatch" }}
/>
<Select name="role" label="Role" items={roles} defaultValue="silicon" />
<InputArea name="note" label="Why this person" required={false} rows={3} />
<DialogActions>
<DialogClose render={<Button>Cancel</Button>} />
<Button type="submit" variant="primary" loading={busy}>
Add referral
</Button>
</DialogActions>
</Form>
</Dialog>
</DialogRoot>
);
}Checks before sending
On submit, every field runs its browser checks (required, type="email"). A field that fails shows why, focus moves to the first one, and nothing is sent. After that, each field checks itself again as the user types.
import { useState } from "react";
import { Button } from "@etched/monet/button";
import { Input } from "@etched/monet/input";
import { Form } from "@etched/monet/form";
export function ChecksBeforeSending() {
const [sent, setSent] = useState("");
return (
<Form className="grid" onFormSubmit={(values) => setSent(`Sent: ${String(values.lot)}, ${String(values.owner)}`)}>
<Input name="lot" label="Lot" required placeholder="L-7801" />
<Input name="owner" type="email" label="Owner email" required placeholder="name@etched.ai" />
<Button type="submit">Save lot</Button>
<p role="status">{sent}</p>
</Form>
);
}Accessibility
Enterin a text field submits the form, as in any native form.- A native
form. It is aformlandmark only when you name it witharia-labeloraria-labelledby. - A failed submit moves focus to the first invalid field. Its error is linked by
aria-describedbyand the field getsaria-invalid, so screen readers read why. - Server
errorsshow under the field with the samename, take focus the same way, and clear when the user changes that field. - Give each control a
name, and alabelor aField, so its error has a place. Pass newerrorsafter each submit, also{}on success.
API reference
| Prop | Type | Default | Description |
|---|---|---|---|
| errors | FormErrors | – | Errors from the server, keyed by each control's name. Each shows under its field, and the field turns invalid. After a submit, focus moves to the first invalid field. An error clears when the user changes its field. Pass a new object after each submit, also an empty one when the server accepts the values. |
| onFormSubmit | BaseForm.Props["onFormSubmit"] | – | Called on submit when every field passes its checks, with the values by name: text, or a list for a control that holds several values (File[] for FileInput, string[] for TagInput and a multiple Select or Combobox). Without it, the form submits natively (action, onSubmit). |
| className | string | – |