101 lines
3.3 KiB
Vue
101 lines
3.3 KiB
Vue
<script setup>
|
|
import { toasts } from "~/composables/useToast";
|
|
import { TransitionGroup } from "vue";
|
|
import { computed } from "vue";
|
|
|
|
const props = defineProps({
|
|
position: {
|
|
type: String,
|
|
default: "bottom-right",
|
|
validator: (value) =>
|
|
["top-left", "top-right", "bottom-left", "bottom-right"].includes(value),
|
|
},
|
|
});
|
|
|
|
const positionClasses = {
|
|
"top-left": "top-0 left-0",
|
|
"top-right": "top-0 right-0",
|
|
"bottom-left": "bottom-0 left-0",
|
|
"bottom-right": "bottom-0 right-0",
|
|
};
|
|
|
|
// Compute animation classes based on position
|
|
const getAnimationClasses = (position) => {
|
|
const isTop = position.startsWith("top");
|
|
return {
|
|
enter: {
|
|
active: "transition ease-out duration-300",
|
|
from: `opacity-0 ${isTop ? "-translate-y-2" : "translate-y-2"}`,
|
|
to: "opacity-100 translate-y-0",
|
|
},
|
|
leave: {
|
|
active: "transition ease-in duration-200",
|
|
from: "opacity-100 translate-y-0",
|
|
to: `opacity-0 ${isTop ? "-translate-y-2" : "translate-y-2"}`,
|
|
},
|
|
};
|
|
};
|
|
|
|
const animations = computed(() => getAnimationClasses(props.position));
|
|
|
|
// Compute whether there are active toasts
|
|
const hasActiveToasts = computed(() => toasts[props.position].value.length > 0);
|
|
</script>
|
|
|
|
<template>
|
|
<div
|
|
:class="[
|
|
'fixed z-[100] flex max-h-screen w-full flex-col gap-2 p-4 sm:max-w-[420px]',
|
|
positionClasses[position],
|
|
{
|
|
'flex-col': position.startsWith('top'),
|
|
'flex-col-reverse': position.startsWith('bottom'),
|
|
'pointer-events-none': !hasActiveToasts,
|
|
},
|
|
]"
|
|
>
|
|
<TransitionGroup
|
|
:enter-active-class="animations.enter.active"
|
|
:enter-from-class="animations.enter.from"
|
|
:enter-to-class="animations.enter.to"
|
|
:leave-active-class="animations.leave.active"
|
|
:leave-from-class="animations.leave.from"
|
|
:leave-to-class="animations.leave.to"
|
|
>
|
|
<div
|
|
v-for="toast in toasts[position].value"
|
|
:key="toast.id"
|
|
class="group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all"
|
|
:class="{
|
|
'bg-background text-foreground': toast.type === 'default',
|
|
'bg-danger text-danger-foreground': toast.type === 'error',
|
|
'bg-primary text-primary-foreground': toast.type === 'success',
|
|
}"
|
|
>
|
|
<div class="flex items-start gap-2">
|
|
<Icon
|
|
v-if="toast.type === 'error'"
|
|
name="ph:x-circle"
|
|
class="h-5 w-5 text-danger-foreground"
|
|
/>
|
|
<Icon
|
|
v-if="toast.type === 'success'"
|
|
name="ph:check-circle"
|
|
class="h-5 w-5 text-primary-foreground"
|
|
/>
|
|
<div class="grid gap-1">
|
|
<div class="text-sm font-semibold">{{ toast.title }}</div>
|
|
<div class="text-sm opacity-90">{{ toast.description }}</div>
|
|
</div>
|
|
</div>
|
|
<button
|
|
class="absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100"
|
|
@click="useToast().remove(toast.id, toast.position)"
|
|
>
|
|
<Icon name="ph:x" class="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
</TransitionGroup>
|
|
</div>
|
|
</template>
|