@unif/react-native-design 0.33.1 → 0.34.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 (160) hide show
  1. package/lib/module/components/ui/ActionMenuContent/ActionMenuContent.js +97 -0
  2. package/lib/module/components/ui/ActionMenuContent/ActionMenuContent.js.map +1 -0
  3. package/lib/module/components/ui/ActionMenuContent/index.js +4 -0
  4. package/lib/module/components/ui/ActionMenuContent/index.js.map +1 -0
  5. package/lib/module/components/ui/ActionMenuContent/styles.js +54 -0
  6. package/lib/module/components/ui/ActionMenuContent/styles.js.map +1 -0
  7. package/lib/module/components/ui/ActionMenuContent/types.js.map +1 -0
  8. package/lib/module/components/ui/Button/ButtonBase.js +10 -2
  9. package/lib/module/components/ui/Button/ButtonBase.js.map +1 -1
  10. package/lib/module/components/ui/IconButton/IconButton.js +5 -1
  11. package/lib/module/components/ui/IconButton/IconButton.js.map +1 -1
  12. package/lib/module/components/ui/ImagePreview/ImagePreview.js +200 -0
  13. package/lib/module/components/ui/ImagePreview/ImagePreview.js.map +1 -0
  14. package/lib/module/components/ui/ImagePreview/constants.js +5 -0
  15. package/lib/module/components/ui/ImagePreview/constants.js.map +1 -0
  16. package/lib/module/components/ui/ImagePreview/index.js +4 -0
  17. package/lib/module/components/ui/ImagePreview/index.js.map +1 -0
  18. package/lib/module/components/ui/ImagePreview/styles.js +55 -0
  19. package/lib/module/components/ui/ImagePreview/styles.js.map +1 -0
  20. package/lib/module/components/ui/ImagePreview/types.js +4 -0
  21. package/lib/module/components/ui/ImagePreview/types.js.map +1 -0
  22. package/lib/module/components/ui/Sheet/Sheet.js +40 -0
  23. package/lib/module/components/ui/Sheet/Sheet.js.map +1 -0
  24. package/lib/module/components/ui/Sheet/index.js +4 -0
  25. package/lib/module/components/ui/Sheet/index.js.map +1 -0
  26. package/lib/module/components/ui/Sheet/styles.js +41 -0
  27. package/lib/module/components/ui/Sheet/styles.js.map +1 -0
  28. package/lib/module/components/ui/Sheet/types.js +4 -0
  29. package/lib/module/components/ui/Sheet/types.js.map +1 -0
  30. package/lib/module/components/ui/SmsField/SmsField.js +40 -0
  31. package/lib/module/components/ui/SmsField/SmsField.js.map +1 -0
  32. package/lib/module/components/ui/SmsField/index.js +4 -0
  33. package/lib/module/components/ui/SmsField/index.js.map +1 -0
  34. package/lib/module/components/ui/SmsField/types.js +4 -0
  35. package/lib/module/components/ui/SmsField/types.js.map +1 -0
  36. package/lib/module/components/ui/TextEntryContent/TextEntryContent.js +60 -0
  37. package/lib/module/components/ui/TextEntryContent/TextEntryContent.js.map +1 -0
  38. package/lib/module/components/ui/TextEntryContent/index.js +4 -0
  39. package/lib/module/components/ui/TextEntryContent/index.js.map +1 -0
  40. package/lib/module/components/ui/TextEntryContent/styles.js +29 -0
  41. package/lib/module/components/ui/TextEntryContent/styles.js.map +1 -0
  42. package/lib/module/components/ui/TextEntryContent/types.js +4 -0
  43. package/lib/module/components/ui/TextEntryContent/types.js.map +1 -0
  44. package/lib/module/components/ui/index.js +5 -1
  45. package/lib/module/components/ui/index.js.map +1 -1
  46. package/lib/module/icons/data.js +87 -1
  47. package/lib/module/icons/data.js.map +1 -1
  48. package/lib/typescript/src/components/ui/ActionMenuContent/ActionMenuContent.d.ts +4 -0
  49. package/lib/typescript/src/components/ui/ActionMenuContent/ActionMenuContent.d.ts.map +1 -0
  50. package/lib/typescript/src/components/ui/ActionMenuContent/index.d.ts +3 -0
  51. package/lib/typescript/src/components/ui/ActionMenuContent/index.d.ts.map +1 -0
  52. package/lib/typescript/src/components/ui/ActionMenuContent/styles.d.ts +51 -0
  53. package/lib/typescript/src/components/ui/ActionMenuContent/styles.d.ts.map +1 -0
  54. package/lib/typescript/src/components/ui/ActionMenuContent/types.d.ts +25 -0
  55. package/lib/typescript/src/components/ui/ActionMenuContent/types.d.ts.map +1 -0
  56. package/lib/typescript/src/components/ui/Button/ButtonBase.d.ts +3 -1
  57. package/lib/typescript/src/components/ui/Button/ButtonBase.d.ts.map +1 -1
  58. package/lib/typescript/src/components/ui/IconButton/IconButton.d.ts +1 -1
  59. package/lib/typescript/src/components/ui/IconButton/IconButton.d.ts.map +1 -1
  60. package/lib/typescript/src/components/ui/IconButton/types.d.ts +4 -0
  61. package/lib/typescript/src/components/ui/IconButton/types.d.ts.map +1 -1
  62. package/lib/typescript/src/components/ui/ImagePreview/ImagePreview.d.ts +3 -0
  63. package/lib/typescript/src/components/ui/ImagePreview/ImagePreview.d.ts.map +1 -0
  64. package/lib/typescript/src/components/ui/ImagePreview/constants.d.ts +3 -0
  65. package/lib/typescript/src/components/ui/ImagePreview/constants.d.ts.map +1 -0
  66. package/lib/typescript/src/components/ui/ImagePreview/index.d.ts +3 -0
  67. package/lib/typescript/src/components/ui/ImagePreview/index.d.ts.map +1 -0
  68. package/lib/typescript/src/components/ui/ImagePreview/styles.d.ts +56 -0
  69. package/lib/typescript/src/components/ui/ImagePreview/styles.d.ts.map +1 -0
  70. package/lib/typescript/src/components/ui/ImagePreview/types.d.ts +33 -0
  71. package/lib/typescript/src/components/ui/ImagePreview/types.d.ts.map +1 -0
  72. package/lib/typescript/src/components/ui/Sheet/Sheet.d.ts +4 -0
  73. package/lib/typescript/src/components/ui/Sheet/Sheet.d.ts.map +1 -0
  74. package/lib/typescript/src/components/ui/Sheet/index.d.ts +3 -0
  75. package/lib/typescript/src/components/ui/Sheet/index.d.ts.map +1 -0
  76. package/lib/typescript/src/components/ui/Sheet/styles.d.ts +38 -0
  77. package/lib/typescript/src/components/ui/Sheet/styles.d.ts.map +1 -0
  78. package/lib/typescript/src/components/ui/Sheet/types.d.ts +13 -0
  79. package/lib/typescript/src/components/ui/Sheet/types.d.ts.map +1 -0
  80. package/lib/typescript/src/components/ui/SmsField/SmsField.d.ts +4 -0
  81. package/lib/typescript/src/components/ui/SmsField/SmsField.d.ts.map +1 -0
  82. package/lib/typescript/src/components/ui/SmsField/index.d.ts +3 -0
  83. package/lib/typescript/src/components/ui/SmsField/index.d.ts.map +1 -0
  84. package/lib/typescript/src/components/ui/SmsField/types.d.ts +12 -0
  85. package/lib/typescript/src/components/ui/SmsField/types.d.ts.map +1 -0
  86. package/lib/typescript/src/components/ui/TextEntryContent/TextEntryContent.d.ts +4 -0
  87. package/lib/typescript/src/components/ui/TextEntryContent/TextEntryContent.d.ts.map +1 -0
  88. package/lib/typescript/src/components/ui/TextEntryContent/index.d.ts +3 -0
  89. package/lib/typescript/src/components/ui/TextEntryContent/index.d.ts.map +1 -0
  90. package/lib/typescript/src/components/ui/TextEntryContent/styles.d.ts +26 -0
  91. package/lib/typescript/src/components/ui/TextEntryContent/styles.d.ts.map +1 -0
  92. package/lib/typescript/src/components/ui/TextEntryContent/types.d.ts +18 -0
  93. package/lib/typescript/src/components/ui/TextEntryContent/types.d.ts.map +1 -0
  94. package/lib/typescript/src/components/ui/index.d.ts +10 -2
  95. package/lib/typescript/src/components/ui/index.d.ts.map +1 -1
  96. package/lib/typescript/src/icons/data.d.ts +1 -1
  97. package/lib/typescript/src/icons/data.d.ts.map +1 -1
  98. package/lib/typescript/type-tests/content-api.d.ts +2 -0
  99. package/lib/typescript/type-tests/content-api.d.ts.map +1 -0
  100. package/package.json +1 -1
  101. package/src/components/ui/ActionMenuContent/ActionMenuContent.tsx +118 -0
  102. package/src/components/ui/ActionMenuContent/index.ts +6 -0
  103. package/src/components/ui/ActionMenuContent/styles.ts +47 -0
  104. package/src/components/ui/ActionMenuContent/types.ts +27 -0
  105. package/src/components/ui/Button/ButtonBase.tsx +11 -2
  106. package/src/components/ui/IconButton/IconButton.tsx +9 -1
  107. package/src/components/ui/IconButton/types.ts +4 -0
  108. package/src/components/ui/ImagePreview/ImagePreview.tsx +243 -0
  109. package/src/components/ui/ImagePreview/constants.ts +2 -0
  110. package/src/components/ui/ImagePreview/index.ts +6 -0
  111. package/src/components/ui/ImagePreview/styles.ts +48 -0
  112. package/src/components/ui/ImagePreview/types.ts +37 -0
  113. package/src/components/ui/Sheet/Sheet.tsx +43 -0
  114. package/src/components/ui/Sheet/index.ts +2 -0
  115. package/src/components/ui/Sheet/styles.ts +40 -0
  116. package/src/components/ui/Sheet/types.ts +14 -0
  117. package/src/components/ui/SmsField/SmsField.tsx +40 -0
  118. package/src/components/ui/SmsField/index.ts +2 -0
  119. package/src/components/ui/SmsField/types.ts +15 -0
  120. package/src/components/ui/TextEntryContent/TextEntryContent.tsx +56 -0
  121. package/src/components/ui/TextEntryContent/index.ts +2 -0
  122. package/src/components/ui/TextEntryContent/styles.ts +17 -0
  123. package/src/components/ui/TextEntryContent/types.ts +18 -0
  124. package/src/components/ui/index.ts +18 -2
  125. package/src/icons/data.ts +134 -0
  126. package/lib/module/components/ui/BorderBeam/BorderBeam.js +0 -102
  127. package/lib/module/components/ui/BorderBeam/BorderBeam.js.map +0 -1
  128. package/lib/module/components/ui/BorderBeam/BorderBeam.web.js +0 -108
  129. package/lib/module/components/ui/BorderBeam/BorderBeam.web.js.map +0 -1
  130. package/lib/module/components/ui/BorderBeam/index.js +0 -4
  131. package/lib/module/components/ui/BorderBeam/index.js.map +0 -1
  132. package/lib/module/components/ui/BorderBeam/normalizeBorderBeam.js +0 -23
  133. package/lib/module/components/ui/BorderBeam/normalizeBorderBeam.js.map +0 -1
  134. package/lib/module/components/ui/BorderBeam/shared.js +0 -54
  135. package/lib/module/components/ui/BorderBeam/shared.js.map +0 -1
  136. package/lib/module/components/ui/BorderBeam/styles.js +0 -18
  137. package/lib/module/components/ui/BorderBeam/styles.js.map +0 -1
  138. package/lib/module/components/ui/BorderBeam/types.js.map +0 -1
  139. package/lib/typescript/src/components/ui/BorderBeam/BorderBeam.d.ts +0 -10
  140. package/lib/typescript/src/components/ui/BorderBeam/BorderBeam.d.ts.map +0 -1
  141. package/lib/typescript/src/components/ui/BorderBeam/BorderBeam.web.d.ts +0 -5
  142. package/lib/typescript/src/components/ui/BorderBeam/BorderBeam.web.d.ts.map +0 -1
  143. package/lib/typescript/src/components/ui/BorderBeam/index.d.ts +0 -3
  144. package/lib/typescript/src/components/ui/BorderBeam/index.d.ts.map +0 -1
  145. package/lib/typescript/src/components/ui/BorderBeam/normalizeBorderBeam.d.ts +0 -16
  146. package/lib/typescript/src/components/ui/BorderBeam/normalizeBorderBeam.d.ts.map +0 -1
  147. package/lib/typescript/src/components/ui/BorderBeam/shared.d.ts +0 -28
  148. package/lib/typescript/src/components/ui/BorderBeam/shared.d.ts.map +0 -1
  149. package/lib/typescript/src/components/ui/BorderBeam/styles.d.ts +0 -15
  150. package/lib/typescript/src/components/ui/BorderBeam/styles.d.ts.map +0 -1
  151. package/lib/typescript/src/components/ui/BorderBeam/types.d.ts +0 -23
  152. package/lib/typescript/src/components/ui/BorderBeam/types.d.ts.map +0 -1
  153. package/src/components/ui/BorderBeam/BorderBeam.tsx +0 -130
  154. package/src/components/ui/BorderBeam/BorderBeam.web.tsx +0 -127
  155. package/src/components/ui/BorderBeam/index.ts +0 -2
  156. package/src/components/ui/BorderBeam/normalizeBorderBeam.ts +0 -58
  157. package/src/components/ui/BorderBeam/shared.ts +0 -47
  158. package/src/components/ui/BorderBeam/styles.ts +0 -16
  159. package/src/components/ui/BorderBeam/types.ts +0 -23
  160. /package/lib/module/components/ui/{BorderBeam → ActionMenuContent}/types.js +0 -0
