@hanzogui/toast 3.0.2 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/package.json +42 -25
  2. package/src/Toast.tsx +5 -5
  3. package/src/ToastAnnounce.tsx +2 -2
  4. package/src/ToastComposable.tsx +1380 -0
  5. package/src/ToastImpl.tsx +19 -7
  6. package/src/ToastItemFrame.tsx +136 -0
  7. package/src/ToastPortal.tsx +2 -2
  8. package/src/ToastProvider.tsx +13 -6
  9. package/src/ToastState.ts +398 -0
  10. package/src/ToastViewport.tsx +11 -10
  11. package/src/Toaster.tsx +181 -0
  12. package/src/createNativeToast.native.tsx +4 -0
  13. package/src/createNativeToast.tsx +18 -24
  14. package/src/dispatchNativeToast.ts +43 -0
  15. package/src/useAnimatedDragGesture.native.ts +255 -0
  16. package/src/useAnimatedDragGesture.ts +319 -0
  17. package/src/useReducedMotion.ts +59 -0
  18. package/src/useToastAnimations.ts +377 -0
  19. package/src/v2.ts +31 -0
  20. package/types/Toast.d.ts +36 -36
  21. package/types/ToastAnnounce.d.ts +4 -4
  22. package/types/ToastComposable.d.ts +199 -0
  23. package/types/ToastImperative.d.ts +2 -2
  24. package/types/ToastImpl.d.ts +12 -12
  25. package/types/ToastItemFrame.d.ts +25 -0
  26. package/types/ToastProvider.d.ts +8 -8
  27. package/types/ToastState.d.ts +179 -0
  28. package/types/ToastViewport.d.ts +12 -12
  29. package/types/Toaster.d.ts +112 -0
  30. package/types/createNativeToast.d.ts +5 -0
  31. package/types/createNativeToast.native.d.ts +1 -0
  32. package/types/dispatchNativeToast.d.ts +12 -0
  33. package/types/useAnimatedDragGesture.d.ts +33 -0
  34. package/types/useAnimatedDragGesture.native.d.ts +33 -0
  35. package/types/useReducedMotion.d.ts +6 -0
  36. package/types/useToastAnimations.d.ts +50 -0
  37. package/types/v2.d.ts +10 -0
  38. package/v2/index.cjs +2 -0
  39. package/v2/index.js +2 -0
  40. package/v2/index.native.cjs +2 -0
  41. package/v2/index.native.js +2 -0
  42. package/LICENSE +0 -21
  43. package/dist/cjs/Toast.cjs +0 -170
  44. package/dist/cjs/Toast.native.js +0 -174
  45. package/dist/cjs/Toast.native.js.map +0 -1
  46. package/dist/cjs/ToastAnnounce.cjs +0 -97
  47. package/dist/cjs/ToastAnnounce.native.js +0 -105
  48. package/dist/cjs/ToastAnnounce.native.js.map +0 -1
  49. package/dist/cjs/ToastImperative.cjs +0 -100
  50. package/dist/cjs/ToastImperative.native.js +0 -122
  51. package/dist/cjs/ToastImperative.native.js.map +0 -1
  52. package/dist/cjs/ToastImpl.cjs +0 -292
  53. package/dist/cjs/ToastImpl.native.js +0 -326
  54. package/dist/cjs/ToastImpl.native.js.map +0 -1
  55. package/dist/cjs/ToastPortal.cjs +0 -44
  56. package/dist/cjs/ToastPortal.native.js +0 -47
  57. package/dist/cjs/ToastPortal.native.js.map +0 -1
  58. package/dist/cjs/ToastProvider.cjs +0 -146
  59. package/dist/cjs/ToastProvider.native.js +0 -159
  60. package/dist/cjs/ToastProvider.native.js.map +0 -1
  61. package/dist/cjs/ToastViewport.cjs +0 -278
  62. package/dist/cjs/ToastViewport.native.js +0 -315
  63. package/dist/cjs/ToastViewport.native.js.map +0 -1
  64. package/dist/cjs/constants.cjs +0 -28
  65. package/dist/cjs/constants.native.js +0 -31
  66. package/dist/cjs/constants.native.js.map +0 -1
  67. package/dist/cjs/createNativeToast.cjs +0 -51
  68. package/dist/cjs/createNativeToast.native.js +0 -48
  69. package/dist/cjs/createNativeToast.native.js.map +0 -1
  70. package/dist/cjs/index.cjs +0 -18
  71. package/dist/cjs/index.native.js +0 -21
  72. package/dist/cjs/index.native.js.map +0 -1
  73. package/dist/cjs/types.cjs +0 -16
  74. package/dist/cjs/types.native.js +0 -19
  75. package/dist/cjs/types.native.js.map +0 -1
  76. package/dist/esm/Toast.mjs +0 -131
  77. package/dist/esm/Toast.mjs.map +0 -1
  78. package/dist/esm/Toast.native.js +0 -132
  79. package/dist/esm/Toast.native.js.map +0 -1
  80. package/dist/esm/ToastAnnounce.mjs +0 -62
  81. package/dist/esm/ToastAnnounce.mjs.map +0 -1
  82. package/dist/esm/ToastAnnounce.native.js +0 -67
  83. package/dist/esm/ToastAnnounce.native.js.map +0 -1
  84. package/dist/esm/ToastImperative.mjs +0 -63
  85. package/dist/esm/ToastImperative.mjs.map +0 -1
  86. package/dist/esm/ToastImperative.native.js +0 -82
  87. package/dist/esm/ToastImperative.native.js.map +0 -1
  88. package/dist/esm/ToastImpl.mjs +0 -256
  89. package/dist/esm/ToastImpl.mjs.map +0 -1
  90. package/dist/esm/ToastImpl.native.js +0 -287
  91. package/dist/esm/ToastImpl.native.js.map +0 -1
  92. package/dist/esm/ToastPortal.mjs +0 -21
  93. package/dist/esm/ToastPortal.mjs.map +0 -1
  94. package/dist/esm/ToastPortal.native.js +0 -21
  95. package/dist/esm/ToastPortal.native.js.map +0 -1
  96. package/dist/esm/ToastProvider.mjs +0 -108
  97. package/dist/esm/ToastProvider.mjs.map +0 -1
  98. package/dist/esm/ToastProvider.native.js +0 -118
  99. package/dist/esm/ToastProvider.native.js.map +0 -1
  100. package/dist/esm/ToastViewport.mjs +0 -241
  101. package/dist/esm/ToastViewport.mjs.map +0 -1
  102. package/dist/esm/ToastViewport.native.js +0 -275
  103. package/dist/esm/ToastViewport.native.js.map +0 -1
  104. package/dist/esm/constants.mjs +0 -4
  105. package/dist/esm/constants.mjs.map +0 -1
  106. package/dist/esm/constants.native.js +0 -4
  107. package/dist/esm/constants.native.js.map +0 -1
  108. package/dist/esm/createNativeToast.mjs +0 -27
  109. package/dist/esm/createNativeToast.mjs.map +0 -1
  110. package/dist/esm/createNativeToast.native.js +0 -21
  111. package/dist/esm/createNativeToast.native.js.map +0 -1
  112. package/dist/esm/index.js +0 -2
  113. package/dist/esm/index.js.map +0 -1
  114. package/dist/esm/index.mjs +0 -2
  115. package/dist/esm/index.mjs.map +0 -1
  116. package/dist/esm/index.native.js +0 -2
  117. package/dist/esm/index.native.js.map +0 -1
  118. package/dist/esm/types.mjs +0 -2
  119. package/dist/esm/types.mjs.map +0 -1
  120. package/dist/esm/types.native.js +0 -2
  121. package/dist/esm/types.native.js.map +0 -1
  122. package/dist/jsx/Toast.mjs +0 -131
  123. package/dist/jsx/Toast.mjs.map +0 -1
  124. package/dist/jsx/Toast.native.js +0 -174
  125. package/dist/jsx/Toast.native.js.map +0 -1
  126. package/dist/jsx/ToastAnnounce.mjs +0 -62
  127. package/dist/jsx/ToastAnnounce.mjs.map +0 -1
  128. package/dist/jsx/ToastAnnounce.native.js +0 -105
  129. package/dist/jsx/ToastAnnounce.native.js.map +0 -1
  130. package/dist/jsx/ToastImperative.mjs +0 -63
  131. package/dist/jsx/ToastImperative.mjs.map +0 -1
  132. package/dist/jsx/ToastImperative.native.js +0 -122
  133. package/dist/jsx/ToastImperative.native.js.map +0 -1
  134. package/dist/jsx/ToastImpl.mjs +0 -256
  135. package/dist/jsx/ToastImpl.mjs.map +0 -1
  136. package/dist/jsx/ToastImpl.native.js +0 -326
  137. package/dist/jsx/ToastImpl.native.js.map +0 -1
  138. package/dist/jsx/ToastPortal.mjs +0 -21
  139. package/dist/jsx/ToastPortal.mjs.map +0 -1
  140. package/dist/jsx/ToastPortal.native.js +0 -47
  141. package/dist/jsx/ToastPortal.native.js.map +0 -1
  142. package/dist/jsx/ToastProvider.mjs +0 -108
  143. package/dist/jsx/ToastProvider.mjs.map +0 -1
  144. package/dist/jsx/ToastProvider.native.js +0 -159
  145. package/dist/jsx/ToastProvider.native.js.map +0 -1
  146. package/dist/jsx/ToastViewport.mjs +0 -241
  147. package/dist/jsx/ToastViewport.mjs.map +0 -1
  148. package/dist/jsx/ToastViewport.native.js +0 -315
  149. package/dist/jsx/ToastViewport.native.js.map +0 -1
  150. package/dist/jsx/constants.mjs +0 -4
  151. package/dist/jsx/constants.mjs.map +0 -1
  152. package/dist/jsx/constants.native.js +0 -31
  153. package/dist/jsx/constants.native.js.map +0 -1
  154. package/dist/jsx/createNativeToast.mjs +0 -27
  155. package/dist/jsx/createNativeToast.mjs.map +0 -1
  156. package/dist/jsx/createNativeToast.native.js +0 -48
  157. package/dist/jsx/createNativeToast.native.js.map +0 -1
  158. package/dist/jsx/index.js +0 -2
  159. package/dist/jsx/index.js.map +0 -1
  160. package/dist/jsx/index.mjs +0 -2
  161. package/dist/jsx/index.mjs.map +0 -1
  162. package/dist/jsx/index.native.js +0 -21
  163. package/dist/jsx/index.native.js.map +0 -1
  164. package/dist/jsx/types.mjs +0 -2
  165. package/dist/jsx/types.mjs.map +0 -1
  166. package/dist/jsx/types.native.js +0 -19
  167. package/dist/jsx/types.native.js.map +0 -1
