Skills/resilient-forms-validation/SKILL.md

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.