Toast.tsx 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169
  1. import { useEffect, type ReactNode } from "react"
  2. export type ToastVariant = "info" | "success" | "warning" | "error"
  3. export interface Toast {
  4. id: string
  5. title?: string
  6. message: string
  7. variant: ToastVariant
  8. duration?: number // auto-dismiss after this many ms (0 = never)
  9. action?: {
  10. label: string
  11. onClick: () => void
  12. }
  13. }
  14. interface ToastProps {
  15. toast: Toast
  16. onDismiss: (id: string) => void
  17. }
  18. const VARIANT_STYLES: Record<ToastVariant, { bg: string; border: string; icon: ReactNode }> = {
  19. info: {
  20. bg: "bg-blue-50 dark:bg-blue-950",
  21. border: "border-blue-300 dark:border-blue-700",
  22. icon: (
  23. <svg className="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
  24. <path
  25. strokeLinecap="round"
  26. strokeLinejoin="round"
  27. strokeWidth={2}
  28. d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
  29. />
  30. </svg>
  31. ),
  32. },
  33. success: {
  34. bg: "bg-green-50 dark:bg-green-950",
  35. border: "border-green-300 dark:border-green-700",
  36. icon: (
  37. <svg className="w-5 h-5 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
  38. <path
  39. strokeLinecap="round"
  40. strokeLinejoin="round"
  41. strokeWidth={2}
  42. d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
  43. />
  44. </svg>
  45. ),
  46. },
  47. warning: {
  48. bg: "bg-yellow-50 dark:bg-yellow-950",
  49. border: "border-yellow-300 dark:border-yellow-700",
  50. icon: (
  51. <svg
  52. className="w-5 h-5 text-yellow-600 dark:text-yellow-400"
  53. fill="none"
  54. stroke="currentColor"
  55. viewBox="0 0 24 24"
  56. >
  57. <path
  58. strokeLinecap="round"
  59. strokeLinejoin="round"
  60. strokeWidth={2}
  61. d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
  62. />
  63. </svg>
  64. ),
  65. },
  66. error: {
  67. bg: "bg-red-50 dark:bg-red-950",
  68. border: "border-red-300 dark:border-red-700",
  69. icon: (
  70. <svg className="w-5 h-5 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
  71. <path
  72. strokeLinecap="round"
  73. strokeLinejoin="round"
  74. strokeWidth={2}
  75. d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
  76. />
  77. </svg>
  78. ),
  79. },
  80. }
  81. /**
  82. * Toast notification component
  83. * Displays a dismissible notification message with different variants
  84. */
  85. export function ToastComponent({ toast, onDismiss }: ToastProps) {
  86. const { bg, border, icon } = VARIANT_STYLES[toast.variant]
  87. // Auto-dismiss after duration (if specified)
  88. useEffect(() => {
  89. if (toast.duration && toast.duration > 0) {
  90. const timer = setTimeout(() => {
  91. onDismiss(toast.id)
  92. }, toast.duration)
  93. return () => clearTimeout(timer)
  94. }
  95. }, [toast.id, toast.duration, onDismiss])
  96. return (
  97. <div
  98. className={`${bg} ${border} border rounded-lg shadow-lg p-3 min-w-72 max-w-md animate-in slide-in-from-top-2 fade-in duration-200`}
  99. role="alert"
  100. >
  101. <div className="flex items-start gap-3">
  102. {/* Icon */}
  103. <div className="flex-shrink-0">{icon}</div>
  104. {/* Content */}
  105. <div className="flex-1 min-w-0">
  106. {toast.title && (
  107. <h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-1">{toast.title}</h4>
  108. )}
  109. <p className="text-sm text-gray-700 dark:text-gray-300">{toast.message}</p>
  110. {toast.action && (
  111. <button
  112. onClick={toast.action.onClick}
  113. className="mt-2 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
  114. >
  115. {toast.action.label}
  116. </button>
  117. )}
  118. </div>
  119. {/* Dismiss button */}
  120. <button
  121. onClick={() => onDismiss(toast.id)}
  122. className="flex-shrink-0 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
  123. aria-label="Dismiss"
  124. >
  125. <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
  126. <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
  127. </svg>
  128. </button>
  129. </div>
  130. </div>
  131. )
  132. }
  133. /**
  134. * Toast container component
  135. * Renders all active toasts in a fixed position
  136. */
  137. interface ToastContainerProps {
  138. toasts: Toast[]
  139. onDismiss: (id: string) => void
  140. }
  141. export function ToastContainer({ toasts, onDismiss }: ToastContainerProps) {
  142. if (toasts.length === 0) return null
  143. return (
  144. <div
  145. className="fixed top-4 right-4 z-50 flex flex-col gap-2 pointer-events-none"
  146. aria-live="polite"
  147. aria-atomic="true"
  148. >
  149. {toasts.map((toast) => (
  150. <div key={toast.id} className="pointer-events-auto">
  151. <ToastComponent toast={toast} onDismiss={onDismiss} />
  152. </div>
  153. ))}
  154. </div>
  155. )
  156. }