react-native-x-components 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md ADDED
@@ -0,0 +1,128 @@
1
+ # AGENTS.md — AI 编码工具使用指南
2
+
3
+ > 本文件供 AI 编码工具(Cursor / Copilot / Claude Code / WorkBuddy 等)在使用
4
+ > react-native-x-components 时参考。人也可以读,但写法面向 AI。
5
+
6
+ ## 0. 安装与前置依赖
7
+
8
+ ```bash
9
+ npm install react-native-x-components dayjs zustand
10
+ # 必需 peer(Expo 项目):
11
+ npx expo install react-native-reanimated react-native-safe-area-context react-native-svg react-native-gesture-handler
12
+ # 可选 peer(用到哪个装哪个):
13
+ # @shopify/react-native-skia → XSignatureSkia
14
+ # expo-audio → XRecord
15
+ # expo-image-picker + expo-image-manipulator + expo-file-system → XUpload*
16
+ # expo-video → XVideoPreview
17
+ # react-native-zoom-toolkit → XImagePreview 缩放
18
+ ```
19
+
20
+ 原生依赖安装后需要 `npx expo prebuild --clean`(裸项目则重新构建原生包),
21
+ 否则录音/拍照权限弹窗、Skia、视频编解码不会生效。
22
+
23
+ ## 1. 必须的 App 根部接线(漏掉 = 命令式 API 静默失效)
24
+
25
+ ```tsx
26
+ import { GestureHandlerRootView } from 'react-native-gesture-handler';
27
+ import { XPopupProvider } from 'react-native-x-components';
28
+
29
+ export default function App() {
30
+ return (
31
+ <GestureHandlerRootView style={{flex: 1}}>
32
+ <XPopupProvider>
33
+ {/* 整个 App */}
34
+ </XPopupProvider>
35
+ </GestureHandlerRootView>
36
+ );
37
+ }
38
+ ```
39
+
40
+ XPopupProvider 内部挂载:XTopView(弹层宿主)、XConfirmForm、XActionSheet 全局实例、
41
+ XToast / XLoadingModal / XImagePreview 的 Provider。以下命令式 API 全部依赖它:
42
+
43
+ - `XToastService.show({ message, type, position })`
44
+ - `confirm({ title, content, danger })` → `Promise<boolean>`
45
+ - `showXActionSheet({ title, options })` → `Promise<value | null>`
46
+ - `XLoadingModalService.show({ message }) / hide()`
47
+ - `XImagePreviewService.show({ images, initialIndex })`
48
+
49
+ ## 2. 常见错误对照表
50
+
51
+ | 症状 | 原因 | 修复 |
52
+ |---|---|---|
53
+ | Toast / confirm / ActionSheet 无反应 | XPopupProvider 未挂根 | App 根部包 `<XPopupProvider>` |
54
+ | 上传报 adapter 错误 | 未注入上传适配器 | `setXUploadAdapter(createMinioPresignedAdapter({...}))` |
55
+ | 手势 / 滚轮不响应 | 缺 gesture-handler | install + GestureHandlerRootView 包根 |
56
+ | Skia 签名报错 | 未装 skia | `npx expo install @shopify/react-native-skia` |
57
+ | 录音/拍照无权限弹窗 | prebuild 未重跑 | `npx expo prebuild --clean` |
58
+ | 暗黑切换全局不生效 | mode 被手动写死 | `setXThemeMode('system')` 恢复跟随 |
59
+ | 主题色不生效 | 组件硬编码颜色 | 颜色一律取 `useXTheme()` token |
60
+
61
+ ## 3. 最小代码骨架
62
+
63
+ ### 上传(MinIO 预签名直传)
64
+
65
+ ```tsx
66
+ import { setXUploadAdapter, createMinioPresignedAdapter, XUploadImage } from 'react-native-x-components';
67
+
68
+ setXUploadAdapter(createMinioPresignedAdapter({
69
+ getUploadUrl: async ({ name }) => {
70
+ const res = await fetch(API + '/file/getUploadUrl?fileName=' + name);
71
+ const { data } = await res.json();
72
+ return { objectKey: data.objectKey, preSignedUrl: data.preSignedUrl };
73
+ },
74
+ getPreviewUrl: async objectKey => fetchPreviewUrl(objectKey),
75
+ }));
76
+
77
+ <XUploadImage value={imgs} onChange={setImgs} max={9} />
78
+ ```
79
+
80
+ ### 表单
81
+
82
+ ```tsx
83
+ import { XForm, XInput, XButton } from 'react-native-x-components';
84
+
85
+ const [form] = XForm.useForm();
86
+ <XForm form={form} onFinish={console.log}>
87
+ <XForm.Item label="姓名" name="name" trigger="onChangeText"
88
+ rules={[{ required: true, message: '必填' }]}>
89
+ <XInput placeholder="请输入姓名" />
90
+ </XForm.Item>
91
+ <XButton type="primary" onPress={() => form.submit()}>提交</XButton>
92
+ </XForm>
93
+ ```
94
+
95
+ 注意:输入类控件 `trigger="onChangeText"`,选择类 `trigger="onChange"`(默认),写错则值不同步。
96
+
97
+ ### 主题 / 语言
98
+
99
+ ```tsx
100
+ import { useXTheme, setXThemeMode, setXBrandByName, setXLocale } from 'react-native-x-components';
101
+
102
+ setXThemeMode('dark'); // 'light' | 'dark' | 'system'(默认跟随系统)
103
+ setXBrandByName('薰衣紫'); // 4 套预设品牌色,深浅色各自适配
104
+ setXLocale('en-US'); // 组件文案即时切换
105
+
106
+ const t = useXTheme(); // 组件内取 token,禁硬编码颜色
107
+ ```
108
+
109
+ ## 4. 设计约定(生成代码前遵守)
110
+
111
+ 1. **弹层一律用本库体系**:XPullView(side: bottom/top/left/right/center)+ 全局 XTopView 宿主;**不要用 RN Modal**。
112
+ 2. **命令式服务统一 `XxxService.show()` 风格**,不再自己 setState 管 visible(除非用受控形态组件)。
113
+ 3. **颜色/圆角一律 `useXTheme()`**:浅深两套 token 自动切换,硬编码颜色是 bug。
114
+ 4. **XRadio / XCheckbox 的 onChange 事件对象与 antd 同构**:`e.target.value / e.target.checked`。
115
+ 5. XForm.Item 的 trigger:输入类 `onChangeText`,其余 `onChange`。
116
+ 6. 上传适配器协议:`upload(file, onProgress) => Promise<XUploadResult>`;`XUploadResult` 至少含 `url`(或 `objectKey`)。
117
+ 7. XSignatureSkia 需要 Skia;XRecord 需要 expo-audio;这些可选 peer 没装时不要 import 对应组件。
118
+
119
+ ## 5. 组件速查(36 个)
120
+
121
+ 基础:XButton · XDivider · XInput(customKeyboard 数字键盘) · XTag · XProgress · XRadio · XCheckbox
122
+ 表单:XForm · XFormPro · XCascadeSelect · XMultiSelect
123
+ 弹层:XPullView · XActionSheet · XPicker · XPickerDate · XModalForm · XConfirmForm · XAnimatedView · XToast · XLoadingModal
124
+ 交互:XTabs · XDropdownMenu · XAnimatedSearchPanel · XNumberKeyboard · XLicensePlate
125
+ 媒体:XCalendar · XCalendarPopup · XRecord · XSignature · XSignatureSkia · XUploadImage · XUploadVideo · XVideoPreview · XImage · XImagePreview
126
+ 数据:XChart
127
+
128
+ 完整用法与 props 表:https://react-native-x-components.dev(AI 可抓取 /llms-full.txt 获取全量文档)。
package/README.md CHANGED
@@ -1,7 +1,51 @@
1
1
  # react-native-x-components
