DS

Forms

Patterns de formulaires utilisant react-hook-form et Zod pour la validation, avec les composants Form de shadcn/ui.

Architecture

Les formulaires suivent une hierarchie precise de composants. Chaque champ est encapsule dans un FormField qui gere la connexion avec react-hook-form.

<Form>
<FormField>
<FormItem>
<FormLabel />
<FormControl>
<Input /> | <Select /> | ...
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</Form>

Schema Zod

La validation est definie avec un schema Zod. Le type TypeScript est infere automatiquement a partir du schema.

import { z } from "zod";

// Definition du schema de validation
const profileSchema = z.object({
  username: z
    .string()
    .min(3, "Le pseudo doit contenir au moins 3 caracteres")
    .max(20, "Le pseudo ne peut pas depasser 20 caracteres")
    .regex(
      /^[a-zA-Z0-9_-]+$/,
      "Seuls les lettres, chiffres, tirets et underscores sont autorises"
    ),
  email: z
    .string()
    .email("Adresse email invalide"),
  bio: z
    .string()
    .max(160, "La bio ne peut pas depasser 160 caracteres")
    .optional(),
  wcaId: z
    .string()
    .regex(/^\d{4}[A-Z]{4}\d{2}$/, "Format WCA ID invalide (ex: 2023DUPO01)")
    .optional()
    .or(z.literal("")),
  mainCube: z.enum(["3x3", "2x2", "4x4", "5x5", "pyraminx", "megaminx", "skewb", "sq1"]),
});

// Type infere automatiquement
type ProfileFormData = z.infer<typeof profileSchema>;

Formulaire complet

Exemple complet d'un formulaire avec validation, gestion des erreurs et soumission.

"use client";

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
  FormDescription,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { toast } from "sonner";

const schema = z.object({
  username: z.string().min(3, "Minimum 3 caracteres"),
  email: z.string().email("Email invalide"),
  bio: z.string().max(160).optional(),
  mainCube: z.enum(["3x3", "2x2", "4x4", "5x5"]),
});

type FormData = z.infer<typeof schema>;

export function ProfileForm() {
  const form = useForm<FormData>({
    resolver: zodResolver(schema),
    defaultValues: {
      username: "",
      email: "",
      bio: "",
      mainCube: "3x3",
    },
  });

  async function onSubmit(data: FormData) {
    try {
      await updateProfile(data);
      toast.success("Profil mis a jour !");
    } catch {
      toast.error("Erreur lors de la mise a jour");
    }
  }

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
        {/* Champ texte */}
        <FormField
          control={form.control}
          name="username"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Pseudo</FormLabel>
              <FormControl>
                <Input
                  placeholder="speedcuber42"
                  className="min-h-[44px]"
                  {...field}
                />
              </FormControl>
              <FormDescription>
                Votre nom affiche publiquement.
              </FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />

        {/* Champ email */}
        <FormField
          control={form.control}
          name="email"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Email</FormLabel>
              <FormControl>
                <Input
                  type="email"
                  placeholder="cuber@example.com"
                  className="min-h-[44px]"
                  {...field}
                />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />

        {/* Select */}
        <FormField
          control={form.control}
          name="mainCube"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Puzzle principal</FormLabel>
              <Select
                onValueChange={field.onChange}
                defaultValue={field.value}
              >
                <FormControl>
                  <SelectTrigger className="min-h-[44px]">
                    <SelectValue placeholder="Choisir un puzzle" />
                  </SelectTrigger>
                </FormControl>
                <SelectContent>
                  <SelectItem value="3x3">3x3</SelectItem>
                  <SelectItem value="2x2">2x2</SelectItem>
                  <SelectItem value="4x4">4x4</SelectItem>
                  <SelectItem value="5x5">5x5</SelectItem>
                </SelectContent>
              </Select>
              <FormMessage />
            </FormItem>
          )}
        />

        {/* Textarea */}
        <FormField
          control={form.control}
          name="bio"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Bio</FormLabel>
              <FormControl>
                <Textarea
                  placeholder="Parlez de vous..."
                  className="min-h-[100px] resize-none"
                  {...field}
                />
              </FormControl>
              <FormDescription>
                {field.value?.length || 0}/160 caracteres
              </FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />

        {/* Submit */}
        <Button
          type="submit"
          disabled={form.formState.isSubmitting}
          className="w-full sm:w-auto min-h-[44px]"
        >
          {form.formState.isSubmitting
            ? "Sauvegarde..."
            : "Sauvegarder"}
        </Button>
      </form>
    </Form>
  );
}

Gestion des erreurs

Les erreurs de validation sont affichees automatiquement par FormMessage. Pour les erreurs serveur, on utilise form.setError().

// Erreur sur un champ specifique (retour serveur)
form.setError("username", {
  type: "server",
  message: "Ce pseudo est deja pris",
});

// Erreur globale du formulaire
form.setError("root", {
  type: "server",
  message: "Une erreur inattendue est survenue",
});

// Affichage de l'erreur globale
{form.formState.errors.root && (
  <div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4">
    <p className="text-sm text-destructive">
      {form.formState.errors.root.message}
    </p>
  </div>
)}

Validations courantes

Patterns de validation Zod frequemment utilises dans l'application.

// Champ requis
z.string().min(1, "Ce champ est requis")

// Email
z.string().email("Email invalide")

// Longueur
z.string().min(3, "Minimum 3 caracteres").max(50, "Maximum 50 caracteres")

// Nombre
z.number().min(0, "Doit etre positif").max(100, "Maximum 100")

// Enum
z.enum(["3x3", "2x2", "4x4"], {
  errorMap: () => ({ message: "Selectionnez un puzzle" }),
})

// Regex
z.string().regex(/^[a-zA-Z0-9]+$/, "Caracteres alphanumeriques uniquement")

// Optionnel avec valeur vide autorisee
z.string().optional().or(z.literal(""))

// Confirmation de mot de passe
z.object({
  password: z.string().min(8),
  confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
  message: "Les mots de passe ne correspondent pas",
  path: ["confirmPassword"],
})

// Temps de solve (format mm:ss.xx)
z.string().regex(
  /^(\d{1,2}:)?\d{1,2}\.\d{2,3}$/,
  "Format invalide (ex: 1:23.45 ou 12.34)"
)

Accessibilite tactile

Tous les champs de formulaire doivent avoir une hauteur minimale de 44px pour etre facilement utilisables au toucher sur mobile.

// Hauteur minimale pour les inputs
<Input className="min-h-[44px]" />
<SelectTrigger className="min-h-[44px]" />
<Button className="min-h-[44px]" />

// Espacement suffisant entre les champs
<form className="space-y-6">
  {/* Les champs ont assez d'espace pour etre touches */}
</form>

// Labels cliquables (FormLabel est automatiquement lie au champ)
<FormLabel>Email</FormLabel>
<FormControl>
  <Input type="email" />
</FormControl>