@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/Image.tsx ADDED
@@ -0,0 +1,68 @@
1
+ import { useState, type ReactNode } from 'react';
2
+ import { Image as RNImage, Pressable, View, Modal, type ImageProps as RNImageProps, type ImageStyle } from 'react-native';
3
+ import { useTheme } from './ElvoraProvider';
4
+
5
+ export interface ImageProps extends Omit<RNImageProps, 'source' | 'style'> {
6
+ src: string;
7
+ fallback?: string;
8
+ placeholder?: ReactNode;
9
+ /** Enable tap-to-preview overlay. */
10
+ preview?: boolean;
11
+ width?: number;
12
+ height?: number;
13
+ fit?: 'cover' | 'contain' | 'stretch' | 'center';
14
+ rounded?: boolean;
15
+ style?: ImageStyle;
16
+ }
17
+
18
+ export function Image(props: ImageProps) {
19
+ const { src, fallback, placeholder, preview, width, height, fit = 'cover', rounded, style, ...rest } = props;
20
+ const theme = useTheme();
21
+ const [loaded, setLoaded] = useState(false);
22
+ const [errored, setErrored] = useState(false);
23
+ const [showPreview, setShowPreview] = useState(false);
24
+
25
+ const actualSrc = errored && fallback ? fallback : src;
26
+
27
+ const img = (
28
+ <RNImage
29
+ source={{ uri: actualSrc }}
30
+ onLoad={() => setLoaded(true)}
31
+ onError={() => setErrored(true)}
32
+ resizeMode={fit}
33
+ style={[{ width: '100%', height: '100%' }, style]}
34
+ {...rest}
35
+ />
36
+ );
37
+
38
+ return (
39
+ <>
40
+ <Pressable
41
+ disabled={!preview}
42
+ onPress={preview ? () => setShowPreview(true) : undefined}
43
+ style={{
44
+ width,
45
+ height,
46
+ borderRadius: rounded ? 8 : 0,
47
+ overflow: 'hidden',
48
+ backgroundColor: theme.colors.intent.neutral.subtle,
49
+ }}
50
+ >
51
+ {!loaded && !errored && placeholder ? (
52
+ <View style={{ position: 'absolute', inset: 0, alignItems: 'center', justifyContent: 'center' }}>{placeholder}</View>
53
+ ) : null}
54
+ {img}
55
+ </Pressable>
56
+ {preview && showPreview ? (
57
+ <Modal visible transparent animationType="fade" onRequestClose={() => setShowPreview(false)}>
58
+ <Pressable
59
+ onPress={() => setShowPreview(false)}
60
+ style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.9)', alignItems: 'center', justifyContent: 'center' }}
61
+ >
62
+ <RNImage source={{ uri: actualSrc }} resizeMode="contain" style={{ width: '90%', height: '90%' }} />
63
+ </Pressable>
64
+ </Modal>
65
+ ) : null}
66
+ </>
67
+ );
68
+ }
@@ -0,0 +1,58 @@
1
+ import type { ReactNode } from 'react';
2
+ import { Image as RNImage, Text, View, useWindowDimensions, type ViewStyle } from 'react-native';
3
+ import { useTheme } from './ElvoraProvider';
4
+
5
+ export interface ImageListItem {
6
+ key: string;
7
+ src?: string;
8
+ alt?: string;
9
+ node?: ReactNode;
10
+ caption?: ReactNode;
11
+ }
12
+
13
+ export interface ImageListProps {
14
+ items: ImageListItem[];
15
+ cols?: number;
16
+ gap?: number;
17
+ rowHeight?: number;
18
+ style?: ViewStyle;
19
+ }
20
+
21
+ export function ImageList(props: ImageListProps) {
22
+ const { items, cols = 3, gap = 8, rowHeight = 120, style } = props;
23
+ const theme = useTheme();
24
+ const { width } = useWindowDimensions();
25
+ const cellWidth = (width - gap * (cols + 1) - 32) / cols;
26
+ return (
27
+ <View style={[{ flexDirection: 'row', flexWrap: 'wrap', gap }, style]}>
28
+ {items.map((item) => (
29
+ <View
30
+ key={item.key}
31
+ style={{
32
+ width: cellWidth,
33
+ height: rowHeight,
34
+ borderRadius: 8,
35
+ overflow: 'hidden',
36
+ backgroundColor: theme.colors.intent.neutral.subtle,
37
+ }}
38
+ >
39
+ {item.node ?? (item.src ? <RNImage source={{ uri: item.src }} style={{ width: '100%', height: '100%' }} resizeMode="cover" /> : null)}
40
+ {item.caption ? (
41
+ <View
42
+ style={{
43
+ position: 'absolute',
44
+ left: 0,
45
+ right: 0,
46
+ bottom: 0,
47
+ padding: 6,
48
+ backgroundColor: 'rgba(0,0,0,0.55)',
49
+ }}
50
+ >
51
+ <Text style={{ color: '#fff', fontSize: 11 }}>{item.caption}</Text>
52
+ </View>
53
+ ) : null}
54
+ </View>
55
+ ))}
56
+ </View>
57
+ );
58
+ }
package/src/Input.tsx ADDED
@@ -0,0 +1,87 @@
1
+ import { forwardRef, useState } from 'react';
2
+ import {
3
+ TextInput,
4
+ type StyleProp,
5
+ type TextInputProps,
6
+ type ViewStyle,
7
+ } from 'react-native';
8
+ import { defaultInputProps, type InputOwnProps } from '@elvora/core';
9
+ import { useTheme } from './ElvoraProvider';
10
+
11
+ export interface InputProps
12
+ extends InputOwnProps,
13
+ Omit<TextInputProps, 'editable' | 'style'> {
14
+ style?: StyleProp<ViewStyle>;
15
+ }
16
+
17
+ const sizeMap = {
18
+ xs: { padX: 8, padY: 4, font: 12, height: 28 },
19
+ sm: { padX: 10, padY: 6, font: 13, height: 36 },
20
+ md: { padX: 12, padY: 8, font: 14, height: 44 },
21
+ lg: { padX: 14, padY: 10, font: 16, height: 52 },
22
+ xl: { padX: 16, padY: 12, font: 18, height: 60 },
23
+ };
24
+
25
+ /** React Native text input with Elvora styling. */
26
+ export const Input = forwardRef<TextInput, InputProps>(function Input(props, ref) {
27
+ const {
28
+ size = defaultInputProps.size,
29
+ status = defaultInputProps.status,
30
+ isReadOnly = defaultInputProps.isReadOnly,
31
+ isDisabled = defaultInputProps.isDisabled,
32
+ isInvalid = defaultInputProps.isInvalid,
33
+ style,
34
+ onFocus,
35
+ onBlur,
36
+ ...rest
37
+ } = props;
38
+ const theme = useTheme();
39
+ const [focused, setFocused] = useState(false);
40
+ const dims = sizeMap[size];
41
+
42
+ const invalid = isInvalid || status === 'error';
43
+ const intentForStatus =
44
+ status === 'error' || invalid
45
+ ? theme.colors.intent.danger
46
+ : status === 'success'
47
+ ? theme.colors.intent.success
48
+ : status === 'warning'
49
+ ? theme.colors.intent.warning
50
+ : status === 'info'
51
+ ? theme.colors.intent.info
52
+ : theme.colors.intent.primary;
53
+
54
+ const borderColor = focused ? intentForStatus.border : invalid ? theme.colors.intent.danger.border : theme.colors.border;
55
+
56
+ return (
57
+ <TextInput
58
+ ref={ref}
59
+ editable={!isDisabled && !isReadOnly}
60
+ placeholderTextColor={theme.colors.fgMuted}
61
+ style={[
62
+ {
63
+ minHeight: dims.height,
64
+ paddingHorizontal: dims.padX,
65
+ paddingVertical: dims.padY,
66
+ borderRadius: Number(theme.radii.md),
67
+ borderWidth: 1,
68
+ borderColor,
69
+ backgroundColor: isDisabled ? theme.colors.surface : theme.colors.surfaceElevated,
70
+ color: theme.colors.fg,
71
+ fontSize: dims.font,
72
+ opacity: isDisabled ? 0.6 : 1,
73
+ },
74
+ style,
75
+ ]}
76
+ onFocus={(e) => {
77
+ setFocused(true);
78
+ onFocus?.(e);
79
+ }}
80
+ onBlur={(e) => {
81
+ setFocused(false);
82
+ onBlur?.(e);
83
+ }}
84
+ {...rest}
85
+ />
86
+ );
87
+ });
package/src/Label.tsx ADDED
@@ -0,0 +1,46 @@
1
+ import { forwardRef, type ReactNode } from 'react';
2
+ import { Text, View, type StyleProp, type TextStyle, type ViewStyle } from 'react-native';
3
+ import { useTheme } from './ElvoraProvider';
4
+
5
+ export interface LabelProps {
6
+ isRequired?: boolean;
7
+ size?: 'xs' | 'sm' | 'md' | 'lg';
8
+ children?: ReactNode;
9
+ style?: StyleProp<ViewStyle>;
10
+ textStyle?: StyleProp<TextStyle>;
11
+ }
12
+
13
+ const sizeMap = {
14
+ xs: { fontSize: 11 },
15
+ sm: { fontSize: 12 },
16
+ md: { fontSize: 14 },
17
+ lg: { fontSize: 16 },
18
+ };
19
+
20
+ /** Form label for React Native. */
21
+ export const Label = forwardRef<View, LabelProps>(function Label(props, ref) {
22
+ const { isRequired, size = 'md', children, style, textStyle } = props;
23
+ const theme = useTheme();
24
+ const dims = sizeMap[size];
25
+ return (
26
+ <View ref={ref} style={[{ flexDirection: 'row', alignItems: 'center' }, style]}>
27
+ <Text
28
+ style={[
29
+ {
30
+ color: theme.colors.fg,
31
+ fontSize: dims.fontSize,
32
+ fontWeight: '500',
33
+ },
34
+ textStyle,
35
+ ]}
36
+ >
37
+ {children}
38
+ </Text>
39
+ {isRequired ? (
40
+ <Text accessibilityElementsHidden style={{ color: theme.colors.intent.danger.fg, marginLeft: 2 }}>
41
+ *
42
+ </Text>
43
+ ) : null}
44
+ </View>
45
+ );
46
+ });
package/src/List.tsx ADDED
@@ -0,0 +1,82 @@
1
+ import { type ReactNode } from 'react';
2
+ import { View, Text, type ViewProps } from 'react-native';
3
+ import { useTheme } from './ElvoraProvider';
4
+
5
+ export interface ListProps<Item = unknown> extends Omit<ViewProps, 'children'> {
6
+ dataSource: Item[];
7
+ renderItem: (item: Item, index: number) => ReactNode;
8
+ itemKey?: keyof Item | ((item: Item, index: number) => string | number);
9
+ header?: ReactNode;
10
+ footer?: ReactNode;
11
+ bordered?: boolean;
12
+ size?: 'sm' | 'md' | 'lg';
13
+ divided?: boolean;
14
+ emptyState?: ReactNode;
15
+ }
16
+
17
+ export function List<Item>(props: ListProps<Item>) {
18
+ const {
19
+ dataSource,
20
+ renderItem,
21
+ itemKey,
22
+ header,
23
+ footer,
24
+ bordered,
25
+ size = 'md',
26
+ divided = true,
27
+ emptyState,
28
+ style,
29
+ ...rest
30
+ } = props;
31
+ const theme = useTheme();
32
+ const padding = size === 'sm' ? 10 : size === 'lg' ? 18 : 14;
33
+
34
+ const keyFor = (item: Item, idx: number): string => {
35
+ if (typeof itemKey === 'function') return String(itemKey(item, idx));
36
+ if (itemKey) return String((item as unknown as Record<string, unknown>)[itemKey as string] ?? idx);
37
+ return String(idx);
38
+ };
39
+
40
+ return (
41
+ <View
42
+ style={[
43
+ {
44
+ borderWidth: bordered ? 1 : 0,
45
+ borderColor: theme.colors.border,
46
+ borderRadius: 8,
47
+ backgroundColor: theme.colors.surfaceElevated,
48
+ overflow: 'hidden',
49
+ },
50
+ style,
51
+ ]}
52
+ {...rest}
53
+ >
54
+ {header ? (
55
+ <View style={{ padding, borderBottomWidth: 1, borderBottomColor: theme.colors.border }}>
56
+ {typeof header === 'string' ? <Text style={{ color: theme.colors.fg, fontWeight: '600' }}>{header}</Text> : header}
57
+ </View>
58
+ ) : null}
59
+ {dataSource.length === 0 && emptyState ? (
60
+ <View style={{ padding }}>{typeof emptyState === 'string' ? <Text style={{ color: theme.colors.fgSubtle }}>{emptyState}</Text> : emptyState}</View>
61
+ ) : (
62
+ dataSource.map((item, idx) => (
63
+ <View
64
+ key={keyFor(item, idx)}
65
+ style={{
66
+ padding,
67
+ borderBottomWidth: divided && idx < dataSource.length - 1 ? 1 : 0,
68
+ borderBottomColor: theme.colors.border,
69
+ }}
70
+ >
71
+ {renderItem(item, idx)}
72
+ </View>
73
+ ))
74
+ )}
75
+ {footer ? (
76
+ <View style={{ padding, borderTopWidth: 1, borderTopColor: theme.colors.border }}>
77
+ {typeof footer === 'string' ? <Text style={{ color: theme.colors.fgSubtle }}>{footer}</Text> : footer}
78
+ </View>
79
+ ) : null}
80
+ </View>
81
+ );
82
+ }
@@ -0,0 +1,148 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { Pressable, TextInput, View, Text, type TextInputProps, type ViewProps } from 'react-native';
3
+ import { useTheme } from './ElvoraProvider';
4
+
5
+ export interface MentionItem {
6
+ value: string;
7
+ label?: string;
8
+ }
9
+
10
+ export interface MentionsProps extends Omit<ViewProps, 'children'> {
11
+ value?: string;
12
+ defaultValue?: string;
13
+ onChangeText?: (v: string) => void;
14
+ options?: MentionItem[];
15
+ loadOptions?: (query: string) => Promise<MentionItem[]>;
16
+ trigger?: string;
17
+ isDisabled?: boolean;
18
+ textInputProps?: Omit<TextInputProps, 'value' | 'onChangeText'>;
19
+ }
20
+
21
+ interface MentionState {
22
+ active: boolean;
23
+ start: number;
24
+ query: string;
25
+ }
26
+
27
+ export function Mentions(props: MentionsProps) {
28
+ const {
29
+ value: valueProp,
30
+ defaultValue = '',
31
+ onChangeText,
32
+ options = [],
33
+ loadOptions,
34
+ trigger = '@',
35
+ isDisabled,
36
+ textInputProps,
37
+ style,
38
+ ...rest
39
+ } = props;
40
+ const theme = useTheme();
41
+ const [internal, setInternal] = useState(defaultValue);
42
+ const value = valueProp ?? internal;
43
+ const [state, setState] = useState<MentionState>({ active: false, start: -1, query: '' });
44
+ const [suggestions, setSuggestions] = useState<MentionItem[]>([]);
45
+ const caretRef = useRef<number>(value.length);
46
+
47
+ useEffect(() => {
48
+ let cancelled = false;
49
+ if (!state.active) {
50
+ setSuggestions([]);
51
+ return;
52
+ }
53
+ if (loadOptions) {
54
+ loadOptions(state.query).then((opts) => {
55
+ if (!cancelled) setSuggestions(opts);
56
+ });
57
+ } else {
58
+ const q = state.query.toLowerCase();
59
+ setSuggestions(options.filter((o) => o.value.toLowerCase().includes(q)).slice(0, 8));
60
+ }
61
+ return () => {
62
+ cancelled = true;
63
+ };
64
+ }, [state.active, state.query, options, loadOptions]);
65
+
66
+ const setValue = (next: string) => {
67
+ if (valueProp === undefined) setInternal(next);
68
+ onChangeText?.(next);
69
+ };
70
+
71
+ const handleChange = (next: string) => {
72
+ setValue(next);
73
+ const caret = caretRef.current;
74
+ let i = caret - 1;
75
+ while (i >= 0 && next[i] !== trigger && !/\s/.test(next[i]!)) {
76
+ i--;
77
+ }
78
+ if (i >= 0 && next[i] === trigger) {
79
+ setState({ active: true, start: i, query: next.slice(i + 1, caret) });
80
+ } else {
81
+ setState({ active: false, start: -1, query: '' });
82
+ }
83
+ };
84
+
85
+ const insertSelected = (item: MentionItem) => {
86
+ const text = value;
87
+ const before = text.slice(0, state.start);
88
+ const after = text.slice(caretRef.current);
89
+ const insertion = `${trigger}${item.value} `;
90
+ setValue(`${before}${insertion}${after}`);
91
+ setState({ active: false, start: -1, query: '' });
92
+ };
93
+
94
+ return (
95
+ <View style={style} {...rest}>
96
+ <TextInput
97
+ value={value}
98
+ editable={!isDisabled}
99
+ multiline
100
+ onChangeText={handleChange}
101
+ onSelectionChange={(e) => {
102
+ caretRef.current = e.nativeEvent.selection.end ?? value.length;
103
+ }}
104
+ placeholderTextColor={theme.colors.fgMuted}
105
+ style={{
106
+ minHeight: 84,
107
+ paddingHorizontal: 12,
108
+ paddingVertical: 8,
109
+ borderWidth: 1,
110
+ borderColor: theme.colors.border,
111
+ borderRadius: Number(theme.radii.md),
112
+ backgroundColor: isDisabled ? theme.colors.surface : theme.colors.surfaceElevated,
113
+ color: theme.colors.fg,
114
+ fontSize: 14,
115
+ textAlignVertical: 'top',
116
+ }}
117
+ {...textInputProps}
118
+ />
119
+ {state.active && suggestions.length > 0 ? (
120
+ <View
121
+ style={{
122
+ marginTop: 4,
123
+ borderRadius: Number(theme.radii.md),
124
+ borderWidth: 1,
125
+ borderColor: theme.colors.border,
126
+ backgroundColor: theme.colors.surfaceElevated,
127
+ maxHeight: 200,
128
+ paddingVertical: 4,
129
+ }}
130
+ >
131
+ {suggestions.map((item) => (
132
+ <Pressable
133
+ key={item.value}
134
+ onPress={() => insertSelected(item)}
135
+ style={({ pressed }) => ({
136
+ paddingHorizontal: 12,
137
+ paddingVertical: 8,
138
+ backgroundColor: pressed ? theme.colors.intent.neutral.subtle : 'transparent',
139
+ })}
140
+ >
141
+ <Text style={{ color: theme.colors.fg, fontSize: 14 }}>{item.label ?? item.value}</Text>
142
+ </Pressable>
143
+ ))}
144
+ </View>
145
+ ) : null}
146
+ </View>
147
+ );
148
+ }
package/src/Menu.tsx ADDED
@@ -0,0 +1,77 @@
1
+ import { useState, type ReactNode } from 'react';
2
+ import { Modal as RNModal, Pressable, View, Text } from 'react-native';
3
+ import { useTheme } from './ElvoraProvider';
4
+
5
+ export interface MenuItem {
6
+ value: string;
7
+ label: string;
8
+ isDisabled?: boolean;
9
+ hasSeparatorBefore?: boolean;
10
+ }
11
+
12
+ export interface MenuProps {
13
+ trigger: ReactNode;
14
+ items: MenuItem[];
15
+ onSelect?: (value: string) => void;
16
+ }
17
+
18
+ /**
19
+ * Menu — opens a modal sheet from the trigger. Suitable for RN because there
20
+ * is no anchored-popover API; sheet-style menus are idiomatic.
21
+ */
22
+ export function Menu(props: MenuProps) {
23
+ const { trigger, items, onSelect } = props;
24
+ const [isOpen, setIsOpen] = useState(false);
25
+ const theme = useTheme();
26
+
27
+ return (
28
+ <>
29
+ <Pressable accessibilityRole="button" onPress={() => setIsOpen(true)}>
30
+ {trigger}
31
+ </Pressable>
32
+ <RNModal visible={isOpen} transparent animationType="fade" onRequestClose={() => setIsOpen(false)}>
33
+ <Pressable
34
+ onPress={() => setIsOpen(false)}
35
+ style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.35)', justifyContent: 'flex-end' }}
36
+ >
37
+ <Pressable onPress={(e) => e.stopPropagation()}>
38
+ <View
39
+ style={{
40
+ backgroundColor: theme.colors.surfaceElevated,
41
+ borderTopLeftRadius: theme.radii.lg,
42
+ borderTopRightRadius: theme.radii.lg,
43
+ paddingTop: 8,
44
+ paddingBottom: 24,
45
+ }}
46
+ >
47
+ {items.map((item) => (
48
+ <View key={item.value}>
49
+ {item.hasSeparatorBefore ? (
50
+ <View style={{ height: 1, backgroundColor: theme.colors.border, marginVertical: 4 }} />
51
+ ) : null}
52
+ <Pressable
53
+ accessibilityRole="menuitem"
54
+ accessibilityState={{ disabled: item.isDisabled }}
55
+ disabled={item.isDisabled}
56
+ onPress={() => {
57
+ onSelect?.(item.value);
58
+ setIsOpen(false);
59
+ }}
60
+ style={({ pressed }) => ({
61
+ paddingVertical: 14,
62
+ paddingHorizontal: 16,
63
+ backgroundColor: pressed ? theme.colors.intent.neutral.subtle : 'transparent',
64
+ opacity: item.isDisabled ? 0.5 : 1,
65
+ })}
66
+ >
67
+ <Text style={{ fontSize: 16, color: theme.colors.fg }}>{item.label}</Text>
68
+ </Pressable>
69
+ </View>
70
+ ))}
71
+ </View>
72
+ </Pressable>
73
+ </Pressable>
74
+ </RNModal>
75
+ </>
76
+ );
77
+ }
package/src/Modal.tsx ADDED
@@ -0,0 +1,114 @@
1
+ import { forwardRef, type ReactNode } from 'react';
2
+ import { Modal as RNModal, Pressable, View, Text, type ViewProps } from 'react-native';
3
+ import { useTheme } from './ElvoraProvider';
4
+ import { IconButton } from './IconButton';
5
+ import { Icon } from './Icon';
6
+
7
+ export interface ModalProps extends Omit<ViewProps, 'children'> {
8
+ isOpen: boolean;
9
+ onClose: () => void;
10
+ title?: ReactNode;
11
+ children?: ReactNode;
12
+ footer?: ReactNode;
13
+ showCloseButton?: boolean;
14
+ closeOnOverlayPress?: boolean;
15
+ closeLabel?: string;
16
+ }
17
+
18
+ /** Modal — centered overlay dialog for RN (uses native Modal). */
19
+ export const Modal = forwardRef<View, ModalProps>(function Modal(props, ref) {
20
+ const {
21
+ isOpen,
22
+ onClose,
23
+ title,
24
+ children,
25
+ footer,
26
+ showCloseButton = true,
27
+ closeOnOverlayPress = true,
28
+ closeLabel = 'Close',
29
+ style,
30
+ ...rest
31
+ } = props;
32
+ const theme = useTheme();
33
+ return (
34
+ <RNModal
35
+ visible={isOpen}
36
+ transparent
37
+ animationType="fade"
38
+ onRequestClose={onClose}
39
+ accessibilityViewIsModal
40
+ >
41
+ <Pressable
42
+ onPress={closeOnOverlayPress ? onClose : undefined}
43
+ style={{
44
+ flex: 1,
45
+ backgroundColor: 'rgba(0,0,0,0.5)',
46
+ alignItems: 'center',
47
+ justifyContent: 'center',
48
+ padding: 16,
49
+ }}
50
+ >
51
+ <Pressable
52
+ onPress={(e) => e.stopPropagation()}
53
+ accessibilityRole="none"
54
+ style={{ width: '100%', maxWidth: 520 }}
55
+ >
56
+ <View
57
+ ref={ref}
58
+ accessibilityRole="none"
59
+ accessibilityViewIsModal
60
+ style={[
61
+ {
62
+ backgroundColor: theme.colors.surfaceElevated,
63
+ borderRadius: theme.radii.lg,
64
+ overflow: 'hidden',
65
+ maxHeight: '90%',
66
+ },
67
+ style,
68
+ ]}
69
+ {...rest}
70
+ >
71
+ {(title || showCloseButton) && (
72
+ <View
73
+ style={{
74
+ flexDirection: 'row',
75
+ alignItems: 'center',
76
+ gap: 12,
77
+ padding: 14,
78
+ borderBottomWidth: 1,
79
+ borderBottomColor: theme.colors.border,
80
+ }}
81
+ >
82
+ <Text style={{ flex: 1, color: theme.colors.fg, fontSize: 16, fontWeight: '600' }}>{title}</Text>
83
+ {showCloseButton ? (
84
+ <IconButton
85
+ accessibilityLabel={closeLabel}
86
+ variant="ghost"
87
+ size="sm"
88
+ onPress={onClose}
89
+ icon={<Icon name="x" size={16} color={theme.colors.fg} />}
90
+ />
91
+ ) : null}
92
+ </View>
93
+ )}
94
+ <View style={{ padding: 16 }}>{children}</View>
95
+ {footer ? (
96
+ <View
97
+ style={{
98
+ flexDirection: 'row',
99
+ gap: 8,
100
+ justifyContent: 'flex-end',
101
+ padding: 12,
102
+ borderTopWidth: 1,
103
+ borderTopColor: theme.colors.border,
104
+ }}
105
+ >
106
+ {footer}
107
+ </View>
108
+ ) : null}
109
+ </View>
110
+ </Pressable>
111
+ </Pressable>
112
+ </RNModal>
113
+ );
114
+ });