Formulare
Auftrag
Jetzt kommt der Teil der App, auf den der Vorstand am meisten wartet.
Als Mitglied möchte ich ein Ding für einen Zeitraum reservieren, damit es bei der Abholung bereitliegt.
Die Akzeptanzkriterien lauten:
- Das Formular fragt nach der Mitgliedernummer, dem ersten und dem letzten Tag.
- Abholen und zurückbringen geht nur am Mittwoch oder am Samstag, und eine Ausleihe dauert höchstens sieben Tage.
- Fehler erscheinen beim Feld, sobald jemand es verlässt oder das Formular sendet, und ein Screenreader liest sie vor.
- Nach dem Senden fasst die App die Anfrage zusammen.
Lernziele
Nach diesem Kapitel kannst du
- Felder jeder Art mit
v-modelan den Zustand binden, - die Modifikatoren
.trim,.numberund.lazyeinsetzen, - ein Formular mit
@submit.preventabschicken, - Eingaben mit einem berechneten Objekt von Fehlern prüfen,
- Fehlermeldungen so anzeigen, dass alle sie wahrnehmen.
v-model
Abschnitt betitelt „v-model“Im letzten Kapitel hat v-model das Suchfeld mit einem Ref verbunden. Bei einem Textfeld ist es eine Kurzform für zwei Dinge, die du schon kennst. Die beiden Zeilen tun dasselbe.
<input :value="query" @input="query = $event.target.value"><input v-model="query">:value schreibt den Wert des Refs ins Feld, @input schreibt bei jeder Eingabe den Wert des Felds ins Ref. Je nach Art des Felds wählt Vue die passende Eigenschaft und das passende Event. Bei Kontrollkästchen und Optionsfeldern bindet es checked und hört auf change, bei einer Auswahl value und change.
Den Anfangswert bestimmt das Ref. Ein Attribut value, checked oder selected im HTML übergeht v-model.
Felder jeder Art
Abschnitt betitelt „Felder jeder Art“Das Beispiel zeigt alle üblichen Felder und darunter die Werte, die in den Refs ankommen. Überleg dir zuerst, welchen Typ jeder Wert hat, und ändere dann die Felder im Playground.
<script setup>import { ref } from 'vue'
const note = ref('')const days = ref(3)const rulesRead = ref(false)const extras = ref([])const pickup = ref('Mittwoch')const category = ref('')const firstDay = ref('')</script>
<template> <form class="fields"> <label for="note">Notiz</label> <textarea id="note" v-model="note"></textarea>
<label for="days">Anzahl Tage</label> <input id="days" v-model="days" type="number" min="1" max="7">
<label for="first-day">Erster Tag</label> <input id="first-day" v-model="firstDay" type="date">
<label for="category">Kategorie</label> <select id="category" v-model="category"> <option value="">Bitte wählen</option> <option>Werkzeug</option> <option>Garten</option> <option>Küche</option> </select>
<fieldset> <legend>Zubehör</legend> <label><input v-model="extras" type="checkbox" value="Akku"> Akku</label> <label><input v-model="extras" type="checkbox" value="Bohrer-Set"> Bohrer-Set</label> </fieldset>
<fieldset> <legend>Abholung</legend> <label><input v-model="pickup" type="radio" value="Mittwoch"> Mittwoch</label> <label><input v-model="pickup" type="radio" value="Samstag"> Samstag</label> </fieldset>
<label><input v-model="rulesRead" type="checkbox"> Ich habe die Ausleihregeln gelesen.</label> </form> <pre>{{ { note, days, firstDay, category, extras, pickup, rulesRead } }}</pre></template>
<style>.fields { display: grid; gap: 0.4rem; max-width: 22rem;}</style>Ein einzelnes Kontrollkästchen liefert true oder false. Mehrere Kontrollkästchen am selben Array sammeln die Werte der angekreuzten Kästchen. Optionsfelder liefern den Wert des gewählten Felds, eine Auswahl den Wert der gewählten Option, und eine Option ohne Attribut value liefert ihren Text. Ein Datumsfeld liefert einen Text wie 2026-11-18 oder einen leeren Text. Ein Zahlenfeld mit type="number" liefert eine Zahl, weil Vue dort den Modifikator .number von selbst anwendet.
Jedes Feld braucht eine Beschriftung. <label for="days"> gehört zum Feld mit der id days, ein Klick darauf setzt den Fokus ins Feld, und ein Screenreader liest die Beschriftung vor. Optionsfelder und Kontrollkästchen, die zusammengehören, fasst ein <fieldset> mit einer <legend> zusammen.
Modifikatoren
Abschnitt betitelt „Modifikatoren“Drei Modifikatoren verändern, was v-model ins Ref schreibt. .trim entfernt Leerzeichen am Anfang und am Ende. .number wandelt die Eingabe in eine Zahl um und lässt den Text stehen, wenn das nicht geht. .lazy schreibt erst, wenn jemand das Feld verlässt, statt bei jedem Zeichen.
<script setup>import { ref } from 'vue'
const member = ref('')const days = ref('')const note = ref('')</script>
<template> <p> <label for="member">Mitgliedernummer mit .trim</label> <input id="member" v-model.trim="member"> </p> <p> <label for="days">Tage mit .number</label> <input id="days" v-model.number="days"> </p> <p> <label for="note">Notiz mit .lazy</label> <input id="note" v-model.lazy="note"> </p> <pre>{{ JSON.stringify(member) }} {{ typeof days }} {{ days }} {{ JSON.stringify(note) }}</pre></template>Ein Formular abschicken
Abschnitt betitelt „Ein Formular abschicken“Felder gehören in ein Element <form> mit einem Knopf <button type="submit">. Dann sendet auch die Eingabetaste in einem Feld das Formular, wie es Menschen erwarten. Ohne weiteres Zutun schickt der Browser das Formular an den Server und lädt die Seite neu, und der ganze Zustand der App ist weg. @submit.prevent="submit" verhindert das und ruft stattdessen die Funktion submit auf.
Eingaben prüfen
Abschnitt betitelt „Eingaben prüfen“Die Prüfung gehört in einen berechneten Wert. Er gibt ein Objekt mit einer Meldung für jedes Feld zurück, an dem etwas nicht stimmt. Ist das Objekt leer, ist das Formular gültig. Weil computed von selbst neu rechnet, sind die Meldungen immer auf dem Stand der Felder.
<script setup>import { computed, ref } from 'vue'
const name = ref('')const category = ref('')const deposit = ref('')const touched = ref({})const submitted = ref(false)const thanks = ref('')
// One message per field with a problem, an empty object means the form is validconst errors = computed(() => { const result = {} if (name.value.trim() === '') result.name = 'Gib den Namen des Dings ein.' if (category.value === '') result.category = 'Wähl eine Kategorie.' if (typeof deposit.value !== 'number' || deposit.value < 10 || deposit.value > 80) { result.deposit = 'Das Pfand liegt zwischen 10 und 80 Franken.' } return result})
// A message appears once someone has left the field or sent the formconst shownErrors = computed(() => { const result = {} for (const [field, message] of Object.entries(errors.value)) { if (touched.value[field] || submitted.value) result[field] = message } return result})
function submit() { submitted.value = true if (Object.keys(errors.value).length > 0) return thanks.value = `Danke für den Vorschlag «${name.value.trim()}».` name.value = '' category.value = '' deposit.value = '' touched.value = {} submitted.value = false}</script>
<template> <h1>Ein Ding vorschlagen</h1> <form class="fields" novalidate @submit.prevent="submit"> <label for="name">Name des Dings</label> <input id="name" v-model="name" :aria-invalid="!!shownErrors.name" :aria-describedby="shownErrors.name ? 'name-error' : undefined" @blur="touched.name = true" > <p v-if="shownErrors.name" id="name-error" class="error">{{ shownErrors.name }}</p>
<label for="category">Kategorie</label> <select id="category" v-model="category" :aria-invalid="!!shownErrors.category" :aria-describedby="shownErrors.category ? 'category-error' : undefined" @blur="touched.category = true" > <option value="">Bitte wählen</option> <option>Werkzeug</option> <option>Garten</option> <option>Küche</option> <option>Camping</option> <option>Kinder</option> <option>Feste</option> </select> <p v-if="shownErrors.category" id="category-error" class="error">{{ shownErrors.category }}</p>
<label for="deposit">Pfand in Franken</label> <input id="deposit" v-model="deposit" type="number" min="10" max="80" :aria-invalid="!!shownErrors.deposit" :aria-describedby="shownErrors.deposit ? 'deposit-error' : undefined" @blur="touched.deposit = true" > <p v-if="shownErrors.deposit" id="deposit-error" class="error">{{ shownErrors.deposit }}</p>
<button type="submit">Vorschlagen</button> </form> <!-- The status element is always there, so screen readers announce new text --> <p role="status">{{ thanks }}</p></template>
<style>.fields { display: grid; gap: 0.4rem; max-width: 22rem;}.error { margin: 0; color: #a3341f;}</style>Wer ein Feld zum ersten Mal ausfüllt, soll nicht schon beim ersten Buchstaben einen Fehler sehen. Das Beispiel zeigt eine Meldung erst, wenn jemand das Feld verlassen hat oder das Formular sendet. touched merkt sich die verlassenen Felder, submitted den Versuch zu senden, und shownErrors enthält nur die Meldungen, die schon erscheinen dürfen.
Damit alle die Meldungen wahrnehmen, stehen sie als Text beim Feld und nicht nur als rote Farbe. aria-describedby verbindet das Feld mit dem Element der Meldung, ein Screenreader liest sie dann zusammen mit der Beschriftung vor. aria-invalid="true" sagt, dass die Eingabe nicht stimmt. Die Bestätigung steht in einem Element mit role="status". Ein Screenreader liest neuen Text darin von selbst vor, wenn das Element schon vorher in der Seite war.
Das Attribut novalidate am Formular schaltet die Meldungen des Browsers aus. Sie sehen in jedem Browser anders aus und erscheinen in der Sprache des Browsers, nicht in der der App. Attribute wie min und max lohnen sich trotzdem, ein Zahlenfeld zeigt dann passende Pfeile und ein Datumsfeld einen passenden Kalender.
Für Daten musst du nicht rechnen. Texte wie 2026-11-18 lassen sich direkt vergleichen, weil die Reihenfolge der Zeichen der Reihenfolge der Tage entspricht. new Date('2026-11-18').getUTCDay() liefert den Wochentag, 0 für Sonntag bis 6 für Samstag. Ein Datum ohne Uhrzeit liest JavaScript als Mitternacht in UTC, wie du aus der JS-Werkstatt weisst. Darum gilt getUTCDay und nicht getDay.
Im Code
Abschnitt betitelt „Im Code“Arbeite mit dem Beispiel zum Vorschlag. Lies den Code und schreib auf, was du erwartest, bevor du etwas änderst.
- Was erscheint, wenn du sofort auf «Vorschlagen» klickst? Und was, wenn du einen Namen eingibst und das Feld mit der Tabulatortaste verlässt?
- Gib als Pfand 85 ein und verlasse das Feld. Welche Meldung erscheint, und welche Attribute hat das Feld danach? Prüfe es mit den Entwicklertools des Browsers.
- Entferne
.preventund sende das Formular mit gültigen Werten. Was passiert mit der Vorschau, und warum? - Entferne
novalidateund ergänze am Namen das Attributrequired. Was zeigt der Browser beim Senden mit leerem Namen?
Aufgaben
Abschnitt betitelt „Aufgaben“Die Aufgaben bauen die Reservationsanfrage auf der Seite der Bohrmaschine. Du findest das Projekt im Kursrepo im Ordner exercises/03-vue/forms/de. Kopiere den Ordner, installiere die Pakete und starte die Tests.
npm installnpm testDie Tests tun so, als wäre heute Montag, der 2. November 2026. Die Datei src/dates.js liefert drei Hilfen, today() für das heutige Datum, daysBetween(from, to) für die Zahl der Tage dazwischen und formatDate(date) für die Anzeige.
Aufgabe Einstieg
Binde die drei Felder mit v-model an die Refs member, from und to, die Mitgliedernummer mit .trim. Beim Senden lädt die Seite nicht neu, sondern zeigt in einem Element <p class="summary" role="status"> die Zusammenfassung, etwa «Reservationsanfrage von M-117 für Bohrmaschine vom 18.11.2026 bis 21.11.2026 erfasst.».
Lösung
Die Felder bekommen v-model.trim="member", v-model="from" und v-model="to", und das Formular ruft beim Senden submit auf.
<form class="fields" novalidate @submit.prevent="submit">function submit() { submitted.value = true if (Object.keys(errors.value).length > 0) return summary.value = `Reservationsanfrage von ${member.value} für ${item.name} vom ${formatDate(from.value)} bis ${formatDate(to.value)} erfasst.`}Die beiden Zeilen mit submitted und dem Abbruch bei Fehlern kommen in der Vertiefung dazu. summary ist ein Ref mit einem leeren Text, darum ist das Element für den Status von Anfang an da.
Aufgabe Vertiefung
Prüf die Eingaben nach diesen Regeln und zeig die Meldung in einem Element mit der id member-error, from-error oder to-error und der Klasse error. Das Feld bekommt dann aria-invalid="true" und aria-describedby mit der id der Meldung. Die Meldungen erscheinen, sobald jemand das Formular sendet, und nur ein gültiges Formular erzeugt eine Zusammenfassung.
| Feld | Regel | Meldung |
|---|---|---|
| Mitgliedernummer | «M-» und drei Ziffern | Die Mitgliedernummer hat die Form M-117. |
| Erster Tag | nicht leer | Wähl den ersten Tag. |
| Erster Tag | nicht vor heute | Der erste Tag liegt in der Vergangenheit. |
| Letzter Tag | nicht leer | Wähl den letzten Tag. |
| Letzter Tag | nicht vor dem ersten Tag | Der letzte Tag liegt vor dem ersten. |
| Letzter Tag | höchstens sieben Tage nach dem ersten | Eine Ausleihe dauert höchstens sieben Tage. |
Lösung
Die Prüfung steht in einem berechneten Wert. Hier die Regeln für die Mitgliedernummer und den ersten Tag.
const errors = computed(() => { const result = {} if (!/^M-\d{3}$/.test(member.value)) { result.member = 'Die Mitgliedernummer hat die Form M-117.' } if (from.value === '') { result.from = 'Wähl den ersten Tag.' } else if (from.value < today()) { result.from = 'Der erste Tag liegt in der Vergangenheit.'Für den letzten Tag folgen die drei Regeln aus der Tabelle.
if (to.value === '') { result.to = 'Wähl den letzten Tag.' } else if (from.value !== '' && to.value < from.value) { result.to = 'Der letzte Tag liegt vor dem ersten.' } else if (from.value !== '' && daysBetween(from.value, to.value) > 7) { result.to = 'Eine Ausleihe dauert höchstens sieben Tage.'Das Feld und seine Meldung hängen über die id zusammen.
<label for="member">Mitgliedernummer</label> <input id="member" v-model.trim="member" :aria-invalid="!!shownErrors.member" :aria-describedby="shownErrors.member ? 'member-error' : undefined" @blur="touched.member = true" > <p v-if="shownErrors.member" id="member-error" class="error">{{ shownErrors.member }}</p>In der Vertiefung genügt als Bedingung submitted && errors.member statt shownErrors.member, und @blur kommt erst in der Knobelei dazu. /^M-\d{3}$/ verlangt genau «M-» und drei Ziffern, ^ und $ stehen für Anfang und Ende des Texts.
Aufgabe Knobelei
Abholen und zurückbringen geht nur an den Öffnungstagen. Ergänze für den ersten Tag die Meldung «Abholen kannst du am Mittwoch oder am Samstag.» und für den letzten Tag «Zurückbringen kannst du am Mittwoch oder am Samstag.». Ausserdem soll die Meldung zu einem Feld schon erscheinen, sobald jemand das Feld verlässt. new Date(date).getUTCDay() liefert 3 für Mittwoch und 6 für Samstag.
Lösung
<script setup>import { computed, ref } from 'vue'
import items from '../data/items.json'import { daysBetween, formatDate, today } from './dates.js'
const item = items.find((entry) => entry.id === 1)
const member = ref('')const from = ref('')const to = ref('')const touched = ref({})const submitted = ref(false)const summary = ref('')
// Wednesday and Saturday, the days the shop is open, as numbers of getUTCDayconst OPENING_DAYS = [3, 6]
function isOpeningDay(date) { return OPENING_DAYS.includes(new Date(date).getUTCDay())}
// One message per field with a problem, an empty object means the request is validconst errors = computed(() => { const result = {} if (!/^M-\d{3}$/.test(member.value)) { result.member = 'Die Mitgliedernummer hat die Form M-117.' } if (from.value === '') { result.from = 'Wähl den ersten Tag.' } else if (from.value < today()) { result.from = 'Der erste Tag liegt in der Vergangenheit.' } else if (!isOpeningDay(from.value)) { result.from = 'Abholen kannst du am Mittwoch oder am Samstag.' } if (to.value === '') { result.to = 'Wähl den letzten Tag.' } else if (from.value !== '' && to.value < from.value) { result.to = 'Der letzte Tag liegt vor dem ersten.' } else if (from.value !== '' && daysBetween(from.value, to.value) > 7) { result.to = 'Eine Ausleihe dauert höchstens sieben Tage.' } else if (!isOpeningDay(to.value)) { result.to = 'Zurückbringen kannst du am Mittwoch oder am Samstag.' } return result})
// A message appears once someone has left the field or sent the formconst shownErrors = computed(() => { const result = {} for (const [field, message] of Object.entries(errors.value)) { if (touched.value[field] || submitted.value) result[field] = message } return result})
function submit() { submitted.value = true if (Object.keys(errors.value).length > 0) return summary.value = `Reservationsanfrage von ${member.value} für ${item.name} vom ${formatDate(from.value)} bis ${formatDate(to.value)} erfasst.`}</script>
<template> <h1>{{ item.name }}</h1> <p>{{ item.category }}</p> <h2>Reservation anfragen</h2> <form class="fields" novalidate @submit.prevent="submit"> <label for="member">Mitgliedernummer</label> <input id="member" v-model.trim="member" :aria-invalid="!!shownErrors.member" :aria-describedby="shownErrors.member ? 'member-error' : undefined" @blur="touched.member = true" > <p v-if="shownErrors.member" id="member-error" class="error">{{ shownErrors.member }}</p>
<label for="from">Erster Tag</label> <input id="from" v-model="from" type="date" :aria-invalid="!!shownErrors.from" :aria-describedby="shownErrors.from ? 'from-error' : undefined" @blur="touched.from = true" > <p v-if="shownErrors.from" id="from-error" class="error">{{ shownErrors.from }}</p>
<label for="to">Letzter Tag</label> <input id="to" v-model="to" type="date" :aria-invalid="!!shownErrors.to" :aria-describedby="shownErrors.to ? 'to-error' : undefined" @blur="touched.to = true" > <p v-if="shownErrors.to" id="to-error" class="error">{{ shownErrors.to }}</p>
<button type="submit">Anfrage senden</button> </form> <p class="summary" role="status">{{ summary }}</p></template>isOpeningDay prüft den Wochentag, und die Prüfung steht jeweils zuletzt, damit zuerst die wichtigere Meldung erscheint. @blur trägt das verlassene Feld in touched ein, und shownErrors zeigt eine Meldung, wenn ihr Feld verlassen oder das Formular gesendet wurde. Von Samstag bis Samstag sind es genau sieben Tage, das ist die längste Ausleihe, die die Regeln erlauben.
Typische Fehler
Abschnitt betitelt „Typische Fehler“| Fehler | Was hilft |
|---|---|
| Beim Senden lädt die Seite neu, und alle Eingaben sind weg. | @submit.prevent am Formular setzen. |
value="3" im HTML erscheint nicht im Feld. | v-model übergeht das Attribut. Den Anfangswert im Ref setzen, ref(3). |
Ein Textfeld für Zahlen liefert '3' statt 3, und days + 1 ergibt '31'. | v-model.number verwenden oder type="number", dann wandelt Vue den Wert um. |
<textarea>{{ note }}</textarea> hält Feld und Ref nicht im Takt. | Auch Textbereiche mit v-model binden, Interpolation funktioniert darin nicht. |
| Der Wochentag stimmt in manchen Zeitzonen nicht. | Bei Daten ohne Uhrzeit getUTCDay statt getDay verwenden. |
| Fehler sind nur rot umrandet oder stehen im Platzhalter. | Die Meldung als Text beim Feld zeigen und mit aria-describedby verbinden. |
| Ein KI-Assistent schlägt für ein kleines Formular eine Bibliothek zur Validierung vor. | Für wenige Felder genügt ein berechnetes Objekt mit Fehlern. Bibliotheken lohnen sich bei vielen und verschachtelten Formularen. |
Selbstcheck
Abschnitt betitelt „Selbstcheck“Selbstcheck
- Wofür ist
v-modelbei einem Textfeld eine Kurzform? - Was bewirken
.trim,.numberund.lazy? - Warum braucht das Formular
@submit.prevent? - Wie verbindest du eine Fehlermeldung so mit ihrem Feld, dass ein Screenreader sie vorliest?
Antworten
- Für
:value="query"zusammen mit@input="query = $event.target.value". .trimentfernt Leerzeichen am Rand,.numberwandelt in eine Zahl um, und.lazyschreibt erst beim Verlassen des Felds.- Ohne
.preventschickt der Browser das Formular ab und lädt die Seite neu, der Zustand der App geht verloren. - Die Meldung bekommt eine id, und das Feld verweist mit
aria-describedbydarauf.aria-invalid="true"sagt zusätzlich, dass die Eingabe nicht stimmt.
Auftrag
Die erste Reservationsanfrage ist erfasst, und Martina Hostettler ist zufrieden. Der Freiwillige hat beim Durchsehen aber etwas bemerkt. «App.vue hat inzwischen fast 180 Zeilen, und Katalog, Suche und Formular stecken alle darin. Das sollten wir aufteilen.» Genau das tut Teil 4 mit Komponenten. Vorher prüfst du mit dem Kompetenzraster, ob du für Teil 4 bereit bist.
Texte und Abbildungen stehen unter CC BY-SA 4.0, der Code unter der MIT-Lizenz und die Daten des Kurses unter CC0 1.0. Die Schrift der Abbildungen ist Kalam unter der SIL Open Font License 1.1. Die Quellen liegen auf GitHub.