feat(#33): Add a global toast/notification component

This commit is contained in:
Sirttas
2026-07-16 14:20:38 +02:00
parent 24c7ec4917
commit 187c6b0f99
5 changed files with 166 additions and 1 deletions
+2
View File
@@ -3,6 +3,7 @@ import {computed} from 'vue';
import {RouterView, useRoute} from 'vue-router';
import {Sidebar} from './sidebar';
import {ConfirmModal} from '@/confirm';
import {ToastContainer} from '@/toast';
import {routeNames} from '@/routes';
const route = useRoute();
@@ -23,6 +24,7 @@ const hideSidebar = computed(() => {
</div>
</template>
<ConfirmModal />
<ToastContainer />
</template>
<style scoped>
+4 -1
View File
@@ -5,6 +5,7 @@ import log from "loglevel";
import {ScriptEditor, useRuleBookStore} from "@/rules";
import {PlusIcon, TrashIcon} from "@heroicons/vue/24/outline";
import {isMain, Ledger, LedgerSelect, systemLedger, useLedgersStore} from "@/ledger";
import {toast} from "@/toast";
type Binding = { ref: string; ledger: Ledger };
@@ -42,7 +43,9 @@ const save = () => ruleBookStore.update({
.map(b => [b.ref, b.ledger.ledgerId])
),
script: script.value
});
})
.then(() => toast.success('Rule book saved'))
.catch(() => toast.error('Failed to save rule book'));
useEventListener(window, 'keydown', (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') {
+94
View File
@@ -0,0 +1,94 @@
<script setup lang="ts">
import {
CheckCircleIcon,
InformationCircleIcon,
XCircleIcon,
XMarkIcon,
} from '@heroicons/vue/24/outline';
import type {ToastType} from './useToast';
import {useToastStore} from './useToast';
const toastStore = useToastStore();
const icons = {
success: CheckCircleIcon,
error: XCircleIcon,
info: InformationCircleIcon,
} as const;
const accent = (type: ToastType) => `accent-${type}`;
</script>
<template>
<div class="toast-container">
<TransitionGroup name="toast">
<div v-for="toast in toastStore.toasts" :key="toast.id" class="toast" :class="accent(toast.type)">
<component :is="icons[toast.type]" class="icon" />
<div class="body">
<span v-if="toast.title" class="title">{{ toast.title }}</span>
<span class="message">{{ toast.message }}</span>
</div>
<button class="btn-icon close" @click="toastStore.dismiss(toast.id)">
<XMarkIcon />
</button>
</div>
</TransitionGroup>
</div>
</template>
<style scoped>
@reference "@/style.css";
div.toast-container {
@apply fixed top-4 right-4 z-50 flex flex-col gap-2 w-80 max-w-[calc(100vw-2rem)];
}
div.toast {
@apply flex items-start gap-2 p-3 rounded bg-slate-700 border-l-4 shadow-lg;
&.accent-success {
@apply border-emerald-500;
> .icon { @apply text-emerald-500; }
}
&.accent-error {
@apply border-amber-700;
> .icon { @apply text-amber-700; }
}
&.accent-info {
@apply border-sky-600;
> .icon { @apply text-sky-600; }
}
> .icon {
@apply w-6 h-6 shrink-0;
}
> .body {
@apply flex flex-col grow min-w-0;
> .title {
@apply font-semibold;
}
> .message {
@apply break-words;
}
}
> .close {
@apply shrink-0;
}
}
.toast-enter-active,
.toast-leave-active,
.toast-move {
@apply transition-all duration-300;
}
.toast-enter-from,
.toast-leave-to {
@apply opacity-0 translate-x-8;
}
.toast-leave-active {
@apply absolute w-full;
}
</style>
+3
View File
@@ -0,0 +1,3 @@
export { default as ToastContainer } from './ToastContainer.vue';
export { toast, useToastStore } from './useToast';
export type { ToastOptions, ToastType } from './useToast';
+63
View File
@@ -0,0 +1,63 @@
import {defineStore} from "pinia";
import {ref} from "vue";
export type ToastType = 'success' | 'error' | 'info';
export interface ToastOptions {
message: string;
title?: string;
type?: ToastType;
duration?: number;
}
export interface Toast {
id: number;
message: string;
title?: string;
type: ToastType;
}
const DEFAULT_DURATION = 5000;
export const useToastStore = defineStore('toast', () => {
const toasts = ref<Toast[]>([]);
let nextId = 0;
const dismiss = (id: number) => {
toasts.value = toasts.value.filter(toast => toast.id !== id);
};
const push = (opts: ToastOptions | string): number => {
const options = typeof opts === "string" ? {message: opts} : opts;
const id = nextId++;
toasts.value.push({
id,
message: options.message,
title: options.title,
type: options.type ?? 'info',
});
const duration = options.duration ?? DEFAULT_DURATION;
if (duration > 0) {
setTimeout(() => dismiss(id), duration);
}
return id;
};
return {toasts, push, dismiss};
});
interface ToastHelper {
(opts: ToastOptions | string): number;
success: (message: string) => number;
error: (message: string) => number;
info: (message: string) => number;
}
export const toast: ToastHelper = Object.assign(
(opts: ToastOptions | string): number => useToastStore().push(opts),
{
success: (message: string): number => useToastStore().push({message, type: 'success'}),
error: (message: string): number => useToastStore().push({message, type: 'error'}),
info: (message: string): number => useToastStore().push({message, type: 'info'}),
},
);