콘텐츠로 이동

Form — spec

Status: example artifact. The Form is a composition pattern, not a single component — it specs how Input/Select/Checkbox/etc. compose into a coherent form. Generated by: skills/component-spec-writer, citing Ant Design Form, MUI form patterns, shadcn-ui react-hook-form integration

Purpose

A Form coordinates multiple inputs into a single submission. It owns: layout, label/error wiring, validation orchestration, submission state, and progressive disclosure. Get this wrong and every individual input feels disconnected.

When this is a Form vs. just inputs

Use Form Use loose inputs
Submission to a server with success/error A single search input
Coordinated validation across fields (e.g., password match) Independent settings toggles
Multi-step Inline edit on a profile page
Required validation + "save changes" gate Filter inputs that update results in real-time

Composition

<Form onSubmit={handleSubmit}>
  <Form.Field name="email">
    <Form.Label>Email</Form.Label>
    <Form.Control>
      <Input type="email" />
    </Form.Control>
    <Form.HelpText>We'll only use for password recovery.</Form.HelpText>
    <Form.ErrorText />
  </Form.Field>

  <Form.Field name="password" required>
    <Form.Label>Password</Form.Label>
    <Form.Control>
      <Input type="password" />
    </Form.Control>
    <Form.ErrorText />
  </Form.Field>

  <Form.Footer>
    <Button type="submit" loading={form.formState.isSubmitting}>
      Sign in
    </Button>
  </Form.Footer>
</Form>
Slot Required Notes
Form yes Owns the state, validation, submission
Form.Field for each input Wraps one logical field. Wires id/aria automatically.
Form.Label yes Visible label, htmlFor auto-set
Form.Control yes Slot for the actual input element
Form.HelpText optional Below-input hint
Form.ErrorText yes Below-input error (replaces help text on error)
Form.Footer usual Submit/cancel buttons
Form.Section optional Logical group of fields with a heading

API

<Form> props

Prop Type Default Description
onSubmit (values: T, e: FormEvent) => void \| Promise<void> Called with validated values. If returns a Promise, form is in submitting state until it resolves.
defaultValues Partial<T> {} Initial values for uncontrolled fields.
validate (values: T) => Errors \| Promise<Errors> Custom validator. Use Zod schema for the typical case.
schema ZodSchema<T> Zod schema. Auto-validates per field on blur and on submit.
mode "onSubmit" \| "onBlur" \| "onChange" \| "all" "onBlur" When to validate.
onError (errors: Errors) => void Called when validation fails on submit.

<Form.Field> props

Prop Type Default Description
name string Required. The key in the form values object. Supports dotted paths (address.city).
required boolean false Marks visually + sets aria-required.
disabled boolean false Disables the input AND its label/help styling.

Layout

Single-column rule

See knowledge/patterns/form-design.md — single-column over multi-column unless the fields are genuine pairs.

Sections

<Form>
  <Form.Section title="Contact">
    <Form.Field name="email">...</Form.Field>
    <Form.Field name="phone">...</Form.Field>
  </Form.Section>
  <Form.Section title="Shipping address">
    <Form.Field name="address">...</Form.Field>
    <Form.Field name="postalCode">...</Form.Field>
  </Form.Section>
</Form>

Section renders a <fieldset> with <legend> for accessibility. Visual: heading + thin divider above, no heavy box.

Spacing

  • Between fields: --space-base (16px)
  • Between sections: --space-2xl (32px)
  • Field internal vertical (label → input → help): --space-xs (4px)

Validation orchestration

Use react-hook-form + zod (this is the modern de facto standard; both Ant and shadcn ecosystems converge here). MUI's react-hook-form adapter is well-maintained too.

import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";

const schema = z.object({
  email: z.string().email("올바른 이메일 형식이 아닙니다"),
  password: z.string().min(8, "8자 이상 입력해 주세요"),
  agreeToTerms: z.literal(true, { errorMap: () => ({ message: "약관 동의가 필요합니다" }) }),
});

type FormValues = z.infer<typeof schema>;

function SignupForm() {
  const form = useForm<FormValues>({
    resolver: zodResolver(schema),
    mode: "onBlur",
  });

  const onSubmit = async (values: FormValues) => {
    await api.signup(values);
  };

  return <Form form={form} onSubmit={onSubmit}>...</Form>;
}

Validation timing matrix

Trigger What
User typing Format only, no errors
User leaves field (blur) Validate that field. If error, show.
User returns to errored field, types Validate on every keystroke (live correction)
User submits Validate all fields. Set focus to first errored field.

Cross-field validation

Password confirmation, date range, conditional required:

const schema = z.object({
  password: z.string().min(8),
  passwordConfirm: z.string(),
}).refine((data) => data.password === data.passwordConfirm, {
  message: "비밀번호가 일치하지 않습니다",
  path: ["passwordConfirm"],
});

But also: skip the password-confirm field entirely. See knowledge/patterns/form-design.md Don'ts.

Submission states

