@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,219 @@
1
+ import { useMemo, useState, type ReactNode } from 'react';
2
+ import { Pressable, ScrollView, View, Text, TextInput, type ViewProps } from 'react-native';
3
+ import { useTheme } from './ElvoraProvider';
4
+
5
+ export interface TransferItem {
6
+ key: string;
7
+ label: ReactNode;
8
+ isDisabled?: boolean;
9
+ }
10
+
11
+ export interface TransferProps extends Omit<ViewProps, 'onChange'> {
12
+ dataSource: TransferItem[];
13
+ value?: string[];
14
+ defaultValue?: string[];
15
+ onChange?: (targetKeys: string[]) => void;
16
+ titles?: [ReactNode, ReactNode];
17
+ showSearch?: boolean;
18
+ isDisabled?: boolean;
19
+ }
20
+
21
+ interface ListProps {
22
+ title: ReactNode;
23
+ items: TransferItem[];
24
+ selected: Set<string>;
25
+ setSelected: (set: Set<string>) => void;
26
+ showSearch?: boolean;
27
+ isDisabled?: boolean;
28
+ }
29
+
30
+ function ColumnList({ title, items, selected, setSelected, showSearch, isDisabled }: ListProps) {
31
+ const theme = useTheme();
32
+ const [query, setQuery] = useState('');
33
+ const filtered = useMemo(() => {
34
+ if (!query) return items;
35
+ const q = query.toLowerCase();
36
+ return items.filter((it) => String(it.label).toLowerCase().includes(q) || it.key.toLowerCase().includes(q));
37
+ }, [items, query]);
38
+
39
+ const toggle = (it: TransferItem) => {
40
+ if (it.isDisabled || isDisabled) return;
41
+ const next = new Set(selected);
42
+ if (next.has(it.key)) next.delete(it.key);
43
+ else next.add(it.key);
44
+ setSelected(next);
45
+ };
46
+
47
+ return (
48
+ <View
49
+ style={{
50
+ width: 200,
51
+ borderWidth: 1,
52
+ borderColor: theme.colors.border,
53
+ borderRadius: Number(theme.radii.md),
54
+ backgroundColor: theme.colors.surfaceElevated,
55
+ maxHeight: 320,
56
+ }}
57
+ >
58
+ <View
59
+ style={{
60
+ flexDirection: 'row',
61
+ alignItems: 'center',
62
+ paddingHorizontal: 10,
63
+ paddingVertical: 8,
64
+ borderBottomWidth: 1,
65
+ borderBottomColor: theme.colors.border,
66
+ }}
67
+ >
68
+ <Text style={{ flex: 1, fontSize: 13, fontWeight: '500', color: theme.colors.fg }}>{title}</Text>
69
+ <Text style={{ fontSize: 12, color: theme.colors.fgMuted }}>
70
+ {selected.size}/{items.length}
71
+ </Text>
72
+ </View>
73
+ {showSearch ? (
74
+ <View style={{ padding: 6, borderBottomWidth: 1, borderBottomColor: theme.colors.border }}>
75
+ <TextInput
76
+ value={query}
77
+ onChangeText={setQuery}
78
+ placeholder="Search"
79
+ placeholderTextColor={theme.colors.fgMuted}
80
+ editable={!isDisabled}
81
+ style={{
82
+ paddingHorizontal: 8,
83
+ paddingVertical: 6,
84
+ borderWidth: 1,
85
+ borderColor: theme.colors.border,
86
+ borderRadius: Number(theme.radii.sm),
87
+ fontSize: 13,
88
+ color: theme.colors.fg,
89
+ }}
90
+ />
91
+ </View>
92
+ ) : null}
93
+ <ScrollView>
94
+ {filtered.map((it) => {
95
+ const checked = selected.has(it.key);
96
+ return (
97
+ <Pressable
98
+ key={it.key}
99
+ disabled={it.isDisabled || isDisabled}
100
+ onPress={() => toggle(it)}
101
+ style={({ pressed }) => ({
102
+ flexDirection: 'row',
103
+ alignItems: 'center',
104
+ paddingHorizontal: 10,
105
+ paddingVertical: 8,
106
+ backgroundColor: checked
107
+ ? theme.colors.intent.primary.subtle
108
+ : pressed
109
+ ? theme.colors.intent.neutral.subtle
110
+ : 'transparent',
111
+ })}
112
+ >
113
+ <View
114
+ style={{
115
+ width: 16,
116
+ height: 16,
117
+ borderRadius: Number(theme.radii.sm),
118
+ borderWidth: 1,
119
+ borderColor: theme.colors.border,
120
+ backgroundColor: checked ? theme.colors.intent.primary.solid : 'transparent',
121
+ marginRight: 8,
122
+ }}
123
+ />
124
+ <Text style={{ flex: 1, color: it.isDisabled ? theme.colors.fgSubtle : theme.colors.fg, fontSize: 13 }}>
125
+ {it.label}
126
+ </Text>
127
+ </Pressable>
128
+ );
129
+ })}
130
+ </ScrollView>
131
+ </View>
132
+ );
133
+ }
134
+
135
+ export function Transfer(props: TransferProps) {
136
+ const { dataSource, value: valueProp, defaultValue, onChange, titles, showSearch, isDisabled, style, ...rest } = props;
137
+ const theme = useTheme();
138
+ const [internal, setInternal] = useState<string[]>(defaultValue ?? []);
139
+ const targetKeys = valueProp ?? internal;
140
+ const [leftSelected, setLeftSelected] = useState<Set<string>>(new Set());
141
+ const [rightSelected, setRightSelected] = useState<Set<string>>(new Set());
142
+
143
+ const leftItems = dataSource.filter((it) => !targetKeys.includes(it.key));
144
+ const rightItems = dataSource.filter((it) => targetKeys.includes(it.key));
145
+
146
+ const setTargets = (next: string[]) => {
147
+ if (valueProp === undefined) setInternal(next);
148
+ onChange?.(next);
149
+ };
150
+
151
+ const moveRight = () => {
152
+ const keys = leftItems.filter((it) => leftSelected.has(it.key) && !it.isDisabled).map((it) => it.key);
153
+ if (keys.length === 0) return;
154
+ setTargets([...targetKeys, ...keys]);
155
+ setLeftSelected(new Set());
156
+ };
157
+
158
+ const moveLeft = () => {
159
+ const keys = new Set(targetKeys);
160
+ rightItems.forEach((it) => {
161
+ if (rightSelected.has(it.key) && !it.isDisabled) keys.delete(it.key);
162
+ });
163
+ setTargets(Array.from(keys));
164
+ setRightSelected(new Set());
165
+ };
166
+
167
+ return (
168
+ <View style={[{ flexDirection: 'row', alignItems: 'stretch', gap: 12 }, style]} {...rest}>
169
+ <ColumnList
170
+ title={titles?.[0] ?? 'Source'}
171
+ items={leftItems}
172
+ selected={leftSelected}
173
+ setSelected={setLeftSelected}
174
+ showSearch={showSearch}
175
+ isDisabled={isDisabled}
176
+ />
177
+ <View style={{ justifyContent: 'center', gap: 8 }}>
178
+ <Pressable
179
+ disabled={isDisabled || leftSelected.size === 0}
180
+ onPress={moveRight}
181
+ style={({ pressed }) => ({
182
+ paddingVertical: 6,
183
+ paddingHorizontal: 8,
184
+ borderRadius: Number(theme.radii.sm),
185
+ borderWidth: 1,
186
+ borderColor: theme.colors.border,
187
+ backgroundColor: pressed ? theme.colors.intent.neutral.subtle : theme.colors.surfaceElevated,
188
+ opacity: isDisabled || leftSelected.size === 0 ? 0.4 : 1,
189
+ })}
190
+ >
191
+ <Text style={{ color: theme.colors.fg }}>›</Text>
192
+ </Pressable>
193
+ <Pressable
194
+ disabled={isDisabled || rightSelected.size === 0}
195
+ onPress={moveLeft}
196
+ style={({ pressed }) => ({
197
+ paddingVertical: 6,
198
+ paddingHorizontal: 8,
199
+ borderRadius: Number(theme.radii.sm),
200
+ borderWidth: 1,
201
+ borderColor: theme.colors.border,
202
+ backgroundColor: pressed ? theme.colors.intent.neutral.subtle : theme.colors.surfaceElevated,
203
+ opacity: isDisabled || rightSelected.size === 0 ? 0.4 : 1,
204
+ })}
205
+ >
206
+ <Text style={{ color: theme.colors.fg }}>‹</Text>
207
+ </Pressable>
208
+ </View>
209
+ <ColumnList
210
+ title={titles?.[1] ?? 'Target'}
211
+ items={rightItems}
212
+ selected={rightSelected}
213
+ setSelected={setRightSelected}
214
+ showSearch={showSearch}
215
+ isDisabled={isDisabled}
216
+ />
217
+ </View>
218
+ );
219
+ }
package/src/Tree.tsx ADDED
@@ -0,0 +1,144 @@
1
+ import { useCallback, useState, type ReactNode } from 'react';
2
+ import { Pressable, ScrollView, View, Text, type ViewProps } from 'react-native';
3
+ import { useTheme } from './ElvoraProvider';
4
+
5
+ export interface TreeNode {
6
+ key: string;
7
+ label: ReactNode;
8
+ children?: TreeNode[];
9
+ isDisabled?: boolean;
10
+ }
11
+
12
+ export interface TreeProps extends Omit<ViewProps, 'onSelect'> {
13
+ treeData: TreeNode[];
14
+ expandedKeys?: string[];
15
+ defaultExpandedKeys?: string[];
16
+ onExpand?: (keys: string[]) => void;
17
+ selectedKeys?: string[];
18
+ defaultSelectedKeys?: string[];
19
+ onSelect?: (keys: string[], info: { node: TreeNode }) => void;
20
+ multiple?: boolean;
21
+ }
22
+
23
+ interface NodeProps {
24
+ node: TreeNode;
25
+ depth: number;
26
+ expandedKeys: Set<string>;
27
+ toggleExpand: (key: string) => void;
28
+ selectedKeys: Set<string>;
29
+ onClickNode: (node: TreeNode) => void;
30
+ }
31
+
32
+ function NodeView({ node, depth, expandedKeys, toggleExpand, selectedKeys, onClickNode }: NodeProps) {
33
+ const theme = useTheme();
34
+ const hasChildren = !!node.children && node.children.length > 0;
35
+ const isExpanded = expandedKeys.has(node.key);
36
+ const isSelected = selectedKeys.has(node.key);
37
+
38
+ return (
39
+ <View>
40
+ <Pressable
41
+ disabled={node.isDisabled}
42
+ onPress={() => {
43
+ if (node.isDisabled) return;
44
+ if (hasChildren) toggleExpand(node.key);
45
+ onClickNode(node);
46
+ }}
47
+ style={{
48
+ flexDirection: 'row',
49
+ alignItems: 'center',
50
+ paddingVertical: 6,
51
+ paddingHorizontal: 8,
52
+ marginLeft: depth * 16,
53
+ borderRadius: 6,
54
+ backgroundColor: isSelected ? theme.colors.intent.primary.subtle : 'transparent',
55
+ }}
56
+ >
57
+ <Text style={{ width: 14, color: theme.colors.fgSubtle, fontSize: 12 }}>
58
+ {hasChildren ? (isExpanded ? '▾' : '▸') : ' '}
59
+ </Text>
60
+ <Text style={{ color: node.isDisabled ? theme.colors.fgSubtle : theme.colors.fg, fontSize: 14 }}>
61
+ {typeof node.label === 'string' ? node.label : null}
62
+ </Text>
63
+ </Pressable>
64
+ {hasChildren && isExpanded
65
+ ? node.children!.map((child) => (
66
+ <NodeView
67
+ key={child.key}
68
+ node={child}
69
+ depth={depth + 1}
70
+ expandedKeys={expandedKeys}
71
+ toggleExpand={toggleExpand}
72
+ selectedKeys={selectedKeys}
73
+ onClickNode={onClickNode}
74
+ />
75
+ ))
76
+ : null}
77
+ </View>
78
+ );
79
+ }
80
+
81
+ export function Tree(props: TreeProps) {
82
+ const {
83
+ treeData,
84
+ expandedKeys: expandedProp,
85
+ defaultExpandedKeys,
86
+ onExpand,
87
+ selectedKeys: selectedProp,
88
+ defaultSelectedKeys,
89
+ onSelect,
90
+ multiple,
91
+ style,
92
+ ...rest
93
+ } = props;
94
+ const [expandedInternal, setExpandedInternal] = useState<string[]>(defaultExpandedKeys ?? []);
95
+ const [selectedInternal, setSelectedInternal] = useState<string[]>(defaultSelectedKeys ?? []);
96
+
97
+ const expandedKeys = new Set(expandedProp ?? expandedInternal);
98
+ const selectedKeys = new Set(selectedProp ?? selectedInternal);
99
+
100
+ const toggleExpand = useCallback(
101
+ (key: string) => {
102
+ const next = new Set(expandedKeys);
103
+ if (next.has(key)) next.delete(key);
104
+ else next.add(key);
105
+ const list = Array.from(next);
106
+ if (expandedProp === undefined) setExpandedInternal(list);
107
+ onExpand?.(list);
108
+ },
109
+ [expandedKeys, expandedProp, onExpand],
110
+ );
111
+
112
+ const onClickNode = useCallback(
113
+ (node: TreeNode) => {
114
+ const next = new Set(selectedKeys);
115
+ if (multiple) {
116
+ if (next.has(node.key)) next.delete(node.key);
117
+ else next.add(node.key);
118
+ } else {
119
+ next.clear();
120
+ next.add(node.key);
121
+ }
122
+ const list = Array.from(next);
123
+ if (selectedProp === undefined) setSelectedInternal(list);
124
+ onSelect?.(list, { node });
125
+ },
126
+ [selectedKeys, selectedProp, multiple, onSelect],
127
+ );
128
+
129
+ return (
130
+ <ScrollView style={style} {...rest}>
131
+ {treeData.map((node) => (
132
+ <NodeView
133
+ key={node.key}
134
+ node={node}
135
+ depth={0}
136
+ expandedKeys={expandedKeys}
137
+ toggleExpand={toggleExpand}
138
+ selectedKeys={selectedKeys}
139
+ onClickNode={onClickNode}
140
+ />
141
+ ))}
142
+ </ScrollView>
143
+ );
144
+ }
@@ -0,0 +1,221 @@
1
+ import { useMemo, useState, type ReactNode } from 'react';
2
+ import { Modal, Pressable, ScrollView, View, Text, type ViewProps } from 'react-native';
3
+ import { useTheme } from './ElvoraProvider';
4
+
5
+ export interface TreeNode {
6
+ value: string;
7
+ label: ReactNode;
8
+ children?: TreeNode[];
9
+ isDisabled?: boolean;
10
+ }
11
+
12
+ export interface TreeSelectProps extends Omit<ViewProps, 'onChange'> {
13
+ options: TreeNode[];
14
+ value?: string | string[];
15
+ defaultValue?: string | string[];
16
+ onChange?: (value: string | string[]) => void;
17
+ multiple?: boolean;
18
+ placeholder?: string;
19
+ defaultExpanded?: string[];
20
+ isDisabled?: boolean;
21
+ }
22
+
23
+ function findNode(nodes: TreeNode[], v: string): TreeNode | undefined {
24
+ for (const n of nodes) {
25
+ if (n.value === v) return n;
26
+ if (n.children) {
27
+ const f = findNode(n.children, v);
28
+ if (f) return f;
29
+ }
30
+ }
31
+ return undefined;
32
+ }
33
+
34
+ interface NodeRowProps {
35
+ node: TreeNode;
36
+ depth: number;
37
+ expanded: Set<string>;
38
+ toggle: (v: string) => void;
39
+ isSelected: (v: string) => boolean;
40
+ onSelect: (n: TreeNode) => void;
41
+ multiple: boolean;
42
+ }
43
+
44
+ function NodeRow({ node, depth, expanded, toggle, isSelected, onSelect, multiple }: NodeRowProps) {
45
+ const theme = useTheme();
46
+ const hasChildren = !!node.children?.length;
47
+ const isExpanded = expanded.has(node.value);
48
+ const selected = isSelected(node.value);
49
+
50
+ return (
51
+ <View>
52
+ <Pressable
53
+ disabled={node.isDisabled}
54
+ onPress={() => onSelect(node)}
55
+ style={({ pressed }) => ({
56
+ flexDirection: 'row',
57
+ alignItems: 'center',
58
+ paddingHorizontal: 8 + depth * 14,
59
+ paddingVertical: 6,
60
+ backgroundColor: selected ? theme.colors.intent.primary.subtle : pressed ? theme.colors.intent.neutral.subtle : 'transparent',
61
+ })}
62
+ >
63
+ {hasChildren ? (
64
+ <Pressable
65
+ onPress={() => toggle(node.value)}
66
+ hitSlop={6}
67
+ style={{ width: 16, alignItems: 'center', marginRight: 4 }}
68
+ >
69
+ <Text style={{ color: theme.colors.fgMuted }}>{isExpanded ? '▾' : '▸'}</Text>
70
+ </Pressable>
71
+ ) : (
72
+ <View style={{ width: 20 }} />
73
+ )}
74
+ {multiple ? (
75
+ <View
76
+ style={{
77
+ width: 16,
78
+ height: 16,
79
+ borderRadius: Number(theme.radii.sm),
80
+ borderWidth: 1,
81
+ borderColor: theme.colors.border,
82
+ backgroundColor: selected ? theme.colors.intent.primary.solid : 'transparent',
83
+ marginRight: 8,
84
+ }}
85
+ />
86
+ ) : null}
87
+ <Text style={{ color: node.isDisabled ? theme.colors.fgSubtle : theme.colors.fg, fontSize: 13 }}>{node.label}</Text>
88
+ </Pressable>
89
+ {hasChildren && isExpanded ? (
90
+ <View>
91
+ {node.children!.map((child) => (
92
+ <NodeRow
93
+ key={child.value}
94
+ node={child}
95
+ depth={depth + 1}
96
+ expanded={expanded}
97
+ toggle={toggle}
98
+ isSelected={isSelected}
99
+ onSelect={onSelect}
100
+ multiple={multiple}
101
+ />
102
+ ))}
103
+ </View>
104
+ ) : null}
105
+ </View>
106
+ );
107
+ }
108
+
109
+ export function TreeSelect(props: TreeSelectProps) {
110
+ const {
111
+ options,
112
+ value: valueProp,
113
+ defaultValue,
114
+ onChange,
115
+ multiple = false,
116
+ placeholder = 'Please select',
117
+ defaultExpanded,
118
+ isDisabled,
119
+ style,
120
+ ...rest
121
+ } = props;
122
+ const theme = useTheme();
123
+ const [internal, setInternal] = useState<string | string[]>(defaultValue ?? (multiple ? [] : ''));
124
+ const value = valueProp !== undefined ? valueProp : internal;
125
+ const [isOpen, setOpen] = useState(false);
126
+ const [expanded, setExpanded] = useState<Set<string>>(new Set(defaultExpanded ?? []));
127
+
128
+ const setValue = (next: string | string[]) => {
129
+ if (valueProp === undefined) setInternal(next);
130
+ onChange?.(next);
131
+ };
132
+
133
+ const toggle = (v: string) => {
134
+ setExpanded((prev) => {
135
+ const next = new Set(prev);
136
+ if (next.has(v)) next.delete(v);
137
+ else next.add(v);
138
+ return next;
139
+ });
140
+ };
141
+
142
+ const isSelected = (v: string): boolean => {
143
+ if (multiple) return Array.isArray(value) && value.includes(v);
144
+ return value === v;
145
+ };
146
+
147
+ const onSelectNode = (n: TreeNode) => {
148
+ if (multiple) {
149
+ const arr = Array.isArray(value) ? [...value] : [];
150
+ const idx = arr.indexOf(n.value);
151
+ if (idx >= 0) arr.splice(idx, 1);
152
+ else arr.push(n.value);
153
+ setValue(arr);
154
+ } else {
155
+ setValue(n.value);
156
+ setOpen(false);
157
+ }
158
+ };
159
+
160
+ const displayed = useMemo(() => {
161
+ if (multiple) {
162
+ const arr = Array.isArray(value) ? value : [];
163
+ return arr.map((v) => findNode(options, v)?.label).filter(Boolean) as ReactNode[];
164
+ }
165
+ return typeof value === 'string' && value ? [findNode(options, value)?.label].filter(Boolean) : [];
166
+ }, [value, options, multiple]);
167
+
168
+ return (
169
+ <View style={style} {...rest}>
170
+ <Pressable
171
+ accessibilityRole="button"
172
+ disabled={isDisabled}
173
+ onPress={() => setOpen(true)}
174
+ style={({ pressed }) => ({
175
+ paddingHorizontal: 12,
176
+ paddingVertical: 8,
177
+ borderWidth: 1,
178
+ borderColor: theme.colors.border,
179
+ borderRadius: Number(theme.radii.md),
180
+ backgroundColor: pressed ? theme.colors.intent.neutral.subtle : theme.colors.surfaceElevated,
181
+ opacity: isDisabled ? 0.6 : 1,
182
+ })}
183
+ >
184
+ <Text style={{ color: displayed.length === 0 ? theme.colors.fgMuted : theme.colors.fg, fontSize: 14 }}>
185
+ {displayed.length === 0 ? placeholder : displayed.map((d, i) => (i > 0 ? `, ${d}` : d)).join('')}
186
+ </Text>
187
+ </Pressable>
188
+ <Modal transparent visible={isOpen} animationType="fade" onRequestClose={() => setOpen(false)}>
189
+ <Pressable
190
+ style={{ flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: 'rgba(0,0,0,0.4)' }}
191
+ onPress={() => setOpen(false)}
192
+ >
193
+ <Pressable
194
+ onPress={() => undefined}
195
+ style={{
196
+ backgroundColor: theme.colors.surfaceElevated,
197
+ borderRadius: Number(theme.radii.md),
198
+ width: 280,
199
+ maxHeight: 360,
200
+ }}
201
+ >
202
+ <ScrollView>
203
+ {options.map((opt) => (
204
+ <NodeRow
205
+ key={opt.value}
206
+ node={opt}
207
+ depth={0}
208
+ expanded={expanded}
209
+ toggle={toggle}
210
+ isSelected={isSelected}
211
+ onSelect={onSelectNode}
212
+ multiple={multiple}
213
+ />
214
+ ))}
215
+ </ScrollView>
216
+ </Pressable>
217
+ </Pressable>
218
+ </Modal>
219
+ </View>
220
+ );
221
+ }
package/src/Upload.tsx ADDED
@@ -0,0 +1,109 @@
1
+ import { useState, type ReactNode } from 'react';
2
+ import { Pressable, View, Text, type ViewProps } from 'react-native';
3
+ import { useTheme } from './ElvoraProvider';
4
+
5
+ export interface UploadFile {
6
+ id: string;
7
+ name: string;
8
+ size?: number;
9
+ uri?: string;
10
+ status?: 'pending' | 'uploading' | 'done' | 'error';
11
+ error?: string;
12
+ }
13
+
14
+ export interface UploadProps extends ViewProps {
15
+ files?: UploadFile[];
16
+ defaultFiles?: UploadFile[];
17
+ onChange?: (files: UploadFile[]) => void;
18
+ /**
19
+ * Called when the user taps the picker area. Host app is expected to launch
20
+ * the platform file picker (e.g., expo-document-picker) and add the resulting
21
+ * files via `onChange`.
22
+ */
23
+ onPick?: () => void;
24
+ description?: ReactNode;
25
+ isDisabled?: boolean;
26
+ }
27
+
28
+ let nextId = 0;
29
+
30
+ export function Upload(props: UploadProps) {
31
+ const { files: filesProp, defaultFiles = [], onChange, onPick, description, isDisabled, style, ...rest } = props;
32
+ const theme = useTheme();
33
+ const [internal, setInternal] = useState<UploadFile[]>(defaultFiles);
34
+ const files = filesProp ?? internal;
35
+
36
+ const setFiles = (next: UploadFile[]) => {
37
+ if (filesProp === undefined) setInternal(next);
38
+ onChange?.(next);
39
+ };
40
+
41
+ const remove = (id: string) => setFiles(files.filter((f) => f.id !== id));
42
+
43
+ return (
44
+ <View style={style} {...rest}>
45
+ <Pressable
46
+ accessibilityRole="button"
47
+ disabled={isDisabled}
48
+ onPress={onPick}
49
+ style={({ pressed }) => ({
50
+ padding: 20,
51
+ borderRadius: Number(theme.radii.md),
52
+ borderWidth: 2,
53
+ borderStyle: 'dashed',
54
+ borderColor: pressed ? theme.colors.intent.primary.solid : theme.colors.border,
55
+ backgroundColor: pressed ? theme.colors.intent.primary.subtle : theme.colors.surface,
56
+ alignItems: 'center',
57
+ opacity: isDisabled ? 0.6 : 1,
58
+ })}
59
+ >
60
+ <Text style={{ fontSize: 14, color: theme.colors.fg, fontWeight: '500' }}>Tap to add files</Text>
61
+ {description ? <Text style={{ marginTop: 4, fontSize: 12, color: theme.colors.fgMuted }}>{description}</Text> : null}
62
+ </Pressable>
63
+ {files.length > 0 ? (
64
+ <View style={{ marginTop: 12, gap: 6 }}>
65
+ {files.map((f) => (
66
+ <View
67
+ key={f.id}
68
+ style={{
69
+ flexDirection: 'row',
70
+ alignItems: 'center',
71
+ paddingHorizontal: 12,
72
+ paddingVertical: 8,
73
+ borderRadius: Number(theme.radii.sm),
74
+ borderWidth: 1,
75
+ borderColor: theme.colors.border,
76
+ backgroundColor: theme.colors.surface,
77
+ }}
78
+ >
79
+ <View style={{ flex: 1 }}>
80
+ <Text style={{ fontSize: 13, color: theme.colors.fg }} numberOfLines={1}>
81
+ {f.name}
82
+ </Text>
83
+ <Text style={{ fontSize: 11, color: theme.colors.fgMuted }}>
84
+ {f.size != null ? `${(f.size / 1024).toFixed(1)} KB` : ''} {f.status && f.status !== 'pending' ? `• ${f.status}` : ''}
85
+ </Text>
86
+ </View>
87
+ <Pressable
88
+ accessibilityRole="button"
89
+ accessibilityLabel={`Remove ${f.name}`}
90
+ onPress={() => remove(f.id)}
91
+ style={({ pressed }) => ({
92
+ padding: 6,
93
+ borderRadius: Number(theme.radii.sm),
94
+ backgroundColor: pressed ? theme.colors.intent.neutral.subtle : 'transparent',
95
+ })}
96
+ >
97
+ <Text style={{ color: theme.colors.fgMuted }}>×</Text>
98
+ </Pressable>
99
+ </View>
100
+ ))}
101
+ </View>
102
+ ) : null}
103
+ </View>
104
+ );
105
+ }
106
+
107
+ export function makeUploadFile(name: string, opts: Partial<UploadFile> = {}): UploadFile {
108
+ return { id: `upload-${++nextId}`, name, ...opts };
109
+ }