Cours Vue.js · Module 5: Événements et formulaires
Formulaires avancés
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. Comment crée-t-on un select permettant de choisir plusieurs options ?
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 :