Ich möchte dir zeigen, wie man eine einfache Toast-Komponente mit Vue.js bauen kann. Toasts sind Notifications die man zum Beispiel von seinem Smartphone oder Rechner schon kennt. Das sind die kleinen Banner die meistens oben rechts eingeblendet werden.
TL;DR - Der Link zum Code auf github
Wenn Du einfach nur Code zum Kopieren und einfügen benötigst, dann findest den Code zu dem Beitrag auf meinem Github-Account unter:
Ein neues Vue 3 Projekt erstellen mit create-vue
Seit dem 07. Februar 2022 ist Vue 3 die offizielle aktuelle Vue Version, auch auf vuejs.org, daher werde ich dieses Projekt ebenfalls mit der aktuellen Vue Version erstellen und dabei auch die neue Composition-API von Vue 3 nutzen.
Im Ordner deiner Wahl folgenden Befehl ausführen:
npm init vue@latest
Da ich hier nur eine Beispiel-Komponente bauen möchte, hab ich ich bei den Optionen so gut wie nichts angehakt, außer ESLint und Prettier:
✔ Project name: … toasty-vue
✔ Add TypeScript? … No / Yes
✔ Add JSX Support? … No / Yes
✔ Add Vue Router for Single Page Application development? … No / Yes
✔ Add Pinia for state management? … No / Yes
✔ Add Vitest for Unit Testing? … No / Yes
✔ Add Cypress for both Unit and End-to-End testing? … No / Yes
✔ Add ESLint for code quality? … No / Yes
✔ Add Prettier for code formatting? … No / Yes
Als nächstes einfach den vorgegebenen Schritten folgen:
cd toasty-vue
npm install
npm run lint
npm run dev
Et voilà, wir haben ein laufendes Vue 3 Projekt mit einem Vite Server unter der Haube 🥳
vite v2.8.1 dev server running at:
> Local: http://localhost:3000/
> Network: use `--host` to expose
ready in 637ms.
Als nächstes schmeiß ich den ganzen Boilerplate Code raus. Ich leere den Ordner components
, den Ordner assets
und die Inhalte aus dem App.vue, damit ich ein komplett blankes Projekt in der Hand habe.
Welche Files werden benötigt?
Für mein Beispiel benötige ich nur zwei Files, die App.vue
die als Parent fungiert und die Komponente ToastyVue.vue
die als Child im Parent eingebunden, bzw. aufgerufen wird.
Der Plan ist, das im Parent ein Array mit den anfallenden Meldungen existiert, die als Prop in die Komponente reingereicht werden. Die Komponente kümmert sich dann um die Darstellung und Ausgabe der Meldung. Die Meldungen sollen sich Stapeln können und Animationen soll das ganze dann abrunden.
App.vue und das HTML-Grundgerüst
Da bei diesem Beispiel keine echten Events anfallen, bei denen man mit Notifications reagieren könnte, hab ich ein einfache Seitenkomponente in App.vue erstellt, in der ein wenig Text enthalten ist, sowie ein Button, der die Toast-Meldungen auslösen soll. Dazu noch ein wenig CSS und das Grundgerüst ist bereits fertig.
<template>
<section class="main">
<h1>Toasty-Vue Demo</h1>
<p>Just click the Button to try it out 😃</p>
</section>
<section>
<button class="primary">Show me the Toast!</button>
</section>
</template>
<style>
body {
height: 100vh;
width: 100vw;
display: flex;
justify-content: center;
align-items: center;
text-align: center;
font-family: Helvetica, Arial, sans-serif;
background-color: #f7f7f7;
color: #222222;
}
h1 {
font-size: 2em;
}
button {
cursor: pointer;
border: none;
border-radius: 5px;
padding: 0.5em 1em;
}
button.primary {
background-color: #d78407;
color: #ffffff;
font-size: 1.5em;
font-weight: bold;
}
button.primary:hover {
background-color: #e6951d;
}
</style>
Als nächstes kommt der Inhalt des neuen <script setup>
Abschnitts:
<script setup>import {ref} from "vue"; const toasts = ref([]);</script>
Ich erzeuge hier ein reaktives Array toasts
das die Meldungen enthalten soll, die angezeigt werden sollen.
Ich ergänze daher eine Funktion, die beim Klick auf dem Button, eine Meldung in das Array pushen soll und verdrahte den Button mit der Funktion:
<script setup>
import { ref } from "vue";
const toasts = ref([]);
function addOne() {
const fakeid = Date.now().toString();
toasts.value.push({
id: fakeid,
title: "This is the Title",
content: "This is the content",
});
}
</script>
Da ich später mit einem v-for über die Meldungen drüber loopen werde, erzeuge ich mir mit der
fakeid
einen String, den ich als id in die Meldung reinhänge. Solange ich nicht innerhalb einer Millisekunde zweimal auf den Button klicke, ist dieser Workaround erst mal “Good to Go” 😅
Damit haben wir das Grundgerüst in App.vue
fertig. Als nächstes geht es dann schon um die Komponente.
Die Toasty-Vue Komponente
Da die Daten von außen in die Komponente reingegeben werden, muss innerhalb der Komponente ein prop
definiert werden, das die Meldungen entgegen nehmen kann.
Da die Meldungen sich “aufstapeln” sollen, macht es Sinn alle Toasts in einen Container zu packen und innerhalb dem Container über die prop
zu loopen. Ich hab mir überlegt, auch einen Slot mit reinzupacken, damit man nach belieben von außen auch SVG-Icons mit reingeben kann. Damit die optische Aufteilung innerhalb der Notification mit Flex-Box funktionert, hab ich title und content noch zusätzlich in einen Wrapper gepackt. Den Wrapper benötige ich nur wegen dem CSS.
Das File ToastyVue.vue
schaut zunächst so aus:
<template>
<div class="toast-container">
<div v-for="toast in toasts" :key="toast.id" class="toast">
<slot></slot>
<div class="wrapper">
<div class="title">{{ toast.title.toUpperCase() }}</div>
<div class="content">{{ toast.content }}</div>
</div>
</div>
</div>
</template>
<script setup>
const props = defineProps({
toasts: {
type: Array,
default: () => [
{
title: {
type: String,
},
content: {
type: String,
},
},
],
},
});
</script>
<style>
.toast-container {
width: 22em;
position: absolute;
top: 0.75em;
right: 0.75em;
z-index: 10;
}
.toast {
background-color: #ffffff;
border-radius: 5px;
text-align: left;
display: flex;
align-items: stretch;
overflow: hidden;
justify-content: space-between;
margin-bottom: 10px;
box-shadow: 0 8px 17px rgba(0, 0, 0, 0.2);
-webkit-box-shadow: 0 8px 17px rgba(0, 0, 0, 0.2);
-moz-box-shadow: 0 8px 17px rgba(0, 0, 0, 0.2);
}
.title {
font-weight: bold;
font-size: 1.2em;
padding: 0.5em 0.75em 0.25em 0.75em;
}
.content {
padding: 0.5em 0.75em;
}
.wrapper {
padding: 0.5em;
display: flex;
flex-direction: column;
flex-grow: 1;
}
</style>
Ich ergänze auf Button “Show me the Toast” in App.vue
noch die Methode addOne
um einen Toast auszulösen und natürlich bau ich die neue Komponente auch in die Seite ein. Daraus ergibt sich dann folgendes Template:
<template>
<section class="main">
<h1>Toasty-Vue Demo</h1>
<p>Just click the Button to try it out 😃</p>
</section>
<section>
<button class="primary" @click="addOne">Show me the Toast!</button>
</section>
<ToastyVue :toasts="toasts" />
</template>
Ein Testklick und wir sehen schon oben rechts folgendes Ergebnis:
Den Toast mit einem emit wieder entfernen
Bis hierhin haben wir schon mal den Toast zur Anzeige gebracht. Da der gedachte User die Meldungen nicht bis in alle Ewigkeiten auf seinem Bildschirm haben möchte, müssen wir eine Möglichkeit schaffen, die Meldung zu schließen.
Da die Quelle der Meldung im Parent liegt, müssen wir im Child ein Event per emit abfeuern.
Ich ergänze hierzu in ToastyVue.vue
ein Button mit einem X
als SVG und ergänze ein @click-Methode die wiederum ein Event auslöst, auf das ich dann in App.vue reagieren kann. Da es mehrere aktive Toasts geben kann, schick ich als Payload die id mit, damit ich auch den richtigen Toast wieder entferne.
<template>
<div class="toast-container">
<div v-for="toast in toasts" :key="toast.id" class="toast">
<div class="wrapper">
<div class="title">{{ toast.title.toUpperCase() }}</div>
<div class="content">{{ toast.content }}</div>
</div>
<button class="close" @click="this.$emit('clear-one', toast.id)">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
</template>
<script setup>
const props = defineProps({
toasts: {
type: Array,
default: () => [
{
title: {
type: String,
},
content: {
type: String,
},
},
],
},
});
</script>
<style>
.toast-container {
width: 22em;
position: absolute;
top: 0.75em;
right: 0.75em;
z-index: 10;
}
.toast {
background-color: #ffffff;
border-radius: 5px;
text-align: left;
display: flex;
align-items: stretch;
overflow: hidden;
justify-content: space-between;
margin-bottom: 10px;
box-shadow: 0 8px 17px rgba(0, 0, 0, 0.2);
-webkit-box-shadow: 0 8px 17px rgba(0, 0, 0, 0.2);
-moz-box-shadow: 0 8px 17px rgba(0, 0, 0, 0.2);
}
button.close {
background-color: transparent;
align-self: flex-start;
padding: 0.5em;
}
button.close > svg {
stroke: #666666;
width: 1.5em;
height: 1.5em;
}
.title {
font-weight: bold;
font-size: 1.2em;
padding: 0.5em 0.75em 0.25em 0.75em;
}
.content {
padding: 0.5em 0.75em;
}
.wrapper {
padding: 0.5em;
display: flex;
flex-direction: column;
flex-grow: 1;
}
</style>
Damit das ganze auch tut was es soll, müssen wir natürlich im Parent auf das Event reagieren.
Also zurück in App.vue
müssen wir zwei Sachen ergänzen:
- Eine Funktion ergänzen, die eine
id
entgegen nimmt und das Element mit der entsprechendenid
aus unseremtoasts
-Array rausschmeißt - Einen Event-Listener der auf den
emit
vom Event horcht und unsere Funktion mit dem Payload ausführt
Unsere App.vue
schaut danach wie folgt aus:
<template>
<section class="main">
<h1>Toasty-Vue Demo</h1>
<p>Just click the Button to try it out 😃</p>
</section>
<section>
<button class="primary" @click="addOne">Show me the Toast!</button>
</section>
<ToastyVue :toasts="toasts" @clear-one="clearOne" />
</template>
<script setup>
import { ref } from "vue";
import ToastyVue from "./components/ToastyVue.vue";
const toasts = ref([]);
function clearOne(id) {
toasts.value = toasts.value.filter((item) => item.id !== id);
}
function addOne() {
const fakeid = Date.now().toString();
toasts.value.push({
id: fakeid,
title: "This is the Title",
content: "This is the content",
});
}
</script>
...
Die Grundlegende Funktionalität ist damit bereits fertig! Wenn wir jetzt den Button clicken, erscheint unser Toast und wenn wir das Kreuz in der Ecke anklicken, wird der Toast wieder geschlossen 😃
Es funktioniert, jetzt machen wir es noch geschmeidig
Mit Vue 3 kam auch der watchEffect()
dazu. Damit kann einfach gesagt der Zustand einzelner Objekte überwacht werden. Sollte sich irgendwas am Zustand ändern, dann kann man selber darauf reagieren und zum Beispiel eigenen Code ausführen. Ich nutze watchEffect()
in der ToastyVue-Komponente um das prop
zu überwachen. Ich möchte nach einer Zeit von 5 Sekunden die Toasts wieder von selber schließen, ohne das man das X
klicken muss.
Was mir ebenfalls noch nicht gefällt, ist das plötzliche erscheinen und verschwinden der Toasts. Hier möchte einen Effekt, der die Toasts von der Seite reinfährt und beim schließen auch wieder rausfährt.
On Top, fände ich es auch deutlich schöner wenn am Toast noch ein Icon vorne angezeigt würde.
Let’s do this!
Mit watchEffect die props überwachen
Damit man den watchEffect()
nutzen kann, muss man diesen zunächst oben in der Script Section importieren. Da ich zum entfernen eines Toasts auch hier einen Event feuern muss, muss ich zusätzlich auch noch den emit
im script setup deklarieren.
Im watchEffect()
überprüfe ich ob es Toasts gibt. Sobald ein Toast dazu kommt, soll ein Timer mit 5 Sekunden erzeugt werden, der dann die id
von dem zuerst hinzugekommen Toast wieder zum löschen freigibt.
Unsere Script-Sektion in ToastyVue.vue
schaut somit wie folgt aus:
...
<script setup>
import { watchEffect } from "vue";
const emit = defineEmits(["clear-one"]);
const props = defineProps({
toasts: {
type: Array,
default: () => [
{
title: {
type: String,
},
content: {
type: String,
},
},
],
},
});
watchEffect(() => {
if (props.toasts.length > 0) {
const addedItemId = props.toasts[0].id;
setTimeout(() => {
emit("clear-one", addedItemId);
}, 50000);
}
});
</script>
...
Ein SVG in den Slot der Komponente laden
Den Slot haben wir ja bereits in ToastyVue.vue
im Template drinnen, jetzt müssen wir diesen nur noch in App.vue
beim Komponentenaufruf befüllen. Dazu muss lediglich der selfClosing-Tag in einen Open- und Closing-Tag abgeändert werden und dazwischen kommt dann das, was im Slot landen soll. Da es nur einen Slot in der Komponente gibt, landet das SVG damit automatisch an der richtigen Stelle in unserem Template. Easy AF!
...
<ToastyVue :toasts="toasts" @clear-one="clearOne">
<div class="icon">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
</div>
</ToastyVue>
...
Wie man Elemente aus einem v-for Loop animieren kann
Da ich per v-for
über die Toasts aus dem prop drüber loope, muss ich für die Animation der einzelnen Toasts die TransitionGroup
von Vue verwenden. Die Standard-Transition
von Vue kann nur von einzelnen fixen Elementen verwendet werden.
Durch die Nutzung von TransitionGroup
, können wir in unserem CSS ein paar Anweisungen ergänzen und damit das Verhalten in 4 verschiedenen Phasen steuern:
enter-from
: Der Startzustandenter-active
: Der Weg zum aktiven Zustandleave-active
: Der Weg zum End-Zustandleave-to
: Der End-Zustand
// TranisitionGroup um das v-for herum
<template>
<div class="toast-container">
<TransitionGroup name="toastlist" tag="div">
<div v-for="toast in toasts" :key="toast.id" class="toast">
...
</div>
</TransitionGroup>
</div>
</template>
// Die Animationsanweisungen im CSS:
.toastlist-enter-from {
opacity: 50%;
transform: translateX(22em);
}
.toastlist-enter-active {
transition: all 0.3s cubic-bezier(0.5, 1.5, 1, 1);
}
.toastlist-leave-active {
transition: all 0.3s cubic-bezier(0.88, -0.33, 1, 1);
}
.toastlist-leave-to {
opacity: 0;
transform: translateX(22em);
}
Round-Up - die finalen Files
Wenn wir nun alles bisherige in einen Topf werfen, dann kommen wir auf folgenden File Zustand:
// App.vue
<template>
<section class="main">
<h1>Toasty-Vue Demo</h1>
<p>Just click the Button to try it out 😃</p>
</section>
<section>
<button class="primary" @click="addOne">Show me the Toast!</button>
</section>
<ToastyVue :toasts="toasts" @clear-one="clearOne">
<div class="icon">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
</div>
</ToastyVue>
</template>
<script setup>
import { ref } from "vue";
import ToastyVue from "./components/ToastyVue.vue";
const toasts = ref([]);
function clearOne(id) {
toasts.value = toasts.value.filter((item) => item.id !== id);
}
function addOne() {
const fakeid = Date.now().toString();
toasts.value.push({
id: fakeid,
title: "This is the Title",
content: "This is the content",
});
}
</script>
<style>
body {
height: 100vh;
width: 100vw;
display: flex;
justify-content: center;
align-items: center;
text-align: center;
font-family: Helvetica, Arial, sans-serif;
background-color: #f7f7f7;
color: #222222;
}
h1 {
font-size: 2em;
}
button {
cursor: pointer;
border: none;
border-radius: 5px;
padding: 0.5em 1em;
}
button.primary {
background-color: #d78407;
color: #ffffff;
font-size: 1.5em;
font-weight: bold;
}
button.primary:hover {
background-color: #e6951d;
}
</style>
// ToastyVue.vue
<template>
<div class="toast-container">
<TransitionGroup name="toastlist" tag="div">
<div v-for="toast in toasts" :key="toast.id" class="toast">
<slot></slot>
<div class="wrapper">
<div class="title">{{ toast.title.toUpperCase() }}</div>
<div class="content">{{ toast.content }}</div>
</div>
<button class="close" @click="this.$emit('clear-one', toast.id)">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</TransitionGroup>
</div>
</template>
<script setup>
import { watchEffect } from "vue";
const emit = defineEmits(["clear-one"]);
const props = defineProps({
toasts: {
type: Array,
default: () => [
{
title: {
type: String,
},
content: {
type: String,
},
},
],
},
});
watchEffect(() => {
if (props.toasts.length > 0) {
const addedItemId = props.toasts[0].id;
setTimeout(() => {
emit("clear-one", addedItemId);
}, 5000);
}
});
</script>
<style>
.toast-container {
width: 22em;
position: absolute;
top: 0.75em;
right: 0.75em;
z-index: 10;
}
.toast {
background-color: #ffffff;
border-radius: 5px;
text-align: left;
display: flex;
align-items: stretch;
overflow: hidden;
justify-content: space-between;
margin-bottom: 10px;
box-shadow: 0 8px 17px rgba(0, 0, 0, 0.2);
-webkit-box-shadow: 0 8px 17px rgba(0, 0, 0, 0.2);
-moz-box-shadow: 0 8px 17px rgba(0, 0, 0, 0.2);
}
.icon {
width: 3em;
display: flex;
background-color: #d78407;
align-items: center;
justify-content: center;
}
.icon > svg {
stroke: #ffffff;
width: 1.5em;
height: 1.5em;
}
button.close {
background-color: transparent;
align-self: flex-start;
padding: 0.5em;
}
button.close > svg {
stroke: #666666;
width: 1.5em;
height: 1.5em;
}
.title {
font-weight: bold;
font-size: 1.2em;
padding: 0.5em 0.75em 0.25em 0.75em;
}
.content {
padding: 0.5em 0.75em;
}
.wrapper {
padding: 0.5em;
display: flex;
flex-direction: column;
flex-grow: 1;
}
.toastlist-enter-from {
opacity: 50%;
transform: translateX(22em);
}
.toastlist-enter-active {
transition: all 0.3s cubic-bezier(0.5, 1.5, 1, 1);
}
.toastlist-leave-active {
transition: all 0.3s cubic-bezier(0.88, -0.33, 1, 1);
}
.toastlist-leave-to {
opacity: 0;
transform: translateX(22em);
}
</style>
Das alles führt uns zu folgenden Ergebnis:
Danke fürs lesen =)
Ich hoffe ich konnte dir mit diesem Beitrag bei einem deiner Projekte helfen, oder zumindest ein wenig Inspiration geben, wie man eine Toast-Komponente mit Vue 3 selber bauen kann 😃