/// import type { Ref } from 'vue' import { computed, reactive, ref, toRef } from 'vue' export type ToastType = 'success' | 'error' | 'info' | 'warning' | string export type ToastPosition = | 'top-start' | 'top-center' | 'top-end' | 'middle-start' | 'middle-center' | 'middle-end' | 'bottom-start' | 'bottom-center' | 'bottom-end' export type ToastStatus = 'pending' | 'success' | 'error' | 'info' | 'warning' | 'default' export interface Toast { id: number message: string name?: string // toast channel name, optional but always set by logic type?: ToastType duration?: number position: ToastPosition countdown?: number status?: ToastStatus progress?: number // 0-1 for progress bar promiseId?: string // for async/promise support ariaLive?: 'polite' | 'assertive' // accessibility intervalId?: number originalDuration?: number startTime?: number [key: string]: any } /** * State for a single toast channel (internal use) */ export interface ToastChannelState { toasts: Ref toastQueue: Toast[] toastLimit: number | null } let globalToastChannels: Ref }>> | null = null let globalNextToastId: Ref | null = null function getGlobalToastChannels() { if (!globalToastChannels) { globalToastChannels = ref({}) } return globalToastChannels } function getGlobalNextToastId() { if (!globalNextToastId) { globalNextToastId = ref(1) } return globalNextToastId } function getOrCreateChannel(name: string, defaults?: Partial, limit?: number | null) { const channels = getGlobalToastChannels().value if (!channels[name]) { channels[name] = { toasts: ref([]), toastQueue: [], toastLimit: typeof limit === 'number' ? limit : null, defaults, } } return toRef(channels[name]) } /** * Clear all toast queues for all channels (useful for logout or channel switch) */ export function clearAllToastQueues() { const channels = getGlobalToastChannels().value Object.values(channels).forEach(channel => { channel.toastQueue.length = 0 }) } /** * Global toast notification composable supporting named channels. * * - Each toast has an optional `name` property (defaults to 'default'). * - All toast state (toasts, queue, limit) is isolated per channel. * - UI and logic can independently manage/display different channels. * - Defensive dev warnings if name is missing/invalid. * * Example usage: * const { toasts, addToast } = useToast({ name: 'admin' }) * addToast({ message: 'Hi', name: 'admin' }) * // In UI: */ /** * Options for useToast composable. * - name: channel name (default: 'default') * - defaults: default toast settings for this channel (merged into each toast) */ export interface UseToastOptions { name?: string defaults?: Partial limit?: number // per-channel toast limit } /** * Global toast notification composable supporting named channels and customizable defaults. * * - Each toast has an optional `name` property (defaults to 'default'). * - All toast state (toasts, queue, limit) is isolated per channel. * - UI and logic can independently manage/display different channels. * - Defensive dev warnings if name is missing/invalid. * - You can provide `defaults` to set default toast settings for all toasts in this channel. * * Example usage: * const { toasts, addToast } = useToast({ name: 'admin', defaults: { duration: 6000, type: 'info' } }) * addToast({ message: 'Hi' }) // will use defaults * // In UI: */ function normalizeToast(toast: any): Toast { if (toast.originalDuration == null) { toast.originalDuration = toast.duration ?? 0 } if (toast.countdown == null) { toast.countdown = toast.originalDuration } if (typeof toast.intervalId === 'undefined') { toast.intervalId = undefined } return toast as Toast } export function useToast(options?: UseToastOptions) { const name = options?.name?.trim() || 'default' const defaults = options?.defaults || {} const limit = typeof options?.limit === 'number' ? options.limit : null // Always get or create the channel (init only once) const channel = getOrCreateChannel(name, defaults, limit) function addToast( toast: { message: string; position?: ToastPosition; name?: string } & Partial>, ) { const toastName = toast.name?.trim() || name const nextToastIdRef = getGlobalNextToastId() // Always get or create the target channel for this toast, using the correct limit if provided const channelLimit = typeof options?.limit === 'number' ? options.limit : null const channel = getOrCreateChannel(toastName, defaults, channelLimit) const merged = { ...channel.value.defaults, ...defaults, ...toast } const position = merged.position ?? 'bottom-center' const duration = merged.duration ?? 0 const status = merged.status ?? 'default' if (import.meta.env?.NODE_ENV !== 'production' && !toastName) { console.warn('[addToast] Toast channel name is empty or invalid. Falling back to "default".') } const id = nextToastIdRef.value++ const newToast = normalizeToast( reactive({ id, ...merged, name: toastName, position, countdown: duration, originalDuration: duration, status, progress: typeof merged.progress === 'number' ? merged.progress : undefined, ariaLive: merged.ariaLive ?? 'polite', intervalId: undefined as number | undefined, }), ) // If limit is set and reached, queue the toast if (channel.value.toastLimit && channel.value.toasts.length >= channel.value.toastLimit) { ;(channel.value.toastQueue ??= []).push(newToast) return id } channel.value.toasts.push(newToast) // Ensure timer always starts for visible toasts if (channel.value.toasts.includes(newToast)) { startToastTimer(newToast) } return id } function removeToast(id: number) { const idx = channel.value.toasts.findIndex(t => t.id === id) if (idx !== -1) { // Defensive: clear countdown timer if present const toast = channel.value.toasts[idx]! if (toast.intervalId) { clearInterval(toast.intervalId) } channel.value.toasts.splice(idx, 1) // If queue exists, pop next toast if (channel.value.toastLimit && (channel.value.toastQueue?.length ?? 0) > 0) { const next = channel.value.toastQueue!.shift() if (next) { const norm = normalizeToast(next) channel.value.toasts.push(norm) // Ensure timer always starts for visible toasts if (channel.value.toasts.includes(norm)) { startToastTimer(norm) } } } } } function clearToasts() { channel.value.toasts = [] if (channel.value.toastQueue) { channel.value.toastQueue.length = 0 } } /** * Update or replace a toast by id */ function updateToast(id: number, updates: Partial) { const toast = channel.value.toasts.find(t => t.id === id) if (toast) { Object.assign(toast, updates) normalizeToast(toast) // If updating countdown/duration, recalculate progress if (typeof toast.countdown === 'number' && typeof toast.duration === 'number' && toast.duration > 0) { toast.progress = Math.max(0, toast.countdown / toast.duration) } } } /** * Show a toast for the duration of a promise * Sets status to 'pending', then 'success' or 'error' on resolve/reject */ function toastPromise( promise: Promise, options: { pending: Omit success: Omit error: Omit name?: string }, ): Promise { const toastName = options.name?.trim() || name if (import.meta.env?.NODE_ENV !== 'production' && !toastName) { console.warn('[toastPromise] Toast channel name is empty or invalid. Falling back to "default".') } const promiseId = `promise-${Date.now()}-${Math.random()}` const pendingId = addToast({ ...options.pending, status: 'pending', promiseId, message: options.pending.message, name: toastName, }) return promise.then( result => { updateToast(pendingId, { ...options.success, status: 'success', promiseId, message: options.success.message, name: toastName, }) return result }, err => { updateToast(pendingId, { ...options.error, status: 'error', promiseId, message: options.error.message, name: toastName, }) throw err }, ) } /** * Set a maximum number of visible toasts (queue extras) */ function setToastLimit(limit: number) { channel.value.toastLimit = limit if (channel.value.toastLimit) { // If over limit, move extras to queue while (channel.value.toasts.length > channel.value.toastLimit) { const removed = channel.value.toasts.pop() if (removed) { // Stop timer for toast leaving visible list if (removed.intervalId) { clearInterval(removed.intervalId) removed.intervalId = undefined } ;(channel.value.toastQueue ??= []).unshift(removed) } } // If under limit and queue has items, fill up while (channel.value.toasts.length < channel.value.toastLimit && (channel.value.toastQueue?.length ?? 0) > 0) { const next = channel.value.toastQueue!.shift() if (next) { const norm = normalizeToast(next) channel.value.toasts.push(norm) // Ensure timer always starts for visible toasts if (channel.value.toasts.includes(norm)) { startToastTimer(norm) } } } } } function startToastTimer(toast: Toast) { const duration = toast.originalDuration ?? 0 if (duration > 0) { if (toast.intervalId) { clearInterval(toast.intervalId) } const start = Date.now() toast.startTime = start toast.intervalId = setInterval(() => { const elapsed = Date.now() - (toast.startTime ?? start) toast.countdown = Math.max(0, duration - elapsed) toast.progress = Math.max(0, toast.countdown / duration) if (toast.countdown <= 0) { clearInterval(toast.intervalId) removeToast(toast.id) } }, 16) as unknown as number // 60fps for smooth animation } } return { toasts: computed(() => channel.value.toasts), addToast, removeToast, clearToasts, updateToast, toastPromise, setToastLimit, name, } }