Amélioration de l'accessibilité

Dans le chapitre précédent, nous avons vu comment intercepter les erreurs (y compris les erreurs 404) et afficher un contenu de repli à l'utilisateur. Cependant, il nous reste à aborder une autre pièce du puzzle : la validation des formulaires. Voyons comment implémenter une validation côté serveur avec les Server Actions, et comment afficher les erreurs de formulaire en utilisant le hook useActionState de React - tout en gardant l'accessibilité à l'esprit !

Qu'est-ce que l'accessibilité ?

L'accessibilité consiste à concevoir et implémenter des applications web utilisables par tous, y compris les personnes en situation de handicap. C'est un vaste sujet qui couvre de nombreux aspects comme la navigation au clavier, le HTML sémantique, les images, les couleurs, les vidéos, etc.

Bien que nous n'abordions pas en profondeur l'accessibilité dans ce cours, nous discuterons des fonctionnalités d'accessibilité disponibles dans Next.js et de quelques bonnes pratiques pour rendre vos applications plus accessibles.

Si vous souhaitez en savoir plus sur l'accessibilité, nous recommandons le cours Learn Accessibility de web.dev.

Utilisation du plugin ESLint pour l'accessibilité dans Next.js

Next.js inclut le plugin eslint-plugin-jsx-a11y dans sa configuration ESLint pour aider à détecter précocement les problèmes d'accessibilité. Par exemple, ce plugin avertit si vous avez des images sans texte alternatif (alt), si vous utilisez incorrectement les attributs aria-* et role, etc.

Optionnellement, si vous souhaitez essayer, ajoutez next lint comme script dans votre fichier package.json :

/package.json
"scripts": {
    "build": "next build",
    "dev": "next dev",
    "start": "next start",
    "lint": "next lint"
},

Puis exécutez pnpm lint dans votre terminal :

Terminal
pnpm lint

Cela vous guidera pour installer et configurer ESLint dans votre projet. Si vous exécutez pnpm lint maintenant, vous devriez voir le résultat suivant :

Terminal
 Aucun avertissement ou erreur ESLint

Mais que se passerait-il si vous aviez une image sans texte alternatif ? Découvrons-le !

Allez dans /app/ui/invoices/table.tsx et supprimez la prop alt de l'image. Vous pouvez utiliser la fonction de recherche de votre éditeur pour trouver rapidement le composant <Image> :

/app/ui/invoices/table.tsx
<Image
  src={invoice.image_url}
  className="rounded-full"
  width={28}
  height={28}
  alt={`${invoice.name}'s profile picture`} // Supprimez cette ligne
/>

Exécutez à nouveau pnpm lint, et vous devriez voir l'avertissement suivant :

Terminal
./app/ui/invoices/table.tsx
45:25  Avertissement : Les éléments Image doivent avoir une prop alt,
soit avec un texte significatif, soit une chaîne vide pour les images décoratives. jsx-a11y/alt-text

Bien que l'ajout et la configuration d'un linter ne soient pas obligatoires, cela peut être utile pour détecter les problèmes d'accessibilité pendant le développement.

Amélioration de l'accessibilité des formulaires

Nous faisons déjà trois choses pour améliorer l'accessibilité de nos formulaires :

  • HTML sémantique : Utilisation d'éléments sémantiques (<input>, <option>, etc.) plutôt que des <div>. Cela permet aux technologies d'assistance (TA) de se concentrer sur les éléments de saisie et de fournir des informations contextuelles appropriées à l'utilisateur, rendant le formulaire plus facile à naviguer et à comprendre.
  • Étiquetage : L'inclusion de <label> et de l'attribut htmlFor garantit que chaque champ de formulaire a une étiquette textuelle descriptive. Cela améliore la prise en charge par les TA en fournissant du contexte et améliore aussi l'ergonomie en permettant aux utilisateurs de cliquer sur l'étiquette pour accéder au champ correspondant.
  • Contour de focus : Les champs sont correctement stylisés pour afficher un contour lorsqu'ils sont en focus. Ceci est crucial pour l'accessibilité car cela indique visuellement l'élément actif sur la page, aidant les utilisateurs de clavier et de lecteurs d'écran à comprendre où ils se trouvent dans le formulaire. Vous pouvez le vérifier en appuyant sur tab.

