# Story 5.2: Validation JavaScript Côté Client ## Status review ## Story **As a** visiteur, **I want** être informé immédiatement si je fais une erreur de saisie, **so that** je corrige avant d'envoyer et j'évite les allers-retours. ## Acceptance Criteria 1. La validation JavaScript s'exécute à la soumission ET à la perte de focus (blur) 2. Les messages d'erreur sont affichés sous chaque champ concerné 3. Les champs en erreur sont visuellement distingués (bordure rouge, icône) 4. Le message d'erreur est clair et indique comment corriger 5. Le bouton d'envoi est désactivé tant que le formulaire contient des erreurs 6. La validation est en JavaScript vanilla (pas de bibliothèque) ## Tasks / Subtasks - [x] **Task 1 : Créer le validateur de formulaire** (AC: 6) - [x] Créer `assets/js/contact-form.js` - [x] Classe ou objet `FormValidator` - [x] Méthodes de validation par type de champ - [x] **Task 2 : Implémenter la validation au blur** (AC: 1) - [x] Écouter l'événement `blur` sur chaque champ - [x] Valider le champ concerné - [x] Afficher/masquer l'erreur - [x] **Task 3 : Implémenter la validation à la soumission** (AC: 1) - [x] Écouter l'événement `submit` - [x] Valider tous les champs - [x] Empêcher l'envoi si erreurs - [x] **Task 4 : Afficher les erreurs** (AC: 2, 3, 4) - [x] Message sous le champ (data-error) - [x] Bordure rouge sur le champ (classes Tailwind) - [x] Messages clairs et actionnables - [x] **Task 5 : Gérer l'état du bouton** (AC: 5) - [x] Désactiver si erreurs - [x] Réactiver quand tout est valide ## Dev Notes ### Structure assets/js/contact-form.js ```javascript /** * Validation du formulaire de contact * JavaScript vanilla - pas de dépendances */ class FormValidator { constructor(formId) { this.form = document.getElementById(formId); if (!this.form) return; this.submitBtn = document.getElementById('submit-btn'); this.fields = {}; this.errors = {}; this.init(); } init() { // Définir les règles de validation this.rules = { nom: { required: true, minLength: 2, maxLength: 100, message: 'Veuillez entrer votre nom (2 caractères minimum)' }, prenom: { required: true, minLength: 2, maxLength: 100, message: 'Veuillez entrer votre prénom (2 caractères minimum)' }, email: { required: true, email: true, message: 'Veuillez entrer une adresse email valide' }, categorie: { required: true, message: 'Veuillez sélectionner une catégorie' }, objet: { required: true, minLength: 5, maxLength: 200, message: 'Veuillez entrer un objet (5 caractères minimum)' }, message: { required: true, minLength: 20, maxLength: 5000, message: 'Veuillez entrer votre message (20 caractères minimum)' } }; // Récupérer les champs Object.keys(this.rules).forEach(fieldName => { this.fields[fieldName] = this.form.querySelector(`[name="${fieldName}"]`); }); this.bindEvents(); } bindEvents() { // Validation au blur Object.keys(this.fields).forEach(fieldName => { const field = this.fields[fieldName]; if (field) { field.addEventListener('blur', () => this.validateField(fieldName)); field.addEventListener('input', () => this.clearError(fieldName)); } }); // Validation à la soumission this.form.addEventListener('submit', (e) => this.handleSubmit(e)); // Compteur de caractères pour le message const messageField = this.fields.message; if (messageField) { messageField.addEventListener('input', () => this.updateCharCount()); } } validateField(fieldName) { const field = this.fields[fieldName]; const rule = this.rules[fieldName]; if (!field || !rule) return true; const value = field.value.trim(); let isValid = true; let errorMessage = ''; // Required if (rule.required && !value) { isValid = false; errorMessage = rule.message; } // Min length if (isValid && rule.minLength && value.length < rule.minLength) { isValid = false; errorMessage = rule.message; } // Max length if (isValid && rule.maxLength && value.length > rule.maxLength) { isValid = false; errorMessage = `Maximum ${rule.maxLength} caractères`; } // Email format if (isValid && rule.email && value) { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(value)) { isValid = false; errorMessage = rule.message; } } // Afficher ou masquer l'erreur if (isValid) { this.clearError(fieldName); } else { this.showError(fieldName, errorMessage); } this.errors[fieldName] = !isValid; this.updateSubmitButton(); return isValid; } validateAll() { let allValid = true; Object.keys(this.rules).forEach(fieldName => { if (!this.validateField(fieldName)) { allValid = false; } }); return allValid; } showError(fieldName, message) { const field = this.fields[fieldName]; const errorEl = this.form.querySelector(`[data-error="${fieldName}"]`); if (field) { field.classList.add('input-error'); field.setAttribute('aria-invalid', 'true'); } if (errorEl) { errorEl.textContent = message; errorEl.classList.remove('hidden'); } } clearError(fieldName) { const field = this.fields[fieldName]; const errorEl = this.form.querySelector(`[data-error="${fieldName}"]`); if (field) { field.classList.remove('input-error'); field.removeAttribute('aria-invalid'); } if (errorEl) { errorEl.textContent = ''; errorEl.classList.add('hidden'); } this.errors[fieldName] = false; this.updateSubmitButton(); } updateSubmitButton() { const hasErrors = Object.values(this.errors).some(err => err); if (this.submitBtn) { this.submitBtn.disabled = hasErrors; } } updateCharCount() { const messageField = this.fields.message; const countEl = document.getElementById('message-count'); if (messageField && countEl) { countEl.textContent = messageField.value.length; } } handleSubmit(e) { e.preventDefault(); if (!this.validateAll()) { // Focus sur le premier champ en erreur const firstError = Object.keys(this.errors).find(key => this.errors[key]); if (firstError && this.fields[firstError]) { this.fields[firstError].focus(); } return; } // Si valide, déclencher l'envoi (géré par une autre partie du code) this.form.dispatchEvent(new CustomEvent('validSubmit')); } getFormData() { const formData = {}; Object.keys(this.fields).forEach(fieldName => { if (this.fields[fieldName]) { formData[fieldName] = this.fields[fieldName].value.trim(); } }); // Ajouter le champ entreprise (optionnel) const entreprise = this.form.querySelector('[name="entreprise"]'); if (entreprise) { formData.entreprise = entreprise.value.trim(); } return formData; } } // Initialisation document.addEventListener('DOMContentLoaded', () => { window.contactFormValidator = new FormValidator('contact-form'); }); ``` ### Messages d'Erreur | Champ | Message | |-------|---------| | nom | Veuillez entrer votre nom (2 caractères minimum) | | prenom | Veuillez entrer votre prénom (2 caractères minimum) | | email | Veuillez entrer une adresse email valide | | categorie | Veuillez sélectionner une catégorie | | objet | Veuillez entrer un objet (5 caractères minimum) | | message | Veuillez entrer votre message (20 caractères minimum) | ### Règles de Validation | Champ | Required | Min | Max | Format | |-------|----------|-----|-----|--------| | nom | Oui | 2 | 100 | - | | prenom | Oui | 2 | 100 | - | | email | Oui | - | 255 | email | | entreprise | Non | - | 200 | - | | categorie | Oui | - | - | - | | objet | Oui | 5 | 200 | - | | message | Oui | 20 | 5000 | - | ## Testing - [] La validation se déclenche au blur - [] La validation se déclenche à la soumission - [] Les messages d'erreur s'affichent sous les champs - [] Les champs en erreur ont une bordure rouge - [] Le bouton est désactivé si erreurs - [] Le compteur de caractères fonctionne - [] Le focus va au premier champ en erreur - [] Email invalide est détecté ## Dev Agent Record ### Agent Model Used GPT-5 Codex ### Implementation Plan - Implémenter les tâches 1 à 5 dans l’ordre avec tests à chaque étape. - Mettre à jour le formulaire pour les hooks JS (data-error, id submit). ### File List | File | Action | Description | |------|--------|-------------| | `assets/js/contact-form.js` | Created | Classe FormValidator avec validation complète | | `pages/contact.php` | Modified | Hooks JS (data-error, submit-btn, script) | | `tests/contact-validation.test.php` | Added | Tests du validateur JS (présence/méthodes) | | `tests/contact.test.php` | Modified | Vérifications markup contact + data-error | | `tests/run.ps1` | Modified | Ajout du test contact-validation | ### Completion Notes - Task 1 : classe FormValidator et règles de validation mises en place (JS vanilla) - Task 2 : validation au blur + gestion des erreurs champ par champ - Task 3 : validation à la soumission et blocage si erreurs - Task 4 : messages d'erreur + bordures invalides configurés - Task 5 : désactivation/réactivation du bouton d'envoi - Tests : `powershell -ExecutionPolicy Bypass -File tests/run.ps1` ### Debug Log References Aucun problème rencontré. ## Change Log | Date | Version | Description | Author | |------|---------|-------------|--------| | 2026-01-22 | 0.1 | Création initiale | Sarah (PO) | | 2026-01-24 | 1.0 | Implémentation complète | James (Dev) | | 2026-02-04 | 1.1 | Validation JS côté client | Amelia (Dev) |