package/src/ToastImpl.tsx CHANGED
@@ -1,7 +1,7 @@
1
1
  import { useIsPresent } from '@hanzogui/animate-presence'
2
2
  import { useComposedRefs } from '@hanzogui/compose-refs'
3
3
  import { isWeb } from '@hanzogui/constants'
4
- import type { GetProps, GuiElement } from '@hanzogui/core'
4
+ import type { GetProps, HanzoguiElement } from '@hanzogui/core'
5
5
  import {
6
6
  View,
7
7
  Theme,
@@ -21,8 +21,17 @@ import type {
21
21
  Animated,
22
22
  GestureResponderEvent,
23
23
  PanResponderGestureState,
24
+ PanResponder as PanResponderType,
24
25
  } from 'react-native'
25
- import { PanResponder } from 'react-native'
26
+
27
+ // Lazy load PanResponder only on native to avoid SSR issues
28
+ const getPanResponder = (): typeof PanResponderType | null => {
29
+ if (process.env.TAMAGUI_TARGET === 'native') {
30
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
31
+ return require('react-native').PanResponder
32
+ }
33
+ return null
34
+ }
26
35
  import { TOAST_CONTEXT, TOAST_NAME } from './constants'
27
36
  import { ToastAnnounce } from './ToastAnnounce'
28
37
  import type { ScopedProps, SwipeDirection } from './ToastProvider'
@@ -53,7 +62,7 @@ const ToastImplFrame = styled(YStack, {
53
62
  } as const,
54
63
 
55
64
  defaultVariants: {
56
- unstyled: process.env.HANZO_GUI_HEADLESS === '1',
65
+ unstyled: process.env.HANZOGUI_HEADLESS === '1',
57
66
  },
58
67
  })