Ces pratiques constituent une bonne base pour rendre vos formulaires plus accessibles à de nombreux utilisateurs. Cependant, elles ne traitent pas de la validation des formulaires et des erreurs.

Validation des formulaires

Allez sur http://localhost:3000/dashboard/invoices/create, et soumettez un formulaire vide. Que se passe-t-il ?

Vous obtenez une erreur ! Cela se produit parce que vous envoyez des valeurs vides à votre Server Action. Vous pouvez éviter cela en validant votre formulaire côté client ou côté serveur.

Validation côté client

Il existe plusieurs façons de valider les formulaires côté client. La plus simple serait d'utiliser la validation native du navigateur en ajoutant l'attribut required aux éléments <input> et <select> de vos formulaires. Par exemple :

/app/ui/invoices/create-form.tsx
<input
  id="amount"
  name="amount"
  type="number"
  placeholder="Enter USD amount"
  className="peer block w-full rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500"
  required
/>

Soumettez à nouveau le formulaire. Le navigateur affichera un avertissement si vous essayez de soumettre un formulaire avec des valeurs vides.

Cette approche est généralement acceptable car certaines TA prennent en charge la validation du navigateur.

Une alternative à la validation côté client est la validation côté serveur. Voyons comment l'implémenter dans la section suivante. Pour l'instant, supprimez les attributs required si vous les avez ajoutés.

Validation côté serveur (Server-Side validation)

En validant les formulaires côté serveur, vous pouvez :

  • Garantir que vos données sont dans le format attendu avant de les envoyer à votre base de données.
  • Réduire le risque que des utilisateurs malveillants contournent la validation côté client.
  • Avoir une source unique de vérité pour ce qui est considéré comme des données valides.

Dans votre composant create-form.tsx, importez le hook useActionState depuis react. Comme useActionState est un hook, vous devrez transformer votre formulaire en Composant Client en utilisant la directive "use client" :

/app/ui/invoices/create-form.tsx
'use client';
 
// ...
import { useActionState } from 'react';

Dans votre Composant Formulaire, le hook useActionState :

  • Prend deux arguments : (action, initialState).
  • Retourne deux valeurs : [state, formAction] - l'état du formulaire et une fonction à appeler lors de la soumission du formulaire.

Passez votre action createInvoice comme argument de useActionState, et dans l'attribut <form action={}>, appelez formAction.

/app/ui/invoices/create-form.tsx
// ...
import { useActionState } from 'react';
 
export default function Form({ customers }: { customers: CustomerField[] }) {
  const [state, formAction] = useActionState(createInvoice, initialState);
 
  return <form action={formAction}>...</form>;
}

L'initialState peut être n'importe quoi que vous définissez. Dans ce cas, créez un objet avec deux clés vides : message et errors, et importez le type State depuis votre fichier actions.ts. State n'existe pas encore, mais nous le créerons ensuite :

/app/ui/invoices/create-form.tsx
// ...
import { createInvoice, State } from '@/app/lib/actions';
import { useActionState } from 'react';
 
export default function Form({ customers }: { customers: CustomerField[] }) {
  const initialState: State = { message: null, errors: {} };
  const [state, formAction] = useActionState(createInvoice, initialState);
 
  return <form action={formAction}>...</form>;
}

Cela peut sembler confus au début, mais cela prendra plus de sens une fois que vous aurez mis à jour l'action serveur. Faisons cela maintenant.

Dans votre fichier action.ts, vous pouvez utiliser Zod pour valider les données du formulaire. Mettez à jour votre FormSchema comme suit :

