@unif/react-native-design 0.31.2 → 0.32.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 (169) hide show
  1. package/README.md +6 -0
  2. package/lib/module/components/ui/Avatar/Avatar.js +2 -1
  3. package/lib/module/components/ui/Avatar/Avatar.js.map +1 -1
  4. package/lib/module/components/ui/Avatar/geometry.js +43 -0
  5. package/lib/module/components/ui/Avatar/geometry.js.map +1 -0
  6. package/lib/module/components/ui/Avatar/styles.js +1 -41
  7. package/lib/module/components/ui/Avatar/styles.js.map +1 -1
  8. package/lib/module/components/ui/AvatarGroup/AvatarGroup.js +1 -1
  9. package/lib/module/components/ui/AvatarGroup/AvatarGroup.js.map +1 -1
  10. package/lib/module/components/ui/BorderBeam/normalizeBorderBeam.js +2 -2
  11. package/lib/module/components/ui/CircularProgress/CircularProgress.js +14 -25
  12. package/lib/module/components/ui/CircularProgress/CircularProgress.js.map +1 -1
  13. package/lib/module/components/ui/CircularProgress/normalizeCircularProgress.js.map +1 -1
  14. package/lib/module/components/ui/CircularProgress/styles.js +24 -0
  15. package/lib/module/components/ui/CircularProgress/styles.js.map +1 -0
  16. package/lib/module/components/ui/Pulse/PulseDot.js +5 -5
  17. package/lib/module/components/ui/Pulse/PulseDot.js.map +1 -1
  18. package/lib/module/components/ui/Pulse/constants.js +15 -0
  19. package/lib/module/components/ui/Pulse/constants.js.map +1 -0
  20. package/lib/module/components/ui/Pulse/usePulse.js +4 -62
  21. package/lib/module/components/ui/Pulse/usePulse.js.map +1 -1
  22. package/lib/module/components/ui/Skeleton/Skeleton.js +6 -2
  23. package/lib/module/components/ui/Skeleton/Skeleton.js.map +1 -1
  24. package/lib/module/components/ui/Skeleton/constants.js +9 -0
  25. package/lib/module/components/ui/Skeleton/constants.js.map +1 -0
  26. package/lib/module/components/ui/TextField/TextFieldBase.js +21 -4
  27. package/lib/module/components/ui/TextField/TextFieldBase.js.map +1 -1
  28. package/lib/module/components/ui/TextField/constants.js +7 -0
  29. package/lib/module/components/ui/TextField/constants.js.map +1 -0
  30. package/lib/module/components/ui/TextField/styles.js +10 -2
  31. package/lib/module/components/ui/TextField/styles.js.map +1 -1
  32. package/lib/module/components/ui/TextField/useMultilineLayout.js +18 -0
  33. package/lib/module/components/ui/TextField/useMultilineLayout.js.map +1 -0
  34. package/lib/module/components/ui/TextField/useMultilineLayout.web.js +44 -0
  35. package/lib/module/components/ui/TextField/useMultilineLayout.web.js.map +1 -0
  36. package/lib/module/components/ui/Thumbnail/Thumbnail.js +13 -6
  37. package/lib/module/components/ui/Thumbnail/Thumbnail.js.map +1 -1
  38. package/lib/module/components/ui/Thumbnail/constants.js +22 -0
  39. package/lib/module/components/ui/Thumbnail/constants.js.map +1 -0
  40. package/lib/module/components/ui/Thumbnail/normalize.js +30 -1
  41. package/lib/module/components/ui/Thumbnail/normalize.js.map +1 -1
  42. package/lib/module/components/ui/Thumbnail/styles.js +5 -28
  43. package/lib/module/components/ui/Thumbnail/styles.js.map +1 -1
  44. package/lib/module/components/ui/index.js.map +1 -1
  45. package/lib/module/components/ui/shared/pulse/constants.js +5 -0
  46. package/lib/module/components/ui/shared/pulse/constants.js.map +1 -0
  47. package/lib/module/components/ui/{Pulse → shared/pulse}/normalizePulseOptions.js +2 -12
  48. package/lib/module/components/ui/shared/pulse/normalizePulseOptions.js.map +1 -0
  49. package/lib/module/components/ui/shared/pulse/types.js +2 -0
  50. package/lib/module/components/ui/shared/pulse/types.js.map +1 -0
  51. package/lib/module/components/ui/shared/pulse/usePulseAnimation.js +30 -0
  52. package/lib/module/components/ui/shared/pulse/usePulseAnimation.js.map +1 -0
  53. package/lib/module/components/ui/{Pulse → shared/pulse}/usePulseDriver.js +1 -1
  54. package/lib/module/components/ui/shared/pulse/usePulseDriver.js.map +1 -0
  55. package/lib/module/components/ui/{Pulse → shared/pulse}/usePulseDriver.web.js +2 -17
  56. package/lib/module/components/ui/shared/pulse/usePulseDriver.web.js.map +1 -0
  57. package/lib/typescript/src/components/ui/Avatar/Avatar.d.ts.map +1 -1
  58. package/lib/typescript/src/components/ui/Avatar/geometry.d.ts +7 -0
  59. package/lib/typescript/src/components/ui/Avatar/geometry.d.ts.map +1 -0
  60. package/lib/typescript/src/components/ui/Avatar/styles.d.ts +1 -9
  61. package/lib/typescript/src/components/ui/Avatar/styles.d.ts.map +1 -1
  62. package/lib/typescript/src/components/ui/Avatar/types.d.ts +7 -2
  63. package/lib/typescript/src/components/ui/Avatar/types.d.ts.map +1 -1
  64. package/lib/typescript/src/components/ui/BorderBeam/types.d.ts +2 -2
  65. package/lib/typescript/src/components/ui/CircularProgress/CircularProgress.d.ts.map +1 -1
  66. package/lib/typescript/src/components/ui/CircularProgress/normalizeCircularProgress.d.ts +2 -14
  67. package/lib/typescript/src/components/ui/CircularProgress/normalizeCircularProgress.d.ts.map +1 -1
  68. package/lib/typescript/src/components/ui/CircularProgress/styles.d.ts +19 -0
  69. package/lib/typescript/src/components/ui/CircularProgress/styles.d.ts.map +1 -0
  70. package/lib/typescript/src/components/ui/CircularProgress/types.d.ts +17 -2
  71. package/lib/typescript/src/components/ui/CircularProgress/types.d.ts.map +1 -1
  72. package/lib/typescript/src/components/ui/Pulse/PulseDot.d.ts.map +1 -1
  73. package/lib/typescript/src/components/ui/Pulse/constants.d.ts +4 -0
  74. package/lib/typescript/src/components/ui/Pulse/constants.d.ts.map +1 -0
  75. package/lib/typescript/src/components/ui/Pulse/types.d.ts +6 -6
  76. package/lib/typescript/src/components/ui/Pulse/types.d.ts.map +1 -1
  77. package/lib/typescript/src/components/ui/Pulse/usePulse.d.ts +0 -9
  78. package/lib/typescript/src/components/ui/Pulse/usePulse.d.ts.map +1 -1
  79. package/lib/typescript/src/components/ui/Skeleton/Skeleton.d.ts.map +1 -1
  80. package/lib/typescript/src/components/ui/Skeleton/constants.d.ts +3 -0
  81. package/lib/typescript/src/components/ui/Skeleton/constants.d.ts.map +1 -0
  82. package/lib/typescript/src/components/ui/Skeleton/types.d.ts +2 -2
  83. package/lib/typescript/src/components/ui/Skeleton/types.d.ts.map +1 -1
  84. package/lib/typescript/src/components/ui/TextField/TextFieldBase.d.ts.map +1 -1
  85. package/lib/typescript/src/components/ui/TextField/constants.d.ts +4 -0
  86. package/lib/typescript/src/components/ui/TextField/constants.d.ts.map +1 -0
  87. package/lib/typescript/src/components/ui/TextField/styles.d.ts +6 -0
  88. package/lib/typescript/src/components/ui/TextField/styles.d.ts.map +1 -1
  89. package/lib/typescript/src/components/ui/TextField/types.d.ts +36 -11
  90. package/lib/typescript/src/components/ui/TextField/types.d.ts.map +1 -1
  91. package/lib/typescript/src/components/ui/TextField/useMultilineLayout.d.ts +4 -0
  92. package/lib/typescript/src/components/ui/TextField/useMultilineLayout.d.ts.map +1 -0
  93. package/lib/typescript/src/components/ui/TextField/useMultilineLayout.web.d.ts +4 -0
  94. package/lib/typescript/src/components/ui/TextField/useMultilineLayout.web.d.ts.map +1 -0
  95. package/lib/typescript/src/components/ui/Textarea/types.d.ts +4 -2
  96. package/lib/typescript/src/components/ui/Textarea/types.d.ts.map +1 -1
  97. package/lib/typescript/src/components/ui/Thumbnail/Thumbnail.d.ts.map +1 -1
  98. package/lib/typescript/src/components/ui/Thumbnail/constants.d.ts +4 -0
  99. package/lib/typescript/src/components/ui/Thumbnail/constants.d.ts.map +1 -0
  100. package/lib/typescript/src/components/ui/Thumbnail/index.d.ts +1 -1
  101. package/lib/typescript/src/components/ui/Thumbnail/index.d.ts.map +1 -1
  102. package/lib/typescript/src/components/ui/Thumbnail/normalize.d.ts +2 -6
  103. package/lib/typescript/src/components/ui/Thumbnail/normalize.d.ts.map +1 -1
  104. package/lib/typescript/src/components/ui/Thumbnail/styles.d.ts +9 -8
  105. package/lib/typescript/src/components/ui/Thumbnail/styles.d.ts.map +1 -1
  106. package/lib/typescript/src/components/ui/Thumbnail/types.d.ts +23 -5
  107. package/lib/typescript/src/components/ui/Thumbnail/types.d.ts.map +1 -1
  108. package/lib/typescript/src/components/ui/index.d.ts +1 -1
  109. package/lib/typescript/src/components/ui/index.d.ts.map +1 -1
  110. package/lib/typescript/src/components/ui/shared/pulse/constants.d.ts +2 -0
  111. package/lib/typescript/src/components/ui/shared/pulse/constants.d.ts.map +1 -0
  112. package/lib/typescript/src/components/ui/shared/pulse/normalizePulseOptions.d.ts +7 -0
  113. package/lib/typescript/src/components/ui/shared/pulse/normalizePulseOptions.d.ts.map +1 -0
  114. package/lib/typescript/src/components/ui/shared/pulse/types.d.ts +17 -0
  115. package/lib/typescript/src/components/ui/shared/pulse/types.d.ts.map +1 -0
  116. package/lib/typescript/src/components/ui/shared/pulse/usePulseAnimation.d.ts +8 -0
  117. package/lib/typescript/src/components/ui/shared/pulse/usePulseAnimation.d.ts.map +1 -0
  118. package/lib/typescript/src/components/ui/{Pulse → shared/pulse}/usePulseDriver.d.ts +1 -1
  119. package/lib/typescript/src/components/ui/shared/pulse/usePulseDriver.d.ts.map +1 -0
  120. package/lib/typescript/src/components/ui/shared/pulse/usePulseDriver.web.d.ts +7 -0
  121. package/lib/typescript/src/components/ui/shared/pulse/usePulseDriver.web.d.ts.map +1 -0
  122. package/package.json +1 -1
  123. package/src/components/ui/Avatar/Avatar.tsx +2 -6
  124. package/src/components/ui/Avatar/geometry.ts +30 -0
  125. package/src/components/ui/Avatar/styles.ts +2 -30
  126. package/src/components/ui/Avatar/types.ts +8 -2
  127. package/src/components/ui/AvatarGroup/AvatarGroup.tsx +1 -1
  128. package/src/components/ui/BorderBeam/normalizeBorderBeam.ts +2 -2
  129. package/src/components/ui/BorderBeam/types.ts +2 -2
  130. package/src/components/ui/CircularProgress/CircularProgress.tsx +20 -26
  131. package/src/components/ui/CircularProgress/normalizeCircularProgress.ts +7 -15
  132. package/src/components/ui/CircularProgress/styles.ts +13 -0
  133. package/src/components/ui/CircularProgress/types.ts +19 -2
  134. package/src/components/ui/Pulse/PulseDot.tsx +6 -5
  135. package/src/components/ui/Pulse/constants.ts +14 -0
  136. package/src/components/ui/Pulse/types.ts +6 -6
  137. package/src/components/ui/Pulse/usePulse.ts +4 -81
  138. package/src/components/ui/Skeleton/Skeleton.tsx +6 -6
  139. package/src/components/ui/Skeleton/constants.ts +8 -0
  140. package/src/components/ui/Skeleton/types.ts +2 -2
  141. package/src/components/ui/TextField/TextFieldBase.tsx +28 -4
  142. package/src/components/ui/TextField/constants.ts +6 -0
  143. package/src/components/ui/TextField/styles.ts +8 -2
  144. package/src/components/ui/TextField/types.ts +41 -11
  145. package/src/components/ui/TextField/useMultilineLayout.ts +20 -0
  146. package/src/components/ui/TextField/useMultilineLayout.web.ts +59 -0
  147. package/src/components/ui/Textarea/types.ts +9 -6
  148. package/src/components/ui/Thumbnail/Thumbnail.tsx +19 -5
  149. package/src/components/ui/Thumbnail/constants.ts +24 -0
  150. package/src/components/ui/Thumbnail/index.ts +1 -0
  151. package/src/components/ui/Thumbnail/normalize.ts +38 -19
  152. package/src/components/ui/Thumbnail/styles.ts +6 -17
  153. package/src/components/ui/Thumbnail/types.ts +27 -5
  154. package/src/components/ui/index.ts +1 -0
  155. package/src/components/ui/shared/pulse/constants.ts +2 -0
  156. package/src/components/ui/shared/pulse/normalizePulseOptions.ts +48 -0
  157. package/src/components/ui/shared/pulse/types.ts +18 -0
  158. package/src/components/ui/shared/pulse/usePulseAnimation.ts +29 -0
  159. package/src/components/ui/{Pulse → shared/pulse}/usePulseDriver.ts +2 -2
  160. package/src/components/ui/{Pulse → shared/pulse}/usePulseDriver.web.ts +4 -19
  161. package/lib/module/components/ui/Pulse/normalizePulseOptions.js.map +0 -1
  162. package/lib/module/components/ui/Pulse/usePulseDriver.js.map +0 -1
  163. package/lib/module/components/ui/Pulse/usePulseDriver.web.js.map +0 -1
  164. package/lib/typescript/src/components/ui/Pulse/normalizePulseOptions.d.ts +0 -32
  165. package/lib/typescript/src/components/ui/Pulse/normalizePulseOptions.d.ts.map +0 -1
  166. package/lib/typescript/src/components/ui/Pulse/usePulseDriver.d.ts.map +0 -1
  167. package/lib/typescript/src/components/ui/Pulse/usePulseDriver.web.d.ts +0 -18
  168. package/lib/typescript/src/components/ui/Pulse/usePulseDriver.web.d.ts.map +0 -1
  169. package/src/components/ui/Pulse/normalizePulseOptions.ts +0 -76
