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

Formularze w Praktyce - Rejestracja Modułu

6 min czytania

Dział inżynierii NOVA LAB dostał zlecenie: jeden formularz, w którym zarejestruje się każdy nowy moduł stacji, od śluzy po laboratorium. Moduł ma nazwę, typ, rok i status, parametry techniczne, kategorie, opis i opcje serwisowe. Kilkanaście pól wrzuconych jedno pod drugim to ściana, której nikt nie przeczyta. Zbudujemy więc formularz podzielony na grupy, z polami, które pojawiają się tylko wtedy, gdy mają sens, z resetem i z podglądem danych.

Grupy pól: fieldset i legend

Element <fieldset> grupuje powiązane pola, a <legend> jest podpisem tej grupy. Czytniki ekranu odczytują go przed polami, więc operator korzystający z czytnika wie, w której sekcji jest. Etykieta <label> z atrybutem for wskazuje id pola, dzięki czemu kliknięcie w napis ustawia kursor w polu. Pierwsza grupa zaczyna się od nazwy i typu modułu.

1<template>
2  <form @submit.prevent="submitModule" class="module-form" style="background: #0a0e27; color: #00ff88;">
3    <h2>Rejestracja modułu misji</h2>
4
5    <!-- Podstawowe informacje -->
6    <fieldset style="border-color: #00b4d8;">
7      <legend>Informacje podstawowe</legend>
8
9      <div class="form-group">
10        <label for="name">Nazwa modułu *</label>
11        <input
12          id="name"
13          v-model.trim="module.name"
14          required
15          minlength="3"
16          style="background: #1a1e37; color: #00ff88;"
17        />
18      </div>
19
20      <div class="form-group">
21        <label for="type">Typ *</label>
22        <input id="type" v-model.trim="module.type" required style="background: #1a1e37; color: #00ff88;" />
23      </div>

Nazwa ma atrybuty required i minlength="3", czyli wbudowaną walidację HTML. Przeglądarka sprawdza takie atrybuty, zanim w ogóle wyśle zdarzenie submit, i przy błędzie pokazuje własny komunikat - @submit.prevent niczego tu nie wyłącza. v-model.trim usuwa przy tym zbędne spacje. Dalej rząd z rokiem i statusem.

1      <div class="form-row">
2        <div class="form-group">
3          <label for="year">Rok wdrożenia</label>
4          <input
5            id="year"
6            v-model.number="module.year"
7            type="number"
8            min="2020"
9            max="2100"
10            style="background: #1a1e37; color: #00ff88;"
11          />
12        </div>
13
14        <div class="form-group">
15          <label for="status">Status</label>
16          <select id="status" v-model="module.status" style="background: #1a1e37; color: #00ff88;">
17            <option value="">Wybierz...</option>
18            <option value="ACTIVE">Aktywny</option>
19            <option value="STANDBY">Gotowość</option>
20            <option value="MAINTENANCE">Konserwacja</option>
21            <option value="OFFLINE">Offline</option>
22          </select>
23        </div>
24      </div>
25    </fieldset>

Rok ma min i max dla przeglądarki oraz .number dla Vue. Computed isValid roku nie sprawdza, więc wpis 1999 zatrzyma dopiero przeglądarka po kliknięciu przycisku wysyłania. Lista statusów zaczyna się od pustej opcji zachęcającej do wyboru. Druga grupa pokazuje, że v-model działa też na zagnieżdżonych ścieżkach, takich jak module.params.maxPower.

1    <!-- Parametry -->
2    <fieldset style="border-color: #00b4d8;">
3      <legend>Parametry</legend>
4      <div class="form-row">
5        <div class="form-group">
6          <label>Max moc (MW)</label>
7          <input v-model.number="module.params.maxPower" type="number" style="background: #1a1e37; color: #00ff88;" />
8        </div>
9        <div class="form-group">
10          <label>Temperatura (K)</label>
11          <input v-model.number="module.params.temperature" type="number" style="background: #1a1e37; color: #00ff88;" />
12        </div>
13        <div class="form-group" v-if="needsPressure">
14          <label>Ciśnienie (kPa)</label>
15          <input v-model.number="module.params.pressure" type="number" style="background: #1a1e37; color: #00ff88;" />
16        </div>
17      </div>
18    </fieldset>