2
2
 
3
+ <div align="center">
4
+
5
+ [![npm version](https://img.shields.io/npm/v/react-native-x-components.svg)](https://www.npmjs.com/package/react-native-x-components)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE)
7
+ [![GitHub](https://img.shields.io/badge/GitHub-TimSpan%2Frn--x--components-181717?logo=github)](https://github.com/TimSpan/rn-x-components)
8
+
9
+ </div>
10
+
3
11
  Ant Design 风格的 React Native (Expo) 组件库。对齐 antd v5 设计 token,内置**暗黑模式**与**中英双语**,弹层体系基于自研 TopView(比 RN Modal 更快),覆盖表单、选择器、日历、录音、签名(Skia 逐字签名)、上传(MinIO 预签名适配器)、轻量图表等 40+ 组件。
4
12
 
13
+ - 📦 GitHub:https://github.com/TimSpan/rn-x-components
14
+ - 📖 文档:https://react-native-x-components.dev(AI 可抓取 `/llms-full.txt` 全量文档)
15
+ - 💬 微信交流(问题反馈 / 技术交流 / 定制咨询):
16
+
17
+ <div align="center">
18
+ <img src="./wechat-qrcode.png" alt="微信添加 Otis 为好友" width="260" />
19
+ </div>
20
+
21
+ > 🤖 **AI 编码工具(Cursor/Copilot/Claude 等)请先读包内 [AGENTS.md](./AGENTS.md)** ——
22
+ > 前置接线清单、常见错误对照表、最小代码骨架,能避开 90% 的集成坑。
23
+
24
+ ## 30 秒最小骨架
25
+
26
+ ```tsx
27
+ import { GestureHandlerRootView } from 'react-native-gesture-handler';
28
+ import { XPopupProvider, XButton, XToastService, confirm } from 'react-native-x-components';
29
+
30
+ export default function App() {
31
+ return (
32
+ <GestureHandlerRootView style={{flex: 1}}>
33
+ <XPopupProvider> {/* 必挂!否则命令式 API 静默失效 */}
34
+ <XButton
35
+ type="primary"
36
+ onPress={async () => {
37
+ const ok = await confirm({ title: '确认提交?', danger: true });
38
+ if (ok) XToastService.show({ message: '已提交', type: 'success' });
39
+ }}
40
+ >
41
+ 点我
42
+ </XButton>
43
+ </XPopupProvider>
44
+ </GestureHandlerRootView>
45
+ );
46
+ }
47
+ ```
48
+
5
49
  ## 特性
6
50
 
7
51
  - 🌗 **暗黑模式**:`useXTheme()` 全组件自适应,`setXThemeMode('light' | 'dark' | 'system')` 一行切换
@@ -17,12 +61,22 @@ Ant Design 风格的 React Native (Expo) 组件库。对齐 antd v5 设计 token
17
61
  npm install react-native-x-components dayjs zustand
18
62
  # 必需 peer 依赖(Expo 项目推荐用 expo install 自动匹配版本)
19
63
  npx expo install react-native-reanimated react-native-safe-area-context react-native-svg
64
+ # 推荐安装(XImagePreview/XInput 焦点等需要手势处理):
65
+ npx expo install react-native-gesture-handler
20
66
  # 可选(按需装):
21
67
  # 签名: npx expo install @shopify/react-native-skia
22
68
  # 录音: npx expo install expo-audio
23
69
  # 上传: npx expo install expo-image-picker expo-image-manipulator expo-file-system
24
70
  ```
25
71
 
72
+ ### Android 权限说明(首次录音 / 拍照 / 选媒体时系统会自动弹)
73
+
74
+ - **录音**:`expo-audio` 在 `npx expo prebuild --clean` 后会自动往 AndroidManifest.xml 注入 `RECORD_AUDIO`、iOS Info.plist 注入 `NSMicrophoneUsageDescription`
75
+ - **拍照**:自动注入 `CAMERA`、iOS NSCameraUsageDescription
76
+ - **选媒体**:自动注入 `READ_MEDIA_IMAGES` / `READ_MEDIA_VIDEO` (Android 13+)、`READ_EXTERNAL_STORAGE` (Android 12-) 及 NSPhotoLibraryUsageDescription (iOS)
77
+
78
+ 如果预编译后系统仍不弹权限框,先执行 `npx expo prebuild --clean && npx expo run:android` 让插件重新注入原生配置。
79
+
26
80
  ## 快速上手
27
81
 
28
82
  ```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
  };