@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/Select.tsx
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { forwardRef, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
FlatList,
|
|
4
|
+
Modal,
|
|
5
|
+
Pressable,
|
|
6
|
+
Text,
|
|
7
|
+
View,
|
|
8
|
+
type ListRenderItem,
|
|
9
|
+
type StyleProp,
|
|
10
|
+
type ViewStyle,
|
|
11
|
+
} from 'react-native';
|
|
12
|
+
import { defaultInputProps, type InputOwnProps } from '@elvora/core';
|
|
13
|
+
import { useControllableState } from '@elvora/core/react';
|
|
14
|
+
import { useTheme } from './ElvoraProvider';
|
|
15
|
+
import { Icon } from './Icon';
|
|
16
|
+
|
|
17
|
+
export interface SelectOption {
|
|
18
|
+
label: string;
|
|
19
|
+
value: string;
|
|
20
|
+
disabled?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SelectProps extends InputOwnProps {
|
|
24
|
+
options: SelectOption[];
|
|
25
|
+
value?: string;
|
|
26
|
+
defaultValue?: string;
|
|
27
|
+
onChange?: (value: string) => void;
|
|
28
|
+
placeholder?: string;
|
|
29
|
+
style?: StyleProp<ViewStyle>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const sizeMap = {
|
|
33
|
+
xs: { padX: 8, padY: 6, font: 12, height: 28 },
|
|
34
|
+
sm: { padX: 10, padY: 8, font: 13, height: 36 },
|
|
35
|
+
md: { padX: 12, padY: 10, font: 14, height: 44 },
|
|
36
|
+
lg: { padX: 14, padY: 12, font: 16, height: 52 },
|
|
37
|
+
xl: { padX: 16, padY: 14, font: 18, height: 60 },
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* React Native Select. Opens a modal sheet with the option list — the most
|
|
42
|
+
* platform-appropriate variant for cross-platform consistency.
|
|
43
|
+
*/
|
|
44
|
+
export const Select = forwardRef<View, SelectProps>(function Select(props, ref) {
|
|
45
|
+
const {
|
|
46
|
+
options,
|
|
47
|
+
value,
|
|
48
|
+
defaultValue,
|
|
49
|
+
onChange,
|
|
50
|
+
placeholder = 'Select…',
|
|
51
|
+
size = defaultInputProps.size,
|
|
52
|
+
status = defaultInputProps.status,
|
|
53
|
+
isDisabled = defaultInputProps.isDisabled,
|
|
54
|
+
isInvalid = defaultInputProps.isInvalid,
|
|
55
|
+
style,
|
|
56
|
+
} = props;
|
|
57
|
+
const theme = useTheme();
|
|
58
|
+
const [selected, setSelected] = useControllableState({ value, defaultValue, onChange });
|
|
59
|
+
const [open, setOpen] = useState(false);
|
|
60
|
+
const dims = sizeMap[size];
|
|
61
|
+
|
|
62
|
+
const invalid = isInvalid || status === 'error';
|
|
63
|
+
const current = options.find((o) => o.value === selected);
|
|
64
|
+
|
|
65
|
+
const renderItem: ListRenderItem<SelectOption> = ({ item }) => {
|
|
66
|
+
const isActive = item.value === selected;
|
|
67
|
+
return (
|
|
68
|
+
<Pressable
|
|
69
|
+
accessibilityRole="menuitem"
|
|
70
|
+
accessibilityState={{ selected: isActive, disabled: item.disabled }}
|
|
71
|
+
disabled={item.disabled}
|
|
72
|
+
onPress={() => {
|
|
73
|
+
setSelected(item.value);
|
|
74
|
+
setOpen(false);
|
|
75
|
+
}}
|
|
76
|
+
style={({ pressed }) => ({
|
|
77
|
+
paddingHorizontal: 16,
|
|
78
|
+
paddingVertical: 12,
|
|
79
|
+
backgroundColor: pressed ? theme.colors.intent.neutral.subtle : 'transparent',
|
|
80
|
+
opacity: item.disabled ? 0.5 : 1,
|
|
81
|
+
flexDirection: 'row',
|
|
82
|
+
alignItems: 'center',
|
|
83
|
+
justifyContent: 'space-between',
|
|
84
|
+
})}
|
|
85
|
+
>
|
|
86
|
+
<Text style={{ color: theme.colors.fg, fontSize: 14 }}>{item.label}</Text>
|
|
87
|
+
{isActive ? <Icon name="check" size={16} color={theme.colors.intent.primary.solid} /> : null}
|
|
88
|
+
</Pressable>
|
|
89
|
+
);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<View ref={ref} style={style}>
|
|
94
|
+
<Pressable
|
|
95
|
+
accessibilityRole="combobox"
|
|
96
|
+
accessibilityState={{ disabled: isDisabled, expanded: open }}
|
|
97
|
+
accessibilityValue={{ text: current?.label ?? placeholder }}
|
|
98
|
+
disabled={isDisabled}
|
|
99
|
+
onPress={() => setOpen(true)}
|
|
100
|
+
style={{
|
|
101
|
+
minHeight: dims.height,
|
|
102
|
+
paddingHorizontal: dims.padX,
|
|
103
|
+
paddingVertical: dims.padY,
|
|
104
|
+
borderRadius: Number(theme.radii.md),
|
|
105
|
+
borderWidth: 1,
|
|
106
|
+
borderColor: invalid ? theme.colors.intent.danger.border : theme.colors.border,
|
|
107
|
+
backgroundColor: theme.colors.surfaceElevated,
|
|
108
|
+
opacity: isDisabled ? 0.6 : 1,
|
|
109
|
+
flexDirection: 'row',
|
|
110
|
+
alignItems: 'center',
|
|
111
|
+
justifyContent: 'space-between',
|
|
112
|
+
}}
|
|
113
|
+
>
|
|
114
|
+
<Text style={{ color: current ? theme.colors.fg : theme.colors.fgMuted, fontSize: dims.font }}>
|
|
115
|
+
{current?.label ?? placeholder}
|
|
116
|
+
</Text>
|
|
117
|
+
<Icon name="chevronDown" size={16} color={theme.colors.fgMuted} />
|
|
118
|
+
</Pressable>
|
|
119
|
+
|
|
120
|
+
<Modal visible={open} transparent animationType="fade" onRequestClose={() => setOpen(false)}>
|
|
121
|
+
<Pressable
|
|
122
|
+
onPress={() => setOpen(false)}
|
|
123
|
+
style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.4)', justifyContent: 'flex-end' }}
|
|
124
|
+
>
|
|
125
|
+
<Pressable
|
|
126
|
+
onPress={(e) => e.stopPropagation()}
|
|
127
|
+
style={{
|
|
128
|
+
backgroundColor: theme.colors.surfaceElevated,
|
|
129
|
+
borderTopLeftRadius: 16,
|
|
130
|
+
borderTopRightRadius: 16,
|
|
131
|
+
maxHeight: '60%',
|
|
132
|
+
paddingVertical: 8,
|
|
133
|
+
}}
|
|
134
|
+
>
|
|
135
|
+
<FlatList
|
|
136
|
+
data={options}
|
|
137
|
+
keyExtractor={(item) => item.value}
|
|
138
|
+
renderItem={renderItem}
|
|
139
|
+
ItemSeparatorComponent={() => <View style={{ height: 1, backgroundColor: theme.colors.border }} />}
|
|
140
|
+
/>
|
|
141
|
+
</Pressable>
|
|
142
|
+
</Pressable>
|
|
143
|
+
</Modal>
|
|
144
|
+
</View>
|
|
145
|
+
);
|
|
146
|
+
});
|
package/src/Skeleton.tsx
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { forwardRef, useEffect, useRef } from 'react';
|
|
2
|
+
import { Animated, View, type ViewProps, type ViewStyle } from 'react-native';
|
|
3
|
+
import { useTheme } from './ElvoraProvider';
|
|
4
|
+
|
|
5
|
+
export interface SkeletonProps extends ViewProps {
|
|
6
|
+
width?: number | string;
|
|
7
|
+
height?: number | string;
|
|
8
|
+
rounded?: number;
|
|
9
|
+
/** Animate a pulse effect. Default true. */
|
|
10
|
+
animated?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Skeleton — placeholder loading block for RN. */
|
|
14
|
+
export const Skeleton = forwardRef<View, SkeletonProps>(function Skeleton(props, ref) {
|
|
15
|
+
const { width = '100%', height = 16, rounded, animated = true, style, ...rest } = props;
|
|
16
|
+
const theme = useTheme();
|
|
17
|
+
const opacity = useRef(new Animated.Value(0.6)).current;
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (!animated) return;
|
|
21
|
+
const loop = Animated.loop(
|
|
22
|
+
Animated.sequence([
|
|
23
|
+
Animated.timing(opacity, { toValue: 1, duration: 700, useNativeDriver: true }),
|
|
24
|
+
Animated.timing(opacity, { toValue: 0.6, duration: 700, useNativeDriver: true }),
|
|
25
|
+
]),
|
|
26
|
+
);
|
|
27
|
+
loop.start();
|
|
28
|
+
return () => loop.stop();
|
|
29
|
+
}, [animated, opacity]);
|
|
30
|
+
|
|
31
|
+
const base: ViewStyle = {
|
|
32
|
+
width: width as ViewStyle['width'],
|
|
33
|
+
height: height as ViewStyle['height'],
|
|
34
|
+
backgroundColor: theme.colors.intent.neutral.subtle,
|
|
35
|
+
borderRadius: rounded ?? theme.radii.sm,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
if (!animated) {
|
|
39
|
+
return <View ref={ref} accessibilityLabel="Loading" style={[base, style]} {...rest} />;
|
|
40
|
+
}
|
|
41
|
+
return (
|
|
42
|
+
<Animated.View
|
|
43
|
+
ref={ref as React.Ref<typeof Animated.View> & React.Ref<View>}
|
|
44
|
+
accessibilityLabel="Loading"
|
|
45
|
+
style={[base, { opacity }, style]}
|
|
46
|
+
{...rest}
|
|
47
|
+
/>
|
|
48
|
+
);
|
|
49
|
+
});
|
package/src/Slider.tsx
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { useRef, useState } from 'react';
|
|
2
|
+
import { PanResponder, View, type LayoutChangeEvent, type ViewProps } from 'react-native';
|
|
3
|
+
import { useTheme } from './ElvoraProvider';
|
|
4
|
+
|
|
5
|
+
export interface SliderProps extends Omit<ViewProps, 'onChange'> {
|
|
6
|
+
value?: number;
|
|
7
|
+
defaultValue?: number;
|
|
8
|
+
onChange?: (value: number) => void;
|
|
9
|
+
onChangeEnd?: (value: number) => void;
|
|
10
|
+
min?: number;
|
|
11
|
+
max?: number;
|
|
12
|
+
step?: number;
|
|
13
|
+
isDisabled?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function snap(value: number, min: number, max: number, step: number): number {
|
|
17
|
+
const clamped = Math.min(Math.max(value, min), max);
|
|
18
|
+
if (step <= 0) return clamped;
|
|
19
|
+
const steps = Math.round((clamped - min) / step);
|
|
20
|
+
return Math.min(Math.max(min + steps * step, min), max);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function Slider(props: SliderProps) {
|
|
24
|
+
const {
|
|
25
|
+
value: valueProp,
|
|
26
|
+
defaultValue = 0,
|
|
27
|
+
onChange,
|
|
28
|
+
onChangeEnd,
|
|
29
|
+
min = 0,
|
|
30
|
+
max = 100,
|
|
31
|
+
step = 1,
|
|
32
|
+
isDisabled,
|
|
33
|
+
style,
|
|
34
|
+
...rest
|
|
35
|
+
} = props;
|
|
36
|
+
const theme = useTheme();
|
|
37
|
+
const [width, setWidth] = useState(1);
|
|
38
|
+
const [internal, setInternal] = useState<number>(defaultValue);
|
|
39
|
+
const value = valueProp ?? internal;
|
|
40
|
+
const trackOriginRef = useRef<number>(0);
|
|
41
|
+
|
|
42
|
+
const commit = (next: number) => {
|
|
43
|
+
const snapped = snap(next, min, max, step);
|
|
44
|
+
if (valueProp === undefined) setInternal(snapped);
|
|
45
|
+
onChange?.(snapped);
|
|
46
|
+
return snapped;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const panResponder = useRef(
|
|
50
|
+
PanResponder.create({
|
|
51
|
+
onStartShouldSetPanResponder: () => !isDisabled,
|
|
52
|
+
onMoveShouldSetPanResponder: () => !isDisabled,
|
|
53
|
+
onPanResponderGrant: (evt) => {
|
|
54
|
+
const x = evt.nativeEvent.locationX;
|
|
55
|
+
commit(min + (x / width) * (max - min));
|
|
56
|
+
},
|
|
57
|
+
onPanResponderMove: (_, gesture) => {
|
|
58
|
+
const x = Math.max(0, Math.min(width, gesture.moveX - trackOriginRef.current));
|
|
59
|
+
commit(min + (x / width) * (max - min));
|
|
60
|
+
},
|
|
61
|
+
onPanResponderRelease: () => {
|
|
62
|
+
onChangeEnd?.(value);
|
|
63
|
+
},
|
|
64
|
+
}),
|
|
65
|
+
).current;
|
|
66
|
+
|
|
67
|
+
const onLayout = (e: LayoutChangeEvent) => {
|
|
68
|
+
setWidth(Math.max(1, e.nativeEvent.layout.width));
|
|
69
|
+
e.target.measure?.((x) => {
|
|
70
|
+
trackOriginRef.current = x;
|
|
71
|
+
});
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const percent = ((value - min) / (max - min)) * 100;
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<View
|
|
78
|
+
accessible
|
|
79
|
+
accessibilityRole="adjustable"
|
|
80
|
+
accessibilityValue={{ min, max, now: value }}
|
|
81
|
+
style={[{ paddingVertical: 12, opacity: isDisabled ? 0.6 : 1 }, style]}
|
|
82
|
+
{...rest}
|
|
83
|
+
>
|
|
84
|
+
<View
|
|
85
|
+
onLayout={onLayout}
|
|
86
|
+
{...panResponder.panHandlers}
|
|
87
|
+
style={{
|
|
88
|
+
height: 4,
|
|
89
|
+
borderRadius: 999,
|
|
90
|
+
backgroundColor: theme.colors.intent.neutral.subtle,
|
|
91
|
+
justifyContent: 'center',
|
|
92
|
+
}}
|
|
93
|
+
>
|
|
94
|
+
<View
|
|
95
|
+
style={{
|
|
96
|
+
position: 'absolute',
|
|
97
|
+
left: 0,
|
|
98
|
+
top: 0,
|
|
99
|
+
bottom: 0,
|
|
100
|
+
width: `${percent}%`,
|
|
101
|
+
backgroundColor: theme.colors.intent.primary.solid,
|
|
102
|
+
borderRadius: 999,
|
|
103
|
+
}}
|
|
104
|
+
/>
|
|
105
|
+
<View
|
|
106
|
+
style={{
|
|
107
|
+
position: 'absolute',
|
|
108
|
+
left: `${percent}%`,
|
|
109
|
+
transform: [{ translateX: -9 }, { translateY: 0 }],
|
|
110
|
+
width: 18,
|
|
111
|
+
height: 18,
|
|
112
|
+
borderRadius: 9,
|
|
113
|
+
backgroundColor: theme.colors.surfaceElevated,
|
|
114
|
+
borderWidth: 2,
|
|
115
|
+
borderColor: theme.colors.intent.primary.solid,
|
|
116
|
+
top: -7,
|
|
117
|
+
}}
|
|
118
|
+
/>
|
|
119
|
+
</View>
|
|
120
|
+
</View>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { useState, type ReactNode } from 'react';
|
|
2
|
+
import { Pressable, Text, View } from 'react-native';
|
|
3
|
+
import { FloatButton } from './FloatButton';
|
|
4
|
+
import { useTheme } from './ElvoraProvider';
|
|
5
|
+
|
|
6
|
+
export interface SpeedDialAction {
|
|
7
|
+
key: string;
|
|
8
|
+
icon: ReactNode;
|
|
9
|
+
label: string;
|
|
10
|
+
onPress?: () => void;
|
|
11
|
+
isDisabled?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface SpeedDialProps {
|
|
15
|
+
icon: ReactNode;
|
|
16
|
+
actions: SpeedDialAction[];
|
|
17
|
+
right?: number;
|
|
18
|
+
bottom?: number;
|
|
19
|
+
open?: boolean;
|
|
20
|
+
defaultOpen?: boolean;
|
|
21
|
+
onOpenChange?: (open: boolean) => void;
|
|
22
|
+
accessibilityLabel?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function SpeedDial(props: SpeedDialProps) {
|
|
26
|
+
const {
|
|
27
|
+
icon,
|
|
28
|
+
actions,
|
|
29
|
+
right = 24,
|
|
30
|
+
bottom = 24,
|
|
31
|
+
open: openProp,
|
|
32
|
+
defaultOpen = false,
|
|
33
|
+
onOpenChange,
|
|
34
|
+
accessibilityLabel,
|
|
35
|
+
} = props;
|
|
36
|
+
const theme = useTheme();
|
|
37
|
+
const [internal, setInternal] = useState(defaultOpen);
|
|
38
|
+
const open = openProp ?? internal;
|
|
39
|
+
const setOpen = (next: boolean) => {
|
|
40
|
+
if (openProp === undefined) setInternal(next);
|
|
41
|
+
onOpenChange?.(next);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<View accessibilityLabel={accessibilityLabel}>
|
|
46
|
+
{open
|
|
47
|
+
? actions.map((a, idx) => (
|
|
48
|
+
<Pressable
|
|
49
|
+
key={a.key}
|
|
50
|
+
accessibilityRole="button"
|
|
51
|
+
accessibilityLabel={a.label}
|
|
52
|
+
disabled={a.isDisabled}
|
|
53
|
+
onPress={() => {
|
|
54
|
+
a.onPress?.();
|
|
55
|
+
setOpen(false);
|
|
56
|
+
}}
|
|
57
|
+
style={({ pressed }) => ({
|
|
58
|
+
position: 'absolute',
|
|
59
|
+
right,
|
|
60
|
+
bottom: bottom + (idx + 1) * 60,
|
|
61
|
+
paddingHorizontal: 14,
|
|
62
|
+
height: 44,
|
|
63
|
+
borderRadius: 22,
|
|
64
|
+
flexDirection: 'row',
|
|
65
|
+
alignItems: 'center',
|
|
66
|
+
gap: 8,
|
|
67
|
+
backgroundColor: theme.colors.surfaceElevated,
|
|
68
|
+
borderColor: theme.colors.border,
|
|
69
|
+
borderWidth: 1,
|
|
70
|
+
opacity: a.isDisabled ? 0.5 : pressed ? 0.85 : 1,
|
|
71
|
+
})}
|
|
72
|
+
>
|
|
73
|
+
{a.icon}
|
|
74
|
+
<Text style={{ color: theme.colors.fg, fontWeight: '500' }}>{a.label}</Text>
|
|
75
|
+
</Pressable>
|
|
76
|
+
))
|
|
77
|
+
: null}
|
|
78
|
+
<FloatButton
|
|
79
|
+
icon={icon}
|
|
80
|
+
right={right}
|
|
81
|
+
bottom={bottom}
|
|
82
|
+
accessibilityLabel={accessibilityLabel ?? 'Open actions'}
|
|
83
|
+
onPress={() => setOpen(!open)}
|
|
84
|
+
/>
|
|
85
|
+
</View>
|
|
86
|
+
);
|
|
87
|
+
}
|
package/src/Spinner.tsx
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { forwardRef } from 'react';
|
|
2
|
+
import { ActivityIndicator, View, type StyleProp, type ViewStyle } from 'react-native';
|
|
3
|
+
import type { ElvoraSize } from '@elvora/core';
|
|
4
|
+
|
|
5
|
+
export interface SpinnerProps {
|
|
6
|
+
size?: ElvoraSize | number;
|
|
7
|
+
color?: string;
|
|
8
|
+
/** Accessible label. */
|
|
9
|
+
label?: string;
|
|
10
|
+
style?: StyleProp<ViewStyle>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const sizePx: Record<ElvoraSize, number> = { xs: 12, sm: 14, md: 16, lg: 20, xl: 28 };
|
|
14
|
+
|
|
15
|
+
export const Spinner = forwardRef<View, SpinnerProps>(function Spinner(props, ref) {
|
|
16
|
+
const { size = 'md', color = '#0072F5', label = 'Loading', style } = props;
|
|
17
|
+
const px = typeof size === 'number' ? size : sizePx[size];
|
|
18
|
+
return (
|
|
19
|
+
<View
|
|
20
|
+
ref={ref}
|
|
21
|
+
accessible
|
|
22
|
+
accessibilityRole="progressbar"
|
|
23
|
+
accessibilityLabel={label}
|
|
24
|
+
style={style}
|
|
25
|
+
>
|
|
26
|
+
<ActivityIndicator size={px <= 18 ? 'small' : 'large'} color={color} />
|
|
27
|
+
</View>
|
|
28
|
+
);
|
|
29
|
+
});
|
package/src/Splitter.tsx
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { useRef, useState, type ReactNode } from 'react';
|
|
2
|
+
import { PanResponder, View, useWindowDimensions, type ViewStyle, type LayoutChangeEvent } from 'react-native';
|
|
3
|
+
import { useTheme } from './ElvoraProvider';
|
|
4
|
+
|
|
5
|
+
export interface SplitterProps {
|
|
6
|
+
first: ReactNode;
|
|
7
|
+
second: ReactNode;
|
|
8
|
+
direction?: 'horizontal' | 'vertical';
|
|
9
|
+
/** Controlled split (0..1). */
|
|
10
|
+
split?: number;
|
|
11
|
+
defaultSplit?: number;
|
|
12
|
+
onChange?: (split: number) => void;
|
|
13
|
+
minRatio?: number;
|
|
14
|
+
maxRatio?: number;
|
|
15
|
+
handleSize?: number;
|
|
16
|
+
style?: ViewStyle;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Splitter — drag-to-resize layout for two panes. Horizontal direction stacks
|
|
21
|
+
* left/right; vertical stacks top/bottom.
|
|
22
|
+
*/
|
|
23
|
+
export function Splitter(props: SplitterProps) {
|
|
24
|
+
const {
|
|
25
|
+
first,
|
|
26
|
+
second,
|
|
27
|
+
direction = 'horizontal',
|
|
28
|
+
split: splitProp,
|
|
29
|
+
defaultSplit = 0.5,
|
|
30
|
+
onChange,
|
|
31
|
+
minRatio = 0.1,
|
|
32
|
+
maxRatio = 0.9,
|
|
33
|
+
handleSize = 8,
|
|
34
|
+
style,
|
|
35
|
+
} = props;
|
|
36
|
+
const theme = useTheme();
|
|
37
|
+
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
|
38
|
+
const [internal, setInternal] = useState(defaultSplit);
|
|
39
|
+
const split = splitProp ?? internal;
|
|
40
|
+
const [size, setSize] = useState({ width: windowWidth, height: windowHeight });
|
|
41
|
+
const startSplit = useRef(split);
|
|
42
|
+
|
|
43
|
+
const responder = useRef(
|
|
44
|
+
PanResponder.create({
|
|
45
|
+
onStartShouldSetPanResponder: () => true,
|
|
46
|
+
onMoveShouldSetPanResponder: () => true,
|
|
47
|
+
onPanResponderGrant: () => {
|
|
48
|
+
startSplit.current = split;
|
|
49
|
+
},
|
|
50
|
+
onPanResponderMove: (_e, gesture) => {
|
|
51
|
+
const total = direction === 'horizontal' ? size.width : size.height;
|
|
52
|
+
if (!total) return;
|
|
53
|
+
const delta = direction === 'horizontal' ? gesture.dx : gesture.dy;
|
|
54
|
+
const next = Math.min(maxRatio, Math.max(minRatio, startSplit.current + delta / total));
|
|
55
|
+
if (splitProp === undefined) setInternal(next);
|
|
56
|
+
onChange?.(next);
|
|
57
|
+
},
|
|
58
|
+
}),
|
|
59
|
+
).current;
|
|
60
|
+
|
|
61
|
+
const onLayout = (e: LayoutChangeEvent) => {
|
|
62
|
+
const { width, height } = e.nativeEvent.layout;
|
|
63
|
+
setSize({ width, height });
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const isHorizontal = direction === 'horizontal';
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<View
|
|
70
|
+
onLayout={onLayout}
|
|
71
|
+
style={[
|
|
72
|
+
{ flexDirection: isHorizontal ? 'row' : 'column', flex: 1, overflow: 'hidden' },
|
|
73
|
+
style,
|
|
74
|
+
]}
|
|
75
|
+
>
|
|
76
|
+
<View style={{ flexBasis: `${split * 100}%`, [isHorizontal ? 'height' : 'width']: '100%' } as ViewStyle}>
|
|
77
|
+
{first}
|
|
78
|
+
</View>
|
|
79
|
+
<View
|
|
80
|
+
{...responder.panHandlers}
|
|
81
|
+
accessibilityRole="adjustable"
|
|
82
|
+
style={{
|
|
83
|
+
width: isHorizontal ? handleSize : '100%',
|
|
84
|
+
height: isHorizontal ? '100%' : handleSize,
|
|
85
|
+
backgroundColor: theme.colors.border,
|
|
86
|
+
}}
|
|
87
|
+
/>
|
|
88
|
+
<View style={{ flex: 1 }}>{second}</View>
|
|
89
|
+
</View>
|
|
90
|
+
);
|
|
91
|
+
}
|
package/src/Stack.tsx
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { forwardRef } from 'react';
|
|
2
|
+
import { View, type ViewProps, type ViewStyle, type StyleProp, type FlexAlignType } from 'react-native';
|
|
3
|
+
|
|
4
|
+
export interface StackProps extends ViewProps {
|
|
5
|
+
direction?: 'row' | 'column' | 'row-reverse' | 'column-reverse';
|
|
6
|
+
gap?: number;
|
|
7
|
+
align?: FlexAlignType;
|
|
8
|
+
justify?: ViewStyle['justifyContent'];
|
|
9
|
+
wrap?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Stack — flex container for RN. */
|
|
13
|
+
export const Stack = forwardRef<View, StackProps>(function Stack(props, ref) {
|
|
14
|
+
const { direction = 'column', gap = 8, align, justify, wrap, style, children, ...rest } = props;
|
|
15
|
+
const composed: StyleProp<ViewStyle> = [
|
|
16
|
+
{
|
|
17
|
+
flexDirection: direction,
|
|
18
|
+
gap,
|
|
19
|
+
alignItems: align,
|
|
20
|
+
justifyContent: justify,
|
|
21
|
+
flexWrap: wrap ? 'wrap' : 'nowrap',
|
|
22
|
+
},
|
|
23
|
+
style,
|
|
24
|
+
];
|
|
25
|
+
return (
|
|
26
|
+
<View ref={ref} style={composed} {...rest}>
|
|
27
|
+
{children}
|
|
28
|
+
</View>
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export const HStack = forwardRef<View, Omit<StackProps, 'direction'>>(function HStack(props, ref) {
|
|
33
|
+
return <Stack ref={ref} direction="row" align="center" {...props} />;
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export const VStack = forwardRef<View, Omit<StackProps, 'direction'>>(function VStack(props, ref) {
|
|
37
|
+
return <Stack ref={ref} direction="column" {...props} />;
|
|
38
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import { View, Text, type ViewProps } from 'react-native';
|
|
3
|
+
import { useTheme } from './ElvoraProvider';
|
|
4
|
+
|
|
5
|
+
export interface StatisticProps extends ViewProps {
|
|
6
|
+
title?: ReactNode;
|
|
7
|
+
value: number | string;
|
|
8
|
+
precision?: number;
|
|
9
|
+
prefix?: ReactNode;
|
|
10
|
+
suffix?: ReactNode;
|
|
11
|
+
groupSeparator?: boolean;
|
|
12
|
+
tone?: 'default' | 'success' | 'danger' | 'warning' | 'primary';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function formatValue(value: number | string, precision?: number, group?: boolean): string {
|
|
16
|
+
if (typeof value === 'string') return value;
|
|
17
|
+
if (typeof precision === 'number') {
|
|
18
|
+
return group ? value.toLocaleString(undefined, { minimumFractionDigits: precision, maximumFractionDigits: precision }) : value.toFixed(precision);
|
|
19
|
+
}
|
|
20
|
+
return group ? value.toLocaleString() : String(value);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function Statistic(props: StatisticProps) {
|
|
24
|
+
const { title, value, precision, prefix, suffix, groupSeparator = true, tone = 'default', style, ...rest } = props;
|
|
25
|
+
const theme = useTheme();
|
|
26
|
+
const formatted = formatValue(value, precision, groupSeparator);
|
|
27
|
+
const toneColor =
|
|
28
|
+
tone === 'success'
|
|
29
|
+
? theme.colors.intent.success.solid
|
|
30
|
+
: tone === 'danger'
|
|
31
|
+
? theme.colors.intent.danger.solid
|
|
32
|
+
: tone === 'warning'
|
|
33
|
+
? theme.colors.intent.warning.solid
|
|
34
|
+
: tone === 'primary'
|
|
35
|
+
? theme.colors.intent.primary.solid
|
|
36
|
+
: theme.colors.fg;
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<View style={style} {...rest}>
|
|
40
|
+
{title ? (
|
|
41
|
+
<Text style={{ color: theme.colors.fgSubtle, fontSize: 14, marginBottom: 4 }}>
|
|
42
|
+
{typeof title === 'string' ? title : null}
|
|
43
|
+
</Text>
|
|
44
|
+
) : null}
|
|
45
|
+
<View style={{ flexDirection: 'row', alignItems: 'baseline' }}>
|
|
46
|
+
{prefix ? (
|
|
47
|
+
<Text style={{ color: toneColor, fontSize: 16, marginRight: 4 }}>
|
|
48
|
+
{typeof prefix === 'string' || typeof prefix === 'number' ? String(prefix) : ''}
|
|
49
|
+
</Text>
|
|
50
|
+
) : null}
|
|
51
|
+
<Text style={{ color: toneColor, fontSize: 28, fontWeight: '600' }}>{formatted}</Text>
|
|
52
|
+
{suffix ? (
|
|
53
|
+
<Text style={{ color: toneColor, fontSize: 16, marginLeft: 4 }}>
|
|
54
|
+
{typeof suffix === 'string' || typeof suffix === 'number' ? String(suffix) : ''}
|
|
55
|
+
</Text>
|
|
56
|
+
) : null}
|
|
57
|
+
</View>
|
|
58
|
+
</View>
|
|
59
|
+
);
|
|
60
|
+
}
|