Cours Vue.js · Module 5: Événements et formulaires

Formulaires avancés

6 min de lecture
Dans cette leçon3

Les ingénieurs de NOVA LAB enregistrent un nouveau module de la station, et le formulaire des leçons précédentes ne leur suffit plus. Ils veulent cocher plusieurs systèmes à surveiller d'un coup, choisir un module dans un catalogue qui change chaque semaine, et empêcher qu'un nom de deux lettres ou une année de déploiement 1999 n'entre dans la base. Chacun de ces besoins correspond à un nouvel élément de formulaire.

Sélection multiple

L'attribut multiple transforme une liste déroulante en liste à choix multiples. v-model écrit alors dans la variable un tableau des valeurs des options sélectionnées, donc la variable doit démarrer comme un tableau vide. La méthode join(', ') colle le tableau en une chaîne lisible.

1<template>
2  <div>
3    <label>Sélectionne les systèmes à surveiller (Ctrl+Clic) :</label>
4    <select v-model="selectedSystems" multiple>
5      <option value="oxygen">Générateur d'oxygène</option>
6      <option value="water">Recycleur d'eau</option>
7      <option value="power">Énergie solaire</option>
8      <option value="comms">Communications</option>
9    </select>
10    <p>Sélectionnés : {{ selectedSystems.join(', ') }}</p>
11  </div>
12</template>
13
14<script setup>
15import { ref } from 'vue'
16
17const selectedSystems = ref([])
18</script>

Tu sélectionnes des options supplémentaires en maintenant Ctrl, ou Cmd sur macOS. Dans le tableau, les valeurs apparaissent dans l'ordre des options de la liste, et non dans l'ordre des clics, contrairement à un groupe de cases à cocher. La syntaxe de v-model n'a pas changé, seul le type de données a changé. Avec trois ou quatre options, je te recommande pourtant un groupe de cases à cocher : il ne demande aucun raccourci clavier caché et montre tous les choix d'un coup d'œil.

Options dynamiques

Le catalogue des modules arrive sous forme de tableau d'objets avec un id, un nom et un secteur. Les options sont générées par v-for, et :value="mod.id" écrit l'id numérique dans la variable. Le nom du module choisi est retrouvé par une fonction qui utilise la méthode find, laquelle retourne le premier élément qui remplit une condition, ou undefined. L'opérateur ?. (optional chaining) ne va chercher name que si un module a été trouvé, et || '' transforme un résultat absent en chaîne vide.

1<template>
2  <div>
3    <select v-model="selectedModule">
4      <option disabled value="">Sélectionne un module de la station</option>
5      <option
6        v-for="mod in modules"
7        :key="mod.id"
8        :value="mod.id"
9      >
10        {{ mod.name }} ({{ mod.sector }})
11      </option>
12    </select>
13
14    <p v-if="selectedModule">
15      Sélectionné : {{ getModuleName(selectedModule) }}
16    </p>
17  </div>
18</template>
19
20<script setup>
21import { ref } from 'vue'
22
23const modules = ref([
24  { id: 1, name: 'Oxygène Alpha', sector: 'Secteur A' },
25  { id: 2, name: 'Plasma Beta', sector: 'Secteur B' },
26  { id: 3, name: 'Eau Gamma', sector: 'Secteur C' }
27])
28
29const selectedModule = ref('')
30
31function getModuleName(id) {
32  return modules.value.find(m => m.id === id)?.name || ''
33}
34</script>

Comme :value conserve le type, selectedModule contient un nombre, et la comparaison stricte m.id === id fonctionne. Le paragraphe avec v-if reste caché tant que la variable contient une chaîne vide. Ajouter un module au tableau modules ajoute aussitôt une option, sans toucher au template.

Validation de formulaire

La validation répond à trois questions : quoi vérifier, quand le vérifier et que faire du résultat. Commençons par le template des deux premiers champs. Chacun a un v-model, un écouteur @blur qui lance la validation à la sortie du champ, et un message d'erreur visible seulement quand il existe.

1<template>
2  <form @submit.prevent="submitForm">
3    <div :class="{ error: errors.name }">
4      <label>Nom du module :</label>
5      <input v-model="form.name" @blur="validateName" />
6      <span v-if="errors.name" class="error-message">
7        {{ errors.name }}
8      </span>
9    </div>
10
11    <div :class="{ error: errors.year }">
12      <label>Année de déploiement :</label>
13      <input v-model.number="form.year" type="number" @blur="validateYear" />
14      <span v-if="errors.year" class="error-message">
15        {{ errors.year }}
16      </span>
17    </div>