Pole ciśnienia ma v-if="needsPressure", więc istnieje tylko wtedy, gdy computed zwraca true. Przy wartości false Vue usuwa je z DOM, a nie tylko ukrywa. Usunięcie pola nie czyści jednak danych: wpisane wcześniej ciśnienie zostaje w module.params.pressure. Kategorie w trzeciej grupie generuje v-for z tablicy.

1    <!-- Kategorie -->
2    <fieldset>
3      <legend>Kategorie</legend>
4      <div class="checkbox-group">
5        <label v-for="cat in availableCategories" :key="cat.value">
6          <input
7            type="checkbox"
8            v-model="module.categories"
9            :value="cat.value"
10          />
11          {{ cat.label }}
12        </label>
13      </div>
14    </fieldset>

Każdy checkbox bierze swoją wartość z :value="cat.value", a wszystkie zapisują do jednej tablicy module.categories, dokładnie jak grupa checkboxów z lekcji o v-model. Czwarta grupa to opis w obszarze tekstowym.

1    <!-- Opis -->
2    <fieldset>
3      <legend>Opis</legend>
4      <textarea
5        v-model="module.description"
6        rows="4"
7        placeholder="Opisz moduł misji..."
8      ></textarea>
9      <p class="char-count">
10        {{ module.description.length }} / 500 znaków
11      </p>
12    </fieldset>

Atrybut rows="4" ustala widoczną wysokość pola. Licznik pod spodem tylko informuje o długości opisu i niczego nie ogranicza: twardy limit dałby dopiero atrybut maxlength="500". Piąta grupa zawiera opcje i pole warunkowe.

1    <!-- Opcje -->
2    <fieldset>
3      <legend>Opcje</legend>
4      <label class="checkbox-label">
5        <input type="checkbox" v-model="module.isActive" />
6        Moduł aktywny
7      </label>
8      <label class="checkbox-label">
9        <input type="checkbox" v-model="module.needsMaintenance" />
10        Wymaga konserwacji
11      </label>
12
13      <div v-if="module.needsMaintenance" class="form-group">
14        <label>Budżet konserwacji (USD)</label>
15        <input v-model.number="module.maintenanceCost" type="number" min="0" />
16      </div>
17    </fieldset>

To wzorzec pola warunkowego: checkbox z v-model steruje zmienną, a v-if na tej zmiennej pokazuje dodatkowe pole z budżetem konserwacji. isActive startuje od true, więc pole jest zaznaczone od początku, bo v-model bierze stan z JavaScriptu. Na końcu szablonu są przyciski i podgląd danych.

1    <!-- Przyciski -->
2    <div class="form-actions">
3      <button type="button" @click="resetForm">Wyczyść</button>
4      <button type="submit" :disabled="!isValid">Zarejestruj moduł</button>
5    </div>
6
7    <!-- Podgląd -->
8    <details>
9      <summary>Podgląd danych</summary>
10      <pre>{{ JSON.stringify(module, null, 2) }}</pre>
11    </details>
12  </form>
13</template>

type="button" przy przycisku czyszczenia jest kluczowy, bo przycisk w formularzu domyślnie ma typ submit i bez tego atrybutu czyszczenie wysłałoby formularz. <details> z <summary> tworzy rozwijany podgląd, a JSON.stringify(module, null, 2) formatuje dane z wcięciem o dwie spacje. Przejdźmy do skryptu.

1<script setup>
2import { reactive, computed } from 'vue'
3
4const initialState = {
5  name: '',
6  type: '',
7  year: null,
8  status: '',
9  params: { maxPower: null, temperature: null, pressure: null },
10  categories: [],
11  description: '',
12  isActive: true,
13  needsMaintenance: false,
14  maintenanceCost: null
15}
16
17const module = reactive({
18  ...initialState,
19  params: { ...initialState.params },
20  categories: []
21})