/app/lib/actions.ts
const FormSchema = z.object({
  id: z.string(),
  customerId: z.string({
    invalid_type_error: 'Veuillez sélectionner un client.',
  }),
  amount: z.coerce
    .number()
    .gt(0, { message: 'Veuillez entrer un montant supérieur à 0 $.' }),
  status: z.enum(['pending', 'paid'], {
    invalid_type_error: 'Veuillez sélectionner un statut de facture.',
  }),
  date: z.string(),
});
  • customerId - Zod génère déjà une erreur si le champ client est vide car il attend un type string. Mais ajoutons un message convivial si l'utilisateur ne sélectionne pas de client.
  • amount - Comme vous convertissez le type amount de string à number, il sera par défaut à zéro si la chaîne est vide. Disons à Zod que nous voulons toujours un montant supérieur à 0 avec la fonction .gt().
  • status - Zod génère déjà une erreur si le champ statut est vide car il attend "pending" ou "paid". Ajoutons également un message convivial si l'utilisateur ne sélectionne pas de statut.

Ensuite, mettez à jour votre action createInvoice pour accepter deux paramètres - prevState et formData :

/app/lib/actions.ts
export type State = {
  errors?: {
    customerId?: string[];
    amount?: string[];
    status?: string[];
  };
  message?: string | null;
};
 
export async function createInvoice(prevState: State, formData: FormData) {
  // ...
}
  • formData - identique à avant.
  • prevState - contient l'état passé depuis le hook useActionState. Vous ne l'utiliserez pas dans l'action dans cet exemple, mais c'est une prop requise.

Puis, changez la fonction parse() de Zod en safeParse() :

/app/lib/actions.ts
export async function createInvoice(prevState: State, formData: FormData) {
  // Valider les champs du formulaire avec Zod
  const validatedFields = CreateInvoice.safeParse({
    customerId: formData.get('customerId'),
    amount: formData.get('amount'),
    status: formData.get('status'),
  });
 
  // ...
}

safeParse() retournera un objet contenant soit un champ success soit un champ error. Cela aidera à gérer la validation plus élégamment sans avoir à mettre cette logique dans un bloc try/catch.

Avant d'envoyer les informations à votre base de données, vérifiez si les champs du formulaire ont été validés correctement avec une condition :

/app/lib/actions.ts
export async function createInvoice(prevState: State, formData: FormData) {
  // Valider les champs du formulaire avec Zod
  const validatedFields = CreateInvoice.safeParse({
    customerId: formData.get('customerId'),
    amount: formData.get('amount'),
    status: formData.get('status'),
  });
 
  // Si la validation du formulaire échoue, retournez les erreurs tôt. Sinon, continuez.
  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
      message: 'Champs manquants. Échec de la création de la facture.',
    };
  }
 
  // ...
}

Si validatedFields n'est pas un succès, nous retournons la fonction tôt avec les messages d'erreur de Zod.

Astuce : console.log validatedFields et soumettez un formulaire vide pour voir sa forme.

Enfin, comme vous gérez la validation du formulaire séparément, en dehors de votre bloc try/catch, vous pouvez retourner un message spécifique pour toute erreur de base de données. Votre code final devrait ressembler à ceci :

/app/lib/actions.ts
export async function createInvoice(prevState: State, formData: FormData) {
  // Valider le formulaire avec Zod
  const validatedFields = CreateInvoice.safeParse({
    customerId: formData.get('customerId'),
    amount: formData.get('amount'),
    status: formData.get('status'),
  });
 
  // Si la validation du formulaire échoue, retournez les erreurs tôt. Sinon, continuez.
  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
      message: 'Champs manquants. Échec de la création de la facture.',
    };
  }
 
  // Préparer les données pour l'insertion dans la base de données
  const { customerId, amount, status } = validatedFields.data;
  const amountInCents = amount * 100;
  const date = new Date().toISOString().split('T')[0];
 
  // Insérer les données dans la base de données
  try {
    await sql`
      INSERT INTO invoices (customer_id, amount, status, date)
      VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
    `;
  } catch (error) {
    // Si une erreur de base de données survient, retournez une erreur plus spécifique.
    return {
      message: 'Erreur de base de données : Échec de la création de la facture.',
    };
  }
 
  // Revalider le cache pour la page des factures et rediriger l'utilisateur.
  revalidatePath('/dashboard/invoices');
  redirect('/dashboard/invoices');
}

