Monet
@etched/monet 0.0.1

Form

Ported

A 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

  • Enter in a text field submits the form, as in any native form.
  • A native form. It is a form landmark only when you name it with aria-label or aria-labelledby.
  • A failed submit moves focus to the first invalid field. Its error is linked by aria-describedby and the field gets aria-invalid, so screen readers read why.
  • Server errors show under the field with the same name, take focus the same way, and clear when the user changes that field.
  • Give each control a name, and a label or a Field, so its error has a place. Pass new errors after each submit, also {} on success.

API reference

PropTypeDefaultDescription
errorsFormErrors–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.
onFormSubmitBaseForm.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).
classNamestring–