codefast/ui

Command Palette

Search for a command to run...

Source
Form

Form

React Hook Form integration with accessible label, description, and error message binding.

Examples

Sign-in with validation

A controlled form: submit invalid values to see inline errors, valid ones to succeed.

63 lines
import { Button } from "@codefast/ui/button";
import { Field, FieldError, FieldLabel } from "@codefast/ui/field";
import { Input } from "@codefast/ui/input";
import { InputPassword } from "@codefast/ui/input-password";
import type { FormEvent } from "react";
import { useState } from "react";

const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

export function FormSignIn() {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [touched, setTouched] = useState(false);
  const [done, setDone] = useState(false);

  const emailInvalid = touched && !EMAIL.test(email);
  const passwordInvalid = touched && password.length < 8;

  function submit(event: FormEvent<HTMLFormElement>): void {
    event.preventDefault();
    setTouched(true);
    if (EMAIL.test(email) && password.length >= 8) {
      setDone(true);
    }
  }

  return (
    <form noValidate className="w-full max-w-xs space-y-4" onSubmit={submit}>
      <Field>
        <FieldLabel htmlFor="signin-email">Email</FieldLabel>
        <Input
          id="signin-email"
          type="email"
          placeholder="you@example.com"
          value={email}
          aria-invalid={emailInvalid || undefined}
          onChange={(event) => {
            setEmail(event.target.value);
            setDone(false);
          }}
        />
        {emailInvalid ? <FieldError>Enter a valid email.</FieldError> : null}
      </Field>
      <Field>
        <FieldLabel htmlFor="signin-password">Password</FieldLabel>
        <InputPassword
          id="signin-password"
          placeholder="Min. 8 characters"
          value={password}
          aria-invalid={passwordInvalid || undefined}
          onChange={(event) => {
            setPassword(event.target.value);
            setDone(false);
          }}
        />
        {passwordInvalid ? <FieldError>At least 8 characters.</FieldError> : null}
      </Field>
      <Button type="submit" className="w-full">
        {done ? "Signed in ✓" : "Sign in"}
      </Button>
    </form>
  );
}

Contact form

Field + Textarea with inline validation and a helper.

We typically reply within a day.

49 lines
import { Button } from "@codefast/ui/button";
import { Field, FieldDescription, FieldError, FieldLabel } from "@codefast/ui/field";
import { Input } from "@codefast/ui/input";
import { Textarea } from "@codefast/ui/textarea";
import type { FormEvent } from "react";
import { useState } from "react";

export function FormContact() {
  const [message, setMessage] = useState("");
  const [touched, setTouched] = useState(false);

  const invalid = touched && message.trim().length < 10;

  function submit(event: FormEvent<HTMLFormElement>): void {
    event.preventDefault();
    setTouched(true);
  }

  return (
    <form noValidate className="w-full max-w-xs space-y-4" onSubmit={submit}>
      <Field>
        <FieldLabel htmlFor="contact-name">Name</FieldLabel>
        <Input id="contact-name" placeholder="Ada Lovelace" />
      </Field>
      <Field>
        <FieldLabel htmlFor="contact-message">Message</FieldLabel>
        <Textarea
          id="contact-message"
          rows={3}
          className="resize-none"
          placeholder="How can we help?"
          value={message}
          aria-invalid={invalid || undefined}
          onChange={(event) => {
            setMessage(event.target.value);
          }}
        />
        {invalid ? (
          <FieldError>Please write at least 10 characters.</FieldError>
        ) : (
          <FieldDescription>We typically reply within a day.</FieldDescription>
        )}
      </Field>
      <Button type="submit" className="w-full">
        Send message
      </Button>
    </form>
  );
}

With consent

Combine an input with a required consent checkbox.

43 lines
import { Button } from "@codefast/ui/button";
import { Checkbox } from "@codefast/ui/checkbox";
import { Field, FieldLabel } from "@codefast/ui/field";
import { Input } from "@codefast/ui/input";
import { Label } from "@codefast/ui/label";
import type { FormEvent } from "react";
import { useState } from "react";

