Skills/resilient-forms-validation/SKILL.md

6.8 KiB

name description
resilient-forms-validation 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)

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