All files / components/ui toast.tsx

0% Statements 0/19
0% Branches 0/6
0% Functions 0/6
0% Lines 0/18

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                                                                                                                                                                                                     
"use client";
 
import { useEffect, useState } from "react";
 
export type ToastVariant = "success" | "error" | "info";
 
type ToastProps = {
  message: string;
  variant?: ToastVariant;
  durationMs?: number;
  onDismiss: () => void;
};
 
const ICON_BY_VARIANT: Record<ToastVariant, string> = {
  success: "✓",
  error: "✕",
  info: "ℹ",
};
 
const STYLE_BY_VARIANT: Record<ToastVariant, string> = {
  success:
    "border-emerald-500/30 bg-emerald-500/12 text-emerald-300 shadow-[0_8px_32px_rgba(16,185,129,0.18)]",
  error:
    "border-red-500/30 bg-red-500/12 text-red-300 shadow-[0_8px_32px_rgba(239,68,68,0.18)]",
  info:
    "border-sky-500/30 bg-sky-500/12 text-sky-300 shadow-[0_8px_32px_rgba(14,165,233,0.18)]",
};
 
const ICON_BG_BY_VARIANT: Record<ToastVariant, string> = {
  success: "bg-emerald-500/20 text-emerald-400",
  error: "bg-red-500/20 text-red-400",
  info: "bg-sky-500/20 text-sky-400",
};
 
const DEFAULT_DURATION_MS = 3500;
 
export function Toast({
  message,
  variant = "success",
  durationMs = DEFAULT_DURATION_MS,
  onDismiss,
}: ToastProps) {
  const [isVisible, setIsVisible] = useState(false);
  const [isLeaving, setIsLeaving] = useState(false);
 
  useEffect(() => {
    // Trigger enter animation on next frame.
    const enterFrame = requestAnimationFrame(() => setIsVisible(true));
 
    const dismissTimer = setTimeout(() => {
      setIsLeaving(true);
      // Wait for exit animation before unmounting.
      setTimeout(onDismiss, 280);
    }, durationMs);
 
    return () => {
      cancelAnimationFrame(enterFrame);
      clearTimeout(dismissTimer);
    };
  }, [durationMs, onDismiss]);
 
  return (
    <div
      role="status"
      aria-live="polite"
      className={[
        "fixed right-5 top-5 z-[9999] flex max-w-sm items-center gap-3 rounded-2xl border px-4 py-3 backdrop-blur-xl transition-all duration-280",
        STYLE_BY_VARIANT[variant],
        isVisible && !isLeaving
          ? "translate-y-0 opacity-100"
          : "-translate-y-3 opacity-0",
      ].join(" ")}
    >
      <span
        className={[
          "flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-sm font-semibold",
          ICON_BG_BY_VARIANT[variant],
        ].join(" ")}
      >
        {ICON_BY_VARIANT[variant]}
      </span>
      <span className="font-dm-sans text-sm font-medium leading-snug">
        {message}
      </span>
      <button
        type="button"
        className="ml-auto shrink-0 rounded-lg p-1 opacity-60 transition hover:opacity-100"
        onClick={() => {
          setIsLeaving(true);
          setTimeout(onDismiss, 280);
        }}
        aria-label="Dismiss"
      >
        ✕
      </button>
    </div>
  );
}