zkit-ui 1.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 (267) hide show
  1. package/README.md +267 -0
  2. package/dist/ComponentLibProvider.d.ts +15 -0
  3. package/dist/ComponentLibProvider.d.ts.map +1 -0
  4. package/dist/ComponentLibProvider.js +18 -0
  5. package/dist/assets/animations/spinner.json +1 -0
  6. package/dist/assets/icons/check.svg +4 -0
  7. package/dist/assets/icons/toast/error.webp +0 -0
  8. package/dist/assets/icons/toast/success.webp +0 -0
  9. package/dist/assets/icons/toast/warning.webp +0 -0
  10. package/dist/config.d.ts +16 -0
  11. package/dist/config.d.ts.map +1 -0
  12. package/dist/config.js +42 -0
  13. package/dist/i18n/I18nProvider.d.ts +11 -0
  14. package/dist/i18n/I18nProvider.d.ts.map +1 -0
  15. package/dist/i18n/I18nProvider.js +84 -0
  16. package/dist/i18n/locale.d.ts +7 -0
  17. package/dist/i18n/locale.d.ts.map +1 -0
  18. package/dist/i18n/locale.js +87 -0
  19. package/dist/i18n/locales/de.d.ts +51 -0
  20. package/dist/i18n/locales/de.d.ts.map +1 -0
  21. package/dist/i18n/locales/de.js +62 -0
  22. package/dist/i18n/locales/en-US.d.ts +51 -0
  23. package/dist/i18n/locales/en-US.d.ts.map +1 -0
  24. package/dist/i18n/locales/en-US.js +62 -0
  25. package/dist/i18n/locales/index.d.ts +9 -0
  26. package/dist/i18n/locales/index.d.ts.map +1 -0
  27. package/dist/i18n/locales/index.js +23 -0
  28. package/dist/i18n/locales/ja.d.ts +51 -0
  29. package/dist/i18n/locales/ja.d.ts.map +1 -0
  30. package/dist/i18n/locales/ja.js +62 -0
  31. package/dist/i18n/locales/zh-CN.d.ts +51 -0
  32. package/dist/i18n/locales/zh-CN.d.ts.map +1 -0
  33. package/dist/i18n/locales/zh-CN.js +62 -0
  34. package/dist/i18n/locales/zh-TW.d.ts +51 -0
  35. package/dist/i18n/locales/zh-TW.d.ts.map +1 -0
  36. package/dist/i18n/locales/zh-TW.js +62 -0
  37. package/dist/i18n/types.d.ts +11 -0
  38. package/dist/i18n/types.d.ts.map +1 -0
  39. package/dist/i18n/types.js +2 -0
  40. package/dist/i18n/useI18n.d.ts +2 -0
  41. package/dist/i18n/useI18n.d.ts.map +1 -0
  42. package/dist/i18n/useI18n.js +41 -0
  43. package/dist/index.d.ts +57 -0
  44. package/dist/index.d.ts.map +1 -0
  45. package/dist/index.js +90 -0
  46. package/dist/services/ActionDialogService/ActionDialog.d.ts +4 -0
  47. package/dist/services/ActionDialogService/ActionDialog.d.ts.map +1 -0
  48. package/dist/services/ActionDialogService/ActionDialog.js +531 -0
  49. package/dist/services/ActionDialogService/index.d.ts +4 -0
  50. package/dist/services/ActionDialogService/index.d.ts.map +1 -0
  51. package/dist/services/ActionDialogService/index.js +8 -0
  52. package/dist/services/ActionDialogService/service.d.ts +7 -0
  53. package/dist/services/ActionDialogService/service.d.ts.map +1 -0
  54. package/dist/services/ActionDialogService/service.js +309 -0
  55. package/dist/services/ActionDialogService/shared.d.ts +17 -0
  56. package/dist/services/ActionDialogService/shared.d.ts.map +1 -0
  57. package/dist/services/ActionDialogService/shared.js +167 -0
  58. package/dist/services/ActionDialogService/types.d.ts +160 -0
  59. package/dist/services/ActionDialogService/types.d.ts.map +1 -0
  60. package/dist/services/ActionDialogService/types.js +2 -0
  61. package/dist/services/CardToastService/index.d.ts +115 -0
  62. package/dist/services/CardToastService/index.d.ts.map +1 -0
  63. package/dist/services/CardToastService/index.js +899 -0
  64. package/dist/services/ImagePreviewService/index.d.ts +179 -0
  65. package/dist/services/ImagePreviewService/index.d.ts.map +1 -0
  66. package/dist/services/ImagePreviewService/index.js +1383 -0
  67. package/dist/services/LoadingService/index.d.ts +146 -0
  68. package/dist/services/LoadingService/index.d.ts.map +1 -0
  69. package/dist/services/LoadingService/index.js +827 -0
  70. package/dist/services/PermissionPurposeDialogService/index.d.ts +99 -0
  71. package/dist/services/PermissionPurposeDialogService/index.d.ts.map +1 -0
  72. package/dist/services/PermissionPurposeDialogService/index.js +360 -0
  73. package/dist/services/PickerService/index.d.ts +128 -0
  74. package/dist/services/PickerService/index.d.ts.map +1 -0
  75. package/dist/services/PickerService/index.js +250 -0
  76. package/dist/theme/ThemeProvider.d.ts +15 -0
  77. package/dist/theme/ThemeProvider.d.ts.map +1 -0
  78. package/dist/theme/ThemeProvider.js +61 -0
  79. package/dist/theme/defaultTheme.d.ts +3 -0
  80. package/dist/theme/defaultTheme.d.ts.map +1 -0
  81. package/dist/theme/defaultTheme.js +28 -0
  82. package/dist/theme/mergeTheme.d.ts +3 -0
  83. package/dist/theme/mergeTheme.d.ts.map +1 -0
  84. package/dist/theme/mergeTheme.js +19 -0
  85. package/dist/theme/types.d.ts +17 -0
  86. package/dist/theme/types.d.ts.map +1 -0
  87. package/dist/theme/types.js +2 -0
  88. package/dist/theme/useTheme.d.ts +2 -0
  89. package/dist/theme/useTheme.d.ts.map +1 -0
  90. package/dist/theme/useTheme.js +46 -0
  91. package/dist/ui/Accordion/index.d.ts +99 -0
  92. package/dist/ui/Accordion/index.d.ts.map +1 -0
  93. package/dist/ui/Accordion/index.js +777 -0
  94. package/dist/ui/AddressCascader/index.d.ts +58 -0
  95. package/dist/ui/AddressCascader/index.d.ts.map +1 -0
  96. package/dist/ui/AddressCascader/index.js +710 -0
  97. package/dist/ui/BetweenTime/index.d.ts +82 -0
  98. package/dist/ui/BetweenTime/index.d.ts.map +1 -0
  99. package/dist/ui/BetweenTime/index.js +702 -0
  100. package/dist/ui/Button/index.d.ts +93 -0
  101. package/dist/ui/Button/index.d.ts.map +1 -0
  102. package/dist/ui/Button/index.js +832 -0
  103. package/dist/ui/Checkbox/index.d.ts +103 -0
  104. package/dist/ui/Checkbox/index.d.ts.map +1 -0
  105. package/dist/ui/Checkbox/index.js +694 -0
  106. package/dist/ui/DatePicker/index.d.ts +5 -0
  107. package/dist/ui/DatePicker/index.d.ts.map +1 -0
  108. package/dist/ui/DatePicker/index.js +157 -0
  109. package/dist/ui/DatePicker/types.d.ts +99 -0
  110. package/dist/ui/DatePicker/types.d.ts.map +1 -0
  111. package/dist/ui/DatePicker/types.js +2 -0
  112. package/dist/ui/DatePicker/utils.d.ts +32 -0
  113. package/dist/ui/DatePicker/utils.d.ts.map +1 -0
  114. package/dist/ui/DatePicker/utils.js +316 -0
  115. package/dist/ui/LinkedScroll/index.d.ts +78 -0
  116. package/dist/ui/LinkedScroll/index.d.ts.map +1 -0
  117. package/dist/ui/LinkedScroll/index.js +470 -0
  118. package/dist/ui/LoadingSpinner/index.d.ts +8 -0
  119. package/dist/ui/LoadingSpinner/index.d.ts.map +1 -0
  120. package/dist/ui/LoadingSpinner/index.js +122 -0
  121. package/dist/ui/Picker/actionBar.d.ts +14 -0
  122. package/dist/ui/Picker/actionBar.d.ts.map +1 -0
  123. package/dist/ui/Picker/actionBar.js +28 -0
  124. package/dist/ui/Picker/index.d.ts +5 -0
  125. package/dist/ui/Picker/index.d.ts.map +1 -0
  126. package/dist/ui/Picker/index.js +412 -0
  127. package/dist/ui/Picker/types.d.ts +80 -0
  128. package/dist/ui/Picker/types.d.ts.map +1 -0
  129. package/dist/ui/Picker/types.js +2 -0
  130. package/dist/ui/Picker/utils.d.ts +27 -0
  131. package/dist/ui/Picker/utils.d.ts.map +1 -0
  132. package/dist/ui/Picker/utils.js +182 -0
  133. package/dist/ui/Radio/index.d.ts +95 -0
  134. package/dist/ui/Radio/index.d.ts.map +1 -0
  135. package/dist/ui/Radio/index.js +633 -0
  136. package/dist/ui/Sheet/index.d.ts +12 -0
  137. package/dist/ui/Sheet/index.d.ts.map +1 -0
  138. package/dist/ui/Sheet/index.js +832 -0
  139. package/dist/ui/Sheet/nativeBottom.d.ts +52 -0
  140. package/dist/ui/Sheet/nativeBottom.d.ts.map +1 -0
  141. package/dist/ui/Sheet/nativeBottom.js +442 -0
  142. package/dist/ui/Sheet/nativeBottom.web.d.ts +18 -0
  143. package/dist/ui/Sheet/nativeBottom.web.d.ts.map +1 -0
  144. package/dist/ui/Sheet/nativeBottom.web.js +46 -0
  145. package/dist/ui/Sheet/slots.d.ts +5 -0
  146. package/dist/ui/Sheet/slots.d.ts.map +1 -0
  147. package/dist/ui/Sheet/slots.js +40 -0
  148. package/dist/ui/Sheet/types.d.ts +131 -0
  149. package/dist/ui/Sheet/types.d.ts.map +1 -0
  150. package/dist/ui/Sheet/types.js +2 -0
  151. package/dist/ui/SliderCaptcha/index.d.ts +57 -0
  152. package/dist/ui/SliderCaptcha/index.d.ts.map +1 -0
  153. package/dist/ui/SliderCaptcha/index.js +586 -0
  154. package/dist/ui/Switch/index.d.ts +72 -0
  155. package/dist/ui/Switch/index.d.ts.map +1 -0
  156. package/dist/ui/Switch/index.js +671 -0
  157. package/dist/ui/Text/index.d.ts +41 -0
  158. package/dist/ui/Text/index.d.ts.map +1 -0
  159. package/dist/ui/Text/index.js +325 -0
  160. package/dist/ui/TextInput/TextInputPrimitive.d.ts +8 -0
  161. package/dist/ui/TextInput/TextInputPrimitive.d.ts.map +1 -0
  162. package/dist/ui/TextInput/TextInputPrimitive.js +54 -0
  163. package/dist/ui/TextInput/index.d.ts +89 -0
  164. package/dist/ui/TextInput/index.d.ts.map +1 -0
  165. package/dist/ui/TextInput/index.js +545 -0
  166. package/dist/ui/WheelColumn/ZKitWheelPickerNativeComponent.d.ts +39 -0
  167. package/dist/ui/WheelColumn/ZKitWheelPickerNativeComponent.d.ts.map +1 -0
  168. package/dist/ui/WheelColumn/ZKitWheelPickerNativeComponent.js +60 -0
  169. package/dist/ui/WheelColumn/constants.d.ts +2 -0
  170. package/dist/ui/WheelColumn/constants.d.ts.map +1 -0
  171. package/dist/ui/WheelColumn/constants.js +4 -0
  172. package/dist/ui/WheelColumn/index.d.ts +59 -0
  173. package/dist/ui/WheelColumn/index.d.ts.map +1 -0
  174. package/dist/ui/WheelColumn/index.js +547 -0
  175. package/ios/ZKitWheelPicker.h +18 -0
  176. package/ios/ZKitWheelPicker.mm +230 -0
  177. package/ios/ZKitWheelPickerLabel.h +10 -0
  178. package/ios/ZKitWheelPickerLabel.mm +31 -0
  179. package/ios/ZKitWheelPickerManager.h +5 -0
  180. package/ios/ZKitWheelPickerManager.mm +53 -0
  181. package/package.json +75 -0
  182. package/react-native.config.js +10 -0
  183. package/src/ComponentLibProvider.tsx +56 -0
  184. package/src/assets/animations/spinner.json +1 -0
  185. package/src/assets/icons/check.svg +4 -0
  186. package/src/assets/icons/toast/error.webp +0 -0
  187. package/src/assets/icons/toast/success.webp +0 -0
  188. package/src/assets/icons/toast/warning.webp +0 -0
  189. package/src/config.ts +65 -0
  190. package/src/i18n/I18nProvider.tsx +69 -0
  191. package/src/i18n/locale.ts +108 -0
  192. package/src/i18n/locales/de.ts +67 -0
  193. package/src/i18n/locales/en-US.ts +67 -0
  194. package/src/i18n/locales/index.ts +19 -0
  195. package/src/i18n/locales/ja.ts +67 -0
  196. package/src/i18n/locales/zh-CN.ts +67 -0
  197. package/src/i18n/locales/zh-TW.ts +67 -0
  198. package/src/i18n/types.ts +21 -0
  199. package/src/i18n/useI18n.ts +7 -0
  200. package/src/index.ts +395 -0
  201. package/src/services/ActionDialogService/ActionDialog.tsx +756 -0
  202. package/src/services/ActionDialogService/README.md +146 -0
  203. package/src/services/ActionDialogService/index.tsx +39 -0
  204. package/src/services/ActionDialogService/service.tsx +355 -0
  205. package/src/services/ActionDialogService/shared.ts +204 -0
  206. package/src/services/ActionDialogService/types.ts +197 -0
  207. package/src/services/CardToastService/README.md +128 -0
  208. package/src/services/CardToastService/index.tsx +1284 -0
  209. package/src/services/ImagePreviewService/README.md +67 -0
  210. package/src/services/ImagePreviewService/index.tsx +2148 -0
  211. package/src/services/LoadingService/README.md +128 -0
  212. package/src/services/LoadingService/index.tsx +1113 -0
  213. package/src/services/PermissionPurposeDialogService/README.md +80 -0
  214. package/src/services/PermissionPurposeDialogService/index.tsx +449 -0
  215. package/src/services/PickerService/README.md +154 -0
  216. package/src/services/PickerService/index.tsx +410 -0
  217. package/src/theme/ThemeProvider.tsx +40 -0
  218. package/src/theme/defaultTheme.ts +30 -0
  219. package/src/theme/mergeTheme.ts +16 -0
  220. package/src/theme/types.ts +36 -0
  221. package/src/theme/useTheme.ts +10 -0
  222. package/src/types/svg.d.ts +9 -0
  223. package/src/types/vant-area-data.d.ts +19 -0
  224. package/src/ui/Accordion/README.md +67 -0
  225. package/src/ui/Accordion/index.tsx +1221 -0
  226. package/src/ui/AddressCascader/README.md +144 -0
  227. package/src/ui/AddressCascader/index.tsx +1084 -0
  228. package/src/ui/BetweenTime/README.md +490 -0
  229. package/src/ui/BetweenTime/index.tsx +1001 -0
  230. package/src/ui/Button/README.md +186 -0
  231. package/src/ui/Button/index.tsx +1356 -0
  232. package/src/ui/Checkbox/README.md +211 -0
  233. package/src/ui/Checkbox/index.tsx +1231 -0
  234. package/src/ui/DatePicker/README.md +117 -0
  235. package/src/ui/DatePicker/index.tsx +295 -0
  236. package/src/ui/DatePicker/types.ts +115 -0
  237. package/src/ui/DatePicker/utils.ts +405 -0
  238. package/src/ui/LinkedScroll/README.md +55 -0
  239. package/src/ui/LinkedScroll/index.tsx +809 -0
  240. package/src/ui/LoadingSpinner/index.tsx +104 -0
  241. package/src/ui/Picker/README.md +190 -0
  242. package/src/ui/Picker/actionBar.tsx +70 -0
  243. package/src/ui/Picker/index.tsx +694 -0
  244. package/src/ui/Picker/types.ts +94 -0
  245. package/src/ui/Picker/utils.ts +239 -0
  246. package/src/ui/Radio/README.md +207 -0
  247. package/src/ui/Radio/index.tsx +1117 -0
  248. package/src/ui/Sheet/README.md +82 -0
  249. package/src/ui/Sheet/index.tsx +1163 -0
  250. package/src/ui/Sheet/nativeBottom.tsx +683 -0
  251. package/src/ui/Sheet/nativeBottom.web.tsx +40 -0
  252. package/src/ui/Sheet/slots.tsx +75 -0
  253. package/src/ui/Sheet/types.ts +195 -0
  254. package/src/ui/SliderCaptcha/README.md +61 -0
  255. package/src/ui/SliderCaptcha/index.tsx +816 -0
  256. package/src/ui/Switch/README.md +143 -0
  257. package/src/ui/Switch/index.tsx +1092 -0
  258. package/src/ui/Text/README.md +157 -0
  259. package/src/ui/Text/index.tsx +450 -0
  260. package/src/ui/TextInput/README.md +197 -0
  261. package/src/ui/TextInput/TextInputPrimitive.tsx +44 -0
  262. package/src/ui/TextInput/index.tsx +942 -0
  263. package/src/ui/WheelColumn/README.md +104 -0
  264. package/src/ui/WheelColumn/ZKitWheelPickerNativeComponent.tsx +68 -0
  265. package/src/ui/WheelColumn/constants.ts +1 -0
  266. package/src/ui/WheelColumn/index.tsx +826 -0
  267. package/zkit-ui.podspec +19 -0
