react-native-x-components 0.1.0 → 0.2.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.
package/README.md CHANGED
@@ -17,12 +17,22 @@ Ant Design 风格的 React Native (Expo) 组件库。对齐 antd v5 设计 token
17
17
  npm install react-native-x-components dayjs zustand
18
18
  # 必需 peer 依赖(Expo 项目推荐用 expo install 自动匹配版本)
19
19
  npx expo install react-native-reanimated react-native-safe-area-context react-native-svg
20
+ # 推荐安装(XImagePreview/XInput 焦点等需要手势处理):
21
+ npx expo install react-native-gesture-handler
20
22
  # 可选(按需装):
21
23
  # 签名: npx expo install @shopify/react-native-skia
22
24
  # 录音: npx expo install expo-audio
23
25
  # 上传: npx expo install expo-image-picker expo-image-manipulator expo-file-system
24
26
  ```
25
27
 
28
+ ### Android 权限说明(首次录音 / 拍照 / 选媒体时系统会自动弹)
29
+
30
+ - **录音**:`expo-audio` 在 `npx expo prebuild --clean` 后会自动往 AndroidManifest.xml 注入 `RECORD_AUDIO`、iOS Info.plist 注入 `NSMicrophoneUsageDescription`
31
+ - **拍照**:自动注入 `CAMERA`、iOS NSCameraUsageDescription
32
+ - **选媒体**:自动注入 `READ_MEDIA_IMAGES` / `READ_MEDIA_VIDEO` (Android 13+)、`READ_EXTERNAL_STORAGE` (Android 12-) 及 NSPhotoLibraryUsageDescription (iOS)
33
+
34
+ 如果预编译后系统仍不弹权限框,先执行 `npx expo prebuild --clean && npx expo run:android` 让插件重新注入原生配置。
35
+
26
36
  ## 快速上手
27
37
 
28
38
  ```tsx
@@ -0,0 +1,15 @@
1
+ /**
2
+ * XActionSheet 全局命令式 API:
3
+ * XActionSheet.show({title, options}) → Promise<value|null>
4
+ *
5
+ * 在 App 根部的 XPopupProvider 下挂载一次 XActionSheetGlobalOverlay 即可。
6
+ */
7
+ import React from 'react';
8
+ import { XActionSheetOption } from './index';
9
+ /** 命令式调用入口 */
10
+ export declare function showXActionSheet(opts: {
11
+ title?: string;
12
+ options: XActionSheetOption[];
13
+ }): Promise<any | null>;
14
+ /** 全局覆盖层:挂一次到 XPopupProvider 内即可 */
15
+ export declare function XActionSheetGlobalOverlay(): React.JSX.Element | null;
@@ -0,0 +1,32 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { create } from 'zustand';
3
+ import { XActionSheet } from './index';
4
+ const useStore = create(set => ({
5
+ visible: false,
6
+ options: [],
7
+ show: (opts) => {
8
+ return new Promise(resolve => {
9
+ set({ visible: true, title: opts.title, options: opts.options, resolve });
10
+ });
11
+ },
12
+ close: (value) => {
13
+ set(state => {
14
+ state.resolve?.(value);
15
+ return { visible: false, options: [], resolve: undefined };
16
+ });
17
+ },
18
+ }));
19
+ /** 命令式调用入口 */
20
+ export async function showXActionSheet(opts) {
21
+ return useStore.getState().show(opts);
22
+ }
23
+ /** 全局覆盖层:挂一次到 XPopupProvider 内即可 */
24
+ export function XActionSheetGlobalOverlay() {
25
+ const visible = useStore(s => s.visible);
26
+ const title = useStore(s => s.title);
27
+ const options = useStore(s => s.options);
28
+ const close = useStore(s => s.close);
29
+ if (!visible)
30
+ return null;
31
+ return (_jsx(XActionSheet, { visible: visible, onClose: () => close(null), options: options, title: title, onSelect: opt => close(opt.value) }));
32
+ }
@@ -89,10 +89,11 @@ export function XActionSheet({ visible, onClose, options, title, cancelText, dur
89
89
  color: t.colorTextSecondary,
90
90
  },
91
91
  }), [t]);
92
- /** 点选项:先关闭弹层,再通知业务方选中了谁 */
92
+ /** 点选项:先通知业务方选中了谁,再关弹层(顺序关键:命令式 show() 的
93
+ * Promise 依赖 onSelect 先 resolve,onClose 后触发时 resolve 已消费) */
93
94
  const handleSelect = useCallback((option) => {
94
- onClose();
95
95
  onSelect?.(option);
96
+ onClose();
96
97
  }, [onClose, onSelect]);
