@elvora/react-native 1.0.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +40 -0
  3. package/dist/index.cjs +5785 -0
  4. package/dist/index.cjs.map +1 -0
  5. package/dist/index.d.cts +1253 -0
  6. package/dist/index.d.ts +1253 -0
  7. package/dist/index.js +5683 -0
  8. package/dist/index.js.map +1 -0
  9. package/package.json +88 -0
  10. package/src/Accordion.tsx +11 -0
  11. package/src/Affix.tsx +20 -0
  12. package/src/Alert.tsx +102 -0
  13. package/src/Anchor.tsx +58 -0
  14. package/src/AutoComplete.tsx +122 -0
  15. package/src/Avatar.tsx +58 -0
  16. package/src/BackTop.tsx +71 -0
  17. package/src/Backdrop.tsx +32 -0
  18. package/src/Badge.tsx +87 -0
  19. package/src/Box.tsx +67 -0
  20. package/src/Breadcrumb.tsx +46 -0
  21. package/src/Button.test.tsx +39 -0
  22. package/src/Button.tsx +127 -0
  23. package/src/ButtonGroup.tsx +74 -0
  24. package/src/Calendar.tsx +165 -0
  25. package/src/Card.tsx +69 -0
  26. package/src/Carousel.tsx +99 -0
  27. package/src/Cascader.tsx +160 -0
  28. package/src/Checkbox.tsx +85 -0
  29. package/src/ChipInput.tsx +130 -0
  30. package/src/Collapse.tsx +120 -0
  31. package/src/ColorPicker.tsx +114 -0
  32. package/src/Container.tsx +22 -0
  33. package/src/DataGrid.tsx +170 -0
  34. package/src/DatePicker.tsx +195 -0
  35. package/src/DateRangePicker.tsx +249 -0
  36. package/src/Descriptions.tsx +98 -0
  37. package/src/Divider.tsx +32 -0
  38. package/src/Drawer.tsx +103 -0
  39. package/src/Dropdown.tsx +15 -0
  40. package/src/ElvoraProvider.tsx +31 -0
  41. package/src/Empty.tsx +34 -0
  42. package/src/FloatButton.tsx +78 -0
  43. package/src/Form.tsx +119 -0
  44. package/src/Grid.tsx +68 -0
  45. package/src/Icon.tsx +49 -0
  46. package/src/IconButton.tsx +28 -0
  47. package/src/Image.tsx +68 -0
  48. package/src/ImageList.tsx +58 -0
  49. package/src/Input.tsx +87 -0
  50. package/src/Label.tsx +46 -0
  51. package/src/List.tsx +82 -0
  52. package/src/Mentions.tsx +148 -0
  53. package/src/Menu.tsx +77 -0
  54. package/src/Modal.tsx +114 -0
  55. package/src/NumberInput.tsx +156 -0
  56. package/src/Pagination.tsx +148 -0
  57. package/src/PaginationVariants.tsx +64 -0
  58. package/src/Popover.tsx +74 -0
  59. package/src/ProForm.tsx +219 -0
  60. package/src/ProLayout.tsx +151 -0
  61. package/src/ProTable.tsx +91 -0
  62. package/src/Progress.tsx +92 -0
  63. package/src/QRCode.tsx +65 -0
  64. package/src/Radio.tsx +98 -0
  65. package/src/Rate.tsx +66 -0
  66. package/src/Result.tsx +64 -0
  67. package/src/Segmented.tsx +75 -0
  68. package/src/Select.tsx +146 -0
  69. package/src/Skeleton.tsx +49 -0
  70. package/src/Slider.tsx +122 -0
  71. package/src/SpeedDial.tsx +87 -0
  72. package/src/Spinner.tsx +29 -0
  73. package/src/Splitter.tsx +91 -0
  74. package/src/Stack.tsx +38 -0
  75. package/src/Statistic.tsx +60 -0
  76. package/src/Stepper.tsx +113 -0
  77. package/src/Steps.tsx +146 -0
  78. package/src/Switch.tsx +52 -0
  79. package/src/Table.tsx +178 -0
  80. package/src/Tabs.tsx +122 -0
  81. package/src/Tag.tsx +83 -0
  82. package/src/Textarea.tsx +22 -0
  83. package/src/TimePicker.tsx +187 -0
  84. package/src/Timeline.tsx +92 -0
  85. package/src/Toast.tsx +140 -0
  86. package/src/ToggleButton.tsx +66 -0
  87. package/src/Tooltip.tsx +56 -0
  88. package/src/Tour.tsx +118 -0
  89. package/src/Transfer.tsx +219 -0
  90. package/src/Tree.tsx +144 -0
  91. package/src/TreeSelect.tsx +221 -0
  92. package/src/Upload.tsx +109 -0
  93. package/src/Watermark.tsx +76 -0
  94. package/src/index.ts +221 -0
  95. package/src/smoke.test.tsx +113 -0
  96. package/src/test/react-native-stub.tsx +413 -0
  97. package/src/test/react-native-svg-stub.tsx +33 -0
  98. package/src/test/setup.ts +7 -0
@@ -0,0 +1,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
+ }
@@ -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
+ }
@@ -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
+ }