59
68
 
@@ -152,7 +161,7 @@ type ToastImplProps = ScopedProps<
152
161
  ToastImplPrivateProps & ToastImplFrameProps & ToastExtraProps
153
162
  >
154
163
 
155
- const ToastImpl = React.forwardRef<GuiElement, ToastImplProps>(
164
+ const ToastImpl = React.forwardRef<HanzoguiElement, ToastImplProps>(
156
165
  (props, forwardedRef) => {
157
166
  const {
158
167
  scope,
@@ -172,7 +181,7 @@ const ToastImpl = React.forwardRef<GuiElement, ToastImplProps>(
172
181
  } = props
173
182
  const isPresent = useIsPresent()
174
183
  const context = useToastProviderContext(scope)
175
- const [node, setNode] = React.useState<GuiElement | null>(null)
184
+ const [node, setNode] = React.useState<HanzoguiElement | null>(null)
176
185
  const composedRefs = useComposedRefs(forwardedRef, setNode)
177
186
  const duration = durationProp || context.duration
178
187
  const closeTimerStartTimeRef = React.useRef(0)
@@ -261,7 +270,7 @@ const ToastImpl = React.forwardRef<GuiElement, ToastImplProps>(
261
270
 
262
271
  const { animationDriver } = useConfiguration()
263
272
  if (!animationDriver) {
264
- throw new Error('Must set animations in gui.config.ts')
273
+ throw new Error('Must set animations in hanzogui.config.ts')
265
274
  }
266
275
 
267
276
  const { useAnimatedNumber, useAnimatedNumberStyle } = animationDriver
@@ -281,6 +290,8 @@ const ToastImpl = React.forwardRef<GuiElement, ToastImplProps>(
281
290
  })
282
291
 
283
292
  const panResponder = React.useMemo(() => {
293
+ const PanResponder = getPanResponder()
294
+ if (!PanResponder) return null
284
295
  return PanResponder.create({
285
296
  onMoveShouldSetPanResponder: (e, gesture) => {
286
297
  const shouldMove = shouldGrantGestureMove(context.swipeDirection, gesture)
@@ -330,7 +341,8 @@ const ToastImpl = React.forwardRef<GuiElement, ToastImplProps>(
330
341
  {announceTextContent && (
331
342
  <ToastAnnounce
332
343
  scope={scope}
333
- // toasts are always role=status to avoid stuttering issues with role=alert in SRs
344
+ // Toasts are always role=status to avoid stuttering issues with role=alert in SRs.
345
+ // biome-ignore lint/a11y/useSemanticElements: <explanation>
334
346
  role="status"
335
347
  aria-live={type === 'foreground' ? 'assertive' : 'polite'}
336
348
  aria-atomic
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Shared visual styled components for toast items.
3
+ * Shared across web and native — imported by ToastComposable.
4
+ */
5
+
6
+ import { styled } from '@hanzogui/core'
7
+ import { XStack, YStack } from '@hanzogui/stacks'
8
+ import { SizableText } from '@hanzogui/text'
9
+
10
+ /* -------------------------------------------------------------------------------------------------
11
+ * ToastPositionWrapper - handles absolute positioning and stacking animations
12
+ * On web: uses Hanzogui transition/enterStyle/exitStyle
13
+ * On native: replaced by Animated.View with useAnimatedStyle
14
+ * -----------------------------------------------------------------------------------------------*/
15
+
16
+ export const ToastPositionWrapper = styled(YStack, {
17
+ name: 'ToastPositionWrapper',
18
+ pointerEvents: 'auto',
19
+ position: 'absolute',
20
+ left: 0,
21
+ right: 0,
22
+ opacity: 1,
23
+ scale: 1,
24
+ y: 0,
25
+ x: 0,
26
+ })
27
+
28
+ /* -------------------------------------------------------------------------------------------------
29
+ * ToastItemFrame - visual styling for the toast
30
+ * Shared across web and native — the visual appearance of the toast card.
31
+ * -----------------------------------------------------------------------------------------------*/
32
+
33
+ export const ToastItemFrame = styled(YStack, {
34
+ name: 'ToastItem',
35
+ userSelect: 'none',
36
+ cursor: 'default',
37
+ focusable: true,
38
+
39
+ variants: {
40
+ unstyled: {
41
+ false: {
42
+ backgroundColor: '$background',
43
+ borderRadius: '$6',
44
+ paddingHorizontal: '$4',
45
+ paddingVertical: '$3',
46
+ borderWidth: 1,
47
+ borderColor: '$borderColor',
48
+ shadowColor: 'rgba(0, 0, 0, 0.15)',
49
+ shadowOffset: { width: 0, height: 4 },
50
+ shadowRadius: 12,
51
+ focusVisibleStyle: {
52
+ outlineWidth: 2,
53
+ outlineColor: '$color8',
54
+ outlineStyle: 'solid',
55
+ },
56
+ },
57
+ },
58
+ } as const,
59
+
60
+ defaultVariants: {
61
+ unstyled: process.env.HANZOGUI_HEADLESS === '1',
62
+ },
63
+ })
64
+
65
+ /* -------------------------------------------------------------------------------------------------
66
+ * ToastCloseFrame
67
+ * -----------------------------------------------------------------------------------------------*/
68
+
69
+ export const ToastCloseFrame = styled(XStack, {
70
+ name: 'ToastClose',
71
+ render: 'button',
72
+ alignItems: 'center',
73
+ justifyContent: 'center',
74
+ cursor: 'pointer',
75
+
76
+ variants: {
77
+ unstyled: {
78
+ false: {
79
+ width: 18,
80
+ height: 18,
81
+ borderRadius: '$10',
82
+ backgroundColor: '$background',
83
+ borderWidth: 1,
84
+ borderColor: '$borderColor',
85
+ shadowColor: 'rgba(0, 0, 0, 0.08)',
86
+ shadowOffset: { width: 0, height: 1 },
87
+ shadowRadius: 3,
88
+ hoverStyle: { backgroundColor: '$color3' },
89
+ pressStyle: { backgroundColor: '$color4' },
90
+ },
91
+ },
92
+ } as const,
93
+
94
+ defaultVariants: {
95
+ unstyled: process.env.HANZOGUI_HEADLESS === '1',
96
+ },
97
+ })
98
+
99
+ /* -------------------------------------------------------------------------------------------------
100
+ * ToastActionFrame - for action/cancel buttons with text
101
+ * -----------------------------------------------------------------------------------------------*/
102
+
103
+ export const ToastActionFrame = styled(XStack, {
104
+ name: 'ToastAction',
105
+ render: 'button',
106
+ alignItems: 'center',
107
+ justifyContent: 'center',
108
+ cursor: 'pointer',
109
+
110
+ variants: {
111
+ unstyled: {
112
+ false: {
113
+ borderRadius: '$2',
114
+ paddingHorizontal: '$2',
115
+ height: 24,
116
+ backgroundColor: '$color5',
117
+ hoverStyle: { backgroundColor: '$color6' },
118
+ pressStyle: { backgroundColor: '$color7' },
119
+ },
120
+ },
121
+ } as const,
122
+
123
+ defaultVariants: {
124
+ unstyled: process.env.HANZOGUI_HEADLESS === '1',
125
+ },
126
+ })
127
+
128
+ /* -------------------------------------------------------------------------------------------------
129
+ * DefaultCloseIcon
130
+ * -----------------------------------------------------------------------------------------------*/
131
+
132
+ export const DefaultCloseIcon = () => (
133
+ <SizableText size="$1" color="$color11">
134
+ ✕
135
+ </SizableText>
136
+ )
@@ -1,6 +1,6 @@
1
+ import { isWeb } from '@hanzogui/constants'
1
2
  import { Portal } from '@hanzogui/portal'
2
3
  import type { ReactNode } from 'react'
3
- import { Platform } from 'react-native'
4
4
  import { ReprogapateToastProvider, type ToastProviderContextValue } from './ToastProvider'
5
5
 
6
6
  export function ToastPortal(props: {
@@ -10,7 +10,7 @@ export function ToastPortal(props: {
10
10
  }) {
11
11
  const { context, children, zIndex } = props
12
12
  let content = children
13
- if (Platform.OS === 'android' || Platform.OS === 'ios') {
13
+ if (!isWeb) {
14
14
  content = (
15
15
  <ReprogapateToastProvider context={context}>{children}</ReprogapateToastProvider>
16
16
  )
@@ -1,5 +1,5 @@
1
1
  import { createCollection } from '@hanzogui/collection'
2
- import type { NativeValue, GuiElement } from '@hanzogui/core'
2
+ import type { NativeValue, HanzoguiElement } from '@hanzogui/core'
3
3
  import { createStyledContext } from '@hanzogui/core'
4
4
  import { startTransition } from '@hanzogui/start-transition'
5
5
  import * as React from 'react'
@@ -15,9 +15,16 @@ import type { BurntToastOptions } from './types'
15
15
 
16
16
  const PROVIDER_NAME = 'ToastProvider'
17
17
 
18
- const [Collection, useCollection] = createCollection<GuiElement>('Toast')
18
+ const [Collection, useCollection] = createCollection<HanzoguiElement>('Toast')
19
19
 
20
- export type SwipeDirection = 'vertical' | 'up' | 'down' | 'horizontal' | 'left' | 'right'
20
+ export type SwipeDirection =
21
+ | 'vertical'
22
+ | 'up'
23
+ | 'down'
24
+ | 'horizontal'
25
+ | 'left'
26
+ | 'right'
27
+ | 'auto'
21
28
 
22
29
  export type ToastProviderContextValue = {
23
30
  id: string
@@ -27,8 +34,8 @@ export type ToastProviderContextValue = {
27
34
  swipeDirection: SwipeDirection
28
35
  swipeThreshold: number
29
36
  toastCount: number
30
- viewports: Record<string, GuiElement | null>
31
- onViewportChange(name: string, viewport: GuiElement): void
37
+ viewports: Record<string, HanzoguiElement | null>
38
+ onViewportChange(name: string, viewport: HanzoguiElement): void
32
39
  onToastAdd(): void
33
40
  onToastRemove(): void
34
41
  isFocusedToastEscapeKeyDownRef: React.MutableRefObject<boolean>
@@ -114,7 +121,7 @@ const ToastProvider: React.FC<ToastProviderProps> = (
114
121
  const isClosePausedRef = React.useRef(false)
115
122
 
116
123
  const handleViewportChange = React.useCallback(
117
- (name: string, viewport: GuiElement | null) => {
124
+ (name: string, viewport: HanzoguiElement | null) => {
118
125
  startTransition(() => {
119
126
  setViewports((prev) => ({ ...prev, [name]: viewport }))
120
127
  })
@@ -0,0 +1,398 @@
1
+ import type React from 'react'
2
+ import type { CreateNativeToastOptions, NativeToastRef } from './types'
3
+
4
+ // counter for generating unique toast ids
5
+ let toastsCounter = 1
6
+
7
+ export type ToastType = 'default' | 'success' | 'error' | 'warning' | 'info' | 'loading'
8
+
9
+ export interface ToastT {
10
+ id: string | number
11
+ title: React.ReactNode | (() => React.ReactNode)
12
+ description?: React.ReactNode | (() => React.ReactNode)
13
+ type?: ToastType
14
+ icon?: React.ReactNode
15
+ jsx?: React.ReactElement
16
+ dismissible?: boolean
17
+ duration?: number
18
+ promise?: PromiseT
19
+ action?: ToastAction
20
+ cancel?: ToastAction
21
+ closeButton?: boolean
22
+ onDismiss?: (toast: ToastT) => void
23
+ onAutoClose?: (toast: ToastT) => void
24
+ // internal
25
+ delete?: boolean
26
+ // style overrides
27
+ style?: React.CSSProperties
28
+ className?: string
29
+ // native options
30
+ burntOptions?: CreateNativeToastOptions['burntOptions']
31
+ notificationOptions?: CreateNativeToastOptions['notificationOptions']
32
+ /** Custom user data */
33
+ data?: Record<string, unknown>
34
+ }
35
+
36
+ export interface ToastAction {
37
+ label: string
38
+ onClick?: (event: React.MouseEvent) => void
39
+ }
40
+
41
+ export interface ToastToDismiss {
42
+ id: string | number
43
+ dismiss: true
44
+ }
45
+
46
+ export type ExternalToast = Omit<
47
+ ToastT,
48
+ 'id' | 'title' | 'type' | 'delete' | 'promise'
49
+ > & {
50
+ id?: string | number
51
+ }
52
+
53
+ export type PromiseT<Data = any> = Promise<Data> | (() => Promise<Data>)
54
+
55
+ export interface PromiseData<Data = any> {
56
+ loading?: React.ReactNode | (() => React.ReactNode)
57
+ success?: React.ReactNode | ((data: Data) => React.ReactNode)
58
+ error?: React.ReactNode | ((error: any) => React.ReactNode)
59
+ description?: React.ReactNode | ((data: any) => React.ReactNode)
60
+ finally?: () => void
61
+ }
62
+
63
+ type TitleT = React.ReactNode | (() => React.ReactNode)
64
+
65
+ /**
66
+ * Observer class that manages toast state globally.
67
+ * Follows the pattern from Sonner for a clean, decoupled architecture.
68
+ */
69
+ class Observer {
70
+ subscribers: Array<(toast: ToastT | ToastToDismiss) => void> = []
71
+ toasts: ToastT[] = []
72
+ dismissedToasts: Set<string | number> = new Set()
73
+
74
+ /**
75
+ * Subscribe to toast state changes.
76
+ * Returns an unsubscribe function.
77
+ */
78
+ subscribe = (subscriber: (toast: ToastT | ToastToDismiss) => void) => {
79
+ this.subscribers.push(subscriber)
80
+
81
+ return () => {
82
+ const index = this.subscribers.indexOf(subscriber)
83
+ if (index > -1) {
84
+ this.subscribers.splice(index, 1)
85
+ }
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Publish a toast to all subscribers
91
+ */
92
+ publish = (data: ToastT) => {
93
+ this.subscribers.forEach((subscriber) => subscriber(data))
94
+ }
95
+
96
+ /**
97
+ * Add a new toast to the internal array and publish to subscribers
98
+ */
99
+ addToast = (data: ToastT) => {
100
+ this.publish(data)
101
+ this.toasts = [...this.toasts, data]
102
+ }
103
+
104
+ /**
105
+ * Create or update a toast
106
+ */
107
+ create = (
108
+ data: ExternalToast & {
109
+ title?: TitleT
110
+ type?: ToastType
111
+ promise?: PromiseT
112
+ jsx?: React.ReactElement
113
+ }
114
+ ) => {
115
+ const { title, ...rest } = data
116
+ const id =
117
+ typeof data?.id === 'number' || (typeof data?.id === 'string' && data.id.length > 0)
118
+ ? data.id
119
+ : toastsCounter++
120
+
121
+ const alreadyExists = this.toasts.find((toast) => toast.id === id)
122
+ const dismissible = data.dismissible ?? true
123
+
124
+ // if this toast was previously dismissed, clear that
125
+ if (this.dismissedToasts.has(id)) {
126
+ this.dismissedToasts.delete(id)
127
+ }
128
+
129
+ if (alreadyExists) {
130
+ // update existing toast
131
+ this.toasts = this.toasts.map((toast) => {
132
+ if (toast.id === id) {
133
+ this.publish({ ...toast, ...data, id, title, dismissible })
134
+ return { ...toast, ...data, id, title, dismissible }
135
+ }
136
+ return toast
137
+ })
138
+ } else {
139
+ this.addToast({ title, ...rest, dismissible, id } as ToastT)
140
+ }
141
+
142
+ return id
143
+ }
144
+
145
+ /**
146
+ * Dismiss a toast by id, or all toasts if no id provided
147
+ */
148
+ dismiss = (id?: string | number) => {
149
+ if (id !== undefined) {
150
+ this.dismissedToasts.add(id)
151
+ // use requestAnimationFrame to batch updates
152
+ requestAnimationFrame(() => {
153
+ this.subscribers.forEach((subscriber) => subscriber({ id, dismiss: true }))
154
+ })
155
+ } else {
156
+ // dismiss all
157
+ this.toasts.forEach((toast) => {
158
+ this.subscribers.forEach((subscriber) =>
159
+ subscriber({ id: toast.id, dismiss: true })
160
+ )
161
+ })
162
+ }
163
+
164
+ return id
165
+ }
166
+
167
+ /**
168
+ * Show a basic toast message
169
+ */
170
+ message = (title: TitleT, data?: ExternalToast) => {
171
+ return this.create({ ...data, title, type: 'default' })
172
+ }
173
+
174
+ /**
175
+ * Show a success toast
176
+ */
177
+ success = (title: TitleT, data?: ExternalToast) => {
178
+ return this.create({ ...data, title, type: 'success' })
179
+ }
180
+
181
+ /**
182
+ * Show an error toast
183
+ */
184
+ error = (title: TitleT, data?: ExternalToast) => {
185
+ return this.create({ ...data, title, type: 'error' })
186
+ }
187
+
188
+ /**
189
+ * Show a warning toast
190
+ */
191
+ warning = (title: TitleT, data?: ExternalToast) => {
192
+ return this.create({ ...data, title, type: 'warning' })
193
+ }
194
+
195
+ /**
196
+ * Show an info toast
197
+ */
198
+ info = (title: TitleT, data?: ExternalToast) => {
199
+ return this.create({ ...data, title, type: 'info' })
200
+ }
201
+
202
+ /**
203
+ * Show a loading toast
204
+ */
205
+ loading = (title: TitleT, data?: ExternalToast) => {
206
+ return this.create({ ...data, title, type: 'loading' })
207
+ }
208
+
209
+ /**
210
+ * Show a toast for a promise, automatically transitioning through
211
+ * loading -> success/error states
212
+ */
213
+ promise = <ToastData>(promise: PromiseT<ToastData>, data?: PromiseData<ToastData>) => {
214
+ if (!data) {
215
+ return
216
+ }
217
+
218
+ let id: string | number | undefined = undefined
219
+
220
+ // show loading state if provided
221
+ if (data.loading !== undefined) {
222
+ id = this.create({
223
+ promise,
224
+ type: 'loading',
225
+ title: data.loading,
226
+ description:
227
+ typeof data.description !== 'function' ? data.description : undefined,
228
+ // loading toasts shouldn't auto-dismiss
229
+ duration: Number.POSITIVE_INFINITY,
230
+ })
231
+ }
232
+
233
+ const p = Promise.resolve(promise instanceof Function ? promise() : promise)
234
+
235
+ let shouldDismiss = id !== undefined
236
+ let result: ['resolve', ToastData] | ['reject', unknown]
237
+
238
+ const originalPromise = p
239
+ .then(async (response) => {
240
+ result = ['resolve', response]
241
+
242
+ // check if response is an HTTP error
243
+ if (isHttpResponse(response) && !response.ok) {
244
+ shouldDismiss = false
245
+ const errorMsg =
246
+ typeof data.error === 'function'
247
+ ? await data.error(`HTTP error! status: ${response.status}`)
248
+ : data.error
249
+ const description =
250
+ typeof data.description === 'function'
251
+ ? await data.description(`HTTP error! status: ${response.status}`)
252
+ : data.description
253
+
254
+ this.create({ id, type: 'error', title: errorMsg, description })
255
+ } else if (data.success !== undefined) {
256
+ shouldDismiss = false
257
+ const successMsg =
258
+ typeof data.success === 'function'
259
+ ? await data.success(response)
260
+ : data.success
261
+ const description =
262
+ typeof data.description === 'function'
263
+ ? await data.description(response)
264
+ : data.description
265
+
266
+ this.create({ id, type: 'success', title: successMsg, description })
267
+ }
268
+ })
269
+ .catch(async (error) => {
270
+ result = ['reject', error]
271
+
272
+ if (data.error !== undefined) {
273
+ shouldDismiss = false
274
+ const errorMsg =
275
+ typeof data.error === 'function' ? await data.error(error) : data.error
276
+ const description =
277
+ typeof data.description === 'function'
278
+ ? await data.description(error)
279
+ : data.description
280
+
281
+ this.create({ id, type: 'error', title: errorMsg, description })
282
+ }
283
+ })
284
+ .finally(() => {
285
+ if (shouldDismiss) {
286
+ // toast is still in load state, dismiss it
287
+ this.dismiss(id)
288
+ id = undefined
289
+ }
290
+ data.finally?.()
291
+ })
292
+
293
+ // return a promise that can be unwrapped
294
+ const unwrap = () =>
295
+ new Promise<ToastData>((resolve, reject) =>
296
+ originalPromise
297
+ .then(() => (result[0] === 'reject' ? reject(result[1]) : resolve(result[1])))
298
+ .catch(reject)
299
+ )
300
+
301
+ if (typeof id !== 'string' && typeof id !== 'number') {
302
+ return { unwrap }
303
+ } else {
304
+ return Object.assign(id, { unwrap })
305
+ }
306
+ }
307
+
308
+ /**
309
+ * Show a custom JSX toast
310
+ */
311
+ custom = (jsx: (id: string | number) => React.ReactElement, data?: ExternalToast) => {
312
+ const id = data?.id ?? toastsCounter++
313
+ this.create({ jsx: jsx(id), ...data, id })
314
+ return id
315
+ }
316
+
317
+ /**
318
+ * Get all active (non-dismissed) toasts
319
+ */
320
+ getActiveToasts = () => {
321
+ return this.toasts.filter((toast) => !this.dismissedToasts.has(toast.id))
322
+ }
323
+
324
+ /**
325
+ * Get full toast history
326
+ */
327
+ getHistory = () => {
328
+ return this.toasts
329
+ }
330
+ }
331
+
332
+ function isHttpResponse(data: any): data is Response {
333
+ return (
334
+ data &&
335
+ typeof data === 'object' &&
336
+ 'ok' in data &&
337
+ typeof data.ok === 'boolean' &&
338
+ 'status' in data &&
339
+ typeof data.status === 'number'
340
+ )
341
+ }
342
+
343
+ // singleton instance
344
+ export const ToastState = new Observer()
345
+
346
+ // basic toast function
347
+ const toastFunction = (title: TitleT, data?: ExternalToast) => {
348
+ return ToastState.create({ ...data, title })
349
+ }
350
+
351
+ // getters
352
+ const getHistory = () => ToastState.getHistory()
353
+ const getToasts = () => ToastState.getActiveToasts()
354
+
355
+ /**
356
+ * Main toast API - call directly or use methods like toast.success()
357
+ *
358
+ * @example
359
+ * // basic usage
360
+ * toast('Hello world')
361
+ *
362
+ * // with type
363
+ * toast.success('Saved!')
364
+ * toast.error('Something went wrong')
365
+ *
366
+ * // with options
367
+ * toast('Hello', { duration: 5000 })
368
+ *
369
+ * // promise toast
370
+ * toast.promise(fetch('/api'), {
371
+ * loading: 'Loading...',
372
+ * success: 'Done!',
373
+ * error: 'Failed'
374
+ * })
375
+ *
376
+ * // custom JSX
377
+ * toast.custom((id) => <MyToast id={id} />)
378
+ *
379
+ * // dismiss
380
+ * const id = toast('Hello')
381
+ * toast.dismiss(id)
382
+ * toast.dismiss() // dismiss all
383
+ */
384
+ export const toast = Object.assign(toastFunction, {
385
+ success: ToastState.success,
386
+ error: ToastState.error,
387
+ warning: ToastState.warning,
388
+ info: ToastState.info,
389
+ loading: ToastState.loading,
390
+ promise: ToastState.promise,
391
+ custom: ToastState.custom,
392
+ dismiss: ToastState.dismiss,
393
+ message: ToastState.message,
394
+ getHistory,
395
+ getToasts,
396
+ })
397
+
398
+ export type { NativeToastRef }