@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,98 @@
1
+ import { type ReactNode } from 'react';
2
+ import { View, Text, type ViewProps } from 'react-native';
3
+ import { useTheme } from './ElvoraProvider';
4
+
5
+ export interface DescriptionItem {
6
+ key: string;
7
+ label: ReactNode;
8
+ children: ReactNode;
9
+ span?: number;
10
+ }
11
+
12
+ export interface DescriptionsProps extends Omit<ViewProps, 'children'> {
13
+ items: DescriptionItem[];
14
+ title?: ReactNode;
15
+ layout?: 'horizontal' | 'vertical';
16
+ bordered?: boolean;
17
+ size?: 'sm' | 'md' | 'lg';
18
+ }
19
+
20
+ export function Descriptions(props: DescriptionsProps) {
21
+ const { items, title, layout = 'horizontal', bordered, size = 'md', style, ...rest } = props;
22
+ const theme = useTheme();
23
+ const cellPadding = size === 'sm' ? 8 : size === 'lg' ? 16 : 12;
24
+ const borderColor = theme.colors.border;
25
+
26
+ return (
27
+ <View style={style} {...rest}>
28
+ {title ? (
29
+ <Text style={{ color: theme.colors.fg, fontWeight: '600', marginBottom: 8 }}>
30
+ {typeof title === 'string' ? title : null}
31
+ </Text>
32
+ ) : null}
33
+ <View
34
+ style={{
35
+ borderWidth: bordered ? 1 : 0,
36
+ borderColor,
37
+ borderRadius: bordered ? 8 : 0,
38
+ overflow: 'hidden',
39
+ }}
40
+ >
41
+ {items.map((item, idx) => {
42
+ if (layout === 'horizontal') {
43
+ return (
44
+ <View
45
+ key={item.key}
46
+ style={{
47
+ flexDirection: 'row',
48
+ borderTopWidth: bordered && idx > 0 ? 1 : 0,
49
+ borderTopColor: borderColor,
50
+ }}
51
+ >
52
+ <View
53
+ style={{
54
+ padding: cellPadding,
55
+ backgroundColor: bordered ? theme.colors.intent.neutral.subtle : undefined,
56
+ borderRightWidth: bordered ? 1 : 0,
57
+ borderRightColor: borderColor,
58
+ minWidth: 120,
59
+ }}
60
+ >
61
+ <Text style={{ color: theme.colors.fg, fontWeight: '500' }}>
62
+ {typeof item.label === 'string' ? item.label : null}
63
+ </Text>
64
+ </View>
65
+ <View style={{ flex: 1, padding: cellPadding }}>
66
+ {typeof item.children === 'string' || typeof item.children === 'number' ? (
67
+ <Text style={{ color: theme.colors.fg }}>{String(item.children)}</Text>
68
+ ) : (
69
+ item.children
70
+ )}
71
+ </View>
72
+ </View>
73
+ );
74
+ }
75
+ return (
76
+ <View
77
+ key={item.key}
78
+ style={{
79
+ padding: cellPadding,
80
+ borderTopWidth: bordered && idx > 0 ? 1 : 0,
81
+ borderTopColor: borderColor,
82
+ }}
83
+ >
84
+ <Text style={{ color: theme.colors.fgSubtle, fontWeight: '500', marginBottom: 4 }}>
85
+ {typeof item.label === 'string' ? item.label : null}
86
+ </Text>
87
+ {typeof item.children === 'string' || typeof item.children === 'number' ? (
88
+ <Text style={{ color: theme.colors.fg }}>{String(item.children)}</Text>
89
+ ) : (
90
+ item.children
91
+ )}
92
+ </View>
93
+ );
94
+ })}
95
+ </View>
96
+ </View>
97
+ );
98
+ }
@@ -0,0 +1,32 @@
1
+ import { forwardRef, type ReactNode } from 'react';
2
+ import { Text, View, type StyleProp, type ViewStyle } from 'react-native';
3
+ import type { Orientation } from '@elvora/core';
4
+ import { useTheme } from './ElvoraProvider';
5
+
6
+ export interface DividerProps {
7
+ orientation?: Orientation;
8
+ thickness?: number;
9
+ children?: ReactNode;
10
+ style?: StyleProp<ViewStyle>;
11
+ }
12
+
13
+ /** Visual separator for React Native. */
14
+ export const Divider = forwardRef<View, DividerProps>(function Divider(props, ref) {
15
+ const { orientation = 'horizontal', thickness = 1, children, style } = props;
16
+ const theme = useTheme();
17
+
18
+ if (children) {
19
+ return (
20
+ <View ref={ref} style={[{ flexDirection: 'row', alignItems: 'center', gap: 12 }, style]}>
21
+ <View style={{ flex: 1, height: thickness, backgroundColor: theme.colors.border }} />
22
+ <Text style={{ color: theme.colors.fgMuted, fontSize: 13 }}>{children}</Text>
23
+ <View style={{ flex: 1, height: thickness, backgroundColor: theme.colors.border }} />
24
+ </View>
25
+ );
26
+ }
27
+
28
+ if (orientation === 'vertical') {
29
+ return <View ref={ref} accessibilityRole="none" style={[{ width: thickness, alignSelf: 'stretch', backgroundColor: theme.colors.border }, style]} />;
30
+ }
31
+ return <View ref={ref} accessibilityRole="none" style={[{ height: thickness, alignSelf: 'stretch', backgroundColor: theme.colors.border }, style]} />;
32
+ });
package/src/Drawer.tsx ADDED
@@ -0,0 +1,103 @@
1
+ import { forwardRef, type ReactNode } from 'react';
2
+ import { Modal as RNModal, Pressable, View, Text, type ViewProps, type ViewStyle } from 'react-native';
3
+ import { useTheme } from './ElvoraProvider';
4
+ import { IconButton } from './IconButton';
5
+ import { Icon } from './Icon';
6
+
7
+ export interface DrawerProps extends Omit<ViewProps, 'children'> {
8
+ isOpen: boolean;
9
+ onClose: () => void;
10
+ placement?: 'left' | 'right' | 'top' | 'bottom';
11
+ size?: number | string;
12
+ title?: ReactNode;
13
+ children?: ReactNode;
14
+ footer?: ReactNode;
15
+ showCloseButton?: boolean;
16
+ closeOnOverlayPress?: boolean;
17
+ closeLabel?: string;
18
+ }
19
+
20
+ /** Drawer — edge-anchored overlay panel for RN. */
21
+ export const Drawer = forwardRef<View, DrawerProps>(function Drawer(props, ref) {
22
+ const {
23
+ isOpen,
24
+ onClose,
25
+ placement = 'right',
26
+ size = 320,
27
+ title,
28
+ children,
29
+ footer,
30
+ showCloseButton = true,
31
+ closeOnOverlayPress = true,
32
+ closeLabel = 'Close',
33
+ style,
34
+ ...rest
35
+ } = props;
36
+ const theme = useTheme();
37
+ const horizontal = placement === 'left' || placement === 'right';
38
+ const panelStyle: ViewStyle = {
39
+ backgroundColor: theme.colors.surfaceElevated,
40
+ width: horizontal ? size : '100%',
41
+ height: horizontal ? '100%' : size,
42
+ } as ViewStyle;
43
+ const overlayAlign: ViewStyle =
44
+ placement === 'right'
45
+ ? { alignItems: 'flex-end' }
46
+ : placement === 'left'
47
+ ? { alignItems: 'flex-start' }
48
+ : placement === 'bottom'
49
+ ? { justifyContent: 'flex-end' }
50
+ : { justifyContent: 'flex-start' };
51
+
52
+ return (
53
+ <RNModal visible={isOpen} transparent animationType="slide" onRequestClose={onClose} accessibilityViewIsModal>
54
+ <Pressable
55
+ onPress={closeOnOverlayPress ? onClose : undefined}
56
+ style={[{ flex: 1, backgroundColor: 'rgba(0,0,0,0.45)' }, overlayAlign]}
57
+ >
58
+ <Pressable onPress={(e) => e.stopPropagation()} style={{ flex: horizontal ? undefined : 0 }}>
59
+ <View ref={ref} accessibilityViewIsModal style={[panelStyle, style]} {...rest}>
60
+ {(title || showCloseButton) && (
61
+ <View
62
+ style={{
63
+ flexDirection: 'row',
64
+ alignItems: 'center',
65
+ gap: 12,
66
+ padding: 14,
67
+ borderBottomWidth: 1,
68
+ borderBottomColor: theme.colors.border,
69
+ }}
70
+ >
71
+ <Text style={{ flex: 1, color: theme.colors.fg, fontSize: 16, fontWeight: '600' }}>{title}</Text>
72
+ {showCloseButton ? (
73
+ <IconButton
74
+ accessibilityLabel={closeLabel}
75
+ variant="ghost"
76
+ size="sm"
77
+ onPress={onClose}
78
+ icon={<Icon name="x" size={16} color={theme.colors.fg} />}
79
+ />
80
+ ) : null}
81
+ </View>
82
+ )}
83
+ <View style={{ padding: 16, flex: 1 }}>{children}</View>
84
+ {footer ? (
85
+ <View
86
+ style={{
87
+ flexDirection: 'row',
88
+ gap: 8,
89
+ justifyContent: 'flex-end',
90
+ padding: 12,
91
+ borderTopWidth: 1,
92
+ borderTopColor: theme.colors.border,
93
+ }}
94
+ >
95
+ {footer}
96
+ </View>
97
+ ) : null}
98
+ </View>
99
+ </Pressable>
100
+ </Pressable>
101
+ </RNModal>
102
+ );
103
+ });
@@ -0,0 +1,15 @@
1
+ import { type ReactNode } from 'react';
2
+ import { Menu, type MenuItem } from './Menu';
3
+
4
+ export interface DropdownItem extends MenuItem {}
5
+
6
+ export interface DropdownProps {
7
+ trigger: ReactNode;
8
+ items: DropdownItem[];
9
+ onSelect?: (value: string) => void;
10
+ }
11
+
12
+ /** Dropdown — alias for Menu on RN where both use a modal sheet. */
13
+ export function Dropdown(props: DropdownProps) {
14
+ return <Menu {...props} />;
15
+ }
@@ -0,0 +1,31 @@
1
+ import { createContext, useContext, useMemo, type ReactNode } from 'react';
2
+ import type { ElvoraTheme, Direction } from '@elvora/core';
3
+ import { defaultTheme } from '@elvora/themes';
4
+
5
+ interface ElvoraContextValue {
6
+ theme: ElvoraTheme;
7
+ direction: Direction;
8
+ }
9
+
10
+ const ElvoraContext = createContext<ElvoraContextValue | null>(null);
11
+
12
+ export interface ElvoraProviderProps {
13
+ theme?: ElvoraTheme;
14
+ direction?: Direction;
15
+ children: ReactNode;
16
+ }
17
+
18
+ export function ElvoraProvider({ theme = defaultTheme, direction = 'ltr', children }: ElvoraProviderProps) {
19
+ const value = useMemo<ElvoraContextValue>(() => ({ theme, direction }), [theme, direction]);
20
+ return <ElvoraContext.Provider value={value}>{children}</ElvoraContext.Provider>;
21
+ }
22
+
23
+ export function useElvoraContext(): ElvoraContextValue {
24
+ const ctx = useContext(ElvoraContext);
25
+ if (!ctx) return { theme: defaultTheme, direction: 'ltr' };
26
+ return ctx;
27
+ }
28
+
29
+ export function useTheme(): ElvoraTheme {
30
+ return useElvoraContext().theme;
31
+ }
package/src/Empty.tsx ADDED
@@ -0,0 +1,34 @@
1
+ import { forwardRef, type ReactNode } from 'react';
2
+ import { View, Text, type ViewProps } from 'react-native';
3
+ import { useTheme } from './ElvoraProvider';
4
+ import { Icon } from './Icon';
5
+
6
+ export interface EmptyProps extends ViewProps {
7
+ title?: ReactNode;
8
+ description?: ReactNode;
9
+ icon?: ReactNode;
10
+ action?: ReactNode;
11
+ }
12
+
13
+ /** Empty — placeholder for empty/no-data states. */
14
+ export const Empty = forwardRef<View, EmptyProps>(function Empty(props, ref) {
15
+ const { title = 'No data', description, icon, action, style, ...rest } = props;
16
+ const theme = useTheme();
17
+ return (
18
+ <View
19
+ ref={ref}
20
+ style={[
21
+ { alignItems: 'center', justifyContent: 'center', paddingVertical: 40, paddingHorizontal: 16, gap: 12 },
22
+ style,
23
+ ]}
24
+ {...rest}
25
+ >
26
+ <View>{icon ?? <Icon name="search" size={48} color={theme.colors.fgSubtle} />}</View>
27
+ {title ? <Text style={{ color: theme.colors.fg, fontSize: 16, fontWeight: '600' }}>{title}</Text> : null}
28
+ {description ? (
29
+ <Text style={{ color: theme.colors.fgMuted, fontSize: 14, textAlign: 'center' }}>{description}</Text>
30
+ ) : null}
31
+ {action}
32
+ </View>
33
+ );
34
+ });
@@ -0,0 +1,78 @@
1
+ import type { ReactNode } from 'react';
2
+ import { Pressable, Text, type ViewStyle } from 'react-native';
3
+ import { useTheme } from './ElvoraProvider';
4
+
5
+ export interface FloatButtonProps {
6
+ icon?: ReactNode;
7
+ label?: string;
8
+ size?: number;
9
+ right?: number;
10
+ bottom?: number;
11
+ tone?: 'primary' | 'neutral' | 'danger' | 'success';
12
+ extended?: boolean;
13
+ onPress?: () => void;
14
+ isDisabled?: boolean;
15
+ accessibilityLabel?: string;
16
+ style?: ViewStyle;
17
+ }
18
+
19
+ export function FloatButton(props: FloatButtonProps) {
20
+ const {
21
+ icon,
22
+ label,
23
+ size = 56,
24
+ right = 24,
25
+ bottom = 24,
26
+ tone = 'primary',
27
+ extended,
28
+ onPress,
29
+ isDisabled,
30
+ accessibilityLabel,
31
+ style,
32
+ } = props;
33
+ const theme = useTheme();
34
+ const intent =
35
+ tone === 'neutral'
36
+ ? theme.colors.intent.neutral
37
+ : tone === 'danger'
38
+ ? theme.colors.intent.danger
39
+ : tone === 'success'
40
+ ? theme.colors.intent.success
41
+ : theme.colors.intent.primary;
42
+ return (
43
+ <Pressable
44
+ accessibilityRole="button"
45
+ accessibilityLabel={accessibilityLabel ?? label}
46
+ disabled={isDisabled}
47
+ onPress={onPress}
48
+ style={({ pressed }) => [
49
+ {
50
+ position: 'absolute',
51
+ right,
52
+ bottom,
53
+ height: size,
54
+ minWidth: size,
55
+ paddingHorizontal: extended ? 20 : 0,
56
+ borderRadius: extended ? size / 2 : size / 2,
57
+ backgroundColor: intent.solid,
58
+ alignItems: 'center',
59
+ justifyContent: 'center',
60
+ flexDirection: 'row',
61
+ opacity: isDisabled ? 0.6 : pressed ? 0.85 : 1,
62
+ gap: 8,
63
+ shadowColor: '#000',
64
+ shadowOpacity: 0.25,
65
+ shadowOffset: { width: 0, height: 4 },
66
+ shadowRadius: 8,
67
+ elevation: 6,
68
+ },
69
+ style,
70
+ ]}
71
+ >
72
+ {icon}
73
+ {extended && label ? (
74
+ <Text style={{ color: intent.solidFg, fontSize: 14, fontWeight: '500' }}>{label}</Text>
75
+ ) : null}
76
+ </Pressable>
77
+ );
78
+ }
package/src/Form.tsx ADDED
@@ -0,0 +1,119 @@
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useMemo,
6
+ useState,
7
+ type ReactNode,
8
+ } from 'react';
9
+ import { View, Text, type ViewProps } from 'react-native';
10
+ import { useTheme } from './ElvoraProvider';
11
+ import { Label } from './Label';
12
+
13
+ type FieldErrors = Record<string, string | undefined>;
14
+
15
+ interface FormContextValue {
16
+ layout: 'vertical' | 'horizontal';
17
+ labelWidth?: number;
18
+ errors: FieldErrors;
19
+ setFieldError: (name: string, error: string | undefined) => void;
20
+ }
21
+
22
+ const FormContext = createContext<FormContextValue | null>(null);
23
+
24
+ function useFormContext(): FormContextValue {
25
+ return (
26
+ useContext(FormContext) ?? {
27
+ layout: 'vertical',
28
+ labelWidth: undefined,
29
+ errors: {},
30
+ setFieldError: () => {},
31
+ }
32
+ );
33
+ }
34
+
35
+ export interface FormProps extends ViewProps {
36
+ layout?: 'vertical' | 'horizontal';
37
+ labelWidth?: number;
38
+ children?: ReactNode;
39
+ }
40
+
41
+ /** Form — RN container that owns shared layout + per-field error state. */
42
+ export function Form(props: FormProps) {
43
+ const { layout = 'vertical', labelWidth, children, ...rest } = props;
44
+ const [errors, setErrors] = useState<FieldErrors>({});
45
+ const setFieldError = useCallback((name: string, error: string | undefined) => {
46
+ setErrors((prev) => {
47
+ if (prev[name] === error) return prev;
48
+ const next = { ...prev };
49
+ if (error === undefined) delete next[name];
50
+ else next[name] = error;
51
+ return next;
52
+ });
53
+ }, []);
54
+ const ctx = useMemo<FormContextValue>(
55
+ () => ({ layout, labelWidth, errors, setFieldError }),
56
+ [layout, labelWidth, errors, setFieldError],
57
+ );
58
+ return (
59
+ <FormContext.Provider value={ctx}>
60
+ <View {...rest}>{children}</View>
61
+ </FormContext.Provider>
62
+ );
63
+ }
64
+
65
+ export interface FormFieldProps extends ViewProps {
66
+ name: string;
67
+ label?: ReactNode;
68
+ hint?: ReactNode;
69
+ error?: string;
70
+ isRequired?: boolean;
71
+ children?: ReactNode;
72
+ }
73
+
74
+ export function FormField(props: FormFieldProps) {
75
+ const { name, label, hint, error, isRequired, children, style, ...rest } = props;
76
+ const ctx = useFormContext();
77
+ const theme = useTheme();
78
+ const displayedError = error ?? ctx.errors[name];
79
+ const isHorizontal = ctx.layout === 'horizontal';
80
+
81
+ return (
82
+ <View
83
+ style={[
84
+ {
85
+ flexDirection: isHorizontal ? 'row' : 'column',
86
+ marginBottom: 16,
87
+ gap: isHorizontal ? 12 : 6,
88
+ },
89
+ style,
90
+ ]}
91
+ {...rest}
92
+ >
93
+ {label ? (
94
+ <Label isRequired={isRequired} style={isHorizontal ? { width: ctx.labelWidth ?? 120, paddingTop: 8 } : undefined}>
95
+ {label}
96
+ </Label>
97
+ ) : null}
98
+ <View style={{ flex: 1 }}>
99
+ {children}
100
+ {hint && !displayedError ? (
101
+ <Text style={{ marginTop: 4, fontSize: 12, color: theme.colors.fgMuted }}>{hint}</Text>
102
+ ) : null}
103
+ {displayedError ? (
104
+ <Text accessibilityRole="alert" style={{ marginTop: 4, fontSize: 12, color: theme.colors.intent.danger.fg }}>
105
+ {displayedError}
106
+ </Text>
107
+ ) : null}
108
+ </View>
109
+ </View>
110
+ );
111
+ }
112
+
113
+ export function useFormField(name: string): { error: string | undefined; setError: (e: string | undefined) => void } {
114
+ const ctx = useFormContext();
115
+ return {
116
+ error: ctx.errors[name],
117
+ setError: (e) => ctx.setFieldError(name, e),
118
+ };
119
+ }
package/src/Grid.tsx ADDED
@@ -0,0 +1,68 @@
1
+ import { forwardRef, type ReactElement, type ReactNode } from 'react';
2
+ import { View, type ViewProps, type ViewStyle } from 'react-native';
3
+
4
+ export interface GridProps extends ViewProps {
5
+ /** Number of columns. */
6
+ columns?: number;
7
+ /** Gap between cells. */
8
+ gap?: number;
9
+ children?: ReactNode;
10
+ }
11
+
12
+ export interface GridItemProps extends ViewProps {
13
+ /** Number of columns this item spans. */
14
+ colSpan?: number;
15
+ children?: ReactNode;
16
+ }
17
+
18
+ /**
19
+ * Grid — simulates CSS Grid by laying out children in equal-width columns via
20
+ * flexbox rows. Pure React Native (no native grid module needed).
21
+ */
22
+ export const Grid = forwardRef<View, GridProps>(function Grid(props, ref) {
23
+ const { columns = 12, gap = 8, style, children, ...rest } = props;
24
+ const childArray = Array.isArray(children) ? children : children == null ? [] : [children];
25
+ const rows: ReactElement[][] = [];
26
+ let currentRow: ReactElement[] = [];
27
+ let usedCols = 0;
28
+ for (const child of childArray) {
29
+ if (!child) continue;
30
+ const span = (((child as ReactElement).props as Record<string, unknown> | undefined)?.colSpan as number) ?? 1;
31
+ if (usedCols + span > columns) {
32
+ rows.push(currentRow);
33
+ currentRow = [];
34
+ usedCols = 0;
35
+ }
36
+ currentRow.push(child as ReactElement);
37
+ usedCols += span;
38
+ }
39
+ if (currentRow.length) rows.push(currentRow);
40
+ return (
41
+ <View ref={ref} style={[{ gap }, style]} {...rest}>
42
+ {rows.map((row, i) => (
43
+ <View key={i} style={{ flexDirection: 'row', gap }}>
44
+ {row.map((child, j) => {
45
+ const span = (((child.props as Record<string, unknown>)?.colSpan as number) ?? 1);
46
+ const flexStyle: ViewStyle = { flex: span };
47
+ return (
48
+ <View key={j} style={flexStyle}>
49
+ {child}
50
+ </View>
51
+ );
52
+ })}
53
+ </View>
54
+ ))}
55
+ </View>
56
+ );
57
+ });
58
+
59
+ /** GridItem — the colSpan is read by the parent Grid. */
60
+ export const GridItem = forwardRef<View, GridItemProps>(function GridItem(props, ref) {
61
+ const { colSpan: _colSpan, style, children, ...rest } = props;
62
+ void _colSpan;
63
+ return (
64
+ <View ref={ref} style={style} {...rest}>
65
+ {children}
66
+ </View>
67
+ );
68
+ });
package/src/Icon.tsx ADDED
@@ -0,0 +1,49 @@
1
+ import { forwardRef } from 'react';
2
+ import { getIcon, icons, type IconName } from '@elvora/icons';
3
+ import Svg, { Path } from 'react-native-svg';
4
+
5
+ export interface IconProps {
6
+ /** Icon name from `@elvora/icons`. */
7
+ name: IconName;
8
+ /** Pixel size for both width/height. Defaults to 16. */
9
+ size?: number;
10
+ /** Stroke or fill color. Defaults to `currentColor`-equivalent (black). */
11
+ color?: string;
12
+ /** Accessible label. When omitted, the icon is treated as decorative. */
13
+ label?: string;
14
+ }
15
+
16
+ /**
17
+ * Renders an icon from the Elvora icon set using `react-native-svg`. Install
18
+ * `react-native-svg` in your app for this to work.
19
+ */
20
+ export const Icon = forwardRef<unknown, IconProps>(function Icon(props, _ref) {
21
+ const { name, size = 16, color = '#000', label } = props;
22
+ if (!(name in icons)) {
23
+ if (typeof console !== 'undefined') {
24
+ console.warn(`[elvora] Icon "${String(name)}" is not registered.`);
25
+ }
26
+ return null;
27
+ }
28
+ const def = getIcon(name);
29
+ const a11y = label
30
+ ? { accessible: true, accessibilityRole: 'image' as const, accessibilityLabel: label }
31
+ : { accessible: false, accessibilityElementsHidden: true, importantForAccessibility: 'no-hide-descendants' as const };
32
+
33
+ if (def.stroke) {
34
+ return (
35
+ <Svg width={size} height={size} viewBox="0 0 24 24" fill="none" {...a11y}>
36
+ {def.d2 ? (
37
+ <Path d={def.d2} stroke={color} strokeWidth={def.strokeWidth ?? 2} strokeLinecap="round" strokeLinejoin="round" />
38
+ ) : null}
39
+ <Path d={def.d} stroke={color} strokeWidth={def.strokeWidth ?? 2} strokeLinecap="round" strokeLinejoin="round" />
40
+ </Svg>
41
+ );
42
+ }
43
+ return (
44
+ <Svg width={size} height={size} viewBox="0 0 24 24" fill={color} {...a11y}>
45
+ {def.d2 ? <Path d={def.d2} /> : null}
46
+ <Path d={def.d} />
47
+ </Svg>
48
+ );
49
+ });
@@ -0,0 +1,28 @@
1
+ import { forwardRef, type ReactNode } from 'react';
2
+ import { type View } from 'react-native';
3
+ import type { ElvoraSize } from '@elvora/core';
4
+ import { Button, type ButtonProps } from './Button';
5
+
6
+ export interface IconButtonProps extends Omit<ButtonProps, 'children' | 'leftIcon' | 'rightIcon'> {
7
+ icon: ReactNode;
8
+ /** Required: announce icon-only button to screen readers. */
9
+ accessibilityLabel: string;
10
+ }
11
+
12
+ const sizePx: Record<ElvoraSize, number> = { xs: 28, sm: 36, md: 44, lg: 52, xl: 60 };
13
+
14
+ export const IconButton = forwardRef<View, IconButtonProps>(function IconButton(props, ref) {
15
+ const { icon, size = 'md', accessibilityLabel, style, ...rest } = props;
16
+ const px = sizePx[size];
17
+ return (
18
+ <Button
19
+ ref={ref}
20
+ size={size}
21
+ accessibilityLabel={accessibilityLabel}
22
+ style={[{ width: px, minHeight: px, paddingHorizontal: 0 }, style]}
23
+ {...rest}
24
+ >
25
+ {icon}
26
+ </Button>
27
+ );
28
+ });