react-native-x-components 0.1.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/LICENSE +21 -0
- package/README.md +96 -0
- package/dist/XActionSheet/index.d.ts +47 -0
- package/dist/XActionSheet/index.js +100 -0
- package/dist/XAnimatedSearchPanel/index.d.ts +33 -0
- package/dist/XAnimatedSearchPanel/index.js +295 -0
- package/dist/XAnimatedView/index.d.ts +77 -0
- package/dist/XAnimatedView/index.js +227 -0
- package/dist/XButton/index.d.ts +44 -0
- package/dist/XButton/index.js +126 -0
- package/dist/XCalendar/XCalendarPopup.d.ts +38 -0
- package/dist/XCalendar/XCalendarPopup.js +76 -0
- package/dist/XCalendar/index.d.ts +50 -0
- package/dist/XCalendar/index.js +241 -0
- package/dist/XCarousel/index.d.ts +72 -0
- package/dist/XCarousel/index.js +183 -0
- package/dist/XCascadeSelect/index.d.ts +39 -0
- package/dist/XCascadeSelect/index.js +147 -0
- package/dist/XChart/index.d.ts +78 -0
- package/dist/XChart/index.js +162 -0
- package/dist/XCheckbox/index.d.ts +61 -0
- package/dist/XCheckbox/index.js +128 -0
- package/dist/XConfirmForm/index.d.ts +69 -0
- package/dist/XConfirmForm/index.js +113 -0
- package/dist/XDivider/index.d.ts +33 -0
- package/dist/XDivider/index.js +61 -0
- package/dist/XDropdownMenu/index.d.ts +66 -0
- package/dist/XDropdownMenu/index.js +258 -0
- package/dist/XElevator/index.d.ts +52 -0
- package/dist/XElevator/index.js +138 -0
- package/dist/XForm/FormStore.d.ts +82 -0
- package/dist/XForm/FormStore.js +279 -0
- package/dist/XForm/index.d.ts +116 -0
- package/dist/XForm/index.js +212 -0
- package/dist/XForm/types.d.ts +82 -0
- package/dist/XForm/types.js +1 -0
- package/dist/XForm/useForm.d.ts +2 -0
- package/dist/XForm/useForm.js +19 -0
- package/dist/XForm/utils.d.ts +42 -0
- package/dist/XForm/utils.js +131 -0
- package/dist/XForm/validate.d.ts +20 -0
- package/dist/XForm/validate.js +118 -0
- package/dist/XFormPro/components/FieldTrigger.d.ts +14 -0
- package/dist/XFormPro/components/FieldTrigger.js +38 -0
- package/dist/XFormPro/components/XDateField.d.ts +22 -0
- package/dist/XFormPro/components/XDateField.js +22 -0
- package/dist/XFormPro/components/XSelectField.d.ts +16 -0
- package/dist/XFormPro/components/XSelectField.js +19 -0
- package/dist/XFormPro/index.d.ts +44 -0
- package/dist/XFormPro/index.js +111 -0
- package/dist/XFormPro/renderers.d.ts +24 -0
- package/dist/XFormPro/renderers.js +53 -0
- package/dist/XFormPro/types.d.ts +142 -0
- package/dist/XFormPro/types.js +1 -0
- package/dist/XImage/index.d.ts +13 -0
- package/dist/XImage/index.js +32 -0
- package/dist/XInput/index.d.ts +43 -0
- package/dist/XInput/index.js +136 -0
- package/dist/XLicensePlate/index.d.ts +40 -0
- package/dist/XLicensePlate/index.js +172 -0
- package/dist/XLocale/index.d.ts +23 -0
- package/dist/XLocale/index.js +174 -0
- package/dist/XModalForm/index.d.ts +32 -0
- package/dist/XModalForm/index.js +85 -0
- package/dist/XMultiSelect/index.d.ts +31 -0
- package/dist/XMultiSelect/index.js +131 -0
- package/dist/XNumberKeyboard/index.d.ts +57 -0
- package/dist/XNumberKeyboard/index.js +142 -0
- package/dist/XPicker/index.d.ts +35 -0
- package/dist/XPicker/index.js +96 -0
- package/dist/XPickerDate/index.d.ts +58 -0
- package/dist/XPickerDate/index.js +234 -0
- package/dist/XPopupProvider/index.d.ts +23 -0
- package/dist/XPopupProvider/index.js +6 -0
- package/dist/XProgress/index.d.ts +49 -0
- package/dist/XProgress/index.js +196 -0
- package/dist/XProviders/XImagePreview/index.d.ts +44 -0
- package/dist/XProviders/XImagePreview/index.js +164 -0
- package/dist/XProviders/XLoadingModal/index.d.ts +46 -0
- package/dist/XProviders/XLoadingModal/index.js +119 -0
- package/dist/XProviders/XToast/index.d.ts +50 -0
- package/dist/XProviders/XToast/index.js +183 -0
- package/dist/XProviders/index.d.ts +31 -0
- package/dist/XProviders/index.js +28 -0
- package/dist/XProviders/usageExample.d.ts +19 -0
- package/dist/XProviders/usageExample.js +83 -0
- package/dist/XPullView/index.d.ts +76 -0
- package/dist/XPullView/index.js +220 -0
- package/dist/XRadio/index.d.ts +83 -0
- package/dist/XRadio/index.js +186 -0
- package/dist/XRecord/index.d.ts +37 -0
- package/dist/XRecord/index.js +160 -0
- package/dist/XSignature/XSignatureSkia.d.ts +39 -0
- package/dist/XSignature/XSignatureSkia.js +184 -0
- package/dist/XSignature/core.d.ts +69 -0
- package/dist/XSignature/core.js +108 -0
- package/dist/XSignature/index.d.ts +33 -0
- package/dist/XSignature/index.js +88 -0
- package/dist/XTabs/index.d.ts +65 -0
- package/dist/XTabs/index.js +210 -0
- package/dist/XTag/index.d.ts +61 -0
- package/dist/XTag/index.js +153 -0
- package/dist/XTopView/index.d.ts +25 -0
- package/dist/XTopView/index.js +8 -0
- package/dist/XTopViewStore/index.d.ts +45 -0
- package/dist/XTopViewStore/index.js +49 -0
- package/dist/XUpload/XUploadImage.d.ts +37 -0
- package/dist/XUpload/XUploadImage.js +142 -0
- package/dist/XUpload/XUploadVideo.d.ts +24 -0
- package/dist/XUpload/XUploadVideo.js +83 -0
- package/dist/XUpload/adapters.d.ts +50 -0
- package/dist/XUpload/adapters.js +110 -0
- package/dist/XUpload/helpers.d.ts +35 -0
- package/dist/XUpload/helpers.js +85 -0
- package/dist/XUpload/index.d.ts +12 -0
- package/dist/XUpload/index.js +5 -0
- package/dist/XUpload/provider.d.ts +26 -0
- package/dist/XUpload/provider.js +43 -0
- package/dist/XUpload/types.d.ts +31 -0
- package/dist/XUpload/types.js +4 -0
- package/dist/XWheel/index.d.ts +49 -0
- package/dist/XWheel/index.js +263 -0
- package/dist/index.d.ts +113 -0
- package/dist/index.js +127 -0
- package/dist/theme.d.ts +164 -0
- package/dist/theme.js +161 -0
- package/package.json +78 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* ============================================================================
|
|
4
|
+
* XPickerDate -- 日期时间滚轮选择器(antd-mobile DatePicker 架构移植)
|
|
5
|
+
* ============================================================================
|
|
6
|
+
*
|
|
7
|
+
* 【format 决定列】出现哪些单位 token 就显示哪些列,顺序固定为
|
|
8
|
+
* 年 → 月 → 日 → 时 → 分 → 秒
|
|
9
|
+
* (与 antd-mobile precision 模型一致:antd 用 precision 指定末列,
|
|
10
|
+
* format 是它的超集 -- antd 只能选"到某单位为止的前缀",format 可以
|
|
11
|
+
* 任意组合,如 'YYYY-MM' 或 'HH:mm')。
|
|
12
|
+
*
|
|
13
|
+
* format='YYYY-MM-DD' 年 | 月 | 日(默认)
|
|
14
|
+
* format='YYYY-MM-DD HH:mm:ss' 年 | 月 | 日 | 时 | 分 | 秒
|
|
15
|
+
* format='HH:mm:ss' 时 | 分 | 秒
|
|
16
|
+
* format='YYYY-MM' 年 | 月
|
|
17
|
+
*
|
|
18
|
+
* 输入解析与输出格式化都使用同一个 format:
|
|
19
|
+
* value 传入组件输出过的同格式字符串可精确回读(dayjs customParseFormat),
|
|
20
|
+
* 传 Date 直接取各字段,解析失败取当前时间。
|
|
21
|
+
*
|
|
22
|
+
* 【对标 antd-mobile DatePicker 的逐条核对结论】
|
|
23
|
+
* 1. 列顺序/子集模型(precision) -> format 超集实现 ✅
|
|
24
|
+
* 2. 时/分/秒两位补零,年/月/日不补
|
|
25
|
+
* (antd 默认 renderLabel: ('0'+n).slice(-2)) ✅
|
|
26
|
+
* 3. 日列随年月联动(antd getMonthDays) -> dayjs daysInMonth ✅
|
|
27
|
+
* 4. 滚轮手势/弹簧/橡皮筋/松手立即上报
|
|
28
|
+
* (antd picker-view wheel.tsx) -> 通用 XWheel 组件 ✅
|
|
29
|
+
* 5. min/max 逐列收窄(antd 全量支持) -> 仅 minYear/maxYear ⚠️ 差异,
|
|
30
|
+
* 维持现状:本项目只需要年份范围,无整段 Date 边界需求。
|
|
31
|
+
*
|
|
32
|
+
* 滚轮本体见 ./XWheel.tsx(与 XPicker 单列选择器共用)。
|
|
33
|
+
* ============================================================================
|
|
34
|
+
*/
|
|
35
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
36
|
+
import { Pressable, StyleSheet, View, Text } from 'react-native';
|
|
37
|
+
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
38
|
+
import dayjs from 'dayjs';
|
|
39
|
+
import customParseFormat from 'dayjs/plugin/customParseFormat';
|
|
40
|
+
import { XPullView } from '../XPullView';
|
|
41
|
+
import { XWheel } from '../XWheel';
|
|
42
|
+
import { useXTheme } from '../theme';
|
|
43
|
+
import { useXLocale } from '../XLocale';
|
|
44
|
+
// 按格式解析字符串值需要 customParseFormat 插件(antd 同款方案)
|
|
45
|
+
dayjs.extend(customParseFormat);
|
|
46
|
+
/** 默认 format:年月日 */
|
|
47
|
+
const DEFAULT_FORMAT = 'YYYY-MM-DD';
|
|
48
|
+
const UNIT_ORDER = ['year', 'month', 'day', 'hour', 'minute', 'second'];
|
|
49
|
+
/** 生成 [start, end] 的整数数组 */
|
|
50
|
+
const range = (start, end) => {
|
|
51
|
+
const list = [];
|
|
52
|
+
for (let i = start; i <= end; i++) {
|
|
53
|
+
list.push(i);
|
|
54
|
+
}
|
|
55
|
+
return list;
|
|
56
|
+
};
|
|
57
|
+
/** 两位补零(时/分/秒的展示,与 antd 默认 renderLabel 一致) */
|
|
58
|
+
const pad2 = (n) => String(n).padStart(2, '0');
|
|
59
|
+
/** 数值 → 滚轮选项(时分秒补零,年月日不补) */
|
|
60
|
+
const toOption = (v, pad) => ({ label: pad ? pad2(v) : String(v), value: v });
|
|
61
|
+
/** 静态列(无联动,模块级只生成一次) */
|
|
62
|
+
const MONTH_OPTIONS = range(1, 12).map(v => toOption(v, false));
|
|
63
|
+
const HOUR_OPTIONS = range(0, 23).map(v => toOption(v, true));
|
|
64
|
+
const MINUTE_OPTIONS = range(0, 59).map(v => toOption(v, true));
|
|
65
|
+
const SECOND_OPTIONS = range(0, 59).map(v => toOption(v, true));
|
|
66
|
+
/**
|
|
67
|
+
* 解析 format:出现哪些单位 token 就有哪些列(顺序固定为 UNIT_ORDER 的子序列)。
|
|
68
|
+
* 支持的 token:YYYY / MM / DD / HH / mm / ss(dayjs 标准写法,大小写敏感)。
|
|
69
|
+
* 一个单位都没有(传了不认识的格式)时退回默认年月日。
|
|
70
|
+
*/
|
|
71
|
+
const parseUnits = (format) => {
|
|
72
|
+
const tokens = {
|
|
73
|
+
year: format.includes('YYYY'),
|
|
74
|
+
month: format.includes('MM'),
|
|
75
|
+
day: format.includes('DD'),
|
|
76
|
+
hour: format.includes('HH'),
|
|
77
|
+
minute: format.includes('mm'),
|
|
78
|
+
second: format.includes('ss'),
|
|
79
|
+
};
|
|
80
|
+
const units = UNIT_ORDER.filter(unit => tokens[unit]);
|
|
81
|
+
return units.length ? units : ['year', 'month', 'day'];
|
|
82
|
+
};
|
|
83
|
+
/** dayjs 对象 → 六单位草稿(dayjs.month() 是 0~11,所以要 +1) */
|
|
84
|
+
const splitDraft = (d) => ({
|
|
85
|
+
year: d.year(),
|
|
86
|
+
month: d.month() + 1,
|
|
87
|
+
day: d.date(),
|
|
88
|
+
hour: d.hour(),
|
|
89
|
+
minute: d.minute(),
|
|
90
|
+
second: d.second(),
|
|
91
|
+
});
|
|
92
|
+
/**
|
|
93
|
+
* 外部 value → 草稿。
|
|
94
|
+
* 字符串先按 format 解析(组件输出值的回读路径,精确匹配),
|
|
95
|
+
* 失败再宽松兜底(如值 '2026-08-19' 配 format 'YYYY-MM-DD HH:mm:ss',
|
|
96
|
+
* 日期部分正确、时间归零),仍失败取当前时间;Date 直接取各字段。
|
|
97
|
+
*/
|
|
98
|
+
const parseValue = (format, value) => {
|
|
99
|
+
let d = null;
|
|
100
|
+
if (value instanceof Date) {
|
|
101
|
+
d = dayjs(value);
|
|
102
|
+
}
|
|
103
|
+
else if (typeof value === 'string' && value.trim()) {
|
|
104
|
+
d = dayjs(value, format);
|
|
105
|
+
if (!d.isValid()) {
|
|
106
|
+
d = dayjs(value);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return splitDraft(d && d.isValid() ? d : dayjs());
|
|
110
|
+
};
|
|
111
|
+
export function XPickerDate({ visible, onClose, value, onChange, title, duration = 200, format, mode = 'date', minYear = 1970, maxYear = 2100, }) {
|
|
112
|
+
const t = useXTheme();
|
|
113
|
+
const { t: i18n } = useXLocale();
|
|
114
|
+
const insets = useSafeAreaInsets();
|
|
115
|
+
const styles = useMemo(() => StyleSheet.create({
|
|
116
|
+
panel: {
|
|
117
|
+
backgroundColor: t.colorBgContainer,
|
|
118
|
+
borderTopLeftRadius: 12,
|
|
119
|
+
borderTopRightRadius: 12,
|
|
120
|
+
overflow: 'hidden',
|
|
121
|
+
},
|
|
122
|
+
toolbar: {
|
|
123
|
+
flexDirection: 'row',
|
|
124
|
+
alignItems: 'center',
|
|
125
|
+
justifyContent: 'space-between',
|
|
126
|
+
paddingHorizontal: 16,
|
|
127
|
+
height: 48,
|
|
128
|
+
borderBottomWidth: StyleSheet.hairlineWidth,
|
|
129
|
+
borderBottomColor: t.colorSplit,
|
|
130
|
+
},
|
|
131
|
+
cancelBtn: {
|
|
132
|
+
fontSize: 15,
|
|
133
|
+
},
|
|
134
|
+
title: {
|
|
135
|
+
fontSize: 16,
|
|
136
|
+
fontWeight: '600',
|
|
137
|
+
flex: 1,
|
|
138
|
+
textAlign: 'center',
|
|
139
|
+
marginHorizontal: 8,
|
|
140
|
+
},
|
|
141
|
+
confirmBtn: {
|
|
142
|
+
fontSize: 15,
|
|
143
|
+
fontWeight: '600',
|
|
144
|
+
},
|
|
145
|
+
/** 各列平分(antd: column flex:1) */
|
|
146
|
+
columns: {
|
|
147
|
+
flexDirection: 'row',
|
|
148
|
+
},
|
|
149
|
+
}), [t]);
|
|
150
|
+
/** format 优先;未传时按旧 mode 兜底,保持向后兼容 */
|
|
151
|
+
const resolvedFormat = format ?? (mode === 'time' ? 'HH:mm:ss' : DEFAULT_FORMAT);
|
|
152
|
+
/** format 决定的列集合(固定顺序) */
|
|
153
|
+
const units = useMemo(() => parseUnits(resolvedFormat), [resolvedFormat]);
|
|
154
|
+
const hasDate = units.some(unit => unit === 'year' || unit === 'month' || unit === 'day');
|
|
155
|
+
const hasTime = units.some(unit => unit === 'hour' || unit === 'minute' || unit === 'second');
|
|
156
|
+
const [draft, setDraft] = useState(() => parseValue(resolvedFormat, value));
|
|
157
|
+
/** 上一次的 visible,用于识别"本次渲染刚被打开" */
|
|
158
|
+
const prevVisibleRef = useRef(visible);
|
|
159
|
+
/** 各列滚轮的 flush 句柄:确定时强制按当前视觉位置结算 */
|
|
160
|
+
const wheelRefs = useRef({});
|
|
161
|
+
/**
|
|
162
|
+
* 打开瞬间同步重置草稿为当前 value(渲染期修正派生状态)。
|
|
163
|
+
* 不用 useEffect:effect 要等这一帧提交后才跑,弹层会先以上次的旧值挂载。
|
|
164
|
+
*/
|
|
165
|
+
if (visible !== prevVisibleRef.current) {
|
|
166
|
+
prevVisibleRef.current = visible;
|
|
167
|
+
if (visible) {
|
|
168
|
+
setDraft(parseValue(resolvedFormat, value));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/** 年列数据源(useMemo 缓存,避免每次渲染重新生成数组) */
|
|
172
|
+
const yearOptions = useMemo(() => range(minYear, maxYear).map(v => toOption(v, false)), [minYear, maxYear]);
|
|
173
|
+
/** 打开期间外部 value / format 变化(比如上层异步回填)也跟着同步 */
|
|
174
|
+
useEffect(() => {
|
|
175
|
+
if (visible) {
|
|
176
|
+
setDraft(parseValue(resolvedFormat, value));
|
|
177
|
+
}
|
|
178
|
+
}, [visible, value, resolvedFormat]);
|
|
179
|
+
/** 当月实际天数(2月28/29、小月30)。antd: getMonthDays(selectedYear, selectedMonth) */
|
|
180
|
+
const daysInMonth = useMemo(() => dayjs(`${draft.year}-${draft.month}-01`).daysInMonth(), [draft.year, draft.month]);
|
|
181
|
+
/** 日列数据源:跟随当月天数变化 */
|
|
182
|
+
const dayOptions = useMemo(() => range(1, daysInMonth).map(v => toOption(v, false)), [daysInMonth]);
|
|
183
|
+
/** 各列的选项表与显示值(静态列用模块级常量,引用稳定不触发滚轮重建手势) */
|
|
184
|
+
const columns = useMemo(() => units.map(unit => ({
|
|
185
|
+
unit,
|
|
186
|
+
options: unit === 'year'
|
|
187
|
+
? yearOptions
|
|
188
|
+
: unit === 'day'
|
|
189
|
+
? dayOptions
|
|
190
|
+
: unit === 'month'
|
|
191
|
+
? MONTH_OPTIONS
|
|
192
|
+
: unit === 'hour'
|
|
193
|
+
? HOUR_OPTIONS
|
|
194
|
+
: unit === 'minute'
|
|
195
|
+
? MINUTE_OPTIONS
|
|
196
|
+
: SECOND_OPTIONS,
|
|
197
|
+
/** 日列:草稿天数可能超过当月天数(如切到2月还停着31),夹回来 */
|
|
198
|
+
value: unit === 'day' ? Math.min(draft.day, daysInMonth) : draft[unit],
|
|
199
|
+
})), [units, yearOptions, dayOptions, draft, daysInMonth]);
|
|
200
|
+
/** 某列选中:更新草稿单位。年/月变化可能改变当月天数,顺手把日夹回来(antd 日列 upper 联动) */
|
|
201
|
+
const handleChange = useCallback((unit, v) => {
|
|
202
|
+
setDraft(prev => {
|
|
203
|
+
if (prev[unit] === v) {
|
|
204
|
+
return prev;
|
|
205
|
+
}
|
|
206
|
+
const next = { ...prev, [unit]: v };
|
|
207
|
+
if ((unit === 'year' || unit === 'month') && units.includes('day')) {
|
|
208
|
+
next.day = Math.min(prev.day, dayjs(`${next.year}-${next.month}-01`).daysInMonth());
|
|
209
|
+
}
|
|
210
|
+
return next;
|
|
211
|
+
});
|
|
212
|
+
}, [units]);
|
|
213
|
+
/**
|
|
214
|
+
* 确定:先逐列 flush(拖拽/spring 进行中也按当前视觉位置结算),
|
|
215
|
+
* 再按 format 格式化输出。
|
|
216
|
+
*/
|
|
217
|
+
const handleConfirm = useCallback(() => {
|
|
218
|
+
const final = { ...draft };
|
|
219
|
+
for (const unit of units) {
|
|
220
|
+
const flushed = wheelRefs.current[unit]?.flush();
|
|
221
|
+
if (flushed !== undefined) {
|
|
222
|
+
final[unit] = flushed.value;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
// 日再按当月天数保险收窄:防 2月31日 被 Date 构造器滚动成 3月3日
|
|
226
|
+
const safeDay = Math.min(final.day, dayjs(`${final.year}-${final.month}-01`).daysInMonth());
|
|
227
|
+
const result = dayjs(new Date(final.year, final.month - 1, safeDay, final.hour, final.minute, final.second)).format(resolvedFormat);
|
|
228
|
+
onChange?.(result);
|
|
229
|
+
onClose();
|
|
230
|
+
}, [units, draft, resolvedFormat, onChange, onClose]);
|
|
231
|
+
return (_jsx(XPullView, { visible: visible, onClose: onClose, side: 'bottom', duration: duration, overlayOpacity: 0.4, children: _jsxs(View, { style: [styles.panel, { paddingBottom: Math.max(insets.bottom, 10) }], children: [_jsxs(View, { style: styles.toolbar, children: [_jsx(Pressable, { onPress: onClose, hitSlop: 8, children: _jsx(Text, { style: [styles.cancelBtn, { color: t.colorTextSecondary }], children: i18n('cancel') }) }), _jsx(Text, { style: [styles.title, { color: t.colorText }], numberOfLines: 1, children: title ?? (hasDate && hasTime ? i18n('selectDateTime') : hasTime ? i18n('selectTime') : i18n('selectDate')) }), _jsx(Pressable, { onPress: handleConfirm, hitSlop: 8, children: _jsx(Text, { style: [styles.confirmBtn, { color: t.colorPrimary }], children: i18n('confirm') }) })] }), _jsx(View, { style: styles.columns, children: columns.map(({ unit, options, value: columnValue }) => (_jsx(XWheel, { ref: el => {
|
|
232
|
+
wheelRefs.current[unit] = el;
|
|
233
|
+
}, items: options, value: columnValue, onSelect: v => handleChange(unit, v) }, unit))) })] }) }));
|
|
234
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================================
|
|
3
|
+
* XPopupProvider —— 弹窗体系的"根部挂载点"
|
|
4
|
+
* ============================================================================
|
|
5
|
+
*
|
|
6
|
+
* 【为什么需要它?】
|
|
7
|
+
* 命令式弹窗(XConfirmForm/confirm)和 XTopView 宿主都需要一个"永远在场"
|
|
8
|
+
* 的渲染位置。RN 没有 React 官方的 Portal,所以用 Provider 组件把这两样
|
|
9
|
+
* 东西常驻挂载在应用根部(App.tsx 里已经接好)。
|
|
10
|
+
*
|
|
11
|
+
* 【渲染顺序 = 层级顺序】
|
|
12
|
+
* {children} ← 整个 App
|
|
13
|
+
* <XConfirmForm/> ← 命令式确认框实例(内部弹层会进 XTopView)
|
|
14
|
+
* <XTopView /> ← 所有 XPullView 弹层的最终渲染层(最后渲染 = 最上层)
|
|
15
|
+
*
|
|
16
|
+
* 注意必须放在 GestureHandlerRootView 之内(App.tsx 里就是这样),
|
|
17
|
+
* 否则手势库的触摸拦截会影响弹层内部的手势。
|
|
18
|
+
* ============================================================================
|
|
19
|
+
*/
|
|
20
|
+
import React from 'react';
|
|
21
|
+
export declare const XPopupProvider: ({ children }: {
|
|
22
|
+
children: React.ReactNode;
|
|
23
|
+
}) => React.JSX.Element;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { XConfirmForm } from '../XConfirmForm';
|
|
3
|
+
import { XTopView } from '../XTopView';
|
|
4
|
+
export const XPopupProvider = ({ children }) => {
|
|
5
|
+
return (_jsxs(_Fragment, { children: [children, _jsx(XConfirmForm, {}), _jsx(XTopView, {})] }));
|
|
6
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================================
|
|
3
|
+
* XProgress —— 对标 antd Progress(RN 自实现版)
|
|
4
|
+
* ============================================================================
|
|
5
|
+
*
|
|
6
|
+
* 支持的 antd API:
|
|
7
|
+
* - type: 'line' | 'circle' | 'dashboard'
|
|
8
|
+
* - percent / showInfo / format(percent) 自定义文案
|
|
9
|
+
* - status: 'success' | 'exception' | 'normal' | 'active'
|
|
10
|
+
* - strokeColor: 纯色 或 {from, to} 渐变(line 用 LinearGradient,circle 用 SVG 渐变)
|
|
11
|
+
* - trailColor / strokeWidth
|
|
12
|
+
* - success: {percent, strokeColor} —— line 形态下的已完成成功段(绿色前置段)
|
|
13
|
+
* - size: circle/dashboard 的直径(antd 老 API 叫 width,这里 size 即 width)
|
|
14
|
+
*
|
|
15
|
+
* 与 antd 的差异:
|
|
16
|
+
* - status='active' 的流光动画未实现(颜色按 normal 处理),其余行为一致
|
|
17
|
+
* - percent 超过 100:进度条封顶 100%,文案显示真实值(同 antd)
|
|
18
|
+
* - circle 满进度/成功态的 ✓、异常态的 ✕ 用 SVG Path 画(几何居中),不依赖图标字体
|
|
19
|
+
*/
|
|
20
|
+
import React from 'react';
|
|
21
|
+
import { StyleProp, ViewStyle } from 'react-native';
|
|
22
|
+
export interface XProgressProps {
|
|
23
|
+
type?: 'line' | 'circle' | 'dashboard';
|
|
24
|
+
/** 0-100;超出封顶显示 */
|
|
25
|
+
percent?: number;
|
|
26
|
+
showInfo?: boolean;
|
|
27
|
+
status?: 'success' | 'exception' | 'normal' | 'active';
|
|
28
|
+
/** 纯色或渐变 {from, to} */
|
|
29
|
+
strokeColor?: string | {
|
|
30
|
+
from: string;
|
|
31
|
+
to: string;
|
|
32
|
+
};
|
|
33
|
+
trailColor?: string;
|
|
34
|
+
/** line 默认 8,circle 默认 6 */
|
|
35
|
+
strokeWidth?: number;
|
|
36
|
+
/** circle/dashboard 直径,默认 120 */
|
|
37
|
+
size?: number;
|
|
38
|
+
/** line 形态的成功段 */
|
|
39
|
+
success?: {
|
|
40
|
+
percent?: number;
|
|
41
|
+
strokeColor?: string;
|
|
42
|
+
};
|
|
43
|
+
/** 自定义文案,返回 null 可隐藏(配合 showInfo) */
|
|
44
|
+
format?: (percent?: number) => React.ReactNode;
|
|
45
|
+
style?: StyleProp<ViewStyle>;
|
|
46
|
+
testID?: string;
|
|
47
|
+
}
|
|
48
|
+
export declare function XProgress({ type, percent, showInfo, status, strokeColor, trailColor, strokeWidth, success, format, size, style, testID, }: XProgressProps): React.JSX.Element;
|
|
49
|
+
export default XProgress;
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* ============================================================================
|
|
4
|
+
* XProgress —— 对标 antd Progress(RN 自实现版)
|
|
5
|
+
* ============================================================================
|
|
6
|
+
*
|
|
7
|
+
* 支持的 antd API:
|
|
8
|
+
* - type: 'line' | 'circle' | 'dashboard'
|
|
9
|
+
* - percent / showInfo / format(percent) 自定义文案
|
|
10
|
+
* - status: 'success' | 'exception' | 'normal' | 'active'
|
|
11
|
+
* - strokeColor: 纯色 或 {from, to} 渐变(line 用 LinearGradient,circle 用 SVG 渐变)
|
|
12
|
+
* - trailColor / strokeWidth
|
|
13
|
+
* - success: {percent, strokeColor} —— line 形态下的已完成成功段(绿色前置段)
|
|
14
|
+
* - size: circle/dashboard 的直径(antd 老 API 叫 width,这里 size 即 width)
|
|
15
|
+
*
|
|
16
|
+
* 与 antd 的差异:
|
|
17
|
+
* - status='active' 的流光动画未实现(颜色按 normal 处理),其余行为一致
|
|
18
|
+
* - percent 超过 100:进度条封顶 100%,文案显示真实值(同 antd)
|
|
19
|
+
* - circle 满进度/成功态的 ✓、异常态的 ✕ 用 SVG Path 画(几何居中),不依赖图标字体
|
|
20
|
+
*/
|
|
21
|
+
import { useEffect, useId, useRef, useState } from 'react';
|
|
22
|
+
import { Animated, StyleSheet, View, Text } from 'react-native';
|
|
23
|
+
import LinearGradient from 'react-native-linear-gradient';
|
|
24
|
+
import Svg, { Circle, Circle as SvgCircle, Defs, LinearGradient as SvgLinearGradient, Path, Stop } from 'react-native-svg';
|
|
25
|
+
import { useXTheme, xTheme } from '../theme';
|
|
26
|
+
/** 把 strokeColor 归一化成 {from, to} */
|
|
27
|
+
function normalizeGradient(color) {
|
|
28
|
+
if (!color)
|
|
29
|
+
return null;
|
|
30
|
+
if (typeof color === 'string')
|
|
31
|
+
return null; // 字符串走纯色逻辑
|
|
32
|
+
return color;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* 数字过渡动画 hook:percent 变化时从当前值平滑过渡到目标值,
|
|
36
|
+
* 每帧 setState 驱动重渲染(circle 的 strokeDashoffset 需要 JS 侧值)。
|
|
37
|
+
*/
|
|
38
|
+
function useAnimatedPercent(percent, animate = true) {
|
|
39
|
+
const [display, setDisplay] = useState(animate ? 0 : percent);
|
|
40
|
+
const current = useRef(display);
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (!animate) {
|
|
43
|
+
current.current = percent;
|
|
44
|
+
setDisplay(percent);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const value = new Animated.Value(current.current);
|
|
48
|
+
const listener = value.addListener(({ value: v }) => {
|
|
49
|
+
current.current = v;
|
|
50
|
+
setDisplay(v);
|
|
51
|
+
});
|
|
52
|
+
const timing = Animated.timing(value, {
|
|
53
|
+
toValue: percent,
|
|
54
|
+
duration: xTheme.motionDurationMid * 3,
|
|
55
|
+
useNativeDriver: false, // 要驱动 setState,不能走 native
|
|
56
|
+
});
|
|
57
|
+
timing.start(({ finished }) => {
|
|
58
|
+
if (finished)
|
|
59
|
+
value.removeListener(listener);
|
|
60
|
+
});
|
|
61
|
+
// 卸载时停掉动画,避免 setState after unmount
|
|
62
|
+
return () => {
|
|
63
|
+
timing.stop();
|
|
64
|
+
value.removeListener(listener);
|
|
65
|
+
};
|
|
66
|
+
}, [percent, animate]);
|
|
67
|
+
return display;
|
|
68
|
+
}
|
|
69
|
+
export function XProgress({ type = 'line', percent = 0, showInfo = true, status = 'normal', strokeColor, trailColor, strokeWidth, success, format, size = 120, style, testID, }) {
|
|
70
|
+
const t = useXTheme();
|
|
71
|
+
// 状态色:exception 红 / success 绿 / 其余主色(active 视同 normal)
|
|
72
|
+
const semanticColor = status === 'exception' ? t.colorError : status === 'success' ? t.colorSuccess : t.colorPrimary;
|
|
73
|
+
const gradient = normalizeGradient(strokeColor);
|
|
74
|
+
// antd 规则:渐变模式下状态色不覆盖(strokeColor 优先级高于 status)
|
|
75
|
+
const solidColor = typeof strokeColor === 'string' ? strokeColor : semanticColor;
|
|
76
|
+
const finalColor = gradient ? gradient.from : solidColor;
|
|
77
|
+
// 视觉封顶 100(文案仍显示真实值)
|
|
78
|
+
const clamped = Math.min(Math.max(percent, 0), 100);
|
|
79
|
+
const successPercent = success?.percent ? Math.min(Math.max(success.percent, 0), 100) : 0;
|
|
80
|
+
const successColor = success?.strokeColor ?? t.colorSuccess;
|
|
81
|
+
if (type === 'line') {
|
|
82
|
+
return (_jsx(XLineProgress, { percent: clamped, successPercent: successPercent, successColor: successColor, color: finalColor, gradient: gradient, trailColor: trailColor, strokeWidth: strokeWidth ?? 8, showInfo: showInfo, format: format, percentRaw: percent, status: status, style: style, testID: testID }));
|
|
83
|
+
}
|
|
84
|
+
return (_jsx(XCircleProgress, { variant: type, percent: clamped, percentRaw: percent, color: finalColor, gradient: gradient, trailColor: trailColor, strokeWidth: strokeWidth ?? 6, size: size, showInfo: showInfo, format: format, status: status, style: style, testID: testID }));
|
|
85
|
+
}
|
|
86
|
+
// ============================================================================
|
|
87
|
+
// line 形态
|
|
88
|
+
// ============================================================================
|
|
89
|
+
/**
|
|
90
|
+
* 信息文案渲染:format 返回的可能是纯字符串 —— RN 里 View 的字符串子节点不渲染,
|
|
91
|
+
* 必须包一层 Text(antd 网页端没这个约束,这是 RN 适配点)。
|
|
92
|
+
*/
|
|
93
|
+
function renderInfo(showInfo, format, percentRaw, textStyle) {
|
|
94
|
+
if (!showInfo)
|
|
95
|
+
return null;
|
|
96
|
+
const node = format ? format(percentRaw) : `${percentRaw}%`;
|
|
97
|
+
if (node === null || node === undefined)
|
|
98
|
+
return null;
|
|
99
|
+
if (typeof node === 'string' || typeof node === 'number') {
|
|
100
|
+
return (_jsx(Text, { style: textStyle, allowFontScaling: false, children: node }));
|
|
101
|
+
}
|
|
102
|
+
return node;
|
|
103
|
+
}
|
|
104
|
+
function XLineProgress(props) {
|
|
105
|
+
const t = useXTheme();
|
|
106
|
+
const { percent, percentRaw, successPercent, successColor, color, gradient, trailColor, strokeWidth, showInfo, format, status, style, testID } = props;
|
|
107
|
+
const animated = useAnimatedPercent(percent);
|
|
108
|
+
/**
|
|
109
|
+
* 填充宽度 = 进度百分比。不能用 flex:轨道是 column 方向,flex 控制的是
|
|
110
|
+
* 主轴(竖向)尺寸,横向会被 stretch 撑满 —— 表现为进度条永远全宽。
|
|
111
|
+
*/
|
|
112
|
+
const barStyle = { width: `${Math.max(animated, 0)}%` };
|
|
113
|
+
const info = renderInfo(showInfo, format, percentRaw, [
|
|
114
|
+
styles.lineInfo,
|
|
115
|
+
{ color: t.colorTextSecondary },
|
|
116
|
+
status === 'exception' && { color: t.colorError },
|
|
117
|
+
]);
|
|
118
|
+
return (_jsxs(View, { testID: testID, style: [styles.lineRow, style], children: [_jsx(View, { style: [
|
|
119
|
+
styles.track,
|
|
120
|
+
{
|
|
121
|
+
height: strokeWidth,
|
|
122
|
+
borderRadius: strokeWidth / 2,
|
|
123
|
+
backgroundColor: trailColor ?? t.colorProgressTrack,
|
|
124
|
+
},
|
|
125
|
+
], children: _jsxs(View, { style: [styles.lineFill, { flexDirection: 'row' }, barStyle], children: [successPercent > 0 && _jsx(View, { style: { flex: successPercent, backgroundColor: successColor } }), gradient ? (_jsx(LinearGradient, { colors: [gradient.from, gradient.to], start: { x: 0, y: 0 }, end: { x: 1, y: 0 }, style: { flex: Math.max(animated - successPercent, 0) } })) : (_jsx(View, { style: { flex: Math.max(animated - successPercent, 0), backgroundColor: color } }))] }) }), info] }));
|
|
126
|
+
}
|
|
127
|
+
function XCircleProgress(props) {
|
|
128
|
+
const t = useXTheme();
|
|
129
|
+
const { variant, percent, percentRaw, color, gradient, trailColor, strokeWidth, size, showInfo, format, status, style, testID } = props;
|
|
130
|
+
const animated = useAnimatedPercent(percent);
|
|
131
|
+
const gradientId = `xprog-${useId().replace(/[^a-zA-Z0-9-]/g, '')}`;
|
|
132
|
+
// dashboard:上下各留 45° 缺口(antd gapDegree=75 的视觉近似值,取 45° 更圆润)
|
|
133
|
+
const totalAngle = variant === 'dashboard' ? 270 : 360;
|
|
134
|
+
const r = (size - strokeWidth) / 2;
|
|
135
|
+
const c = 2 * Math.PI * r;
|
|
136
|
+
// 参与弧长的周长比例(dashboard 只画 270°)
|
|
137
|
+
const arcLength = (c * totalAngle) / 360;
|
|
138
|
+
/**
|
|
139
|
+
* 进度弧 = 可见弧长 + 空白补齐。不能用"全长 dash + offset 回退"的画法:
|
|
140
|
+
* 360° 圆的 gap 为 0,图案退化为连续线,offset 失效 —— 表现为圆环永远满圈。
|
|
141
|
+
*/
|
|
142
|
+
const visibleArc = arcLength * (Math.min(Math.max(animated, 0), 100) / 100);
|
|
143
|
+
const strokeDasharray = `${visibleArc} ${c - visibleArc}`;
|
|
144
|
+
// SVG Circle 的 dash 起点在 3 点钟;circle:-90° 把起点挪到 12 点;dashboard:135° 起点在 7:30
|
|
145
|
+
// 注意:react-native-svg 的 transform 用字符串形式(对象形式支持不完整)
|
|
146
|
+
const transform = `rotate(${variant === 'dashboard' ? 135 : -90}, ${size / 2}, ${size / 2})`;
|
|
147
|
+
// 满圈/成功 → ✓;异常 → ✕(antd 同款行为;SVG Path 绘制,几何上绝对居中)
|
|
148
|
+
const showDone = status === 'success' || (percent >= 100 && status !== 'exception');
|
|
149
|
+
const showException = status === 'exception';
|
|
150
|
+
const badgeColor = showDone ? t.colorSuccess : t.colorError;
|
|
151
|
+
return (_jsxs(View, { testID: testID, style: [{ width: size, height: size, alignItems: 'center', justifyContent: 'center' }, style], children: [_jsxs(Svg, { width: size, height: size, children: [_jsx(Defs, { children: gradient && (_jsxs(SvgLinearGradient, { id: gradientId, x1: '0%', y1: '0%', x2: '100%', y2: '100%', children: [_jsx(Stop, { offset: '0%', stopColor: gradient.from }), _jsx(Stop, { offset: '100%', stopColor: gradient.to })] })) }), _jsx(Circle, { cx: size / 2, cy: size / 2, r: r, stroke: trailColor ?? t.colorProgressTrack, strokeWidth: strokeWidth, fill: 'none', strokeDasharray: `${arcLength} ${c - arcLength}`, transform: transform }), _jsx(Circle, { cx: size / 2, cy: size / 2, r: r, stroke: gradient ? `url(#${gradientId})` : color, strokeWidth: strokeWidth, fill: 'none', strokeLinecap: 'round', strokeDasharray: strokeDasharray, transform: transform })] }), showInfo && (_jsx(View, { style: styles.infoOverlay, pointerEvents: 'none', children: showDone ? (_jsxs(Svg, { width: 40, height: 40, viewBox: '0 0 40 40', children: [_jsx(SvgCircle, { cx: 20, cy: 20, r: 17.5, stroke: badgeColor, strokeWidth: 2.5, fill: 'none' }), _jsx(Path, { d: 'M12.5 20.5 L17.5 25.5 L27 13.5', stroke: badgeColor, strokeWidth: 3, fill: 'none', strokeLinecap: 'round', strokeLinejoin: 'round' })] })) : showException ? (_jsxs(Svg, { width: 40, height: 40, viewBox: '0 0 40 40', children: [_jsx(SvgCircle, { cx: 20, cy: 20, r: 17.5, stroke: badgeColor, strokeWidth: 2.5, fill: 'none' }), _jsx(Path, { d: 'M14.5 14.5 L25.5 25.5 M25.5 14.5 L14.5 25.5', stroke: badgeColor, strokeWidth: 3, fill: 'none', strokeLinecap: 'round' })] })) : (_jsx(View, { style: styles.circleInfo, children: renderInfo(true, format, percentRaw, [styles.circleInfoText, { color: t.colorText }]) })) }))] }));
|
|
152
|
+
}
|
|
153
|
+
const styles = StyleSheet.create({
|
|
154
|
+
lineRow: {
|
|
155
|
+
flexDirection: 'row',
|
|
156
|
+
alignItems: 'center',
|
|
157
|
+
width: '100%',
|
|
158
|
+
},
|
|
159
|
+
track: {
|
|
160
|
+
flex: 1,
|
|
161
|
+
overflow: 'hidden',
|
|
162
|
+
flexDirection: 'row',
|
|
163
|
+
},
|
|
164
|
+
lineFill: {
|
|
165
|
+
flexDirection: 'row',
|
|
166
|
+
borderRadius: 999,
|
|
167
|
+
overflow: 'hidden',
|
|
168
|
+
},
|
|
169
|
+
lineInfo: {
|
|
170
|
+
fontSize: xTheme.fontSize,
|
|
171
|
+
marginLeft: 8,
|
|
172
|
+
minWidth: 40,
|
|
173
|
+
textAlign: 'right',
|
|
174
|
+
},
|
|
175
|
+
/**
|
|
176
|
+
* 信息浮层:absoluteFillObject 定位到圆心区域 + 双轴居中。
|
|
177
|
+
* 注意不能只用 StyleSheet.absoluteFill:绝对定位的子元素不参与父级
|
|
178
|
+
* alignItems/justifyContent 布局(Yoga 行为),必须自己声明居中,
|
|
179
|
+
* 否则 SVG 图标会缩在左上角。
|
|
180
|
+
*/
|
|
181
|
+
infoOverlay: {
|
|
182
|
+
...{ position: "absolute", top: 0, left: 0, right: 0, bottom: 0 },
|
|
183
|
+
alignItems: 'center',
|
|
184
|
+
justifyContent: 'center',
|
|
185
|
+
},
|
|
186
|
+
circleInfo: {
|
|
187
|
+
flex: 1,
|
|
188
|
+
alignItems: 'center',
|
|
189
|
+
justifyContent: 'center',
|
|
190
|
+
},
|
|
191
|
+
circleInfoText: {
|
|
192
|
+
fontSize: xTheme.fontSizeLG,
|
|
193
|
+
fontWeight: '600',
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
export default XProgress;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================================
|
|
3
|
+
* XImagePreview —— 命令式图片预览(X 系列,复刻自 components/ImagePreview)
|
|
4
|
+
* ============================================================================
|
|
5
|
+
*
|
|
6
|
+
* 由四部分组成(单文件聚合,避免目录过碎):
|
|
7
|
+
* - XGalleryImage 单图渲染(fitContainer 适配屏幕)
|
|
8
|
+
* - XImagePreview 预览 UI(Gallery 翻页 + 页码 + 关闭按钮)
|
|
9
|
+
* - XImagePreviewProvider 状态宿主,App 根部挂载一次
|
|
10
|
+
* - XImagePreviewService 命令式入口,任意地方调用
|
|
11
|
+
*
|
|
12
|
+
* 用法:
|
|
13
|
+
* XImagePreviewService.show({images: urls, initialIndex: 0});
|
|
14
|
+
* XImagePreviewService.hide();
|
|
15
|
+
*
|
|
16
|
+
* 实现要点:
|
|
17
|
+
* - 全局句柄 useEffect 注册/清理(无 render 副作用);
|
|
18
|
+
* - 关闭时清空 images,及时释放大图列表引用;
|
|
19
|
+
* - DEV 环境未挂载时给出 console.warn;
|
|
20
|
+
* - Modal 使用 transparent + fade,浮在原页面上(系统相册同款观感);
|
|
21
|
+
* - GestureHandlerRootView 是 react-native-zoom-toolkit 手势正常工作的必要包裹。
|
|
22
|
+
*/
|
|
23
|
+
import React, { type ReactNode } from 'react';
|
|
24
|
+
export interface XImagePreviewParams {
|
|
25
|
+
images: string[];
|
|
26
|
+
initialIndex?: number;
|
|
27
|
+
}
|
|
28
|
+
interface XImagePreviewProps {
|
|
29
|
+
initialIndex?: number;
|
|
30
|
+
imageList: string[];
|
|
31
|
+
onClose?: () => void;
|
|
32
|
+
}
|
|
33
|
+
export declare const XImagePreview: ({ initialIndex, imageList, onClose }: XImagePreviewProps) => React.JSX.Element;
|
|
34
|
+
export declare const XImagePreviewProvider: ({ children }: {
|
|
35
|
+
children: ReactNode;
|
|
36
|
+
}) => React.JSX.Element;
|
|
37
|
+
/**
|
|
38
|
+
* XImagePreviewService —— 命令式调用入口(可在组件外使用)
|
|
39
|
+
*/
|
|
40
|
+
export declare const XImagePreviewService: {
|
|
41
|
+
show: (params: XImagePreviewParams) => void;
|
|
42
|
+
hide: () => void;
|
|
43
|
+
};
|
|
44
|
+
export {};
|