@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.
- package/LICENSE +21 -0
- package/README.md +40 -0
- package/dist/index.cjs +5785 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +1253 -0
- package/dist/index.d.ts +1253 -0
- package/dist/index.js +5683 -0
- package/dist/index.js.map +1 -0
- package/package.json +88 -0
- package/src/Accordion.tsx +11 -0
- package/src/Affix.tsx +20 -0
- package/src/Alert.tsx +102 -0
- package/src/Anchor.tsx +58 -0
- package/src/AutoComplete.tsx +122 -0
- package/src/Avatar.tsx +58 -0
- package/src/BackTop.tsx +71 -0
- package/src/Backdrop.tsx +32 -0
- package/src/Badge.tsx +87 -0
- package/src/Box.tsx +67 -0
- package/src/Breadcrumb.tsx +46 -0
- package/src/Button.test.tsx +39 -0
- package/src/Button.tsx +127 -0
- package/src/ButtonGroup.tsx +74 -0
- package/src/Calendar.tsx +165 -0
- package/src/Card.tsx +69 -0
- package/src/Carousel.tsx +99 -0
- package/src/Cascader.tsx +160 -0
- package/src/Checkbox.tsx +85 -0
- package/src/ChipInput.tsx +130 -0
- package/src/Collapse.tsx +120 -0
- package/src/ColorPicker.tsx +114 -0
- package/src/Container.tsx +22 -0
- package/src/DataGrid.tsx +170 -0
- package/src/DatePicker.tsx +195 -0
- package/src/DateRangePicker.tsx +249 -0
- package/src/Descriptions.tsx +98 -0
- package/src/Divider.tsx +32 -0
- package/src/Drawer.tsx +103 -0
- package/src/Dropdown.tsx +15 -0
- package/src/ElvoraProvider.tsx +31 -0
- package/src/Empty.tsx +34 -0
- package/src/FloatButton.tsx +78 -0
- package/src/Form.tsx +119 -0
- package/src/Grid.tsx +68 -0
- package/src/Icon.tsx +49 -0
- package/src/IconButton.tsx +28 -0
- package/src/Image.tsx +68 -0
- package/src/ImageList.tsx +58 -0
- package/src/Input.tsx +87 -0
- package/src/Label.tsx +46 -0
- package/src/List.tsx +82 -0
- package/src/Mentions.tsx +148 -0
- package/src/Menu.tsx +77 -0
- package/src/Modal.tsx +114 -0
- package/src/NumberInput.tsx +156 -0
- package/src/Pagination.tsx +148 -0
- package/src/PaginationVariants.tsx +64 -0
- package/src/Popover.tsx +74 -0
- package/src/ProForm.tsx +219 -0
- package/src/ProLayout.tsx +151 -0
- package/src/ProTable.tsx +91 -0
- package/src/Progress.tsx +92 -0
- package/src/QRCode.tsx +65 -0
- package/src/Radio.tsx +98 -0
- package/src/Rate.tsx +66 -0
- package/src/Result.tsx +64 -0
- package/src/Segmented.tsx +75 -0
- package/src/Select.tsx +146 -0
- package/src/Skeleton.tsx +49 -0
- package/src/Slider.tsx +122 -0
- package/src/SpeedDial.tsx +87 -0
- package/src/Spinner.tsx +29 -0
- package/src/Splitter.tsx +91 -0
- package/src/Stack.tsx +38 -0
- package/src/Statistic.tsx +60 -0
- package/src/Stepper.tsx +113 -0
- package/src/Steps.tsx +146 -0
- package/src/Switch.tsx +52 -0
- package/src/Table.tsx +178 -0
- package/src/Tabs.tsx +122 -0
- package/src/Tag.tsx +83 -0
- package/src/Textarea.tsx +22 -0
- package/src/TimePicker.tsx +187 -0
- package/src/Timeline.tsx +92 -0
- package/src/Toast.tsx +140 -0
- package/src/ToggleButton.tsx +66 -0
- package/src/Tooltip.tsx +56 -0
- package/src/Tour.tsx +118 -0
- package/src/Transfer.tsx +219 -0
- package/src/Tree.tsx +144 -0
- package/src/TreeSelect.tsx +221 -0
- package/src/Upload.tsx +109 -0
- package/src/Watermark.tsx +76 -0
- package/src/index.ts +221 -0
- package/src/smoke.test.tsx +113 -0
- package/src/test/react-native-stub.tsx +413 -0
- package/src/test/react-native-svg-stub.tsx +33 -0
- package/src/test/setup.ts +7 -0
package/src/Transfer.tsx
ADDED
|
@@ -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
|
+
}
|