@@ -0,0 +1,8 @@
1
+ import type { PulseDefaults } from '../shared/pulse/types';
2
+
3
+ export const SKELETON_DEFAULTS: Readonly<PulseDefaults> = {
4
+ duration: 700,
5
+ delay: 0,
6
+ from: 0.5,
7
+ to: 1,
8
+ };
@@ -2,7 +2,7 @@ import type { DimensionValue, StyleProp, ViewStyle } from 'react-native';
2
2
 
3
3
  export type SkeletonShape = 'line' | 'rect' | 'circle';
4
4
 
5
- export type SkeletonProps = {
5
+ export interface SkeletonProps {
6
6
  /** 形状,默认 'rect'。
7
7
  * - 'line' → width 100% / height 11 / radius 3(文本占位)
8
8
  * - 'rect' → width 100% / height 80 / radius 8(图片或卡片占位)
@@ -19,4 +19,4 @@ export type SkeletonProps = {
19
19
  style?: StyleProp<ViewStyle>;
20
20
  /** E2E / 测试定位 */
21
21
  testID?: string;
22
- };
22
+ }
@@ -7,7 +7,7 @@ import React, {
7
7
  } from 'react';
8
8
  import { Platform, Text, TextInput, View } from 'react-native';
9
9
  import type { TextInputProps } from 'react-native';
