feat(#33): Add a global toast/notification component
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as ToastContainer } from './ToastContainer.vue';
|
||||
export { toast, useToastStore } from './useToast';
|
||||
export type { ToastOptions, ToastType } from './useToast';
|
||||
@@ -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'}),
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user