@elvora/react-native 1.0.0-rc.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.
Files changed (98) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +40 -0
  3. package/dist/index.cjs +5785 -0
  4. package/dist/index.cjs.map +1 -0
  5. package/dist/index.d.cts +1253 -0
  6. package/dist/index.d.ts +1253 -0
  7. package/dist/index.js +5683 -0
  8. package/dist/index.js.map +1 -0
  9. package/package.json +88 -0
  10. package/src/Accordion.tsx +11 -0
  11. package/src/Affix.tsx +20 -0
  12. package/src/Alert.tsx +102 -0
  13. package/src/Anchor.tsx +58 -0
  14. package/src/AutoComplete.tsx +122 -0
  15. package/src/Avatar.tsx +58 -0
  16. package/src/BackTop.tsx +71 -0
  17. package/src/Backdrop.tsx +32 -0
  18. package/src/Badge.tsx +87 -0
  19. package/src/Box.tsx +67 -0
  20. package/src/Breadcrumb.tsx +46 -0
  21. package/src/Button.test.tsx +39 -0
  22. package/src/Button.tsx +127 -0
  23. package/src/ButtonGroup.tsx +74 -0
  24. package/src/Calendar.tsx +165 -0
  25. package/src/Card.tsx +69 -0
  26. package/src/Carousel.tsx +99 -0
  27. package/src/Cascader.tsx +160 -0
  28. package/src/Checkbox.tsx +85 -0
  29. package/src/ChipInput.tsx +130 -0
  30. package/src/Collapse.tsx +120 -0
  31. package/src/ColorPicker.tsx +114 -0
  32. package/src/Container.tsx +22 -0
  33. package/src/DataGrid.tsx +170 -0
  34. package/src/DatePicker.tsx +195 -0
  35. package/src/DateRangePicker.tsx +249 -0
  36. package/src/Descriptions.tsx +98 -0
  37. package/src/Divider.tsx +32 -0
  38. package/src/Drawer.tsx +103 -0
  39. package/src/Dropdown.tsx +15 -0
  40. package/src/ElvoraProvider.tsx +31 -0
  41. package/src/Empty.tsx +34 -0
  42. package/src/FloatButton.tsx +78 -0
  43. package/src/Form.tsx +119 -0
  44. package/src/Grid.tsx +68 -0
  45. package/src/Icon.tsx +49 -0
  46. package/src/IconButton.tsx +28 -0
  47. package/src/Image.tsx +68 -0
  48. package/src/ImageList.tsx +58 -0
  49. package/src/Input.tsx +87 -0
  50. package/src/Label.tsx +46 -0
  51. package/src/List.tsx +82 -0
  52. package/src/Mentions.tsx +148 -0
  53. package/src/Menu.tsx +77 -0
  54. package/src/Modal.tsx +114 -0
  55. package/src/NumberInput.tsx +156 -0
  56. package/src/Pagination.tsx +148 -0
  57. package/src/PaginationVariants.tsx +64 -0
  58. package/src/Popover.tsx +74 -0
  59. package/src/ProForm.tsx +219 -0
  60. package/src/ProLayout.tsx +151 -0
  61. package/src/ProTable.tsx +91 -0
  62. package/src/Progress.tsx +92 -0
  63. package/src/QRCode.tsx +65 -0
  64. package/src/Radio.tsx +98 -0
  65. package/src/Rate.tsx +66 -0
  66. package/src/Result.tsx +64 -0
  67. package/src/Segmented.tsx +75 -0
  68. package/src/Select.tsx +146 -0
  69. package/src/Skeleton.tsx +49 -0
  70. package/src/Slider.tsx +122 -0
  71. package/src/SpeedDial.tsx +87 -0
  72. package/src/Spinner.tsx +29 -0
  73. package/src/Splitter.tsx +91 -0
  74. package/src/Stack.tsx +38 -0
  75. package/src/Statistic.tsx +60 -0
  76. package/src/Stepper.tsx +113 -0
  77. package/src/Steps.tsx +146 -0
  78. package/src/Switch.tsx +52 -0
  79. package/src/Table.tsx +178 -0
  80. package/src/Tabs.tsx +122 -0
  81. package/src/Tag.tsx +83 -0
  82. package/src/Textarea.tsx +22 -0
  83. package/src/TimePicker.tsx +187 -0
  84. package/src/Timeline.tsx +92 -0
  85. package/src/Toast.tsx +140 -0
  86. package/src/ToggleButton.tsx +66 -0
  87. package/src/Tooltip.tsx +56 -0
  88. package/src/Tour.tsx +118 -0
  89. package/src/Transfer.tsx +219 -0
  90. package/src/Tree.tsx +144 -0
  91. package/src/TreeSelect.tsx +221 -0
  92. package/src/Upload.tsx +109 -0
  93. package/src/Watermark.tsx +76 -0
  94. package/src/index.ts +221 -0
  95. package/src/smoke.test.tsx +113 -0
  96. package/src/test/react-native-stub.tsx +413 -0
  97. package/src/test/react-native-svg-stub.tsx +33 -0
  98. package/src/test/setup.ts +7 -0
