KDS
Forms

Form

Wrappers de react-hook-form + zod com ARIA wiring automático.

Overview

Form agrupa os primitivos do KDS (Input, Textarea, Select, Checkbox, RadioGroup, Switch) com react-hook-form

  • zod e ARIA wiring automático. O resultado: formulários acessíveis com validação tipada e mensagens em PT-BR, sem ter que ligar htmlFor / aria-describedby / aria-invalid manualmente em cada campo.

A anatomia segue o template canônico de form row:

  1. Label — descreve o campo
  2. Input/Control — o controle em si
  3. Description — helper text opcional
  4. Message — mensagem de erro (só renderiza se houver)

Quando o erro aparece, FormLabel ganha cor destructive, FormControl recebe aria-invalid="true", e FormMessage aparece no slot já reservado pelo grid gap-2 do FormItem — sem layout shift. Esse desacoplamento é importante para evitar CLS — ver [[ui/layout/cumulative-layout-shift]].

<Form {...form}>
  <FormField
    control={form.control}
    name="email"
    render={({ field }) => (
      <FormItem>
        <FormLabel>Email</FormLabel>
        <FormControl>
          <Input type="email" {...field} />
        </FormControl>
        <FormMessage />
      </FormItem>
    )}
  />
</Form>

Anatomy

<Form {...methods}>            ← FormProvider de react-hook-form
  <FormField                    ← Controller + name context
    control={methods.control}
    name="email"
    render={({ field }) => (
      <FormItem>                ← Wrapper grid com id único
        <FormLabel />            ← Label vinculado via htmlFor=formItemId
        <FormControl>            ← Slot que injeta id + aria-* no filho
          <Input />
        </FormControl>
        <FormDescription />      ← Helper text com id formDescriptionId
        <FormMessage />          ← Mensagem de erro com id formMessageId
      </FormItem>
    )}
  />
</Form>

Subcomponents

ComponenteDescrição
FormAlias para FormProvider do RHF. Aceita o objeto de useForm().
FormFieldWrapper de Controller. Provê o name para os filhos.
FormItemGrid gap-2 com id único. Contexto pra subcomponents.
FormLabelLabel vinculado via id automático. Vermelho em erro.
FormControlSlot que injeta id, aria-describedby, aria-invalid.
FormDescriptionHelper text com id rastreável por screen readers.
FormMessageMensagem de erro (só renderiza quando há erro).
useFormFieldHook que expõe os ids, name, e fieldState do contexto.

Usage

Instale as peer deps no projeto consumidor:

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

import { Button } from "@kalvner/kds/forms/button";
import {
  Form,
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage
} from "@kalvner/kds/forms/form";
import { Input } from "@kalvner/kds/forms/input";

const schema = z.object({
  email: z.string().email("Digite um e-mail válido"),
  password: z.string().min(8, "Mínimo 8 caracteres")
});

export function LoginForm() {
  const form = useForm<z.infer<typeof schema>>({
    resolver: zodResolver(schema),
    defaultValues: { email: "", password: "" }
  });

  return (
    <Form {...form}>
      <form
        onSubmit={form.handleSubmit((data) => {
          console.log(data);
        })}
        className="grid gap-4"
      >
        <FormField
          control={form.control}
          name="email"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Email</FormLabel>
              <FormControl>
                <Input type="email" {...field} />
              </FormControl>
              <FormDescription>
                Usaremos para confirmar a conta.
              </FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />
        <FormField
          control={form.control}
          name="password"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Senha</FormLabel>
              <FormControl>
                <Input type="password" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <Button type="submit">Entrar</Button>
      </form>
    </Form>
  );
}

Props

Form

Form é alias direto para FormProvider do RHF — aceita todas as métodos retornadas por useForm(). Use o spread:

const form = useForm({...});
return <Form {...form}>...</Form>;

FormField

PropTipoDescrição
controlControlform.control do useForm.
namestring (paths do schema)Caminho do campo.
render({ field, fieldState }) => ReactNodeRender prop.

FormItem / FormLabel / FormControl / FormDescription / FormMessage

Aceitam todos os props do elemento subjacente (<div>, <label>, <p>). FormControl é um Slot — clona o filho injetando id, aria-describedby, aria-invalid.

Variants

Submit (default)
useForm sem mode valida apenas no submit.

Ideal para login, signup. Erros aparecem ao clicar Submit.

onChange
Valida a cada keystroke.

Útil em campos com requisitos visíveis (senha forte, slug disponível).

onBlur
Valida ao sair do campo.

Sweet spot — não interrompe quem digita, avisa antes do submit.

States

EstadoComo ativarComportamento visual
IdleEstado inicial.Label normal, controle com border padrão.
FocusedTab/click no controle.Ring ring-ring/50.
ValidatingEm modes onChange/onBlur, durante a validação async.Geralmente imperceptível — Zod sync é instantâneo.
ErrorSchema falha.FormLabel vermelho, FormControl com aria-invalid="true", FormMessage aparece.
Submittingform.formState.isSubmitting — desabilite o Button + spinner.Button com disabled + spinner.

Composition

Profile form
Form completo combinando todos os primitivos.

Veja a story Composed · Profile form em Storybook → Primitives → Forms → Form para um exemplo end-to-end com Input, Textarea, Select, RadioGroup, Switch e Checkbox dentro de um único Form.

When to use

  • Qualquer formulário multi-campo com validação.
  • Login, signup, profile, configurações estruturadas.
  • Quando você precisa de mensagens de erro tipadas (Zod) e ARIA automático.

When not to use

  • Forms triviais (1-2 campos sem validação) — useState + <form> HTML cru é mais leve.
  • Buscas que disparam em onChange — RHF é overkill; um Input controlado basta.
  • Server Actions com validação no servidor — Zod via zod-form-data
    • useFormStatus pode ser mais simples.

Best practices

  • Use mode: "onBlur" ou "onTouched" por padrão. Validar em onChange cansa; validar só em submit demora a dar feedback.
  • Erros em PT-BR no schema. Zod aceita strings nas validações (z.string().email("Digite um e-mail válido")). Mantém todo o texto de erro em um lugar.
  • Disable o Submit durante isSubmitting. Spinner no botão para evitar duplo envio. Cite [[ui/layout/cumulative-layout-shift]] — o spinner não deve mudar a altura do botão.
  • Reservar espaço pra mensagem de erro. O grid gap-2 do FormItem já reserva — não animar height na transição idle → error.
  • Toast em erros de servidor (não validação). Validação é inline; falha de rede ou backend é um Toast (Sonner) — quando Sonner estiver no KDS.

Accessibility

ConcernComportamento
LabelFormLabel recebe htmlFor={formItemId} automaticamente.
DescriptionFormDescription ganha id; FormControl aponta aria-describedby para ele.
ErroQuando há erro, FormMessage ganha id e é incluído em aria-describedby; FormControl ganha aria-invalid="true".
Roving focusNão aplicável — formulários são linear tab order.
Submit failureUse form.setFocus("campo") para focar o primeiro campo com erro após Submit.
Live regionsPara feedback assíncrono (ex: "Salvando…" → "Salvo"), considere aria-live="polite" em um container de status.

On this page