refactor: reorganize sonner toast components

This commit is contained in:
2025-12-29 16:50:57 +00:00
parent 99d4411a41
commit 710d53647c
3 changed files with 187 additions and 148 deletions

View File

@@ -3,94 +3,50 @@
/**
* Sonner-compatible toast API using MUI Snackbar
* Provides a drop-in replacement for the 'sonner' package
*
* Usage:
* import { toast } from '@/components/ui/sonner'
* toast.success('Saved!')
* toast.error('Failed to save')
* toast('Default message')
*/
import React, { createContext, useContext, useCallback, useState, useEffect } from 'react'
import { Snackbar, Alert, type AlertColor, Box } from '@mui/material'
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react'
// Types
export interface ToastOptions {
description?: string
duration?: number
position?: 'top-left' | 'top-center' | 'top-right' | 'bottom-left' | 'bottom-center' | 'bottom-right'
id?: string | number
}
import { ToastContainer, type ToastHandlers } from './sonner/ToastContainer'
import {
DEFAULT_DURATION,
type Toast,
type ToastOptions,
type ToastType,
} from './sonner/config'
interface Toast {
id: string | number
message: string
description?: string
type: AlertColor | 'default'
duration: number
}
const ToastContext = createContext<ToastHandlers>({
addToast: () => {},
removeToast: () => {},
})
interface ToastContextValue {
addToast: (toast: Toast) => void
removeToast: (id?: string | number) => void
}
// Context
const ToastContext = createContext<ToastContextValue | null>(null)
// Global toast queue for when called outside provider
let globalAddToast: ((toast: Toast) => void) | null = null
let globalRemoveToast: ((id?: string | number) => void) | null = null
let toastIdCounter = 0
const generateId = () => `toast-${++toastIdCounter}`
/**
* Creates a toast message
*/
const createToast = (message: string, type: AlertColor | 'default', options?: ToastOptions): Toast => ({
const createToast = (message: string, type: ToastType, options?: ToastOptions): Toast => ({
id: options?.id ?? generateId(),
message,
description: options?.description,
type,
duration: options?.duration ?? 4000,
duration: options?.duration ?? DEFAULT_DURATION,
})
/**
* Toast API - Sonner-compatible interface
*/
const enqueueToast = (toast: Toast) => {
globalAddToast?.(toast)
return toast.id
}
export const toast = Object.assign(
(message: string, options?: ToastOptions) => {
const t = createToast(message, 'default', options)
globalAddToast?.(t)
return t.id
},
(message: string, options?: ToastOptions) => enqueueToast(createToast(message, 'default', options)),
{
success: (message: string, options?: ToastOptions) => {
const t = createToast(message, 'success', options)
globalAddToast?.(t)
return t.id
},
error: (message: string, options?: ToastOptions) => {
const t = createToast(message, 'error', options)
globalAddToast?.(t)
return t.id
},
warning: (message: string, options?: ToastOptions) => {
const t = createToast(message, 'warning', options)
globalAddToast?.(t)
return t.id
},
info: (message: string, options?: ToastOptions) => {
const t = createToast(message, 'info', options)
globalAddToast?.(t)
return t.id
},
loading: (message: string, options?: ToastOptions) => {
const t = createToast(message, 'info', { ...options, duration: 0 })
globalAddToast?.(t)
return t.id
},
success: (message: string, options?: ToastOptions) => enqueueToast(createToast(message, 'success', options)),
error: (message: string, options?: ToastOptions) => enqueueToast(createToast(message, 'error', options)),
warning: (message: string, options?: ToastOptions) => enqueueToast(createToast(message, 'warning', options)),
info: (message: string, options?: ToastOptions) => enqueueToast(createToast(message, 'info', options)),
loading: (message: string, options?: ToastOptions) => enqueueToast(createToast(message, 'info', { ...options, duration: 0 })),
dismiss: (id?: string | number) => {
globalRemoveToast?.(id)
},
@@ -107,16 +63,12 @@ export const toast = Object.assign(
try {
const result = await promise
toast.dismiss(id)
const successMessage = typeof messages.success === 'function'
? messages.success(result)
: messages.success
const successMessage = typeof messages.success === 'function' ? messages.success(result) : messages.success
toast.success(successMessage, options)
return result
} catch (error) {
toast.dismiss(id)
const errorMessage = typeof messages.error === 'function'
? messages.error(error)
: messages.error
const errorMessage = typeof messages.error === 'function' ? messages.error(error) : messages.error
toast.error(errorMessage, options)
throw error
}
@@ -124,11 +76,7 @@ export const toast = Object.assign(
}
)
/**
* Toaster component - Renders toast notifications
* Place this at the root of your app (in layout.tsx)
*/
export function Toaster({
export function Toaster({
position = 'bottom-right',
richColors = false,
expand = false,
@@ -139,89 +87,40 @@ export function Toaster({
expand?: boolean
closeButton?: boolean
}) {
const [toasts, setToasts] = useState<Toast[]>([])
const [handlers, setHandlers] = useState<ToastHandlers>({
addToast: () => {},
removeToast: () => {},
})
const addToast = useCallback((toast: Toast) => {
setToasts(prev => [...prev, toast])
}, [])
const removeToast = useCallback((id?: string | number) => {
if (typeof id === 'undefined') {
setToasts([])
return
}
setToasts(prev => prev.filter(t => t.id !== id))
}, [])
// Register global handler
useEffect(() => {
globalAddToast = addToast
globalRemoveToast = removeToast
return () => {
globalAddToast = null
globalRemoveToast = null
}
}, [addToast, removeToast])
}, [])
// Map position to MUI anchor origin
const getAnchorOrigin = () => {
const vertical = position?.startsWith('top') ? 'top' : 'bottom'
const horizontal = position?.includes('left') ? 'left' : position?.includes('center') ? 'center' : 'right'
return { vertical, horizontal } as const
}
const registerHandlers = useCallback((nextHandlers: ToastHandlers) => {
setHandlers(nextHandlers)
globalAddToast = nextHandlers.addToast
globalRemoveToast = nextHandlers.removeToast
}, [])
return (
<ToastContext.Provider value={{ addToast, removeToast }}>
<Box
sx={{
position: 'fixed',
zIndex: 9999,
...(position?.includes('top') ? { top: 16 } : { bottom: 16 }),
...(position?.includes('left') ? { left: 16 } : position?.includes('center') ? { left: '50%', transform: 'translateX(-50%)' } : { right: 16 }),
}}
>
{toasts.map((t, index) => (
<Snackbar
key={t.id}
open
autoHideDuration={t.duration || null}
onClose={() => removeToast(t.id)}
anchorOrigin={getAnchorOrigin()}
sx={{
position: 'relative',
mb: 1,
}}
>
<Alert
severity={t.type === 'default' ? 'info' : t.type}
variant={richColors ? 'filled' : 'standard'}
onClose={closeButton ? () => removeToast(t.id) : undefined}
sx={{ width: '100%', minWidth: 300 }}
>
{t.message}
{t.description && (
<Box component="div" sx={{ fontSize: '0.875rem', opacity: 0.8, mt: 0.5 }}>
{t.description}
</Box>
)}
</Alert>
</Snackbar>
))}
</Box>
<ToastContext.Provider value={handlers}>
<ToastContainer
position={position}
richColors={richColors}
expand={expand}
closeButton={closeButton}
onRegister={registerHandlers}
/>
</ToastContext.Provider>
)
}
/**
* Hook to access toast context
*/
export function useToast() {
const context = useContext(ToastContext)
return {
toast,
...(context ?? { addToast: () => {}, removeToast: () => {} }),
}
return { toast, ...context }
}
// Default export for sonner compatibility
export default toast

View File

@@ -0,0 +1,93 @@
'use client'
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Alert, Box, Snackbar } from '@mui/material'
import {
anchorFromPosition,
containerPlacement,
type Toast,
type ToastOptions,
} from './config'
export interface ToastHandlers {
addToast: (toast: Toast) => void
removeToast: (id?: string | number) => void
}
interface ToastContainerProps extends ToastOptions {
richColors?: boolean
expand?: boolean
closeButton?: boolean
onRegister?: (handlers: ToastHandlers) => void
}
export function ToastContainer({
position = 'bottom-right',
richColors = false,
expand = false,
closeButton = false,
onRegister,
}: ToastContainerProps) {
const [toasts, setToasts] = useState<Toast[]>([])
const addToast = useCallback((toast: Toast) => {
setToasts(prev => [...prev, toast])
}, [])
const removeToast = useCallback((id?: string | number) => {
if (typeof id === 'undefined') {
setToasts([])
return
}
setToasts(prev => prev.filter(t => t.id !== id))
}, [])
useEffect(() => {
onRegister?.({ addToast, removeToast })
}, [addToast, onRegister, removeToast])
const anchorOrigin = useMemo(() => anchorFromPosition(position), [position])
const containerPosition = useMemo(
() => containerPlacement(position),
[position]
)
return (
<Box
sx={{
position: 'fixed',
zIndex: 9999,
...containerPosition,
}}
>
{toasts.map(t => (
<Snackbar
key={t.id}
open
autoHideDuration={t.duration || null}
onClose={() => removeToast(t.id)}
anchorOrigin={anchorOrigin}
sx={{
position: 'relative',
mb: 1,
}}
>
<Alert
severity={t.type === 'default' ? 'info' : t.type}
variant={richColors ? 'filled' : 'standard'}
onClose={closeButton ? () => removeToast(t.id) : undefined}
sx={{ width: expand ? '100%' : 'auto', minWidth: 300 }}
>
{t.message}
{t.description && (
<Box component="div" sx={{ fontSize: '0.875rem', opacity: 0.8, mt: 0.5 }}>
{t.description}
</Box>
)}
</Alert>
</Snackbar>
))}
</Box>
)
}

View File

@@ -0,0 +1,47 @@
import { type AlertColor } from '@mui/material'
export type ToastPosition =
| 'top-left'
| 'top-center'
| 'top-right'
| 'bottom-left'
| 'bottom-center'
| 'bottom-right'
export interface ToastOptions {
description?: string
duration?: number
position?: ToastPosition
id?: string | number
}
export type ToastType = AlertColor | 'default'
export interface Toast {
id: string | number
message: string
description?: string
type: ToastType
duration: number
}
export const DEFAULT_DURATION = 4000
export const anchorFromPosition = (position?: ToastPosition) => {
const vertical = position?.startsWith('top') ? 'top' : 'bottom'
const horizontal = position?.includes('left')
? 'left'
: position?.includes('center')
? 'center'
: 'right'
return { vertical, horizontal } as const
}
export const containerPlacement = (position?: ToastPosition) => ({
...(position?.includes('top') ? { top: 16 } : { bottom: 16 }),
...(position?.includes('left')
? { left: 16 }
: position?.includes('center')
? { left: '50%', transform: 'translateX(-50%)' }
: { right: 16 }),
})