:class="{ error: errors.name }" ajoute la classe CSS error quand le message n'est pas vide, donc le champ peut s'allumer en rouge. L'année utilise v-model.number, et le champ de l'ingénieur, dans la partie suivante, utilise v-model.trim : tu connais ces deux modificateurs depuis la leçon précédente. La seconde partie du template termine le formulaire avec un bouton.

1    <div :class="{ error: errors.engineer }">
2      <label>Ingénieur responsable :</label>
3      <input v-model.trim="form.engineer" @blur="validateEngineer" />
4      <span v-if="errors.engineer" class="error-message">
5        {{ errors.engineer }}
6      </span>
7    </div>
8
9    <button type="submit" :disabled="!isFormValid">
10      Enregistrer le module
11    </button>
12  </form>
13</template>

:disabled="!isFormValid" lie l'attribut à une computed, donc le bouton se débloque tout seul dès que le formulaire devient valide. Sans les deux-points, disabled="!isFormValid" serait un simple texte et le bouton resterait bloqué pour toujours, et v-if à la place de :disabled cacherait complètement le bouton. Passons à l'état du formulaire.

1<script setup>
2import { reactive, computed } from 'vue'
3
4const form = reactive({
5  name: '',
6  year: null,
7  engineer: ''
8})
9
10const errors = reactive({
11  name: '',
12  year: '',
13  engineer: ''
14})

reactive crée un objet réactif dont tu lis les champs sans .value. L'objet form contient les valeurs des champs, et errors les messages, une chaîne vide signifiant l'absence d'erreur. Viennent ensuite les fonctions de validation.

1function validateName() {
2  if (!form.name) {
3    errors.name = 'Le nom est obligatoire'
4  } else if (form.name.length < 3) {
5    errors.name = 'Le nom doit comporter au moins 3 caractères'
6  } else {
7    errors.name = ''
8  }
9}
10
11function validateYear() {
12  if (!form.year) {
13    errors.year = 'L\'année est obligatoire'
14  } else if (form.year < 2020 || form.year > 2100) {
15    errors.year = 'L\'année doit être comprise entre 2020 et 2100'
16  } else {
17    errors.year = ''
18  }
19}
20
21function validateEngineer() {
22  errors.engineer = form.engineer ? '' : 'L\'ingénieur est obligatoire'
23}

Chaque fonction vérifie un champ et fixe son message. L'ordre des conditions compte : d'abord le champ vide, puis les détails, comme la longueur du nom ou la plage d'années de 2020 à 2100. Pour finir, la computed et la gestion de l'envoi.

1const isFormValid = computed(() => {
2  return form.name && form.year && form.engineer &&
3         !errors.name && !errors.year && !errors.engineer
4})
5
6function submitForm() {
7  validateName()
8  validateYear()
9  validateEngineer()
10
11  if (isFormValid.value) {
12    console.log('Enregistrement :', form)
13    alert('Module enregistré !')
14  }
15}
16</script>

isFormValid se recalcule automatiquement à chaque changement des données du formulaire, c'est pourquoi une computed convient mieux à ce rôle qu'un drapeau mis à jour à la main. submitForm lance d'abord toutes les validations, car l'opérateur a pu corriger un champ et appuyer sur Entrée sans le quitter, et dans ce cas @blur ne s'est pas encore déclenché. Ensuite, elle lit isFormValid.value : la computed se recalcule à cette lecture, donc elle voit les messages fixés une ligne plus haut. Le déroulé complet est le suivant : un clic sur le bouton, @submit.prevent appelle la procédure, la validation de tous les champs, l'envoi des données si elles sont valides, et enfin un message de succès ou la réinitialisation du formulaire.

Quand faut-il valider ? Je te recommande deux moments : @blur, quand l'opérateur en a fini avec un champ, et @submit comme contrôle final. Valider à chaque @input noie sous les erreurs quelqu'un qui vient à peine de commencer à taper. Dans la prochaine leçon, tu assembleras ces éléments en un formulaire d'enregistrement complet, avec des groupes de champs et une réinitialisation.

Souviens-toi : la validation est le sas de la station - les données ne passent que lorsque chaque capteur confirme qu'elles sont sûres.