initialState opisuje pusty formularz, a reactive tworzy z niego stan. Uwaga na pułapkę: rozwinięcie ...initialState kopiuje tylko pierwszy poziom obiektu. Gdyby params nie dostało osobnej kopii, wpisanie mocy zmieniłoby także initialState.params, a reset przywróciłby wpisane liczby zamiast pustych pól. Dlatego params i categories dostają własne kopie. Dalej lista kategorii i dwa computed.

1const availableCategories = [
2  { value: 'lifesupport', label: 'Podtrzymywanie życia' },
3  { value: 'energy', label: 'Energetyczny' },
4  { value: 'research', label: 'Badawczy' },
5  { value: 'communication', label: 'Komunikacyjny' },
6  { value: 'navigation', label: 'Nawigacyjny' }
7]
8
9const needsPressure = computed(() => {
10  return ['lifesupport', 'research'].some(c => module.categories.includes(c))
11})
12
13const isValid = computed(() => {
14  return module.name.length >= 3 && module.type
15})

availableCategories to zwykła tablica, bo nigdy się nie zmienia i nie potrzebuje reaktywności. needsPressure używa metody some, która zwraca true, gdy choć jeden element spełnia warunek. isValid pełni rolę isFormValid z poprzedniej lekcji: wymaga nazwy z co najmniej trzema znakami i niepustego typu. Zostały reset i wysyłka.

1function resetForm() {
2  Object.assign(module, {
3    ...initialState,
4    params: { ...initialState.params },
5    categories: []
6  })
7}
8
9function submitModule() {
10  if (isValid.value) {
11    console.log('Rejestruję:', JSON.parse(JSON.stringify(module)))
12    alert('Moduł został zarejestrowany!')
13  }
14}
15</script>

Object.assign wpisuje wartości początkowe do istniejącego obiektu module, więc szablon pozostaje podpięty do tego samego stanu. Podmiana całego obiektu na nowy odcięłaby szablon od danych. params i categories znów dostają świeże kopie. JSON.parse(JSON.stringify(module)) daje w konsoli zwykłą migawkę danych zamiast obiektu reaktywnego. Polecam pójść o krok dalej i zamienić initialState na funkcję zwracającą nowy obiekt przy każdym wywołaniu - pułapka płytkiej kopii znika wtedy całkowicie. W projekcie modułu wykorzystasz ten sam porządek: stan, v-model, walidacja na @blur, computed isFormValid i @submit.prevent.

Pamiętaj: dobry formularz rejestracji działa jak dobrze zaprojektowany moduł stacji - każda grupa ma swój przedział, a reset przywraca fabryczne ustawienia bez zostawiania śladów.