97
98
  return (_jsx(XPullView, { visible: visible, onClose: onClose, side: 'bottom', duration: duration, overlayOpacity: 0.4, children: _jsxs(View, { style: [styles.panel, style, { paddingBottom: Math.max(insets.bottom, 10) }], children: [!!title && (_jsx(View, { style: styles.header, children: _jsx(Text, { style: styles.title, children: title }) })), _jsx(View, { style: styles.optionList, children: options.map(option => (_jsx(Pressable, { disabled: option.disabled, onPress: () => handleSelect(option),
98
99
  // style 可以是函数:pressed 状态时换个底色,模拟按压反馈
@@ -147,24 +147,28 @@ export function XCalendar({ mode = 'day', value, defaultValue, multiple = false,
147
147
  setInnerValue(next);
148
148
  onChange?.(next);
149
149
  }, [mode, multiple, currentValue, onChange, weekStartsOn, pendingStart, isDisabled]);
150
- /** 单元格样式计算 */
150
+ /** 单元格样式计算
151
+ * 【scope 重新选择规则】已选完一个范围后再点击:pendingStart 存在时
152
+ * 优先渲染"新一轮起点",忽略旧范围(否则样式纹丝不动,像卡死)。
153
+ * 渲染优先级:pendingStart(scope 新起点)> 旧范围 > day 选中 */
151
154
  const cellState = useCallback((d) => {
152
155
  const key = d.format('YYYY-MM-DD');
153
156
  const disabled = isDisabled(d);
154
157
  const isToday = d.isSame(dayjs(), 'day');
155
158
  let selected = false;
156
159
  let inRange = false;
157
- if (mode === 'day') {
160
+ if (mode === 'scope' && pendingStart) {
161
+ // 新一轮选择中:只高亮起点,旧范围不再显示
162
+ selected = key === pendingStart.format('YYYY-MM-DD');
163
+ }
164
+ else if (mode === 'day') {
158
165
  selected = multiple ? selectedSet.has(key) : key === selStart?.format('YYYY-MM-DD');
159
166
  }
160
167
  else if (selStart && selEnd) {
161
- // week/scope:起止实心、中间浅底(week 模式每次选中天然成对)
168
+ // week/scope:起止实心、中间浅底
162
169
  selected = key === selStart.format('YYYY-MM-DD') || key === selEnd.format('YYYY-MM-DD');
163
170
  inRange = d.isAfter(selStart, 'day') && d.isBefore(selEnd, 'day');
164
171
  }
165
- else if (mode === 'scope' && pendingStart) {
166
- selected = key === pendingStart.format('YYYY-MM-DD');
167
- }
168
172
  return { selected, inRange, disabled, isToday };
169
173
  }, [mode, multiple, selectedSet, selStart, selEnd, pendingStart, isDisabled]);
170
174
  const renderCell = (d, index) => {
@@ -46,42 +46,56 @@ function TriggerTitle({ itemProps, active, onPress, height, }) {
46
46
  ? itemProps.options.find(o => o.value === itemProps.value)?.name
47
47
  : undefined;
48
48
  const label = selectedName ?? itemProps.title;
49
- return (_jsxs(Pressable, { onPress: onPress, style: [styles.triggerItem, { height }], disabled: itemProps.disabled || !onPress, children: [_jsx(Text, { style: [styles.triggerText, { color }, active && styles.triggerTextActive], numberOfLines: 1, allowFontScaling: false, children: label }), _jsx(Text, { style: [styles.arrow, { color }, active && styles.arrowOpen], allowFontScaling: false, children: "\u25BE" })] }));
49
+ return (_jsxs(Pressable, { onPress: onPress, style: [styles.triggerItem, { height }], disabled: itemProps.disabled || !onPress, children: [_jsx(Text, { style: [styles.triggerText, { color }, active && styles.triggerTextActive], numberOfLines: 1, allowFontScaling: false, children: label }), _jsx(Text, { style: [styles.arrow, { color }, active && styles.arrowOpen], allowFontScaling: false, children: "\u25BC" })] }));
50
50
  }
51
51
  const MenuOverlay = ({ visible, trigger, round, onClose, onExitEnd, snapshot, panel }) => {
52
52
  const t = useXTheme();
53
- /** 面板内容高度(onLayout 实测 决定位移初值) */
53
+ /** 面板内容高度(onLayout 实测,决定遮罩起点 & 完全收起判断) */
54
54
  const [panelHeight, setPanelHeight] = useState(0);
55
- const translateY = useSharedValue(0);
55
+ /** scaleY 展开动画(transformOrigin 顶部 = 触发条下沿),像"从触发条下面抽出/收回" */
56
+ const scaleY = useSharedValue(0);
56
57
  const maskOpacity = useSharedValue(0);
57
- /**
58
- * 面板位移:隐藏态 = -(panelHeight + 20)(藏在触发条上方),visible 后
59
- * 下一帧滑到 0(duxui 的 translateY(-100%) 下拉)。panelHeight 未测得前
60
- * 不动画,避免从 0 位置闪现。
61
- */
58
+ /** 展开 scaleY 0→1(顶部锚点),遮罩淡入 */
62
59
  useEffect(() => {
63
- if (visible && panelHeight > 0) {
60
+ if (visible) {
64
61
  const raf = requestAnimationFrame(() => {
65
- translateY.value = withTiming(0, { duration: 200, easing: Easing.out(Easing.ease) });
66
- maskOpacity.value = withTiming(1, { duration: 200 });
62
+ scaleY.value = withTiming(1, { duration: 200, easing: Easing.out(Easing.cubic) });
63
+ maskOpacity.value = withTiming(0.4, { duration: 200 });
67
64
  });
68
65
  return () => cancelAnimationFrame(raf);
69
66
  }
70
- }, [visible, panelHeight, translateY, maskOpacity]);
71
- /** 离场:反向动画后通知宿主移除 */
67
+ }, [visible, scaleY, maskOpacity]);
68
+ /** 收起 scaleY →0(同锚点收回,不再向上平移),动画后通知宿主移除 */
72
69
  useEffect(() => {
73
70
  if (!visible) {
74
- translateY.value = withTiming(-(panelHeight + 20), { duration: 200, easing: Easing.inOut(Easing.ease) });
75
- maskOpacity.value = withTiming(0, { duration: 200 });
76
- const timer = setTimeout(onExitEnd, 250);
71
+ scaleY.value = withTiming(0, { duration: 180, easing: Easing.in(Easing.cubic) });
72
+ maskOpacity.value = withTiming(0, { duration: 180 });
73
+ const timer = setTimeout(onExitEnd, 220);
77
74
  return () => clearTimeout(timer);
78
75
  }
79
- }, [visible, panelHeight, translateY, maskOpacity, onExitEnd]);
76
+ }, [visible, scaleY, maskOpacity, onExitEnd]);
80
77
  const panelStyle = useAnimatedStyle(() => ({
81
- transform: [{ translateY: translateY.value }],
78
+ transform: [{ scaleY: scaleY.value }],
82
79
  }));
83
80
  const maskStyle = useAnimatedStyle(() => ({ opacity: maskOpacity.value }));
84
- return (_jsxs(View, { style: StyleSheet.absoluteFill, pointerEvents: 'box-none', children: [_jsx(AnimatedPressable, { style: [StyleSheet.absoluteFill, { backgroundColor: '#000' }, maskStyle], onPress: onClose }), _jsx(View, { pointerEvents: 'none', style: { position: 'absolute', left: trigger.x, top: trigger.y, width: trigger.width, height: trigger.height }, children: snapshot }), _jsx(Animated.View, { style: [
81
+ return (_jsxs(View, { style: StyleSheet.absoluteFill, pointerEvents: 'box-none', children: [_jsx(AnimatedPressable, { style: [
82
+ {
83
+ position: 'absolute',
84
+ left: 0,
85
+ right: 0,
86
+ top: trigger.y + trigger.height,
87
+ bottom: 0,
88
+ backgroundColor: '#000',
89
+ },
90
+ maskStyle,
91
+ ], onPress: onClose }), _jsx(View, { pointerEvents: 'none', style: {
92
+ position: 'absolute',
93
+ left: trigger.x,
94
+ top: trigger.y,
95
+ width: trigger.width,
96
+ height: trigger.height,
97
+ backgroundColor: t.colorBgContainer,
98
+ }, children: snapshot }), _jsx(Animated.View, { style: [
85
99
  styles.panel,
86
100
  {
87
101
  left: trigger.x,
@@ -90,6 +104,7 @@ const MenuOverlay = ({ visible, trigger, round, onClose, onExitEnd, snapshot, pa
90
104
  backgroundColor: t.colorBgContainer,
91
105
  borderBottomLeftRadius: round ? t.borderRadiusXL : 0,
92
106
  borderBottomRightRadius: round ? t.borderRadiusXL : 0,
107
+ transformOrigin: '50% 0%',
93
108
  },
94
109
  panelStyle,
95
110
  ], pointerEvents: 'auto', children: _jsx(View, { onLayout: e => {
@@ -192,7 +207,7 @@ export function XDropdownMenu({ round = true, onOpenChange, triggerHeight = 44,
192
207
  }, [remove]);
193
208
  /** context value:open/close/activeIndex 分发给每个 Item */
194
209
  const ctx = useMemo(() => ({ index: -1, activeIndex: activeIndex ?? -1, open, close, triggerHeight }), [activeIndex, open, close, triggerHeight]);
195
- return (_jsx(View, { ref: triggerRef, style: [styles.triggerRow, { height: triggerHeight }, style], children: items.map((child, i) => (_jsx(MenuContext.Provider, { value: { ...ctx, index: i }, children: child }, i))) }));
210
+ return (_jsx(View, { ref: triggerRef, style: [styles.triggerRow, { height: triggerHeight, backgroundColor: t.colorBgContainer }, style], children: items.map((child, i) => (_jsx(MenuContext.Provider, { value: { ...ctx, index: i }, children: child }, i))) }));
196
211
  }
197
212
  /**
198
213
  * 触发条单项(真实渲染):从 MenuContext 拿到自己的序号与开合动作。
@@ -202,7 +217,7 @@ export function XDropdownMenuItem(props) {
202
217
  const ctx = useContext(MenuContext);
203
218
  if (!ctx)
204
219
  return null;
205
- return (_jsx(TriggerTitle, { itemProps: props, active: ctx.activeIndex === ctx.index, onPress: () => !props.disabled && ctx.open(ctx.index), height: 0 }));
220
+ return (_jsx(TriggerTitle, { itemProps: props, active: ctx.activeIndex === ctx.index, onPress: () => !props.disabled && ctx.open(ctx.index), height: ctx.triggerHeight }));
206
221
  }
207
222
  const styles = StyleSheet.create({
208
223
  triggerRow: {
@@ -224,7 +239,7 @@ const styles = StyleSheet.create({
224
239
  fontWeight: '600',
225
240
  },
226
241
  arrow: {
227
- fontSize: 10,
242
+ fontSize: 9,
228
243
  marginLeft: 4,
229
244
  },
230
245
  arrowOpen: {
@@ -11,6 +11,7 @@
11
11
  * <XInput value={name} onChangeText={setName} placeholder='请输入姓名' />
12
12
  * <XInput multiline placeholder='请输入备注' />
13
13
  * <XInput disabled value='只读' />
14
+ * <XInput customKeyboard='numeric' value={amount} onChangeText={setAmount} /> // 自绘数字键盘
14
15
  * ```
15
16
  *
16
17
  * 与 antd Input 的对应:
@@ -18,6 +19,8 @@
18
19
  * - disabled:对应 antd disabled(不可编辑 + 禁用样式)
19
20
  * - multiline:对应 antd Input.TextArea
20
21
  * - allowClear:对应 antd allowClear(非多行时显示清空按钮)
22
+ * - customKeyboard='numeric':盖住系统键盘,弹起自绘数字键盘(带"完成"按钮)
23
+ * 适合金额/数量等场景,外观风格统一;与 XNumberKeyboard 同源渲染
21
24
  * - 其余 props(maxLength/keyboardType/secureTextEntry 等)原样透传给 TextInput
22
25
  *
23
26
  * 注意:value 是完全受控透传,不做任何 trim/transform —— 改写会打断
@@ -33,6 +36,14 @@ export interface XInputProps extends TextInputProps {
33
36
  disabled?: boolean;
34
37
  /** 有内容时显示清空按钮(antd allowClear;multiline 下不生效) */
35
38
  allowClear?: boolean;
39
+ /**
40
+ * 自定义键盘:'numeric' 时盖住系统键盘,弹起自绘数字键盘
41
+ * (带"完成"工具栏,按键直接回写 value;与 XNumberKeyboard 同源)。
42
+ * 仅单行模式生效;与 multiline 互斥。
43
+ */
44
+ customKeyboard?: 'numeric';
45
+ /** 自定义键盘的小数点(false 隐藏;true 显示,默认 true) */
46
+ customKeyboardPoint?: boolean;
36
47
  style?: StyleProp<TextStyle>;
37
48
  /** 外层容器样式(有 allowClear 按钮时想要撑满/贴边用) */
38
49
  containerStyle?: StyleProp<ViewStyle>;
@@ -12,6 +12,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
12
12
  * <XInput value={name} onChangeText={setName} placeholder='请输入姓名' />
13
13
  * <XInput multiline placeholder='请输入备注' />
14
14
  * <XInput disabled value='只读' />
15
+ * <XInput customKeyboard='numeric' value={amount} onChangeText={setAmount} /> // 自绘数字键盘
15
16
  * ```
16
17
  *
17
18
  * 与 antd Input 的对应:
@@ -19,17 +20,26 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
19
20
  * - disabled:对应 antd disabled(不可编辑 + 禁用样式)
20
21
  * - multiline:对应 antd Input.TextArea
21
22
  * - allowClear:对应 antd allowClear(非多行时显示清空按钮)
23
+ * - customKeyboard='numeric':盖住系统键盘,弹起自绘数字键盘(带"完成"按钮)
24
+ * 适合金额/数量等场景,外观风格统一;与 XNumberKeyboard 同源渲染
22
25
  * - 其余 props(maxLength/keyboardType/secureTextEntry 等)原样透传给 TextInput
23
26
  *
24
27
  * 注意:value 是完全受控透传,不做任何 trim/transform —— 改写会打断
25
28
  * Android 中文输入法的合成过程(XForm 的同值守卫也依赖原样透传)。
26
29
  */
27
30
  import { forwardRef, useEffect, useRef, useState } from 'react';
28
- import { Pressable, StyleSheet, TextInput, View } from 'react-native';
31
+ import { Pressable, StyleSheet, TextInput, View, Text } from 'react-native';
32
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
29
33
  import { useXTheme } from '../theme';
30
- export const XInput = forwardRef(function XInput({ value, onChangeText, multiline, disabled, allowClear, style, containerStyle, editable, ...rest }, ref) {
34
+ import { useXLocale } from '../XLocale';
35
+ import { XPullView } from '../XPullView';
36
+ import { XNumberKeyboard } from '../XNumberKeyboard';
37
+ export const XInput = forwardRef(function XInput({ value, onChangeText, multiline, disabled, allowClear, customKeyboard, customKeyboardPoint = true, style, containerStyle, editable, ...rest }, ref) {
31
38
  const [focused, setFocused] = useState(false);
32
39
  const isDisabled = disabled || editable === false;
40
+ const useCustomKb = customKeyboard === 'numeric' && !multiline;
41
+ /** 自定义键盘弹层是否展开 */
42
+ const [kbVisible, setKbVisible] = useState(false);
33
43
  /**
34
44
  * Android IME 防回抛镜像(关键):
35
45
  * 受控模式下若把父级 store 的 value 每键原样回写给 TextInput,IME 合成会被
@@ -56,15 +66,58 @@ export const XInput = forwardRef(function XInput({ value, onChangeText, multilin
56
66
  // 受控用法显示镜像;非受控(没传 value)不干预,TextInput 自己管
57
67
  const displayValue = value !== undefined ? mirror : undefined;
58
68
  const t = useXTheme();
69
+ const { t: i18n } = useXLocale();
70
+ const insets = useSafeAreaInsets();
59
71
  const showClear = !!allowClear && !multiline && !isDisabled && !!displayValue;
60
72
  /** 清空后把光标送回输入框,继续输入不用再点一次 */
61
73
  const handleClear = () => {
62
74
  reportedRef.current = '';
63
75
  setMirror('');
64
76
  onChangeText?.('');
65
- ref && typeof ref === 'object' && ref.current?.focus();
77
+ if (!useCustomKb) {
78
+ ref && typeof ref === 'object' && ref.current?.focus();
79
+ }
80
+ else {
81
+ setKbVisible(true);
82
+ }
83
+ };
84
+ /** 自定义键盘:按键 → 回写 value(带 maxLength 校验) */
85
+ const handleKeyPress = (key) => {
86
+ const prev = displayValue ?? '';
87
+ const maxLength = rest.maxLength;
88
+ if (typeof maxLength === 'number' && prev.length >= maxLength)
89
+ return;
90
+ // 小数点只允许一次、不允许开头
91
+ if (key === '.' && (prev.includes('.') || prev.length === 0))
92
+ return;
93
+ handleChange(prev + key);
94
+ };
95
+ /** 自定义键盘:退格 */
96
+ const handleBackspace = () => {
97
+ const prev = displayValue ?? '';
98
+ handleChange(prev.slice(0, -1));
66
99
  };
67
- return (_jsxs(View, { style: [styles.container, containerStyle], children: [_jsx(TextInput, { ...rest, ref: ref, value: displayValue, onChangeText: handleChange, multiline: multiline, editable: !isDisabled, pointerEvents: isDisabled ? 'none' : undefined, style: [
100
+ // 自定义键盘模式下,"聚焦状态"由弹层是否展开驱动
101
+ const isFocused = useCustomKb ? kbVisible : focused;
102
+ return (_jsxs(View, { style: [styles.container, containerStyle], children: [useCustomKb ? (_jsx(Pressable, { onPress: () => !isDisabled && setKbVisible(true), disabled: isDisabled, children: ({ pressed }) => (_jsx(View, { style: [
103
+ styles.input,
104
+ {
105
+ borderColor: isFocused && !isDisabled ? t.colorPrimary : t.colorBorder,
106
+ backgroundColor: isDisabled
107
+ ? t.colorBgContainerDisabled
108
+ : pressed
109
+ ? t.colorBgLayout
110
+ : t.colorBgContainer,
111
+ },
112
+ ], pointerEvents: 'none', children: _jsx(Text, { style: [
113
+ styles.kbText,
114
+ {
115
+ color: displayValue ? t.colorText : t.colorTextQuaternary,
116
+ },
117
+ typeof style === 'object' && !Array.isArray(style)
118
+ ? style
119
+ : undefined,
120
+ ], numberOfLines: 1, allowFontScaling: false, children: displayValue || rest.placeholder }) })) })) : (_jsx(TextInput, { ...rest, ref: ref, value: displayValue, onChangeText: handleChange, multiline: multiline, editable: !isDisabled, pointerEvents: isDisabled ? 'none' : undefined, style: [
68
121
  styles.input,
69
122
  {
70
123
  borderColor: focused && !isDisabled ? t.colorPrimary : t.colorBorder,
@@ -83,7 +136,7 @@ export const XInput = forwardRef(function XInput({ value, onChangeText, multilin
83
136
  }, onBlur: e => {
84
137
  setFocused(false);
85
138
  rest.onBlur?.(e);
86
- } }), showClear && (_jsx(Pressable, { onPress: handleClear, style: styles.clearBtn, hitSlop: 8, children: _jsxs(View, { style: [styles.clearCircle, { backgroundColor: t.colorTextQuaternary }], children: [_jsx(View, { style: [styles.clearX1, { backgroundColor: t.colorTextLightSolid }] }), _jsx(View, { style: [styles.clearX2, { backgroundColor: t.colorTextLightSolid }] })] }) }))] }));
139
+ } })), showClear && (_jsx(Pressable, { onPress: handleClear, style: styles.clearBtn, hitSlop: 8, children: _jsxs(View, { style: [styles.clearCircle, { backgroundColor: t.colorTextQuaternary }], children: [_jsx(View, { style: [styles.clearX1, { backgroundColor: t.colorTextLightSolid }] }), _jsx(View, { style: [styles.clearX2, { backgroundColor: t.colorTextLightSolid }] })] }) })), useCustomKb && (_jsx(XPullView, { visible: kbVisible, onClose: () => setKbVisible(false), side: 'bottom', overlayOpacity: 0.4, children: _jsxs(View, { style: [styles.kbPanel, { backgroundColor: t.colorBgContainer, paddingBottom: Math.max(insets.bottom, 10) }], children: [_jsxs(View, { style: [styles.kbToolbar, { borderBottomColor: t.colorSplit }], children: [_jsx(Text, { style: [styles.kbToolbarTitle, { color: t.colorTextSecondary }], numberOfLines: 1, children: rest.placeholder ?? i18n('pleaseInput') }), _jsx(Pressable, { onPress: () => setKbVisible(false), hitSlop: 8, children: _jsx(Text, { style: [styles.kbDone, { color: t.colorPrimary }], children: i18n('done') }) })] }), _jsx(XNumberKeyboard, { onKeyPress: handleKeyPress, onBackspace: handleBackspace, extraKey: customKeyboardPoint ? '.' : null })] }) }))] }));
87
140
  });
88
141
  /** antd Input.TextArea 的对应形态 */
89
142
  export const XTextArea = (props) => _jsx(XInput, { multiline: true, ...props });
@@ -107,6 +160,32 @@ const styles = StyleSheet.create({
107
160
  textAlignVertical: 'top',
108
161
  paddingVertical: 10,
109
162
  },
163
+ kbText: {
164
+ fontSize: 15,
165
+ paddingVertical: 2,
166
+ },
167
+ kbPanel: {
168
+ borderTopLeftRadius: 12,
169
+ borderTopRightRadius: 12,
170
+ overflow: 'hidden',
171
+ },
172
+ kbToolbar: {
173
+ flexDirection: 'row',
174
+ alignItems: 'center',
175
+ justifyContent: 'space-between',
176
+ paddingHorizontal: 16,
177
+ height: 44,
178
+ borderBottomWidth: StyleSheet.hairlineWidth,
179
+ },
180
+ kbToolbarTitle: {
181
+ fontSize: 13,
182
+ flex: 1,
183
+ marginRight: 8,
184
+ },
185
+ kbDone: {
186
+ fontSize: 15,
187
+ fontWeight: '600',
188
+ },
110
189
  clearBtn: {
111
190
  position: 'absolute',
112
191
  right: 8,
@@ -15,6 +15,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
15
15
  */
16
16
  import { useCallback, useMemo, useState } from 'react';
17
17
  import { Pressable, StyleSheet, Text, View } from 'react-native';
18
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
18
19
  import { XPullView } from '../XPullView';
19
20
  import { useXTheme } from '../theme';
20
21
  import { useXLocale } from '../XLocale';
@@ -81,6 +82,7 @@ export function XLicensePlateKeyboard({ phase, onInput, onBackspace, allowedKeys
81
82
  export function XLicensePlate({ value = '', onChange, length = 7, disabled = false, placeholder, style }) {
82
83
  const t = useXTheme();
83
84
  const { t: i18n } = useXLocale();
85
+ const insets = useSafeAreaInsets();
84
86
  const [visible, setVisible] = useState(false);
85
87
  /** 当前键盘阶段 */
86
88
  const phase = value.length === 0 ? 'province' : value.length === 1 ? 'city' : 'alnum';
@@ -112,7 +114,7 @@ export function XLicensePlate({ value = '', onChange, length = 7, disabled = fal
112
114
  isNewEnergyTail && { borderColor: t.colorSuccess, borderWidth: 1.5 },
113
115
  ], children: filled && (_jsx(Text, { style: [styles.cellText, { color: t.colorText }], allowFontScaling: false, children: value[i] })) }, i));
114
116
  });
115
- return (_jsxs(_Fragment, { children: [_jsx(Pressable, { onPress: () => !disabled && setVisible(true), style: [styles.cellRow, style], disabled: disabled, children: cells }), _jsx(XPullView, { visible: visible, onClose: () => setVisible(false), side: 'bottom', overlayOpacity: 0.4, children: _jsxs(View, { style: [styles.panel, { backgroundColor: t.colorBgContainer }], children: [_jsxs(View, { style: [styles.header, { borderBottomColor: t.colorSplit }], children: [_jsx(Text, { style: [styles.headerTitle, { color: t.colorTextSecondary }], allowFontScaling: false, children: length === 8 ? i18n('newEnergyTip') : i18n('platePlaceholder') }), _jsx(Pressable, { onPress: () => setVisible(false), hitSlop: 8, children: _jsx(Text, { style: [styles.doneBtn, { color: t.colorPrimary }], children: i18n('done') }) })] }), _jsx(XLicensePlateKeyboard, { phase: phase, onInput: handleInput, onBackspace: handleBackspace, allowedKeys: allowedKeys, isLast: isLast && length === 7 })] }) })] }));
117
+ return (_jsxs(_Fragment, { children: [_jsx(Pressable, { onPress: () => !disabled && setVisible(true), style: [styles.cellRow, style], disabled: disabled, children: cells }), _jsx(XPullView, { visible: visible, onClose: () => setVisible(false), side: 'bottom', overlayOpacity: 0.4, children: _jsxs(View, { style: [styles.panel, { backgroundColor: t.colorBgContainer, paddingBottom: Math.max(insets.bottom, 10) }], children: [_jsxs(View, { style: [styles.header, { borderBottomColor: t.colorSplit }], children: [_jsx(Text, { style: [styles.headerTitle, { color: t.colorTextSecondary }], allowFontScaling: false, children: length === 8 ? i18n('newEnergyTip') : i18n('platePlaceholder') }), _jsx(Pressable, { onPress: () => setVisible(false), hitSlop: 8, children: _jsx(Text, { style: [styles.doneBtn, { color: t.colorPrimary }], children: i18n('done') }) })] }), _jsx(XLicensePlateKeyboard, { phase: phase, onInput: handleInput, onBackspace: handleBackspace, allowedKeys: allowedKeys, isLast: isLast && length === 7 })] }) })] }));
116
118
  }
117
119
  const styles = StyleSheet.create({
118
120
  keyboard: {
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { XConfirmForm } from '../XConfirmForm';
3
3
  import { XTopView } from '../XTopView';
4
+ import { XActionSheetGlobalOverlay } from '../XActionSheet/global';
4
5
  export const XPopupProvider = ({ children }) => {
5
- return (_jsxs(_Fragment, { children: [children, _jsx(XConfirmForm, {}), _jsx(XTopView, {})] }));
6
+ return (_jsxs(_Fragment, { children: [children, _jsx(XConfirmForm, {}), _jsx(XActionSheetGlobalOverlay, {}), _jsx(XTopView, {})] }));
6
7
  };
@@ -32,7 +32,9 @@ export function XSignatureSkia({ value, onChange, buttonText = '签名', strokeC
32
32
  const t = useXTheme();
33
33
  const { t: i18n } = useXLocale();
34
34
  const insets = useSafeAreaInsets();
35
- const signatureValue = value?.characters ? value : emptyValue();
35
+ /** 非受控兜底:未传 value/onChange 时内部自持状态(示例/轻量场景开箱即用) */
36
+ const [innerValue, setInnerValue] = useState(null);
37
+ const signatureValue = value !== undefined ? (value?.characters ? value : emptyValue()) : (innerValue ?? emptyValue());
36
38
  /** 预览画布 ref(完成时截图用) */
37
39
  const previewCanvasRef = useCanvasRef();
38
40
  const [visible, setVisible] = useState(false);
@@ -43,8 +45,14 @@ export function XSignatureSkia({ value, onChange, buttonText = '签名', strokeC
43
45
  /** 预览:字符集拼到预览画布 */
44
46
  const previewStrokes = useMemo(() => transformCharacters(signatureValue.characters, previewSize), [previewSize, signatureValue.characters]);
45
47
  const emitCharacters = useCallback((characters) => {
46
- onChange?.(characters.length ? { version: 1, characters } : null);
47
- }, [onChange]);
48
+ const next = characters.length ? { version: 1, characters } : null;
49
+ if (value !== undefined) {
50
+ onChange?.(next);
51
+ }
52
+ else {
53
+ setInnerValue(next);
54
+ }
55
+ }, [value, onChange]);
48
56
  /** 一笔画完 */
49
57
  const handleStrokeComplete = useCallback((points, size) => {
50
58
  setCurrentStrokes(strokes => [
@@ -105,12 +113,12 @@ export function XSignatureSkia({ value, onChange, buttonText = '签名', strokeC
105
113
  setVisible(false);
106
114
  }, [signatureValue.characters, currentStrokes, emitCharacters, previewCanvasRef, onExport]);
107
115
  /** 签名板内容(XPullView 内) */
108
- const board = (_jsxs(View, { style: [styles.board, { backgroundColor: t.colorBgContainer, paddingBottom: Math.max(insets.bottom, 8) }], children: [_jsxs(ScrollView, { horizontal: true, style: [styles.characterStrip, { backgroundColor: t.colorBgLayout }], contentContainerStyle: styles.characterStripContent, children: [_jsx(Text, { style: [styles.savedCount, { color: t.colorTextTertiary }], allowFontScaling: false, children: i18n('savedChars', { n: signatureValue.characters.length }) }), signatureValue.characters.map(character => (_jsx(Pressable, { onLongPress: () => handleRemoveCharacter(character.id), style: [styles.characterChip, { borderColor: t.colorBorder, backgroundColor: t.colorBgContainer }], children: _jsx(CharacterChip, { character: character }) }, character.id)))] }), _jsxs(View, { style: styles.signArea, children: [_jsx(SignatureCanvas, { strokes: currentStrokes, touchEnabled: true, strokeColor: strokeColor, strokeWidth: strokeWidth, onStrokeComplete: handleStrokeComplete, onLayoutSize: setCanvasSize }), canvasSize.width > 0 && canvasSize.height > 0 && (_jsxs(Svg, { pointerEvents: 'none', width: canvasSize.width, height: canvasSize.height, style: styles.grid, children: [_jsx(Line, { x1: '0%', y1: '50%', x2: '100%', y2: '50%', stroke: t.colorSplit, strokeWidth: '1', strokeDasharray: [4, 4] }), _jsx(Line, { x1: '50%', y1: '0%', x2: '50%', y2: '100%', stroke: t.colorSplit, strokeWidth: '1', strokeDasharray: [4, 4] }), _jsx(Line, { x1: '0%', y1: '0%', x2: '100%', y2: '100%', stroke: t.colorSplit, strokeWidth: '1', strokeDasharray: [4, 4] }), _jsx(Line, { x1: '100%', y1: '0%', x2: '0%', y2: '100%', stroke: t.colorSplit, strokeWidth: '1', strokeDasharray: [4, 4] })] }))] }), _jsxs(View, { style: styles.footer, children: [_jsx(XButton, { type: 'text', onPress: () => setVisible(false), children: i18n('close') }), _jsx(XButton, { type: 'text', onPress: handleClearCurrent, children: i18n('rewrite') }), _jsx(XButton, { type: 'default', size: 'small', onPress: handleSaveCharacter, children: i18n('saveChar') }), _jsx(XButton, { type: 'text', danger: true, onPress: handleClearAll, children: i18n('clear') }), _jsx(XButton, { type: 'primary', size: 'small', onPress: handleDone, children: i18n('done') })] })] }));
116
+ const board = (_jsxs(View, { style: [styles.board, { backgroundColor: t.colorBgContainer, paddingBottom: Math.max(insets.bottom, 8) }], children: [_jsxs(ScrollView, { horizontal: true, style: [styles.characterStrip, { backgroundColor: t.colorBgLayout }], contentContainerStyle: styles.characterStripContent, children: [_jsx(Text, { style: [styles.savedCount, { color: t.colorTextTertiary }], allowFontScaling: false, children: i18n('savedChars', { n: signatureValue.characters.length }) }), signatureValue.characters.map(character => (_jsx(Pressable, { onLongPress: () => handleRemoveCharacter(character.id), style: [styles.characterChip, { borderColor: t.colorBorder, backgroundColor: t.colorBgContainer }], children: _jsx(CharacterChip, { character: character }) }, character.id)))] }), _jsxs(View, { style: [styles.signArea, { height: 320 }], children: [_jsx(SignatureCanvas, { strokes: currentStrokes, touchEnabled: true, strokeColor: strokeColor, strokeWidth: strokeWidth, onStrokeComplete: handleStrokeComplete, onLayoutSize: setCanvasSize }), canvasSize.width > 0 && canvasSize.height > 0 && (_jsxs(Svg, { pointerEvents: 'none', width: canvasSize.width, height: canvasSize.height, style: styles.grid, children: [_jsx(Line, { x1: '0%', y1: '50%', x2: '100%', y2: '50%', stroke: '#DDDDDD', strokeWidth: '1', strokeDasharray: [4, 4] }), _jsx(Line, { x1: '50%', y1: '0%', x2: '50%', y2: '100%', stroke: '#DDDDDD', strokeWidth: '1', strokeDasharray: [4, 4] }), _jsx(Line, { x1: '0%', y1: '0%', x2: '100%', y2: '100%', stroke: '#DDDDDD', strokeWidth: '1', strokeDasharray: [4, 4] }), _jsx(Line, { x1: '100%', y1: '0%', x2: '0%', y2: '100%', stroke: '#DDDDDD', strokeWidth: '1', strokeDasharray: [4, 4] })] }))] }), _jsxs(View, { style: styles.footer, children: [_jsx(XButton, { type: 'text', onPress: () => setVisible(false), children: i18n('close') }), _jsx(XButton, { type: 'text', onPress: handleClearCurrent, children: i18n('rewrite') }), _jsx(XButton, { type: 'default', size: 'small', onPress: handleSaveCharacter, children: i18n('saveChar') }), _jsx(XButton, { type: 'text', danger: true, onPress: handleClearAll, children: i18n('clear') }), _jsx(XButton, { type: 'primary', size: 'small', onPress: handleDone, children: i18n('done') })] })] }));
109
117
  // inline 模式:外部自己控制 XPullView(board 由 renderBoard 暴露)——当前版本仅内部用
110
118
  if (inline) {
111
119
  return board;
112
120
  }
113
- return (_jsxs(View, { style: styles.container, children: [_jsx(XButton, { type: 'primary', style: styles.openButton, onPress: () => setVisible(true), children: buttonText }), _jsxs(View, { style: [styles.preview, { borderColor: t.colorBorder }], children: [_jsx(SignatureCanvas, { strokes: previewStrokes, strokeColor: strokeColor, strokeWidth: strokeWidth, onLayoutSize: setPreviewSize, canvasRef: previewCanvasRef }), previewStrokes.length === 0 && (_jsx(Text, { style: [styles.placeholder, { color: t.colorTextTertiary }], children: i18n('signHint') }))] }), _jsx(XPullView, { visible: visible, onClose: () => setVisible(false), side: 'bottom', overlayOpacity: 0.5, children: _jsx(View, { style: { height: '88%' }, children: board }) })] }));
121
+ return (_jsxs(View, { style: styles.container, children: [_jsx(XButton, { type: 'primary', style: styles.openButton, onPress: () => setVisible(true), children: buttonText }), _jsxs(View, { style: [styles.preview, { borderColor: t.colorBorder }], children: [_jsx(SignatureCanvas, { strokes: previewStrokes, strokeColor: strokeColor, strokeWidth: strokeWidth, onLayoutSize: setPreviewSize, canvasRef: previewCanvasRef }), previewStrokes.length === 0 && (_jsx(Text, { style: [styles.placeholder, { color: t.colorTextTertiary }], children: i18n('signHint') }))] }), _jsx(XPullView, { visible: visible, onClose: () => setVisible(false), side: 'bottom', overlayOpacity: 0.5, children: _jsx(View, { style: { height: 520, paddingBottom: 10 }, children: board }) })] }));
114
122
  }
115
123
  /** 单个已存字的 chip 缩略渲染 */
116
124
  function CharacterChip({ character }) {
@@ -17,7 +17,6 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
17
17
  import { useCallback, useState } from 'react';
18
18
  import { ImageFormat, useCanvasRef } from '@shopify/react-native-skia';
19
19
  import { StyleSheet, Text, View } from 'react-native';
20
- import Svg, { Line } from 'react-native-svg';
21
20
  import { XButton } from '../XButton';
22
21
  import { useXTheme } from '../theme';
23
22
  import { useXLocale } from '../XLocale';
@@ -59,7 +58,7 @@ export function XSignature({ height = 200, strokeColor = '#111111', strokeWidth
59
58
  if (image)
60
59
  onExport?.(image.encodeToBase64(ImageFormat.PNG));
61
60
  }, [strokes.length, canvasRef, onExport]);
62
- return (_jsxs(View, { style: [styles.container, style], children: [_jsxs(View, { style: [styles.paper, { height, borderColor: t.colorBorder, borderRadius: t.borderRadiusXL }], children: [_jsx(SignatureCanvas, { strokes: strokes, touchEnabled: !disabled, strokeColor: strokeColor, strokeWidth: strokeWidth, onStrokeComplete: handleStrokeComplete, onLayoutSize: setSize, canvasRef: canvasRef }), size.width > 0 && (_jsxs(Svg, { pointerEvents: 'none', width: size.width, height: size.height, style: styles.grid, children: [_jsx(Line, { x1: '0%', y1: '50%', x2: '100%', y2: '50%', stroke: t.colorSplit, strokeWidth: '1', strokeDasharray: [4, 4] }), _jsx(Line, { x1: '50%', y1: '0%', x2: '50%', y2: '100%', stroke: t.colorSplit, strokeWidth: '1', strokeDasharray: [4, 4] })] })), strokes.length === 0 && (_jsx(Text, { pointerEvents: 'none', style: [styles.placeholder, { color: t.colorTextTertiary }], children: i18n('signHint') }))] }), _jsxs(View, { style: styles.actions, children: [_jsx(XButton, { size: 'small', onPress: handleUndo, disabled: disabled || !strokes.length, children: i18n('undo') }), _jsx(XButton, { size: 'small', danger: true, type: 'text', onPress: handleClear, disabled: disabled || !strokes.length, children: i18n('clear') }), _jsx(XButton, { size: 'small', type: 'primary', onPress: handleDone, disabled: disabled || !strokes.length, children: i18n('done') })] })] }));
61
+ return (_jsxs(View, { style: [styles.container, style], children: [_jsxs(View, { style: [styles.paper, { height, borderColor: t.colorBorder, borderRadius: t.borderRadiusXL }], children: [_jsx(SignatureCanvas, { strokes: strokes, touchEnabled: !disabled, strokeColor: strokeColor, strokeWidth: strokeWidth, onStrokeComplete: handleStrokeComplete, onLayoutSize: setSize, canvasRef: canvasRef }), strokes.length === 0 && (_jsx(Text, { pointerEvents: 'none', style: [styles.placeholder, { color: t.colorTextTertiary }], children: i18n('signHint') }))] }), _jsxs(View, { style: styles.actions, children: [_jsx(XButton, { size: 'small', onPress: handleUndo, disabled: disabled || !strokes.length, children: i18n('undo') }), _jsx(XButton, { size: 'small', danger: true, type: 'text', onPress: handleClear, disabled: disabled || !strokes.length, children: i18n('clear') }), _jsx(XButton, { size: 'small', type: 'primary', onPress: handleDone, disabled: disabled || !strokes.length, children: i18n('done') })] })] }));
63
62
  }
64
63
  const styles = StyleSheet.create({
65
64
  container: {
@@ -0,0 +1,20 @@
1
+ export interface XBrandPreset {
2
+ name: string;
3
+ /** 浅色主色 */
4
+ primary: string;
5
+ /** 暗黑主色 */
6
+ primaryDark: string;
7
+ /** 弱背景色(antd colorPrimaryBg) */
8
+ primaryBg: string;
9
+ }
10
+ export declare const X_BRAND_PRESETS: XBrandPreset[];
11
+ interface XBrandState {
12
+ brand: XBrandPreset;
13
+ setXBrand: (brand: XBrandPreset) => void;
14
+ setXBrandByName: (name: string) => void;
15
+ }
16
+ export declare const useXBrandStore: import("zustand").UseBoundStore<import("zustand").StoreApi<XBrandState>>;
17
+ /** 命令式:切换品牌色 */
18
+ export declare function setXBrand(brand: XBrandPreset): void;
19
+ export declare function setXBrandByName(name: string): void;
20
+ export {};
@@ -0,0 +1,33 @@
1
+ /**
2
+ * ============================================================================
3
+ * XBrand —— 运行时主题色切换
4
+ * ============================================================================
5
+ * 一组预设品牌色 + zustand 全局 store。setXBrand(name) 立即生效:
6
+ * 浅/深两套 token 里的 colorPrimary / colorPrimaryActive / colorPrimaryBg
7
+ * 被覆盖。组件统一 useXTheme() 取色,不需要额外订阅。
8
+ * ============================================================================
9
+ */
10
+ import { create } from 'zustand';
11
+ export const X_BRAND_PRESETS = [
12
+ { name: '海洋蓝', primary: '#2080F0', primaryDark: '#3C93F5', primaryBg: 'rgba(32,128,240,0.18)' },
13
+ { name: '薰衣紫', primary: '#7C3AED', primaryDark: '#A78BFA', primaryBg: 'rgba(124,58,237,0.18)' },
14
+ { name: '翡翠绿', primary: '#16A34A', primaryDark: '#4ADE80', primaryBg: 'rgba(22,163,74,0.18)' },
15
+ { name: '暖阳橙', primary: '#F97316', primaryDark: '#FB923C', primaryBg: 'rgba(249,115,22,0.18)' },
16
+ ];
17
+ const DEFAULT_BRAND = X_BRAND_PRESETS[0];
18
+ export const useXBrandStore = create(set => ({
19
+ brand: DEFAULT_BRAND,
20
+ setXBrand: brand => set({ brand }),
21
+ setXBrandByName: name => {
22
+ const found = X_BRAND_PRESETS.find(p => p.name === name);
23
+ if (found)
24
+ set({ brand: found });
25
+ },
26
+ }));
27
+ /** 命令式:切换品牌色 */
28
+ export function setXBrand(brand) {
29
+ useXBrandStore.getState().setXBrand(brand);
30
+ }
31
+ export function setXBrandByName(name) {
32
+ useXBrandStore.getState().setXBrandByName(name);
33
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * ============================================================================
3
+ * ThemeControls —— 暗黑/主题色/语言 三个切换按钮组
4
+ * ============================================================================
5
+ * 在 3 个位置使用:
6
+ * 1. 介绍页(app/index.tsx)顶部
7
+ * 2. 组件页(app/components/index.tsx)Stack headerRight
8
+ * 3. 我的/关于页(app/mine.tsx)顶部
9
+ *
10
+ * props:
11
+ * compact?: boolean true 时紧凑(用于 headerRight 单行)
12
+ * inline?: boolean true 时背景透明、无边框(用于页面顶部)
13
+ * ============================================================================
14
+ */
15
+ import React from 'react';
16
+ export interface ThemeControlsProps {
17
+ compact?: boolean;
18
+ style?: any;
19
+ }
20
+ /** 三个按钮组合 */
21
+ export declare function ThemeControls({ compact, style }: ThemeControlsProps): React.JSX.Element;
22
+ export default ThemeControls;