@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
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { useCallback, useState, type ReactNode } from 'react';
|
|
2
|
+
import { Pressable, ScrollView, Text, View, useWindowDimensions, type ViewProps } from 'react-native';
|
|
3
|
+
import type { IconName } from '@elvora/icons';
|
|
4
|
+
import { useTheme } from './ElvoraProvider';
|
|
5
|
+
import { Avatar } from './Avatar';
|
|
6
|
+
import { Icon } from './Icon';
|
|
7
|
+
|
|
8
|
+
export interface ProLayoutMenuItem {
|
|
9
|
+
key: string;
|
|
10
|
+
label: ReactNode;
|
|
11
|
+
icon?: IconName;
|
|
12
|
+
children?: ProLayoutMenuItem[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ProLayoutUser {
|
|
16
|
+
name: string;
|
|
17
|
+
avatar?: string;
|
|
18
|
+
caption?: ReactNode;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ProLayoutProps extends Omit<ViewProps, 'children'> {
|
|
22
|
+
brand?: ReactNode;
|
|
23
|
+
menu?: ProLayoutMenuItem[];
|
|
24
|
+
selectedKey?: string;
|
|
25
|
+
onSelect?: (key: string) => void;
|
|
26
|
+
user?: ProLayoutUser;
|
|
27
|
+
header?: ReactNode;
|
|
28
|
+
footer?: ReactNode;
|
|
29
|
+
children?: ReactNode;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function renderMenuItems(
|
|
33
|
+
items: ProLayoutMenuItem[],
|
|
34
|
+
selectedKey: string | undefined,
|
|
35
|
+
onSelect: (key: string) => void,
|
|
36
|
+
theme: ReturnType<typeof useTheme>,
|
|
37
|
+
depth = 0,
|
|
38
|
+
): ReactNode {
|
|
39
|
+
return items.map((item) => {
|
|
40
|
+
const isSelected = item.key === selectedKey;
|
|
41
|
+
return (
|
|
42
|
+
<View key={item.key}>
|
|
43
|
+
<Pressable
|
|
44
|
+
accessibilityRole="menuitem"
|
|
45
|
+
onPress={() => onSelect(item.key)}
|
|
46
|
+
style={{
|
|
47
|
+
flexDirection: 'row',
|
|
48
|
+
alignItems: 'center',
|
|
49
|
+
paddingHorizontal: 12 + depth * 12,
|
|
50
|
+
paddingVertical: 8,
|
|
51
|
+
backgroundColor: isSelected ? theme.colors.intent.primary.subtle : 'transparent',
|
|
52
|
+
borderRadius: 6,
|
|
53
|
+
gap: 8,
|
|
54
|
+
}}
|
|
55
|
+
>
|
|
56
|
+
{item.icon ? <Icon name={item.icon} size={16} /> : null}
|
|
57
|
+
<Text
|
|
58
|
+
style={{
|
|
59
|
+
color: isSelected ? theme.colors.intent.primary.fg : theme.colors.fg,
|
|
60
|
+
fontWeight: isSelected ? '600' : '400',
|
|
61
|
+
fontSize: 14,
|
|
62
|
+
}}
|
|
63
|
+
>
|
|
64
|
+
{item.label}
|
|
65
|
+
</Text>
|
|
66
|
+
</Pressable>
|
|
67
|
+
{item.children?.length ? renderMenuItems(item.children, selectedKey, onSelect, theme, depth + 1) : null}
|
|
68
|
+
</View>
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function ProLayout(props: ProLayoutProps) {
|
|
74
|
+
const {
|
|
75
|
+
brand,
|
|
76
|
+
menu = [],
|
|
77
|
+
selectedKey,
|
|
78
|
+
onSelect,
|
|
79
|
+
user,
|
|
80
|
+
header,
|
|
81
|
+
footer,
|
|
82
|
+
children,
|
|
83
|
+
style,
|
|
84
|
+
...rest
|
|
85
|
+
} = props;
|
|
86
|
+
const theme = useTheme();
|
|
87
|
+
const { width } = useWindowDimensions();
|
|
88
|
+
const showSidebar = width >= 720;
|
|
89
|
+
const [internalKey, setInternalKey] = useState<string | undefined>(selectedKey);
|
|
90
|
+
|
|
91
|
+
const handleSelect = useCallback(
|
|
92
|
+
(key: string) => {
|
|
93
|
+
setInternalKey(key);
|
|
94
|
+
onSelect?.(key);
|
|
95
|
+
},
|
|
96
|
+
[onSelect],
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
const activeKey = selectedKey ?? internalKey;
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<View style={[{ flex: 1, flexDirection: 'row', backgroundColor: theme.colors.background }, style]} {...rest}>
|
|
103
|
+
{showSidebar ? (
|
|
104
|
+
<View
|
|
105
|
+
style={{
|
|
106
|
+
width: 240,
|
|
107
|
+
borderRightWidth: 1,
|
|
108
|
+
borderRightColor: theme.colors.border,
|
|
109
|
+
backgroundColor: theme.colors.surfaceElevated,
|
|
110
|
+
padding: 16,
|
|
111
|
+
gap: 16,
|
|
112
|
+
}}
|
|
113
|
+
>
|
|
114
|
+
{brand ? <View>{brand}</View> : null}
|
|
115
|
+
<ScrollView style={{ flex: 1 }}>{renderMenuItems(menu, activeKey, handleSelect, theme)}</ScrollView>
|
|
116
|
+
{footer ? <View>{footer}</View> : null}
|
|
117
|
+
</View>
|
|
118
|
+
) : null}
|
|
119
|
+
<View style={{ flex: 1 }}>
|
|
120
|
+
{(header || user) && (
|
|
121
|
+
<View
|
|
122
|
+
style={{
|
|
123
|
+
flexDirection: 'row',
|
|
124
|
+
alignItems: 'center',
|
|
125
|
+
borderBottomWidth: 1,
|
|
126
|
+
borderBottomColor: theme.colors.border,
|
|
127
|
+
backgroundColor: theme.colors.surfaceElevated,
|
|
128
|
+
paddingHorizontal: 16,
|
|
129
|
+
paddingVertical: 12,
|
|
130
|
+
gap: 12,
|
|
131
|
+
}}
|
|
132
|
+
>
|
|
133
|
+
<View style={{ flex: 1 }}>{header}</View>
|
|
134
|
+
{user ? (
|
|
135
|
+
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
|
136
|
+
<Avatar src={user.avatar} name={user.name} size="sm" />
|
|
137
|
+
<View>
|
|
138
|
+
<Text style={{ color: theme.colors.fg, fontSize: 14, fontWeight: '500' }}>{user.name}</Text>
|
|
139
|
+
{user.caption ? (
|
|
140
|
+
<Text style={{ color: theme.colors.fgMuted, fontSize: 12 }}>{user.caption}</Text>
|
|
141
|
+
) : null}
|
|
142
|
+
</View>
|
|
143
|
+
</View>
|
|
144
|
+
) : null}
|
|
145
|
+
</View>
|
|
146
|
+
)}
|
|
147
|
+
<ScrollView contentContainerStyle={{ padding: 16, gap: 12 }}>{children}</ScrollView>
|
|
148
|
+
</View>
|
|
149
|
+
</View>
|
|
150
|
+
);
|
|
151
|
+
}
|
package/src/ProTable.tsx
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { useMemo, useState, type ReactNode } from 'react';
|
|
2
|
+
import { View, Text, type ViewProps } from 'react-native';
|
|
3
|
+
import { useTheme } from './ElvoraProvider';
|
|
4
|
+
import { Table, type TableColumn } from './Table';
|
|
5
|
+
import { Input } from './Input';
|
|
6
|
+
|
|
7
|
+
export interface ProTableToolbar {
|
|
8
|
+
title?: ReactNode;
|
|
9
|
+
actions?: ReactNode;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ProTableColumn<Row = Record<string, unknown>> extends TableColumn<Row> {
|
|
13
|
+
searchable?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ProTableProps<Row = Record<string, unknown>> extends Omit<ViewProps, 'children'> {
|
|
17
|
+
columns: ProTableColumn<Row>[];
|
|
18
|
+
dataSource: Row[];
|
|
19
|
+
rowKey?: keyof Row | ((row: Row, index: number) => string | number);
|
|
20
|
+
toolbar?: ProTableToolbar;
|
|
21
|
+
search?: boolean;
|
|
22
|
+
searchPlaceholder?: string;
|
|
23
|
+
empty?: ReactNode;
|
|
24
|
+
onRowPress?: (row: Row, index: number) => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function ProTable<Row extends Record<string, unknown>>(props: ProTableProps<Row>) {
|
|
28
|
+
const {
|
|
29
|
+
columns,
|
|
30
|
+
dataSource,
|
|
31
|
+
rowKey,
|
|
32
|
+
toolbar,
|
|
33
|
+
search = true,
|
|
34
|
+
searchPlaceholder = 'Search…',
|
|
35
|
+
empty,
|
|
36
|
+
onRowPress,
|
|
37
|
+
style,
|
|
38
|
+
...rest
|
|
39
|
+
} = props;
|
|
40
|
+
const theme = useTheme();
|
|
41
|
+
const [query, setQuery] = useState('');
|
|
42
|
+
|
|
43
|
+
const searchableKeys = useMemo(
|
|
44
|
+
() =>
|
|
45
|
+
columns
|
|
46
|
+
.filter((c) => c.searchable !== false && c.dataIndex !== undefined)
|
|
47
|
+
.map((c) => String(c.dataIndex)),
|
|
48
|
+
[columns],
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const filtered = useMemo(() => {
|
|
52
|
+
if (!query.trim()) return dataSource;
|
|
53
|
+
const lower = query.toLowerCase();
|
|
54
|
+
return dataSource.filter((row) =>
|
|
55
|
+
searchableKeys.some((key) =>
|
|
56
|
+
String((row as Record<string, unknown>)[key] ?? '').toLowerCase().includes(lower),
|
|
57
|
+
),
|
|
58
|
+
);
|
|
59
|
+
}, [dataSource, query, searchableKeys]);
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<View style={[{ gap: 12 }, style]} {...rest}>
|
|
63
|
+
{(toolbar?.title || toolbar?.actions || search) && (
|
|
64
|
+
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 12 }}>
|
|
65
|
+
{toolbar?.title && (
|
|
66
|
+
<Text style={{ color: theme.colors.fg, fontWeight: '600', fontSize: 16, flex: 1 }}>
|
|
67
|
+
{toolbar.title}
|
|
68
|
+
</Text>
|
|
69
|
+
)}
|
|
70
|
+
{search && (
|
|
71
|
+
<Input
|
|
72
|
+
value={query}
|
|
73
|
+
onChangeText={setQuery}
|
|
74
|
+
placeholder={searchPlaceholder}
|
|
75
|
+
style={{ flex: 1, maxWidth: 240 }}
|
|
76
|
+
/>
|
|
77
|
+
)}
|
|
78
|
+
{toolbar?.actions}
|
|
79
|
+
</View>
|
|
80
|
+
)}
|
|
81
|
+
<Table<Row>
|
|
82
|
+
columns={columns}
|
|
83
|
+
dataSource={filtered}
|
|
84
|
+
rowKey={rowKey}
|
|
85
|
+
bordered
|
|
86
|
+
emptyState={empty ?? 'No data'}
|
|
87
|
+
onRowPress={onRowPress}
|
|
88
|
+
/>
|
|
89
|
+
</View>
|
|
90
|
+
);
|
|
91
|
+
}
|
package/src/Progress.tsx
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { forwardRef, useEffect, useRef } from 'react';
|
|
2
|
+
import { Animated, Easing, View, type StyleProp, type ViewStyle } from 'react-native';
|
|
3
|
+
import type { ElvoraSize, ElvoraStatus } from '@elvora/core';
|
|
4
|
+
import { useTheme } from './ElvoraProvider';
|
|
5
|
+
|
|
6
|
+
export interface ProgressProps {
|
|
7
|
+
value?: number;
|
|
8
|
+
max?: number;
|
|
9
|
+
min?: number;
|
|
10
|
+
size?: Exclude<ElvoraSize, 'xl'>;
|
|
11
|
+
status?: ElvoraStatus;
|
|
12
|
+
label?: string;
|
|
13
|
+
style?: StyleProp<ViewStyle>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const heights = { xs: 4, sm: 6, md: 8, lg: 12 };
|
|
17
|
+
const statusIntent = {
|
|
18
|
+
neutral: 'primary',
|
|
19
|
+
info: 'info',
|
|
20
|
+
success: 'success',
|
|
21
|
+
warning: 'warning',
|
|
22
|
+
error: 'danger',
|
|
23
|
+
} as const;
|
|
24
|
+
|
|
25
|
+
export const Progress = forwardRef<View, ProgressProps>(function Progress(props, ref) {
|
|
26
|
+
const { value, max = 100, min = 0, size = 'md', status = 'neutral', label, style } = props;
|
|
27
|
+
const theme = useTheme();
|
|
28
|
+
const intent = theme.colors.intent[statusIntent[status]];
|
|
29
|
+
const indeterminate = value === undefined;
|
|
30
|
+
const clamped = indeterminate ? 0 : Math.max(min, Math.min(max, value));
|
|
31
|
+
const pct = indeterminate ? 0.3 : (clamped - min) / (max - min);
|
|
32
|
+
const height = heights[size];
|
|
33
|
+
|
|
34
|
+
const anim = useRef(new Animated.Value(0)).current;
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (!indeterminate) return;
|
|
37
|
+
const loop = Animated.loop(
|
|
38
|
+
Animated.timing(anim, {
|
|
39
|
+
toValue: 1,
|
|
40
|
+
duration: 1500,
|
|
41
|
+
easing: Easing.inOut(Easing.ease),
|
|
42
|
+
useNativeDriver: true,
|
|
43
|
+
}),
|
|
44
|
+
);
|
|
45
|
+
loop.start();
|
|
46
|
+
return () => loop.stop();
|
|
47
|
+
}, [anim, indeterminate]);
|
|
48
|
+
|
|
49
|
+
const translateX = anim.interpolate({ inputRange: [0, 1], outputRange: [-100, 333] });
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<View
|
|
53
|
+
ref={ref}
|
|
54
|
+
accessibilityRole="progressbar"
|
|
55
|
+
accessibilityLabel={label}
|
|
56
|
+
accessibilityValue={indeterminate ? undefined : { min, max, now: clamped }}
|
|
57
|
+
style={[
|
|
58
|
+
{
|
|
59
|
+
height,
|
|
60
|
+
width: '100%',
|
|
61
|
+
backgroundColor: theme.colors.intent.neutral.subtle,
|
|
62
|
+
borderRadius: 9999,
|
|
63
|
+
overflow: 'hidden',
|
|
64
|
+
},
|
|
65
|
+
style,
|
|
66
|
+
]}
|
|
67
|
+
>
|
|
68
|
+
{indeterminate ? (
|
|
69
|
+
<Animated.View
|
|
70
|
+
style={{
|
|
71
|
+
position: 'absolute',
|
|
72
|
+
top: 0,
|
|
73
|
+
bottom: 0,
|
|
74
|
+
width: `${pct * 100}%`,
|
|
75
|
+
backgroundColor: intent.solid,
|
|
76
|
+
borderRadius: 9999,
|
|
77
|
+
transform: [{ translateX }],
|
|
78
|
+
}}
|
|
79
|
+
/>
|
|
80
|
+
) : (
|
|
81
|
+
<View
|
|
82
|
+
style={{
|
|
83
|
+
width: `${pct * 100}%`,
|
|
84
|
+
height: '100%',
|
|
85
|
+
backgroundColor: intent.solid,
|
|
86
|
+
borderRadius: 9999,
|
|
87
|
+
}}
|
|
88
|
+
/>
|
|
89
|
+
)}
|
|
90
|
+
</View>
|
|
91
|
+
);
|
|
92
|
+
});
|
package/src/QRCode.tsx
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { useMemo, type ReactNode } from 'react';
|
|
2
|
+
import { View, type ViewStyle } from 'react-native';
|
|
3
|
+
import { generateQrMatrix, type ErrorCorrection } from '@elvora/core';
|
|
4
|
+
|
|
5
|
+
export interface QRCodeProps {
|
|
6
|
+
value: string;
|
|
7
|
+
size?: number;
|
|
8
|
+
level?: ErrorCorrection;
|
|
9
|
+
foreground?: string;
|
|
10
|
+
background?: string;
|
|
11
|
+
/** Optional centered node (e.g. logo). Should be roughly 18% of `size`. */
|
|
12
|
+
icon?: ReactNode;
|
|
13
|
+
style?: ViewStyle;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* QRCode — renders a QR code as a stacked grid of `View` cells. Pure RN, no
|
|
18
|
+
* native deps. For very large codes consider rendering on the JS side and
|
|
19
|
+
* passing through `react-native-svg` for sharper printing.
|
|
20
|
+
*/
|
|
21
|
+
export function QRCode(props: QRCodeProps) {
|
|
22
|
+
const { value, size = 160, level = 'M', foreground = '#000', background = '#fff', icon, style } = props;
|
|
23
|
+
const matrix = useMemo(() => generateQrMatrix(value, level), [value, level]);
|
|
24
|
+
const cell = size / matrix.size;
|
|
25
|
+
return (
|
|
26
|
+
<View
|
|
27
|
+
style={[
|
|
28
|
+
{ width: size, height: size, backgroundColor: background, position: 'relative' },
|
|
29
|
+
style,
|
|
30
|
+
]}
|
|
31
|
+
>
|
|
32
|
+
{matrix.modules.map((row, ri) => (
|
|
33
|
+
<View key={ri} style={{ flexDirection: 'row', height: cell }}>
|
|
34
|
+
{row.map((isOn, ci) => (
|
|
35
|
+
<View
|
|
36
|
+
key={ci}
|
|
37
|
+
style={{
|
|
38
|
+
width: cell,
|
|
39
|
+
height: cell,
|
|
40
|
+
backgroundColor: isOn ? foreground : background,
|
|
41
|
+
}}
|
|
42
|
+
/>
|
|
43
|
+
))}
|
|
44
|
+
</View>
|
|
45
|
+
))}
|
|
46
|
+
{icon ? (
|
|
47
|
+
<View
|
|
48
|
+
style={{
|
|
49
|
+
position: 'absolute',
|
|
50
|
+
top: size / 2 - size * 0.12,
|
|
51
|
+
left: size / 2 - size * 0.12,
|
|
52
|
+
width: size * 0.24,
|
|
53
|
+
height: size * 0.24,
|
|
54
|
+
alignItems: 'center',
|
|
55
|
+
justifyContent: 'center',
|
|
56
|
+
backgroundColor: background,
|
|
57
|
+
borderRadius: 4,
|
|
58
|
+
}}
|
|
59
|
+
>
|
|
60
|
+
{icon}
|
|
61
|
+
</View>
|
|
62
|
+
) : null}
|
|
63
|
+
</View>
|
|
64
|
+
);
|
|
65
|
+
}
|
package/src/Radio.tsx
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { createContext, forwardRef, useContext, type ReactNode } from 'react';
|
|
2
|
+
import { Pressable, Text, View, type PressableProps, type StyleProp, type ViewStyle } from 'react-native';
|
|
3
|
+
import { useControllableState } from '@elvora/core/react';
|
|
4
|
+
import { useTheme } from './ElvoraProvider';
|
|
5
|
+
|
|
6
|
+
interface RadioGroupContextValue {
|
|
7
|
+
name: string;
|
|
8
|
+
value: string | undefined;
|
|
9
|
+
setValue: (v: string) => void;
|
|
10
|
+
isDisabled: boolean;
|
|
11
|
+
size: 'sm' | 'md' | 'lg';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const RadioGroupContext = createContext<RadioGroupContextValue | null>(null);
|
|
15
|
+
|
|
16
|
+
export interface RadioGroupProps {
|
|
17
|
+
name: string;
|
|
18
|
+
value?: string;
|
|
19
|
+
defaultValue?: string;
|
|
20
|
+
onChange?: (value: string) => void;
|
|
21
|
+
isDisabled?: boolean;
|
|
22
|
+
size?: 'sm' | 'md' | 'lg';
|
|
23
|
+
label?: string;
|
|
24
|
+
children?: ReactNode;
|
|
25
|
+
style?: StyleProp<ViewStyle>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function RadioGroup(props: RadioGroupProps) {
|
|
29
|
+
const { name, value, defaultValue, onChange, isDisabled = false, size = 'md', label, children, style } = props;
|
|
30
|
+
const [v, setV] = useControllableState({ value, defaultValue, onChange });
|
|
31
|
+
return (
|
|
32
|
+
<RadioGroupContext.Provider value={{ name, value: v ?? undefined, setValue: setV, isDisabled, size }}>
|
|
33
|
+
<View accessibilityRole="radiogroup" accessibilityLabel={label} style={[{ gap: 8 }, style]}>
|
|
34
|
+
{children}
|
|
35
|
+
</View>
|
|
36
|
+
</RadioGroupContext.Provider>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface RadioProps extends Omit<PressableProps, 'onPress' | 'children'> {
|
|
41
|
+
value: string;
|
|
42
|
+
isDisabled?: boolean;
|
|
43
|
+
children?: ReactNode;
|
|
44
|
+
style?: StyleProp<ViewStyle>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const sizeMap = {
|
|
48
|
+
sm: { outer: 16, inner: 7, font: 13 },
|
|
49
|
+
md: { outer: 20, inner: 9, font: 14 },
|
|
50
|
+
lg: { outer: 24, inner: 11, font: 16 },
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const Radio = forwardRef<View, RadioProps>(function Radio(props, ref) {
|
|
54
|
+
const { value, isDisabled, children, style, ...rest } = props;
|
|
55
|
+
const ctx = useContext(RadioGroupContext);
|
|
56
|
+
const theme = useTheme();
|
|
57
|
+
if (!ctx) throw new Error('Radio must be rendered inside a RadioGroup');
|
|
58
|
+
const dims = sizeMap[ctx.size];
|
|
59
|
+
const isChecked = ctx.value === value;
|
|
60
|
+
const disabled = Boolean(ctx.isDisabled || isDisabled);
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<Pressable
|
|
64
|
+
ref={ref}
|
|
65
|
+
accessibilityRole="radio"
|
|
66
|
+
accessibilityState={{ selected: isChecked, disabled }}
|
|
67
|
+
disabled={disabled}
|
|
68
|
+
onPress={() => ctx.setValue(value)}
|
|
69
|
+
style={[{ flexDirection: 'row', alignItems: 'center', gap: 8, opacity: disabled ? 0.5 : 1 }, style]}
|
|
70
|
+
{...rest}
|
|
71
|
+
>
|
|
72
|
+
<View
|
|
73
|
+
style={{
|
|
74
|
+
width: dims.outer,
|
|
75
|
+
height: dims.outer,
|
|
76
|
+
borderWidth: 1.5,
|
|
77
|
+
borderColor: isChecked ? theme.colors.intent.primary.solid : theme.colors.borderStrong,
|
|
78
|
+
borderRadius: dims.outer / 2,
|
|
79
|
+
backgroundColor: theme.colors.surfaceElevated,
|
|
80
|
+
alignItems: 'center',
|
|
81
|
+
justifyContent: 'center',
|
|
82
|
+
}}
|
|
83
|
+
>
|
|
84
|
+
{isChecked ? (
|
|
85
|
+
<View
|
|
86
|
+
style={{
|
|
87
|
+
width: dims.inner,
|
|
88
|
+
height: dims.inner,
|
|
89
|
+
borderRadius: dims.inner / 2,
|
|
90
|
+
backgroundColor: theme.colors.intent.primary.solid,
|
|
91
|
+
}}
|
|
92
|
+
/>
|
|
93
|
+
) : null}
|
|
94
|
+
</View>
|
|
95
|
+
{children ? <Text style={{ color: theme.colors.fg, fontSize: dims.font }}>{children}</Text> : null}
|
|
96
|
+
</Pressable>
|
|
97
|
+
);
|
|
98
|
+
});
|
package/src/Rate.tsx
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
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 RateProps extends Omit<ViewProps, 'onChange'> {
|
|
6
|
+
value?: number;
|
|
7
|
+
defaultValue?: number;
|
|
8
|
+
onChange?: (value: number) => void;
|
|
9
|
+
count?: number;
|
|
10
|
+
allowHalf?: boolean;
|
|
11
|
+
isDisabled?: boolean;
|
|
12
|
+
/** Custom character (string emoji or React node). Defaults to ★ */
|
|
13
|
+
character?: ReactNode;
|
|
14
|
+
size?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function Rate(props: RateProps) {
|
|
18
|
+
const {
|
|
19
|
+
value: valueProp,
|
|
20
|
+
defaultValue = 0,
|
|
21
|
+
onChange,
|
|
22
|
+
count = 5,
|
|
23
|
+
allowHalf,
|
|
24
|
+
isDisabled,
|
|
25
|
+
character = '★',
|
|
26
|
+
size = 24,
|
|
27
|
+
style,
|
|
28
|
+
...rest
|
|
29
|
+
} = props;
|
|
30
|
+
const theme = useTheme();
|
|
31
|
+
const [internal, setInternal] = useState<number>(defaultValue);
|
|
32
|
+
const value = valueProp ?? internal;
|
|
33
|
+
|
|
34
|
+
const setValue = (next: number) => {
|
|
35
|
+
if (isDisabled) return;
|
|
36
|
+
const clamped = Math.min(Math.max(next, 0), count);
|
|
37
|
+
if (valueProp === undefined) setInternal(clamped);
|
|
38
|
+
onChange?.(clamped);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<View
|
|
43
|
+
accessibilityRole="adjustable"
|
|
44
|
+
accessibilityValue={{ min: 0, max: count, now: value }}
|
|
45
|
+
style={[{ flexDirection: 'row', opacity: isDisabled ? 0.6 : 1 }, style]}
|
|
46
|
+
{...rest}
|
|
47
|
+
>
|
|
48
|
+
{Array.from({ length: count }).map((_, i) => {
|
|
49
|
+
const filled = value >= i + 1;
|
|
50
|
+
const half = !filled && allowHalf && value >= i + 0.5;
|
|
51
|
+
const color = filled || half ? theme.colors.intent.warning.solid : theme.colors.border;
|
|
52
|
+
return (
|
|
53
|
+
<Pressable
|
|
54
|
+
key={i}
|
|
55
|
+
disabled={isDisabled}
|
|
56
|
+
onPress={() => setValue(i + 1)}
|
|
57
|
+
onLongPress={allowHalf ? () => setValue(i + 0.5) : undefined}
|
|
58
|
+
style={{ marginHorizontal: 2 }}
|
|
59
|
+
>
|
|
60
|
+
<Text style={{ color, fontSize: size }}>{character}</Text>
|
|
61
|
+
</Pressable>
|
|
62
|
+
);
|
|
63
|
+
})}
|
|
64
|
+
</View>
|
|
65
|
+
);
|
|
66
|
+
}
|
package/src/Result.tsx
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { forwardRef, type ReactNode } from 'react';
|
|
2
|
+
import { View, Text, type ViewProps } from 'react-native';
|
|
3
|
+
import type { ElvoraStatus } from '@elvora/core';
|
|
4
|
+
import { useTheme } from './ElvoraProvider';
|
|
5
|
+
import { Icon } from './Icon';
|
|
6
|
+
import type { IconName } from '@elvora/icons';
|
|
7
|
+
|
|
8
|
+
export type ResultStatus = ElvoraStatus | '404' | '500' | '403';
|
|
9
|
+
|
|
10
|
+
export interface ResultProps extends ViewProps {
|
|
11
|
+
status?: ResultStatus;
|
|
12
|
+
title?: ReactNode;
|
|
13
|
+
subtitle?: ReactNode;
|
|
14
|
+
icon?: ReactNode;
|
|
15
|
+
extra?: ReactNode;
|
|
16
|
+
children?: ReactNode;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const statusIntent = {
|
|
20
|
+
neutral: 'neutral',
|
|
21
|
+
info: 'info',
|
|
22
|
+
success: 'success',
|
|
23
|
+
warning: 'warning',
|
|
24
|
+
error: 'danger',
|
|
25
|
+
'404': 'neutral',
|
|
26
|
+
'500': 'danger',
|
|
27
|
+
'403': 'warning',
|
|
28
|
+
} as const;
|
|
29
|
+
|
|
30
|
+
const statusIcon: Record<ResultStatus, IconName> = {
|
|
31
|
+
neutral: 'info',
|
|
32
|
+
info: 'info',
|
|
33
|
+
success: 'checkCircle',
|
|
34
|
+
warning: 'alertCircle',
|
|
35
|
+
error: 'x',
|
|
36
|
+
'404': 'info',
|
|
37
|
+
'500': 'alertCircle',
|
|
38
|
+
'403': 'x',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** Result — page-level status component for RN. */
|
|
42
|
+
export const Result = forwardRef<View, ResultProps>(function Result(props, ref) {
|
|
43
|
+
const { status = 'info', title, subtitle, icon, extra, children, style, ...rest } = props;
|
|
44
|
+
const theme = useTheme();
|
|
45
|
+
const intent = theme.colors.intent[statusIntent[status]];
|
|
46
|
+
return (
|
|
47
|
+
<View
|
|
48
|
+
ref={ref}
|
|
49
|
+
style={[
|
|
50
|
+
{ alignItems: 'center', justifyContent: 'center', paddingVertical: 40, paddingHorizontal: 16, gap: 12 },
|
|
51
|
+
style,
|
|
52
|
+
]}
|
|
53
|
+
{...rest}
|
|
54
|
+
>
|
|
55
|
+
<View>{icon ?? <Icon name={statusIcon[status]} size={56} color={intent.solid} />}</View>
|
|
56
|
+
{title ? <Text style={{ color: theme.colors.fg, fontSize: 20, fontWeight: '700' }}>{title}</Text> : null}
|
|
57
|
+
{subtitle ? (
|
|
58
|
+
<Text style={{ color: theme.colors.fgMuted, fontSize: 14, textAlign: 'center' }}>{subtitle}</Text>
|
|
59
|
+
) : null}
|
|
60
|
+
{children}
|
|
61
|
+
{extra ? <View style={{ flexDirection: 'row', gap: 8, marginTop: 8 }}>{extra}</View> : null}
|
|
62
|
+
</View>
|
|
63
|
+
);
|
|
64
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
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 SegmentedOption<V extends string = string> {
|
|
6
|
+
value: V;
|
|
7
|
+
label: ReactNode;
|
|
8
|
+
isDisabled?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface SegmentedProps<V extends string = string> extends ViewProps {
|
|
12
|
+
options: SegmentedOption<V>[];
|
|
13
|
+
value?: V;
|
|
14
|
+
defaultValue?: V;
|
|
15
|
+
onChange?: (value: V) => void;
|
|
16
|
+
size?: 'sm' | 'md' | 'lg';
|
|
17
|
+
isDisabled?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function Segmented<V extends string = string>(props: SegmentedProps<V>) {
|
|
21
|
+
const { options, value: valueProp, defaultValue, onChange, size = 'md', isDisabled, style, ...rest } = props;
|
|
22
|
+
const theme = useTheme();
|
|
23
|
+
const [internal, setInternal] = useState<V>((defaultValue ?? options[0]?.value) as V);
|
|
24
|
+
const current = valueProp ?? internal;
|
|
25
|
+
const padY = size === 'sm' ? 4 : size === 'lg' ? 10 : 6;
|
|
26
|
+
const padX = size === 'sm' ? 10 : size === 'lg' ? 16 : 12;
|
|
27
|
+
const fontSize = size === 'sm' ? 12 : size === 'lg' ? 15 : 14;
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<View
|
|
31
|
+
accessibilityRole="radiogroup"
|
|
32
|
+
style={[
|
|
33
|
+
{
|
|
34
|
+
flexDirection: 'row',
|
|
35
|
+
backgroundColor: theme.colors.intent.neutral.subtle,
|
|
36
|
+
borderRadius: Number(theme.radii.md),
|
|
37
|
+
padding: 4,
|
|
38
|
+
alignSelf: 'flex-start',
|
|
39
|
+
opacity: isDisabled ? 0.6 : 1,
|
|
40
|
+
},
|
|
41
|
+
style,
|
|
42
|
+
]}
|
|
43
|
+
{...rest}
|
|
44
|
+
>
|
|
45
|
+
{options.map((opt) => {
|
|
46
|
+
const active = opt.value === current;
|
|
47
|
+
const disabled = isDisabled || opt.isDisabled;
|
|
48
|
+
return (
|
|
49
|
+
<Pressable
|
|
50
|
+
key={opt.value}
|
|
51
|
+
accessibilityRole="radio"
|
|
52
|
+
accessibilityState={{ selected: active, disabled }}
|
|
53
|
+
disabled={disabled}
|
|
54
|
+
onPress={() => {
|
|
55
|
+
if (disabled) return;
|
|
56
|
+
if (valueProp === undefined) setInternal(opt.value);
|
|
57
|
+
onChange?.(opt.value);
|
|
58
|
+
}}
|
|
59
|
+
style={{
|
|
60
|
+
paddingHorizontal: padX,
|
|
61
|
+
paddingVertical: padY,
|
|
62
|
+
borderRadius: Number(theme.radii.sm),
|
|
63
|
+
backgroundColor: active ? theme.colors.surfaceElevated : 'transparent',
|
|
64
|
+
marginHorizontal: 2,
|
|
65
|
+
}}
|
|
66
|
+
>
|
|
67
|
+
<Text style={{ color: active ? theme.colors.fg : theme.colors.fgMuted, fontSize, fontWeight: '500' }}>
|
|
68
|
+
{opt.label}
|
|
69
|
+
</Text>
|
|
70
|
+
</Pressable>
|
|
71
|
+
);
|
|
72
|
+
})}
|
|
73
|
+
</View>
|
|
74
|
+
);
|
|
75
|
+
}
|