@@ -0,0 +1,1284 @@
1
+ /**
2
+ * @file CardToastService
3
+ * @description A service-driven toast viewport for lightweight global feedback.
4
+ */
5
+
6
+ import * as React from 'react';
7
+ import { Feather } from '@expo/vector-icons';
8
+ import { Image } from 'expo-image';
9
+ import {
10
+ Animated as RNAnimated,
11
+ Easing as RNEasing,
12
+ Platform,
13
+ Pressable,
14
+ StyleSheet,
15
+ View,
16
+ type StyleProp,
17
+ type ViewStyle,
18
+ } from 'react-native';
19
+ import Animated, {
20
+ Easing,
21
+ cancelAnimation,
22
+ interpolate,
23
+ useAnimatedStyle,
24
+ useSharedValue,
25
+ withSpring,
26
+ withTiming,
27
+ } from 'react-native-reanimated';
28
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
29
+ import { scheduleOnRN } from 'react-native-worklets';
30
+ import { wp } from 'zkit-tools';
31
+ import { useTheme } from '../../theme/useTheme';
32
+ import { Text } from '../../ui/Text';
33
+
34
+ // ============================================================================
35
+ // Public Types
36
+ // ============================================================================
37
+
38
+ export type ToastTone = 'success' | 'error' | 'warning' | 'info' | 'neutral';
39
+ export type ToastPlacement = 'top' | 'bottom';
40
+ export type ToastStrategy = 'replace' | 'queue' | 'stack';
41
+ export type ToastDismissReason =
42
+ | 'timeout'
43
+ | 'action'
44
+ | 'close-button'
45
+ | 'api'
46
+ | 'replace'
47
+ | 'overflow'
48
+ | 'provider-unmount';
49
+
50
+ export type ToastOpenChangeReason = 'show' | 'update' | ToastDismissReason;
51
+
52
+ export type ToastOpenChangeMeta = {
53
+ id: string;
54
+ open: boolean;
55
+ reason: ToastOpenChangeReason;
56
+ };
57
+
58
+ export type ToastActionContext = {
59
+ id: string;
60
+ dismiss: (reason?: ToastDismissReason) => void;
61
+ update: (patch: ToastUpdateOptions) => void;
62
+ };
63
+
64
+ export type ToastAction = {
65
+ label: React.ReactNode;
66
+ onPress?: (context: ToastActionContext) => void | boolean | Promise<void | boolean>;
67
+ closeOnPress?: boolean;
68
+ accessibilityLabel?: string;
69
+ testID?: string;
70
+ };
71
+
72
+ export type ToastIconRenderContext = {
73
+ toast: ToastItem;
74
+ tone: ToastTone;
75
+ color: string;
76
+ size: number;
77
+ };
78
+
79
+ export type ToastRenderContext = ToastActionContext & {
80
+ toast: ToastItem;
81
+ };
82
+
83
+ export type ToastOptions = {
84
+ id?: string;
85
+ tone?: ToastTone;
86
+ title?: React.ReactNode;
87
+ description?: React.ReactNode;
88
+ duration?: number;
89
+ placement?: ToastPlacement;
90
+ offset?: number;
91
+ strategy?: ToastStrategy;
92
+ maxVisible?: number;
93
+ action?: ToastAction;
94
+ closeButton?: boolean;
95
+ icon?: React.ReactNode | false | ((context: ToastIconRenderContext) => React.ReactNode);
96
+ render?: (context: ToastRenderContext) => React.ReactNode;
97
+ accessibilityLabel?: string;
98
+ accessibilityLiveRegion?: 'none' | 'polite' | 'assertive';
99
+ testID?: string;
100
+ style?: StyleProp<ViewStyle>;
101
+ onDismiss?: (reason: ToastDismissReason, id: string) => void;
102
+ onOpenChange?: (open: boolean, meta: ToastOpenChangeMeta) => void;
103
+ };
104
+
105
+ export type ToastShortcutOptions = Omit<ToastOptions, 'tone' | 'title'>;
106
+ export type ToastCustomOptions = Omit<ToastOptions, 'render'>;
107
+ export type ToastUpdateOptions = Partial<
108
+ Pick<
109
+ ToastOptions,
110
+ | 'tone'
111
+ | 'title'
112
+ | 'description'
113
+ | 'duration'
114
+ | 'placement'
115
+ | 'offset'
116
+ | 'action'
117
+ | 'closeButton'
118
+ | 'icon'
119
+ | 'render'
120
+ | 'accessibilityLabel'
121
+ | 'accessibilityLiveRegion'
122
+ | 'testID'
123
+ | 'style'
124
+ | 'onDismiss'
125
+ | 'onOpenChange'
126
+ >
127
+ >;
128
+
129
+ export type ToastDefaults = Partial<
130
+ Pick<ToastOptions, 'duration' | 'placement' | 'offset' | 'strategy' | 'maxVisible' | 'closeButton'>
131
+ > & {
132
+ accessibilityLiveRegion?: 'none' | 'polite' | 'assertive';
133
+ };
134
+
135
+ export type ToastHandle = {
136
+ readonly id: string;
137
+ dismiss: (reason?: ToastDismissReason) => void;
138
+ update: (patch: ToastUpdateOptions) => void;
139
+ };
140
+
141
+ export type ToastService = {
142
+ configure: (defaults?: ToastDefaults) => void;
143
+ getDefaults: () => Required<ToastDefaults>;
144
+ show: (options: ToastOptions) => ToastHandle;
145
+ custom: (render: NonNullable<ToastOptions['render']>, options?: ToastCustomOptions) => ToastHandle;
146
+ success: (title: React.ReactNode, options?: ToastShortcutOptions) => ToastHandle;
147
+ error: (title: React.ReactNode, options?: ToastShortcutOptions) => ToastHandle;
148
+ warning: (title: React.ReactNode, options?: ToastShortcutOptions) => ToastHandle;
149
+ info: (title: React.ReactNode, options?: ToastShortcutOptions) => ToastHandle;
150
+ neutral: (title: React.ReactNode, options?: ToastShortcutOptions) => ToastHandle;
151
+ update: (idOrHandle: string | ToastHandle, patch: ToastUpdateOptions) => boolean;
152
+ dismiss: (idOrHandle?: string | ToastHandle, reason?: ToastDismissReason) => void;
153
+ dismissAll: (reason?: ToastDismissReason) => void;
154
+ isActive: (idOrHandle: string | ToastHandle) => boolean;
155
+ };
156
+
157
+ export type ToastProviderProps = {
158
+ children: React.ReactNode;
159
+ defaults?: ToastDefaults;
160
+ };
161
+
162
+ export type ToastItem = {
163
+ id: string;
164
+ tone: ToastTone;
165
+ title?: React.ReactNode;
166
+ description?: React.ReactNode;
167
+ duration: number;
168
+ placement: ToastPlacement;
169
+ offset: number;
170
+ strategy: ToastStrategy;
171
+ maxVisible: number;
172
+ action?: ToastAction;
173
+ closeButton: boolean;
174
+ icon?: ToastOptions['icon'];
175
+ render?: ToastOptions['render'];
176
+ accessibilityLabel: string;
177
+ accessibilityLiveRegion: 'none' | 'polite' | 'assertive';
178
+ testID?: string;
179
+ style?: StyleProp<ViewStyle>;
180
+ open: boolean;
181
+ createdAt: number;
182
+ updatedAt: number;
183
+ onDismiss?: ToastOptions['onDismiss'];
184
+ onOpenChange?: ToastOptions['onOpenChange'];
185
+ };
186
+
187
+ type ToastSnapshot = {
188
+ items: ToastItem[];
189
+ };
190
+
191
+ type InternalToastItem = ToastItem & {
192
+ dismissedReason?: ToastDismissReason;
193
+ };
194
+
195
+ type ToastListener = () => void;
196
+
197
+ // ============================================================================
198
+ // Defaults
199
+ // ============================================================================
200
+
201
+ const DEFAULT_DURATION = 2400;
202
+ const DEFAULT_OFFSET = 35;
203
+ const DEFAULT_PLACEMENT: ToastPlacement = 'top';
204
+ const DEFAULT_STRATEGY: ToastStrategy = 'replace';
205
+ const DEFAULT_MAX_VISIBLE = 3;
206
+ const DEFAULT_CLOSE_BUTTON = false;
207
+ const DEFAULT_LIVE_REGION: Required<ToastDefaults>['accessibilityLiveRegion'] = 'polite';
208
+ const MAX_VISIBLE_TOASTS = 5;
209
+ const MAX_PENDING_TOASTS = 20;
210
+ const TOAST_HOST_Z_INDEX = 9200;
211
+ const DEFAULT_TOAST_ID_PREFIX = 'toast';
212
+
213
+ const EMPTY_SNAPSHOT: ToastSnapshot = { items: [] };
214
+
215
+ const TOAST_ICON_SUCCESS = require('../../assets/icons/toast/success.webp');
216
+ const TOAST_ICON_ERROR = require('../../assets/icons/toast/error.webp');
217
+ const TOAST_ICON_WARNING = require('../../assets/icons/toast/warning.webp');
218
+
219
+ const IOS_TOAST_ENTER_OFFSET = wp(22);
220
+ const IOS_TOAST_EXIT_OFFSET = wp(14);
221
+ const IOS_TOAST_CONTENT_OFFSET = wp(6);
222
+ const IOS_TOAST_SHADOW_RADIUS = wp(18);
223
+ const IOS_TOAST_ENTER_EASING = Easing.bezier(0.22, 1, 0.36, 1);
224
+ const IOS_TOAST_EXIT_EASING = Easing.bezier(0.4, 0, 1, 1);
225
+ const IOS_TOAST_ENTER_SPRING = {
226
+ damping: 18,
227
+ stiffness: 260,
228
+ mass: 0.78,
229
+ };
230
+ const IOS_TOAST_SCALE_SPRING = {
231
+ damping: 16,
232
+ stiffness: 280,
233
+ mass: 0.72,
234
+ };
235
+ const IOS_TOAST_SQUASH_SPRING = {
236
+ damping: 15,
237
+ stiffness: 240,
238
+ mass: 0.68,
239
+ };
240
+
241
+ const DEFAULTS: Required<ToastDefaults> = {
242
+ duration: DEFAULT_DURATION,
243
+ placement: DEFAULT_PLACEMENT,
244
+ offset: DEFAULT_OFFSET,
245
+ strategy: DEFAULT_STRATEGY,
246
+ maxVisible: DEFAULT_MAX_VISIBLE,
247
+ closeButton: DEFAULT_CLOSE_BUTTON,
248
+ accessibilityLiveRegion: DEFAULT_LIVE_REGION,
249
+ };
250
+
251
+ // ============================================================================
252
+ // Normalizers
253
+ // ============================================================================
254
+
255
+ function isPromiseLike<T>(value: unknown): value is PromiseLike<T> {
256
+ return Boolean(value && typeof (value as PromiseLike<T>).then === 'function');
257
+ }
258
+
259
+ function normalizeId(value: unknown) {
260
+ if (typeof value !== 'string') return undefined;
261
+ const id = value.trim();
262
+ return id || undefined;
263
+ }
264
+
265
+ function normalizeTone(value: unknown): ToastTone | undefined {
266
+ if (value === 'success' || value === 'error' || value === 'warning' || value === 'info' || value === 'neutral') {
267
+ return value;
268
+ }
269
+ return undefined;
270
+ }
271
+
272
+ function normalizePlacement(value: unknown): ToastPlacement | undefined {
273
+ if (value === 'top' || value === 'bottom') return value;
274
+ return undefined;
275
+ }
276
+
277
+ function normalizeStrategy(value: unknown): ToastStrategy | undefined {
278
+ if (value === 'replace' || value === 'queue' || value === 'stack') return value;
279
+ return undefined;
280
+ }
281
+
282
+ function normalizeDuration(value: unknown, fallback: number) {
283
+ if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
284
+ return Math.max(0, Math.round(value));
285
+ }
286
+
287
+ function normalizeOffset(value: unknown, fallback: number) {
288
+ if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
289
+ return Math.max(0, value);
290
+ }
291
+
292
+ function normalizeMaxVisible(value: unknown, fallback: number) {
293
+ if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
294
+ return Math.max(1, Math.min(MAX_VISIBLE_TOASTS, Math.round(value)));
295
+ }
296
+
297
+ function normalizeLiveRegion(
298
+ value: unknown,
299
+ fallback: Required<ToastDefaults>['accessibilityLiveRegion']
300
+ ): Required<ToastDefaults>['accessibilityLiveRegion'] {
301
+ if (value === 'none' || value === 'polite' || value === 'assertive') return value;
302
+ return fallback;
303
+ }
304
+
305
+ function normalizeNode(value: unknown): React.ReactNode | undefined {
306
+ if (value == null || value === false || value === true) return undefined;
307
+ if (value instanceof Error) return value.message || String(value);
308
+ return value as React.ReactNode;
309
+ }
310
+
311
+ function getTextFromNode(value: React.ReactNode): string {
312
+ if (value == null || typeof value === 'boolean') return '';
313
+ if (typeof value === 'string') return value.trim();
314
+ if (typeof value === 'number') return String(value);
315
+ if (Array.isArray(value)) return value.map(getTextFromNode).filter(Boolean).join(' ').trim();
316
+ if (React.isValidElement<{ children?: React.ReactNode }>(value)) return getTextFromNode(value.props.children);
317
+ return '';
318
+ }
319
+
320
+ function createAccessibilityLabel(title: React.ReactNode, description: React.ReactNode) {
321
+ const titleText = getTextFromNode(title);
322
+ const descriptionText = getTextFromNode(description);
323
+ if (titleText && descriptionText) return `${titleText},${descriptionText}`;
324
+ return titleText || descriptionText || 'Notification';
325
+ }
326
+
327
+ function getHandleId(idOrHandle: string | ToastHandle | undefined) {
328
+ if (!idOrHandle) return undefined;
329
+ if (typeof idOrHandle === 'string') return normalizeId(idOrHandle);
330
+ return normalizeId(idOrHandle.id);
331
+ }
332
+
333
+ function warnInvalidToast() {
334
+ if (typeof console === 'undefined') return;
335
+ console.warn('[toast] show() requires title, description, or render.');
336
+ }
337
+
338
+ // ============================================================================
339
+ // Store
340
+ // ============================================================================
341
+
342
+ class ToastStore {
343
+ private defaults: Required<ToastDefaults> = { ...DEFAULTS };
344
+ private snapshot: ToastSnapshot = EMPTY_SNAPSHOT;
345
+ private pending: InternalToastItem[] = [];
346
+ private listeners = new Set<ToastListener>();
347
+ private timers = new Map<string, ReturnType<typeof setTimeout>>();
348
+ private idSeed = 0;
349
+ private hostCount = 0;
350
+
351
+ configure(defaults?: ToastDefaults) {
352
+ if (!defaults) return;
353
+ this.defaults = {
354
+ duration: normalizeDuration(defaults.duration, this.defaults.duration),
355
+ placement: normalizePlacement(defaults.placement) ?? this.defaults.placement,
356
+ offset: normalizeOffset(defaults.offset, this.defaults.offset),
357
+ strategy: normalizeStrategy(defaults.strategy) ?? this.defaults.strategy,
358
+ maxVisible: normalizeMaxVisible(defaults.maxVisible, this.defaults.maxVisible),
359
+ closeButton: defaults.closeButton ?? this.defaults.closeButton,
360
+ accessibilityLiveRegion: normalizeLiveRegion(defaults.accessibilityLiveRegion, this.defaults.accessibilityLiveRegion),
361
+ };
362
+ }
363
+
364
+ getDefaults() {
365
+ return { ...this.defaults };
366
+ }
367
+
368
+ subscribe = (listener: ToastListener) => {
369
+ this.listeners.add(listener);
370
+ this.hostCount += 1;
371
+ this.scheduleVisibleToasts();
372
+
373
+ return () => {
374
+ this.listeners.delete(listener);
375
+ this.hostCount = Math.max(0, this.hostCount - 1);
376
+ if (this.hostCount === 0) this.clearAll('provider-unmount');
377
+ };
378
+ };
379
+
380
+ getSnapshot = () => this.snapshot;
381
+
382
+ show(options: ToastOptions) {
383
+ const item = this.resolveToast(options);
384
+ if (!item) return this.createHandle('');
385
+ this.enqueue(item);
386
+ return this.createHandle(item.id);
387
+ }
388
+
389
+ custom(render: NonNullable<ToastOptions['render']>, options: ToastCustomOptions = {}) {
390
+ return this.show({ ...options, render });
391
+ }
392
+
393
+ success(title: React.ReactNode, options?: ToastShortcutOptions) {
394
+ return this.showShortcut('success', title, options);
395
+ }
396
+
397
+ error(title: React.ReactNode, options?: ToastShortcutOptions) {
398
+ return this.showShortcut('error', title, options);
399
+ }
400
+
401
+ warning(title: React.ReactNode, options?: ToastShortcutOptions) {
402
+ return this.showShortcut('warning', title, options);
403
+ }
404
+
405
+ info(title: React.ReactNode, options?: ToastShortcutOptions) {
406
+ return this.showShortcut('info', title, options);
407
+ }
408
+
409
+ neutral(title: React.ReactNode, options?: ToastShortcutOptions) {
410
+ return this.showShortcut('neutral', title, options);
411
+ }
412
+
413
+ update(idOrHandle: string | ToastHandle, patch: ToastUpdateOptions) {
414
+ const id = getHandleId(idOrHandle);
415
+ if (!id) return false;
416
+
417
+ const pendingIndex = this.pending.findIndex((item) => item.id === id);
418
+ if (pendingIndex >= 0) {
419
+ this.pending[pendingIndex] = this.applyPatch(this.pending[pendingIndex], patch);
420
+ return true;
421
+ }
422
+
423
+ const index = this.snapshot.items.findIndex((item) => item.id === id);
424
+ if (index < 0) return false;
425
+
426
+ const nextItems = [...this.snapshot.items];
427
+ const nextItem = this.applyPatch(nextItems[index] as InternalToastItem, patch);
428
+ nextItems[index] = nextItem;
429
+ this.setItems(nextItems);
430
+
431
+ if (nextItem.open) this.schedule(nextItem);
432
+ this.callOpenChange(nextItem, true, 'update');
433
+
434
+ return true;
435
+ }
436
+
437
+ dismiss(idOrHandle?: string | ToastHandle, reason: ToastDismissReason = 'api') {
438
+ const id = getHandleId(idOrHandle) ?? this.getLatestOpenId();
439
+ if (!id) return;
440
+ this.dismissByIds([id], reason);
441
+ }
442
+
443
+ dismissAll(reason: ToastDismissReason = 'api') {
444
+ const openIds = this.snapshot.items.filter((item) => item.open).map((item) => item.id);
445
+ this.pending.forEach((item) => this.callDismiss(item, reason));
446
+ this.pending = [];
447
+ this.dismissByIds(openIds, reason);
448
+ }
449
+
450
+ isActive(idOrHandle: string | ToastHandle) {
451
+ const id = getHandleId(idOrHandle);
452
+ if (!id) return false;
453
+ return this.snapshot.items.some((item) => item.id === id && item.open) || this.pending.some((item) => item.id === id);
454
+ }
455
+
456
+ completeExit(id: string) {
457
+ if (!this.snapshot.items.some((item) => item.id === id && !item.open)) return;
458
+ this.setItems(this.snapshot.items.filter((item) => item.id !== id));
459
+ this.promoteQueuedToast();
460
+ }
461
+
462
+ private showShortcut(tone: ToastTone, title: React.ReactNode, options: ToastShortcutOptions = {}) {
463
+ return this.show({ ...options, title, tone });
464
+ }
465
+
466
+ private resolveToast(options: ToastOptions): InternalToastItem | null {
467
+ const title = normalizeNode(options.title);
468
+ const description = normalizeNode(options.description);
469
+
470
+ if (!title && !description && !options.render) {
471
+ warnInvalidToast();
472
+ return null;
473
+ }
474
+
475
+ const now = Date.now();
476
+ const tone = normalizeTone(options.tone) ?? 'info';
477
+ const duration = normalizeDuration(options.duration, this.defaults.duration);
478
+ const closeButton = (options.closeButton ?? this.defaults.closeButton) || duration <= 0;
479
+
480
+ return {
481
+ id: normalizeId(options.id) ?? this.createId(),
482
+ tone,
483
+ title,
484
+ description,
485
+ duration,
486
+ placement: normalizePlacement(options.placement) ?? this.defaults.placement,
487
+ offset: normalizeOffset(options.offset, this.defaults.offset),
488
+ strategy: normalizeStrategy(options.strategy) ?? this.defaults.strategy,
489
+ maxVisible: normalizeMaxVisible(options.maxVisible, this.defaults.maxVisible),
490
+ action: options.action,
491
+ closeButton,
492
+ icon: options.icon,
493
+ render: options.render,
494
+ accessibilityLabel: options.accessibilityLabel ?? createAccessibilityLabel(title, description),
495
+ accessibilityLiveRegion: normalizeLiveRegion(
496
+ options.accessibilityLiveRegion,
497
+ tone === 'error' ? 'assertive' : this.defaults.accessibilityLiveRegion
498
+ ),
499
+ testID: options.testID,
500
+ style: options.style,
501
+ open: true,
502
+ createdAt: now,
503
+ updatedAt: now,
504
+ onDismiss: options.onDismiss,
505
+ onOpenChange: options.onOpenChange,
506
+ };
507
+ }
508
+
509
+ private applyPatch(item: InternalToastItem, patch: ToastUpdateOptions): InternalToastItem {
510
+ const title = 'title' in patch ? normalizeNode(patch.title) : item.title;
511
+ const description = 'description' in patch ? normalizeNode(patch.description) : item.description;
512
+ const tone = normalizeTone(patch.tone) ?? item.tone;
513
+ const duration = 'duration' in patch ? normalizeDuration(patch.duration, item.duration) : item.duration;
514
+ const closeButton = 'closeButton' in patch ? patch.closeButton ?? item.closeButton : item.closeButton || duration <= 0;
515
+
516
+ return {
517
+ ...item,
518
+ tone,
519
+ title,
520
+ description,
521
+ duration,
522
+ placement: normalizePlacement(patch.placement) ?? item.placement,
523
+ offset: normalizeOffset(patch.offset, item.offset),
524
+ action: 'action' in patch ? patch.action : item.action,
525
+ closeButton,
526
+ icon: 'icon' in patch ? patch.icon : item.icon,
527
+ render: 'render' in patch ? patch.render : item.render,
528
+ accessibilityLabel: patch.accessibilityLabel ?? item.accessibilityLabel ?? createAccessibilityLabel(title, description),
529
+ accessibilityLiveRegion: normalizeLiveRegion(patch.accessibilityLiveRegion, item.accessibilityLiveRegion),
530
+ testID: patch.testID ?? item.testID,
531
+ style: 'style' in patch ? patch.style : item.style,
532
+ onDismiss: patch.onDismiss ?? item.onDismiss,
533
+ onOpenChange: patch.onOpenChange ?? item.onOpenChange,
534
+ updatedAt: Date.now(),
535
+ };
536
+ }
537
+
538
+ private enqueue(item: InternalToastItem) {
539
+ if (item.strategy === 'queue' && this.snapshot.items.length > 0) {
540
+ this.pending.push(item);
541
+ this.trimPendingQueue();
542
+ return;
543
+ }
544
+
545
+ if (item.strategy === 'replace') {
546
+ this.pending.forEach((pendingItem) => this.callDismiss(pendingItem, 'replace'));
547
+ this.pending = [];
548
+ this.snapshot.items.forEach((currentItem) => {
549
+ this.clearTimer(currentItem.id);
550
+ if (currentItem.open) {
551
+ this.callDismiss(currentItem as InternalToastItem, 'replace');
552
+ this.callOpenChange(currentItem, false, 'replace');
553
+ }
554
+ });
555
+ this.setItems([item]);
556
+ this.callOpenChange(item, true, 'show');
557
+ this.schedule(item);
558
+ return;
559
+ }
560
+
561
+ const openInPlacement = this.snapshot.items
562
+ .filter((toastItem) => toastItem.open && toastItem.placement === item.placement)
563
+ .sort((a, b) => a.createdAt - b.createdAt);
564
+
565
+ let nextItems = this.snapshot.items;
566
+ if (openInPlacement.length >= item.maxVisible) {
567
+ const overflowItem = openInPlacement[0];
568
+ nextItems = this.closeItems(nextItems, 'overflow', [overflowItem.id]);
569
+ }
570
+
571
+ this.setItems([...nextItems, item]);
572
+ this.callOpenChange(item, true, 'show');
573
+ this.schedule(item);
574
+ }
575
+
576
+ private closeItems(items: InternalToastItem[], reason: ToastDismissReason, ids?: string[]) {
577
+ const idSet = ids ? new Set(ids) : undefined;
578
+ return items.map((item) => {
579
+ if (!item.open || (idSet && !idSet.has(item.id))) return item;
580
+ this.clearTimer(item.id);
581
+ const nextItem: InternalToastItem = {
582
+ ...item,
583
+ open: false,
584
+ updatedAt: Date.now(),
585
+ dismissedReason: reason,
586
+ };
587
+ this.callDismiss(nextItem, reason);
588
+ this.callOpenChange(nextItem, false, reason);
589
+ return nextItem;
590
+ });
591
+ }
592
+
593
+ private dismissByIds(ids: string[], reason: ToastDismissReason) {
594
+ if (!ids.length) return;
595
+ const nextItems = this.closeItems(this.snapshot.items, reason, ids);
596
+ this.setItems(nextItems);
597
+ this.promoteQueuedToast();
598
+ }
599
+
600
+ private promoteQueuedToast() {
601
+ if (this.snapshot.items.length > 0) return;
602
+ const nextItem = this.pending.shift();
603
+ if (!nextItem) return;
604
+ this.setItems([...this.snapshot.items, nextItem]);
605
+ this.callOpenChange(nextItem, true, 'show');
606
+ this.schedule(nextItem);
607
+ }
608
+
609
+ private trimPendingQueue() {
610
+ while (this.pending.length > MAX_PENDING_TOASTS) {
611
+ const dropped = this.pending.shift();
612
+ if (dropped) this.callDismiss(dropped, 'overflow');
613
+ }
614
+ }
615
+
616
+ private scheduleVisibleToasts() {
617
+ this.snapshot.items.forEach((item) => {
618
+ if (item.open) this.schedule(item as InternalToastItem);
619
+ });
620
+ }
621
+
622
+ private schedule(item: InternalToastItem) {
623
+ this.clearTimer(item.id);
624
+ if (this.hostCount <= 0 || !item.open || item.duration <= 0) return;
625
+ this.timers.set(
626
+ item.id,
627
+ setTimeout(() => {
628
+ this.dismiss(item.id, 'timeout');
629
+ }, item.duration)
630
+ );
631
+ }
632
+
633
+ private clearTimer(id: string) {
634
+ const timer = this.timers.get(id);
635
+ if (!timer) return;
636
+ clearTimeout(timer);
637
+ this.timers.delete(id);
638
+ }
639
+
640
+ private clearAll(reason: ToastDismissReason) {
641
+ this.timers.forEach((timer) => clearTimeout(timer));
642
+ this.timers.clear();
643
+ [...this.snapshot.items, ...this.pending].forEach((item) => {
644
+ const internalItem = item as InternalToastItem;
645
+ if (internalItem.open || !internalItem.dismissedReason) this.callDismiss(internalItem, reason);
646
+ });
647
+ this.pending = [];
648
+ this.setItems([]);
649
+ }
650
+
651
+ private setItems(items: ToastItem[]) {
652
+ this.snapshot = items.length ? { items } : EMPTY_SNAPSHOT;
653
+ this.listeners.forEach((listener) => listener());
654
+ }
655
+
656
+ private getLatestOpenId() {
657
+ const openItems = this.snapshot.items.filter((item) => item.open);
658
+ return openItems.sort((a, b) => b.createdAt - a.createdAt)[0]?.id;
659
+ }
660
+
661
+ private createId() {
662
+ this.idSeed += 1;
663
+ return `${DEFAULT_TOAST_ID_PREFIX}_${Date.now()}_${this.idSeed}`;
664
+ }
665
+
666
+ private createHandle(id: string): ToastHandle {
667
+ return {
668
+ id,
669
+ dismiss: (reason = 'api') => this.dismiss(id, reason),
670
+ update: (patch) => {
671
+ this.update(id, patch);
672
+ },
673
+ };
674
+ }
675
+
676
+ private callDismiss(item: InternalToastItem, reason: ToastDismissReason) {
677
+ if (item.dismissedReason && item.dismissedReason !== reason) return;
678
+ try {
679
+ item.onDismiss?.(reason, item.id);
680
+ } catch (error) {
681
+ console.error('[toast] onDismiss failed', error);
682
+ }
683
+ }
684
+
685
+ private callOpenChange(item: ToastItem, open: boolean, reason: ToastOpenChangeReason) {
686
+ try {
687
+ item.onOpenChange?.(open, { id: item.id, open, reason });
688
+ } catch (error) {
689
+ console.error('[toast] onOpenChange failed', error);
690
+ }
691
+ }
692
+ }
693
+
694
+ const toastStore = new ToastStore();
695
+
696
+ export const toast: ToastService = toastStore;
697
+
698
+ // ============================================================================
699
+ // Provider
700
+ // ============================================================================
701
+
702
+ function useToastSnapshot() {
703
+ return React.useSyncExternalStore(toastStore.subscribe, toastStore.getSnapshot, toastStore.getSnapshot);
704
+ }
705
+
706
+ export function ToastProvider({ children, defaults }: ToastProviderProps) {
707
+ const snapshot = useToastSnapshot();
708
+ const insets = useSafeAreaInsets();
709
+
710
+ React.useEffect(() => {
711
+ toastStore.configure(defaults);
712
+ }, [defaults]);
713
+
714
+ const topItems = React.useMemo(
715
+ () =>
716
+ snapshot.items
717
+ .filter((item) => item.placement === 'top')
718
+ .sort((a, b) => b.createdAt - a.createdAt),
719
+ [snapshot.items]
720
+ );
721
+
722
+ const bottomItems = React.useMemo(
723
+ () =>
724
+ snapshot.items
725
+ .filter((item) => item.placement === 'bottom')
726
+ .sort((a, b) => b.createdAt - a.createdAt),
727
+ [snapshot.items]
728
+ );
729
+
730
+ const handleExited = React.useCallback((id: string) => {
731
+ toastStore.completeExit(id);
732
+ }, []);
733
+
734
+ return (
735
+ <>
736
+ {children}
737
+ <ToastViewport insets={insets} items={topItems} placement="top" onExited={handleExited} />
738
+ <ToastViewport insets={insets} items={bottomItems} placement="bottom" onExited={handleExited} />
739
+ </>
740
+ );
741
+ }
742
+
743
+ // ============================================================================
744
+ // UI
745
+ // ============================================================================
746
+
747
+ type ToastViewportProps = {
748
+ insets: { top: number; bottom: number };
749
+ items: ToastItem[];
750
+ placement: ToastPlacement;
751
+ onExited: (id: string) => void;
752
+ };
753
+
754
+ type ToastCardProps = {
755
+ item: ToastItem;
756
+ placement: ToastPlacement;
757
+ onExited: (id: string) => void;
758
+ };
759
+
760
+ type ToneConfig = {
761
+ bgColor: string;
762
+ borderColor: string;
763
+ accentColor: string;
764
+ iconSource: number;
765
+ };
766
+
767
+ function pickToneConfig(tone: ToastTone): ToneConfig {
768
+ if (tone === 'success') {
769
+ return {
770
+ bgColor: '#EEF7F2',
771
+ borderColor: '#CFE1D9',
772
+ accentColor: '#16A34A',
773
+ iconSource: TOAST_ICON_SUCCESS,
774
+ };
775
+ }
776
+ if (tone === 'error') {
777
+ return {
778
+ bgColor: '#FBEEF0',
779
+ borderColor: '#E7D1D8',
780
+ accentColor: '#DC2626',
781
+ iconSource: TOAST_ICON_ERROR,
782
+ };
783
+ }
784
+ return {
785
+ bgColor: '#FDF7ED',
786
+ borderColor: '#F7E0B3',
787
+ accentColor: '#D97706',
788
+ iconSource: TOAST_ICON_WARNING,
789
+ };
790
+ }
791
+
792
+ function getDisplayContent(item: ToastItem) {
793
+ if (item.title && item.description) {
794
+ return (
795
+ <>
796
+ {item.title} {item.description}
797
+ </>
798
+ );
799
+ }
800
+ return item.title || item.description;
801
+ }
802
+
803
+ function useToastActionContext(id: string) {
804
+ return React.useMemo<ToastActionContext>(
805
+ () => ({
806
+ id,
807
+ dismiss: (reason = 'api') => toastStore.dismiss(id, reason),
808
+ update: (patch) => {
809
+ toastStore.update(id, patch);
810
+ },
811
+ }),
812
+ [id]
813
+ );
814
+ }
815
+
816
+ function ToastViewport({ insets, items, placement, onExited }: ToastViewportProps) {
817
+ if (!items.length) return null;
818
+
819
+ const edgeOffset = items[0]?.offset ?? DEFAULT_OFFSET;
820
+ const edgeInset = placement === 'top' ? insets.top : insets.bottom;
821
+ const viewportStyle =
822
+ placement === 'top'
823
+ ? { top: edgeInset + wp(edgeOffset) }
824
+ : { bottom: edgeInset + wp(edgeOffset), flexDirection: 'column-reverse' as const };
825
+
826
+ return (
827
+ <View pointerEvents="box-none" style={[styles.viewport, viewportStyle]}>
828
+ {items.map((item, index) => {
829
+ const stackStyle =
830
+ index === 0
831
+ ? undefined
832
+ : placement === 'top'
833
+ ? { marginTop: wp(8) }
834
+ : { marginBottom: wp(8) };
835
+
836
+ return (
837
+ <View key={item.id} pointerEvents="box-none" style={stackStyle}>
838
+ <ToastCard item={item} placement={placement} onExited={onExited} />
839
+ </View>
840
+ );
841
+ })}
842
+ </View>
843
+ );
844
+ }
845
+
846
+ type ToastCardBodyProps = {
847
+ item: ToastItem;
848
+ actionContext: ToastActionContext;
849
+ toneConfig: ToneConfig;
850
+ style?: StyleProp<ViewStyle>;
851
+ children?: React.ReactNode;
852
+ };
853
+
854
+ function ToastCardBody({ item, actionContext, toneConfig, style, children }: ToastCardBodyProps) {
855
+ const theme = useTheme();
856
+ const message = getDisplayContent(item);
857
+
858
+ return (
859
+ <View style={[styles.toastContent, style, { backgroundColor: toneConfig.bgColor, borderColor: toneConfig.borderColor }]}>
860
+ {children}
861
+ <View style={styles.row}>
862
+ {renderToastIcon(item, toneConfig)}
863
+ <View style={styles.content}>
864
+ <Text style={[styles.message, { color: theme.colors.onSurface }]} numberOfLines={3}>
865
+ {message}
866
+ </Text>
867
+ {item.action ? (
868
+ <ToastActionButton action={item.action} context={actionContext} toneConfig={toneConfig} />
869
+ ) : null}
870
+ </View>
871
+ {item.closeButton ? (
872
+ <Pressable
873
+ accessibilityLabel="Dismiss notification"
874
+ accessibilityRole="button"
875
+ hitSlop={wp(8)}
876
+ onPress={() => actionContext.dismiss('close-button')}
877
+ style={({ pressed }) => [styles.closeButton, { opacity: pressed ? 0.62 : 1 }]}
878
+ >
879
+ <Feather name="x" size={wp(15)} color={theme.colors.muted} />
880
+ </Pressable>
881
+ ) : null}
882
+ </View>
883
+ </View>
884
+ );
885
+ }
886
+
887
+ function IOSToastCard({ item, placement, onExited }: ToastCardProps) {
888
+ const actionContext = useToastActionContext(item.id);
889
+ const toneConfig = pickToneConfig(item.tone);
890
+ const direction = placement === 'top' ? -1 : 1;
891
+ const enterOffset = direction * IOS_TOAST_ENTER_OFFSET;
892
+ const exitOffset = direction * IOS_TOAST_EXIT_OFFSET;
893
+ const opacity = useSharedValue(item.open ? 1 : 0);
894
+ const translateY = useSharedValue(item.open ? 0 : exitOffset);
895
+ const scale = useSharedValue(item.open ? 1 : 0.975);
896
+ const squash = useSharedValue(0);
897
+ const contentProgress = useSharedValue(item.open ? 1 : 0);
898
+ const sheenOpacity = useSharedValue(item.open ? 0.08 : 0);
899
+ const isInteractive = Boolean(item.action || item.closeButton || item.render);
900
+
901
+ React.useEffect(() => {
902
+ return () => {
903
+ cancelAnimation(opacity);
904
+ cancelAnimation(translateY);
905
+ cancelAnimation(scale);
906
+ cancelAnimation(squash);
907
+ cancelAnimation(contentProgress);
908
+ cancelAnimation(sheenOpacity);
909
+ };
910
+ }, [contentProgress, opacity, scale, sheenOpacity, squash, translateY]);
911
+
912
+ React.useEffect(() => {
913
+ cancelAnimation(opacity);
914
+ cancelAnimation(translateY);
915
+ cancelAnimation(scale);
916
+ cancelAnimation(squash);
917
+ cancelAnimation(contentProgress);
918
+ cancelAnimation(sheenOpacity);
919
+
920
+ if (item.open) {
921
+ opacity.value = 0;
922
+ translateY.value = enterOffset;
923
+ scale.value = 0.955;
924
+ squash.value = 1;
925
+ contentProgress.value = 0;
926
+ sheenOpacity.value = 0.14;
927
+
928
+ opacity.value = withTiming(1, { duration: 220, easing: IOS_TOAST_ENTER_EASING });
929
+ translateY.value = withSpring(0, IOS_TOAST_ENTER_SPRING);
930
+ scale.value = withSpring(1, IOS_TOAST_SCALE_SPRING);
931
+ squash.value = withSpring(0, IOS_TOAST_SQUASH_SPRING);
932
+ contentProgress.value = withTiming(1, { duration: 260, easing: IOS_TOAST_ENTER_EASING });
933
+ sheenOpacity.value = withTiming(0.06, { duration: 340, easing: IOS_TOAST_ENTER_EASING });
934
+ return;
935
+ }
936
+
937
+ opacity.value = withTiming(0, { duration: 160, easing: IOS_TOAST_EXIT_EASING }, (finished) => {
938
+ if (finished) scheduleOnRN(onExited, item.id);
939
+ });
940
+ translateY.value = withTiming(exitOffset, { duration: 180, easing: IOS_TOAST_EXIT_EASING });
941
+ scale.value = withTiming(0.975, { duration: 180, easing: IOS_TOAST_EXIT_EASING });
942
+ squash.value = withTiming(0.32, { duration: 120, easing: IOS_TOAST_EXIT_EASING });
943
+ contentProgress.value = withTiming(0, { duration: 120, easing: IOS_TOAST_EXIT_EASING });
944
+ sheenOpacity.value = withTiming(0, { duration: 110, easing: IOS_TOAST_EXIT_EASING });
945
+ }, [
946
+ contentProgress,
947
+ enterOffset,
948
+ exitOffset,
949
+ item.id,
950
+ item.open,
951
+ onExited,
952
+ opacity,
953
+ scale,
954
+ sheenOpacity,
955
+ squash,
956
+ translateY,
957
+ ]);
958
+
959
+ const animatedStyle = useAnimatedStyle(() => {
960
+ const scaleX = scale.value * interpolate(squash.value, [0, 1], [1, 1.028]);
961
+ const scaleY = scale.value * interpolate(squash.value, [0, 1], [1, 0.942]);
962
+ return {
963
+ opacity: opacity.value,
964
+ transform: [{ translateY: translateY.value }, { scaleX }, { scaleY }],
965
+ shadowOpacity: interpolate(opacity.value, [0, 1], [0, 0.16]),
966
+ shadowRadius: interpolate(opacity.value, [0, 1], [0, IOS_TOAST_SHADOW_RADIUS]),
967
+ };
968
+ });
969
+
970
+ const contentAnimatedStyle = useAnimatedStyle(() => ({
971
+ opacity: interpolate(contentProgress.value, [0, 1], [0.7, 1]),
972
+ transform: [
973
+ { translateY: interpolate(contentProgress.value, [0, 1], [direction * IOS_TOAST_CONTENT_OFFSET, 0]) },
974
+ { scale: interpolate(contentProgress.value, [0, 1], [0.985, 1]) },
975
+ ],
976
+ }));
977
+
978
+ const sheenAnimatedStyle = useAnimatedStyle(() => ({
979
+ opacity: sheenOpacity.value,
980
+ }));
981
+
982
+ if (item.render) {
983
+ return (
984
+ <Animated.View
985
+ pointerEvents={isInteractive ? 'auto' : 'none'}
986
+ accessible={!isInteractive}
987
+ accessibilityRole="alert"
988
+ accessibilityLabel={item.accessibilityLabel}
989
+ accessibilityLiveRegion={item.accessibilityLiveRegion}
990
+ shouldRasterizeIOS
991
+ style={[styles.toast, styles.toastIOS, animatedStyle, item.style]}
992
+ testID={item.testID}
993
+ >
994
+ <Animated.View style={contentAnimatedStyle}>
995
+ {item.render({ ...actionContext, toast: item })}
996
+ </Animated.View>
997
+ </Animated.View>
998
+ );
999
+ }
1000
+
1001
+ return (
1002
+ <Animated.View
1003
+ pointerEvents={isInteractive ? 'auto' : 'none'}
1004
+ accessible={!isInteractive}
1005
+ accessibilityRole="alert"
1006
+ accessibilityLabel={item.accessibilityLabel}
1007
+ accessibilityLiveRegion={item.accessibilityLiveRegion}
1008
+ shouldRasterizeIOS
1009
+ style={[
1010
+ styles.toast,
1011
+ styles.toastIOS,
1012
+ styles.toastSurface,
1013
+ { backgroundColor: toneConfig.bgColor },
1014
+ animatedStyle,
1015
+ item.style,
1016
+ ]}
1017
+ testID={item.testID}
1018
+ >
1019
+ <Animated.View style={contentAnimatedStyle}>
1020
+ <ToastCardBody
1021
+ item={item}
1022
+ actionContext={actionContext}
1023
+ toneConfig={toneConfig}
1024
+ style={styles.toastContentIOS}
1025
+ >
1026
+ <Animated.View pointerEvents="none" style={[styles.toastSheen, sheenAnimatedStyle]} />
1027
+ </ToastCardBody>
1028
+ </Animated.View>
1029
+ </Animated.View>
1030
+ );
1031
+ }
1032
+
1033
+ function DefaultToastCard({ item, placement, onExited }: ToastCardProps) {
1034
+ const actionContext = useToastActionContext(item.id);
1035
+ const toneConfig = pickToneConfig(item.tone);
1036
+ const direction = placement === 'top' ? -1 : 1;
1037
+ const anim = React.useRef(new RNAnimated.Value(0)).current;
1038
+ const lastVisibleRef = React.useRef<boolean>(false);
1039
+ const isInteractive = Boolean(item.action || item.closeButton || item.render);
1040
+
1041
+ React.useEffect(() => {
1042
+ return () => {
1043
+ anim.stopAnimation();
1044
+ };
1045
+ }, [anim]);
1046
+
1047
+ React.useEffect(() => {
1048
+ if (item.open && !lastVisibleRef.current) {
1049
+ anim.stopAnimation();
1050
+ anim.setValue(0);
1051
+ RNAnimated.timing(anim, {
1052
+ toValue: 1,
1053
+ duration: 160,
1054
+ easing: RNEasing.out(RNEasing.cubic),
1055
+ useNativeDriver: true,
1056
+ }).start();
1057
+ lastVisibleRef.current = true;
1058
+ return;
1059
+ }
1060
+
1061
+ if (!item.open && lastVisibleRef.current) {
1062
+ anim.stopAnimation();
1063
+ RNAnimated.timing(anim, {
1064
+ toValue: 0,
1065
+ duration: 140,
1066
+ easing: RNEasing.out(RNEasing.cubic),
1067
+ useNativeDriver: true,
1068
+ }).start(() => {
1069
+ lastVisibleRef.current = false;
1070
+ onExited(item.id);
1071
+ });
1072
+ }
1073
+ }, [anim, item.id, item.open, onExited]);
1074
+
1075
+ const animatedStyle = React.useMemo(
1076
+ () => ({
1077
+ opacity: anim,
1078
+ transform: [
1079
+ {
1080
+ translateY: anim.interpolate({
1081
+ inputRange: [0, 1],
1082
+ outputRange: [direction * wp(6), 0],
1083
+ }),
1084
+ },
1085
+ ],
1086
+ }),
1087
+ [anim, direction]
1088
+ );
1089
+
1090
+ if (item.render) {
1091
+ return (
1092
+ <RNAnimated.View
1093
+ renderToHardwareTextureAndroid={Platform.OS === 'android'}
1094
+ shouldRasterizeIOS={Platform.OS === 'ios'}
1095
+ pointerEvents={isInteractive ? 'auto' : 'none'}
1096
+ accessible={!isInteractive}
1097
+ accessibilityRole="alert"
1098
+ accessibilityLabel={item.accessibilityLabel}
1099
+ accessibilityLiveRegion={item.accessibilityLiveRegion}
1100
+ style={[styles.toast, animatedStyle, item.style]}
1101
+ testID={item.testID}
1102
+ >
1103
+ {item.render({ ...actionContext, toast: item })}
1104
+ </RNAnimated.View>
1105
+ );
1106
+ }
1107
+
1108
+ return (
1109
+ <RNAnimated.View
1110
+ renderToHardwareTextureAndroid={Platform.OS === 'android'}
1111
+ shouldRasterizeIOS={Platform.OS === 'ios'}
1112
+ pointerEvents={isInteractive ? 'auto' : 'none'}
1113
+ accessible={!isInteractive}
1114
+ accessibilityRole="alert"
1115
+ accessibilityLabel={item.accessibilityLabel}
1116
+ accessibilityLiveRegion={item.accessibilityLiveRegion}
1117
+ style={[
1118
+ styles.toast,
1119
+ styles.toastSurface,
1120
+ { backgroundColor: toneConfig.bgColor },
1121
+ animatedStyle,
1122
+ item.style,
1123
+ ]}
1124
+ testID={item.testID}
1125
+ >
1126
+ <ToastCardBody item={item} actionContext={actionContext} toneConfig={toneConfig} />
1127
+ </RNAnimated.View>
1128
+ );
1129
+ }
1130
+
1131
+ function ToastCard(props: ToastCardProps) {
1132
+ if (Platform.OS === 'ios') return <IOSToastCard {...props} />;
1133
+ return <DefaultToastCard {...props} />;
1134
+ }
1135
+
1136
+ function renderToastIcon(item: ToastItem, toneConfig: ToneConfig) {
1137
+ if (item.icon === false) return null;
1138
+
1139
+ const icon =
1140
+ typeof item.icon === 'function'
1141
+ ? item.icon({ toast: item, tone: item.tone, color: toneConfig.accentColor, size: wp(24) })
1142
+ : item.icon;
1143
+
1144
+ if (icon) return <View style={styles.iconSlot}>{icon}</View>;
1145
+ return <Image source={toneConfig.iconSource} style={styles.iconImage} contentFit="contain" />;
1146
+ }
1147
+
1148
+ type ToastActionButtonProps = {
1149
+ action: ToastAction;
1150
+ context: ToastActionContext;
1151
+ toneConfig: ToneConfig;
1152
+ };
1153
+
1154
+ function ToastActionButton({ action, context, toneConfig }: ToastActionButtonProps) {
1155
+ const handlePress = React.useCallback(() => {
1156
+ const closeOnPress = action.closeOnPress ?? true;
1157
+ const closeIfNeeded = (result: void | boolean) => {
1158
+ if (closeOnPress && result !== false) context.dismiss('action');
1159
+ };
1160
+
1161
+ try {
1162
+ const result = action.onPress?.(context);
1163
+ if (isPromiseLike<void | boolean>(result)) {
1164
+ result.then(closeIfNeeded).catch((error) => {
1165
+ console.error('[toast] action failed', error);
1166
+ if (closeOnPress) context.dismiss('action');
1167
+ });
1168
+ return;
1169
+ }
1170
+ closeIfNeeded(result);
1171
+ } catch (error) {
1172
+ console.error('[toast] action failed', error);
1173
+ if (closeOnPress) context.dismiss('action');
1174
+ }
1175
+ }, [action, context]);
1176
+
1177
+ return (
1178
+ <Pressable
1179
+ accessibilityLabel={action.accessibilityLabel}
1180
+ accessibilityRole="button"
1181
+ onPress={handlePress}
1182
+ style={({ pressed }) => [
1183
+ styles.actionButton,
1184
+ {
1185
+ borderColor: toneConfig.borderColor,
1186
+ opacity: pressed ? 0.72 : 1,
1187
+ },
1188
+ ]}
1189
+ testID={action.testID}
1190
+ >
1191
+ <Text color={toneConfig.accentColor} numberOfLines={1} size="xs" weight="semibold">
1192
+ {action.label}
1193
+ </Text>
1194
+ </Pressable>
1195
+ );
1196
+ }
1197
+
1198
+ const styles = StyleSheet.create({
1199
+ viewport: {
1200
+ position: 'absolute',
1201
+ left: wp(0),
1202
+ right: wp(0),
1203
+ alignItems: 'center',
1204
+ zIndex: TOAST_HOST_Z_INDEX,
1205
+ elevation: Platform.OS === 'android' ? TOAST_HOST_Z_INDEX : 0,
1206
+ },
1207
+ toast: {
1208
+ width: wp(320),
1209
+ maxWidth: '92%',
1210
+ alignSelf: 'center',
1211
+ shadowColor: '#000000',
1212
+ shadowOffset: { width: wp(0), height: wp(2) },
1213
+ shadowOpacity: 0.1,
1214
+ shadowRadius: wp(8),
1215
+ elevation: wp(3),
1216
+ },
1217
+ toastSurface: {
1218
+ borderRadius: wp(10),
1219
+ },
1220
+ toastIOS: {
1221
+ borderRadius: wp(10),
1222
+ shadowOffset: { width: wp(0), height: wp(10) },
1223
+ shadowOpacity: 0.16,
1224
+ shadowRadius: wp(20),
1225
+ },
1226
+ toastContent: {
1227
+ borderRadius: wp(10),
1228
+ padding: wp(12),
1229
+ borderWidth: wp(1),
1230
+ },
1231
+ toastContentIOS: {
1232
+ borderRadius: wp(10),
1233
+ overflow: 'hidden',
1234
+ },
1235
+ toastSheen: {
1236
+ ...StyleSheet.absoluteFillObject,
1237
+ backgroundColor: '#FFFFFF',
1238
+ borderRadius: wp(10),
1239
+ },
1240
+ row: {
1241
+ flexDirection: 'row',
1242
+ alignItems: 'center',
1243
+ columnGap: wp(10),
1244
+ },
1245
+ iconImage: {
1246
+ width: wp(24),
1247
+ height: wp(24),
1248
+ flexShrink: 0,
1249
+ },
1250
+ iconSlot: {
1251
+ width: wp(24),
1252
+ height: wp(24),
1253
+ alignItems: 'center',
1254
+ justifyContent: 'center',
1255
+ flexShrink: 0,
1256
+ },
1257
+ content: {
1258
+ flex: 1,
1259
+ },
1260
+ message: {
1261
+ fontSize: wp(13),
1262
+ lineHeight: wp(18),
1263
+ },
1264
+ actionButton: {
1265
+ alignSelf: 'flex-start',
1266
+ alignItems: 'center',
1267
+ justifyContent: 'center',
1268
+ minHeight: wp(28),
1269
+ marginTop: wp(8),
1270
+ paddingHorizontal: wp(10),
1271
+ borderRadius: wp(14),
1272
+ borderWidth: wp(1),
1273
+ backgroundColor: '#FFFFFF66',
1274
+ },
1275
+ closeButton: {
1276
+ width: wp(28),
1277
+ height: wp(28),
1278
+ borderRadius: wp(14),
1279
+ alignItems: 'center',
1280
+ justifyContent: 'center',
1281
+ flexShrink: 0,
1282
+ marginLeft: wp(2),
1283
+ },
1284
+ });