Aller au contenu
Sylvain Joffraud l4w
Retour

Wording UX : erreurs inline par champ sur des uploads

Spec design (Figma) pour une étape d’upload de pièces justificatives, trois messages d’erreur exigés au mot près :

  1. « Type de fichier non autorisé. Seuls PDF, JPG ou PNG sont acceptés. »
  2. « Le fichier est trop volumineux. Taille maximale : 5 Mo. »
  3. « Veuillez sélectionner un fichier à uploader. »

En cours d’implémentation, on avait dérivé — sans mauvaise intention, juste par « à peu près ». Et l’« à peu près » sur du wording UX, c’est une dette qui se voit.

Symptôme

Deux écarts, repérés à la relecture face à la maquette :

Le second écart est le plus pernicieux : fonctionnellement « ça marche » (l’utilisateur est bien bloqué), mais l’UX est cassée. Un message global « il manque des pièces » sur un formulaire qui en demande huit, c’est une chasse au trésor.

Cause

Deux causes distinctes :

  1. La paraphrase au feeling. Le message de taille avait été recopié « dans l’esprit », pas à la lettre. Personne n’avait décidé d’ajouter « (après compression) » ; ça avait l’air utile sur le moment. Sauf que le wording d’une maquette est validé par le client/designer — le modifier, même pour « mieux », c’est sortir du contrat.

  2. Le composant d’upload n’avait pas de canal d’erreur par champ. Le UploadField (composant Twig) ne connaissait que son état « rempli / vide ». Aucune prop error. Donc le serveur n’avait nulle part où poser une erreur sur un champ précis — d’où le repli sur le flash global, faute de mieux.

Solution

D’abord, aligner le message mot-pour-mot avec la spec — côté client comme côté serveur, et au même endroit pour les deux. Le message vit à un seul endroit, on supprime la variante artisanale :

final class UploadValidator
{
    public const ERROR_MESSAGE = 'Veuillez sélectionner un fichier à uploader.';
    // ...
}
// Contrôleur d'upload — messages strictement conformes à la maquette
return $this->json(
    ['error' => 'Le fichier est trop volumineux. Taille maximale : 5 Mo.'],
    Response::HTTP_BAD_REQUEST,
);
// ...
return $this->json(
    ['error' => 'Type de fichier non autorisé. Seuls PDF, JPG ou PNG sont acceptés.'],
    Response::HTTP_BAD_REQUEST,
);

Le « (après compression) » saute. Le détail d’implémentation reste dans le code (et dans les logs), pas dans l’UI.

Ensuite, donner au composant un canal d’erreur par champ. On ajoute une prop error au UploadField :

#[AsTwigComponent]
final class UploadField
{
    public string $sectionId = '';
    public string $title = '';
    public bool $required = true;
    public array $uploaded = [];
    public ?string $error = null;   // ← erreur inline, rendue sous le champ
}

Côté validation serveur, le UploadValidator ne renvoie plus un booléen « tout est bon / il manque des trucs », mais une map sectionId → message : chaque champ en défaut sait exactement quoi afficher.

/** @return array<string, string>  sectionId => message d'erreur (vide si tout OK) */
public function validate(array $uploads, bool $contratActuel, bool $hasConjoint, array $enfants): array
{
    $errors = [];
    foreach ($this->computeRequiredSections($contratActuel, $hasConjoint, $enfants) as $section) {
        if (empty($uploads[$section])) {
            $errors[$section] = self::ERROR_MESSAGE;
        }
    }
    return $errors;   // ex: ['principal_rib' => 'Veuillez sélectionner un fichier à uploader.']
}

Le contrôleur passe cette map au template, qui distribue chaque message au UploadField correspondant via sa prop error. Résultat : l’erreur s’affiche sous le bon champ, exactement comme la maquette. Le flash global disparaît au profit d’erreurs contextuelles.

Enfin, validation client et serveur affichent le même libellé : la validation JS (taille, type, présence) utilise les mêmes chaînes que le serveur. Si le JS dit « 5 Mo max », le serveur dit pareil, mot pour mot — pas de divergence selon que la validation tombe avant ou après l’envoi.

Lessons learned

#ux #forms #symfony #twig #design-system #figma


Partager cet article :

Article précédent
State machine : fail-open vs fail-closed selon la criticité
Article suivant
iOS Safari injecte une NBSP : la regex `\s` ne la voit pas