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.
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>