@elvora/react-native 1.0.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +40 -0
- package/dist/index.cjs +5785 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +1253 -0
- package/dist/index.d.ts +1253 -0
- package/dist/index.js +5683 -0
- package/dist/index.js.map +1 -0
- package/package.json +88 -0
- package/src/Accordion.tsx +11 -0
- package/src/Affix.tsx +20 -0
- package/src/Alert.tsx +102 -0
- package/src/Anchor.tsx +58 -0
- package/src/AutoComplete.tsx +122 -0
- package/src/Avatar.tsx +58 -0
- package/src/BackTop.tsx +71 -0
- package/src/Backdrop.tsx +32 -0
- package/src/Badge.tsx +87 -0
- package/src/Box.tsx +67 -0
- package/src/Breadcrumb.tsx +46 -0
- package/src/Button.test.tsx +39 -0
- package/src/Button.tsx +127 -0
- package/src/ButtonGroup.tsx +74 -0
- package/src/Calendar.tsx +165 -0
- package/src/Card.tsx +69 -0
- package/src/Carousel.tsx +99 -0
- package/src/Cascader.tsx +160 -0
- package/src/Checkbox.tsx +85 -0
- package/src/ChipInput.tsx +130 -0
- package/src/Collapse.tsx +120 -0
- package/src/ColorPicker.tsx +114 -0
- package/src/Container.tsx +22 -0
- package/src/DataGrid.tsx +170 -0
- package/src/DatePicker.tsx +195 -0
- package/src/DateRangePicker.tsx +249 -0
- package/src/Descriptions.tsx +98 -0
- package/src/Divider.tsx +32 -0
- package/src/Drawer.tsx +103 -0
- package/src/Dropdown.tsx +15 -0
- package/src/ElvoraProvider.tsx +31 -0
- package/src/Empty.tsx +34 -0
- package/src/FloatButton.tsx +78 -0
- package/src/Form.tsx +119 -0
- package/src/Grid.tsx +68 -0
- package/src/Icon.tsx +49 -0
- package/src/IconButton.tsx +28 -0
- package/src/Image.tsx +68 -0
- package/src/ImageList.tsx +58 -0
- package/src/Input.tsx +87 -0
- package/src/Label.tsx +46 -0
- package/src/List.tsx +82 -0
- package/src/Mentions.tsx +148 -0
- package/src/Menu.tsx +77 -0
- package/src/Modal.tsx +114 -0
- package/src/NumberInput.tsx +156 -0
- package/src/Pagination.tsx +148 -0
- package/src/PaginationVariants.tsx +64 -0
- package/src/Popover.tsx +74 -0
- package/src/ProForm.tsx +219 -0
- package/src/ProLayout.tsx +151 -0
- package/src/ProTable.tsx +91 -0
- package/src/Progress.tsx +92 -0
- package/src/QRCode.tsx +65 -0
- package/src/Radio.tsx +98 -0
- package/src/Rate.tsx +66 -0
- package/src/Result.tsx +64 -0
- package/src/Segmented.tsx +75 -0
- package/src/Select.tsx +146 -0
- package/src/Skeleton.tsx +49 -0
- package/src/Slider.tsx +122 -0
- package/src/SpeedDial.tsx +87 -0
- package/src/Spinner.tsx +29 -0
- package/src/Splitter.tsx +91 -0
- package/src/Stack.tsx +38 -0
- package/src/Statistic.tsx +60 -0
- package/src/Stepper.tsx +113 -0
- package/src/Steps.tsx +146 -0
- package/src/Switch.tsx +52 -0
- package/src/Table.tsx +178 -0
- package/src/Tabs.tsx +122 -0
- package/src/Tag.tsx +83 -0
- package/src/Textarea.tsx +22 -0
- package/src/TimePicker.tsx +187 -0
- package/src/Timeline.tsx +92 -0
- package/src/Toast.tsx +140 -0
- package/src/ToggleButton.tsx +66 -0
- package/src/Tooltip.tsx +56 -0
- package/src/Tour.tsx +118 -0
- package/src/Transfer.tsx +219 -0
- package/src/Tree.tsx +144 -0
- package/src/TreeSelect.tsx +221 -0
- package/src/Upload.tsx +109 -0
- package/src/Watermark.tsx +76 -0
- package/src/index.ts +221 -0
- package/src/smoke.test.tsx +113 -0
- package/src/test/react-native-stub.tsx +413 -0
- package/src/test/react-native-svg-stub.tsx +33 -0
- package/src/test/setup.ts +7 -0
|
@@ -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
|
+
}
|
package/src/Divider.tsx
ADDED
|
@@ -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
|
+
});
|
package/src/Dropdown.tsx
ADDED
|
@@ -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
|
+
});
|