| <template> |
| <Teleport to="body"> |
| <div |
| class="pointer-events-none fixed right-4 top-4 z-[9999] space-y-3" |
| aria-live="polite" |
| aria-atomic="true" |
| > |
| <TransitionGroup |
| enter-active-class="transition ease-out duration-300" |
| enter-from-class="opacity-0 translate-x-full" |
| enter-to-class="opacity-100 translate-x-0" |
| leave-active-class="transition ease-in duration-200" |
| leave-from-class="opacity-100 translate-x-0" |
| leave-to-class="opacity-0 translate-x-full" |
| > |
| <div |
| v-for="toast in toasts" |
| :key="toast.id" |
| :class="[ |
| 'pointer-events-auto min-w-[320px] max-w-md overflow-hidden rounded-lg shadow-lg', |
| 'bg-white dark:bg-dark-800', |
| 'border-l-4', |
| getBorderColor(toast.type) |
| ]" |
| > |
| <div class="p-4"> |
| <div class="flex items-start gap-3"> |
| |
| <div class="mt-0.5 flex-shrink-0"> |
| <Icon |
| :name="getToastIconName(toast.type)" |
| size="md" |
| :class="getIconColor(toast.type)" |
| aria-hidden="true" |
| /> |
| </div> |
| |
| |
| <div class="min-w-0 flex-1"> |
| <p v-if="toast.title" class="text-sm font-semibold text-gray-900 dark:text-white"> |
| {{ toast.title }} |
| </p> |
| <p |
| :class="[ |
| 'text-sm leading-relaxed', |
| toast.title |
| ? 'mt-1 text-gray-600 dark:text-gray-300' |
| : 'text-gray-900 dark:text-white' |
| ]" |
| > |
| {{ toast.message }} |
| </p> |
| </div> |
| |
| |
| <button |
| @click="removeToast(toast.id)" |
| class="-m-1 flex-shrink-0 rounded p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:text-gray-500 dark:hover:bg-dark-700 dark:hover:text-gray-300" |
| aria-label="Close notification" |
| > |
| <Icon name="x" size="sm" /> |
| </button> |
| </div> |
| </div> |
| |
| |
| <div v-if="toast.duration" class="h-1 bg-gray-100 dark:bg-dark-700"> |
| <div |
| :class="['h-full toast-progress', getProgressBarColor(toast.type)]" |
| :style="{ animationDuration: `${toast.duration}ms` }" |
| ></div> |
| </div> |
| </div> |
| </TransitionGroup> |
| </div> |
| </Teleport> |
| </template> |
| |
| <script setup lang="ts"> |
| import { computed } from 'vue' |
| import Icon from '@/components/icons/Icon.vue' |
| import { useAppStore } from '@/stores/app' |
| |
| const appStore = useAppStore() |
| |
| const toasts = computed(() => appStore.toasts) |
| |
| const getToastIconName = (type: string): 'checkCircle' | 'xCircle' | 'exclamationTriangle' | 'infoCircle' => { |
| switch (type) { |
| case 'success': |
| return 'checkCircle' |
| case 'error': |
| return 'xCircle' |
| case 'warning': |
| return 'exclamationTriangle' |
| case 'info': |
| default: |
| return 'infoCircle' |
| } |
| } |
| |
| const getIconColor = (type: string): string => { |
| const colors: Record<string, string> = { |
| success: 'text-green-500', |
| error: 'text-red-500', |
| warning: 'text-yellow-500', |
| info: 'text-blue-500' |
| } |
| return colors[type] || colors.info |
| } |
| |
| const getBorderColor = (type: string): string => { |
| const colors: Record<string, string> = { |
| success: 'border-green-500', |
| error: 'border-red-500', |
| warning: 'border-yellow-500', |
| info: 'border-blue-500' |
| } |
| return colors[type] || colors.info |
| } |
| |
| const getProgressBarColor = (type: string): string => { |
| const colors: Record<string, string> = { |
| success: 'bg-green-500', |
| error: 'bg-red-500', |
| warning: 'bg-yellow-500', |
| info: 'bg-blue-500' |
| } |
| return colors[type] || colors.info |
| } |
| |
| const removeToast = (id: string) => { |
| appStore.hideToast(id) |
| } |
| </script> |
| |
| <style scoped> |
| .toast-progress { |
| width: 100%; |
| animation-name: toast-progress-shrink; |
| animation-timing-function: linear; |
| animation-fill-mode: forwards; |
| } |
| |
| @keyframes toast-progress-shrink { |
| from { |
| width: 100%; |
| } |
| to { |
| width: 0%; |
| } |
| } |
| </style> |
| |