Kod do tej lekcji: App.vue
1<script setup>
2import { ref, computed } from 'vue'
3
4const missionForm = ref({
5  commander: '',
6  destination: '',
7  duration: '',
8  crewSize: '',
9  missionType: 'exploration',
10  equipment: [],
11  emergencyProtocol: false,
12  launchDate: '',
13  objectives: ''
14})
15
16const errors = ref({})
17const submitted = ref(false)
18const submittedData = ref(null)
19
20const missionTypes = [
21  { value: 'exploration', label: 'Eksploracja' },
22  { value: 'research', label: 'Badania' },
23  { value: 'construction', label: 'Budowa' },
24  { value: 'rescue', label: 'Ratunkowa' }
25]
26
27const equipmentOptions = [
28  { id: 'rover', name: 'Łazik' },
29  { id: 'drill', name: 'Wiertło' },
30  { id: 'lab', name: 'Laboratorium' },
31  { id: 'satellite', name: 'Satelita' },
32  { id: 'drone', name: 'Dron' }
33]
34
35const validateForm = () => {
36  errors.value = {}
37
38  if (!missionForm.value.commander.trim()) {
39    errors.value.commander = 'Dowódca jest wymagany'
40  }
41
42  if (!missionForm.value.destination.trim()) {
43    errors.value.destination = 'Miejsce docelowe jest wymagane'
44  }
45
46  const duration = Number(missionForm.value.duration)
47  if (!duration || duration < 1 || duration > 365) {
48    errors.value.duration = 'Czas trwania: 1-365 dni'
49  }
50
51  const crew = Number(missionForm.value.crewSize)
52  if (!crew || crew < 1 || crew > 50) {
53    errors.value.crewSize = 'Załoga: 1-50 osób'
54  }
55
56  if (!missionForm.value.launchDate) {
57    errors.value.launchDate = 'Data startu jest wymagana'
58  }
59
60  if (missionForm.value.objectives.trim().length < 20) {
61    errors.value.objectives = 'Cele muszą mieć minimum 20 znaków'
62  }
63
64  return Object.keys(errors.value).length === 0
65}
66
67const handleSubmit = () => {
68  if (validateForm()) {
69    submitted.value = true
70    submittedData.value = {
71      ...missionForm.value,
72      submittedAt: new Date().toLocaleString('pl-PL')
73    }
74
75    // Symulacja wysłania
76    setTimeout(() => {
77      resetForm()
78    }, 5000)
79  }
80}
81
82const resetForm = () => {
83  missionForm.value = {
84    commander: '',
85    destination: '',
86    duration: '',
87    crewSize: '',
88    missionType: 'exploration',
89    equipment: [],
90    emergencyProtocol: false,
91    launchDate: '',
92    objectives: ''
93  }
94  errors.value = {}
95  submitted.value = false
96  submittedData.value = null
97}
98
99const isFormValid = computed(() => {
100  return missionForm.value.commander.trim() &&
101         missionForm.value.destination.trim() &&
102         missionForm.value.duration &&
103         missionForm.value.crewSize &&
104         missionForm.value.launchDate &&
105         missionForm.value.objectives.trim().length >= 20
106})
107</script>
108
109<template>
110  <div class="mission-control">
111    <div class="control-header">
112      <h2>Centrum Kontroli Misji NOVA LAB</h2>
113      <p class="subtitle">Kompletny System Zgłaszania Misji</p>
114    </div>
115
116    <div v-if="submitted" class="success-panel">
117      <div class="success-message">
118        <h3>Misja Zarejestrowana Pomyślnie!</h3>
119        <p>Dane zostały przesłane do Centrum Dowodzenia NOVA LAB</p>
120      </div>
121
122      <div class="submitted-data">
123        <h4>Szczegóły Misji:</h4>
124        <div class="data-grid">
125          <div><strong>Dowódca:</strong> {{ submittedData.commander }}</div>
126          <div><strong>Cel:</strong> {{ submittedData.destination }}</div>
127          <div><strong>Czas trwania:</strong> {{ submittedData.duration }} dni</div>
128          <div><strong>Załoga:</strong> {{ submittedData.crewSize }} osób</div>
129          <div><strong>Typ:</strong> {{ missionTypes.find(t => t.value === submittedData.missionType)?.label }}</div>
130          <div><strong>Sprzęt:</strong> {{ submittedData.equipment.length }} elementów</div>
131          <div><strong>Data startu:</strong> {{ submittedData.launchDate }}</div>
132          <div><strong>Wysłano:</strong> {{ submittedData.submittedAt }}</div>
133        </div>
134      </div>
135
136      <button @click="resetForm" class="reset-btn">
137        Nowa Misja
138      </button>
139    </div>
140
141    <form v-else @submit.prevent="handleSubmit" class="mission-form">
142      <div class="form-section">
143        <h3>Podstawowe Informacje</h3>
144
145        <div class="form-field">
146          <label for="commander">Dowódca Misji:</label>
147          <input
148            id="commander"
149            v-model.trim="missionForm.commander"
150            type="text"
151            placeholder="Imię i nazwisko"
152            class="form-input"
153            :class="{ error: errors.commander }"
154          />
155          <span v-if="errors.commander" class="error-text">{{ errors.commander }}</span>
156        </div>
157
158        <div class="form-field">
159          <label for="destination">Miejsce Docelowe:</label>
160          <input
161            id="destination"
162            v-model.trim="missionForm.destination"
163            type="text"
164            placeholder="np. Krater Gale, Mars"
165            class="form-input"
166            :class="{ error: errors.destination }"
167          />
168          <span v-if="errors.destination" class="error-text">{{ errors.destination }}</span>
169        </div>
170
171        <div class="form-row">
172          <div class="form-field">
173            <label for="duration">Czas trwania (dni):</label>
174            <input
175              id="duration"
176              v-model.number="missionForm.duration"
177              type="number"
178              placeholder="1-365"
179              class="form-input"
180              :class="{ error: errors.duration }"
181            />
182            <span v-if="errors.duration" class="error-text">{{ errors.duration }}</span>
183          </div>
184
185          <div class="form-field">
186            <label for="crew">Wielkość załogi:</label>
187            <input
188              id="crew"
189              v-model.number="missionForm.crewSize"
190              type="number"
191              placeholder="1-50"
192              class="form-input"
193              :class="{ error: errors.crewSize }"
194            />
195            <span v-if="errors.crewSize" class="error-text">{{ errors.crewSize }}</span>
196          </div>
197        </div>
198
199        <div class="form-field">
200          <label for="launch">Data startu:</label>
201          <input
202            id="launch"
203            v-model="missionForm.launchDate"
204            type="date"
205            class="form-input"
206            :class="{ error: errors.launchDate }"
207          />
208          <span v-if="errors.launchDate" class="error-text">{{ errors.launchDate }}</span>
209        </div>
210      </div>
211
212      <div class="form-section">
213        <h3>Konfiguracja Misji</h3>
214
215        <div class="form-field">
216          <label>Typ misji:</label>
217          <div class="radio-group">
218            <label
219              v-for="type in missionTypes"
220              :key="type.value"
221              class="radio-label"
222            >
223              <input
224                v-model="missionForm.missionType"
225                type="radio"
226                :value="type.value"
227              />
228              {{ type.label }}
229            </label>
230          </div>
231        </div>
232
233        <div class="form-field">
234          <label>Wyposażenie:</label>
235          <div class="checkbox-group">
236            <label
237              v-for="item in equipmentOptions"
238              :key="item.id"
239              class="checkbox-label"
240            >
241              <input
242                v-model="missionForm.equipment"
243                type="checkbox"
244                :value="item.id"
245              />
246              {{ item.name }}
247            </label>
248          </div>
249        </div>
250
251        <div class="form-field">
252          <label class="checkbox-label emergency">
253            <input
254              v-model="missionForm.emergencyProtocol"
255              type="checkbox"
256            />
257            Aktywuj Protokół Ratunkowy
258          </label>
259        </div>
260      </div>
261
262      <div class="form-section">
263        <h3>Cele Misji</h3>
264
265        <div class="form-field">
266          <label for="objectives">Szczegółowy opis celów:</label>
267          <textarea
268            id="objectives"
269            v-model.trim="missionForm.objectives"
270            placeholder="Opisz główne cele i zadania misji (minimum 20 znaków)..."
271            class="form-textarea"
272            :class="{ error: errors.objectives }"
273            rows="6"
274          ></textarea>
275          <div class="char-counter">
276            Znaki: {{ missionForm.objectives.length }}
277          </div>
278          <span v-if="errors.objectives" class="error-text">{{ errors.objectives }}</span>
279        </div>
280      </div>
281
282      <div class="form-actions">
283        <button
284          type="submit"
285          class="submit-btn"
286          :class="{ disabled: !isFormValid }"
287          :disabled="!isFormValid"
288        >
289          Zarejestruj Misję
290        </button>
291
292        <button
293          type="button"
294          @click="resetForm"
295          class="cancel-btn"
296        >
297          Anuluj
298        </button>
299      </div>
300
301      <div class="form-status" :class="{ valid: isFormValid }">
302        {{ isFormValid ? 'Formularz gotowy do wysłania' : 'Uzupełnij wszystkie wymagane pola' }}
303      </div>
304    </form>
305  </div>
306</template>
307
308<style scoped>
309.mission-control {
310  background: linear-gradient(135deg, #0a0e27 0%, #1a1e3f 100%);
311  color: #00ff88;
312  padding: 30px;
313  border-radius: 15px;
314  border: 2px solid #00b4d8;
315  font-family: 'Courier New', monospace;
316  max-width: 1000px;
317  margin: 0 auto;
318}
319
320.control-header {
321  text-align: center;
322  margin-bottom: 30px;
323  padding-bottom: 20px;
324  border-bottom: 2px solid #00b4d8;
325}
326
327.control-header h2 {
328  color: #00ff88;
329  margin: 0 0 10px 0;
330  font-size: 28px;
331  text-shadow: 0 0 10px #00ff88;
332}
333
334.subtitle {
335  color: #00b4d8;
336  margin: 0;
337  font-size: 14px;
338}
339
340.success-panel {
341  text-align: center;
342}
343
344.success-message {
345  background: rgba(0, 255, 136, 0.1);
346  border: 2px solid #00ff88;
347  border-radius: 12px;
348  padding: 30px;
349  margin-bottom: 30px;
350}
351
352.success-message h3 {
353  color: #00ff88;
354  margin: 0 0 10px 0;
355  font-size: 24px;
356}
357
358.success-message p {
359  color: #00b4d8;
360  margin: 0;
361}
362
363.submitted-data {
364  background: rgba(0, 180, 216, 0.05);
365  border: 1px solid #00b4d8;
366  border-radius: 8px;
367  padding: 20px;
368  margin-bottom: 20px;
369  text-align: left;
370}
371
372.submitted-data h4 {
373  color: #00ff88;
374  margin-top: 0;
375}
376
377.data-grid {
378  display: grid;
379  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
380  gap: 15px;
381  color: #00b4d8;
382}
383
384.data-grid strong {
385  color: #00ff88;
386}
387
388.reset-btn {
389  background: linear-gradient(135deg, #00b4d8 0%, #0077b6 100%);
390  color: white;
391  border: none;
392  padding: 15px 40px;
393  border-radius: 8px;
394  font-size: 16px;
395  font-weight: bold;
396  cursor: pointer;
397  font-family: 'Courier New', monospace;
398}
399
400.mission-form {
401  display: flex;
402  flex-direction: column;
403  gap: 30px;
404}
405
406.form-section {
407  background: rgba(0, 180, 216, 0.05);
408  padding: 25px;
409  border-radius: 12px;
410  border: 1px solid #00b4d8;
411}
412
413.form-section h3 {
414  color: #00ff88;
415  margin: 0 0 20px 0;
416  font-size: 20px;
417}
418
419.form-field {
420  margin-bottom: 20px;
421}
422
423.form-field:last-child {
424  margin-bottom: 0;
425}
426
427.form-field > label {
428  display: block;
429  color: #00b4d8;
430  margin-bottom: 8px;
431  font-size: 14px;
432  font-weight: bold;
433}
434
435.form-input,
436.form-textarea {
437  width: 100%;
438  background: rgba(0, 180, 216, 0.1);
439  border: 2px solid #00b4d8;
440  border-radius: 8px;
441  padding: 12px 15px;
442  color: #00ff88;
443  font-size: 16px;
444  font-family: 'Courier New', monospace;
445  box-sizing: border-box;
446}
447
448.form-input:focus,
449.form-textarea:focus {
450  outline: none;
451  border-color: #00ff88;
452  box-shadow: 0 0 15px rgba(0, 255, 136, 0.3);
453}
454
455.form-input.error,
456.form-textarea.error {
457  border-color: #ff6b35;
458}
459
460.form-textarea {
461  resize: vertical;
462}
463
464.form-row {
465  display: grid;
466  grid-template-columns: 1fr 1fr;
467  gap: 15px;
468}
469
470.error-text {
471  display: block;
472  margin-top: 5px;
473  color: #ff6b35;
474  font-size: 12px;
475}
476
477.char-counter {
478  margin-top: 5px;
479  color: #00b4d8;
480  font-size: 12px;
481}
482
483.radio-group,
484.checkbox-group {
485  display: flex;
486  flex-direction: column;
487  gap: 10px;
488}
489
490.radio-label,
491.checkbox-label {
492  display: flex;
493  align-items: center;
494  gap: 10px;
495  padding: 10px 15px;
496  background: rgba(0, 180, 216, 0.05);
497  border: 1px solid #00b4d8;
498  border-radius: 6px;
499  cursor: pointer;
500  color: #00ff88;
501}
502
503.radio-label:hover,
504.checkbox-label:hover {
505  background: rgba(0, 180, 216, 0.15);
506}
507
508.checkbox-label.emergency {
509  background: rgba(255, 107, 53, 0.1);
510  border-color: #ff6b35;
511  color: #ff6b35;
512  font-weight: bold;
513}
514
515.form-actions {
516  display: flex;
517  gap: 15px;
518  justify-content: center;
519}
520
521.submit-btn,
522.cancel-btn {
523  padding: 15px 40px;
524  border: none;
525  border-radius: 8px;
526  font-size: 16px;
527  font-weight: bold;
528  cursor: pointer;
529  font-family: 'Courier New', monospace;
530  transition: all 0.3s ease;
531}
532
533.submit-btn {
534  background: linear-gradient(135deg, #00ff88 0%, #00b368 100%);
535  color: #0a0e27;
536}
537
538.submit-btn:hover:not(.disabled) {
539  transform: translateY(-2px);
540  box-shadow: 0 5px 20px rgba(0, 255, 136, 0.4);
541}
542
543.submit-btn.disabled {
544  opacity: 0.5;
545  cursor: not-allowed;
546}
547
548.cancel-btn {
549  background: linear-gradient(135deg, #ff6b35 0%, #d64545 100%);
550  color: white;
551}
552
553.cancel-btn:hover {
554  transform: translateY(-2px);
555  box-shadow: 0 5px 20px rgba(255, 107, 53, 0.4);
556}
557
558.form-status {
559  text-align: center;
560  padding: 15px;
561  background: rgba(255, 107, 53, 0.1);
562  border: 2px solid #ff6b35;
563  border-radius: 8px;
564  color: #ff6b35;
565  font-weight: bold;
566}
567
568.form-status.valid {
569  background: rgba(0, 255, 136, 0.1);
570  border-color: #00ff88;
571  color: #00ff88;
572}
573</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 najlepiej zresetować formularz w Vue?

  2. 2. Jak pokazać dodatkowe pole tylko gdy checkbox jest zaznaczony?

To 2 z 4 pytań do tej lekcji. Pozostałe rozwiążesz w grze.

Zadania praktyczne w grze

  • Układanie w poziomie

    Ułóż składnię warunkowego wyświetlenia pola z v-if:

  • Edytor kodu

    Zbuduj formularz z fieldset, walidacją, reset i submit.

  • Układanie w poziomie

    Ułóż poprawną składnię fieldset z legend:

  • Klikanie w kolejności

    Ułóż składnię formularza z handlerem submit:

  • Edytor kodu

    Zbuduj formularz z walidacją @blur, przyciskiem reset i disabled submit.

  • Układanie w pionie

    Ułóż kroki tworzenia reaktywnego formularza w Vue:

  • Klikanie w kolejności

    Ułóż definicję computed sprawdzającego formularz:

  • Układanie w poziomie

    Ułóż poprawną składnię textarea z v-model od góry do dołu:

Przydatne artykuły