///
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,
}
}