import React, { useState, useRef, useEffect } from 'react'; import { Toast } from './Toast'; import { ToastProps, ToastPosition, ToastLayout, RichColorsMode, ToastSize } from '../types'; import { AnimatePresence, motion } from 'framer-motion'; interface ToastContainerProps { toasts: ToastProps[]; position: ToastPosition; layout: ToastLayout; className?: string; showCloseButton?: boolean; swipeDirection?: 'left' | 'right' | 'up' | 'down'; showProgressBar?: boolean; color?: boolean; richColors?: RichColorsMode; size?: ToastSize; onRemoveToast: (id: string) => void; } export const ToastContainer: React.FC = ({ toasts, position, layout, className = '', showCloseButton = true, swipeDirection = 'right', showProgressBar = true, color = true, richColors, size = 'md', onRemoveToast, }) => { const [isHovered, setIsHovered] = useState(false); const [isMobile, setIsMobile] = useState(false); const containerRef = useRef(null); const toastHeightsRef = useRef>(new Map()); const hoverTimeoutRef = useRef(null); // Sonner-like spacing: simple and consistent const STACK_GAP = 10; // Tight visible gap when stacked (like Sonner - toasts overlap) const EXPANDED_GAP = 8; // Consistent gap when expanded (like Sonner) const BASE_TOAST_HEIGHT = 64; // Base toast height (content + padding) // Detect mobile on mount and resize useEffect(() => { const checkMobile = () => { setIsMobile(window.innerWidth < 640); }; checkMobile(); window.addEventListener('resize', checkMobile); return () => window.removeEventListener('resize', checkMobile); }, []); // Calculate toast height based on content const getToastHeight = (toast: ToastProps, isExpanded: boolean): number => { if (!isExpanded && layout === 'stack') { return 60; // Stacked height } let height = BASE_TOAST_HEIGHT; // Base height // Custom toasts with JSX content need more height if (toast.type === 'custom') { height += 20; // Custom content typically needs more space } // Add height for description if (toast.description) { height += 20; // Description adds ~20px } // Add height for actions if (toast.actions && toast.actions.length > 0) { height += 40; // Actions add ~40px } // Add height for input if (toast.input) { height += 40; // Input adds ~40px } // Add height for cancel button if (toast.cancel) { height += 32; // Cancel button adds ~32px } return height; }; const getPositionStyles = () => { const styles: React.CSSProperties = { position: 'fixed', zIndex: 9999 }; // Use responsive values that work better on mobile const topOffset = isMobile ? '0.5rem' : '1rem'; // For bottom positions, add extra space when using stack layout to prevent going off-screen const baseBottomOffset = isMobile ? '1rem' : '3.5rem'; // When stack layout is used with bottom position, add more space to account for stacked toasts // This ensures toasts don't go off-screen when stacked // Mobile needs more space due to smaller screen and potential navigation bars const bottomOffset = layout === 'stack' && position.includes('bottom') ? (isMobile ? '4.5rem' : '5.5rem') // Increased space for stack layout (more for mobile) : baseBottomOffset; const sideOffset = isMobile ? '0.5rem' : '1rem'; if (position.includes('top')) styles.top = topOffset; if (position.includes('bottom')) styles.bottom = bottomOffset; if (position.includes('left')) styles.left = sideOffset; if (position.includes('right')) styles.right = sideOffset; if (position.includes('center')) { styles.left = '50%'; styles.transform = 'translateX(-50%)'; // Adjust for mobile if (isMobile) { styles.width = 'calc(100% - 1rem)'; styles.maxWidth = '100%'; } } return styles; }; const getSwipeAnimation = () => { const distance = 100; switch (swipeDirection) { case 'left': return { x: -distance, opacity: 0 }; case 'right': return { x: distance, opacity: 0 }; case 'up': return { y: -distance, opacity: 0 }; case 'down': return { y: distance, opacity: 0 }; default: return { x: distance, opacity: 0 }; } }; // Reverse toasts array for proper stacking order const reversedToasts = [...toasts].reverse(); const getOffset = (index: number) => { if (layout === 'normal') { // Normal layout: consistent spacing const gap = isHovered ? 12 : 8; return position.startsWith('top') ? index * gap : -(index * gap); } if (layout === 'stack') { if (isHovered) { // When hovered, calculate cumulative offset based on actual toast heights let offset = 0; for (let i = 0; i < index; i++) { const prevToast = reversedToasts[i]; const prevHeight = getToastHeight(prevToast, true); offset += prevHeight + EXPANDED_GAP; } return offset; } else { // When stacked, tight spacing (toasts overlap, only small portion visible) return index * STACK_GAP; } } return 0; }; return (
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} aria-live="polite" aria-label="Notifications" > {reversedToasts.map((toast, index) => { const isExpanded = toast.expanded !== undefined ? toast.expanded : (layout !== 'stack' || isHovered); const isStacked = layout === 'stack' && !isHovered; const offset = getOffset(index); return ( { // Lower threshold on mobile for easier swiping const threshold = isMobile ? 50 : 100; const velocity = isMobile ? 200 : 500; // Check if dragged far enough or fast enough if ( Math.abs(info.offset.x) > threshold || Math.abs(info.offset.y) > threshold || Math.abs(info.velocity.x) > velocity || Math.abs(info.velocity.y) > velocity ) { toast.onClose?.(); onRemoveToast(toast.id); } }} style={{ position: layout === 'normal' ? 'relative' : 'absolute', width: '100%', transformOrigin: position.startsWith('top') ? 'top' : 'bottom', marginBottom: layout === 'normal' ? (isHovered ? 12 : 8) : 0, }} > ); })}
); };