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,1117 @@
1
+ import * as React from 'react';
2
+ import type {
3
+ GestureResponderEvent,
4
+ Insets,
5
+ PressableStateCallbackType,
6
+ StyleProp,
7
+ TextStyle,
8
+ ViewStyle,
9
+ } from 'react-native';
10
+ import {
11
+ I18nManager,
12
+ Platform,
13
+ Pressable,
14
+ processColor,
15
+ StyleSheet,
16
+ useColorScheme,
17
+ useWindowDimensions,
18
+ View,
19
+ } from 'react-native';
20
+ import Animated, {
21
+ Easing,
22
+ interpolate,
23
+ interpolateColor,
24
+ ReduceMotion,
25
+ useAnimatedStyle,
26
+ useSharedValue,
27
+ withTiming,
28
+ } from 'react-native-reanimated';
29
+ import type { SharedValue } from 'react-native-reanimated';
30
+ import { wp } from 'zkit-tools';
31
+ import type { Theme } from '../../theme/types';
32
+ import { useTheme } from '../../theme/useTheme';
33
+ import { Text } from '../Text';
34
+
35
+ export type RadioValue = string | number;
36
+ export type RadioSize = 'sm' | 'md' | 'lg';
37
+ export type RadioTone = 'primary' | 'neutral' | 'success' | 'warning' | 'danger' | 'info';
38
+ export type RadioVariant = 'outline' | 'soft' | 'solid';
39
+ export type RadioLabelPlacement = 'start' | 'end';
40
+ export type RadioGroupOrientation = 'horizontal' | 'vertical';
41
+ export type RadioGroupAlign = 'start' | 'center' | 'end' | 'stretch';
42
+
43
+ export type RadioLayout = {
44
+ indicatorSize?: number;
45
+ indicatorDotSize?: number;
46
+ indicatorBorderWidth?: number;
47
+ gap?: number;
48
+ minTouchTarget?: number;
49
+ focusRingWidth?: number;
50
+ focusRingOffset?: number;
51
+ };
52
+
53
+ export type RadioColors = {
54
+ checkedBackground?: string;
55
+ checkedBorder?: string;
56
+ checkedIndicator?: string;
57
+ uncheckedBackground?: string;
58
+ uncheckedBorder?: string;
59
+ focusRing?: string;
60
+ };
61
+
62
+ export type RadioSlotProps = {
63
+ checked: boolean;
64
+ value?: RadioValue;
65
+ disabled: boolean;
66
+ interactive: boolean;
67
+ size: RadioSize;
68
+ tone: RadioTone;
69
+ variant: RadioVariant;
70
+ select: () => void;
71
+ };
72
+
73
+ type RadioMetrics = {
74
+ indicatorSize: number;
75
+ indicatorDotSize: number;
76
+ indicatorBorderWidth: number;
77
+ gap: number;
78
+ minTouchTarget: number;
79
+ focusRingWidth: number;
80
+ focusRingOffset: number;
81
+ };
82
+
83
+ type RadioTonePalette = {
84
+ accent: string;
85
+ onAccent: string;
86
+ };
87
+
88
+ type ResolvedRadioColors = {
89
+ checkedBackground: string;
90
+ checkedBorder: string;
91
+ checkedIndicator: string;
92
+ uncheckedBackground: string;
93
+ uncheckedBorder: string;
94
+ focusRing: string;
95
+ };
96
+
97
+ type NativePressableProps = Omit<
98
+ React.ComponentPropsWithoutRef<typeof Pressable>,
99
+ 'accessibilityRole' | 'accessibilityState' | 'children' | 'disabled' | 'style' | 'onChange'
100
+ >;
101
+
102
+ type PressableFocusEvent = Parameters<
103
+ NonNullable<React.ComponentPropsWithoutRef<typeof Pressable>['onFocus']>
104
+ >[0];
105
+ type PressableBlurEvent = Parameters<
106
+ NonNullable<React.ComponentPropsWithoutRef<typeof Pressable>['onBlur']>
107
+ >[0];
108
+
109
+ export type RadioRef = React.ComponentRef<typeof Pressable>;
110
+
111
+ export interface RadioProps extends NativePressableProps {
112
+ value?: RadioValue;
113
+
114
+ checked?: boolean;
115
+ defaultChecked?: boolean;
116
+ onChange?: (checked: boolean) => void;
117
+ allowDeselect?: boolean;
118
+
119
+ disabled?: boolean;
120
+
121
+ size?: RadioSize;
122
+ tone?: RadioTone;
123
+ variant?: RadioVariant;
124
+ duration?: number;
125
+
126
+ color?: string;
127
+ colors?: RadioColors;
128
+ layout?: RadioLayout;
129
+
130
+ label?: React.ReactNode;
131
+ description?: React.ReactNode;
132
+ labelPlacement?: RadioLabelPlacement;
133
+ children?: React.ReactNode | ((slot: RadioSlotProps) => React.ReactNode);
134
+
135
+ showIndicator?: boolean;
136
+ indicator?: React.ReactNode | ((slot: RadioSlotProps) => React.ReactNode);
137
+
138
+ style?: React.ComponentPropsWithoutRef<typeof Pressable>['style'];
139
+ contentStyle?: StyleProp<ViewStyle>;
140
+ indicatorStyle?: StyleProp<ViewStyle>;
141
+ labelStyle?: StyleProp<TextStyle>;
142
+ descriptionStyle?: StyleProp<TextStyle>;
143
+ accessibilityState?: React.ComponentPropsWithoutRef<typeof Pressable>['accessibilityState'];
144
+ }
145
+
146
+ type RadioIndicatorContextValue = {
147
+ checkedSv: SharedValue<number>;
148
+ indicatorSize: number;
149
+ indicatorDotSize: number;
150
+ slot: RadioSlotProps;
151
+ checkedIndicatorColor: string;
152
+ customIndicator?: React.ReactNode | ((slot: RadioSlotProps) => React.ReactNode);
153
+ };
154
+
155
+ const RadioIndicatorContext = React.createContext<RadioIndicatorContextValue | null>(null);
156
+
157
+ type RadioGroupStoreListener = () => void;
158
+
159
+ const noopSubscribe = () => () => {};
160
+
161
+ class RadioGroupStore<T extends RadioValue> {
162
+ private value: T | null;
163
+ private listeners = new Set<RadioGroupStoreListener>();
164
+
165
+ constructor(initialValue: T | null) {
166
+ this.value = initialValue;
167
+ }
168
+
169
+ subscribe = (listener: RadioGroupStoreListener) => {
170
+ this.listeners.add(listener);
171
+ return () => {
172
+ this.listeners.delete(listener);
173
+ };
174
+ };
175
+
176
+ isSelected = (value: T) => Object.is(this.value, value);
177
+
178
+ setValue = (value: T | null) => {
179
+ if (Object.is(this.value, value)) return false;
180
+ this.value = value;
181
+ this.emit();
182
+ return true;
183
+ };
184
+
185
+ private emit() {
186
+ for (const listener of this.listeners) listener();
187
+ }
188
+ }
189
+
190
+ type RadioGroupContextValue<T extends RadioValue> = {
191
+ store: RadioGroupStore<T>;
192
+ disabled: boolean;
193
+ isControlled: boolean;
194
+ allowDeselect: boolean;
195
+ selectValue: (value: T | null) => void;
196
+ size?: RadioSize;
197
+ tone?: RadioTone;
198
+ variant?: RadioVariant;
199
+ color?: string;
200
+ colors?: RadioColors;
201
+ layout?: RadioLayout;
202
+ };
203
+
204
+ const RadioGroupContext = React.createContext<RadioGroupContextValue<RadioValue> | null>(null);
205
+
206
+ const SEMANTIC_COLORS: Record<string, string> = {
207
+ danger: '#DC2626',
208
+ error: '#DC2626',
209
+ info: '#2563EB',
210
+ success: '#16A34A',
211
+ warn: '#D97706',
212
+ warning: '#D97706',
213
+ };
214
+
215
+ const SIZE_TOKENS = {
216
+ sm: {
217
+ indicatorSize: 18,
218
+ indicatorDotSize: 8,
219
+ indicatorBorderWidth: 1.5,
220
+ gap: 8,
221
+ },
222
+ md: {
223
+ indicatorSize: 20,
224
+ indicatorDotSize: 9,
225
+ indicatorBorderWidth: 1.5,
226
+ gap: 10,
227
+ },
228
+ lg: {
229
+ indicatorSize: 24,
230
+ indicatorDotSize: 11,
231
+ indicatorBorderWidth: 1.5,
232
+ gap: 12,
233
+ },
234
+ } as const satisfies Record<
235
+ RadioSize,
236
+ Omit<RadioMetrics, 'minTouchTarget' | 'focusRingWidth' | 'focusRingOffset'>
237
+ >;
238
+
239
+ const DISABLED_OPACITY = 0.48;
240
+ const PRESSED_OPACITY = 0.86;
241
+ const PRESSED_SCALE = 0.965;
242
+ const VALUE_TIMING_DURATION = 170;
243
+ const PRESS_IN_DURATION = 90;
244
+ const PRESS_OUT_DURATION = 140;
245
+ const FOCUS_DURATION = 120;
246
+
247
+ function useRadioIndicatorContext() {
248
+ const context = React.useContext(RadioIndicatorContext);
249
+ if (!context) {
250
+ throw new Error('[zkit-ui] RadioIndicator must be rendered inside <Radio />.');
251
+ }
252
+ return context;
253
+ }
254
+
255
+ function isProcessableColor(color: string) {
256
+ return processColor(color) != null;
257
+ }
258
+
259
+ function colorToRgba(color: string, alpha: number) {
260
+ const processed = processColor(color);
261
+ if (typeof processed !== 'number') return undefined;
262
+
263
+ const normalized = processed >>> 0;
264
+ const r = (normalized >> 16) & 255;
265
+ const g = (normalized >> 8) & 255;
266
+ const b = normalized & 255;
267
+ const a = Math.max(0, Math.min(1, alpha));
268
+ return `rgba(${r},${g},${b},${a})`;
269
+ }
270
+
271
+ function resolveColorToken(input: string | undefined, fallback: string, theme: Theme) {
272
+ if (input == null) return fallback;
273
+
274
+ const key = input.trim();
275
+ if (!key) return fallback;
276
+
277
+ const resolved =
278
+ key === 'default' || key === 'neutral' || key === 'onSurface'
279
+ ? theme.colors.onSurface
280
+ : key === 'primary'
281
+ ? theme.colors.primary
282
+ : key === 'onPrimary' || key === 'inverse'
283
+ ? theme.colors.onPrimary
284
+ : key === 'secondary'
285
+ ? theme.colors.secondary
286
+ : key === 'onSecondary'
287
+ ? theme.colors.onSecondary
288
+ : key === 'surface'
289
+ ? theme.colors.surface
290
+ : key === 'border'
291
+ ? theme.colors.border
292
+ : key === 'muted'
293
+ ? theme.colors.muted
294
+ : key === 'disabled' || key === 'subtle'
295
+ ? theme.colors.disabled
296
+ : SEMANTIC_COLORS[key] ?? key;
297
+
298
+ return isProcessableColor(resolved) ? resolved : fallback;
299
+ }
300
+
301
+ function resolveTonePalette(tone: RadioTone, theme: Theme): RadioTonePalette {
302
+ if (tone === 'neutral') {
303
+ return {
304
+ accent: theme.colors.onSurface,
305
+ onAccent: theme.colors.surface,
306
+ };
307
+ }
308
+
309
+ if (tone === 'success') {
310
+ return {
311
+ accent: SEMANTIC_COLORS.success,
312
+ onAccent: '#FFFFFF',
313
+ };
314
+ }
315
+
316
+ if (tone === 'warning') {
317
+ return {
318
+ accent: SEMANTIC_COLORS.warning,
319
+ onAccent: '#FFFFFF',
320
+ };
321
+ }
322
+
323
+ if (tone === 'danger') {
324
+ return {
325
+ accent: SEMANTIC_COLORS.danger,
326
+ onAccent: '#FFFFFF',
327
+ };
328
+ }
329
+
330
+ if (tone === 'info') {
331
+ return {
332
+ accent: SEMANTIC_COLORS.info,
333
+ onAccent: '#FFFFFF',
334
+ };
335
+ }
336
+
337
+ return {
338
+ accent: theme.colors.primary,
339
+ onAccent: theme.colors.onPrimary,
340
+ };
341
+ }
342
+
343
+ function resolveVariantColors(
344
+ variant: RadioVariant,
345
+ accent: string,
346
+ onAccent: string,
347
+ colors: RadioColors | undefined,
348
+ theme: Theme,
349
+ scheme: ReturnType<typeof useColorScheme>
350
+ ): ResolvedRadioColors {
351
+ const softAlpha = scheme === 'dark' ? 0.24 : 0.14;
352
+ const fallbackSoftBackground = colorToRgba(accent, softAlpha) ?? theme.colors.secondary;
353
+ const fallbackUncheckedBorder =
354
+ colorToRgba(theme.colors.onSurface, scheme === 'dark' ? 0.28 : 0.18) ?? theme.colors.border;
355
+
356
+ const checkedBackground =
357
+ variant === 'solid'
358
+ ? accent
359
+ : variant === 'soft'
360
+ ? fallbackSoftBackground
361
+ : theme.colors.surface;
362
+ const checkedIndicator = variant === 'solid' ? onAccent : accent;
363
+
364
+ return {
365
+ checkedBackground: resolveColorToken(colors?.checkedBackground, checkedBackground, theme),
366
+ checkedBorder: resolveColorToken(colors?.checkedBorder, accent, theme),
367
+ checkedIndicator: resolveColorToken(colors?.checkedIndicator, checkedIndicator, theme),
368
+ uncheckedBackground: resolveColorToken(colors?.uncheckedBackground, theme.colors.surface, theme),
369
+ uncheckedBorder: resolveColorToken(colors?.uncheckedBorder, fallbackUncheckedBorder, theme),
370
+ focusRing: resolveColorToken(
371
+ colors?.focusRing,
372
+ colorToRgba(accent, scheme === 'dark' ? 0.36 : 0.28) ?? accent,
373
+ theme
374
+ ),
375
+ };
376
+ }
377
+
378
+ function resolveRadioMetrics(size: RadioSize, layout: RadioLayout | undefined): RadioMetrics {
379
+ const token = SIZE_TOKENS[size] ?? SIZE_TOKENS.md;
380
+ const indicatorSize = resolvePositiveNumber(layout?.indicatorSize, wp(token.indicatorSize));
381
+
382
+ return {
383
+ indicatorSize,
384
+ indicatorDotSize: resolvePositiveNumber(
385
+ layout?.indicatorDotSize,
386
+ wp(token.indicatorDotSize)
387
+ ),
388
+ indicatorBorderWidth: resolvePositiveNumber(
389
+ layout?.indicatorBorderWidth,
390
+ wp(token.indicatorBorderWidth)
391
+ ),
392
+ gap: resolveNonNegativeNumber(layout?.gap, wp(token.gap)),
393
+ minTouchTarget: resolvePositiveNumber(layout?.minTouchTarget, wp(44)),
394
+ focusRingWidth: resolvePositiveNumber(layout?.focusRingWidth, wp(2)),
395
+ focusRingOffset: resolveNonNegativeNumber(layout?.focusRingOffset, wp(3)),
396
+ };
397
+ }
398
+
399
+ function resolvePositiveNumber(value: number | undefined, fallback: number) {
400
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback;
401
+ }
402
+
403
+ function resolveNonNegativeNumber(value: number | undefined, fallback: number) {
404
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : fallback;
405
+ }
406
+
407
+ function resolveDuration(duration: number | undefined) {
408
+ if (duration == null || !Number.isFinite(duration)) return VALUE_TIMING_DURATION;
409
+ return Math.max(0, duration);
410
+ }
411
+
412
+ function toTimingConfig(duration: number) {
413
+ return {
414
+ duration,
415
+ easing: Easing.out(Easing.cubic),
416
+ reduceMotion: ReduceMotion.System,
417
+ } as const;
418
+ }
419
+
420
+ function resolveHitSlop(width: number, height: number, minTouchTarget: number): Insets | undefined {
421
+ const vertical = Math.max(0, (minTouchTarget - height) / 2);
422
+ const horizontal = Math.max(0, (minTouchTarget - width) / 2);
423
+ if (vertical === 0 && horizontal === 0) return undefined;
424
+ return {
425
+ top: vertical,
426
+ bottom: vertical,
427
+ left: horizontal,
428
+ right: horizontal,
429
+ };
430
+ }
431
+
432
+ function resolveWebCursorStyle(disabled: boolean): ViewStyle | undefined {
433
+ if (Platform.OS !== 'web') return undefined;
434
+ return { cursor: disabled ? 'not-allowed' : 'pointer' } as ViewStyle;
435
+ }
436
+
437
+ function isPrimitiveText(node: React.ReactNode): node is string | number {
438
+ return typeof node === 'string' || typeof node === 'number';
439
+ }
440
+
441
+ function isRenderProp(children: RadioProps['children']): children is (slot: RadioSlotProps) => React.ReactNode {
442
+ return typeof children === 'function';
443
+ }
444
+
445
+ function isIndicatorRenderProp(
446
+ indicator: RadioIndicatorProps['children'] | RadioProps['indicator']
447
+ ): indicator is (slot: RadioSlotProps) => React.ReactNode {
448
+ return typeof indicator === 'function';
449
+ }
450
+
451
+ function resolveAlignItems(align: RadioGroupAlign): ViewStyle['alignItems'] {
452
+ if (align === 'center') return 'center';
453
+ if (align === 'end') return 'flex-end';
454
+ if (align === 'stretch') return 'stretch';
455
+ return 'flex-start';
456
+ }
457
+
458
+ function RadioImpl(
459
+ {
460
+ value,
461
+ checked: checkedProp,
462
+ defaultChecked = false,
463
+ onChange,
464
+ allowDeselect,
465
+ disabled = false,
466
+ size: sizeProp,
467
+ tone: toneProp,
468
+ variant: variantProp,
469
+ duration,
470
+ color: colorProp,
471
+ colors: colorsProp,
472
+ layout: layoutProp,
473
+ label,
474
+ description,
475
+ labelPlacement = 'end',
476
+ children,
477
+ showIndicator = true,
478
+ indicator,
479
+ style,
480
+ contentStyle,
481
+ indicatorStyle,
482
+ labelStyle,
483
+ descriptionStyle,
484
+ accessibilityLabel,
485
+ accessibilityState,
486
+ hitSlop,
487
+ onPress,
488
+ onPressIn,
489
+ onPressOut,
490
+ onFocus,
491
+ onBlur,
492
+ testID,
493
+ ...pressableProps
494
+ }: RadioProps,
495
+ ref: React.ForwardedRef<RadioRef>
496
+ ) {
497
+ const theme = useTheme();
498
+ const scheme = useColorScheme();
499
+ const { width: viewportWidth } = useWindowDimensions();
500
+ const group = React.useContext(RadioGroupContext);
501
+ const isRTL = I18nManager.isRTL;
502
+
503
+ const isStandaloneControlled = checkedProp !== undefined;
504
+ const isGroupItem = group != null && value !== undefined && !isStandaloneControlled;
505
+ const [uncontrolledChecked, setUncontrolledChecked] = React.useState<boolean>(() => defaultChecked);
506
+
507
+ const getGroupCheckedSnapshot = React.useCallback(
508
+ () => (group && value !== undefined ? group.store.isSelected(value) : false),
509
+ [group, value]
510
+ );
511
+ const groupChecked = React.useSyncExternalStore(
512
+ group?.store.subscribe ?? noopSubscribe,
513
+ getGroupCheckedSnapshot,
514
+ getGroupCheckedSnapshot
515
+ );
516
+
517
+ const checked = isGroupItem
518
+ ? groupChecked
519
+ : isStandaloneControlled
520
+ ? checkedProp!
521
+ : uncontrolledChecked;
522
+ const resolvedDisabled = disabled || Boolean(group?.disabled);
523
+ const interactive = !resolvedDisabled;
524
+ const resolvedAllowDeselect = allowDeselect ?? group?.allowDeselect ?? false;
525
+
526
+ const size = sizeProp ?? group?.size ?? 'md';
527
+ const tone = toneProp ?? group?.tone ?? 'primary';
528
+ const variant = variantProp ?? group?.variant ?? 'outline';
529
+ const color = colorProp ?? group?.color;
530
+ const colors = React.useMemo(
531
+ () =>
532
+ group?.colors || colorsProp
533
+ ? {
534
+ ...group?.colors,
535
+ ...colorsProp,
536
+ }
537
+ : undefined,
538
+ [colorsProp, group?.colors]
539
+ );
540
+ const layout = React.useMemo(
541
+ () =>
542
+ group?.layout || layoutProp
543
+ ? {
544
+ ...group?.layout,
545
+ ...layoutProp,
546
+ }
547
+ : undefined,
548
+ [group?.layout, layoutProp]
549
+ );
550
+
551
+ const metrics = React.useMemo(() => resolveRadioMetrics(size, layout), [layout, size, viewportWidth]);
552
+ const tonePalette = React.useMemo(() => resolveTonePalette(tone, theme), [theme, tone]);
553
+ const accent = React.useMemo(
554
+ () => resolveColorToken(color, tonePalette.accent, theme),
555
+ [color, theme, tonePalette.accent]
556
+ );
557
+ const resolvedColors = React.useMemo(
558
+ () => resolveVariantColors(variant, accent, tonePalette.onAccent, colors, theme, scheme),
559
+ [accent, colors, scheme, theme, tonePalette.onAccent, variant]
560
+ );
561
+ const webCursorStyle = React.useMemo(
562
+ () => resolveWebCursorStyle(resolvedDisabled),
563
+ [resolvedDisabled]
564
+ );
565
+ const defaultHitSlop = React.useMemo(
566
+ () => resolveHitSlop(metrics.indicatorSize, metrics.indicatorSize, metrics.minTouchTarget),
567
+ [metrics.indicatorSize, metrics.minTouchTarget]
568
+ );
569
+ const resolvedDuration = resolveDuration(duration);
570
+ const valueTiming = React.useMemo(() => toTimingConfig(resolvedDuration), [resolvedDuration]);
571
+ const pressInTiming = React.useMemo(() => toTimingConfig(PRESS_IN_DURATION), []);
572
+ const pressOutTiming = React.useMemo(() => toTimingConfig(PRESS_OUT_DURATION), []);
573
+ const focusTiming = React.useMemo(() => toTimingConfig(FOCUS_DURATION), []);
574
+
575
+ const checkedSv = useSharedValue(checked ? 1 : 0);
576
+ const pressSv = useSharedValue(0);
577
+ const focusSv = useSharedValue(0);
578
+
579
+ const checkedRef = React.useRef(checked);
580
+ checkedRef.current = checked;
581
+ const groupCheckedRef = React.useRef(groupChecked);
582
+ groupCheckedRef.current = groupChecked;
583
+
584
+ React.useEffect(() => {
585
+ if (!isStandaloneControlled && !isGroupItem) return;
586
+ checkedSv.value = withTiming(checked ? 1 : 0, valueTiming);
587
+ }, [checked, checkedSv, isGroupItem, isStandaloneControlled, valueTiming]);
588
+
589
+ React.useEffect(() => {
590
+ if (interactive) return;
591
+ pressSv.value = withTiming(0, pressOutTiming);
592
+ focusSv.value = withTiming(0, focusTiming);
593
+ }, [focusSv, focusTiming, interactive, pressOutTiming, pressSv]);
594
+
595
+ const commitStandaloneChecked = React.useCallback(
596
+ (next: boolean) => {
597
+ if (!isStandaloneControlled) {
598
+ checkedRef.current = next;
599
+ checkedSv.value = withTiming(next ? 1 : 0, valueTiming);
600
+ setUncontrolledChecked(next);
601
+ }
602
+ onChange?.(next);
603
+ },
604
+ [checkedSv, isStandaloneControlled, onChange, valueTiming]
605
+ );
606
+
607
+ const select = React.useCallback(() => {
608
+ if (!interactive) return;
609
+
610
+ if (isGroupItem && group && value !== undefined) {
611
+ const isSelected = groupCheckedRef.current;
612
+ if (isSelected && !resolvedAllowDeselect) return;
613
+
614
+ const nextValue = isSelected ? null : value;
615
+ if (!group.isControlled) {
616
+ checkedSv.value = withTiming(isSelected ? 0 : 1, valueTiming);
617
+ }
618
+ group.selectValue(nextValue);
619
+ onChange?.(!isSelected);
620
+ return;
621
+ }
622
+
623
+ const current = checkedRef.current;
624
+ if (current && !resolvedAllowDeselect) return;
625
+ commitStandaloneChecked(current ? false : true);
626
+ }, [
627
+ checkedSv,
628
+ commitStandaloneChecked,
629
+ group,
630
+ interactive,
631
+ isGroupItem,
632
+ onChange,
633
+ resolvedAllowDeselect,
634
+ value,
635
+ valueTiming,
636
+ ]);
637
+
638
+ const handlePress = React.useCallback(
639
+ (event: GestureResponderEvent) => {
640
+ if (interactive) select();
641
+ onPress?.(event);
642
+ },
643
+ [interactive, onPress, select]
644
+ );
645
+
646
+ const handlePressIn = React.useCallback(
647
+ (event: GestureResponderEvent) => {
648
+ if (interactive) {
649
+ pressSv.value = withTiming(1, pressInTiming);
650
+ }
651
+ onPressIn?.(event);
652
+ },
653
+ [interactive, onPressIn, pressInTiming, pressSv]
654
+ );
655
+
656
+ const handlePressOut = React.useCallback(
657
+ (event: GestureResponderEvent) => {
658
+ pressSv.value = withTiming(0, pressOutTiming);
659
+ onPressOut?.(event);
660
+ },
661
+ [onPressOut, pressOutTiming, pressSv]
662
+ );
663
+
664
+ const handleFocus = React.useCallback(
665
+ (event: PressableFocusEvent) => {
666
+ if (interactive) {
667
+ focusSv.value = withTiming(1, focusTiming);
668
+ }
669
+ onFocus?.(event);
670
+ },
671
+ [focusSv, focusTiming, interactive, onFocus]
672
+ );
673
+
674
+ const handleBlur = React.useCallback(
675
+ (event: PressableBlurEvent) => {
676
+ focusSv.value = withTiming(0, focusTiming);
677
+ onBlur?.(event);
678
+ },
679
+ [focusSv, focusTiming, onBlur]
680
+ );
681
+
682
+ const slot = React.useMemo<RadioSlotProps>(
683
+ () => ({
684
+ checked,
685
+ value,
686
+ disabled: resolvedDisabled,
687
+ interactive,
688
+ size,
689
+ tone,
690
+ variant,
691
+ select,
692
+ }),
693
+ [checked, interactive, resolvedDisabled, select, size, tone, value, variant]
694
+ );
695
+
696
+ const renderedContent = React.useMemo(() => {
697
+ if (isRenderProp(children)) return children(slot);
698
+ if (children != null) return children;
699
+ if (label == null && description == null) return null;
700
+
701
+ return (
702
+ <View style={styles.copy}>
703
+ {label != null ? (
704
+ isPrimitiveText(label) ? (
705
+ <Text
706
+ disabled={resolvedDisabled}
707
+ numberOfLines={1}
708
+ variant="label"
709
+ style={labelStyle}
710
+ >
711
+ {label}
712
+ </Text>
713
+ ) : (
714
+ label
715
+ )
716
+ ) : null}
717
+ {description != null ? (
718
+ isPrimitiveText(description) ? (
719
+ <Text
720
+ disabled={resolvedDisabled}
721
+ numberOfLines={2}
722
+ tone={resolvedDisabled ? 'disabled' : 'muted'}
723
+ variant="caption"
724
+ style={descriptionStyle}
725
+ >
726
+ {description}
727
+ </Text>
728
+ ) : (
729
+ description
730
+ )
731
+ ) : null}
732
+ </View>
733
+ );
734
+ }, [
735
+ children,
736
+ description,
737
+ descriptionStyle,
738
+ label,
739
+ labelStyle,
740
+ resolvedDisabled,
741
+ slot,
742
+ ]);
743
+
744
+ const indicatorContext = React.useMemo<RadioIndicatorContextValue>(
745
+ () => ({
746
+ checkedSv,
747
+ indicatorSize: metrics.indicatorSize,
748
+ indicatorDotSize: metrics.indicatorDotSize,
749
+ slot,
750
+ checkedIndicatorColor: resolvedColors.checkedIndicator,
751
+ customIndicator: indicator,
752
+ }),
753
+ [
754
+ checkedSv,
755
+ indicator,
756
+ metrics.indicatorDotSize,
757
+ metrics.indicatorSize,
758
+ resolvedColors.checkedIndicator,
759
+ slot,
760
+ ]
761
+ );
762
+
763
+ const visualAnimatedStyle = useAnimatedStyle(() => {
764
+ const opacity = resolvedDisabled
765
+ ? DISABLED_OPACITY
766
+ : interpolate(pressSv.value, [0, 1], [1, PRESSED_OPACITY]);
767
+ return { opacity };
768
+ }, [resolvedDisabled]);
769
+
770
+ const indicatorPressAnimatedStyle = useAnimatedStyle(() => {
771
+ const scale = interpolate(pressSv.value, [0, 1], [1, PRESSED_SCALE]);
772
+ return { transform: [{ scale }] };
773
+ });
774
+
775
+ const focusRingAnimatedStyle = useAnimatedStyle(() => {
776
+ return {
777
+ opacity: focusSv.value,
778
+ transform: [{ scale: interpolate(focusSv.value, [0, 1], [0.96, 1]) }],
779
+ };
780
+ });
781
+
782
+ const indicatorAnimatedStyle = useAnimatedStyle(() => {
783
+ return {
784
+ backgroundColor: interpolateColor(
785
+ checkedSv.value,
786
+ [0, 1],
787
+ [resolvedColors.uncheckedBackground, resolvedColors.checkedBackground]
788
+ ),
789
+ borderColor: interpolateColor(
790
+ checkedSv.value,
791
+ [0, 1],
792
+ [resolvedColors.uncheckedBorder, resolvedColors.checkedBorder]
793
+ ),
794
+ };
795
+ }, [resolvedColors]);
796
+
797
+ const indicatorFrameStyle = React.useMemo<ViewStyle>(
798
+ () => ({
799
+ width: metrics.indicatorSize,
800
+ height: metrics.indicatorSize,
801
+ borderRadius: metrics.indicatorSize / 2,
802
+ borderWidth: metrics.indicatorBorderWidth,
803
+ }),
804
+ [metrics.indicatorBorderWidth, metrics.indicatorSize]
805
+ );
806
+
807
+ const indicatorNode = showIndicator ? (
808
+ <Animated.View
809
+ style={[
810
+ styles.indicatorFrame,
811
+ indicatorFrameStyle,
812
+ indicatorPressAnimatedStyle,
813
+ indicatorAnimatedStyle,
814
+ indicatorStyle,
815
+ ]}
816
+ >
817
+ <Animated.View
818
+ pointerEvents="none"
819
+ style={[
820
+ styles.focusRing,
821
+ {
822
+ top: -metrics.focusRingOffset,
823
+ bottom: -metrics.focusRingOffset,
824
+ left: -metrics.focusRingOffset,
825
+ right: -metrics.focusRingOffset,
826
+ borderRadius: metrics.indicatorSize / 2 + metrics.focusRingOffset,
827
+ borderWidth: metrics.focusRingWidth,
828
+ borderColor: resolvedColors.focusRing,
829
+ },
830
+ focusRingAnimatedStyle,
831
+ ]}
832
+ />
833
+ <RadioIndicator />
834
+ </Animated.View>
835
+ ) : null;
836
+
837
+ const hasContent = renderedContent != null;
838
+ const contentBeforeIndicator =
839
+ (labelPlacement === 'start' && !isRTL) || (labelPlacement === 'end' && isRTL);
840
+ const contentNode = hasContent ? (
841
+ <View style={[styles.content, contentStyle]}>
842
+ {contentBeforeIndicator ? (
843
+ <>
844
+ <View style={[styles.contentSlot, showIndicator ? { marginEnd: metrics.gap } : null]}>
845
+ {renderedContent}
846
+ </View>
847
+ {indicatorNode}
848
+ </>
849
+ ) : (
850
+ <>
851
+ {indicatorNode}
852
+ <View style={[styles.contentSlot, showIndicator ? { marginStart: metrics.gap } : null]}>
853
+ {renderedContent}
854
+ </View>
855
+ </>
856
+ )}
857
+ </View>
858
+ ) : (
859
+ indicatorNode
860
+ );
861
+
862
+ const inferredAccessibilityLabel = isPrimitiveText(label) ? String(label) : undefined;
863
+ const resolvedStyle = React.useCallback(
864
+ (state: PressableStateCallbackType) => {
865
+ const userStyle = typeof style === 'function' ? style(state) : style;
866
+ return [styles.root, webCursorStyle, userStyle];
867
+ },
868
+ [style, webCursorStyle]
869
+ );
870
+
871
+ return (
872
+ <RadioIndicatorContext.Provider value={indicatorContext}>
873
+ <Pressable
874
+ {...pressableProps}
875
+ ref={ref}
876
+ accessibilityLabel={accessibilityLabel ?? inferredAccessibilityLabel}
877
+ accessibilityRole="radio"
878
+ accessibilityState={{
879
+ ...accessibilityState,
880
+ checked,
881
+ disabled: Boolean(resolvedDisabled),
882
+ }}
883
+ disabled={resolvedDisabled}
884
+ hitSlop={hitSlop ?? defaultHitSlop}
885
+ onBlur={handleBlur}
886
+ onFocus={handleFocus}
887
+ onPress={handlePress}
888
+ onPressIn={handlePressIn}
889
+ onPressOut={handlePressOut}
890
+ style={resolvedStyle}
891
+ testID={testID}
892
+ >
893
+ <Animated.View style={[styles.visual, visualAnimatedStyle]}>{contentNode}</Animated.View>
894
+ </Pressable>
895
+ </RadioIndicatorContext.Provider>
896
+ );
897
+ }
898
+
899
+ const RadioWithRef = React.forwardRef<RadioRef, RadioProps>(RadioImpl);
900
+ RadioWithRef.displayName = 'Radio';
901
+
902
+ export const Radio = React.memo(RadioWithRef);
903
+ Radio.displayName = 'Radio';
904
+
905
+ export type RadioIndicatorProps = {
906
+ children?: React.ReactNode | ((slot: RadioSlotProps) => React.ReactNode);
907
+ style?: StyleProp<ViewStyle>;
908
+ };
909
+
910
+ export function RadioIndicator({ children, style }: RadioIndicatorProps) {
911
+ const context = useRadioIndicatorContext();
912
+ const customIndicator = children ?? context.customIndicator;
913
+
914
+ const dotAnimatedStyle = useAnimatedStyle(() => {
915
+ const opacity = context.checkedSv.value;
916
+ const scale = interpolate(context.checkedSv.value, [0, 1], [0.72, 1]);
917
+ return { opacity, transform: [{ scale }] };
918
+ });
919
+
920
+ const resolvedCustomIndicator = isIndicatorRenderProp(customIndicator)
921
+ ? customIndicator(context.slot)
922
+ : customIndicator;
923
+
924
+ return (
925
+ <View
926
+ pointerEvents="none"
927
+ style={[
928
+ styles.indicatorLayer,
929
+ {
930
+ width: context.indicatorSize,
931
+ height: context.indicatorSize,
932
+ },
933
+ style,
934
+ ]}
935
+ >
936
+ <Animated.View style={dotAnimatedStyle}>
937
+ {resolvedCustomIndicator ?? (
938
+ <View
939
+ style={{
940
+ width: context.indicatorDotSize,
941
+ height: context.indicatorDotSize,
942
+ borderRadius: context.indicatorDotSize / 2,
943
+ backgroundColor: context.checkedIndicatorColor,
944
+ }}
945
+ />
946
+ )}
947
+ </Animated.View>
948
+ </View>
949
+ );
950
+ }
951
+
952
+ export type RadioGroupProps<T extends RadioValue = RadioValue> = Omit<
953
+ React.ComponentPropsWithoutRef<typeof View>,
954
+ 'children' | 'onChange'
955
+ > & {
956
+ value?: T | null;
957
+ defaultValue?: T | null;
958
+ onChange?: (value: T | null) => void;
959
+ disabled?: boolean;
960
+ allowDeselect?: boolean;
961
+
962
+ orientation?: RadioGroupOrientation;
963
+ align?: RadioGroupAlign;
964
+ wrap?: boolean;
965
+ gap?: number;
966
+ rowGap?: number;
967
+ columnGap?: number;
968
+
969
+ size?: RadioSize;
970
+ tone?: RadioTone;
971
+ variant?: RadioVariant;
972
+ color?: string;
973
+ colors?: RadioColors;
974
+ layout?: RadioLayout;
975
+
976
+ children: React.ReactNode;
977
+ };
978
+
979
+ export function RadioGroup<T extends RadioValue = RadioValue>({
980
+ value,
981
+ defaultValue,
982
+ onChange,
983
+ disabled = false,
984
+ allowDeselect = false,
985
+ orientation = 'horizontal',
986
+ align = 'start',
987
+ wrap = false,
988
+ gap,
989
+ rowGap,
990
+ columnGap,
991
+ size,
992
+ tone,
993
+ variant,
994
+ color,
995
+ colors,
996
+ layout,
997
+ style,
998
+ children,
999
+ accessibilityState,
1000
+ ...viewProps
1001
+ }: RadioGroupProps<T>) {
1002
+ const { width: viewportWidth } = useWindowDimensions();
1003
+ const isControlled = value !== undefined;
1004
+ const storeRef = React.useRef<RadioGroupStore<T> | null>(null);
1005
+ if (!storeRef.current) {
1006
+ storeRef.current = new RadioGroupStore<T>((value ?? defaultValue ?? null) as T | null);
1007
+ }
1008
+
1009
+ React.useLayoutEffect(() => {
1010
+ if (!isControlled) return;
1011
+ storeRef.current!.setValue((value ?? null) as T | null);
1012
+ }, [isControlled, value]);
1013
+
1014
+ const selectValue = React.useCallback(
1015
+ (nextValue: T | null) => {
1016
+ if (!isControlled) storeRef.current!.setValue(nextValue);
1017
+ onChange?.(nextValue);
1018
+ },
1019
+ [isControlled, onChange]
1020
+ );
1021
+
1022
+ const context = React.useMemo<RadioGroupContextValue<T>>(
1023
+ () => ({
1024
+ store: storeRef.current!,
1025
+ disabled,
1026
+ isControlled,
1027
+ allowDeselect,
1028
+ selectValue,
1029
+ size,
1030
+ tone,
1031
+ variant,
1032
+ color,
1033
+ colors,
1034
+ layout,
1035
+ }),
1036
+ [
1037
+ allowDeselect,
1038
+ color,
1039
+ colors,
1040
+ disabled,
1041
+ isControlled,
1042
+ layout,
1043
+ selectValue,
1044
+ size,
1045
+ tone,
1046
+ variant,
1047
+ ]
1048
+ );
1049
+
1050
+ const defaultGap = React.useMemo(() => wp(12), [viewportWidth]);
1051
+ const resolvedGap = resolveNonNegativeNumber(gap, defaultGap);
1052
+ const resolvedRowGap = resolveNonNegativeNumber(rowGap, resolvedGap);
1053
+ const resolvedColumnGap = resolveNonNegativeNumber(columnGap, resolvedGap);
1054
+ const layoutStyle = React.useMemo<ViewStyle>(
1055
+ () => ({
1056
+ alignItems: resolveAlignItems(align),
1057
+ columnGap: resolvedColumnGap,
1058
+ flexDirection: orientation === 'vertical' ? 'column' : 'row',
1059
+ flexWrap: wrap ? 'wrap' : 'nowrap',
1060
+ rowGap: resolvedRowGap,
1061
+ }),
1062
+ [align, orientation, resolvedColumnGap, resolvedRowGap, wrap]
1063
+ );
1064
+
1065
+ return (
1066
+ <RadioGroupContext.Provider value={context as unknown as RadioGroupContextValue<RadioValue>}>
1067
+ <View
1068
+ {...viewProps}
1069
+ accessibilityRole="radiogroup"
1070
+ accessibilityState={{
1071
+ ...accessibilityState,
1072
+ disabled: Boolean(disabled),
1073
+ }}
1074
+ style={[layoutStyle, style]}
1075
+ >
1076
+ {children}
1077
+ </View>
1078
+ </RadioGroupContext.Provider>
1079
+ );
1080
+ }
1081
+
1082
+ const styles = StyleSheet.create({
1083
+ root: {
1084
+ alignSelf: 'flex-start',
1085
+ },
1086
+ visual: {
1087
+ alignItems: 'center',
1088
+ flexDirection: 'row',
1089
+ minWidth: 0,
1090
+ },
1091
+ content: {
1092
+ alignItems: 'center',
1093
+ flexDirection: 'row',
1094
+ minWidth: 0,
1095
+ },
1096
+ contentSlot: {
1097
+ flexShrink: 1,
1098
+ minWidth: 0,
1099
+ },
1100
+ copy: {
1101
+ flexShrink: 1,
1102
+ minWidth: 0,
1103
+ },
1104
+ indicatorFrame: {
1105
+ alignItems: 'center',
1106
+ flexShrink: 0,
1107
+ justifyContent: 'center',
1108
+ overflow: 'visible',
1109
+ },
1110
+ focusRing: {
1111
+ position: 'absolute',
1112
+ },
1113
+ indicatorLayer: {
1114
+ alignItems: 'center',
1115
+ justifyContent: 'center',
1116
+ },
1117
+ });