Files
metabuilder/hooks/ToastContext.tsx
johndoe6345789 78a54228df feat(hooks): Create centralized hooks npm package
- Added @metabuilder/hooks workspace package at root
- Consolidated 30 React hooks from across codebase into single module
- Implemented conditional exports for tree-shaking support
- Added comprehensive package.json with build/lint/typecheck scripts
- Created README.md documenting hook categories and usage patterns
- Updated root package.json workspaces array to include hooks
- Supports multi-version peer dependencies (React 18/19, Redux 8/9)

Usage:
  import { useDashboardLogic } from '@metabuilder/hooks'
  import { useLoginLogic } from '@metabuilder/hooks/useLoginLogic'

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-23 19:17:17 +00:00

237 lines
6.8 KiB
TypeScript

'use client'
import React, { createContext, useContext, useState, useCallback, useRef, useEffect } from 'react'
import { Snackbar, SnackbarContent } from '../feedback/Snackbar'
export type ToastSeverity = 'success' | 'error' | 'warning' | 'info'
export interface ToastOptions {
/** Toast message content */
message: string
/** Severity level for styling */
severity?: ToastSeverity
/** Auto hide duration in ms (null to disable) */
autoHideDuration?: number | null
/** Action button content */
action?: React.ReactNode
/** Custom key for deduplication */
key?: string
/** Callback when toast closes */
onClose?: () => void
/** Anchor position */
anchorOrigin?: {
vertical: 'top' | 'bottom'
horizontal: 'left' | 'center' | 'right'
}
}
interface Toast extends Required<Pick<ToastOptions, 'message' | 'severity' | 'autoHideDuration'>> {
id: string
action?: React.ReactNode
onClose?: () => void
anchorOrigin: NonNullable<ToastOptions['anchorOrigin']>
}
interface ToastContextValue {
/** Show a toast notification */
toast: (options: ToastOptions | string) => string
/** Show a success toast */
success: (message: string, options?: Omit<ToastOptions, 'message' | 'severity'>) => string
/** Show an error toast */
error: (message: string, options?: Omit<ToastOptions, 'message' | 'severity'>) => string
/** Show a warning toast */
warning: (message: string, options?: Omit<ToastOptions, 'message' | 'severity'>) => string
/** Show an info toast */
info: (message: string, options?: Omit<ToastOptions, 'message' | 'severity'>) => string
/** Close a specific toast by ID */
close: (id: string) => void
/** Close all toasts */
closeAll: () => void
}
const ToastContext = createContext<ToastContextValue | null>(null)
let toastIdCounter = 0
const generateId = () => `toast-${++toastIdCounter}`
export interface ToastProviderProps {
children: React.ReactNode
/** Default auto hide duration in ms */
defaultAutoHideDuration?: number
/** Maximum number of toasts to show at once */
maxToasts?: number
/** Default anchor position */
defaultAnchorOrigin?: ToastOptions['anchorOrigin']
}
export const ToastProvider: React.FC<ToastProviderProps> = ({
children,
defaultAutoHideDuration = 5000,
maxToasts = 3,
defaultAnchorOrigin = { vertical: 'bottom', horizontal: 'left' },
}) => {
const [toasts, setToasts] = useState<Toast[]>([])
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map())
// Clear timer when toast is removed
const clearTimer = useCallback((id: string) => {
const timer = timersRef.current.get(id)
if (timer) {
clearTimeout(timer)
timersRef.current.delete(id)
}
}, [])
// Close a specific toast
const close = useCallback((id: string) => {
clearTimer(id)
setToasts(prev => {
const toast = prev.find(t => t.id === id)
if (toast?.onClose) {
toast.onClose()
}
return prev.filter(t => t.id !== id)
})
}, [clearTimer])
// Close all toasts
const closeAll = useCallback(() => {
timersRef.current.forEach((_, id) => clearTimer(id))
setToasts([])
}, [clearTimer])
// Main toast function
const toast = useCallback((options: ToastOptions | string): string => {
const opts: ToastOptions = typeof options === 'string' ? { message: options } : options
const id = opts.key || generateId()
// Check if toast with same key already exists
setToasts(prev => {
const existingIndex = prev.findIndex(t => t.id === id)
const newToast: Toast = {
id,
message: opts.message,
severity: opts.severity || 'info',
autoHideDuration: opts.autoHideDuration ?? defaultAutoHideDuration,
action: opts.action,
onClose: opts.onClose,
anchorOrigin: opts.anchorOrigin || defaultAnchorOrigin,
}
let newToasts: Toast[]
if (existingIndex >= 0) {
// Update existing toast
newToasts = [...prev]
newToasts[existingIndex] = newToast
} else {
// Add new toast (respecting maxToasts)
newToasts = [...prev, newToast]
if (newToasts.length > maxToasts) {
const removed = newToasts.shift()
if (removed) clearTimer(removed.id)
}
}
return newToasts
})
// Set up auto-hide timer
const duration = opts.autoHideDuration ?? defaultAutoHideDuration
if (duration !== null && duration > 0) {
clearTimer(id) // Clear existing timer if updating
const timer = setTimeout(() => close(id), duration)
timersRef.current.set(id, timer)
}
return id
}, [defaultAutoHideDuration, defaultAnchorOrigin, maxToasts, clearTimer, close])
// Helper methods for each severity
const success = useCallback((message: string, options?: Omit<ToastOptions, 'message' | 'severity'>) => {
return toast({ ...options, message, severity: 'success' })
}, [toast])
const error = useCallback((message: string, options?: Omit<ToastOptions, 'message' | 'severity'>) => {
return toast({ ...options, message, severity: 'error' })
}, [toast])
const warning = useCallback((message: string, options?: Omit<ToastOptions, 'message' | 'severity'>) => {
return toast({ ...options, message, severity: 'warning' })
}, [toast])
const info = useCallback((message: string, options?: Omit<ToastOptions, 'message' | 'severity'>) => {
return toast({ ...options, message, severity: 'info' })
}, [toast])
// Cleanup timers on unmount
useEffect(() => {
return () => {
timersRef.current.forEach(timer => clearTimeout(timer))
}
}, [])
const contextValue: ToastContextValue = {
toast,
success,
error,
warning,
info,
close,
closeAll,
}
return (
<ToastContext.Provider value={contextValue}>
{children}
{/* Render toasts */}
{toasts.map(t => (
<Snackbar
key={t.id}
open={true}
autoHideDuration={null} // Handled by context
onClose={() => close(t.id)}
anchorOrigin={t.anchorOrigin}
>
<SnackbarContent
message={t.message}
severity={t.severity}
action={t.action}
/>
</Snackbar>
))}
</ToastContext.Provider>
)
}
/**
* Hook to access toast notifications
*
* @example
* ```tsx
* const { toast, success, error } = useToast()
*
* // Simple usage
* toast('Hello world')
*
* // With severity helpers
* success('Operation completed!')
* error('Something went wrong')
*
* // With options
* toast({
* message: 'Custom toast',
* severity: 'warning',
* autoHideDuration: 3000,
* action: <button onClick={() => {}}>Undo</button>
* })
* ```
*/
export const useToast = (): ToastContextValue => {
const context = useContext(ToastContext)
if (!context) {
throw new Error('useToast must be used within a ToastProvider')
}
return context
}
export default ToastProvider