Slots
Auftrag
Martina Hostettler möchte nichts mehr verschicken, das sie nicht noch einmal gesehen hat.
Als Mitglied möchte ich meine Anfrage vor dem Senden noch einmal sehen und bestätigen, damit ich keine falschen Angaben schicke.
Der Freiwillige hat einen zweiten Wunsch angehängt. Katalog und Seite eines Dings sollen denselben Rahmen mit Kopf und Fuss haben, ohne dass jede Seite ihn selbst zusammenbaut. Die Akzeptanzkriterien lauten:
- Nach «Anfrage senden» erscheint ein Dialog mit dem Ding, den Tagen und der Mitgliedernummer.
- Der Dialog hat die Knöpfe «Bestätigen» und «Zurück», erst nach «Bestätigen» ist die Anfrage erfasst.
- Katalog und Seite eines Dings stehen im selben Rahmen mit Kopf und Fuss.
Lernziele
Nach diesem Kapitel kannst du
- Inhalte mit einem Standard-Slot an eine Komponente übergeben,
- Ersatzinhalt für einen leeren Slot festlegen,
- benannte Slots für mehrere Stellen einer Komponente verwenden,
- mit Scoped Slots Daten an die Elternkomponente zurückgeben,
- einen Seitenrahmen und einen zugänglichen Dialog als Komponenten bauen.
Inhalte übergeben
Abschnitt betitelt „Inhalte übergeben“Mit Props gibst du einer Komponente Werte, einen Namen oder eine Zahl. Manchmal soll sie aber ganze Inhalte aufnehmen, einen Absatz mit Hervorhebung, einen Knopf oder eine andere Komponente. Dafür gibt es Slots. <slot /> im Template markiert die Stelle, an der der Inhalt erscheint, den die Elternkomponente zwischen die Tags der Komponente schreibt.
App.vue
<script setup>import InfoBox from './InfoBox.vue'</script>
<template> <InfoBox>Die <strong>Bohrmaschine</strong> kommt mit zwei Akkus.</InfoBox> <InfoBox /></template>InfoBox.vue
<template> <aside class="info"> <!-- The content between the tags of InfoBox appears here, otherwise the fallback --> <slot>Zu diesem Ding gibt es keine Hinweise.</slot> </aside></template>
<style scoped>.info { margin-bottom: 0.5rem; padding: 0.5rem 1rem; border-left: 4px solid #1f6b4f; background: #eef6f2;}</style>Die erste InfoBox zeigt den Text mit dem fett gesetzten Namen. Die zweite bekommt nichts und zeigt den Ersatzinhalt, der zwischen <slot> und </slot> steht. Der Inhalt eines Slots gehört zur Elternkomponente. Er kann deren Daten verwenden, nicht aber die der Komponente, in die er kommt.
Benannte Slots
Abschnitt betitelt „Benannte Slots“Ein Seitenrahmen braucht mehrere Stellen, den Kopf, den Inhalt und den Fuss. Dafür bekommen Slots einen Namen, <slot name="header">. Die Elternkomponente füllt sie mit <template #header>, der Kurzform von v-slot:header. Alles, was nicht in einem solchen <template> steht, landet im Standard-Slot ohne Namen.
App.vue
<script setup>import PageLayout from './PageLayout.vue'</script>
<template> <PageLayout> <template #header> <a href="#/">Lorraine Commons</a> </template>
<h1>Katalog</h1> <p>Alles ohne Namen landet im Standard-Slot.</p>
<template #footer> Mittwoch 17 bis 19 Uhr, Samstag 10 bis 13 Uhr </template> </PageLayout></template>PageLayout.vue
<template> <div class="layout"> <header> <slot name="header">Lorraine Commons</slot> </header> <main> <slot /> </main> <footer> <slot name="footer" /> </footer> </div></template>
<style scoped>.layout { display: grid; gap: 0.5rem;}header,footer { padding: 0.5rem 1rem; background: #eef6f2;}</style>PageLayout legt fest, wie Kopf, Inhalt und Fuss angeordnet sind. Die Seiten bestimmen nur, was darin steht. Ändert der Verein später den Rahmen, ändert sich eine Datei, nicht jede Seite.
Ein Dialog mit Slots
Abschnitt betitelt „Ein Dialog mit Slots“Der Dialog für die Anfrage ist ein allgemeiner Baustein, darum heisst er BaseDialog. Er weiss nichts von Reservationen. Seine Slots nehmen den Titel, den Inhalt und die Knöpfe auf, und ohne Knöpfe zeigt er einen Knopf zum Schliessen, der das Event close auslöst.
App.vue
<script setup>import { ref } from 'vue'
import BaseDialog from './BaseDialog.vue'
const open = ref(false)const status = ref('')
function confirm() { open.value = false status.value = 'Reservationsanfrage erfasst.'}</script>
<template> <button type="button" @click="open = true">Anfrage senden</button> <p role="status">{{ status }}</p> <BaseDialog v-if="open" @close="open = false"> <template #title>Anfrage prüfen</template> <p>Bohrmaschine vom 18.11.2026 bis 21.11.2026</p> <template #actions> <button type="button" @click="confirm">Bestätigen</button> <button type="button" @click="open = false">Zurück</button> </template> </BaseDialog></template>BaseDialog.vue
<script setup>import { useId } from 'vue'
defineEmits(['close'])
// A unique id connects the dialog with its titleconst titleId = useId()</script>
<template> <div class="backdrop"> <div class="dialog" role="dialog" aria-modal="true" :aria-labelledby="titleId"> <h2 :id="titleId"> <slot name="title">Hinweis</slot> </h2> <slot /> <div class="actions"> <slot name="actions"> <button type="button" @click="$emit('close')">Schliessen</button> </slot> </div> </div> </div></template>
<style scoped>.backdrop { position: fixed; inset: 0; display: grid; place-items: center; background: rgb(0 0 0 / 40%);}.dialog { max-width: 24rem; padding: 1rem 1.5rem; border-radius: 8px; background: #ffffff;}.actions { display: flex; gap: 0.5rem;}</style>Damit ein Screenreader den Dialog erkennt, trägt er role="dialog" und aria-modal="true". aria-labelledby verweist auf die id des Titels, und ein Screenreader liest ihn beim Öffnen vor. Die id stammt von useId(), einer Funktion von Vue 3.5, die in jeder Komponente eine eindeutige id liefert. Zwei Dialoge kommen sich so nicht in die Quere.
Zwei Dinge fehlen dem Dialog noch. Der Fokus springt beim Öffnen nicht hinein, und die Taste Escape schliesst ihn nicht. Dafür braucht es Zugriff auf Elemente des DOM, den Teil 5 mit Template-Refs zeigt. Das HTML-Element <dialog> mit seiner Methode showModal() erledigt beides von selbst und ist eine Alternative, die ebenfalls ein Template-Ref braucht.
Scoped Slots
Abschnitt betitelt „Scoped Slots“Manchmal kennt die Komponente die Daten, aber die Elternkomponente soll entscheiden, wie sie aussehen. ItemList kennt die Dinge und baut die Liste, der Katalog möchte darin aber Karten zeigen und eine kurze Übersicht nur Namen. Dafür gibt die Komponente dem Slot Daten mit, <slot name="item" :item="item">. Die Elternkomponente nimmt sie mit <template #item="{ item }"> entgegen.
App.vue
<script setup>import ItemList from './ItemList.vue'
const items = [ { id: 1, name: 'Bohrmaschine', deposit: 20, availableFrom: null }, { id: 2, name: 'Zelt für vier Personen', deposit: 50, availableFrom: '2026-11-12' }, { id: 6, name: 'Beamer', deposit: 80, availableFrom: null },]</script>
<template> <h2>Mit eigenem Inhalt</h2> <ItemList :items="items"> <template #item="{ item }"> <strong>{{ item.name }}</strong>, Pfand CHF {{ item.deposit }} </template> </ItemList>
<h2>Mit dem Inhalt der Liste</h2> <ItemList :items="items" /></template>ItemList.vue
<script setup>defineProps({ items: { type: Array, required: true } })</script>
<template> <ul> <li v-for="item in items" :key="item.id"> <!-- The slot hands the entry to the parent, which decides what to show --> <slot name="item" :item="item">{{ item.name }}</slot> </li> </ul></template>Die erste Liste gestaltet ihre Einträge selbst, die zweite zeigt den Ersatzinhalt mit dem Namen. Weil die Daten pro Eintrag kommen, heisst diese Form Scoped Slot, ein Slot mit eigenem Gültigkeitsbereich.
Im Code
Abschnitt betitelt „Im Code“Arbeite mit dem Beispiel zum Dialog. Lies den Code und schreib auf, was du erwartest, bevor du etwas änderst.
- Was zeigt der Dialog, wenn
Appdas<template #actions>weglässt? Und was, wenn auch#titlefehlt? - Öffne den Dialog und untersuche ihn mit den Entwicklertools des Browsers. Auf welches Element verweist
aria-labelledby? - Setz in
AppzweiBaseDialognebeneinander. Haben ihre Titel dieselbe id? - Verschiebe den Absatz mit der Bohrmaschine in ein
<template #default>. Ändert sich etwas, und warum?
Aufgaben
Abschnitt betitelt „Aufgaben“Die Aufgaben bauen den Rahmen der Seite, den Dialog und eine Liste mit Scoped Slot. Du findest das Projekt im Kursrepo im Ordner exercises/04-components/slots/de. Kopiere den Ordner, installiere die Pakete und starte die Tests.
npm installnpm testAufgabe Einstieg
Lege src/components/PageLayout.vue an. Die Komponente hat einen Slot header in einem Element header, den Standard-Slot in einem Element main und einen Slot footer in einem Element footer. Ohne Inhalt für den Kopf zeigt sie «Lorraine Commons». Rahme die Seite in App.vue damit ein, sodass App.vue selbst kein Element header mehr enthält.
Lösung
<template> <div class="layout"> <header class="page-header"> <slot name="header">Lorraine Commons</slot> </header> <main> <slot /> </main> <footer class="page-footer"> <slot name="footer" /> </footer> </div></template>
<style scoped>.page-header,.page-footer { padding: 0.5rem 1rem; background: #eef6f2;}</style>In App.vue steht der Inhalt der Seite zwischen <PageLayout> und </PageLayout>, Kopf und Fuss stehen in <template #header> und <template #footer>.
<PageLayout> <template #header> <a href="#/">Lorraine Commons</a> </template>
<h1>{{ drill.name }}</h1> <button type="button" class="send" @click="open = true">Anfrage senden</button> <p class="status" role="status">{{ status }}</p> <h2>Weitere Dinge</h2> <ItemList :items="items.slice(0, 6)"> <template #item="{ item }"> {{ item.name }}, Pfand {{ formatDeposit(item.deposit) }} </template> </ItemList>
<template #footer> Mittwoch 17 bis 19 Uhr, Samstag 10 bis 13 Uhr </template> </PageLayout>Aufgabe Vertiefung
Lege src/components/BaseDialog.vue an. Der Slot title steht in einem h2, der Standard-Slot darunter und der Slot actions am Ende. Ohne Knöpfe zeigt der Dialog einen Knopf «Schliessen», der das Event close auslöst. Das äussere Element des Dialogs trägt role="dialog", aria-modal="true" und aria-labelledby mit der id des Titels. In App öffnet der Knopf «Anfrage senden» den Dialog mit dem Titel «Anfrage prüfen» und den Knöpfen «Bestätigen» und «Zurück».
Lösung
<script setup>import { useId } from 'vue'
defineEmits(['close'])
// A unique id connects the dialog with its titleconst titleId = useId()</script>
<template> <div class="backdrop"> <div class="dialog" role="dialog" aria-modal="true" :aria-labelledby="titleId"> <h2 :id="titleId"> <slot name="title">Hinweis</slot> </h2> <slot /> <div class="actions"> <slot name="actions"> <button type="button" @click="$emit('close')">Schliessen</button> </slot> </div> </div> </div></template>App zeigt den Dialog mit v-if und füllt seine Slots.
<BaseDialog v-if="open" @close="open = false"> <template #title>Anfrage prüfen</template> <p>{{ drill.name }}</p> <template #actions> <button type="button" class="confirm" @click="confirm">Bestätigen</button> <button type="button" @click="open = false">Zurück</button> </template> </BaseDialog>$emit('close') im Template löst das Event aus, ohne dass das Script defineEmits als Funktion braucht. Die Deklaration mit defineEmits(['close']) dokumentiert das Event trotzdem.
Aufgabe Knobelei
Lege src/components/ItemList.vue an. Sie bekommt die Prop items und zeigt für jedes Ding einen Eintrag. Jeder Eintrag ist ein Slot item, der das Ding als item mitgibt und ohne Inhalt den Namen zeigt. Verwende die Liste in App für die weiteren Dinge und zeig darin zu jedem Ding das Pfand, etwa «Bohrmaschine, Pfand CHF 20.00».
Lösung
<script setup>defineProps({ items: { type: Array, required: true } })</script>
<template> <ul class="catalog"> <li v-for="item in items" :key="item.id"> <!-- The slot hands the entry to the parent, without a slot the name appears --> <slot name="item" :item="item">{{ item.name }}</slot> </li> </ul></template> <ItemList :items="items.slice(0, 6)"> <template #item="{ item }"> {{ item.name }}, Pfand {{ formatDeposit(item.deposit) }} </template> </ItemList>ItemList kümmert sich um die Liste und die Schlüssel, App nur um den Inhalt eines Eintrags. Dieselbe Liste kann auf der Seite mit den Favoriten Karten zeigen und hier eine kurze Zeile.
Typische Fehler
Abschnitt betitelt „Typische Fehler“| Fehler | Was hilft |
|---|---|
| Der Inhalt eines Slots greift auf Daten der Komponente zu und bekommt nichts. | Ein Slot sieht nur die Daten der Elternkomponente. Die Komponente gibt Daten über einen Scoped Slot mit. |
Vue meldet v-slot can only be used on components or <template> tags. | #header an ein <template> oder direkt an die Komponente setzen, nicht an ein div. |
| Inhalt für einen benannten Slot erscheint im Standard-Slot. | Den Inhalt in ein <template #name> setzen. |
Ein Dialog ohne role und Beschriftung, ein Screenreader erkennt ihn nicht. | role="dialog", aria-modal="true" und aria-labelledby setzen. |
Jeder Dialog verwendet die feste id dialog-title, und zwei Dialoge haben dieselbe. | Die id mit useId() erzeugen. |
Ein KI-Assistent schreibt slot="header" an ein Element. | Diese Schreibweise stammt aus Vue 2, in Vue 3 landet der Inhalt im Standard-Slot. <template #header> verwenden. |
Selbstcheck
Abschnitt betitelt „Selbstcheck“Selbstcheck
- Worin unterscheiden sich Props und Slots?
- Wann erscheint der Ersatzinhalt eines Slots?
- Wie kommt ein Inhalt in einen benannten Slot?
- Wofür braucht es einen Scoped Slot?
Antworten
- Props übergeben Werte, Slots ganze Inhalte aus HTML und Komponenten.
- Wenn die Elternkomponente für diesen Slot nichts übergibt.
- Die Elternkomponente setzt ihn in ein
<template #name>, die Komponente zeigt ihn an der Stelle von<slot name="name">. - Wenn die Komponente die Daten kennt, die Elternkomponente aber bestimmen soll, wie sie aussehen, etwa die Einträge einer Liste.
Auftrag
Der Dialog kommt gut an. Martina Hostettler hat aber bemerkt, dass sie ihre Mitgliedernummer jedes Mal neu eintippt. «Die App weiss doch, wer angemeldet ist.» Der Freiwillige fügt an, dass der Dialog am Ende der Seite stehen sollte und nicht mitten im Formular. Beides löst das nächste Kapitel.
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.