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,1113 @@
1
+ /**
2
+ * @file LoadingService
3
+ *
4
+ * A global, service-driven loading HUD. The service owns scheduling and
5
+ * collision rules; the Provider only renders the current snapshot.
6
+ */
7
+
8
+ import * as React from 'react';
9
+ import { Feather } from '@expo/vector-icons';
10
+ import { Platform, StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
11
+ import Animated, {
12
+ Easing,
13
+ ReduceMotion,
14
+ cancelAnimation,
15
+ interpolate,
16
+ useAnimatedStyle,
17
+ useSharedValue,
18
+ withSpring,
19
+ withTiming,
20
+ } from 'react-native-reanimated';
21
+ import { scheduleOnRN } from 'react-native-worklets';
22
+ import { wp } from 'zkit-tools';
23
+ import { useI18n } from '../../i18n/useI18n';
24
+ import { useTheme } from '../../theme/useTheme';
25
+ import { LoadingSpinner } from '../../ui/LoadingSpinner';
26
+ import { Text } from '../../ui/Text';
27
+
28
+ // ============================================================================
29
+ // Public Types
30
+ // ============================================================================
31
+
32
+ export type LoadingStatus = 'loading' | 'success' | 'error';
33
+ export type LoadingLiveRegion = 'none' | 'polite' | 'assertive';
34
+ export type LoadingDismissReason = 'api' | 'timeout' | 'replace' | 'provider-unmount';
35
+ export type LoadingOpenChangeReason = 'show' | 'update' | LoadingDismissReason;
36
+
37
+ export type LoadingLabels = {
38
+ loading: string;
39
+ success: string;
40
+ error: string;
41
+ };
42
+
43
+ export type LoadingColors = {
44
+ backdrop?: string;
45
+ card?: string;
46
+ foreground?: string;
47
+ shadow?: string;
48
+ };
49
+
50
+ export type LoadingOpenChangeMeta = {
51
+ id: string;
52
+ open: boolean;
53
+ reason: LoadingOpenChangeReason;
54
+ status: LoadingStatus;
55
+ };
56
+
57
+ export type LoadingIconRenderContext = {
58
+ item: LoadingItem;
59
+ status: LoadingStatus;
60
+ color: string;
61
+ size: number;
62
+ };
63
+
64
+ export type LoadingRenderContext = {
65
+ item: LoadingItem;
66
+ id: string;
67
+ status: LoadingStatus;
68
+ title?: React.ReactNode;
69
+ description?: React.ReactNode;
70
+ open: boolean;
71
+ hide: (reason?: LoadingDismissReason) => void;
72
+ update: (patch: LoadingUpdateOptions) => void;
73
+ defaultIcon: React.ReactNode;
74
+ };
75
+
76
+ export type LoadingShowOptions = {
77
+ id?: string;
78
+ status?: LoadingStatus;
79
+ title?: React.ReactNode;
80
+ description?: React.ReactNode;
81
+ blocking?: boolean;
82
+ duration?: number;
83
+ timeout?: number;
84
+ icon?: React.ReactNode | false | ((context: LoadingIconRenderContext) => React.ReactNode);
85
+ render?: (context: LoadingRenderContext) => React.ReactNode;
86
+ colors?: LoadingColors;
87
+ accessibilityLabel?: string;
88
+ accessibilityLiveRegion?: LoadingLiveRegion;
89
+ testID?: string;
90
+ style?: StyleProp<ViewStyle>;
91
+ onDismiss?: (reason: LoadingDismissReason, id: string) => void;
92
+ onOpenChange?: (open: boolean, meta: LoadingOpenChangeMeta) => void;
93
+ };
94
+
95
+ export type LoadingResultOptions = Omit<LoadingShowOptions, 'status' | 'timeout'>;
96
+ export type LoadingUpdateOptions = Partial<Omit<LoadingShowOptions, 'id'>>;
97
+ export type LoadingInput = React.ReactNode | LoadingShowOptions;
98
+ export type LoadingResultInput = React.ReactNode | LoadingResultOptions;
99
+ export type LoadingPromiseResultInput<T> =
100
+ | LoadingResultInput
101
+ | false
102
+ | ((result: T) => LoadingResultInput | false | undefined);
103
+ export type LoadingPromiseErrorInput<T> =
104
+ | LoadingResultInput
105
+ | false
106
+ | ((error: unknown, result?: T) => LoadingResultInput | false | undefined);
107
+
108
+ export type LoadingPromiseOptions<T = unknown> = {
109
+ loading?: LoadingInput | false;
110
+ success?: LoadingPromiseResultInput<T>;
111
+ error?: LoadingPromiseErrorInput<T>;
112
+ isSuccess?: (result: T) => boolean;
113
+ };
114
+
115
+ export type LoadingDefaults = {
116
+ blocking?: boolean;
117
+ loadingTimeout?: number;
118
+ successDuration?: number;
119
+ errorDuration?: number;
120
+ accessibilityLiveRegion?: LoadingLiveRegion;
121
+ labels?: Partial<LoadingLabels>;
122
+ colors?: LoadingColors;
123
+ };
124
+
125
+ export type LoadingProviderProps = {
126
+ children: React.ReactNode;
127
+ defaults?: LoadingDefaults;
128
+ };
129
+
130
+ export type LoadingItem = {
131
+ id: string;
132
+ status: LoadingStatus;
133
+ title?: React.ReactNode;
134
+ description?: React.ReactNode;
135
+ blocking: boolean;
136
+ duration: number;
137
+ timeout: number;
138
+ icon?: LoadingShowOptions['icon'];
139
+ render?: LoadingShowOptions['render'];
140
+ colors?: LoadingColors;
141
+ accessibilityLabel: string;
142
+ accessibilityLiveRegion: LoadingLiveRegion;
143
+ testID?: string;
144
+ style?: StyleProp<ViewStyle>;
145
+ open: boolean;
146
+ createdAt: number;
147
+ updatedAt: number;
148
+ onDismiss?: LoadingShowOptions['onDismiss'];
149
+ onOpenChange?: LoadingShowOptions['onOpenChange'];
150
+ };
151
+
152
+ export type LoadingSnapshot = {
153
+ item: LoadingItem | null;
154
+ };
155
+
156
+ export type LoadingHandle = {
157
+ readonly id: string;
158
+ hide: (reason?: LoadingDismissReason) => void;
159
+ update: (patch: LoadingUpdateOptions) => void;
160
+ success: (options?: LoadingResultInput) => void;
161
+ error: (options?: LoadingResultInput) => void;
162
+ isActive: () => boolean;
163
+ };
164
+
165
+ export type LoadingResolvedDefaults = {
166
+ blocking: boolean;
167
+ loadingTimeout: number;
168
+ successDuration: number;
169
+ errorDuration: number;
170
+ accessibilityLiveRegion: LoadingLiveRegion;
171
+ labels: LoadingLabels;
172
+ colors?: LoadingColors;
173
+ };
174
+
175
+ export type LoadingService = {
176
+ configure: (defaults?: LoadingDefaults) => void;
177
+ getDefaults: () => LoadingResolvedDefaults;
178
+ getSnapshot: () => LoadingSnapshot;
179
+ show: (options?: LoadingInput) => LoadingHandle;
180
+ success: (options?: LoadingResultInput) => LoadingHandle;
181
+ error: (options?: LoadingResultInput) => LoadingHandle;
182
+ promise: <T>(promise: Promise<T>, options?: LoadingPromiseOptions<T>) => Promise<T>;
183
+ update: (idOrHandle: string | LoadingHandle, patch: LoadingUpdateOptions) => boolean;
184
+ hide: (idOrHandle?: string | LoadingHandle, reason?: LoadingDismissReason) => void;
185
+ hideAll: (reason?: LoadingDismissReason) => void;
186
+ isActive: (idOrHandle: string | LoadingHandle) => boolean;
187
+ };
188
+
189
+ type InternalLoadingItem = LoadingItem & {
190
+ dismissedReason?: LoadingDismissReason;
191
+ };
192
+
193
+ type LoadingListener = () => void;
194
+
195
+ // ============================================================================
196
+ // Defaults
197
+ // ============================================================================
198
+
199
+ const DEFAULT_LABELS: LoadingLabels = {
200
+ loading: 'Loading',
201
+ success: 'Done',
202
+ error: 'Failed',
203
+ };
204
+
205
+ const DEFAULTS: LoadingResolvedDefaults = {
206
+ blocking: true,
207
+ loadingTimeout: 15000,
208
+ successDuration: 1200,
209
+ errorDuration: 1600,
210
+ accessibilityLiveRegion: 'polite',
211
+ labels: DEFAULT_LABELS,
212
+ colors: {
213
+ card: '#4d4d4d',
214
+ },
215
+ };
216
+
217
+ const EMPTY_SNAPSHOT: LoadingSnapshot = { item: null };
218
+ const DEFAULT_ID_PREFIX = 'loading';
219
+
220
+ const HOST_Z_INDEX = 9999;
221
+ const ENTER_DURATION = 170;
222
+ const EXIT_DURATION = 150;
223
+ const CONTENT_SWAP_DURATION = 130;
224
+ const HUD_MIN_WIDTH = wp(116);
225
+ const HUD_MAX_WIDTH = wp(260);
226
+ const HUD_MIN_HEIGHT = wp(116);
227
+ const HUD_RADIUS = wp(10);
228
+ const HUD_PADDING_HORIZONTAL = wp(16);
229
+ const HUD_PADDING_VERTICAL = wp(16);
230
+ const ICON_FRAME_SIZE = wp(42);
231
+ const LOADING_ICON_SIZE = wp(34);
232
+ const RESULT_ICON_SIZE = wp(36);
233
+ const TITLE_MARGIN_TOP = wp(8);
234
+ const DESCRIPTION_MARGIN_TOP = wp(4);
235
+ const TITLE_MAX_WIDTH = wp(214);
236
+ const IOS_SHADOW_OFFSET_Y = wp(9);
237
+ const IOS_SHADOW_RADIUS = wp(22);
238
+ const CONTENT_ENTER_OFFSET = wp(3);
239
+ const HOST_BACKDROP = 'transparent';
240
+
241
+ const IS_IOS = Platform.OS === 'ios';
242
+ const IS_ANDROID = Platform.OS === 'android';
243
+ const IS_WEB = Platform.OS === 'web';
244
+
245
+ const ENTER_EASING = Easing.bezier(0.22, 1, 0.36, 1);
246
+ const EXIT_EASING = Easing.bezier(0.4, 0, 1, 1);
247
+ const CONTENT_EASING = Easing.out(Easing.cubic);
248
+ const ENTER_SPRING = {
249
+ damping: 18,
250
+ stiffness: 260,
251
+ mass: 0.72,
252
+ };
253
+
254
+ const webShadowStyle: StyleProp<ViewStyle> = IS_WEB
255
+ ? ({
256
+ boxShadow: '0 18px 42px rgba(15, 23, 42, 0.24)',
257
+ } as ViewStyle)
258
+ : undefined;
259
+
260
+ // ============================================================================
261
+ // Normalizers
262
+ // ============================================================================
263
+
264
+ function isFiniteNumber(value: unknown): value is number {
265
+ return typeof value === 'number' && Number.isFinite(value);
266
+ }
267
+
268
+ function normalizeMs(value: unknown, fallback: number) {
269
+ if (!isFiniteNumber(value)) return fallback;
270
+ return Math.max(0, value);
271
+ }
272
+
273
+ function normalizeLiveRegion(value: unknown, fallback: LoadingLiveRegion): LoadingLiveRegion {
274
+ if (value === 'none' || value === 'polite' || value === 'assertive') return value;
275
+ return fallback;
276
+ }
277
+
278
+ function normalizeStatus(value: unknown, fallback: LoadingStatus): LoadingStatus {
279
+ if (value === 'loading' || value === 'success' || value === 'error') return value;
280
+ return fallback;
281
+ }
282
+
283
+ function normalizeId(value: unknown) {
284
+ if (typeof value !== 'string') return undefined;
285
+ const id = value.trim();
286
+ return id || undefined;
287
+ }
288
+
289
+ function normalizeNode(value: unknown): React.ReactNode | undefined {
290
+ if (value == null || value === false || value === true) return undefined;
291
+ if (value instanceof Error) return value.message || String(value);
292
+ return value as React.ReactNode;
293
+ }
294
+
295
+ function isOptionsInput(value: unknown): value is LoadingShowOptions {
296
+ if (!value || typeof value !== 'object' || Array.isArray(value) || React.isValidElement(value)) return false;
297
+ if (value instanceof Error) return false;
298
+ return (
299
+ 'id' in value ||
300
+ 'status' in value ||
301
+ 'title' in value ||
302
+ 'description' in value ||
303
+ 'blocking' in value ||
304
+ 'duration' in value ||
305
+ 'timeout' in value ||
306
+ 'icon' in value ||
307
+ 'render' in value ||
308
+ 'colors' in value ||
309
+ 'accessibilityLabel' in value ||
310
+ 'accessibilityLiveRegion' in value ||
311
+ 'testID' in value ||
312
+ 'style' in value ||
313
+ 'onDismiss' in value ||
314
+ 'onOpenChange' in value
315
+ );
316
+ }
317
+
318
+ function getTextFromNode(value: React.ReactNode): string {
319
+ if (value == null || typeof value === 'boolean') return '';
320
+ if (typeof value === 'string') return value.trim();
321
+ if (typeof value === 'number') return String(value);
322
+ if (Array.isArray(value)) return value.map(getTextFromNode).filter(Boolean).join(' ').trim();
323
+ if (React.isValidElement<{ children?: React.ReactNode }>(value)) return getTextFromNode(value.props.children);
324
+ return '';
325
+ }
326
+
327
+ function createAccessibilityLabel(status: LoadingStatus, title: React.ReactNode, description: React.ReactNode, labels: LoadingLabels) {
328
+ const titleText = getTextFromNode(title);
329
+ const descriptionText = getTextFromNode(description);
330
+ const statusText = labels[status];
331
+ const text = [titleText, descriptionText].filter(Boolean).join(',');
332
+ if (!text || text === statusText) return statusText;
333
+ return `${statusText},${text}`;
334
+ }
335
+
336
+ function getHandleId(idOrHandle: string | LoadingHandle | undefined) {
337
+ if (!idOrHandle) return undefined;
338
+ if (typeof idOrHandle === 'string') return normalizeId(idOrHandle);
339
+ return normalizeId(idOrHandle.id);
340
+ }
341
+
342
+ function getStatusDefaultDuration(status: LoadingStatus, defaults: LoadingResolvedDefaults) {
343
+ if (status === 'success') return defaults.successDuration;
344
+ if (status === 'error') return defaults.errorDuration;
345
+ return 0;
346
+ }
347
+
348
+ function getStatusDefaultBlocking(status: LoadingStatus, defaults: LoadingResolvedDefaults) {
349
+ return status === 'loading' ? defaults.blocking : false;
350
+ }
351
+
352
+ function mergeDefaults(base: LoadingResolvedDefaults, patch?: LoadingDefaults): LoadingResolvedDefaults {
353
+ if (!patch) return base;
354
+ return {
355
+ blocking: patch.blocking ?? base.blocking,
356
+ loadingTimeout: normalizeMs(patch.loadingTimeout, base.loadingTimeout),
357
+ successDuration: normalizeMs(patch.successDuration, base.successDuration),
358
+ errorDuration: normalizeMs(patch.errorDuration, base.errorDuration),
359
+ accessibilityLiveRegion: normalizeLiveRegion(patch.accessibilityLiveRegion, base.accessibilityLiveRegion),
360
+ labels: {
361
+ ...base.labels,
362
+ ...(patch.labels ?? {}),
363
+ },
364
+ colors: patch.colors ? { ...(base.colors ?? {}), ...patch.colors } : base.colors,
365
+ };
366
+ }
367
+
368
+ function warnMissingProvider() {
369
+ if (typeof __DEV__ !== 'undefined' && __DEV__) {
370
+ console.warn('[loading] LoadingProvider is not mounted.');
371
+ }
372
+ }
373
+
374
+ function createBusinessError<T>(message: string, response: T, cause?: unknown) {
375
+ const error = new Error(message || DEFAULT_LABELS.error) as Error & { response?: T; cause?: unknown };
376
+ error.response = response;
377
+ if (cause !== undefined) error.cause = cause;
378
+ return error;
379
+ }
380
+
381
+ function coerceInputToOptions(input: LoadingInput | LoadingResultInput | undefined): LoadingShowOptions {
382
+ if (isOptionsInput(input)) return input;
383
+ return { title: normalizeNode(input) };
384
+ }
385
+
386
+ function attachIdToInput(input: LoadingResultInput | undefined, id: string): LoadingResultInput {
387
+ if (isOptionsInput(input)) return { ...input, id };
388
+ if (input === undefined) return { id };
389
+ return { id, title: normalizeNode(input) };
390
+ }
391
+
392
+ function resolvePromiseResult<T>(
393
+ input: LoadingPromiseResultInput<T> | undefined,
394
+ result: T
395
+ ): LoadingResultInput | false | undefined {
396
+ if (typeof input !== 'function') return input;
397
+ try {
398
+ return input(result);
399
+ } catch (error) {
400
+ console.error('[loading] success resolver failed', error);
401
+ return undefined;
402
+ }
403
+ }
404
+
405
+ function resolvePromiseError<T>(
406
+ input: LoadingPromiseErrorInput<T> | undefined,
407
+ error: unknown,
408
+ result?: T
409
+ ): LoadingResultInput | false | undefined {
410
+ if (typeof input !== 'function') return input;
411
+ try {
412
+ return input(error, result);
413
+ } catch (resolverError) {
414
+ console.error('[loading] error resolver failed', resolverError);
415
+ return undefined;
416
+ }
417
+ }
418
+
419
+ // ============================================================================
420
+ // Store
421
+ // ============================================================================
422
+
423
+ class LoadingStore implements LoadingService {
424
+ private defaults: LoadingResolvedDefaults = DEFAULTS;
425
+ private snapshot: LoadingSnapshot = EMPTY_SNAPSHOT;
426
+ private listeners = new Set<LoadingListener>();
427
+ private autoHideTimer: ReturnType<typeof setTimeout> | null = null;
428
+ private timeoutTimer: ReturnType<typeof setTimeout> | null = null;
429
+ private idSeed = 0;
430
+ private hostCount = 0;
431
+
432
+ configure(defaults?: LoadingDefaults) {
433
+ this.defaults = mergeDefaults(DEFAULTS, defaults);
434
+ }
435
+
436
+ getDefaults() {
437
+ return {
438
+ ...this.defaults,
439
+ labels: { ...this.defaults.labels },
440
+ colors: this.defaults.colors ? { ...this.defaults.colors } : undefined,
441
+ };
442
+ }
443
+
444
+ getSnapshot = () => this.snapshot;
445
+
446
+ subscribe = (listener: LoadingListener) => {
447
+ this.listeners.add(listener);
448
+ this.hostCount += 1;
449
+ this.scheduleCurrent();
450
+
451
+ return () => {
452
+ this.listeners.delete(listener);
453
+ this.hostCount = Math.max(0, this.hostCount - 1);
454
+ if (this.hostCount === 0) this.clearAll('provider-unmount');
455
+ };
456
+ };
457
+
458
+ show(options?: LoadingInput) {
459
+ const item = this.resolveItem(options, 'loading');
460
+ return this.openItem(item);
461
+ }
462
+
463
+ success(options?: LoadingResultInput) {
464
+ const item = this.resolveItem(options, 'success');
465
+ return this.openItem(item);
466
+ }
467
+
468
+ error(options?: LoadingResultInput) {
469
+ const item = this.resolveItem(options, 'error');
470
+ return this.openItem(item);
471
+ }
472
+
473
+ async promise<T>(promise: Promise<T>, options: LoadingPromiseOptions<T> = {}): Promise<T> {
474
+ const handle = options.loading === false ? null : this.show(options.loading);
475
+
476
+ let result: T;
477
+ try {
478
+ result = await promise;
479
+ } catch (error) {
480
+ this.completePromiseFailure(handle, resolvePromiseError(options.error, error));
481
+ throw error;
482
+ }
483
+
484
+ let ok = true;
485
+ let successCheckError: unknown;
486
+ if (typeof options.isSuccess === 'function') {
487
+ try {
488
+ ok = Boolean(options.isSuccess(result));
489
+ } catch (error) {
490
+ ok = false;
491
+ successCheckError = error;
492
+ }
493
+ }
494
+
495
+ if (ok) {
496
+ this.completePromiseSuccess(handle, resolvePromiseResult(options.success, result));
497
+ return result;
498
+ }
499
+
500
+ const errorInput = resolvePromiseError(options.error, successCheckError, result);
501
+ this.completePromiseFailure(handle, errorInput);
502
+ const errorOptions = coerceInputToOptions(errorInput === false ? undefined : errorInput);
503
+ const message = getTextFromNode(errorOptions.title) || this.defaults.labels.error;
504
+ throw createBusinessError(message, result, successCheckError);
505
+ }
506
+
507
+ update(idOrHandle: string | LoadingHandle, patch: LoadingUpdateOptions) {
508
+ const id = getHandleId(idOrHandle);
509
+ if (!id) return false;
510
+ const current = this.snapshot.item as InternalLoadingItem | null;
511
+ if (!current || current.id !== id || !current.open) return false;
512
+
513
+ const nextItem = this.applyPatch(current, patch);
514
+ this.setItem(nextItem);
515
+ this.callOpenChange(nextItem, true, 'update');
516
+ this.scheduleCurrent();
517
+ return true;
518
+ }
519
+
520
+ hide(idOrHandle?: string | LoadingHandle, reason: LoadingDismissReason = 'api') {
521
+ const id = getHandleId(idOrHandle) ?? this.snapshot.item?.id;
522
+ if (!id) return;
523
+ this.hideById(id, reason);
524
+ }
525
+
526
+ hideAll(reason: LoadingDismissReason = 'api') {
527
+ const id = this.snapshot.item?.id;
528
+ if (!id) return;
529
+ this.hideById(id, reason);
530
+ }
531
+
532
+ isActive(idOrHandle: string | LoadingHandle) {
533
+ const id = getHandleId(idOrHandle);
534
+ return Boolean(id && this.snapshot.item?.id === id && this.snapshot.item.open);
535
+ }
536
+
537
+ completeExit(id: string) {
538
+ const current = this.snapshot.item;
539
+ if (!current || current.id !== id || current.open) return;
540
+ this.setItem(null);
541
+ }
542
+
543
+ private openItem(item: InternalLoadingItem): LoadingHandle {
544
+ if (this.hostCount <= 0) warnMissingProvider();
545
+
546
+ const current = this.snapshot.item as InternalLoadingItem | null;
547
+ if (current?.id === item.id) {
548
+ const nextItem = {
549
+ ...current,
550
+ ...item,
551
+ open: true,
552
+ createdAt: current.createdAt,
553
+ updatedAt: Date.now(),
554
+ dismissedReason: undefined,
555
+ };
556
+ this.setItem(nextItem);
557
+ this.callOpenChange(nextItem, true, current.open ? 'update' : 'show');
558
+ this.scheduleCurrent();
559
+ return this.createHandle(nextItem.id);
560
+ }
561
+
562
+ if (current) {
563
+ this.clearTimers();
564
+ if (current.open) {
565
+ this.callDismiss(current, 'replace');
566
+ this.callOpenChange(current, false, 'replace');
567
+ }
568
+ }
569
+
570
+ this.setItem(item);
571
+ this.callOpenChange(item, true, 'show');
572
+ this.scheduleCurrent();
573
+ return this.createHandle(item.id);
574
+ }
575
+
576
+ private hideById(id: string, reason: LoadingDismissReason) {
577
+ const current = this.snapshot.item as InternalLoadingItem | null;
578
+ if (!current || current.id !== id || !current.open) return;
579
+
580
+ this.clearTimers();
581
+ const nextItem: InternalLoadingItem = {
582
+ ...current,
583
+ open: false,
584
+ updatedAt: Date.now(),
585
+ dismissedReason: reason,
586
+ };
587
+ this.callDismiss(nextItem, reason);
588
+ this.callOpenChange(nextItem, false, reason);
589
+ this.setItem(nextItem);
590
+ }
591
+
592
+ private clearAll(reason: LoadingDismissReason) {
593
+ this.clearTimers();
594
+ const current = this.snapshot.item as InternalLoadingItem | null;
595
+ if (current) this.callDismiss(current, reason);
596
+ this.setItem(null);
597
+ }
598
+
599
+ private resolveItem(input: LoadingInput | LoadingResultInput | undefined, defaultStatus: LoadingStatus): InternalLoadingItem {
600
+ const raw = coerceInputToOptions(input);
601
+ const status = normalizeStatus(raw.status, defaultStatus);
602
+ const now = Date.now();
603
+ const hasCustomBody = typeof raw.render === 'function';
604
+ const title = normalizeNode(raw.title) ?? (hasCustomBody ? undefined : this.defaults.labels[status]);
605
+ const description = normalizeNode(raw.description);
606
+ const duration = normalizeMs(raw.duration, getStatusDefaultDuration(status, this.defaults));
607
+ const timeout = status === 'loading' ? normalizeMs(raw.timeout, this.defaults.loadingTimeout) : 0;
608
+
609
+ return {
610
+ id: normalizeId(raw.id) ?? this.createId(),
611
+ status,
612
+ title,
613
+ description,
614
+ blocking: raw.blocking ?? getStatusDefaultBlocking(status, this.defaults),
615
+ duration,
616
+ timeout,
617
+ icon: raw.icon,
618
+ render: raw.render,
619
+ colors: raw.colors ? { ...(this.defaults.colors ?? {}), ...raw.colors } : this.defaults.colors,
620
+ accessibilityLabel:
621
+ raw.accessibilityLabel ?? createAccessibilityLabel(status, title, description, this.defaults.labels),
622
+ accessibilityLiveRegion: normalizeLiveRegion(raw.accessibilityLiveRegion, this.defaults.accessibilityLiveRegion),
623
+ testID: raw.testID,
624
+ style: raw.style,
625
+ open: true,
626
+ createdAt: now,
627
+ updatedAt: now,
628
+ onDismiss: raw.onDismiss,
629
+ onOpenChange: raw.onOpenChange,
630
+ };
631
+ }
632
+
633
+ private applyPatch(item: InternalLoadingItem, patch: LoadingUpdateOptions): InternalLoadingItem {
634
+ const status = normalizeStatus(patch.status, item.status);
635
+ const title = 'title' in patch ? normalizeNode(patch.title) : item.title;
636
+ const description = 'description' in patch ? normalizeNode(patch.description) : item.description;
637
+ const duration =
638
+ 'duration' in patch ? normalizeMs(patch.duration, getStatusDefaultDuration(status, this.defaults)) : item.duration;
639
+ const timeout =
640
+ status === 'loading'
641
+ ? 'timeout' in patch
642
+ ? normalizeMs(patch.timeout, this.defaults.loadingTimeout)
643
+ : item.timeout
644
+ : 0;
645
+
646
+ return {
647
+ ...item,
648
+ status,
649
+ title,
650
+ description,
651
+ blocking: patch.blocking ?? item.blocking,
652
+ duration,
653
+ timeout,
654
+ icon: 'icon' in patch ? patch.icon : item.icon,
655
+ render: 'render' in patch ? patch.render : item.render,
656
+ colors: patch.colors ? { ...(item.colors ?? {}), ...patch.colors } : item.colors,
657
+ accessibilityLabel:
658
+ patch.accessibilityLabel ?? createAccessibilityLabel(status, title, description, this.defaults.labels),
659
+ accessibilityLiveRegion: normalizeLiveRegion(patch.accessibilityLiveRegion, item.accessibilityLiveRegion),
660
+ testID: patch.testID ?? item.testID,
661
+ style: 'style' in patch ? patch.style : item.style,
662
+ updatedAt: Date.now(),
663
+ onDismiss: patch.onDismiss ?? item.onDismiss,
664
+ onOpenChange: patch.onOpenChange ?? item.onOpenChange,
665
+ };
666
+ }
667
+
668
+ private scheduleCurrent() {
669
+ this.clearTimers();
670
+ const current = this.snapshot.item;
671
+ if (!current || !current.open || this.hostCount <= 0) return;
672
+
673
+ if (current.duration > 0) {
674
+ this.autoHideTimer = setTimeout(() => {
675
+ this.hideById(current.id, 'timeout');
676
+ }, current.duration);
677
+ }
678
+
679
+ if (current.status === 'loading' && current.timeout > 0) {
680
+ this.timeoutTimer = setTimeout(() => {
681
+ this.hideById(current.id, 'timeout');
682
+ }, current.timeout);
683
+ }
684
+ }
685
+
686
+ private clearTimers() {
687
+ if (this.autoHideTimer) {
688
+ clearTimeout(this.autoHideTimer);
689
+ this.autoHideTimer = null;
690
+ }
691
+ if (this.timeoutTimer) {
692
+ clearTimeout(this.timeoutTimer);
693
+ this.timeoutTimer = null;
694
+ }
695
+ }
696
+
697
+ private setItem(item: InternalLoadingItem | null) {
698
+ this.snapshot = item ? { item } : EMPTY_SNAPSHOT;
699
+ this.listeners.forEach((listener) => listener());
700
+ }
701
+
702
+ private completePromiseSuccess(handle: LoadingHandle | null, input: LoadingResultInput | false | undefined) {
703
+ if (input === false) {
704
+ handle?.hide();
705
+ return;
706
+ }
707
+ if (handle && !this.isActive(handle)) return;
708
+ this.success(handle ? attachIdToInput(input, handle.id) : input);
709
+ }
710
+
711
+ private completePromiseFailure(handle: LoadingHandle | null, input: LoadingResultInput | false | undefined) {
712
+ if (input === false) {
713
+ handle?.hide();
714
+ return;
715
+ }
716
+ if (handle && !this.isActive(handle)) return;
717
+ this.error(handle ? attachIdToInput(input, handle.id) : input);
718
+ }
719
+
720
+ private createId() {
721
+ this.idSeed += 1;
722
+ return `${DEFAULT_ID_PREFIX}_${Date.now()}_${this.idSeed}`;
723
+ }
724
+
725
+ private createHandle(id: string): LoadingHandle {
726
+ return {
727
+ id,
728
+ hide: (reason = 'api') => this.hide(id, reason),
729
+ update: (patch) => {
730
+ this.update(id, patch);
731
+ },
732
+ success: (options) => {
733
+ this.success(attachIdToInput(options, id));
734
+ },
735
+ error: (options) => {
736
+ this.error(attachIdToInput(options, id));
737
+ },
738
+ isActive: () => this.isActive(id),
739
+ };
740
+ }
741
+
742
+ private callDismiss(item: InternalLoadingItem, reason: LoadingDismissReason) {
743
+ if (item.dismissedReason && item.dismissedReason !== reason) return;
744
+ try {
745
+ item.onDismiss?.(reason, item.id);
746
+ } catch (error) {
747
+ console.error('[loading] onDismiss failed', error);
748
+ }
749
+ }
750
+
751
+ private callOpenChange(item: LoadingItem, open: boolean, reason: LoadingOpenChangeReason) {
752
+ try {
753
+ item.onOpenChange?.(open, { id: item.id, open, reason, status: item.status });
754
+ } catch (error) {
755
+ console.error('[loading] onOpenChange failed', error);
756
+ }
757
+ }
758
+ }
759
+
760
+ const loadingStore = new LoadingStore();
761
+
762
+ export const loading: LoadingService = loadingStore;
763
+
764
+ // ============================================================================
765
+ // Provider
766
+ // ============================================================================
767
+
768
+ function useLoadingSnapshot() {
769
+ return React.useSyncExternalStore(loadingStore.subscribe, loadingStore.getSnapshot, loadingStore.getSnapshot);
770
+ }
771
+
772
+ export function LoadingProvider({ children, defaults }: LoadingProviderProps) {
773
+ const snapshot = useLoadingSnapshot();
774
+ const { t } = useI18n();
775
+
776
+ const providerDefaults = React.useMemo<LoadingDefaults>(
777
+ () => ({
778
+ ...defaults,
779
+ labels: {
780
+ loading: t('loading.loading'),
781
+ success: t('loading.success'),
782
+ error: t('loading.error'),
783
+ ...(defaults?.labels ?? {}),
784
+ },
785
+ }),
786
+ [defaults, t]
787
+ );
788
+
789
+ React.useEffect(() => {
790
+ loadingStore.configure(providerDefaults);
791
+ }, [providerDefaults]);
792
+
793
+ const handleExited = React.useCallback((id: string) => {
794
+ loadingStore.completeExit(id);
795
+ }, []);
796
+
797
+ return (
798
+ <>
799
+ {children}
800
+ <LoadingHost item={snapshot.item} onExited={handleExited} />
801
+ </>
802
+ );
803
+ }
804
+
805
+ // ============================================================================
806
+ // UI
807
+ // ============================================================================
808
+
809
+ type LoadingHostProps = {
810
+ item: LoadingItem | null;
811
+ onExited: (id: string) => void;
812
+ };
813
+
814
+ type LoadingCardProps = {
815
+ item: LoadingItem;
816
+ onExited: (id: string) => void;
817
+ };
818
+
819
+ type ResolvedColors = Required<Pick<LoadingColors, 'backdrop' | 'card' | 'foreground' | 'shadow'>>;
820
+
821
+ function LoadingHost({ item, onExited }: LoadingHostProps) {
822
+ if (!item) return null;
823
+ const pointerEvents = item.open ? (item.blocking ? 'auto' : 'none') : 'none';
824
+
825
+ return (
826
+ <View
827
+ pointerEvents={pointerEvents}
828
+ style={styles.host}
829
+ accessibilityElementsHidden={!item.open}
830
+ importantForAccessibility={item.open ? 'yes' : 'no-hide-descendants'}
831
+ collapsable={false}
832
+ >
833
+ <LoadingCard item={item} onExited={onExited} />
834
+ </View>
835
+ );
836
+ }
837
+
838
+ function resolveColors(item: LoadingItem, themeColors: ReturnType<typeof useTheme>['colors']): ResolvedColors {
839
+ return {
840
+ backdrop: item.colors?.backdrop ?? (item.blocking ? HOST_BACKDROP : 'transparent'),
841
+ card: item.colors?.card ?? themeColors.onSurface,
842
+ foreground: item.colors?.foreground ?? themeColors.surface,
843
+ shadow: item.colors?.shadow ?? '#000000',
844
+ };
845
+ }
846
+
847
+ function LoadingCard({ item, onExited }: LoadingCardProps) {
848
+ const theme = useTheme();
849
+ const colors = resolveColors(item, theme.colors);
850
+ const opacity = useSharedValue(item.open ? 1 : 0);
851
+ const scale = useSharedValue(item.open ? 1 : 0.965);
852
+ const translateY = useSharedValue(item.open ? 0 : CONTENT_ENTER_OFFSET);
853
+ const contentProgress = useSharedValue(item.open ? 1 : 0);
854
+ const previousIdRef = React.useRef(item.id);
855
+
856
+ React.useEffect(() => {
857
+ return () => {
858
+ cancelAnimation(opacity);
859
+ cancelAnimation(scale);
860
+ cancelAnimation(translateY);
861
+ cancelAnimation(contentProgress);
862
+ };
863
+ }, [contentProgress, opacity, scale, translateY]);
864
+
865
+ React.useEffect(() => {
866
+ const isNewItem = previousIdRef.current !== item.id;
867
+ previousIdRef.current = item.id;
868
+
869
+ cancelAnimation(opacity);
870
+ cancelAnimation(scale);
871
+ cancelAnimation(translateY);
872
+ cancelAnimation(contentProgress);
873
+
874
+ if (item.open) {
875
+ if (isNewItem || opacity.value <= 0.02) {
876
+ opacity.value = 0;
877
+ scale.value = 0.965;
878
+ translateY.value = CONTENT_ENTER_OFFSET;
879
+ contentProgress.value = 0;
880
+ }
881
+
882
+ opacity.value = withTiming(1, {
883
+ duration: ENTER_DURATION,
884
+ easing: ENTER_EASING,
885
+ reduceMotion: ReduceMotion.System,
886
+ });
887
+ scale.value = withSpring(1, ENTER_SPRING);
888
+ translateY.value = withSpring(0, ENTER_SPRING);
889
+ contentProgress.value = withTiming(1, {
890
+ duration: CONTENT_SWAP_DURATION,
891
+ easing: CONTENT_EASING,
892
+ reduceMotion: ReduceMotion.System,
893
+ });
894
+ return;
895
+ }
896
+
897
+ opacity.value = withTiming(
898
+ 0,
899
+ {
900
+ duration: EXIT_DURATION,
901
+ easing: EXIT_EASING,
902
+ reduceMotion: ReduceMotion.System,
903
+ },
904
+ (finished) => {
905
+ if (finished) scheduleOnRN(onExited, item.id);
906
+ }
907
+ );
908
+ scale.value = withTiming(0.965, {
909
+ duration: EXIT_DURATION,
910
+ easing: EXIT_EASING,
911
+ reduceMotion: ReduceMotion.System,
912
+ });
913
+ translateY.value = withTiming(CONTENT_ENTER_OFFSET, {
914
+ duration: EXIT_DURATION,
915
+ easing: EXIT_EASING,
916
+ reduceMotion: ReduceMotion.System,
917
+ });
918
+ contentProgress.value = withTiming(0, {
919
+ duration: Math.min(EXIT_DURATION, CONTENT_SWAP_DURATION),
920
+ easing: EXIT_EASING,
921
+ reduceMotion: ReduceMotion.System,
922
+ });
923
+ }, [contentProgress, item.id, item.open, onExited, opacity, scale, translateY]);
924
+
925
+ const backdropStyle = useAnimatedStyle(() => ({
926
+ opacity: opacity.value,
927
+ }));
928
+
929
+ const frameStyle = useAnimatedStyle(() => {
930
+ const progress = opacity.value;
931
+ return {
932
+ opacity: progress,
933
+ transform: [{ translateY: translateY.value }, { scale: scale.value }],
934
+ ...(IS_IOS
935
+ ? {
936
+ shadowOpacity: interpolate(progress, [0, 1], [0, 0.22]),
937
+ shadowRadius: interpolate(progress, [0, 1], [0, IOS_SHADOW_RADIUS]),
938
+ }
939
+ : null),
940
+ };
941
+ });
942
+
943
+ const contentStyle = useAnimatedStyle(() => ({
944
+ opacity: interpolate(contentProgress.value, [0, 1], [0.72, 1]),
945
+ transform: [{ translateY: interpolate(contentProgress.value, [0, 1], [CONTENT_ENTER_OFFSET, 0]) }],
946
+ }));
947
+
948
+ return (
949
+ <>
950
+ <Animated.View
951
+ pointerEvents="none"
952
+ style={[styles.backdrop, { backgroundColor: colors.backdrop }, backdropStyle]}
953
+ />
954
+ <Animated.View
955
+ pointerEvents={item.open && item.blocking ? 'auto' : 'none'}
956
+ renderToHardwareTextureAndroid={IS_ANDROID && item.open}
957
+ style={[
958
+ styles.frame,
959
+ webShadowStyle,
960
+ IS_IOS ? { shadowColor: colors.shadow } : undefined,
961
+ frameStyle,
962
+ ]}
963
+ collapsable={false}
964
+ >
965
+ <View style={[styles.card, item.style, { backgroundColor: colors.card }]} testID={item.testID}>
966
+ <Animated.View
967
+ accessible={item.open}
968
+ accessibilityLabel={item.accessibilityLabel}
969
+ accessibilityLiveRegion={item.accessibilityLiveRegion}
970
+ accessibilityViewIsModal={item.blocking}
971
+ style={[styles.content, contentStyle]}
972
+ >
973
+ {renderLoadingContent(item, colors)}
974
+ </Animated.View>
975
+ </View>
976
+ </Animated.View>
977
+ </>
978
+ );
979
+ }
980
+
981
+ function renderLoadingContent(item: LoadingItem, colors: ResolvedColors) {
982
+ const defaultIcon = renderDefaultIcon(item, colors);
983
+ const context: LoadingRenderContext = {
984
+ item,
985
+ id: item.id,
986
+ status: item.status,
987
+ title: item.title,
988
+ description: item.description,
989
+ open: item.open,
990
+ hide: (reason = 'api') => loadingStore.hide(item.id, reason),
991
+ update: (patch) => {
992
+ loadingStore.update(item.id, patch);
993
+ },
994
+ defaultIcon,
995
+ };
996
+
997
+ if (item.render) {
998
+ try {
999
+ return item.render(context) ?? null;
1000
+ } catch (error) {
1001
+ console.error('[loading] render failed', error);
1002
+ }
1003
+ }
1004
+
1005
+ return (
1006
+ <>
1007
+ {defaultIcon}
1008
+ {item.title ? (
1009
+ <Text
1010
+ align="center"
1011
+ numberOfLines={2}
1012
+ size="sm"
1013
+ weight="semibold"
1014
+ style={[styles.title, { color: colors.foreground }]}
1015
+ >
1016
+ {item.title}
1017
+ </Text>
1018
+ ) : null}
1019
+ {item.description ? (
1020
+ <Text
1021
+ align="center"
1022
+ numberOfLines={3}
1023
+ size="xs"
1024
+ weight="regular"
1025
+ style={[styles.description, { color: colors.foreground }]}
1026
+ >
1027
+ {item.description}
1028
+ </Text>
1029
+ ) : null}
1030
+ </>
1031
+ );
1032
+ }
1033
+
1034
+ function renderDefaultIcon(item: LoadingItem, colors: ResolvedColors) {
1035
+ const color = colors.foreground;
1036
+ const size = item.status === 'loading' ? LOADING_ICON_SIZE : RESULT_ICON_SIZE;
1037
+ const iconContext: LoadingIconRenderContext = {
1038
+ item,
1039
+ status: item.status,
1040
+ color,
1041
+ size,
1042
+ };
1043
+
1044
+ if (item.icon === false) return null;
1045
+ if (typeof item.icon === 'function') return item.icon(iconContext);
1046
+ if (item.icon) return item.icon;
1047
+
1048
+ return (
1049
+ <View style={styles.iconFrame}>
1050
+ {item.status === 'loading' ? (
1051
+ <LoadingSpinner size={LOADING_ICON_SIZE} color={colors.foreground} speed={1.2} animating={item.open} />
1052
+ ) : (
1053
+ <Feather name={item.status === 'success' ? 'check' : 'x'} size={RESULT_ICON_SIZE} color={color} />
1054
+ )}
1055
+ </View>
1056
+ );
1057
+ }
1058
+
1059
+ // ============================================================================
1060
+ // Styles
1061
+ // ============================================================================
1062
+
1063
+ const styles = StyleSheet.create({
1064
+ host: {
1065
+ ...StyleSheet.absoluteFillObject,
1066
+ alignItems: 'center',
1067
+ justifyContent: 'center',
1068
+ zIndex: HOST_Z_INDEX,
1069
+ },
1070
+ backdrop: {
1071
+ ...StyleSheet.absoluteFillObject,
1072
+ },
1073
+ frame: {
1074
+ alignSelf: 'center',
1075
+ borderRadius: HUD_RADIUS,
1076
+ maxWidth: HUD_MAX_WIDTH,
1077
+ ...Platform.select({
1078
+ ios: {
1079
+ shadowOffset: { width: wp(0), height: IOS_SHADOW_OFFSET_Y },
1080
+ },
1081
+ }),
1082
+ },
1083
+ card: {
1084
+ minWidth: HUD_MIN_WIDTH,
1085
+ maxWidth: HUD_MAX_WIDTH,
1086
+ minHeight: HUD_MIN_HEIGHT,
1087
+ alignItems: 'center',
1088
+ justifyContent: 'center',
1089
+ borderRadius: HUD_RADIUS,
1090
+ overflow: 'hidden',
1091
+ paddingHorizontal: HUD_PADDING_HORIZONTAL,
1092
+ paddingVertical: HUD_PADDING_VERTICAL,
1093
+ },
1094
+ content: {
1095
+ alignItems: 'center',
1096
+ justifyContent: 'center',
1097
+ },
1098
+ iconFrame: {
1099
+ width: ICON_FRAME_SIZE,
1100
+ height: ICON_FRAME_SIZE,
1101
+ alignItems: 'center',
1102
+ justifyContent: 'center',
1103
+ },
1104
+ title: {
1105
+ maxWidth: TITLE_MAX_WIDTH,
1106
+ marginTop: TITLE_MARGIN_TOP,
1107
+ },
1108
+ description: {
1109
+ maxWidth: TITLE_MAX_WIDTH,
1110
+ marginTop: DESCRIPTION_MARGIN_TOP,
1111
+ opacity: 0.82,
1112
+ },
1113
+ });