Parfait, maintenant affichons les erreurs dans votre composant formulaire. De retour dans le composant create-form.tsx, vous pouvez accéder aux erreurs en utilisant l'état state du formulaire.

Ajoutez un opérateur ternaire qui vérifie chaque erreur spécifique. Par exemple, après le champ client, vous pouvez ajouter :

/app/ui/invoices/create-form.tsx
<form action={formAction}>
  <div className="rounded-md bg-gray-50 p-4 md:p-6">
    {/* Nom du client */}
    <div className="mb-4">
      <label htmlFor="customer" className="mb-2 block text-sm font-medium">
        Choisir un client
      </label>
      <div className="relative">
        <select
          id="customer"
          name="customerId"
          className="peer block w-full rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500"
          defaultValue=""
          aria-describedby="customer-error"
        >
          <option value="" disabled>
            Sélectionner un client
          </option>
          {customers.map((name) => (
            <option key={name.id} value={name.id}>
              {name.name}
            </option>
          ))}
        </select>
        <UserCircleIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500" />
      </div>
      <div id="customer-error" aria-live="polite" aria-atomic="true">
        {state.errors?.customerId &&
          state.errors.customerId.map((error: string) => (
            <p className="mt-2 text-sm text-red-500" key={error}>
              {error}
            </p>
          ))}
      </div>
    </div>
    // ...
  </div>
</form>

Astuce : Vous pouvez console.log state dans votre composant et vérifier que tout est correctement connecté. Vérifiez la console dans les outils de développement car votre formulaire est maintenant un Composant Client.

Dans le code ci-dessus, vous ajoutez également les étiquettes aria suivantes :

  • aria-describedby="customer-error" : Cela établit une relation entre l'élément select et le conteneur des messages d'erreur. Il indique que le conteneur avec id="customer-error" décrit l'élément select. Les lecteurs d'écran liront cette description lorsque l'utilisateur interagit avec la boîte select pour les informer des erreurs.
  • id="customer-error" : Cet attribut id identifie de manière unique l'élément HTML qui contient le message d'erreur pour l'entrée select. Ceci est nécessaire pour que aria-describedby établisse la relation.
  • aria-live="polite" : Le lecteur d'écran doit notifier poliment l'utilisateur lorsque l'erreur à l'intérieur du div est mise à jour. Lorsque le contenu change (par exemple, lorsqu'un utilisateur corrige une erreur), le lecteur d'écran annoncera ces changements, mais seulement lorsque l'utilisateur est inactif pour ne pas l'interrompre.

Pratique : Ajout d'étiquettes aria

En utilisant l'exemple ci-dessus, ajoutez des erreurs à vos autres champs de formulaire. Vous devriez également afficher un message en bas du formulaire si des champs sont manquants. Votre interface devrait ressembler à ceci :

Formulaire de création de facture montrant des messages d'erreur pour chaque champ.

Une fois prêt, exécutez pnpm lint pour vérifier si vous utilisez correctement les étiquettes aria.

Si vous souhaitez vous challenger, prenez les connaissances apprises dans ce chapitre et ajoutez la validation de formulaire au composant edit-form.tsx.

Vous devrez :

  • Ajouter useActionState à votre composant edit-form.tsx.
  • Modifier l'action updateInvoice pour gérer les erreurs de validation de Zod.
  • Afficher les erreurs dans votre composant et ajouter des étiquettes aria pour améliorer l'accessibilité.

Une fois prêt, développez l'extrait de code ci-dessous pour voir la solution :

On this page