Kurs Vue.js · Moduł 5: Zdarzenia i formularze

Zaawansowane Formularze

5 min czytania
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. 1. Jak stworzyć select z możliwością wyboru wielu opcji?

  2. 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:

Przydatne artykuły