export function FormNewsletter() {
  const [agreed, setAgreed] = useState(false);
  const [touched, setTouched] = useState(false);

  function submit(event: FormEvent<HTMLFormElement>): void {
    event.preventDefault();
    setTouched(true);
  }

  return (
    <form noValidate className="w-full max-w-xs space-y-4" onSubmit={submit}>
      <Field>
        <FieldLabel htmlFor="news-email">Email</FieldLabel>
        <Input id="news-email" type="email" placeholder="you@example.com" />
      </Field>
      <div className="flex items-start gap-2">
        <Checkbox
          id="news-consent"
          checked={agreed}
          className="mt-0.5"
          onCheckedChange={(checked) => {
            setAgreed(checked === true);
          }}
        />
        <Label htmlFor="news-consent" className="text-xs leading-relaxed text-ui-muted">
          I agree to receive product news. Unsubscribe at any time.
        </Label>
      </div>
      {touched && !agreed ? <p className="text-xs text-rose-500">Please accept to continue.</p> : null}
      <Button type="submit" className="w-full">
        Subscribe
      </Button>
    </form>
  );
}

Usage

The minimal import and composition — see Examples below for styled, real-world variants.

44 lines
import { Button } from "@codefast/ui/button";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@codefast/ui/form";
import { Input } from "@codefast/ui/input";
import { useForm } from "react-hook-form";

interface FormUsageValues {
  username: string;
}

export function FormUsage() {
  const form = useForm<FormUsageValues>({ defaultValues: { username: "" } });

  function onSubmit(values: FormUsageValues): void {
    console.log(values);
  }

  return (
    <Form {...form}>
      <form
        className="w-full max-w-sm space-y-4"
        onSubmit={(event) => {
          void form.handleSubmit(onSubmit)(event);
        }}
      >
        <FormField
          control={form.control}
          name="username"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Username</FormLabel>
              <FormControl>
                <Input placeholder="leo-park" {...field} />
              </FormControl>
              <FormDescription>This is your public display name.</FormDescription>
              <FormMessage />
            </FormItem>
          )}
          rules={{ required: "Username is required." }}
        />
        <Button type="submit">Submit</Button>
      </form>
    </Form>
  );
}

Anatomy

How the parts nest — every slot the component exposes, in composition order.

Form
└── FormField
└── FormItem
├── FormLabel
├── FormControl
├── FormDescription
└── FormMessage

Features

  • FormField connects a react-hook-form Controller to a name/control pair and wires the label/description/message ids through context.
  • FormControl (a Slot) sets aria-invalid and aria-describedby on whatever control it wraps, pointing at the description and message ids automatically.
  • FormMessage renders the current react-hook-form field error, or falls back to its children when the field is valid.

API reference

Props for each part of the component. All native element props are also forwarded.

Form (react-hook-form)

The Form parts bind react-hook-form to accessible markup — see Usage above for the full FormField/FormControl/FormMessage wiring with useForm. The examples below intentionally stay dependency-free (plain state + Field) to show the same pattern without requiring react-hook-form.

FormField{ control, name, render }

Connects a control to RHF and wires label, description, and error ids.

FormMessageReactNode

Renders the field’s validation error from RHF state.

Accessibility

Built to be keyboard-navigable and screen-reader friendly out of the box.

  • Associate every control with a label, and link errors via aria-describedby.
  • Set aria-invalid on a control when its value fails validation.
  • Validate on submit (and optionally on blur) — show errors as text, not only colour.

Guidelines

Conventions that keep usage consistent across an app.

Do

  • Keep forms short; group related fields with Field / FieldSet.
  • Show a clear success state after submit.

Don’t

  • Don’t validate aggressively on every keystroke before first submit.
  • Don’t rely on placeholder text instead of labels.

Explore further

Ready to integrate?

Follow the Getting Started guide to install @codefast/ui, or browse the full component gallery.