zkit-ui 1.0.0 → 1.0.1

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.
@@ -837,41 +837,60 @@ function resolveWebCursorStyle(disabled: boolean): ViewStyle | undefined {
837
837
  return { cursor: disabled ? 'not-allowed' : 'pointer' } as ViewStyle;
838
838
  }
839
839
 
840
- function ButtonImpl(
841
- {
842
- variant = 'solid',
843
- tone = 'primary',
844
- size = 'md',
845
- shape = 'rounded',
846
- block = false,
847
- disabled = false,
848
- loading = false,
849
- loadingMode = 'inline',
850
- pressEffect = 'auto',
851
- icon,
852
- iconPlacement = 'start',
853
- iconOnly = false,
854
- color,
855
- colors,
856
- border,
857
- layout,
858
- gradient,
859
- shadow = 'none',
860
- children,
861
- style,
862
- contentStyle,
863
- textStyle,
864
- accessibilityLabel,
865
- accessibilityState,
866
- hitSlop,
867
- onPress,
868
- onPressIn,
869
- onPressOut,
870
- testID,
871
- ...pressableProps
872
- }: ButtonProps,
873
- ref: React.ForwardedRef<ButtonRef>
874
- ) {
840
+ function shouldUseStaticButtonPath({
841
+ disabled,
842
+ loading,
843
+ pressEffect,
844
+ }: {
845
+ disabled?: boolean;
846
+ loading?: boolean;
847
+ pressEffect?: ButtonPressEffect;
848
+ }) {
849
+ return !loading && (pressEffect === 'none' || disabled === true);
850
+ }
851
+
852
+ function useButtonFrame({
853
+ variant = 'solid',
854
+ tone = 'primary',
855
+ size = 'md',
856
+ shape = 'rounded',
857
+ block = false,
858
+ disabled = false,
859
+ loading = false,
860
+ icon,
861
+ iconPlacement = 'start',
862
+ iconOnly = false,
863
+ color,
864
+ colors,
865
+ border,
866
+ layout,
867
+ gradient,
868
+ shadow = 'none',
869
+ children,
870
+ textStyle,
871
+ accessibilityLabel,
872
+ }: Pick<
873
+ ButtonProps,
874
+ | 'variant'
875
+ | 'tone'
876
+ | 'size'
877
+ | 'shape'
878
+ | 'block'
879
+ | 'disabled'
880
+ | 'loading'
881
+ | 'icon'
882
+ | 'iconPlacement'
883
+ | 'iconOnly'
884
+ | 'color'
885
+ | 'colors'
886
+ | 'border'
887
+ | 'layout'
888
+ | 'gradient'
889
+ | 'shadow'
890
+ | 'children'
891
+ | 'textStyle'
892
+ | 'accessibilityLabel'
893
+ >) {
875
894
  const theme = useTheme();
876
895
  const scheme = useColorScheme();
877
896
  const { width: viewportWidth } = useWindowDimensions();
@@ -892,8 +911,6 @@ function ButtonImpl(
892
911
 
893
912
  const interactionDisabled = disabled || loading;
894
913
  const visualDisabled = disabled;
895
- const resolvedLoadingMode = iconOnly ? 'overlay' : loadingMode;
896
- const centeredLoading = resolvedLoadingMode !== 'inline';
897
914
 
898
915
  const visualColors = React.useMemo(
899
916
  () =>
@@ -1009,16 +1026,6 @@ function ButtonImpl(
1009
1026
  const hasText = resolvedText != null;
1010
1027
  const hasIcon = icon != null;
1011
1028
  const iconSize = layout?.iconSize ?? metrics.iconSize;
1012
- const loadingSize = React.useMemo(
1013
- () =>
1014
- resolveLoadingSize({
1015
- iconOnly,
1016
- layout,
1017
- metrics,
1018
- variant,
1019
- }),
1020
- [iconOnly, layout, metrics, variant]
1021
- );
1022
1029
  const contentGap = layout?.gap ?? metrics.gap;
1023
1030
  const iconBaseBoxStyle = React.useMemo(() => resolveIconBoxStyle(hasIcon, iconSize), [hasIcon, iconSize]);
1024
1031
  const iconPlacedBoxStyle = React.useMemo((): ViewStyle => {
@@ -1034,6 +1041,278 @@ function ButtonImpl(
1034
1041
  iconOnly && isPrimitiveTextChild(children) ? String(children) : undefined;
1035
1042
  const resolvedAccessibilityLabel = accessibilityLabel ?? inferredAccessibilityLabel;
1036
1043
 
1044
+ return {
1045
+ accentColor,
1046
+ contentGap,
1047
+ contentSizeStyle,
1048
+ defaultHitSlop,
1049
+ gradientCfg,
1050
+ hasGradientBackground,
1051
+ hasIcon,
1052
+ hasText,
1053
+ iconBaseBoxStyle,
1054
+ iconPlacedBoxStyle,
1055
+ iconSize,
1056
+ interactionDisabled,
1057
+ LinearGradientComponent,
1058
+ metrics,
1059
+ paddingStyle,
1060
+ radiusStyle,
1061
+ resolvedAccessibilityLabel,
1062
+ resolvedText,
1063
+ rootSizeStyle,
1064
+ shadowStyle,
1065
+ visualColors,
1066
+ visualDisabled,
1067
+ webCursorStyle,
1068
+ borderStyle,
1069
+ };
1070
+ }
1071
+
1072
+ function ButtonStaticImpl(
1073
+ {
1074
+ variant = 'solid',
1075
+ tone = 'primary',
1076
+ size = 'md',
1077
+ shape = 'rounded',
1078
+ block = false,
1079
+ disabled = false,
1080
+ loading = false,
1081
+ loadingMode: _loadingMode = 'inline',
1082
+ pressEffect: _pressEffect = 'auto',
1083
+ icon,
1084
+ iconPlacement = 'start',
1085
+ iconOnly = false,
1086
+ color,
1087
+ colors,
1088
+ border,
1089
+ layout,
1090
+ gradient,
1091
+ shadow = 'none',
1092
+ children,
1093
+ style,
1094
+ contentStyle,
1095
+ textStyle,
1096
+ accessibilityLabel,
1097
+ accessibilityState,
1098
+ hitSlop,
1099
+ onPress,
1100
+ testID,
1101
+ ...pressableProps
1102
+ }: ButtonProps,
1103
+ ref: React.ForwardedRef<ButtonRef>
1104
+ ) {
1105
+ const frame = useButtonFrame({
1106
+ accessibilityLabel,
1107
+ block,
1108
+ border,
1109
+ children,
1110
+ color,
1111
+ colors,
1112
+ disabled,
1113
+ gradient,
1114
+ icon,
1115
+ iconOnly,
1116
+ iconPlacement,
1117
+ layout,
1118
+ loading,
1119
+ shadow,
1120
+ shape,
1121
+ size,
1122
+ textStyle,
1123
+ tone,
1124
+ variant,
1125
+ });
1126
+ const {
1127
+ borderStyle,
1128
+ contentSizeStyle,
1129
+ defaultHitSlop,
1130
+ gradientCfg,
1131
+ hasIcon,
1132
+ iconBaseBoxStyle,
1133
+ iconPlacedBoxStyle,
1134
+ interactionDisabled,
1135
+ LinearGradientComponent,
1136
+ paddingStyle,
1137
+ radiusStyle,
1138
+ resolvedAccessibilityLabel,
1139
+ resolvedText,
1140
+ rootSizeStyle,
1141
+ shadowStyle,
1142
+ visualColors,
1143
+ visualDisabled,
1144
+ webCursorStyle,
1145
+ } = frame;
1146
+
1147
+ const handlePress = React.useCallback(
1148
+ (event: GestureResponderEvent) => {
1149
+ if (interactionDisabled) return;
1150
+ onPress?.(event);
1151
+ },
1152
+ [interactionDisabled, onPress]
1153
+ );
1154
+
1155
+ return (
1156
+ <Pressable
1157
+ {...pressableProps}
1158
+ ref={ref}
1159
+ accessibilityLabel={resolvedAccessibilityLabel}
1160
+ accessibilityRole="button"
1161
+ accessibilityState={{
1162
+ ...accessibilityState,
1163
+ busy: Boolean(loading || accessibilityState?.busy),
1164
+ disabled: Boolean(interactionDisabled || accessibilityState?.disabled),
1165
+ }}
1166
+ disabled={interactionDisabled}
1167
+ hitSlop={hitSlop ?? defaultHitSlop}
1168
+ onPress={handlePress}
1169
+ style={[
1170
+ styles.root,
1171
+ rootSizeStyle,
1172
+ radiusStyle,
1173
+ shadowStyle,
1174
+ visualDisabled ? styles.disabledRoot : null,
1175
+ webCursorStyle,
1176
+ style,
1177
+ ]}
1178
+ testID={testID}
1179
+ >
1180
+ <View
1181
+ pointerEvents="none"
1182
+ style={[
1183
+ styles.content,
1184
+ contentSizeStyle,
1185
+ paddingStyle,
1186
+ radiusStyle,
1187
+ borderStyle,
1188
+ { backgroundColor: visualColors.backgroundColor },
1189
+ contentStyle,
1190
+ ]}
1191
+ >
1192
+ {LinearGradientComponent && gradientCfg ? (
1193
+ <LinearGradientComponent
1194
+ pointerEvents="none"
1195
+ colors={gradientCfg.colors}
1196
+ start={gradientCfg.start}
1197
+ end={gradientCfg.end}
1198
+ style={[StyleSheet.absoluteFillObject, radiusStyle]}
1199
+ />
1200
+ ) : null}
1201
+
1202
+ {iconOnly ? (
1203
+ (icon ?? resolvedText)
1204
+ ) : (
1205
+ <>
1206
+ {icon && iconPlacement === 'start' ? (
1207
+ <View style={[iconBaseBoxStyle, iconPlacedBoxStyle]}>{icon}</View>
1208
+ ) : null}
1209
+ {resolvedText}
1210
+ {icon && iconPlacement === 'end' ? (
1211
+ <View style={[iconBaseBoxStyle, iconPlacedBoxStyle]}>{icon}</View>
1212
+ ) : null}
1213
+ </>
1214
+ )}
1215
+ </View>
1216
+ </Pressable>
1217
+ );
1218
+ }
1219
+
1220
+ function ButtonAnimatedImpl(
1221
+ {
1222
+ variant = 'solid',
1223
+ tone = 'primary',
1224
+ size = 'md',
1225
+ shape = 'rounded',
1226
+ block = false,
1227
+ disabled = false,
1228
+ loading = false,
1229
+ loadingMode = 'inline',
1230
+ pressEffect = 'auto',
1231
+ icon,
1232
+ iconPlacement = 'start',
1233
+ iconOnly = false,
1234
+ color,
1235
+ colors,
1236
+ border,
1237
+ layout,
1238
+ gradient,
1239
+ shadow = 'none',
1240
+ children,
1241
+ style,
1242
+ contentStyle,
1243
+ textStyle,
1244
+ accessibilityLabel,
1245
+ accessibilityState,
1246
+ hitSlop,
1247
+ onPress,
1248
+ onPressIn,
1249
+ onPressOut,
1250
+ testID,
1251
+ ...pressableProps
1252
+ }: ButtonProps,
1253
+ ref: React.ForwardedRef<ButtonRef>
1254
+ ) {
1255
+ const resolvedLoadingMode = iconOnly ? 'overlay' : loadingMode;
1256
+ const centeredLoading = resolvedLoadingMode !== 'inline';
1257
+ const frame = useButtonFrame({
1258
+ accessibilityLabel,
1259
+ block,
1260
+ border,
1261
+ children,
1262
+ color,
1263
+ colors,
1264
+ disabled,
1265
+ gradient,
1266
+ icon,
1267
+ iconOnly,
1268
+ iconPlacement,
1269
+ layout,
1270
+ loading,
1271
+ shadow,
1272
+ shape,
1273
+ size,
1274
+ textStyle,
1275
+ tone,
1276
+ variant,
1277
+ });
1278
+ const {
1279
+ accentColor,
1280
+ borderStyle,
1281
+ contentGap,
1282
+ contentSizeStyle,
1283
+ defaultHitSlop,
1284
+ gradientCfg,
1285
+ hasGradientBackground,
1286
+ hasIcon,
1287
+ hasText,
1288
+ iconBaseBoxStyle,
1289
+ iconPlacedBoxStyle,
1290
+ iconSize,
1291
+ interactionDisabled,
1292
+ LinearGradientComponent,
1293
+ metrics,
1294
+ paddingStyle,
1295
+ radiusStyle,
1296
+ resolvedAccessibilityLabel,
1297
+ resolvedText,
1298
+ rootSizeStyle,
1299
+ shadowStyle,
1300
+ visualColors,
1301
+ visualDisabled,
1302
+ webCursorStyle,
1303
+ } = frame;
1304
+
1305
+ const loadingSize = React.useMemo(
1306
+ () =>
1307
+ resolveLoadingSize({
1308
+ iconOnly,
1309
+ layout,
1310
+ metrics,
1311
+ variant,
1312
+ }),
1313
+ [iconOnly, layout, metrics, variant]
1314
+ );
1315
+
1037
1316
  const pressSv = useSharedValue(0);
1038
1317
  const loadingSv = useSharedValue(loading ? 1 : 0);
1039
1318
  const [spinnerMounted, setSpinnerMounted] = React.useState(loading);
@@ -1075,23 +1354,32 @@ function ButtonImpl(
1075
1354
 
1076
1355
  if (loading) {
1077
1356
  setSpinnerMounted(true);
1357
+ loadingSv.value = withTiming(1, LOADING_TIMING);
1358
+ return () => {
1359
+ if (hideSpinnerTimerRef.current) {
1360
+ clearTimeout(hideSpinnerTimerRef.current);
1361
+ hideSpinnerTimerRef.current = null;
1362
+ }
1363
+ };
1078
1364
  }
1079
1365
 
1080
- loadingSv.value = withTiming(loading ? 1 : 0, LOADING_TIMING);
1081
-
1082
- if (!loading) {
1083
- hideSpinnerTimerRef.current = setTimeout(() => {
1084
- setSpinnerMounted(false);
1085
- }, LOADING_TIMING.duration);
1366
+ if (!spinnerMounted) {
1367
+ loadingSv.value = 0;
1368
+ return undefined;
1086
1369
  }
1087
1370
 
1371
+ loadingSv.value = withTiming(0, LOADING_TIMING);
1372
+ hideSpinnerTimerRef.current = setTimeout(() => {
1373
+ setSpinnerMounted(false);
1374
+ }, LOADING_TIMING.duration);
1375
+
1088
1376
  return () => {
1089
1377
  if (hideSpinnerTimerRef.current) {
1090
1378
  clearTimeout(hideSpinnerTimerRef.current);
1091
1379
  hideSpinnerTimerRef.current = null;
1092
1380
  }
1093
1381
  };
1094
- }, [loading, loadingSv]);
1382
+ }, [loading, loadingSv, spinnerMounted]);
1095
1383
 
1096
1384
  const rootAnimatedStyle = useAnimatedStyle(() => {
1097
1385
  const baseOpacity = visualDisabled ? DISABLED_OPACITY : 1;
@@ -1311,6 +1599,20 @@ function ButtonImpl(
1311
1599
  );
1312
1600
  }
1313
1601
 
1602
+ const ButtonStaticWithRef = React.forwardRef<ButtonRef, ButtonProps>(ButtonStaticImpl);
1603
+ ButtonStaticWithRef.displayName = 'ButtonStatic';
1604
+
1605
+ const ButtonAnimatedWithRef = React.forwardRef<ButtonRef, ButtonProps>(ButtonAnimatedImpl);
1606
+ ButtonAnimatedWithRef.displayName = 'ButtonAnimated';
1607
+
1608
+ function ButtonImpl(props: ButtonProps, ref: React.ForwardedRef<ButtonRef>) {
1609
+ if (shouldUseStaticButtonPath(props)) {
1610
+ return <ButtonStaticWithRef {...props} ref={ref} />;
1611
+ }
1612
+
1613
+ return <ButtonAnimatedWithRef {...props} ref={ref} />;
1614
+ }
1615
+
1314
1616
  const ButtonWithRef = React.forwardRef<ButtonRef, ButtonProps>(ButtonImpl);
1315
1617
  ButtonWithRef.displayName = 'Button';
1316
1618
 
@@ -1342,6 +1644,9 @@ const styles = StyleSheet.create({
1342
1644
  textDecorationLine: 'underline',
1343
1645
  },
1344
1646
  root: {},
1647
+ disabledRoot: {
1648
+ opacity: DISABLED_OPACITY,
1649
+ },
1345
1650
  spinnerBox: {
1346
1651
  alignItems: 'center',
1347
1652
  justifyContent: 'center',
@@ -10,6 +10,7 @@ Checkbox 是三端一致的复选框组件,适合协议勾选、多选列表
10
10
  - 非受控模式先在 UI 线程推进动画,再同步内部状态
11
11
  - `CheckboxGroup` 使用 `value/defaultValue/onChange` 管理选中集合
12
12
  - 默认触控热区至少补到 `wp(44)`,小尺寸也保持可点性
13
+ - 自定义 `layout.indicatorSize` 但未传 `layout.indicatorIconSize` 时,默认勾号会按最终指示器尺寸等比缩放并取整
13
14
 
14
15
  ## 基础用法
15
16
 
@@ -102,6 +103,8 @@ export function Demo() {
102
103
  />
103
104
  ```
104
105
 
106
+ 只覆盖 `indicatorSize` 时,内置勾号尺寸会随最终指示器尺寸按比例取整;显式传入 `indicatorIconSize` 或 `indicatorRadius` 时仍以调用侧配置为准。`shape="circle"` 且未传 `indicatorRadius` 时,默认圆角会对 `indicatorSize / 2` 取整,减少 17、18 等小尺寸下的半像素圆角毛边。
107
+
105
108
  ## 自定义内容
106
109
 
107
110
  `children` 支持 render-prop。根节点本身就是可点击区域,子节点通常只读取状态做视觉变化,不需要再绑定一次点击。
@@ -149,7 +152,7 @@ Checkbox 透传 React Native `Pressable` 的常用属性和事件;组件自身
149
152
  - `shape?: 'rounded' | 'square' | 'circle'`:指示器形状,默认 `'rounded'`。
150
153
  - `color?: string`:主色覆盖,支持主题 token、语义色或颜色字符串。
151
154
  - `colors?: CheckboxColors`:结构化颜色覆盖。
152
- - `layout?: CheckboxLayout`:结构化尺寸覆盖。
155
+ - `layout?: CheckboxLayout`:结构化尺寸覆盖;自定义 `indicatorSize` 且未显式传 `indicatorIconSize` 时,勾号默认随最终尺寸缩放。
153
156
  - `duration?: number`:状态切换动画时长,默认 `170`。
154
157
 
155
158
  ### 内容
@@ -274,6 +274,7 @@ const SIZE_TOKENS = {
274
274
  const DISABLED_OPACITY = 0.48;
275
275
  const PRESSED_OPACITY = 0.86;
276
276
  const PRESSED_SCALE = 0.965;
277
+ const CUSTOM_INDICATOR_ICON_SIZE_RATIO = 0.625;
277
278
  const VALUE_TIMING_DURATION = 170;
278
279
  const PRESS_IN_DURATION = 90;
279
280
  const PRESS_OUT_DURATION = 140;
@@ -438,9 +439,19 @@ function resolveCheckboxMetrics(
438
439
  layout: CheckboxLayout | undefined
439
440
  ): CheckboxMetrics {
440
441
  const token = SIZE_TOKENS[size] ?? SIZE_TOKENS.md;
441
- const indicatorSize = resolvePositiveNumber(layout?.indicatorSize, wp(token.indicatorSize));
442
+ const tokenIndicatorSize = wp(token.indicatorSize);
443
+ const tokenIndicatorIconSize = wp(token.indicatorIconSize);
444
+ const hasCustomIndicatorSize = isPositiveNumber(layout?.indicatorSize);
445
+ const indicatorSize = resolvePositiveNumber(layout?.indicatorSize, tokenIndicatorSize);
446
+ const fallbackIconSize = hasCustomIndicatorSize
447
+ ? Math.max(wp(1), Math.round(indicatorSize * CUSTOM_INDICATOR_ICON_SIZE_RATIO))
448
+ : tokenIndicatorIconSize;
442
449
  const fallbackRadius =
443
- shape === 'circle' ? indicatorSize / 2 : shape === 'square' ? 0 : wp(token.indicatorRadius);
450
+ shape === 'circle'
451
+ ? Math.round(indicatorSize / 2)
452
+ : shape === 'square'
453
+ ? 0
454
+ : wp(token.indicatorRadius);
444
455
 
445
456
  return {
446
457
  indicatorSize,
@@ -449,7 +460,7 @@ function resolveCheckboxMetrics(
449
460
  layout?.indicatorBorderWidth,
450
461
  wp(token.indicatorBorderWidth)
451
462
  ),
452
- indicatorIconSize: resolvePositiveNumber(layout?.indicatorIconSize, wp(token.indicatorIconSize)),
463
+ indicatorIconSize: resolvePositiveNumber(layout?.indicatorIconSize, fallbackIconSize),
453
464
  gap: resolveNonNegativeNumber(layout?.gap, wp(token.gap)),
454
465
  minTouchTarget: resolvePositiveNumber(layout?.minTouchTarget, wp(44)),
455
466
  focusRingWidth: resolvePositiveNumber(layout?.focusRingWidth, wp(2)),
@@ -457,8 +468,12 @@ function resolveCheckboxMetrics(
457
468
  };
458
469
  }
459
470
 
471
+ function isPositiveNumber(value: number | undefined): value is number {
472
+ return typeof value === 'number' && Number.isFinite(value) && value > 0;
473
+ }
474
+
460
475
  function resolvePositiveNumber(value: number | undefined, fallback: number) {
461
- return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback;
476
+ return isPositiveNumber(value) ? value : fallback;
462
477
  }
463
478
 
464
479
  function resolveNonNegativeNumber(value: number | undefined, fallback: number) {
@@ -11,6 +11,7 @@ Radio 是三端一致的单选组件,适合互斥选项、设置项模式选
11
11
  - 已选项默认再次点击不取消;筛选类场景需要清空时显式开启 `allowDeselect`
12
12
  - `size/tone/variant` 提供语义化外观,`colors/layout` 作为集中 escape hatch
13
13
  - 默认触控热区至少补到 `wp(44)`,小尺寸也保持可点性
14
+ - 自定义 `layout.indicatorSize` 但未传 `layout.indicatorDotSize` 时,默认内点会按最终指示器尺寸等比缩放并取整
14
15
 
15
16
  ## RadioGroup
16
17
 
@@ -82,6 +83,8 @@ Radio 默认遵循单选控件直觉:点击已选项不会取消。需要筛
82
83
  />
83
84
  ```
84
85
 
86
+ 只覆盖 `indicatorSize` 时,内置圆点尺寸会随最终指示器尺寸按比例取整;显式传入 `indicatorDotSize` 时仍以调用侧配置为准。Radio 外圈默认圆角会对 `indicatorSize / 2` 取整,减少 17、18 等小尺寸下的半像素圆角毛边。
87
+
85
88
  ## 自定义内容
86
89
 
87
90
  `children` 支持 render-prop。根节点本身就是可点击区域,子节点通常只读取状态做视觉变化,不需要再绑定一次点击。
@@ -148,7 +151,7 @@ Radio 透传 React Native `Pressable` 的常用属性和事件;组件自身固
148
151
  - `variant?: 'outline' | 'soft' | 'solid'`:选中态视觉样式,默认 `'outline'`。
149
152
  - `color?: string`:主色覆盖,支持主题 token、语义色或颜色字符串。
150
153
  - `colors?: RadioColors`:结构化颜色覆盖。
151
- - `layout?: RadioLayout`:结构化尺寸覆盖。
154
+ - `layout?: RadioLayout`:结构化尺寸覆盖;自定义 `indicatorSize` 且未显式传 `indicatorDotSize` 时,圆点默认随最终尺寸缩放。
152
155
  - `duration?: number`:状态切换动画时长,默认 `170`。
153
156
 
154
157
  ### 内容
@@ -72,6 +72,7 @@ export type RadioSlotProps = {
72
72
 
73
73
  type RadioMetrics = {
74
74
  indicatorSize: number;
75
+ indicatorRadius: number;
75
76
  indicatorDotSize: number;
76
77
  indicatorBorderWidth: number;
77
78
  gap: number;
@@ -233,12 +234,16 @@ const SIZE_TOKENS = {
233
234
  },
234
235
  } as const satisfies Record<
235
236
  RadioSize,
236
- Omit<RadioMetrics, 'minTouchTarget' | 'focusRingWidth' | 'focusRingOffset'>
237
+ Omit<
238
+ RadioMetrics,
239
+ 'indicatorRadius' | 'minTouchTarget' | 'focusRingWidth' | 'focusRingOffset'
240
+ >
237
241
  >;
238
242
 
239
243
  const DISABLED_OPACITY = 0.48;
240
244
  const PRESSED_OPACITY = 0.86;
241
245
  const PRESSED_SCALE = 0.965;
246
+ const CUSTOM_INDICATOR_DOT_SIZE_RATIO = 0.45;
242
247
  const VALUE_TIMING_DURATION = 170;
243
248
  const PRESS_IN_DURATION = 90;
244
249
  const PRESS_OUT_DURATION = 140;
@@ -377,14 +382,18 @@ function resolveVariantColors(
377
382
 
378
383
  function resolveRadioMetrics(size: RadioSize, layout: RadioLayout | undefined): RadioMetrics {
379
384
  const token = SIZE_TOKENS[size] ?? SIZE_TOKENS.md;
380
- const indicatorSize = resolvePositiveNumber(layout?.indicatorSize, wp(token.indicatorSize));
385
+ const tokenIndicatorSize = wp(token.indicatorSize);
386
+ const tokenIndicatorDotSize = wp(token.indicatorDotSize);
387
+ const hasCustomIndicatorSize = isPositiveNumber(layout?.indicatorSize);
388
+ const indicatorSize = resolvePositiveNumber(layout?.indicatorSize, tokenIndicatorSize);
389
+ const fallbackDotSize = hasCustomIndicatorSize
390
+ ? Math.max(wp(1), Math.round(indicatorSize * CUSTOM_INDICATOR_DOT_SIZE_RATIO))
391
+ : tokenIndicatorDotSize;
381
392
 
382
393
  return {
383
394
  indicatorSize,
384
- indicatorDotSize: resolvePositiveNumber(
385
- layout?.indicatorDotSize,
386
- wp(token.indicatorDotSize)
387
- ),
395
+ indicatorRadius: Math.round(indicatorSize / 2),
396
+ indicatorDotSize: resolvePositiveNumber(layout?.indicatorDotSize, fallbackDotSize),
388
397
  indicatorBorderWidth: resolvePositiveNumber(
389
398
  layout?.indicatorBorderWidth,
390
399
  wp(token.indicatorBorderWidth)
@@ -396,8 +405,12 @@ function resolveRadioMetrics(size: RadioSize, layout: RadioLayout | undefined):
396
405
  };
397
406
  }
398
407
 
408
+ function isPositiveNumber(value: number | undefined): value is number {
409
+ return typeof value === 'number' && Number.isFinite(value) && value > 0;
410
+ }
411
+
399
412
  function resolvePositiveNumber(value: number | undefined, fallback: number) {
400
- return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback;
413
+ return isPositiveNumber(value) ? value : fallback;
401
414
  }
402
415
 
403
416
  function resolveNonNegativeNumber(value: number | undefined, fallback: number) {
@@ -798,10 +811,10 @@ function RadioImpl(
798
811
  () => ({
799
812
  width: metrics.indicatorSize,
800
813
  height: metrics.indicatorSize,
801
- borderRadius: metrics.indicatorSize / 2,
814
+ borderRadius: metrics.indicatorRadius,
802
815
  borderWidth: metrics.indicatorBorderWidth,
803
816
  }),
804
- [metrics.indicatorBorderWidth, metrics.indicatorSize]
817
+ [metrics.indicatorBorderWidth, metrics.indicatorRadius, metrics.indicatorSize]
805
818
  );
806
819
 
807
820
  const indicatorNode = showIndicator ? (
@@ -823,7 +836,7 @@ function RadioImpl(
823
836
  bottom: -metrics.focusRingOffset,
824
837
  left: -metrics.focusRingOffset,
825
838
  right: -metrics.focusRingOffset,
826
- borderRadius: metrics.indicatorSize / 2 + metrics.focusRingOffset,
839
+ borderRadius: metrics.indicatorRadius + metrics.focusRingOffset,
827
840
  borderWidth: metrics.focusRingWidth,
828
841
  borderColor: resolvedColors.focusRing,
829
842
  },
@@ -10,6 +10,8 @@
10
10
  - 默认使用 `getMaxFontSizeMultiplier()` 限制字体缩放上限,单次可用 `maxFontSizeMultiplier` 覆盖。
11
11
  - Android 默认关闭 `includeFontPadding`,用明确 lineHeight 保证 iOS / Android / Web 视觉高度更一致。
12
12
  - `color` 支持主题 token、语义色 token 和原生可解析颜色;无效颜色会回落到 `tone`。
13
+ - `style.fontSize` 作为最终 escape hatch 覆盖字号时,如果没有显式 `lineHeight`,组件不会继续注入默认行高,会交给原生 Text 按字体度量排版。
14
+ - 嵌套在 zkit `Text` 内部的 `Text`,未显式传入 `variant / size / lineHeight / style.fontSize / style.lineHeight` 时会继承父级排版。
13
15
  - 屏幕宽度变化时会重新计算排版尺寸,横竖屏和分屏场景不会卡在初始化尺寸。
14
16
 
15
17
  ## 基础用法
@@ -22,7 +24,7 @@ export function Demo() {
22
24
  }
23
25
  ```
24
26
 
25
- 默认等价于:
27
+ 单独使用时默认等价于:
26
28
 
27
29
  - `variant="body"`
28
30
  - `tone="default"`
@@ -52,7 +54,7 @@ export function Demo() {
52
54
  }
53
55
  ```
54
56
 
55
- `variant` 只决定默认 `size / weight / fontFamily`,显式传入 `size`、`weight` 或 `style` 时会覆盖对应值。
57
+ `variant` 只决定默认 `size / weight / fontFamily`,显式传入 `size`、`weight` 或 `style` 时会覆盖对应值。嵌套 `Text` 未显式声明排版时不会重新套用默认 `body`,以保持内联链接、强调文案和协议文字的字号稳定。
56
58
 
57
59
  ## 尺寸
58
60
 
@@ -88,6 +90,7 @@ export function Demo() {
88
90
  | `4xl` | `wp(36)` | `wp(44)` |
89
91
 
90
92
  传入数字 `size` 或 `lineHeight` 时,数字代表未缩放的设计像素,组件内部会统一转换。
93
+ 通过 `style.fontSize` 直接覆盖字号时,调用侧应传入已经计算好的原生字号;如果没有同时传入 `lineHeight` 或 `style.lineHeight`,组件会移除内置 token 行高,避免字号和行高不匹配。
91
94
 
92
95
  ## 颜色
93
96