All files Toaster.tsx

100% Statements 100/100
100% Branches 20/20
100% Functions 4/4
100% Lines 100/100

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 1622x 2x 2x 2x                                                               2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x     2x 2x 2x 2x 2x 2x         2x 50x 50x 50x 50x 50x   50x 50x 29x 29x   29x   29x 1x 1x 29x   29x 29x 50x     50x 29x 29x 29x 28x 28x 28x 28x 50x   50x 50x 50x   50x 50x 50x 50x 50x     50x 50x 50x 50x 50x 50x   50x 50x 50x 50x 50x       50x 50x 50x 50x   50x             50x   2x 2x 26x 24x 26x 2x 2x 26x 2x 2x 2x 1x 1x 1x 1x 2x 2x 5x 4x 5x 1x 1x 5x 2x     1x  
import * as React from 'react';
import { useState, useEffect } from 'react';
import { cva } from 'class-variance-authority';
import { cn } from '../lib/utils';
 
export type ToastType = 'default' | 'destructive';
export type ToastPosition =
  | 'top-left'
  | 'top-right'
  | 'bottom-left'
  | 'bottom-right';
 
export interface Toast {
  id: string;
  title: string;
  type?: ToastType;
  duration?: number;
  description?: string;
  action?: {
    label: string;
    onClick: () => void;
  };
}
 
interface ToasterProps {
  position?: ToastPosition;
  visibleToasts?: number;
}
 
declare global {
  interface Window {
    __addToast: AddToastFunction | null;
  }
}
 
const toastVariants = cva(
  'relative rounded-lg border w-full px-4 py-3 text-sm grid grid-cols-[1fr_auto] gap-x-4 gap-y-0.5 items-start shadow-lg min-w-[300px] max-w-[500px] text-black',
  {
    variants: {
      variant: {
        default: 'bg-white',
        destructive: 'bg-white border-destructive',
      },
    },
    defaultVariants: {
      variant: 'default',
    },
  }
);
 
const POSITION_CLASSES: Record<ToastPosition, string> = {
  'top-left': 'top-4 left-4',
  'top-right': 'top-4 right-4',
  'bottom-left': 'bottom-4 left-4',
  'bottom-right': 'bottom-4 right-4',
};
 
type ToastOptions = Partial<Omit<Toast, 'id' | 'title'>>;
export type AddToastFunction = (title: string, options?: ToastOptions) => void;
 
const Toaster: React.FC<ToasterProps> = ({
  position = 'bottom-right',
  visibleToasts = 3,
}) => {
  const [toasts, setToasts] = useState<Toast[]>([]);
  const timersRef = React.useRef<Map<string, NodeJS.Timeout>>(new Map());
 
  const addToast = React.useCallback(
    (title: string, options: ToastOptions = {}) => {
      const id = crypto.randomUUID();
      const toast: Toast = { id, title, ...options };
 
      setToasts((prev) => [...prev, toast]);
 
      const timer = setTimeout(() => {
        setToasts((prev) => prev.filter((t) => t.id !== id));
        timersRef.current.delete(id);
      }, options.duration || 3000);
 
      timersRef.current.set(id, timer);
    },
    []
  );
 
  useEffect(() => {
    const currentTimers = timersRef.current;
    window.__addToast = addToast;
    return () => {
      currentTimers.forEach((timer) => clearTimeout(timer));
      currentTimers.clear();
      window.__addToast = null;
    };
  }, [addToast]);
 
  const visibleToastsCount = Math.max(0, visibleToasts);
  const displayedToasts =
    visibleToastsCount === 0 ? [] : toasts.slice(-visibleToastsCount);
 
  return (
    <div
      className={cn(
        'fixed flex flex-col gap-3 z-50',
        POSITION_CLASSES[position]
      )}
    >
      {displayedToasts.map((toast) => (
        <div
          key={toast.id}
          data-testid={`toast-${toast.id}`}
          className={cn(toastVariants({ variant: toast.type }))}
          role='alert'
        >
          <div className='flex flex-col gap-0.5'>
            <div className='font-medium tracking-tight'>{toast.title}</div>
            {toast.description && (
              <div className='text-muted-foreground text-sm [&_p]:leading-relaxed'>
                {toast.description}
              </div>
            )}
          </div>
          {toast.action && (
            <button
              onClick={toast.action.onClick}
              className='self-center text-sm font-medium underline hover:opacity-80'
            >
              {toast.action.label}
            </button>
          )}
        </div>
      ))}
    </div>
  );
};
 
const toast = Object.assign(
  (title: string, options: ToastOptions = {}) => {
    if (window.__addToast) {
      window.__addToast(title, { ...options, type: options.type ?? 'default' });
    } else {
      console.warn('Toaster is not mounted.');
    }
  },
  {
    default: (title: string, options: ToastOptions = {}) => {
      if (window.__addToast) {
        window.__addToast(title, { ...options, type: 'default' });
      } else {
        console.warn('Toaster is not mounted.');
      }
    },
    destructive: (title: string, options: ToastOptions = {}) => {
      if (window.__addToast) {
        window.__addToast(title, { ...options, type: 'destructive' });
      } else {
        console.warn('Toaster is not mounted.');
      }
    },
  }
);
 
export { Toaster, toast };