Kurs Vue.js · Moduł 5: Zdarzenia i formularze
Zaawansowane Formularze
W tej lekcji3
Inżynierowie NOVA LAB rejestrują nowy moduł stacji i formularz z poprzednich lekcji przestał im wystarczać. Chcą zaznaczyć kilka systemów do monitorowania naraz, wybrać moduł z katalogu, który zmienia się co tydzień, i nie przepuścić do bazy nazwy z dwóch liter ani roku wdrożenia 1999. Każda z tych potrzeb to jeden nowy element formularza.
Multi-select
Atrybut multiple zamienia listę rozwijaną w listę wielokrotnego wyboru. v-model zapisuje wtedy do zmiennej tablicę wartości zaznaczonych opcji, więc zmienna musi startować jako pusta tablica. Metoda join(', ') skleja tablicę w czytelny napis.
1<template>
2 <div>
3 <label>Wybierz systemy do monitorowania (Ctrl+Click):</label>
4 <select v-model="selectedSystems" multiple>
5 <option value="oxygen">Generator tlenu</option>
6 <option value="water">Recykler wody</option>
7 <option value="power">Zasilanie solarne</option>
8 <option value="comms">Komunikacja</option>
9 </select>
10 <p>Wybrane: {{ selectedSystems.join(', ') }}</p>
11 </div>
12</template>
13
14<script setup>
15import { ref } from 'vue'
16
17const selectedSystems = ref([])
18</script>Kolejne opcje zaznaczasz z wciśniętym Ctrl, a na macOS z Cmd. W tablicy wartości stoją w kolejności opcji na liście, a nie w kolejności klikania, inaczej niż przy grupie checkboxów. Składnia v-model się nie zmieniła, zmienił się tylko typ danych. Przy trzech czy czterech opcjach polecam jednak grupę checkboxów: nie wymaga ukrytego skrótu klawiszowego i od razu pokazuje wszystkie wybory.
Dynamiczne Opcje
Katalog modułów przychodzi jako tablica obiektów z id, nazwą i sektorem. Opcje generuje v-for, a :value="mod.id" zapisuje do zmiennej liczbowe id. Nazwę wybranego modułu odszukuje funkcja z metodą find, która zwraca pierwszy element spełniający warunek albo undefined. Operator ?. (optional chaining) sięga po name tylko wtedy, gdy moduł się znalazł, a || '' zamienia brak wyniku na pusty tekst.
1<template>
2 <div>
3 <select v-model="selectedModule">
4 <option disabled value="">Wybierz moduł stacji</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 Wybrany: {{ getModuleName(selectedModule) }}
16 </p>
17 </div>
18</template>
19
20<script setup>
21import { ref } from 'vue'
22
23const modules = ref([
24 { id: 1, name: 'Oxygen Alpha', sector: 'Sektor A' },
25 { id: 2, name: 'Plasma Beta', sector: 'Sektor B' },
26 { id: 3, name: 'Water Gamma', sector: 'Sektor C' }
27])
28
29const selectedModule = ref('')
30
31function getModuleName(id) {
32 return modules.value.find(m => m.id === id)?.name || ''
33}
34</script>Ponieważ :value zachowuje typ, selectedModule przechowuje liczbę i ścisłe porównanie m.id === id działa. Akapit z v-if pozostaje ukryty, dopóki zmienna trzyma pusty tekst. Dopisanie modułu do tablicy modules od razu doda opcję, a szablonu nie trzeba ruszać.
Walidacja Formularza
Walidacja odpowiada na trzy pytania: co sprawdzić, kiedy sprawdzić i co zrobić z wynikiem. Zacznijmy od szablonu dwóch pierwszych pól. Każde ma v-model, nasłuch @blur, który uruchamia walidację po opuszczeniu pola, i komunikat błędu widoczny tylko wtedy, gdy istnieje.
1<template>
2 <form @submit.prevent="submitForm">
3 <div :class="{ error: errors.name }">
4 <label>Nazwa modułu:</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>Rok wdrożenia:</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 }" dodaje klasę CSS error, gdy komunikat nie jest pusty, więc pole może zaświecić na czerwono. Rok używa v-model.number, a pole inżyniera w kolejnej części v-model.trim - oba modyfikatory znasz z poprzedniej lekcji. Druga część szablonu kończy formularz przyciskiem.
1 <div :class="{ error: errors.engineer }">
2 <label>Inżynier odpowiedzialny:</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 Zarejestruj moduł
11 </button>
12 </form>
13</template>:disabled="!isFormValid" wiąże atrybut z computed, więc przycisk odblokuje się sam, gdy formularz stanie się poprawny. Bez dwukropka disabled="!isFormValid" byłby zwykłym tekstem i przycisk zostałby zablokowany na stałe, a v-if zamiast :disabled ukryłby przycisk całkowicie. Teraz stan formularza.
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 tworzy reaktywny obiekt, do którego pól sięgasz bez .value. Obiekt form trzyma wartości pól, a errors komunikaty, przy czym pusty tekst oznacza brak błędu. Dalej funkcje walidujące.
1function validateName() {
2 if (!form.name) {
3 errors.name = 'Nazwa jest wymagana'
4 } else if (form.name.length < 3) {
5 errors.name = 'Nazwa musi mieć min. 3 znaki'
6 } else {
7 errors.name = ''
8 }
9}
10
11function validateYear() {
12 if (!form.year) {
13 errors.year = 'Rok jest wymagany'
14 } else if (form.year < 2020 || form.year > 2100) {
15 errors.year = 'Rok musi być między 2020 a 2100'
16 } else {
17 errors.year = ''
18 }
19}
20
21function validateEngineer() {
22 errors.engineer = form.engineer ? '' : 'Inżynier jest wymagany'
23}Każda funkcja sprawdza jedno pole i ustawia jego komunikat. Kolejność warunków ma znaczenie: najpierw puste pole, potem szczegóły, takie jak długość nazwy czy zakres lat od 2020 do 2100. Na koniec computed i obsługa wysłania.
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('Rejestracja:', form)
13 alert('Moduł zarejestrowany!')
14 }
15}
16</script>isFormValid przelicza się automatycznie przy każdej zmianie danych formularza, dlatego computed nadaje się do tej roli lepiej niż ręcznie aktualizowana flaga. submitForm najpierw uruchamia wszystkie walidacje, bo operator mógł poprawić pole i nacisnąć Enter bez opuszczania go, a wtedy @blur jeszcze nie zadziałał. Potem odczytuje isFormValid.value - computed przelicza się przy tym odczycie, więc widzi komunikaty ustawione linijkę wyżej. Pełny przebieg wygląda tak: kliknięcie przycisku, @submit.prevent wywołuje procedurę, walidacja wszystkich pól, wysłanie danych, gdy są poprawne, i na koniec komunikat sukcesu albo reset formularza.
Kiedy walidować? Polecam dwa momenty: @blur, gdy operator skończy pracę z polem, oraz @submit jako ostateczną kontrolę. Walidacja przy każdym @input zasypuje błędami kogoś, kto dopiero zaczął pisać. W następnej lekcji złożysz z tych elementów kompletny formularz rejestracji z grupami pól i resetem.
Pamiętaj: walidacja to śluza stacji - dane przechodzą dalej dopiero wtedy, gdy każdy czujnik potwierdzi, że są bezpieczne.
Kod do tej lekcji: 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 = 'Tytuł jest wymagany'
21 } else if (missionData.value.title.trim().length < 5) {
22 errors.value.title = 'Tytuł musi mieć minimum 5 znaków'
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 = 'Priorytet musi być liczbą od 1 do 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 = 'Budżet musi być liczbą większą od 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 = 'Notatki muszą mieć minimum 10 znaków lub być puste'
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>Centrum Walidacji Misji</h2>
75 <p class="subtitle">Modyfikatory v-model i Walidacja Formularzy</p>
76 </div>
77
78 <div class="form-container">
79 <!-- .trim modifier -->
80 <div class="form-group">
81 <label for="title">Tytuł Misji (.trim):</label>
82 <input
83 id="title"
84 v-model.trim="missionData.title"
85 @blur="validateTitle"
86 type="text"
87 placeholder="np. Eksploracja Krateru 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 Spacje na początku i końcu zostaną usunięte automatycznie
96 </div>
97 </div>
98
99 <!-- .number modifier -->
100 <div class="form-group">
101 <label for="priority">Priorytet Misji (.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 Wartość automatycznie konwertowana na liczbę
116 </div>
117 <div class="type-display">
118 Typ danych: <code>{{ typeof missionData.priority }}</code>
119 </div>
120 </div>
121
122 <!-- .number with formatting -->
123 <div class="form-group">
124 <label for="budget">Budżet Misji (.number):</label>
125 <input
126 id="budget"
127 v-model.number="missionData.budget"
128 @blur="validateBudget"
129 type="text"
130 placeholder="np. 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 Sformatowany budżet: {{ budgetFormatted }}
139 </div>
140 </div>
141
142 <!-- .lazy modifier -->
143 <div class="form-group">
144 <label for="notes">Notatki (.lazy):</label>
145 <textarea
146 id="notes"
147 v-model.lazy="missionData.notes"
148 @change="validateNotes"
149 placeholder="Dodatkowe informacje... (aktualizacja po 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 Dane aktualizują się dopiero po opuszczeniu pola (blur/change)
159 </div>
160 </div>
161
162 <div class="validation-summary" :class="{ valid: isFormValid }">
163 <div v-if="isFormValid" class="valid-message">
164 Formularz jest poprawny i gotowy do wysłania!
165 </div>
166 <div v-else class="invalid-message">
167 Formularz zawiera błędy lub niekompletne dane
168 </div>
169 </div>
170 </div>
171
172 <div class="data-preview">
173 <h3>Podgląd Danych Misji</h3>
174 <div class="preview-container">
175 <div class="preview-item">
176 <span class="preview-label">Tytuł:</span>
177 <span class="preview-value">{{ missionData.title || '(pusty)' }}</span>
178 </div>
179 <div class="preview-item">
180 <span class="preview-label">Priorytet:</span>
181 <span class="preview-value">
182 {{ missionData.priority || '(pusty)' }}
183 (typ: {{ typeof missionData.priority }})
184 </span>
185 </div>
186 <div class="preview-item">
187 <span class="preview-label">Budżet:</span>
188 <span class="preview-value">
189 {{ missionData.budget || '(pusty)' }}
190 (typ: {{ typeof missionData.budget }})
191 </span>
192 </div>
193 <div class="preview-item">
194 <span class="preview-label">Notatki:</span>
195 <span class="preview-value">{{ missionData.notes || '(puste)' }}</span>
196 </div>
197 </div>
198 </div>
199
200 <div class="modifiers-reference">
201 <h3>Modyfikatory v-model:</h3>
202 <div class="modifier-cards">
203 <div class="modifier-card">
204 <h4>.lazy</h4>
205 <p>Synchronizuje dane po zdarzeniu 'change' zamiast 'input'</p>
206 <code>v-model.lazy="variable"</code>
207 </div>
208 <div class="modifier-card">
209 <h4>.number</h4>
210 <p>Automatycznie konwertuje wartość na Number</p>
211 <code>v-model.number="variable"</code>
212 </div>
213 <div class="modifier-card">
214 <h4>.trim</h4>
215 <p>Automatycznie usuwa białe znaki z początku i końca</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>Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Jak stworzyć select z możliwością wyboru wielu opcji?
2. Dlaczego computed jest dobry do sprawdzania czy formularz jest valid?
To 2 z 4 pytań do tej lekcji. Pozostałe rozwiążesz w grze.
Zadania praktyczne w grze
- Edytor kodu
Dodaj walidację na @blur z komunikatami błędów i computed isFormValid.
- Układanie w pionie
Ułóż kroki walidacji formularza od góry do dołu:
- Klikanie w kolejności
Ułóż składnię przycisku z dynamicznym disabled:
- Edytor kodu
Zbuduj select z v-for renderujący opcje z tablicy obiektów i wyświetl wybraną wartość.
- Układanie w pionie
Ułóż prawidłowy flow submit formularza: