@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
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
+ });
@@ -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
+ }
@@ -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
+ });
@@ -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
+ }