10
- import { fixed, space, useColors, useThemedStyles } from '../../../theme';
10
+ import { fixed, useColors, useThemedStyles } from '../../../theme';
11
11
  import { childTestID } from '../../../utils/testID';
12
12
  import { createLogger } from '../../../utils/logger';
13
13
  import {
@@ -20,6 +20,7 @@ import { TextFieldSlot } from './TextFieldSlot';
20
20
  import type { TextFieldBaseProps, TextFieldHandle } from './types';
21
21
  import { useErrorAnnouncement } from './useErrorAnnouncement';
22
22
  import { useTextFieldValue } from './useTextFieldValue';
23
+ import { useMultilineLayout } from './useMultilineLayout';
23
24
 
24
25
  const log = createLogger('TextField');
25
26
 
@@ -49,6 +50,9 @@ export const TextFieldBase = forwardRef<TextFieldHandle, TextFieldBaseProps>(
49
50
  placeholderTextColor: callerPlaceholder,
50
51
  onFocus,
51
52
  onBlur,
53
+ onContentSizeChange,
54
+ onLayout,
55
+ scrollEnabled,
52
56
  testID,
53
57
  style: _style,
54
58
  numberOfLines: _numberOfLines,
@@ -94,6 +98,15 @@ export const TextFieldBase = forwardRef<TextFieldHandle, TextFieldBaseProps>(
94
98
  minHeight,
95
99
  maxHeight
96
100
  );
101
+ const multilineLayout = useMultilineLayout({
102
+ enabled: multiline,
103
+ value: controller.value,
104
+ minHeight: normalizedTextareaHeights.minHeight,
105
+ maxHeight: normalizedTextareaHeights.maxHeight,
106
+ fontSize: styles.input.fontSize,
107
+ placeholder: allowedNativeProps.placeholder as string | undefined,
108
+ inputRef: nativeRef,
109
+ });
97
110
  const sanitizedContainer = sanitizeTextFieldContainerStyle(containerStyle);
98
111
  const effectiveEditable = disabled !== true && editable !== false;
99
112
  const mergedAccessibilityState = {
@@ -193,6 +206,15 @@ export const TextFieldBase = forwardRef<TextFieldHandle, TextFieldBaseProps>(
193
206
  onChangeText={controller.onChangeText}
194
207
  editable={effectiveEditable}
195
208
  multiline={multiline}
209
+ onContentSizeChange={onContentSizeChange}
210
+ onLayout={(event) => {
211
+ if (multiline) multilineLayout.onLayout?.(event);
212
+ onLayout?.(event);
213
+ }}
214
+ scrollEnabled={
215
+ scrollEnabled ??
216
+ (multiline ? multilineLayout.scrollEnabled : undefined)
217
+ }
196
218
  onFocus={(event) => {
197
219
  setFocused(true);
198
220
  onFocus?.(event);
@@ -204,9 +226,11 @@ export const TextFieldBase = forwardRef<TextFieldHandle, TextFieldBaseProps>(
204
226
  style={[
205
227
  styles.input,
206
228
  multiline && styles.inputMultiline,
207
- multiline && {
208
- minHeight: Math.max(0, normalizedHeight - space[4] * 2),
209
- },
229
+ multiline &&
230
+ (multilineLayout.scrollEnabled
231
+ ? styles.inputScrolling
232
+ : styles.inputClipped),
233
+ multiline && multilineLayout.inputStyle,
210
234
  searchLayout !== undefined && {
211
235
  minHeight: searchLayout.interactiveHeight,
212
236
  },
@@ -0,0 +1,6 @@
1
+ import { space } from '../../../theme';
2
+
3
+ export const TEXT_FIELD_BORDER_WIDTH = 1;
4
+ export const TEXTAREA_VERTICAL_PADDING = space[4];
5
+ export const TEXTAREA_VERTICAL_INSET =
6
+ 2 * (TEXTAREA_VERTICAL_PADDING + TEXT_FIELD_BORDER_WIDTH);
@@ -1,6 +1,10 @@
1
1
  import { StyleSheet } from 'react-native';
2
2
  import { fw, radius, space, type as t } from '../../../theme';
3
3
  import type { ColorTokens } from '../../../theme';
4
+ import {
5
+ TEXTAREA_VERTICAL_PADDING,
6
+ TEXT_FIELD_BORDER_WIDTH,
7
+ } from './constants';
4
8
 
5
9
  /** TextField primitive styles —— Input + Textarea 共享。 */
6
10
  export const makeStyles = (c: ColorTokens) =>
@@ -11,10 +15,10 @@ export const makeStyles = (c: ColorTokens) =>
11
15
  gap: space[3],
12
16
  paddingHorizontal: space[5],
13
17
  borderRadius: radius.md,
14
- borderWidth: 1,
18
+ borderWidth: TEXT_FIELD_BORDER_WIDTH,
15
19
  },
16
20
  wrapMultiline: {
17
- paddingVertical: space[4],
21
+ paddingVertical: TEXTAREA_VERTICAL_PADDING,
18
22
  alignItems: 'flex-start',
19
23
  },
20
24
  /** Search 的可见底色/边框绝不承载命中区域。 */
@@ -59,6 +63,8 @@ export const makeStyles = (c: ColorTokens) =>
59
63
  inputMultiline: {
60
64
  textAlignVertical: 'top' as const,
61
65
  },
66
+ inputScrolling: { overflow: 'scroll' },
67
+ inputClipped: { overflow: 'hidden' },
62
68
  slotDisplay: {
63
69
  alignItems: 'center',
64
70
  justifyContent: 'center',
@@ -1,4 +1,11 @@
1
- import type { StyleProp, TextInputProps, ViewStyle } from 'react-native';
1
+ import type { ComponentRef, RefObject } from 'react';
2
+ import type { TextInput } from 'react-native';
3
+ import type {
4
+ StyleProp,
5
+ TextInputProps,
6
+ TextStyle,
7
+ ViewStyle,
8
+ } from 'react-native';
2
9
  import type { IconName } from '../Icon';
3
10
 
4
11
  /**
@@ -7,18 +14,18 @@ import type { IconName } from '../Icon';
7
14
  * `defaultValue?: never` 让「同时传 value 和 defaultValue」在类型层就报错 ——
8
15
  * 那是最常见的受控/非受控混用写法,运行时表现为「defaultValue 被静默忽略」。
9
16
  */
10
- export type ControlledTextValueProps = {
17
+ export interface ControlledTextValueProps {
11
18
  value: string;
12
19
  onChangeText: (value: string) => void;
13
20
  defaultValue?: never;
14
- };
21
+ }
15
22
 
16
23
  /** 非受控:内部持值,`defaultValue` 只在首次 render 读一次。 */
17
- export type UncontrolledTextValueProps = {
24
+ export interface UncontrolledTextValueProps {
18
25
  value?: never;
19
26
  defaultValue?: string;
20
27
  onChangeText?: (value: string) => void;
21
- };
28
+ }
22
29
 
23
30
  /** internal —— 由 InputProps / TextareaProps / SearchProps 组合进公共类型。 */
24
31
  export type TextFieldValueProps =
@@ -31,10 +38,10 @@ export type TextFieldValueProps =
31
38
  * 为什么不暴露原生 TextInput —— `clear()` 会绕过值状态机(受控下把 UI 清空但调用方
32
39
  * state 不变),`setNativeProps()` 能直接改任意原生属性,两者都会让公共契约失效。
33
40
  */
34
- export type TextFieldHandle = {
41
+ export interface TextFieldHandle {
35
42
  focus: () => void;
36
43
  blur: () => void;
37
- };
44
+ }
38
45
 
39
46
  /**
40
47
  * 左右 slot 的可验证配置。**不接受任意 ReactNode** ——
@@ -67,14 +74,14 @@ export type TextFieldContainerStyle = Omit<
67
74
  >;
68
75
 
69
76
  /** internal:Search 把真实交互行与仅作视觉的 surface 明确分层。 */
70
- export type SearchFieldLayout = {
77
+ export interface SearchFieldLayout {
71
78
  interactiveHeight: number;
72
79
  visibleHeight: number;
73
80
  verticalInset: number;
74
- };
81
+ }
75
82
 
76
83
  /** TextField 家族共享的非值 props(内部)。 */
77
- export type TextFieldCommonProps = {
84
+ export interface TextFieldCommonProps {
78
85
  /** 左侧 slot —— 图标 / 文本 / 带 handler 与名称的操作 */
79
86
  leading?: TextFieldSlot;
80
87
  /** 右侧 slot —— 图标 / 文本 / 带 handler 与名称的操作 */
@@ -85,7 +92,7 @@ export type TextFieldCommonProps = {
85
92
  disabled?: boolean;
86
93
  /** 外层容器样式;不接受会破坏最小 frame 的尺寸字段 */
87
94
  containerStyle?: StyleProp<TextFieldContainerStyle>;
88
- };
95
+ }
89
96
 
90
97
  /**
91
98
  * 这些原生 TextInput props 从公共类型删除:
@@ -127,3 +134,26 @@ export type TextFieldBaseProps = Omit<TextInputProps, RemovedTextInputProps> &
127
134
  /** internal:Search 的 44pt interactive row + 36pt decorative surface。 */
128
135
  searchLayout?: SearchFieldLayout;
129
136
  };
137
+
138
+ export interface MultilineLayoutInput {
139
+ enabled: boolean;
140
+ value: string;
141
+ minHeight: number;
142
+ maxHeight: number | undefined;
143
+ fontSize: number | undefined;
144
+ placeholder: string | undefined;
145
+ inputRef: RefObject<ComponentRef<typeof TextInput> | null>;
146
+ }
147
+
148
+ export interface MultilineLayout {
149
+ inputStyle: Pick<TextStyle, 'height' | 'minHeight' | 'maxHeight'>;
150
+ scrollEnabled: boolean;
151
+ onLayout?: TextInputProps['onLayout'];
152
+ }
153
+
154
+ /** RN Web 的公开 host ref 仅在平台实现内使用这两个 DOM 测量属性。 */
155
+ export interface WebTextInputMeasurement {
156
+ scrollHeight: number;
157
+ scrollTop: number;
158
+ style: { height: string; minHeight: string };
159
+ }
@@ -0,0 +1,20 @@
1
+ import { TEXTAREA_VERTICAL_INSET } from './constants';
2
+ import type { MultilineLayout, MultilineLayoutInput } from './types';
3
+
4
+ /** 原生 Yoga 直接测量文本;固定 height 会阻断 iOS Fabric 的后续内容尺寸通知。 */
5
+ export function useMultilineLayout({
6
+ minHeight,
7
+ maxHeight,
8
+ }: MultilineLayoutInput): MultilineLayout {
9
+ return {
10
+ inputStyle: {
11
+ minHeight: Math.max(0, minHeight - TEXTAREA_VERTICAL_INSET),
12
+ maxHeight:
13
+ maxHeight === undefined
14
+ ? undefined
15
+ : Math.max(0, maxHeight - TEXTAREA_VERTICAL_INSET),
16
+ },
17
+ // 原生仅在内容溢出时滚动;min=max 时也不等待不会再变化的框高事件。
18
+ scrollEnabled: true,
19
+ };
20
+ }
@@ -0,0 +1,59 @@
1
+ import { useCallback, useLayoutEffect, useState } from 'react';
2
+ import { TEXTAREA_VERTICAL_INSET } from './constants';
3
+ import type {
4
+ MultilineLayout,
5
+ MultilineLayoutInput,
6
+ WebTextInputMeasurement,
7
+ } from './types';
8
+
9
+ /** RNW 的 contentSize 使用 scrollHeight,固定高度会阻止缩短,须临时解除高度后测量。 */
10
+ export function useMultilineLayout({
11
+ enabled,
12
+ value,
13
+ minHeight,
14
+ maxHeight,
15
+ fontSize,
16
+ placeholder,
17
+ inputRef,
18
+ }: MultilineLayoutInput): MultilineLayout {
19
+ const [contentHeight, setContentHeight] = useState(0);
20
+ const [width, setWidth] = useState(0);
21
+ const minimum = Math.max(0, minHeight - TEXTAREA_VERTICAL_INSET);
22
+ const maximum =
23
+ maxHeight === undefined
24
+ ? Infinity
25
+ : Math.max(0, maxHeight - TEXTAREA_VERTICAL_INSET);
26
+ useLayoutEffect(() => {
27
+ if (!enabled || inputRef.current === null) return;
28
+ const node = inputRef.current as unknown as WebTextInputMeasurement;
29
+ const { height, minHeight: previousMinimum } = node.style;
30
+ const scrollTop = node.scrollTop;
31
+ node.style.height = '0px';
32
+ node.style.minHeight = '0px';
33
+ const measured = node.scrollHeight;
34
+ node.style.height = height;
35
+ node.style.minHeight = previousMinimum;
36
+ node.scrollTop = scrollTop;
37
+ if (Number.isFinite(measured)) setContentHeight(measured);
38
+ }, [
39
+ enabled,
40
+ value,
41
+ width,
42
+ fontSize,
43
+ placeholder,
44
+ minHeight,
45
+ maxHeight,
46
+ inputRef,
47
+ ]);
48
+ const onLayout = useCallback<NonNullable<MultilineLayout['onLayout']>>(
49
+ (event) => setWidth(event.nativeEvent.layout.width),
50
+ []
51
+ );
52
+ return {
53
+ inputStyle: {
54
+ height: Math.min(maximum, Math.max(minimum, contentHeight)),
55
+ },
56
+ scrollEnabled: contentHeight > maximum,
57
+ onLayout,
58
+ };
59
+ }
@@ -13,9 +13,12 @@ import type {
13
13
  */
14
14
  export type TextareaProps = Omit<TextInputProps, RemovedTextInputProps> &
15
15
  TextFieldCommonProps &
16
- TextFieldValueProps & {
17
- /** 最小高度,默认 96(约 5 行) */
18
- minHeight?: number;
19
- /** 最大高度(超过后内部滚动),默认不限制 */
20
- maxHeight?: number;
21
- };
16
+ TextFieldValueProps &
17
+ TextareaHeightProps;
18
+
19
+ interface TextareaHeightProps {
20
+ /** 最小高度,默认 96(约 5 行) */
21
+ minHeight?: number;
22
+ /** 最大高度(超过后内部滚动),默认不限制 */
23
+ maxHeight?: number;
24
+ }
@@ -5,8 +5,11 @@ import { resolveImageSource } from '../../../utils/imageSource';
5
5
  import { createLogger } from '../../../utils/logger';
6
6
  import { ImageAttempt } from '../shared/ImageAttempt';
7
7
  import { A11Y_HIDDEN_PROPS } from '../shared/a11y';
8
- import { sanitizeThumbnailImageStyle } from './normalize';
9
- import { makeStyles, sizingFor } from './styles';
8
+ import {
9
+ normalizeThumbnailDimensions,
10
+ sanitizeThumbnailImageStyle,
11
+ } from './normalize';
12
+ import { makeStyles } from './styles';
10
13
  import type { ThumbnailProps } from './types';
11
14
 
12
15
  const log = createLogger('Thumbnail');
@@ -22,6 +25,7 @@ export function Thumbnail(props: ThumbnailProps): React.JSX.Element {
22
25
  uri,
23
26
  source,
24
27
  size = 'md',
28
+ fallback,
25
29
  selected,
26
30
  containerStyle,
27
31
  imageStyle,
@@ -31,7 +35,8 @@ export function Thumbnail(props: ThumbnailProps): React.JSX.Element {
31
35
  } = props;
32
36
  const colors = useColors();
33
37
  const styles = useThemedStyles(makeStyles);
34
- const dim = sizingFor(size);
38
+ const normalizedDimensions = normalizeThumbnailDimensions(size);
39
+ const dim = normalizedDimensions.dimensions;
35
40
  const hasUri = hasOwn(props, 'uri');
36
41
  const hasSource = hasOwn(props, 'source');
37
42
  const sourceCandidate =
@@ -43,6 +48,7 @@ export function Thumbnail(props: ThumbnailProps): React.JSX.Element {
43
48
  const resolvedSource = resolveImageSource(sourceCandidate);
44
49
  const sanitizedImageStyle = sanitizeThumbnailImageStyle(imageStyle);
45
50
  const diagnostics = [
51
+ ...normalizedDimensions.diagnostics,
46
52
  ...(resolvedSource === undefined ? ['source'] : []),
47
53
  ...sanitizedImageStyle.diagnostics.map((field) =>
48
54
  field === 'style' ? 'imageStyle' : `imageStyle.${field}`
@@ -72,16 +78,24 @@ export function Thumbnail(props: ThumbnailProps): React.JSX.Element {
72
78
  borderRadius: dim.borderRadius,
73
79
  borderColor: selected ? colors.primary : 'transparent',
74
80
  };
81
+ const fallbackContent =
82
+ fallback === undefined ? null : (
83
+ <View {...imageAccessibilityProps} style={styles.fallback}>
84
+ {fallback}
85
+ </View>
86
+ );
75
87
 
76
88
  return (
77
89
  <View style={containerStyle} testID={testID}>
78
90
  <View style={[styles.visualFrame, dim]}>
79
- {resolvedSource === undefined ? null : (
91
+ {resolvedSource === undefined ? (
92
+ fallbackContent
93
+ ) : (
80
94
  <ImageAttempt
81
95
  {...imageAccessibilityProps}
82
96
  key={resolvedSource.key}
83
97
  source={resolvedSource.source}
84
- fallback={null}
98
+ fallback={fallbackContent}
85
99
  style={[sanitizedImageStyle.style, StyleSheet.absoluteFill]}
86
100
  resizeMode={resizeMode}
87
101
  />
@@ -0,0 +1,24 @@
1
+ import { r } from '../../../theme';
2
+ import type { ThumbnailDimensions, ThumbnailSize } from './types';
3
+
4
+ export const THUMBNAIL_DIMENSIONS: Readonly<
5
+ Record<ThumbnailSize, Required<ThumbnailDimensions>>
6
+ > = {
7
+ sm: { width: r(64), height: r(40), borderRadius: r(6) },
8
+ md: { width: r(113), height: r(67), borderRadius: r(8) },
9
+ lg: { width: r(160), height: r(96), borderRadius: r(10) },
10
+ };
11
+
12
+ export const RESERVED_IMAGE_STYLE_KEYS = [
13
+ 'position',
14
+ 'top',
15
+ 'right',
16
+ 'bottom',
17
+ 'left',
18
+ 'width',
19
+ 'height',
20
+ 'minWidth',
21
+ 'minHeight',
22
+ 'maxWidth',
23
+ 'maxHeight',
24
+ ] as const;
@@ -1,5 +1,6 @@
1
1
  export { Thumbnail } from './Thumbnail';
2
2
  export type {
3
+ ThumbnailDimensions,
3
4
  ThumbnailImageStyle,
4
5
  ThumbnailProps,
5
6
  ThumbnailSize,
@@ -1,25 +1,44 @@
1
1
  import { StyleSheet } from 'react-native';
2
2
  import type { StyleProp } from 'react-native';
3
- import type { ThumbnailImageStyle } from './types';
3
+ import type {
4
+ NormalizedThumbnailDimensions,
5
+ SanitizedThumbnailImageStyle,
6
+ ThumbnailImageStyle,
7
+ } from './types';
8
+ import { RESERVED_IMAGE_STYLE_KEYS, THUMBNAIL_DIMENSIONS } from './constants';
4
9
 
5
- const RESERVED_IMAGE_STYLE_KEYS = [
6
- 'position',
7
- 'top',
8
- 'right',
9
- 'bottom',
10
- 'left',
11
- 'width',
12
- 'height',
13
- 'minWidth',
14
- 'minHeight',
15
- 'maxWidth',
16
- 'maxHeight',
17
- ] as const;
18
-
19
- type SanitizedThumbnailImageStyle = {
20
- style: ThumbnailImageStyle;
21
- diagnostics: readonly string[];
22
- };
10
+ export function normalizeThumbnailDimensions(
11
+ size: unknown
12
+ ): NormalizedThumbnailDimensions {
13
+ if (size === undefined || size === 'sm' || size === 'md' || size === 'lg') {
14
+ return { dimensions: THUMBNAIL_DIMENSIONS[size ?? 'md'], diagnostics: [] };
15
+ }
16
+ if (typeof size === 'object' && size !== null && !Array.isArray(size)) {
17
+ const { width, height, borderRadius } = size as Record<string, unknown>;
18
+ if (
19
+ typeof width === 'number' &&
20
+ Number.isFinite(width) &&
21
+ width > 0 &&
22
+ typeof height === 'number' &&
23
+ Number.isFinite(height) &&
24
+ height > 0 &&
25
+ (borderRadius === undefined ||
26
+ (typeof borderRadius === 'number' &&
27
+ Number.isFinite(borderRadius) &&
28
+ borderRadius >= 0))
29
+ ) {
30
+ return {
31
+ dimensions: {
32
+ width,
33
+ height,
34
+ borderRadius: borderRadius ?? THUMBNAIL_DIMENSIONS.md.borderRadius,
35
+ },
36
+ diagnostics: [],
37
+ };
38
+ }
39
+ }
40
+ return { dimensions: THUMBNAIL_DIMENSIONS.md, diagnostics: ['size'] };
41
+ }
23
42
 
24
43
  function invalidStyle(): SanitizedThumbnailImageStyle {
25
44
  return { style: {}, diagnostics: ['style'] };
@@ -1,6 +1,5 @@
1
1
  import { StyleSheet } from 'react-native';
2
- import { r, type ColorTokens } from '../../../theme';
3
- import type { ThumbnailSize } from './types';
2
+ import { type ColorTokens } from '../../../theme';
4
3
 
5
4
  /** Thumbnail 静态样式 ——
6
5
  * - `visualFrame`:固定尺寸、裁切、加载失败 placeholder
@@ -14,19 +13,9 @@ export const makeStyles = (c: ColorTokens) =>
14
13
  ring: {
15
14
  borderWidth: 2,
16
15
  },
16
+ fallback: {
17
+ ...StyleSheet.absoluteFill,
18
+ alignItems: 'center',
19
+ justifyContent: 'center',
20
+ },
17
21
  });
18
-
19
- /** Thumbnail size 阶梯 —— width / height / borderRadius 全 r() 缩放。
20
- * 新增 size 在 types.ts 加 union + 这里加 case,两处同步。 */
21
- export const sizingFor = (size: ThumbnailSize) => {
22
- switch (size) {
23
- case 'sm':
24
- return { width: r(64), height: r(40), borderRadius: r(6) };
25
- case 'lg':
26
- return { width: r(160), height: r(96), borderRadius: r(10) };
27
- case 'md':
28
- default:
29
- // 113×67 与 NewsList / NewsArea 历史一致,16:9.5 接近视频宽高比
30
- return { width: r(113), height: r(67), borderRadius: r(8) };
31
- }
32
- };
@@ -1,3 +1,4 @@
1
+ import type { ReactNode } from 'react';
1
2
  import type {
2
3
  ImageProps,
3
4
  ImageSourcePropType,
@@ -6,12 +7,29 @@ import type {
6
7
  ViewStyle,
7
8
  } from 'react-native';
8
9
 
9
- /** 缩略图尺寸阶梯 —— 跨场景统一,新增 size 在这里 + sizingFor.ts 同步。
10
+ /** 缩略图尺寸阶梯 —— 跨场景统一,新增阶梯在这里与 constants.ts 同步。
10
11
  * - sm:64×40 chat 行内、列表二级图缩略
11
12
  * - md:113×67 NewsList / NewsArea 公告右侧缩略(默认)
12
13
  * - lg:160×96 详情顶部 hero 小图、卡片头图缩略 */
13
14
  export type ThumbnailSize = 'sm' | 'md' | 'lg';
14
15
 
16
+ /** 已计算好的 RN 布局尺寸;组件不再调用 r() 缩放。 */
17
+ export interface ThumbnailDimensions {
18
+ width: number;
19
+ height: number;
20
+ borderRadius?: number;
21
+ }
22
+
23
+ export interface NormalizedThumbnailDimensions {
24
+ dimensions: Required<ThumbnailDimensions>;
25
+ diagnostics: readonly string[];
26
+ }
27
+
28
+ export interface SanitizedThumbnailImageStyle {
29
+ style: ThumbnailImageStyle;
30
+ diagnostics: readonly string[];
31
+ }
32
+
15
33
  export type ThumbnailImageStyle = Omit<
16
34
  ImageStyle,
17
35
  | 'position'
@@ -39,9 +57,11 @@ type ThumbnailSource =
39
57
  uri?: never;
40
58
  };
41
59
 
42
- export type ThumbnailProps = ThumbnailSource & {
43
- /** 视觉尺寸阶梯,默认 'md'。 */
44
- size?: ThumbnailSize;
60
+ interface ThumbnailCommonProps {
61
+ /** 原三档或实际图像框尺寸,默认 md;非法对象整体回退 md。 */
62
+ size?: ThumbnailSize | Readonly<ThumbnailDimensions>;
63
+ /** 无有效 source 或当前图片失败时居中显示;加载中不显示。 */
64
+ fallback?: ReactNode;
45
65
  /** 选中态:在固定 visual frame 内显示 2pt 品牌色 ring。 */
46
66
  selected?: boolean;
47
67
  /** 完整外部布局样式,只落到稳定根 View。 */
@@ -54,4 +74,6 @@ export type ThumbnailProps = ThumbnailSource & {
54
74
  accessibilityLabel?: string;
55
75
  /** E2E / 测试定位,只落到外层 layout View。 */
56
76
  testID?: string;
57
- };
77
+ }
78
+
79
+ export type ThumbnailProps = ThumbnailSource & ThumbnailCommonProps;
@@ -97,6 +97,7 @@ export type { TextareaProps } from './Textarea';
97
97
  export { Thumbnail } from './Thumbnail';
98
98
  export type {
99
99
  ThumbnailImageStyle,
100
+ ThumbnailDimensions,
100
101
  ThumbnailProps,
101
102
  ThumbnailSize,
102
103
  } from './Thumbnail';
@@ -0,0 +1,2 @@
1
+ // 平台计时器使用 int32,达到 2^31 会溢出。
2
+ export const MAX_TIMER = 2 ** 31;
@@ -0,0 +1,48 @@
1
+ import type { PulseOptions } from '../../Pulse/types';
2
+ import type {
3
+ NormalizedPulseOptions,
4
+ PulseDefaults,
5
+ PulseDiagnostic,
6
+ } from './types';
7
+ import { MAX_TIMER } from './constants';
8
+
9
+ /** 共用的参数校验:非法字段采用所属组件默认值并报告诊断,不夹值或取整。 */
10
+ export function normalizePulseOptions(
11
+ input: PulseOptions = {},
12
+ defaults: Readonly<PulseDefaults>
13
+ ): NormalizedPulseOptions {
14
+ const diagnostics: PulseDiagnostic[] = [];
15
+ const pick = (
16
+ field: keyof PulseDefaults,
17
+ valid: (value: number) => boolean
18
+ ): number => {
19
+ const received = input[field];
20
+ if (received === undefined) return defaults[field];
21
+ if (typeof received === 'number' && valid(received)) return received;
22
+ diagnostics.push({ field, received, fallback: defaults[field] });
23
+ return defaults[field];
24
+ };
25
+ const duration = pick(
26
+ 'duration',
27
+ (value) => Number.isFinite(value) && value >= 1 && value < MAX_TIMER
28
+ );
29
+ const delay = pick(
30
+ 'delay',
31
+ (value) => Number.isFinite(value) && value >= 0 && value < MAX_TIMER
32
+ );
33
+ const from = pick(
34
+ 'from',
35
+ (value) => Number.isFinite(value) && value >= 0 && value <= 1
36
+ );
37
+ const to = pick(
38
+ 'to',
39
+ (value) => Number.isFinite(value) && value >= 0 && value <= 1
40
+ );
41
+ return { duration, delay, from, to, isStatic: from === to, diagnostics };
42
+ }
43
+
44
+ /** driver 是否应当启动循环。静态或系统开启减弱动效时都不启动。 */
45
+ export const shouldAnimatePulse = (
46
+ options: NormalizedPulseOptions,
47
+ reducedMotion: boolean
48
+ ): boolean => !reducedMotion && !options.isStatic;