Code de cette leçon : App.vue
1<script setup>
2import { ref, computed } from 'vue'
3
4const missionData = ref({
5  title: '',
6  priority: '',
7  budget: '',
8  notes: ''
9})
10
11const errors = ref({
12  title: '',
13  priority: '',
14  budget: '',
15  notes: ''
16})
17
18const validateTitle = () => {
19  if (!missionData.value.title.trim()) {
20    errors.value.title = 'Le titre est obligatoire'
21  } else if (missionData.value.title.trim().length < 5) {
22    errors.value.title = 'Le titre doit comporter au moins 5 caractères'
23  } else {
24    errors.value.title = ''
25  }
26}
27
28const validatePriority = () => {
29  const num = Number(missionData.value.priority)
30  if (isNaN(num) || num < 1 || num > 10) {
31    errors.value.priority = 'La priorité doit être un nombre de 1 à 10'
32  } else {
33    errors.value.priority = ''
34  }
35}
36
37const validateBudget = () => {
38  const num = Number(missionData.value.budget)
39  if (isNaN(num) || num <= 0) {
40    errors.value.budget = 'Le budget doit être un nombre supérieur à 0'
41  } else {
42    errors.value.budget = ''
43  }
44}
45
46const validateNotes = () => {
47  const trimmed = missionData.value.notes.trim()
48  if (trimmed.length > 0 && trimmed.length < 10) {
49    errors.value.notes = 'Les notes doivent comporter au moins 10 caractères ou être vides'
50  } else {
51    errors.value.notes = ''
52  }
53}
54
55const isFormValid = computed(() => {
56  return !errors.value.title &&
57         !errors.value.priority &&
58         !errors.value.budget &&
59         !errors.value.notes &&
60         missionData.value.title.trim() &&
61         missionData.value.priority &&
62         missionData.value.budget
63})
64
65const budgetFormatted = computed(() => {
66  if (!missionData.value.budget) return ''
67  return `$${Number(missionData.value.budget).toLocaleString()}`
68})
69</script>
70
71<template>
72  <div class="validation-center">
73    <div class="center-header">
74      <h2>Centre de Validation des Missions</h2>
75      <p class="subtitle">Modificateurs de v-model et Validation de Formulaires</p>
76    </div>
77
78    <div class="form-container">
79      <!-- modificateur .trim -->
80      <div class="form-group">
81        <label for="title">Titre de la Mission (.trim) :</label>
82        <input
83          id="title"
84          v-model.trim="missionData.title"
85          @blur="validateTitle"
86          type="text"
87          placeholder="ex. Exploration du Cratère Gale"
88          class="form-input"
89          :class="{ error: errors.title }"
90        />
91        <div v-if="errors.title" class="error-message">
92          {{ errors.title }}
93        </div>
94        <div v-else class="help-text">
95          Les espaces au début et à la fin seront supprimés automatiquement
96        </div>
97      </div>
98
99      <!-- modificateur .number -->
100      <div class="form-group">
101        <label for="priority">Priorité de la Mission (.number) :</label>
102        <input
103          id="priority"
104          v-model.number="missionData.priority"
105          @blur="validatePriority"
106          type="text"
107          placeholder="1-10"
108          class="form-input"
109          :class="{ error: errors.priority }"
110        />
111        <div v-if="errors.priority" class="error-message">
112          {{ errors.priority }}
113        </div>
114        <div v-else class="help-text">
115          Valeur automatiquement convertie en nombre
116        </div>
117        <div class="type-display">
118          Type de données : <code>{{ typeof missionData.priority }}</code>
119        </div>
120      </div>
121
122      <!-- .number avec formatage -->
123      <div class="form-group">
124        <label for="budget">Budget de la Mission (.number) :</label>
125        <input
126          id="budget"
127          v-model.number="missionData.budget"
128          @blur="validateBudget"
129          type="text"
130          placeholder="ex. 1000000"
131          class="form-input"
132          :class="{ error: errors.budget }"
133        />
134        <div v-if="errors.budget" class="error-message">
135          {{ errors.budget }}
136        </div>
137        <div v-else-if="missionData.budget" class="success-text">
138          Budget formaté : {{ budgetFormatted }}
139        </div>
140      </div>
141
142      <!-- modificateur .lazy -->
143      <div class="form-group">
144        <label for="notes">Notes (.lazy) :</label>
145        <textarea
146          id="notes"
147          v-model.lazy="missionData.notes"
148          @change="validateNotes"
149          placeholder="Informations supplémentaires... (mise à jour au blur)"
150          class="form-textarea"
151          :class="{ error: errors.notes }"
152          rows="4"
153        ></textarea>
154        <div v-if="errors.notes" class="error-message">
155          {{ errors.notes }}
156        </div>
157        <div v-else class="help-text">
158          Les données se mettent à jour uniquement après avoir quitté le champ (blur/change)
159        </div>
160      </div>
161
162      <div class="validation-summary" :class="{ valid: isFormValid }">
163        <div v-if="isFormValid" class="valid-message">
164          Le formulaire est valide et prêt à être envoyé !
165        </div>
166        <div v-else class="invalid-message">
167          Le formulaire contient des erreurs ou des données incomplètes
168        </div>
169      </div>
170    </div>
171
172    <div class="data-preview">
173      <h3>Aperçu des Données de Mission</h3>
174      <div class="preview-container">
175        <div class="preview-item">
176          <span class="preview-label">Titre :</span>
177          <span class="preview-value">{{ missionData.title || '(vide)' }}</span>
178        </div>
179        <div class="preview-item">
180          <span class="preview-label">Priorité :</span>
181          <span class="preview-value">
182            {{ missionData.priority || '(vide)' }}
183            (type : {{ typeof missionData.priority }})
184          </span>
185        </div>
186        <div class="preview-item">
187          <span class="preview-label">Budget :</span>
188          <span class="preview-value">
189            {{ missionData.budget || '(vide)' }}
190            (type : {{ typeof missionData.budget }})
191          </span>
192        </div>
193        <div class="preview-item">
194          <span class="preview-label">Notes :</span>
195          <span class="preview-value">{{ missionData.notes || '(vides)' }}</span>
196        </div>
197      </div>
198    </div>
199
200    <div class="modifiers-reference">
201      <h3>Modificateurs de v-model :</h3>
202      <div class="modifier-cards">
203        <div class="modifier-card">
204          <h4>.lazy</h4>
205          <p>Synchronise les données après l'événement 'change' au lieu de 'input'</p>
206          <code>v-model.lazy="variable"</code>
207        </div>
208        <div class="modifier-card">
209          <h4>.number</h4>
210          <p>Convertit automatiquement la valeur en Number</p>
211          <code>v-model.number="variable"</code>
212        </div>
213        <div class="modifier-card">
214          <h4>.trim</h4>
215          <p>Supprime automatiquement les espaces au début et à la fin</p>
216          <code>v-model.trim="variable"</code>
217        </div>
218      </div>
219    </div>
220  </div>
221</template>
222
223<style scoped>
224.validation-center {
225  background: linear-gradient(135deg, #0a0e27 0%, #1a1e3f 100%);
226  color: #00ff88;
227  padding: 30px;
228  border-radius: 15px;
229  border: 2px solid #00b4d8;
230  font-family: 'Courier New', monospace;
231  max-width: 900px;
232  margin: 0 auto;
233}
234
235.center-header {
236  text-align: center;
237  margin-bottom: 30px;
238  padding-bottom: 20px;
239  border-bottom: 2px solid #00b4d8;
240}
241
242.center-header h2 {
243  color: #00ff88;
244  margin: 0 0 10px 0;
245  font-size: 28px;
246  text-shadow: 0 0 10px #00ff88;
247}
248
249.subtitle {
250  color: #00b4d8;
251  margin: 0;
252  font-size: 14px;
253}
254
255.form-container {
256  margin-bottom: 30px;
257}
258
259.form-group {
260  margin-bottom: 25px;
261}
262
263.form-group label {
264  display: block;
265  color: #00b4d8;
266  margin-bottom: 10px;
267  font-size: 14px;
268  font-weight: bold;
269}
270
271.form-input,
272.form-textarea {
273  width: 100%;
274  background: rgba(0, 180, 216, 0.1);
275  border: 2px solid #00b4d8;
276  border-radius: 8px;
277  padding: 12px 15px;
278  color: #00ff88;
279  font-size: 16px;
280  font-family: 'Courier New', monospace;
281  box-sizing: border-box;
282  transition: all 0.3s ease;
283}
284
285.form-input:focus,
286.form-textarea:focus {
287  outline: none;
288  border-color: #00ff88;
289  box-shadow: 0 0 15px rgba(0, 255, 136, 0.3);
290}
291
292.form-input.error,
293.form-textarea.error {
294  border-color: #ff6b35;
295  background: rgba(255, 107, 53, 0.05);
296}
297
298.form-textarea {
299  resize: vertical;
300}
301
302.error-message {
303  margin-top: 8px;
304  color: #ff6b35;
305  font-size: 12px;
306  padding: 8px 12px;
307  background: rgba(255, 107, 53, 0.1);
308  border-radius: 4px;
309  border-left: 3px solid #ff6b35;
310}
311
312.help-text {
313  margin-top: 8px;
314  color: #00b4d8;
315  font-size: 12px;
316  padding: 8px 12px;
317  background: rgba(0, 180, 216, 0.05);
318  border-radius: 4px;
319}
320
321.success-text {
322  margin-top: 8px;
323  color: #00ff88;
324  font-size: 12px;
325  padding: 8px 12px;
326  background: rgba(0, 255, 136, 0.1);
327  border-radius: 4px;
328  font-weight: bold;
329}
330
331.type-display {
332  margin-top: 8px;
333  color: #9d4edd;
334  font-size: 11px;
335}
336
337.type-display code {
338  background: rgba(157, 78, 221, 0.2);
339  padding: 2px 6px;
340  border-radius: 4px;
341  color: #9d4edd;
342}
343
344.validation-summary {
345  padding: 20px;
346  background: rgba(255, 107, 53, 0.1);
347  border: 2px solid #ff6b35;
348  border-radius: 8px;
349  text-align: center;
350  font-weight: bold;
351  transition: all 0.3s ease;
352}
353
354.validation-summary.valid {
355  background: rgba(0, 255, 136, 0.1);
356  border-color: #00ff88;
357}
358
359.valid-message {
360  color: #00ff88;
361  font-size: 16px;
362}
363
364.invalid-message {
365  color: #ff6b35;
366  font-size: 16px;
367}
368
369.data-preview {
370  margin-bottom: 30px;
371}
372
373.data-preview h3 {
374  color: #00ff88;
375  margin-bottom: 15px;
376}
377
378.preview-container {
379  background: rgba(0, 0, 0, 0.3);
380  border: 1px solid #00b4d8;
381  border-radius: 8px;
382  padding: 20px;
383}
384
385.preview-item {
386  padding: 10px 0;
387  border-bottom: 1px solid rgba(0, 180, 216, 0.2);
388  display: flex;
389  justify-content: space-between;
390  gap: 15px;
391}
392
393.preview-item:last-child {
394  border-bottom: none;
395}
396
397.preview-label {
398  color: #00b4d8;
399  font-weight: bold;
400}
401
402.preview-value {
403  color: #00ff88;
404  text-align: right;
405  word-break: break-word;
406}
407
408.modifiers-reference {
409  background: rgba(0, 255, 136, 0.05);
410  padding: 20px;
411  border-radius: 8px;
412  border: 1px solid #00ff88;
413}
414
415.modifiers-reference h3 {
416  color: #00ff88;
417  margin-top: 0;
418}
419
420.modifier-cards {
421  display: grid;
422  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
423  gap: 15px;
424}
425
426.modifier-card {
427  background: rgba(0, 180, 216, 0.1);
428  padding: 15px;
429  border-radius: 8px;
430  border: 1px solid #00b4d8;
431}
432
433.modifier-card h4 {
434  color: #00ff88;
435  margin: 0 0 10px 0;
436}
437
438.modifier-card p {
439  color: #00b4d8;
440  font-size: 12px;
441  margin: 0 0 10px 0;
442}
443
444.modifier-card code {
445  display: block;
446  background: rgba(0, 0, 0, 0.3);
447  padding: 8px;
448  border-radius: 4px;
449  color: #00ff88;
450  font-size: 11px;
451}
452</style>

Tu as repéré une erreur dans cette leçon ?

Teste tes connaissances

Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.

  1. 1. Comment crée-t-on un select permettant de choisir plusieurs options ?

  2. 2. Pourquoi computed est-il bon pour vérifier si un formulaire est valide ?

Voici 2 des 4 questions de cette leçon. Réponds aux autres dans le jeu.

Tâches pratiques dans le jeu

  • Éditeur de code

    Ajoute une validation au @blur avec des messages d'erreur et un computed isFormValid.

  • Tri vertical

    Ordonne les étapes de validation d'un formulaire de haut en bas :

  • Clique dans l'ordre

    Ordonne la syntaxe pour un bouton avec un disabled dynamique :

  • Éditeur de code

    Construis un select avec v-for affichant les options depuis un tableau d'objets et affiche la valeur sélectionnée.

  • Tri vertical

    Ordonne le déroulement correct de l'envoi d'un formulaire :