State Trigger UI
Idle resting Submit enabled
Validating (async) username availability check Field-level spinner
Submitting onSubmit runs (Promise pending) Submit button: loading=true, disabled=true. All inputs disabled.
Success Promise resolved Toast "저장되었습니다", optionally navigate or close form
Error Promise rejected Toast or inline error at top of form. Inputs re-enabled.

Server-side errors mapping

try {
  await api.signup(values);
} catch (err) {
  if (err.code === "EMAIL_ALREADY_EXISTS") {
    form.setError("email", { message: "이미 가입된 이메일입니다" });
    form.setFocus("email");
  } else {
    toast.error("저장에 실패했습니다", { description: err.message });
  }
}

Map server errors to specific fields when possible. Generic toast only for non-field-specific failures.

Multi-step forms

For 8+ fields with logical grouping:

<Form schema={fullSchema} defaultValues={values}>
  <Form.Steps>
    <Form.Step title="Account">
      <Form.Field name="email">...</Form.Field>
      <Form.Field name="password">...</Form.Field>
    </Form.Step>
    <Form.Step title="Profile">
      <Form.Field name="name">...</Form.Field>
      <Form.Field name="phone">...</Form.Field>
    </Form.Step>
    <Form.Step title="Preferences">
      <Form.Field name="locale">...</Form.Field>
    </Form.Step>
  </Form.Steps>
  <Form.Footer>
    <Form.PrevButton>Back</Form.PrevButton>
    <Form.NextButton>Next</Form.NextButton>
    <Form.SubmitButton>Sign up</Form.SubmitButton>
  </Form.Footer>
</Form>

Required affordances: - Progress indicator: dots, numbered steps, or progress bar at top - Per-step validation: can't advance with errors - Back button: always available except on first step - Save progress: persist defaultValues to localStorage on every blur - Don't reset on back navigation

Accessibility

  • <form> element wraps; <button type="submit"> triggers
  • Each Form.Field auto-wires:
  • id on input
  • <label htmlFor> on label
  • aria-describedby to HelpText id
  • aria-invalid="true" on error
  • aria-describedby extends to ErrorText id when error
  • aria-required="true" when required
  • ErrorText in role="alert" for assertive announcement
  • On submit-fail: focus moves to first errored field
  • Form-level errors (e.g., "Network error") announced via role="status" or aria-live="polite" region
  • For long forms: provide an in-form error summary at top with anchor links to each field

Tokens consumed

--space-xs           (label → input gap)
--space-base         (between fields)
--space-2xl          (between sections)
--color-error        (error states)
--color-text-secondary  (help text)
--color-text-tertiary   (optional indicator)

(Plus all tokens consumed by Input, Select, etc. via the Form.Control slot.)

Edge cases

  • Browser autofill conflict with controlled state: react-hook-form handles this via register, but Controller-based fields need setValue on autofill events. Test with 1Password.
  • Form inside Modal: focus on first field on modal open (not close button). On submit-fail, ensure error focus stays inside modal.
  • iOS keyboard pushing submit button off-screen: see knowledge/platforms/react-native.md keyboard handling. Web: scroll-into-view on focus + sticky footer.
  • Pasting whole "Name Address Phone" into one field: not Form's job to split — but offer help text "이름, 주소, 연락처를 따로 입력해 주세요" if you anticipate this.
  • Korean IME composition: don't validate during composition. Listen to compositionstart/end.
  • Optimistic update: for "save profile name" with server save in background — update UI immediately, queue server call, surface error if server rejects (rare).

Don't

  • Don't disable submit until all fields valid. Show errors specifically — see knowledge/patterns/form-design.md.
  • Don't validate on every keystroke before user has tried. Wait for blur.
  • Don't show server-side errors as global toast when they're field-specific.
  • Don't lose progress when user navigates back from a step.
  • Don't ship a confirm-email or confirm-password field. Use show/hide toggle and a verification email instead.

References

  • Ant Design: refs/ant-design/components/form/ — exhaustive Form, Form.Item, Form.List, Form.Provider. Most complete API. Built-in validation rules. Ant's Form.useForm is the imperative escape hatch.
  • MUI: composition with <form> + react-hook-form + <TextField>. No dedicated Form component — MUI explicitly defers form orchestration to RHF.
  • shadcn-ui: refs/shadcn-ui/apps/v4/registry/new-york-v4/ui/form.tsx wraps RHF FormProvider + Controller + auto-wires aria-*. Cleanest minimal version. Mandates Zod + RHF.

API choices made: - Composition (Form.Field/Form.Label/Form.Control) over Ant's Form.Item prop-blob — slot-based scales to custom field types better. - schema prop accepting a Zod schema as the default validator — modern teams converge here. Custom validate is the escape hatch. - mode="onBlur" default, not onChange. Less anxiety-inducing during typing. - Auto-wires aria-: matches shadcn's pattern, removes the most-skipped a11y wiring. - *Form.Steps for multi-step** rather than a separate <Wizard> component — keeps validation/state co-located.

Cross-reference