@@ -0,0 +1,156 @@
1
+ import { forwardRef, useCallback, useState } from 'react';
2
+ import { Pressable, TextInput, View, Text, type ViewProps } from 'react-native';
3
+ import { useTheme } from './ElvoraProvider';
4
+
5
+ export interface NumberInputProps extends Omit<ViewProps, 'children'> {
6
+ value?: number | null;
7
+ defaultValue?: number | null;
8
+ onChangeValue?: (value: number | null) => void;
9
+ min?: number;
10
+ max?: number;
11
+ step?: number;
12
+ precision?: number;
13
+ isDisabled?: boolean;
14
+ isReadOnly?: boolean;
15
+ isInvalid?: boolean;
16
+ placeholder?: string;
17
+ }
18
+
19
+ function clamp(v: number, min?: number, max?: number): number {
20
+ let out = v;
21
+ if (typeof min === 'number') out = Math.max(out, min);
22
+ if (typeof max === 'number') out = Math.min(out, max);
23
+ return out;
24
+ }
25
+
26
+ function format(v: number, precision: number | undefined): string {
27
+ if (precision === undefined) return String(v);
28
+ return v.toFixed(precision);
29
+ }
30
+
31
+ export const NumberInput = forwardRef<View, NumberInputProps>(function NumberInput(props, ref) {
32
+ const {
33
+ value: valueProp,
34
+ defaultValue,
35
+ onChangeValue,
36
+ min,
37
+ max,
38
+ step = 1,
39
+ precision,
40
+ isDisabled,
41
+ isReadOnly,
42
+ isInvalid,
43
+ placeholder,
44
+ style,
45
+ ...rest
46
+ } = props;
47
+ const theme = useTheme();
48
+ const [internal, setInternal] = useState<number | null>(defaultValue ?? null);
49
+ const value = valueProp !== undefined ? valueProp : internal;
50
+ const [text, setText] = useState<string>(value == null ? '' : format(value, precision));
51
+
52
+ const setValue = useCallback(
53
+ (next: number | null) => {
54
+ if (valueProp === undefined) setInternal(next);
55
+ onChangeValue?.(next);
56
+ setText(next == null ? '' : format(next, precision));
57
+ },
58
+ [onChangeValue, precision, valueProp],
59
+ );
60
+
61
+ const increment = (delta: number) => {
62
+ if (isDisabled || isReadOnly) return;
63
+ const current = typeof value === 'number' ? value : 0;
64
+ setValue(clamp(parseFloat(format(current + delta, precision ?? 6)), min, max));
65
+ };
66
+
67
+ const onChangeText = (raw: string) => {
68
+ setText(raw);
69
+ if (raw === '' || raw === '-') {
70
+ if (valueProp === undefined) setInternal(null);
71
+ onChangeValue?.(null);
72
+ return;
73
+ }
74
+ const parsed = Number(raw);
75
+ if (!Number.isNaN(parsed)) {
76
+ const c = clamp(parsed, min, max);
77
+ if (valueProp === undefined) setInternal(c);
78
+ onChangeValue?.(c);
79
+ }
80
+ };
81
+
82
+ const onBlur = () => {
83
+ if (text === '' || text === '-') return;
84
+ const parsed = Number(text);
85
+ if (Number.isNaN(parsed)) {
86
+ setText(value == null ? '' : format(value, precision));
87
+ return;
88
+ }
89
+ setValue(clamp(parsed, min, max));
90
+ };
91
+
92
+ const borderColor = isInvalid ? theme.colors.intent.danger.solid : theme.colors.border;
93
+
94
+ return (
95
+ <View
96
+ ref={ref}
97
+ style={[
98
+ {
99
+ flexDirection: 'row',
100
+ alignItems: 'stretch',
101
+ borderRadius: Number(theme.radii.md),
102
+ borderWidth: 1,
103
+ borderColor,
104
+ overflow: 'hidden',
105
+ backgroundColor: isDisabled ? theme.colors.surface : theme.colors.surfaceElevated,
106
+ opacity: isDisabled ? 0.6 : 1,
107
+ },
108
+ style,
109
+ ]}
110
+ {...rest}
111
+ >
112
+ <TextInput
113
+ value={text}
114
+ keyboardType="numeric"
115
+ editable={!isDisabled && !isReadOnly}
116
+ placeholder={placeholder}
117
+ placeholderTextColor={theme.colors.fgMuted}
118
+ onChangeText={onChangeText}
119
+ onBlur={onBlur}
120
+ style={{ flex: 1, paddingHorizontal: 12, paddingVertical: 8, color: theme.colors.fg, fontSize: 14, minWidth: 80 }}
121
+ />
122
+ <View style={{ borderLeftWidth: 1, borderLeftColor: borderColor }}>
123
+ <Pressable
124
+ accessibilityRole="button"
125
+ accessibilityLabel="Increment"
126
+ disabled={isDisabled || isReadOnly}
127
+ onPress={() => increment(step)}
128
+ style={({ pressed }) => ({
129
+ paddingHorizontal: 10,
130
+ flex: 1,
131
+ justifyContent: 'center',
132
+ backgroundColor: pressed ? theme.colors.intent.neutral.subtle : theme.colors.surface,
133
+ borderBottomWidth: 1,
134
+ borderBottomColor: borderColor,
135
+ })}
136
+ >
137
+ <Text style={{ color: theme.colors.fg, fontSize: 12 }}>+</Text>
138
+ </Pressable>
139
+ <Pressable
140
+ accessibilityRole="button"
141
+ accessibilityLabel="Decrement"
142
+ disabled={isDisabled || isReadOnly}
143
+ onPress={() => increment(-step)}
144
+ style={({ pressed }) => ({
145
+ paddingHorizontal: 10,
146
+ flex: 1,
147
+ justifyContent: 'center',
148
+ backgroundColor: pressed ? theme.colors.intent.neutral.subtle : theme.colors.surface,
149
+ })}
150
+ >
151
+ <Text style={{ color: theme.colors.fg, fontSize: 12 }}>-</Text>
152
+ </Pressable>
153
+ </View>
154
+ </View>
155
+ );
156
+ });
@@ -0,0 +1,148 @@
1
+ import { Pressable, Text, View, type ViewProps } from 'react-native';
2
+ import { useTheme } from './ElvoraProvider';
3
+ import { Icon } from './Icon';
4
+
5
+ export interface PaginationProps extends ViewProps {
6
+ total: number;
7
+ pageSize?: number;
8
+ current?: number;
9
+ defaultCurrent?: number;
10
+ onChange?: (page: number) => void;
11
+ siblingCount?: number;
12
+ showFirstLast?: boolean;
13
+ hideOnSinglePage?: boolean;
14
+ }
15
+
16
+ function range(start: number, end: number): number[] {
17
+ const out: number[] = [];
18
+ for (let i = start; i <= end; i++) out.push(i);
19
+ return out;
20
+ }
21
+
22
+ function buildPages(current: number, totalPages: number, siblingCount: number): Array<number | 'gap'> {
23
+ const totalNumbers = siblingCount * 2 + 5;
24
+ if (totalPages <= totalNumbers) return range(1, totalPages);
25
+ const left = Math.max(current - siblingCount, 1);
26
+ const right = Math.min(current + siblingCount, totalPages);
27
+ const out: Array<number | 'gap'> = [1];
28
+ if (left > 2) out.push('gap');
29
+ else if (left === 2) out.push(2);
30
+ out.push(...range(Math.max(left, left > 2 ? left : 2), Math.min(right, totalPages - 1)));
31
+ if (right < totalPages - 1) out.push('gap');
32
+ else if (right === totalPages - 1) out.push(totalPages - 1);
33
+ if (totalPages > 1) out.push(totalPages);
34
+ const seen = new Set<number | 'gap'>();
35
+ return out.filter((v) => {
36
+ if (v === 'gap') return true;
37
+ if (seen.has(v)) return false;
38
+ seen.add(v);
39
+ return true;
40
+ });
41
+ }
42
+
43
+ /** Pagination — RN paginator. */
44
+ export function Pagination(props: PaginationProps) {
45
+ const {
46
+ total,
47
+ pageSize = 10,
48
+ current: currentProp,
49
+ defaultCurrent = 1,
50
+ onChange,
51
+ siblingCount = 1,
52
+ showFirstLast = false,
53
+ hideOnSinglePage = false,
54
+ style,
55
+ ...rest
56
+ } = props;
57
+ const theme = useTheme();
58
+ const totalPages = Math.max(1, Math.ceil(total / pageSize));
59
+ const current = Math.min(Math.max(currentProp ?? defaultCurrent, 1), totalPages);
60
+ if (hideOnSinglePage && totalPages <= 1) return null;
61
+
62
+ const pages = buildPages(current, totalPages, siblingCount);
63
+ const go = (page: number) => {
64
+ const next = Math.min(Math.max(page, 1), totalPages);
65
+ if (next !== current) onChange?.(next);
66
+ };
67
+
68
+ const btn = (active: boolean, disabled?: boolean) => ({
69
+ minWidth: 32,
70
+ height: 32,
71
+ paddingHorizontal: 8,
72
+ borderRadius: theme.radii.md,
73
+ borderWidth: 1,
74
+ borderColor: active ? theme.colors.intent.primary.solid : theme.colors.border,
75
+ backgroundColor: active ? theme.colors.intent.primary.solid : theme.colors.surfaceElevated,
76
+ opacity: disabled ? 0.5 : 1,
77
+ alignItems: 'center' as const,
78
+ justifyContent: 'center' as const,
79
+ });
80
+ const txt = (active: boolean) => ({
81
+ color: active ? theme.colors.intent.primary.solidFg : theme.colors.fg,
82
+ fontSize: 13,
83
+ fontWeight: '500' as const,
84
+ });
85
+
86
+ return (
87
+ <View style={[{ flexDirection: 'row', alignItems: 'center', gap: 6 }, style]} {...rest}>
88
+ {showFirstLast ? (
89
+ <Pressable
90
+ accessibilityRole="button"
91
+ accessibilityLabel="First page"
92
+ disabled={current === 1}
93
+ onPress={() => go(1)}
94
+ style={btn(false, current === 1)}
95
+ >
96
+ <Text style={txt(false)}>«</Text>
97
+ </Pressable>
98
+ ) : null}
99
+ <Pressable
100
+ accessibilityRole="button"
101
+ accessibilityLabel="Previous page"
102
+ disabled={current === 1}
103
+ onPress={() => go(current - 1)}
104
+ style={btn(false, current === 1)}
105
+ >
106
+ <Icon name="chevronLeft" size={14} color={theme.colors.fg} />
107
+ </Pressable>
108
+ {pages.map((p, idx) =>
109
+ p === 'gap' ? (
110
+ <Text key={`gap-${idx}`} style={{ color: theme.colors.fgSubtle, paddingHorizontal: 4 }}>
111
+ …
112
+ </Text>
113
+ ) : (
114
+ <Pressable
115
+ key={p}
116
+ accessibilityRole="button"
117
+ accessibilityLabel={`Page ${p}`}
118
+ accessibilityState={{ selected: p === current }}
119
+ onPress={() => go(p)}
120
+ style={btn(p === current)}
121
+ >
122
+ <Text style={txt(p === current)}>{p}</Text>
123
+ </Pressable>
124
+ ),
125
+ )}
126
+ <Pressable
127
+ accessibilityRole="button"
128
+ accessibilityLabel="Next page"
129
+ disabled={current === totalPages}
130
+ onPress={() => go(current + 1)}
131
+ style={btn(false, current === totalPages)}
132
+ >
133
+ <Icon name="chevronRight" size={14} color={theme.colors.fg} />
134
+ </Pressable>
135
+ {showFirstLast ? (
136
+ <Pressable
137
+ accessibilityRole="button"
138
+ accessibilityLabel="Last page"
139
+ disabled={current === totalPages}
140
+ onPress={() => go(totalPages)}
141
+ style={btn(false, current === totalPages)}
142
+ >
143
+ <Text style={txt(false)}>»</Text>
144
+ </Pressable>
145
+ ) : null}
146
+ </View>
147
+ );
148
+ }
@@ -0,0 +1,64 @@
1
+ import { Pressable, Text, View, type ViewProps } from 'react-native';
2
+ import { useTheme } from './ElvoraProvider';
3
+ import { Icon } from './Icon';
4
+
5
+ export interface SimplePaginationProps extends ViewProps {
6
+ total: number;
7
+ pageSize?: number;
8
+ current?: number;
9
+ defaultCurrent?: number;
10
+ onChange?: (page: number) => void;
11
+ }
12
+
13
+ export function SimplePagination(props: SimplePaginationProps) {
14
+ const { total, pageSize = 10, current: currentProp, defaultCurrent = 1, onChange, style, ...rest } = props;
15
+ const theme = useTheme();
16
+ const totalPages = Math.max(1, Math.ceil(total / pageSize));
17
+ const current = Math.min(Math.max(currentProp ?? defaultCurrent, 1), totalPages);
18
+ const go = (n: number) => {
19
+ const next = Math.min(Math.max(n, 1), totalPages);
20
+ if (next !== current) onChange?.(next);
21
+ };
22
+ const btn = (disabled: boolean) => ({
23
+ width: 32,
24
+ height: 32,
25
+ borderRadius: theme.radii.md,
26
+ borderWidth: 1,
27
+ borderColor: theme.colors.border,
28
+ backgroundColor: theme.colors.surfaceElevated,
29
+ alignItems: 'center' as const,
30
+ justifyContent: 'center' as const,
31
+ opacity: disabled ? 0.5 : 1,
32
+ });
33
+ return (
34
+ <View style={[{ flexDirection: 'row', alignItems: 'center', gap: 8 }, style]} {...rest}>
35
+ <Pressable
36
+ accessibilityRole="button"
37
+ accessibilityLabel="Previous page"
38
+ disabled={current === 1}
39
+ onPress={() => go(current - 1)}
40
+ style={btn(current === 1)}
41
+ >
42
+ <Icon name="chevronLeft" size={14} color={theme.colors.fg} />
43
+ </Pressable>
44
+ <Text style={{ color: theme.colors.fg, fontSize: 13 }}>
45
+ {current} / {totalPages}
46
+ </Text>
47
+ <Pressable
48
+ accessibilityRole="button"
49
+ accessibilityLabel="Next page"
50
+ disabled={current === totalPages}
51
+ onPress={() => go(current + 1)}
52
+ style={btn(current === totalPages)}
53
+ >
54
+ <Icon name="chevronRight" size={14} color={theme.colors.fg} />
55
+ </Pressable>
56
+ </View>
57
+ );
58
+ }
59
+
60
+ export interface MiniPaginationProps extends SimplePaginationProps {}
61
+
62
+ export function MiniPagination(props: MiniPaginationProps) {
63
+ return <SimplePagination {...props} />;
64
+ }
@@ -0,0 +1,74 @@
1
+ import { forwardRef, useState, type ReactNode } from 'react';
2
+ import { Modal as RNModal, Pressable, View, type ViewProps } from 'react-native';
3
+ import { useTheme } from './ElvoraProvider';
4
+
5
+ export interface PopoverProps extends Omit<ViewProps, 'children'> {
6
+ /** The trigger element. */
7
+ trigger: ReactNode;
8
+ /** Popover content. */
9
+ children: ReactNode;
10
+ /** Controlled open. */
11
+ isOpen?: boolean;
12
+ /** Controlled change handler. */
13
+ onOpenChange?: (open: boolean) => void;
14
+ /** Where the popover anchors relative to the screen. */
15
+ placement?: 'top' | 'bottom' | 'left' | 'right' | 'center';
16
+ }
17
+
18
+ /**
19
+ * Popover — content overlay anchored to a trigger. RN doesn't expose a
20
+ * positioning API, so we use a centered modal-like sheet by default.
21
+ */
22
+ export const Popover = forwardRef<View, PopoverProps>(function Popover(props, ref) {
23
+ const { trigger, children, isOpen, onOpenChange, placement = 'center', style, ...rest } = props;
24
+ const theme = useTheme();
25
+ const [internalOpen, setInternalOpen] = useState(false);
26
+ const open = isOpen ?? internalOpen;
27
+
28
+ const setOpen = (next: boolean) => {
29
+ if (isOpen === undefined) setInternalOpen(next);
30
+ onOpenChange?.(next);
31
+ };
32
+
33
+ const align =
34
+ placement === 'top'
35
+ ? { justifyContent: 'flex-start' as const, paddingTop: 80 }
36
+ : placement === 'bottom'
37
+ ? { justifyContent: 'flex-end' as const, paddingBottom: 80 }
38
+ : { justifyContent: 'center' as const };
39
+
40
+ return (
41
+ <>
42
+ <Pressable onPress={() => setOpen(true)} accessibilityRole="button">
43
+ {trigger}
44
+ </Pressable>
45
+ <RNModal visible={open} transparent animationType="fade" onRequestClose={() => setOpen(false)}>
46
+ <Pressable
47
+ onPress={() => setOpen(false)}
48
+ style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.25)', alignItems: 'center', padding: 16, ...align }}
49
+ >
50
+ <Pressable onPress={(e) => e.stopPropagation()}>
51
+ <View
52
+ ref={ref}
53
+ style={[
54
+ {
55
+ backgroundColor: theme.colors.surfaceElevated,
56
+ borderRadius: theme.radii.md,
57
+ borderWidth: 1,
58
+ borderColor: theme.colors.border,
59
+ padding: 12,
60
+ minWidth: 200,
61
+ maxWidth: 320,
62
+ },
63
+ style,
64
+ ]}
65
+ {...rest}
66
+ >
67
+ {children}
68
+ </View>
69
+ </Pressable>
70
+ </Pressable>
71
+ </RNModal>
72
+ </>
73
+ );
74
+ });
@@ -0,0 +1,219 @@
1
+ import { useCallback, useMemo, useState, type ReactNode } from 'react';
2
+ import { View, type ViewProps } from 'react-native';
3
+ import { useTheme } from './ElvoraProvider';
4
+ import { Form, FormField } from './Form';
5
+ import { Input } from './Input';
6
+ import { Textarea } from './Textarea';
7
+ import { Switch } from './Switch';
8
+ import { Checkbox } from './Checkbox';
9
+ import { Select } from './Select';
10
+ import { NumberInput } from './NumberInput';
11
+ import { Button } from './Button';
12
+
13
+ export type ProFormFieldType =
14
+ | 'text'
15
+ | 'password'
16
+ | 'email'
17
+ | 'number'
18
+ | 'textarea'
19
+ | 'switch'
20
+ | 'checkbox'
21
+ | 'select'
22
+ | 'custom';
23
+
24
+ export interface ProFormSelectOption {
25
+ value: string;
26
+ label: string;
27
+ }
28
+
29
+ export interface ProFormField {
30
+ name: string;
31
+ label?: ReactNode;
32
+ type: ProFormFieldType;
33
+ placeholder?: string;
34
+ hint?: ReactNode;
35
+ required?: boolean;
36
+ disabled?: boolean;
37
+ defaultValue?: unknown;
38
+ options?: ProFormSelectOption[];
39
+ validate?: (value: unknown, values: Record<string, unknown>) => string | undefined;
40
+ render?: (ctx: {
41
+ value: unknown;
42
+ onChange: (v: unknown) => void;
43
+ error: string | undefined;
44
+ name: string;
45
+ }) => ReactNode;
46
+ }
47
+
48
+ export interface ProFormProps extends Omit<ViewProps, 'children'> {
49
+ fields: ProFormField[];
50
+ initialValues?: Record<string, unknown>;
51
+ onSubmit?: (values: Record<string, unknown>) => void | Promise<void>;
52
+ submitLabel?: ReactNode;
53
+ resetLabel?: ReactNode;
54
+ isSubmitting?: boolean;
55
+ }
56
+
57
+ export function ProForm(props: ProFormProps) {
58
+ const {
59
+ fields,
60
+ initialValues,
61
+ onSubmit,
62
+ submitLabel = 'Submit',
63
+ resetLabel,
64
+ isSubmitting,
65
+ style,
66
+ ...rest
67
+ } = props;
68
+ const theme = useTheme();
69
+
70
+ const initial = useMemo(() => {
71
+ const seed: Record<string, unknown> = { ...(initialValues ?? {}) };
72
+ for (const f of fields) {
73
+ if (seed[f.name] === undefined && f.defaultValue !== undefined) seed[f.name] = f.defaultValue;
74
+ }
75
+ return seed;
76
+ }, [initialValues, fields]);
77
+
78
+ const [values, setValues] = useState<Record<string, unknown>>(initial);
79
+ const [errors, setErrors] = useState<Record<string, string | undefined>>({});
80
+
81
+ const setValue = useCallback((name: string, value: unknown) => {
82
+ setValues((prev) => ({ ...prev, [name]: value }));
83
+ setErrors((prev) => ({ ...prev, [name]: undefined }));
84
+ }, []);
85
+
86
+ const validate = useCallback(() => {
87
+ const next: Record<string, string | undefined> = {};
88
+ for (const field of fields) {
89
+ const value = values[field.name];
90
+ if (field.required && (value === undefined || value === null || value === '')) {
91
+ next[field.name] = 'This field is required';
92
+ continue;
93
+ }
94
+ if (field.validate) {
95
+ const msg = field.validate(value, values);
96
+ if (msg) next[field.name] = msg;
97
+ }
98
+ }
99
+ setErrors(next);
100
+ return Object.values(next).every((v) => !v);
101
+ }, [fields, values]);
102
+
103
+ const handleSubmit = useCallback(async () => {
104
+ if (!validate()) return;
105
+ if (onSubmit) await onSubmit(values);
106
+ }, [onSubmit, validate, values]);
107
+
108
+ const reset = useCallback(() => {
109
+ setValues(initial);
110
+ setErrors({});
111
+ }, [initial]);
112
+
113
+ return (
114
+ <Form style={style} {...rest}>
115
+ {fields.map((field) => {
116
+ const value = values[field.name];
117
+ const error = errors[field.name];
118
+ const onChangeValue = (v: unknown) => setValue(field.name, v);
119
+
120
+ let control: ReactNode = null;
121
+ switch (field.type) {
122
+ case 'textarea':
123
+ control = (
124
+ <Textarea
125
+ placeholder={field.placeholder}
126
+ value={String(value ?? '')}
127
+ isDisabled={field.disabled || isSubmitting}
128
+ isInvalid={Boolean(error)}
129
+ onChangeText={(text) => onChangeValue(text)}
130
+ />
131
+ );
132
+ break;
133
+ case 'select':
134
+ control = (
135
+ <Select
136
+ value={String(value ?? '')}
137
+ options={(field.options ?? []).map((opt) => ({ label: opt.label, value: opt.value }))}
138
+ placeholder={field.placeholder ?? 'Select…'}
139
+ isDisabled={field.disabled || isSubmitting}
140
+ isInvalid={Boolean(error)}
141
+ onChange={(v) => onChangeValue(v)}
142
+ />
143
+ );
144
+ break;
145
+ case 'number':
146
+ control = (
147
+ <NumberInput
148
+ value={Number(value ?? 0)}
149
+ isDisabled={field.disabled || isSubmitting}
150
+ onChangeValue={(v) => onChangeValue(v)}
151
+ />
152
+ );
153
+ break;
154
+ case 'checkbox':
155
+ control = (
156
+ <Checkbox
157
+ isChecked={Boolean(value)}
158
+ isDisabled={field.disabled || isSubmitting}
159
+ onChange={(checked) => onChangeValue(checked)}
160
+ >
161
+ {field.placeholder}
162
+ </Checkbox>
163
+ );
164
+ break;
165
+ case 'switch':
166
+ control = (
167
+ <Switch
168
+ isChecked={Boolean(value)}
169
+ isDisabled={field.disabled || isSubmitting}
170
+ onChange={(checked) => onChangeValue(checked)}
171
+ />
172
+ );
173
+ break;
174
+ case 'custom':
175
+ control = field.render
176
+ ? field.render({ value, onChange: onChangeValue, error, name: field.name })
177
+ : null;
178
+ break;
179
+ default:
180
+ control = (
181
+ <Input
182
+ placeholder={field.placeholder}
183
+ value={String(value ?? '')}
184
+ isDisabled={field.disabled || isSubmitting}
185
+ isInvalid={Boolean(error)}
186
+ onChangeText={(text) => onChangeValue(text)}
187
+ secureTextEntry={field.type === 'password'}
188
+ keyboardType={field.type === 'email' ? 'email-address' : undefined}
189
+ />
190
+ );
191
+ }
192
+
193
+ return (
194
+ <FormField
195
+ key={field.name}
196
+ name={field.name}
197
+ label={field.label}
198
+ hint={field.hint}
199
+ isRequired={field.required}
200
+ error={error}
201
+ >
202
+ {control}
203
+ </FormField>
204
+ );
205
+ })}
206
+ <View style={{ flexDirection: 'row', justifyContent: 'flex-end', gap: 8, marginTop: 8 }}>
207
+ {resetLabel ? (
208
+ <Button variant="ghost" onPress={reset} isDisabled={isSubmitting}>
209
+ {resetLabel}
210
+ </Button>
211
+ ) : null}
212
+ <Button onPress={handleSubmit} isLoading={isSubmitting}>
213
+ {submitLabel}
214
+ </Button>
215
+ </View>
216
+ {theme ? null : null}
217
+ </Form>
218
+ );
219
+ }