165 lines
6.8 KiB
Markdown
165 lines
6.8 KiB
Markdown
---
|
|
name: resilient-forms-validation
|
|
description: >-
|
|
Build high-resilience, accessible web forms with unified schema validation (Zod / Valibot),
|
|
React 19 Actions / React Hook Form, optimistic UX, draft auto-save persistence, and strict WCAG error announcements.
|
|
---
|
|
|
|
# Resilient Forms & Accessible Schema Validation
|
|
|
|
Ce skill encadre la création de formulaires web de niveau entreprise. Il élimine les formulaires artisanaux fragiles (multiples `useState` non validés, perte de données lors du rechargement, spinners bloquants) au profit d'un contrat de validation de schéma unifié, d'une résilience hors-ligne et d'une accessibilité irréprochable.
|
|
|
|
---
|
|
|
|
## 🎯 Déclencheurs d'Activation
|
|
|
|
Activez ce skill dès que :
|
|
1. Vous créez ou refactorisez un formulaire (inscription, authentification, paiement, paramètres utilisateur, formulaires multi-étapes).
|
|
2. Vous validez des entrées utilisateur côté client et côté serveur avec **Zod** ou **Valibot**.
|
|
3. Vous implémentez des mutations asynchrones avec **React 19 Actions** (`useActionState`) ou **React Hook Form**.
|
|
4. Vous devez garantir la persistance des brouillons (*draft auto-save*) face aux rafraîchissements intempestifs.
|
|
5. Vous auditez l'accessibilité des erreurs de saisie selon le critère WCAG 3.3 (Identification des erreurs et suggestions).
|
|
|
|
---
|
|
|
|
## 📐 1. Les 4 Piliers d'un Formulaire Résilient
|
|
|
|
1. **Schéma Unique Partagé (Zod)** : Le même schéma TypeScript valide la saisie en temps réel côté client et protège l'endpoint API côté serveur.
|
|
2. **Accessibilité des Erreurs (WCAG 3.3)** :
|
|
- Chaque champ en erreur porte `aria-invalid="true"`.
|
|
- Les messages d'erreur sont reliés au champ via `aria-describedby="field-error-id"`.
|
|
- Un conteneur avec `role="alert"` ou `aria-live="polite"` annonce les erreurs aux lecteurs d'écran sans déplacer brutalement le curseur.
|
|
3. **Persistance Locale Automatique des Brouillons** : Pour les formulaires longs (> 3 champs), les valeurs sont sauvegardées dans `sessionStorage` ou `localStorage` pour éviter la perte de données en cas de crash d'onglet.
|
|
4. **Mutations Non Bloquantes** : Ne jamais afficher d'overlay gris qui fige l'écran entier. Seul le bouton d'action affiche un état en cours, tout en restant désactivé pour empêcher la double soumission.
|
|
|
|
---
|
|
|
|
## 💻 2. Exemple d'Implémentation de Référence (Zod + React 19 Action)
|
|
|
|
```tsx
|
|
'use client';
|
|
|
|
import React, { useActionState } from 'react';
|
|
import { z } from 'zod';
|
|
|
|
// 1. Schéma Zod Partagé
|
|
export const ContactSchema = z.object({
|
|
name: z.string().min(2, 'Le nom doit comporter au moins 2 caractères'),
|
|
email: z.string().email('Adresse email invalide'),
|
|
message: z.string().min(10, 'Votre message doit comporter au moins 10 caractères'),
|
|
});
|
|
|
|
type FormState = {
|
|
success?: boolean;
|
|
errors?: Record<string, string[]>;
|
|
values?: { name: string; email: string; message: string };
|
|
};
|
|
|
|
// 2. Action Serveur / Handler Typé
|
|
async function submitContactAction(prevState: FormState, formData: FormData): Promise<FormState> {
|
|
const rawData = {
|
|
name: formData.get('name') as string,
|
|
email: formData.get('email') as string,
|
|
message: formData.get('message') as string,
|
|
};
|
|
|
|
const validated = ContactSchema.safeParse(rawData);
|
|
if (!validated.success) {
|
|
return {
|
|
success: false,
|
|
errors: validated.error.flatten().fieldErrors,
|
|
values: rawData,
|
|
};
|
|
}
|
|
|
|
// Traitement backend sécurisé...
|
|
return { success: true };
|
|
}
|
|
|
|
// 3. Composant Formulaire Accessible
|
|
export const ResilientContactForm = () => {
|
|
const [state, formAction, isPending] = useActionState(submitContactAction, {});
|
|
|
|
return (
|
|
<form action={formAction} noValidate className="flex flex-col gap-5 max-w-md w-full">
|
|
{state.success && (
|
|
<div role="status" className="p-4 rounded-lg bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-sm">
|
|
Votre message a été envoyé avec succès !
|
|
</div>
|
|
)}
|
|
|
|
{/* Champ Nom */}
|
|
<div className="flex flex-col gap-1.5">
|
|
<label htmlFor="name" className="text-sm font-medium text-white/90">
|
|
Nom complet
|
|
</label>
|
|
<input
|
|
id="name"
|
|
name="name"
|
|
type="text"
|
|
defaultValue={state.values?.name}
|
|
aria-invalid={Boolean(state.errors?.name)}
|
|
aria-describedby={state.errors?.name ? 'name-error' : undefined}
|
|
className="px-3.5 py-2.5 rounded-lg bg-[oklch(0.18_0.01_260)] border border-[oklch(1_0_0/0.12)]
|
|
text-white placeholder:text-white/40 focus:ring-2 focus:ring-indigo-400 focus:outline-none"
|
|
/>
|
|
{state.errors?.name && (
|
|
<p id="name-error" role="alert" className="text-xs text-rose-400 font-medium">
|
|
{state.errors.name[0]}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Champ Email */}
|
|
<div className="flex flex-col gap-1.5">
|
|
<label htmlFor="email" className="text-sm font-medium text-white/90">
|
|
Adresse email
|
|
</label>
|
|
<input
|
|
id="email"
|
|
name="email"
|
|
type="email"
|
|
defaultValue={state.values?.email}
|
|
aria-invalid={Boolean(state.errors?.email)}
|
|
aria-describedby={state.errors?.email ? 'email-error' : undefined}
|
|
className="px-3.5 py-2.5 rounded-lg bg-[oklch(0.18_0.01_260)] border border-[oklch(1_0_0/0.12)]
|
|
text-white placeholder:text-white/40 focus:ring-2 focus:ring-indigo-400 focus:outline-none"
|
|
/>
|
|
{state.errors?.email && (
|
|
<p id="email-error" role="alert" className="text-xs text-rose-400 font-medium">
|
|
{state.errors.email[0]}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Bouton de soumission avec état de chargement intégré */}
|
|
<button
|
|
type="submit"
|
|
disabled={isPending}
|
|
className="mt-2 px-4 py-2.5 rounded-lg font-medium text-sm text-white bg-indigo-600 hover:bg-indigo-500
|
|
disabled:opacity-50 disabled:cursor-not-allowed transition-all flex items-center justify-center gap-2"
|
|
>
|
|
{isPending ? (
|
|
<>
|
|
<span className="size-4 rounded-full border-2 border-white/30 border-t-white animate-spin" />
|
|
Envoi en cours...
|
|
</>
|
|
) : (
|
|
'Envoyer le message'
|
|
)}
|
|
</button>
|
|
</form>
|
|
);
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## 📋 Checklist de Conformité Formulaires
|
|
|
|
- [ ] Un schéma Zod ou Valibot est défini et partagé entre validation UI et API.
|
|
- [ ] L'attribut `noValidate` est présent sur `<form>` pour éviter les infobulles natives inconsistantes des navigateurs.
|
|
- [ ] Tout champ en erreur est balisé par `aria-invalid="true"` et relié à son message par `aria-describedby`.
|
|
- [ ] Le bouton d'envoi est désactivé pendant la soumission pour éviter les doublons.
|
|
- [ ] En cas de rejet serveur, les valeurs déjà saisies sont conservées pour éviter à l'utilisateur de tout retaper.
|