@@ -0,0 +1,118 @@
1
+ import React, { useEffect, useState } from 'react';
2
+ import { Pressable, Text, View } from 'react-native';
3
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
4
+ import { space, useThemedStyles } from '../../../theme';
5
+ import { Button } from '../Button';
6
+ import type { ActionMenuContentProps } from './types';
7
+ import { makeStyles } from './styles';
8
+
9
+ export function ActionMenuContent({
10
+ actions,
11
+ onClose,
12
+ presentation = 'dialog',
13
+ title,
14
+ cancelLabel = '取消',
15
+ style,
16
+ testID = 'action-menu',
17
+ }: ActionMenuContentProps): React.JSX.Element {
18
+ const styles = useThemedStyles(makeStyles);
19
+ const insets = useSafeAreaInsets();
20
+ const [confirmationId, setConfirmationId] = useState<string | null>(null);
21
+ const isSheet = presentation === 'sheet';
22
+ const confirming = actions.find(
23
+ (action) =>
24
+ action.id === confirmationId && action.confirmation !== undefined
25
+ );
26
+
27
+ useEffect(() => {
28
+ if (!confirming) setConfirmationId(null);
29
+ }, [confirming]);
30
+
31
+ const content = (
32
+ <Pressable
33
+ accessible={false}
34
+ testID={`${testID}-card`}
35
+ style={[
36
+ isSheet ? styles.sheetCard : styles.dialogCard,
37
+ isSheet && { paddingBottom: insets.bottom + space[5] },
38
+ ]}
39
+ onPress={(event) => event.stopPropagation()}
40
+ >
41
+ {title ? (
42
+ <Text
43
+ testID={`${testID}-title`}
44
+ style={styles.title}
45
+ numberOfLines={1}
46
+ ellipsizeMode="tail"
47
+ >
48
+ {title}
49
+ </Text>
50
+ ) : null}
51
+ {confirming?.confirmation ? (
52
+ <>
53
+ <Text style={styles.confirmation} accessibilityLiveRegion="polite">
54
+ {confirming.confirmation.message}
55
+ </Text>
56
+ <View style={styles.sheetActions}>
57
+ <Button
58
+ label={cancelLabel}
59
+ variant="neutral"
60
+ style={styles.sheetAction}
61
+ onPress={() => setConfirmationId(null)}
62
+ />
63
+ <Button
64
+ label={confirming.confirmation.confirmLabel}
65
+ variant="danger"
66
+ disabled={confirming.disabled}
67
+ loading={confirming.loading}
68
+ style={styles.sheetAction}
69
+ onPress={() => {
70
+ onClose();
71
+ confirming.onPress();
72
+ }}
73
+ />
74
+ </View>
75
+ </>
76
+ ) : (
77
+ <>
78
+ <View
79
+ testID={`${testID}-actions`}
80
+ style={isSheet ? styles.sheetActions : undefined}
81
+ >
82
+ {actions.map((action) => (
83
+ <Button
84
+ key={action.id}
85
+ label={action.label}
86
+ disabled={action.disabled}
87
+ loading={action.loading}
88
+ variant={isSheet ? (action.tone ?? 'neutral') : 'text'}
89
+ style={isSheet ? styles.sheetAction : undefined}
90
+ onPress={() => {
91
+ if (action.confirmation) {
92
+ setConfirmationId(action.id);
93
+ return;
94
+ }
95
+ action.onPress();
96
+ }}
97
+ />
98
+ ))}
99
+ </View>
100
+ {!isSheet ? (
101
+ <Button label={cancelLabel} variant="text" onPress={onClose} />
102
+ ) : null}
103
+ </>
104
+ )}
105
+ </Pressable>
106
+ );
107
+
108
+ return (
109
+ <Pressable
110
+ accessible={false}
111
+ testID={testID}
112
+ style={[isSheet ? styles.sheetRoot : styles.dialogRoot, style]}
113
+ onPress={onClose}
114
+ >
115
+ {content}
116
+ </Pressable>
117
+ );
118
+ }
@@ -0,0 +1,6 @@
1
+ export { ActionMenuContent } from './ActionMenuContent';
2
+ export type {
3
+ ActionMenuAction,
4
+ ActionMenuConfirmation,
5
+ ActionMenuContentProps,
6
+ } from './types';
@@ -0,0 +1,47 @@
1
+ import { StyleSheet } from 'react-native';
2
+ import { fw, radius, space, type as typography } from '../../../theme';
3
+ import type { ColorTokens } from '../../../theme';
4
+ export const makeStyles = (c: ColorTokens) =>
5
+ StyleSheet.create({
6
+ dialogRoot: {
7
+ flex: 1,
8
+ justifyContent: 'center',
9
+ alignItems: 'center',
10
+ backgroundColor: c.scrim,
11
+ },
12
+ sheetRoot: {
13
+ flex: 1,
14
+ justifyContent: 'flex-end',
15
+ backgroundColor: c.scrim,
16
+ },
17
+ dialogCard: {
18
+ backgroundColor: c.surface,
19
+ borderRadius: radius.xl,
20
+ padding: space[4],
21
+ width: '80%',
22
+ maxWidth: 400,
23
+ gap: space[2],
24
+ },
25
+ sheetCard: {
26
+ backgroundColor: c.surface,
27
+ borderTopLeftRadius: radius.xl,
28
+ borderTopRightRadius: radius.xl,
29
+ paddingHorizontal: space[5],
30
+ paddingTop: space[5],
31
+ gap: space[3],
32
+ },
33
+ title: {
34
+ color: c.foreground,
35
+ fontSize: typography.sm,
36
+ fontWeight: fw.semi,
37
+ textAlign: 'center',
38
+ paddingVertical: space[3],
39
+ },
40
+ confirmation: {
41
+ color: c.foregroundMuted,
42
+ fontSize: typography.xs,
43
+ textAlign: 'center',
44
+ },
45
+ sheetActions: { flexDirection: 'row', flexWrap: 'wrap', gap: space[3] },
46
+ sheetAction: { flex: 1 },
47
+ });
@@ -0,0 +1,27 @@
1
+ import type { StyleProp, ViewStyle } from 'react-native';
2
+
3
+ export interface ActionMenuConfirmation {
4
+ message: string;
5
+ confirmLabel: string;
6
+ }
7
+
8
+ export interface ActionMenuAction {
9
+ id: string;
10
+ label: string;
11
+ onPress(): void;
12
+ disabled?: boolean;
13
+ loading?: boolean;
14
+ tone?: 'neutral' | 'danger';
15
+ confirmation?: ActionMenuConfirmation;
16
+ }
17
+
18
+ /** 操作、确认有效性和宿主关闭由调用方负责。 */
19
+ export interface ActionMenuContentProps {
20
+ actions: readonly ActionMenuAction[];
21
+ presentation?: 'dialog' | 'sheet';
22
+ title?: string;
23
+ cancelLabel?: string;
24
+ onClose(): void;
25
+ style?: StyleProp<ViewStyle>;
26
+ testID?: string;
27
+ }
@@ -29,6 +29,8 @@ export type ButtonBaseProps = {
29
29
  block?: boolean;
30
30
  /** 方形约束 — width=height=sizing.h,优先级高于 horizontal padding。IconButton 用。 */
31
31
  square?: boolean;
32
+ surfaceSize?: number;
33
+ loadingSize?: number;
32
34
  accessibilityLabel?: string;
33
35
  accessibilityRole?: AccessibilityRole;
34
36
  /** SR 朗读 label 后的行为说明 hint。仅在"行为不显然"时加。 */
@@ -53,6 +55,8 @@ export function ButtonBase({
53
55
  loading,
54
56
  block,
55
57
  square,
58
+ surfaceSize,
59
+ loadingSize,
56
60
  accessibilityLabel,
57
61
  accessibilityRole = 'button',
58
62
  accessibilityHint,
@@ -64,7 +68,12 @@ export function ButtonBase({
64
68
  const c = useColors();
65
69
  const styles = useThemedStyles(makeStyles);
66
70
 
67
- const sizing = useMemo(() => sizingFor(size), [size]);
71
+ const sizing = useMemo(() => {
72
+ const base = sizingFor(size);
73
+ return square && surfaceSize !== undefined
74
+ ? { ...base, h: surfaceSize }
75
+ : base;
76
+ }, [size, square, surfaceSize]);
68
77
  const palette = useMemo(() => paletteFor(variant, c), [variant, c]);
69
78
 
70
79
  const isText = variant === 'text';
@@ -128,7 +137,7 @@ export function ButtonBase({
128
137
  ]}
129
138
  >
130
139
  {loading ? (
131
- <ActivityIndicator size="small" color={palette.fg} />
140
+ <ActivityIndicator size={loadingSize ?? 'small'} color={palette.fg} />
132
141
  ) : (
133
142
  children({ sizing, palette })
134
143
  )}
@@ -20,6 +20,8 @@ export function IconButton({
20
20
  icon,
21
21
  onPress,
22
22
  size = 'md',
23
+ iconSize,
24
+ surfaceSize,
23
25
  variant = 'ghost',
24
26
  color,
25
27
  disabled,
@@ -46,6 +48,8 @@ export function IconButton({
46
48
  square
47
49
  onPress={onPress}
48
50
  size={size}
51
+ surfaceSize={surfaceSize}
52
+ loadingSize={iconSize}
49
53
  variant={variant}
50
54
  loading={loading}
51
55
  disabled={disabled === true || hasBlankLabel}
@@ -56,7 +60,11 @@ export function IconButton({
56
60
  testID={testID}
57
61
  >
58
62
  {({ sizing, palette }) => (
59
- <Icon name={icon} size={sizing.fs + 4} color={color ?? palette.fg} />
63
+ <Icon
64
+ name={icon}
65
+ size={iconSize ?? sizing.fs + 4}
66
+ color={color ?? palette.fg}
67
+ />
60
68
  )}
61
69
  </ButtonBase>
62
70
  );
@@ -12,6 +12,10 @@ export type IconButtonProps = {
12
12
  onPress: () => void;
13
13
  /** 尺寸,默认 'md'(28 / 36 / 44 方形) */
14
14
  size?: ButtonSize;
15
+ /** 独立图标及加载指示尺寸;默认沿用 size 对应值。 */
16
+ iconSize?: number;
17
+ /** 方形表面边长;同时据此补足 44pt 命中区。默认沿用 size。 */
18
+ surfaceSize?: number;
15
19
  /** 视觉变体,默认 'ghost'(透明底,适合 header right-tray 场景)。
16
20
  * 不支持 'text'——text variant 撕除高度,与 square 方形布局冲突。 */
17
21
  variant?: IconButtonVariant;
@@ -0,0 +1,243 @@
1
+ import {
2
+ forwardRef,
3
+ useEffect,
4
+ useImperativeHandle,
5
+ useLayoutEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ } from 'react';
10
+ import { Text, View } from 'react-native';
11
+ import { radius, useThemedStyles } from '../../../theme';
12
+ import { IconButton } from '../IconButton';
13
+ import { Thumbnail } from '../Thumbnail';
14
+ import { Carousel, type CarouselRef } from 'react-native-reanimated-carousel';
15
+ import { DELETE_LABEL, IMAGE_UNAVAILABLE } from './constants';
16
+ import { makeStyles } from './styles';
17
+ import type {
18
+ ImagePreviewHandle,
19
+ ImagePreviewProps,
20
+ ImagePreviewSelection,
21
+ ImagePreviewScrollRequest,
22
+ } from './types';
23
+
24
+ export const ImagePreview = forwardRef<ImagePreviewHandle, ImagePreviewProps>(
25
+ function ImagePreviewInner(
26
+ {
27
+ items,
28
+ width,
29
+ height,
30
+ initialId,
31
+ onCurrentChange,
32
+ onRequestDelete,
33
+ style,
34
+ testID,
35
+ },
36
+ ref
37
+ ) {
38
+ const styles = useThemedStyles(makeStyles);
39
+ const carouselRef = useRef<CarouselRef>(null);
40
+ const mounted = useRef(false);
41
+ const moving = useRef(false);
42
+ const pendingScroll = useRef<ImagePreviewScrollRequest | undefined>(
43
+ undefined
44
+ );
45
+ const [selection, setSelection] = useState<ImagePreviewSelection>(() => ({
46
+ items,
47
+ index: Math.max(
48
+ 0,
49
+ items.findIndex((item) => item.id === initialId)
50
+ ),
51
+ initialized: items.length > 0,
52
+ carouselRevision: 0,
53
+ }));
54
+
55
+ if (selection.items !== items) {
56
+ const retainedIndex = items.findIndex(
57
+ (item) => item.id === selection.items[selection.index]?.id
58
+ );
59
+ const identityChanged =
60
+ items.length !== selection.items.length ||
61
+ items.some((item, index) => item.id !== selection.items[index]?.id);
62
+ setSelection({
63
+ items,
64
+ // SDK 的非动画定位不能撤回已排队的旧完成;只重建在途且身份集合变化的轮播。
65
+ carouselRevision:
66
+ selection.carouselRevision +
67
+ (moving.current && identityChanged ? 1 : 0),
68
+ initialized: selection.initialized || items.length > 0,
69
+ index: !selection.initialized
70
+ ? Math.max(
71
+ 0,
72
+ items.findIndex((item) => item.id === initialId)
73
+ )
74
+ : retainedIndex >= 0
75
+ ? retainedIndex
76
+ : Math.max(0, Math.min(selection.index, items.length - 1)),
77
+ });
78
+ }
79
+
80
+ const current = items[selection.index];
81
+ const committed = useRef(selection);
82
+ const currentChange = useRef(onCurrentChange);
83
+ useLayoutEffect(() => {
84
+ mounted.current = true;
85
+ committed.current = selection;
86
+ currentChange.current = onCurrentChange;
87
+ return () => {
88
+ mounted.current = false;
89
+ };
90
+ }, [selection, onCurrentChange]);
91
+
92
+ useLayoutEffect(() => {
93
+ moving.current = false;
94
+ }, [selection.carouselRevision]);
95
+
96
+ useEffect(() => {
97
+ const request = pendingScroll.current;
98
+ pendingScroll.current = undefined;
99
+ if (!request) return;
100
+ const index = committed.current.items.findIndex(
101
+ (item) => item.id === request.id
102
+ );
103
+ if (index >= 0)
104
+ carouselRef.current?.scrollTo({ index, animated: request.animated });
105
+ }, [selection.carouselRevision]);
106
+
107
+ const notifiedId = useRef<string | undefined>(undefined);
108
+ useEffect(() => {
109
+ // 待交接命令可能已同步完成,通知与句柄读取同一份已提交事实。
110
+ const displayed = committed.current.items[committed.current.index];
111
+ if (displayed?.id === notifiedId.current) return;
112
+ notifiedId.current = displayed?.id;
113
+ if (displayed) currentChange.current?.(displayed);
114
+ }, [current, onCurrentChange]);
115
+
116
+ useImperativeHandle(
117
+ ref,
118
+ () => ({
119
+ getCurrent: () =>
120
+ mounted.current
121
+ ? committed.current.items[committed.current.index]
122
+ : undefined,
123
+ scrollTo: (id, animated = true) => {
124
+ if (!mounted.current) return;
125
+ const index = committed.current.items.findIndex(
126
+ (item) => item.id === id
127
+ );
128
+ if (index < 0) return;
129
+ if (moving.current || pendingScroll.current) {
130
+ // 新命令隔离旧完成投递;新 ref 就绪后原样交接 animated。
131
+ pendingScroll.current = { id, animated };
132
+ const next = {
133
+ ...committed.current,
134
+ carouselRevision: committed.current.carouselRevision + 1,
135
+ };
136
+ committed.current = next;
137
+ setSelection(next);
138
+ } else {
139
+ carouselRef.current?.scrollTo({ index, animated });
140
+ }
141
+ },
142
+ }),
143
+ []
144
+ );
145
+
146
+ // SDK 只协调身份变化;source、label、busy 更新不应打断当前动画。
147
+ const data = useMemo(() => items.map((item) => item.id), [items]);
148
+ if (!current) return null;
149
+
150
+ return (
151
+ <View style={[styles.container, style]} testID={testID}>
152
+ <Carousel
153
+ key={selection.carouselRevision}
154
+ ref={carouselRef}
155
+ data={data}
156
+ defaultIndex={selection.index}
157
+ loop={false}
158
+ itemSize={width}
159
+ style={{ width, height }}
160
+ keyExtractor={(id) => id}
161
+ testID={testID ? `${testID}-carousel` : undefined}
162
+ onScrollStart={() => {
163
+ if (
164
+ mounted.current &&
165
+ selection.carouselRevision === committed.current.carouselRevision
166
+ ) {
167
+ moving.current = true;
168
+ }
169
+ }}
170
+ onSnapToItem={(index) => {
171
+ const latest = committed.current;
172
+ if (
173
+ !mounted.current ||
174
+ selection.carouselRevision !== latest.carouselRevision ||
175
+ items.length !== latest.items.length ||
176
+ items.some(
177
+ (item, itemIndex) => item.id !== latest.items[itemIndex]?.id
178
+ )
179
+ )
180
+ return;
181
+ moving.current = false;
182
+ const id = items[index]?.id;
183
+ const nextIndex = latest.items.findIndex((item) => item.id === id);
184
+ if (nextIndex >= 0 && nextIndex !== latest.index) {
185
+ const next = { ...latest, index: nextIndex };
186
+ // SDK 已完成落位;句柄同步可读,不等待 React 下一次提交。
187
+ committed.current = next;
188
+ setSelection(next);
189
+ notifiedId.current = id;
190
+ currentChange.current?.(next.items[nextIndex]!);
191
+ }
192
+ }}
193
+ renderItem={({ item: id, index }) => {
194
+ const item = items.find((candidate) => candidate.id === id)!;
195
+ return (
196
+ <View style={[styles.media, { width, height }]}>
197
+ {item.source === undefined ? (
198
+ <View style={styles.placeholder}>
199
+ <Text style={styles.placeholderText}>
200
+ {IMAGE_UNAVAILABLE}
201
+ </Text>
202
+ </View>
203
+ ) : (
204
+ <Thumbnail
205
+ source={item.source}
206
+ size={{ width, height, borderRadius: radius.xl }}
207
+ resizeMode="contain"
208
+ imageStyle={styles.image}
209
+ accessibilityLabel={item.label ?? `图片第 ${index + 1} 张`}
210
+ fallback={
211
+ <View style={styles.placeholder}>
212
+ <Text style={styles.placeholderText}>
213
+ {IMAGE_UNAVAILABLE}
214
+ </Text>
215
+ </View>
216
+ }
217
+ />
218
+ )}
219
+ <View pointerEvents="none" style={styles.border} />
220
+ </View>
221
+ );
222
+ }}
223
+ />
224
+ <View style={styles.counter}>
225
+ <Text style={styles.counterText}>
226
+ 第{selection.index + 1}/{items.length}张
227
+ </Text>
228
+ </View>
229
+ {current.canDelete && onRequestDelete ? (
230
+ <IconButton
231
+ icon="trash"
232
+ size="sm"
233
+ variant="neutral"
234
+ disabled={current.deleting}
235
+ accessibilityLabel={DELETE_LABEL}
236
+ style={styles.deleteButton}
237
+ onPress={() => onRequestDelete(current)}
238
+ />
239
+ ) : null}
240
+ </View>
241
+ );
242
+ }
243
+ );
@@ -0,0 +1,2 @@
1
+ export const IMAGE_UNAVAILABLE = '图片暂不可预览';
2
+ export const DELETE_LABEL = '删除当前照片';
@@ -0,0 +1,6 @@
1
+ export { ImagePreview } from './ImagePreview';
2
+ export type {
3
+ ImagePreviewItem,
4
+ ImagePreviewProps,
5
+ ImagePreviewHandle,
6
+ } from './types';
@@ -0,0 +1,48 @@
1
+ import { StyleSheet } from 'react-native';
2
+ import { fontMono, radius, space, type as typography } from '../../../theme';
3
+ import type { ColorTokens } from '../../../theme';
4
+
5
+ export const makeStyles = (colors: ColorTokens) =>
6
+ StyleSheet.create({
7
+ container: { alignItems: 'center', gap: space[2] },
8
+ media: {
9
+ backgroundColor: 'black',
10
+ borderRadius: radius.xl,
11
+ overflow: 'hidden',
12
+ },
13
+ border: {
14
+ ...StyleSheet.absoluteFill,
15
+ borderColor: colors.outline,
16
+ borderWidth: StyleSheet.hairlineWidth,
17
+ borderRadius: radius.xl,
18
+ },
19
+ image: { backgroundColor: 'black' },
20
+ placeholder: {
21
+ width: '100%',
22
+ height: '100%',
23
+ backgroundColor: 'black',
24
+ justifyContent: 'center',
25
+ alignItems: 'center',
26
+ paddingHorizontal: space[3],
27
+ },
28
+ placeholderText: {
29
+ color: 'white',
30
+ fontSize: typography.sm,
31
+ textAlign: 'center',
32
+ },
33
+ counter: {
34
+ alignSelf: 'center',
35
+ paddingVertical: space[1],
36
+ paddingHorizontal: space[3],
37
+ borderRadius: radius.pill,
38
+ borderWidth: StyleSheet.hairlineWidth,
39
+ borderColor: colors.glassPillBorder,
40
+ backgroundColor: colors.glassHighlight,
41
+ },
42
+ counterText: {
43
+ color: colors.foreground,
44
+ fontSize: typography.sm,
45
+ fontFamily: fontMono,
46
+ },
47
+ deleteButton: { borderRadius: radius.pill },
48
+ });
@@ -0,0 +1,37 @@
1
+ import type { ImageSourcePropType, StyleProp, ViewStyle } from 'react-native';
2
+
3
+ export interface ImagePreviewItem {
4
+ id: string;
5
+ source?: ImageSourcePropType;
6
+ label?: string;
7
+ canDelete?: boolean;
8
+ deleting?: boolean;
9
+ }
10
+
11
+ export interface ImagePreviewProps {
12
+ items: readonly ImagePreviewItem[];
13
+ width: number;
14
+ height: number;
15
+ initialId?: string;
16
+ onCurrentChange?(item: Readonly<ImagePreviewItem>): void;
17
+ onRequestDelete?(item: Readonly<ImagePreviewItem>): void;
18
+ style?: StyleProp<ViewStyle>;
19
+ testID?: string;
20
+ }
21
+
22
+ export interface ImagePreviewHandle {
23
+ scrollTo(id: string, animated?: boolean): void;
24
+ getCurrent(): Readonly<ImagePreviewItem> | undefined;
25
+ }
26
+
27
+ export interface ImagePreviewSelection {
28
+ items: readonly ImagePreviewItem[];
29
+ index: number;
30
+ initialized: boolean;
31
+ carouselRevision: number;
32
+ }
33
+
34
+ export interface ImagePreviewScrollRequest {
35
+ id: string;
36
+ animated: boolean;
37
+ }
@@ -0,0 +1,43 @@
1
+ import React from 'react';
2
+ import { ScrollView, View } from 'react-native';
3
+ import { useThemedStyles } from '../../../theme';
4
+ import { makeStyles } from './styles';
5
+ import type { SheetProps } from './types';
6
+
7
+ export function Sheet({
8
+ children,
9
+ header,
10
+ footer,
11
+ contentMode = 'fixed',
12
+ contentContainerStyle,
13
+ style,
14
+ testID,
15
+ }: SheetProps): React.JSX.Element {
16
+ const styles = useThemedStyles(makeStyles);
17
+
18
+ return (
19
+ <View style={[styles.root, style]} testID={testID}>
20
+ {header ?? (
21
+ <View style={styles.grabberContainer}>
22
+ <View style={styles.grabber} />
23
+ </View>
24
+ )}
25
+ {contentMode === 'scroll' ? (
26
+ <ScrollView
27
+ style={styles.contentRegion}
28
+ contentContainerStyle={[styles.content, contentContainerStyle]}
29
+ keyboardShouldPersistTaps="handled"
30
+ >
31
+ {children}
32
+ </ScrollView>
33
+ ) : (
34
+ <View
35
+ style={[styles.contentRegion, styles.content, contentContainerStyle]}
36
+ >
37
+ {children}
38
+ </View>
39
+ )}
40
+ {footer ? <View style={styles.footer}>{footer}</View> : null}
41
+ </View>
42
+ );
43
+ }
@@ -0,0 +1,2 @@
1
+ export { Sheet } from './Sheet';
2
+ export type { SheetContentMode, SheetProps } from './types';