mirror of
https://github.com/johndoe6345789/metabuilder.git
synced 2026-04-24 13:54:57 +00:00
FakeMUI Components (MUI API compatibility): - Add sx prop support to all components via sxToStyle utility - Add MUI-style variants to Button (contained, outlined) - Add component prop to Typography for polymorphic rendering - Add label prop to Chip (MUI uses label vs children) - Add edge/color/size props to IconButton - Add component prop to List for nav rendering - Add href support to ListItemButton - Add variant prop to Avatar - Add PaperProps to Drawer New @metabuilder/components package: - vanilla/loading - LoadingIndicator, InlineLoader, AsyncLoading - vanilla/error - ErrorBoundary, ErrorDisplay, withErrorBoundary - vanilla/empty-state - EmptyState + 7 specialized variants - vanilla/skeleton - Skeleton + 6 specialized variants - Organized by framework: vanilla/, radix/, fakemui/ Hooks consolidation (FakeMUI → root hooks/): - useAccessible (5 accessibility hooks) - useToast with ToastProvider - FakeMUI re-exports from hooks for backward compatibility WorkflowUI fixes: - Fix showNotification → useUI error/success methods - Fix Redux reducer setTimeout (Immer proxy issue) - Fix useRef type error - Update to Next.js 16.1.6 with webpack mode - Add @metabuilder/fakemui dependency Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
362 lines
11 KiB
TypeScript
362 lines
11 KiB
TypeScript
'use client'
|
|
|
|
/**
|
|
* useToast Hook
|
|
* Standalone toast notification system with React context
|
|
* Migrated from @metabuilder/fakemui for broader usage.
|
|
*
|
|
* Features:
|
|
* - Multiple severity levels (success, error, warning, info)
|
|
* - Auto-hide with configurable duration
|
|
* - Maximum toast limit
|
|
* - Deduplication by key
|
|
* - Customizable anchor position
|
|
* - Built-in styled toast renderer
|
|
*
|
|
* @example
|
|
* // Wrap your app with ToastProvider
|
|
* <ToastProvider maxToasts={5}>
|
|
* <App />
|
|
* </ToastProvider>
|
|
*
|
|
* // Use in components
|
|
* const { toast, success, error, warning, info, close, closeAll } = useToast()
|
|
*
|
|
* // Simple usage
|
|
* toast('Hello world')
|
|
*
|
|
* // With severity helpers
|
|
* success('Operation completed!')
|
|
* error('Something went wrong')
|
|
*
|
|
* // With full options
|
|
* toast({
|
|
* message: 'Custom toast',
|
|
* severity: 'warning',
|
|
* autoHideDuration: 3000,
|
|
* action: <button onClick={() => {}}>Undo</button>
|
|
* })
|
|
*/
|
|
|
|
import React, { createContext, useContext, useState, useCallback, useRef, useEffect } from 'react'
|
|
|
|
// ============================================================================
|
|
// Types
|
|
// ============================================================================
|
|
|
|
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']>
|
|
}
|
|
|
|
export 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
|
|
}
|
|
|
|
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']
|
|
/** Custom toast renderer (optional) */
|
|
renderToast?: (toast: Toast, onClose: () => void) => React.ReactNode
|
|
}
|
|
|
|
// ============================================================================
|
|
// Context
|
|
// ============================================================================
|
|
|
|
const ToastContext = createContext<ToastContextValue | null>(null)
|
|
|
|
let toastIdCounter = 0
|
|
const generateId = () => `toast-${++toastIdCounter}`
|
|
|
|
// ============================================================================
|
|
// Default Styled Toast Component
|
|
// ============================================================================
|
|
|
|
const severityColors: Record<ToastSeverity, { bg: string; border: string; text: string }> = {
|
|
success: { bg: '#d4edda', border: '#c3e6cb', text: '#155724' },
|
|
error: { bg: '#f8d7da', border: '#f5c6cb', text: '#721c24' },
|
|
warning: { bg: '#fff3cd', border: '#ffeeba', text: '#856404' },
|
|
info: { bg: '#d1ecf1', border: '#bee5eb', text: '#0c5460' },
|
|
}
|
|
|
|
const positionStyles: Record<string, React.CSSProperties> = {
|
|
'top-left': { top: 16, left: 16 },
|
|
'top-center': { top: 16, left: '50%', transform: 'translateX(-50%)' },
|
|
'top-right': { top: 16, right: 16 },
|
|
'bottom-left': { bottom: 16, left: 16 },
|
|
'bottom-center': { bottom: 16, left: '50%', transform: 'translateX(-50%)' },
|
|
'bottom-right': { bottom: 16, right: 16 },
|
|
}
|
|
|
|
interface DefaultToastProps {
|
|
toast: Toast
|
|
onClose: () => void
|
|
}
|
|
|
|
const DefaultToast: React.FC<DefaultToastProps> = ({ toast, onClose }) => {
|
|
const colors = severityColors[toast.severity]
|
|
const positionKey = `${toast.anchorOrigin.vertical}-${toast.anchorOrigin.horizontal}`
|
|
const position = positionStyles[positionKey] || positionStyles['bottom-left']
|
|
|
|
return (
|
|
<div
|
|
role="alert"
|
|
aria-live={toast.severity === 'error' ? 'assertive' : 'polite'}
|
|
style={{
|
|
position: 'fixed',
|
|
...position,
|
|
zIndex: 9999,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 8,
|
|
padding: '12px 16px',
|
|
borderRadius: 4,
|
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
|
|
backgroundColor: colors.bg,
|
|
border: `1px solid ${colors.border}`,
|
|
color: colors.text,
|
|
fontFamily: 'system-ui, -apple-system, sans-serif',
|
|
fontSize: 14,
|
|
lineHeight: 1.4,
|
|
maxWidth: 400,
|
|
minWidth: 200,
|
|
}}
|
|
>
|
|
<span style={{ flex: 1 }}>{toast.message}</span>
|
|
{toast.action && <div style={{ marginLeft: 8 }}>{toast.action}</div>}
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
aria-label="Close"
|
|
style={{
|
|
background: 'none',
|
|
border: 'none',
|
|
padding: 4,
|
|
cursor: 'pointer',
|
|
color: colors.text,
|
|
opacity: 0.7,
|
|
fontSize: 16,
|
|
lineHeight: 1,
|
|
}}
|
|
>
|
|
×
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ============================================================================
|
|
// Provider Component
|
|
// ============================================================================
|
|
|
|
export const ToastProvider: React.FC<ToastProviderProps> = ({
|
|
children,
|
|
defaultAutoHideDuration = 5000,
|
|
maxToasts = 3,
|
|
defaultAnchorOrigin = { vertical: 'bottom', horizontal: 'left' },
|
|
renderToast,
|
|
}) => {
|
|
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 t = prev.find(item => item.id === id)
|
|
if (t?.onClose) {
|
|
t.onClose()
|
|
}
|
|
return prev.filter(item => item.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 => (
|
|
renderToast ? (
|
|
<React.Fragment key={t.id}>{renderToast(t, () => close(t.id))}</React.Fragment>
|
|
) : (
|
|
<DefaultToast key={t.id} toast={t} onClose={() => close(t.id)} />
|
|
)
|
|
))}
|
|
</ToastContext.Provider>
|
|
)
|
|
}
|
|
|
|
// ============================================================================
|
|
// Hook
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Hook to access toast notifications
|
|
*
|
|
* @throws {Error} If used outside of ToastProvider
|
|
*
|
|
* @example
|
|
* 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
|