@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
package/src/Box.tsx
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { forwardRef } from 'react';
|
|
2
|
+
import { View, type ViewProps, type ViewStyle, type StyleProp } from 'react-native';
|
|
3
|
+
|
|
4
|
+
export interface BoxProps extends ViewProps {
|
|
5
|
+
p?: number;
|
|
6
|
+
px?: number;
|
|
7
|
+
py?: number;
|
|
8
|
+
pt?: number;
|
|
9
|
+
pr?: number;
|
|
10
|
+
pb?: number;
|
|
11
|
+
pl?: number;
|
|
12
|
+
m?: number;
|
|
13
|
+
mx?: number;
|
|
14
|
+
my?: number;
|
|
15
|
+
mt?: number;
|
|
16
|
+
mr?: number;
|
|
17
|
+
mb?: number;
|
|
18
|
+
ml?: number;
|
|
19
|
+
w?: number | string;
|
|
20
|
+
h?: number | string;
|
|
21
|
+
bg?: string;
|
|
22
|
+
rounded?: number;
|
|
23
|
+
shadow?: ViewStyle;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function resolveStyle(props: BoxProps): ViewStyle {
|
|
27
|
+
const s: ViewStyle = {};
|
|
28
|
+
if (props.p !== undefined) s.padding = props.p;
|
|
29
|
+
if (props.px !== undefined) s.paddingHorizontal = props.px;
|
|
30
|
+
if (props.py !== undefined) s.paddingVertical = props.py;
|
|
31
|
+
if (props.pt !== undefined) s.paddingTop = props.pt;
|
|
32
|
+
if (props.pr !== undefined) s.paddingRight = props.pr;
|
|
33
|
+
if (props.pb !== undefined) s.paddingBottom = props.pb;
|
|
34
|
+
if (props.pl !== undefined) s.paddingLeft = props.pl;
|
|
35
|
+
if (props.m !== undefined) s.margin = props.m;
|
|
36
|
+
if (props.mx !== undefined) s.marginHorizontal = props.mx;
|
|
37
|
+
if (props.my !== undefined) s.marginVertical = props.my;
|
|
38
|
+
if (props.mt !== undefined) s.marginTop = props.mt;
|
|
39
|
+
if (props.mr !== undefined) s.marginRight = props.mr;
|
|
40
|
+
if (props.mb !== undefined) s.marginBottom = props.mb;
|
|
41
|
+
if (props.ml !== undefined) s.marginLeft = props.ml;
|
|
42
|
+
if (props.w !== undefined) s.width = props.w as ViewStyle['width'];
|
|
43
|
+
if (props.h !== undefined) s.height = props.h as ViewStyle['height'];
|
|
44
|
+
if (props.bg !== undefined) s.backgroundColor = props.bg;
|
|
45
|
+
if (props.rounded !== undefined) s.borderRadius = props.rounded;
|
|
46
|
+
if (props.shadow !== undefined) Object.assign(s, props.shadow);
|
|
47
|
+
return s;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Box — universal layout primitive for RN. Accepts shorthand style props. */
|
|
51
|
+
export const Box = forwardRef<View, BoxProps>(function Box(props, ref) {
|
|
52
|
+
const { style, children, ...rest } = props;
|
|
53
|
+
const resolved = resolveStyle(rest);
|
|
54
|
+
const passthrough: ViewProps = { ...rest };
|
|
55
|
+
for (const k of [
|
|
56
|
+
'p','px','py','pt','pr','pb','pl','m','mx','my','mt','mr','mb','ml',
|
|
57
|
+
'w','h','bg','rounded','shadow',
|
|
58
|
+
] as const) {
|
|
59
|
+
delete (passthrough as Record<string, unknown>)[k];
|
|
60
|
+
}
|
|
61
|
+
const composed: StyleProp<ViewStyle> = [resolved, style];
|
|
62
|
+
return (
|
|
63
|
+
<View ref={ref} style={composed} {...passthrough}>
|
|
64
|
+
{children}
|
|
65
|
+
</View>
|
|
66
|
+
);
|
|
67
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Fragment, type ReactNode } from 'react';
|
|
2
|
+
import { Pressable, Text, View, type ViewProps } from 'react-native';
|
|
3
|
+
import { useTheme } from './ElvoraProvider';
|
|
4
|
+
import { Icon } from './Icon';
|
|
5
|
+
|
|
6
|
+
export interface BreadcrumbItem {
|
|
7
|
+
label: string;
|
|
8
|
+
onPress?: () => void;
|
|
9
|
+
isCurrent?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface BreadcrumbProps extends ViewProps {
|
|
13
|
+
items: BreadcrumbItem[];
|
|
14
|
+
separator?: ReactNode;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Breadcrumb — navigation trail for RN. */
|
|
18
|
+
export function Breadcrumb(props: BreadcrumbProps) {
|
|
19
|
+
const { items, separator, style, ...rest } = props;
|
|
20
|
+
const theme = useTheme();
|
|
21
|
+
const sep = separator ?? <Icon name="chevronRight" size={14} color={theme.colors.fgSubtle} />;
|
|
22
|
+
return (
|
|
23
|
+
<View
|
|
24
|
+
accessibilityRole="header"
|
|
25
|
+
style={[{ flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap', gap: 6 }, style]}
|
|
26
|
+
{...rest}
|
|
27
|
+
>
|
|
28
|
+
{items.map((item, idx) => {
|
|
29
|
+
const isLast = idx === items.length - 1;
|
|
30
|
+
const current = item.isCurrent ?? isLast;
|
|
31
|
+
const color = current ? theme.colors.fg : theme.colors.fgMuted;
|
|
32
|
+
const labelEl = (
|
|
33
|
+
<Text accessibilityRole={current ? 'text' : 'link'} style={{ color, fontSize: 14 }}>
|
|
34
|
+
{item.label}
|
|
35
|
+
</Text>
|
|
36
|
+
);
|
|
37
|
+
return (
|
|
38
|
+
<Fragment key={idx}>
|
|
39
|
+
{item.onPress && !current ? <Pressable onPress={item.onPress}>{labelEl}</Pressable> : labelEl}
|
|
40
|
+
{!isLast ? <View accessibilityElementsHidden>{sep}</View> : null}
|
|
41
|
+
</Fragment>
|
|
42
|
+
);
|
|
43
|
+
})}
|
|
44
|
+
</View>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
3
|
+
import type { ReactNode } from 'react';
|
|
4
|
+
import { ElvoraProvider } from './ElvoraProvider';
|
|
5
|
+
import { Button } from './Button';
|
|
6
|
+
|
|
7
|
+
function wrap(node: ReactNode) {
|
|
8
|
+
return render(<ElvoraProvider>{node}</ElvoraProvider>);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('<Button> (RN)', () => {
|
|
12
|
+
it('renders its label', () => {
|
|
13
|
+
wrap(<Button>Save</Button>);
|
|
14
|
+
expect(screen.getByText('Save')).toBeInTheDocument();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('fires onPress on click', () => {
|
|
18
|
+
const onPress = vi.fn();
|
|
19
|
+
wrap(<Button onPress={onPress}>Tap</Button>);
|
|
20
|
+
fireEvent.click(screen.getByRole('button'));
|
|
21
|
+
expect(onPress).toHaveBeenCalledTimes(1);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('respects isDisabled', () => {
|
|
25
|
+
const onPress = vi.fn();
|
|
26
|
+
wrap(
|
|
27
|
+
<Button onPress={onPress} isDisabled>
|
|
28
|
+
Disabled
|
|
29
|
+
</Button>,
|
|
30
|
+
);
|
|
31
|
+
fireEvent.click(screen.getByRole('button'));
|
|
32
|
+
expect(onPress).not.toHaveBeenCalled();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('announces loading state', () => {
|
|
36
|
+
wrap(<Button isLoading>Saving</Button>);
|
|
37
|
+
expect(screen.getByRole('button')).toHaveAttribute('aria-busy', 'true');
|
|
38
|
+
});
|
|
39
|
+
});
|
package/src/Button.tsx
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { forwardRef, useState, type ReactNode } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Pressable,
|
|
4
|
+
Text,
|
|
5
|
+
ActivityIndicator,
|
|
6
|
+
View,
|
|
7
|
+
type GestureResponderEvent,
|
|
8
|
+
type PressableProps,
|
|
9
|
+
type StyleProp,
|
|
10
|
+
type ViewStyle,
|
|
11
|
+
type TextStyle,
|
|
12
|
+
} from 'react-native';
|
|
13
|
+
import { defaultButtonProps, getButtonStyle, type ButtonOwnProps } from '@elvora/core';
|
|
14
|
+
import { useTheme } from './ElvoraProvider';
|
|
15
|
+
|
|
16
|
+
export interface ButtonProps extends ButtonOwnProps, Omit<PressableProps, 'children' | 'disabled' | 'style'> {
|
|
17
|
+
children?: ReactNode;
|
|
18
|
+
leftIcon?: ReactNode;
|
|
19
|
+
rightIcon?: ReactNode;
|
|
20
|
+
onPress?: (event: GestureResponderEvent) => void;
|
|
21
|
+
style?: StyleProp<ViewStyle>;
|
|
22
|
+
textStyle?: StyleProp<TextStyle>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* React Native Button. Same headless contract as the web Button, rendered
|
|
27
|
+
* with `Pressable`. Identical variants/sizes/states.
|
|
28
|
+
*/
|
|
29
|
+
export const Button = forwardRef<View, ButtonProps>(function Button(props, ref) {
|
|
30
|
+
const {
|
|
31
|
+
variant = defaultButtonProps.variant,
|
|
32
|
+
size = defaultButtonProps.size,
|
|
33
|
+
intent,
|
|
34
|
+
fullWidth = defaultButtonProps.fullWidth,
|
|
35
|
+
isLoading = defaultButtonProps.isLoading,
|
|
36
|
+
isDisabled = defaultButtonProps.isDisabled,
|
|
37
|
+
loadingText = defaultButtonProps.loadingText,
|
|
38
|
+
children,
|
|
39
|
+
leftIcon,
|
|
40
|
+
rightIcon,
|
|
41
|
+
onPress,
|
|
42
|
+
style,
|
|
43
|
+
textStyle,
|
|
44
|
+
...rest
|
|
45
|
+
} = props;
|
|
46
|
+
|
|
47
|
+
const theme = useTheme();
|
|
48
|
+
const [isPressed, setPressed] = useState(false);
|
|
49
|
+
|
|
50
|
+
const inert = isDisabled || isLoading;
|
|
51
|
+
|
|
52
|
+
const styleResult = getButtonStyle({
|
|
53
|
+
theme,
|
|
54
|
+
variant,
|
|
55
|
+
size,
|
|
56
|
+
fullWidth,
|
|
57
|
+
isLoading,
|
|
58
|
+
isDisabled,
|
|
59
|
+
isPressed,
|
|
60
|
+
isHovered: false,
|
|
61
|
+
isFocusVisible: false,
|
|
62
|
+
intent,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Translate the headless web-style descriptor into RN-friendly ViewStyle.
|
|
66
|
+
const rootStyle: ViewStyle = {
|
|
67
|
+
flexDirection: 'row',
|
|
68
|
+
alignItems: 'center',
|
|
69
|
+
justifyContent: 'center',
|
|
70
|
+
gap: Number(styleResult.root.gap) || 8,
|
|
71
|
+
paddingHorizontal: Number(styleResult.root.paddingLeft) || 16,
|
|
72
|
+
paddingVertical: Number(styleResult.root.paddingTop) || 8,
|
|
73
|
+
minHeight: Number(styleResult.root.minHeight) || 40,
|
|
74
|
+
borderRadius: parseInt(String(styleResult.root.borderRadius), 10) || 6,
|
|
75
|
+
borderWidth: Number(styleResult.root.borderWidth) || 0,
|
|
76
|
+
borderColor: String(styleResult.root.borderColor || 'transparent'),
|
|
77
|
+
backgroundColor: String(styleResult.root.backgroundColor || 'transparent'),
|
|
78
|
+
opacity: Number(styleResult.root.opacity) || 1,
|
|
79
|
+
alignSelf: fullWidth ? 'stretch' : 'flex-start',
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<Pressable
|
|
84
|
+
ref={ref}
|
|
85
|
+
accessibilityRole="button"
|
|
86
|
+
accessibilityState={{ disabled: inert, busy: isLoading }}
|
|
87
|
+
accessibilityLabel={isLoading ? loadingText : undefined}
|
|
88
|
+
disabled={inert}
|
|
89
|
+
onPressIn={(e) => {
|
|
90
|
+
setPressed(true);
|
|
91
|
+
rest.onPressIn?.(e);
|
|
92
|
+
}}
|
|
93
|
+
onPressOut={(e) => {
|
|
94
|
+
setPressed(false);
|
|
95
|
+
rest.onPressOut?.(e);
|
|
96
|
+
}}
|
|
97
|
+
onPress={(e) => {
|
|
98
|
+
if (inert) return;
|
|
99
|
+
onPress?.(e);
|
|
100
|
+
}}
|
|
101
|
+
style={[rootStyle, style]}
|
|
102
|
+
{...rest}
|
|
103
|
+
>
|
|
104
|
+
{isLoading ? (
|
|
105
|
+
<View style={{ position: 'absolute', alignItems: 'center', justifyContent: 'center' }}>
|
|
106
|
+
<ActivityIndicator size="small" color={styleResult.spinnerColor} />
|
|
107
|
+
</View>
|
|
108
|
+
) : null}
|
|
109
|
+
{leftIcon}
|
|
110
|
+
<Text
|
|
111
|
+
accessible={false}
|
|
112
|
+
style={[
|
|
113
|
+
{
|
|
114
|
+
color: String(styleResult.label.color),
|
|
115
|
+
fontSize: Number(styleResult.label.fontSize) || 14,
|
|
116
|
+
fontWeight: String(styleResult.label.fontWeight) as TextStyle['fontWeight'],
|
|
117
|
+
opacity: Number(styleResult.label.opacity) ?? 1,
|
|
118
|
+
},
|
|
119
|
+
textStyle,
|
|
120
|
+
]}
|
|
121
|
+
>
|
|
122
|
+
{children}
|
|
123
|
+
</Text>
|
|
124
|
+
{rightIcon}
|
|
125
|
+
</Pressable>
|
|
126
|
+
);
|
|
127
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { Children, cloneElement, isValidElement, useCallback, useState, type ReactElement, type ReactNode } from 'react';
|
|
2
|
+
import { View, type ViewStyle } from 'react-native';
|
|
3
|
+
import type { ToggleButtonProps } from './ToggleButton';
|
|
4
|
+
|
|
5
|
+
export type ButtonGroupOrientation = 'horizontal' | 'vertical';
|
|
6
|
+
export type ButtonGroupSelectionMode = 'none' | 'single' | 'multiple';
|
|
7
|
+
|
|
8
|
+
export interface ButtonGroupProps {
|
|
9
|
+
orientation?: ButtonGroupOrientation;
|
|
10
|
+
mode?: ButtonGroupSelectionMode;
|
|
11
|
+
value?: string | string[] | null;
|
|
12
|
+
defaultValue?: string | string[] | null;
|
|
13
|
+
onChange?: (value: string | string[] | null) => void;
|
|
14
|
+
allowDeselect?: boolean;
|
|
15
|
+
style?: ViewStyle;
|
|
16
|
+
children?: ReactNode;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function ButtonGroup(props: ButtonGroupProps) {
|
|
20
|
+
const {
|
|
21
|
+
orientation = 'horizontal',
|
|
22
|
+
mode = 'none',
|
|
23
|
+
value: valueProp,
|
|
24
|
+
defaultValue,
|
|
25
|
+
onChange,
|
|
26
|
+
allowDeselect = true,
|
|
27
|
+
style,
|
|
28
|
+
children,
|
|
29
|
+
} = props;
|
|
30
|
+
const [internal, setInternal] = useState<string | string[] | null>(
|
|
31
|
+
defaultValue ?? (mode === 'multiple' ? [] : null),
|
|
32
|
+
);
|
|
33
|
+
const value = valueProp !== undefined ? valueProp : internal;
|
|
34
|
+
const setValue = useCallback(
|
|
35
|
+
(next: string | string[] | null) => {
|
|
36
|
+
if (valueProp === undefined) setInternal(next);
|
|
37
|
+
onChange?.(next);
|
|
38
|
+
},
|
|
39
|
+
[valueProp, onChange],
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const handleToggle = useCallback(
|
|
43
|
+
(btnValue: string, selected: boolean) => {
|
|
44
|
+
if (mode === 'single') {
|
|
45
|
+
if (selected) setValue(btnValue);
|
|
46
|
+
else if (allowDeselect) setValue(null);
|
|
47
|
+
} else if (mode === 'multiple') {
|
|
48
|
+
const arr = Array.isArray(value) ? value : [];
|
|
49
|
+
if (selected) setValue([...new Set([...arr, btnValue])]);
|
|
50
|
+
else setValue(arr.filter((v) => v !== btnValue));
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
[mode, value, allowDeselect, setValue],
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<View
|
|
58
|
+
style={[{ flexDirection: orientation === 'horizontal' ? 'row' : 'column', gap: 4 }, style]}
|
|
59
|
+
>
|
|
60
|
+
{Children.map(children, (child) => {
|
|
61
|
+
if (!isValidElement(child)) return child;
|
|
62
|
+
const props = child.props as ToggleButtonProps;
|
|
63
|
+
if (mode === 'none' || props.value === undefined) return child;
|
|
64
|
+
const btnValue = props.value;
|
|
65
|
+
const isSelected =
|
|
66
|
+
mode === 'single' ? value === btnValue : Array.isArray(value) && value.includes(btnValue);
|
|
67
|
+
return cloneElement(child as ReactElement<ToggleButtonProps>, {
|
|
68
|
+
selected: isSelected,
|
|
69
|
+
onChange: (s: boolean) => handleToggle(btnValue, s),
|
|
70
|
+
});
|
|
71
|
+
})}
|
|
72
|
+
</View>
|
|
73
|
+
);
|
|
74
|
+
}
|
package/src/Calendar.tsx
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
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 CalendarProps extends Omit<ViewProps, 'children'> {
|
|
6
|
+
value?: Date;
|
|
7
|
+
defaultValue?: Date;
|
|
8
|
+
onChange?: (date: Date) => void;
|
|
9
|
+
viewMonth?: Date;
|
|
10
|
+
onViewMonthChange?: (date: Date) => void;
|
|
11
|
+
dateCellRender?: (date: Date) => ReactNode;
|
|
12
|
+
isDateDisabled?: (date: Date) => boolean;
|
|
13
|
+
weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
|
14
|
+
locale?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function startOfMonth(d: Date): Date {
|
|
18
|
+
return new Date(d.getFullYear(), d.getMonth(), 1);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function addMonths(d: Date, n: number): Date {
|
|
22
|
+
return new Date(d.getFullYear(), d.getMonth() + n, 1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isSameDay(a: Date, b: Date): boolean {
|
|
26
|
+
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isSameMonth(a: Date, b: Date): boolean {
|
|
30
|
+
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function buildMonthMatrix(view: Date, weekStartsOn: number): Date[][] {
|
|
34
|
+
const first = startOfMonth(view);
|
|
35
|
+
const offset = (first.getDay() - weekStartsOn + 7) % 7;
|
|
36
|
+
const start = new Date(first);
|
|
37
|
+
start.setDate(first.getDate() - offset);
|
|
38
|
+
const weeks: Date[][] = [];
|
|
39
|
+
let cursor = new Date(start);
|
|
40
|
+
for (let w = 0; w < 6; w++) {
|
|
41
|
+
const row: Date[] = [];
|
|
42
|
+
for (let d = 0; d < 7; d++) {
|
|
43
|
+
row.push(new Date(cursor));
|
|
44
|
+
cursor.setDate(cursor.getDate() + 1);
|
|
45
|
+
}
|
|
46
|
+
weeks.push(row);
|
|
47
|
+
}
|
|
48
|
+
return weeks;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function Calendar(props: CalendarProps) {
|
|
52
|
+
const {
|
|
53
|
+
value: valueProp,
|
|
54
|
+
defaultValue,
|
|
55
|
+
onChange,
|
|
56
|
+
viewMonth: viewMonthProp,
|
|
57
|
+
onViewMonthChange,
|
|
58
|
+
dateCellRender,
|
|
59
|
+
isDateDisabled,
|
|
60
|
+
weekStartsOn = 0,
|
|
61
|
+
locale,
|
|
62
|
+
style,
|
|
63
|
+
...rest
|
|
64
|
+
} = props;
|
|
65
|
+
const theme = useTheme();
|
|
66
|
+
const today = new Date();
|
|
67
|
+
const [internalValue, setInternalValue] = useState<Date | undefined>(defaultValue);
|
|
68
|
+
const [internalView, setInternalView] = useState<Date>(startOfMonth(valueProp ?? defaultValue ?? today));
|
|
69
|
+
const value = valueProp ?? internalValue;
|
|
70
|
+
const view = viewMonthProp ?? internalView;
|
|
71
|
+
|
|
72
|
+
const setView = (next: Date) => {
|
|
73
|
+
const start = startOfMonth(next);
|
|
74
|
+
if (viewMonthProp === undefined) setInternalView(start);
|
|
75
|
+
onViewMonthChange?.(start);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const setValue = (next: Date) => {
|
|
79
|
+
if (isDateDisabled?.(next)) return;
|
|
80
|
+
if (valueProp === undefined) setInternalValue(next);
|
|
81
|
+
onChange?.(next);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const dayLabels: string[] = [];
|
|
85
|
+
for (let i = 0; i < 7; i++) {
|
|
86
|
+
const d = new Date(2024, 0, 7 + i + weekStartsOn);
|
|
87
|
+
dayLabels.push(d.toLocaleDateString(locale, { weekday: 'short' }));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const monthLabel = view.toLocaleDateString(locale, { month: 'long', year: 'numeric' });
|
|
91
|
+
const weeks = buildMonthMatrix(view, weekStartsOn);
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<View
|
|
95
|
+
style={[
|
|
96
|
+
{
|
|
97
|
+
backgroundColor: theme.colors.surfaceElevated,
|
|
98
|
+
borderRadius: 8,
|
|
99
|
+
borderWidth: 1,
|
|
100
|
+
borderColor: theme.colors.border,
|
|
101
|
+
padding: 16,
|
|
102
|
+
minWidth: 280,
|
|
103
|
+
},
|
|
104
|
+
style,
|
|
105
|
+
]}
|
|
106
|
+
{...rest}
|
|
107
|
+
>
|
|
108
|
+
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
|
109
|
+
<Pressable accessibilityRole="button" accessibilityLabel="Previous month" onPress={() => setView(addMonths(view, -1))} style={{ padding: 8 }}>
|
|
110
|
+
<Text style={{ color: theme.colors.fg }}>{'<'}</Text>
|
|
111
|
+
</Pressable>
|
|
112
|
+
<Text style={{ color: theme.colors.fg, fontWeight: '600' }}>{monthLabel}</Text>
|
|
113
|
+
<Pressable accessibilityRole="button" accessibilityLabel="Next month" onPress={() => setView(addMonths(view, 1))} style={{ padding: 8 }}>
|
|
114
|
+
<Text style={{ color: theme.colors.fg }}>{'>'}</Text>
|
|
115
|
+
</Pressable>
|
|
116
|
+
</View>
|
|
117
|
+
<View style={{ flexDirection: 'row' }}>
|
|
118
|
+
{dayLabels.map((label) => (
|
|
119
|
+
<View key={label} style={{ flex: 1, alignItems: 'center', paddingVertical: 4 }}>
|
|
120
|
+
<Text style={{ color: theme.colors.fgSubtle, fontSize: 12, fontWeight: '500' }}>{label}</Text>
|
|
121
|
+
</View>
|
|
122
|
+
))}
|
|
123
|
+
</View>
|
|
124
|
+
{weeks.map((week, wi) => (
|
|
125
|
+
<View key={wi} style={{ flexDirection: 'row' }}>
|
|
126
|
+
{week.map((d) => {
|
|
127
|
+
const inMonth = isSameMonth(d, view);
|
|
128
|
+
const selected = value ? isSameDay(d, value) : false;
|
|
129
|
+
const isToday = isSameDay(d, today);
|
|
130
|
+
const disabled = isDateDisabled?.(d);
|
|
131
|
+
const bg = selected
|
|
132
|
+
? theme.colors.intent.primary.solid
|
|
133
|
+
: isToday
|
|
134
|
+
? theme.colors.intent.primary.subtle
|
|
135
|
+
: 'transparent';
|
|
136
|
+
const color = selected
|
|
137
|
+
? theme.colors.intent.primary.solidFg
|
|
138
|
+
: !inMonth
|
|
139
|
+
? theme.colors.fgSubtle
|
|
140
|
+
: theme.colors.fg;
|
|
141
|
+
return (
|
|
142
|
+
<Pressable
|
|
143
|
+
key={d.toISOString()}
|
|
144
|
+
disabled={disabled}
|
|
145
|
+
onPress={() => setValue(d)}
|
|
146
|
+
style={{
|
|
147
|
+
flex: 1,
|
|
148
|
+
alignItems: 'center',
|
|
149
|
+
paddingVertical: 8,
|
|
150
|
+
margin: 1,
|
|
151
|
+
borderRadius: 6,
|
|
152
|
+
backgroundColor: bg,
|
|
153
|
+
opacity: disabled ? 0.5 : 1,
|
|
154
|
+
}}
|
|
155
|
+
>
|
|
156
|
+
<Text style={{ color }}>{d.getDate()}</Text>
|
|
157
|
+
{dateCellRender ? <Text style={{ fontSize: 10, color }}>{dateCellRender(d) as string}</Text> : null}
|
|
158
|
+
</Pressable>
|
|
159
|
+
);
|
|
160
|
+
})}
|
|
161
|
+
</View>
|
|
162
|
+
))}
|
|
163
|
+
</View>
|
|
164
|
+
);
|
|
165
|
+
}
|
package/src/Card.tsx
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { forwardRef, type ReactNode } from 'react';
|
|
2
|
+
import { View, type ViewProps, type ViewStyle } from 'react-native';
|
|
3
|
+
import { useTheme } from './ElvoraProvider';
|
|
4
|
+
|
|
5
|
+
export interface CardProps extends ViewProps {
|
|
6
|
+
variant?: 'outline' | 'elevated' | 'filled';
|
|
7
|
+
padding?: number;
|
|
8
|
+
children?: ReactNode;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const elevatedShadow: ViewStyle = {
|
|
12
|
+
shadowColor: '#000',
|
|
13
|
+
shadowOpacity: 0.08,
|
|
14
|
+
shadowRadius: 6,
|
|
15
|
+
shadowOffset: { width: 0, height: 2 },
|
|
16
|
+
elevation: 3,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** Card — content container for RN. */
|
|
20
|
+
export const Card = forwardRef<View, CardProps>(function Card(props, ref) {
|
|
21
|
+
const { variant = 'outline', padding = 16, style, children, ...rest } = props;
|
|
22
|
+
const theme = useTheme();
|
|
23
|
+
const base: ViewStyle = {
|
|
24
|
+
backgroundColor: variant === 'filled' ? theme.colors.intent.neutral.subtle : theme.colors.surfaceElevated,
|
|
25
|
+
borderColor: theme.colors.border,
|
|
26
|
+
borderWidth: variant === 'outline' ? 1 : 0,
|
|
27
|
+
borderRadius: theme.radii.lg,
|
|
28
|
+
padding,
|
|
29
|
+
};
|
|
30
|
+
return (
|
|
31
|
+
<View ref={ref} style={[base, variant === 'elevated' ? elevatedShadow : null, style]} {...rest}>
|
|
32
|
+
{children}
|
|
33
|
+
</View>
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
export const CardHeader = forwardRef<View, ViewProps>(function CardHeader({ style, children, ...rest }, ref) {
|
|
38
|
+
const theme = useTheme();
|
|
39
|
+
return (
|
|
40
|
+
<View
|
|
41
|
+
ref={ref}
|
|
42
|
+
style={[{ paddingBottom: 12, borderBottomWidth: 1, borderBottomColor: theme.colors.border, marginBottom: 12 }, style]}
|
|
43
|
+
{...rest}
|
|
44
|
+
>
|
|
45
|
+
{children}
|
|
46
|
+
</View>
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
export const CardBody = forwardRef<View, ViewProps>(function CardBody({ style, children, ...rest }, ref) {
|
|
51
|
+
return (
|
|
52
|
+
<View ref={ref} style={style} {...rest}>
|
|
53
|
+
{children}
|
|
54
|
+
</View>
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
export const CardFooter = forwardRef<View, ViewProps>(function CardFooter({ style, children, ...rest }, ref) {
|
|
59
|
+
const theme = useTheme();
|
|
60
|
+
return (
|
|
61
|
+
<View
|
|
62
|
+
ref={ref}
|
|
63
|
+
style={[{ paddingTop: 12, borderTopWidth: 1, borderTopColor: theme.colors.border, marginTop: 12 }, style]}
|
|
64
|
+
{...rest}
|
|
65
|
+
>
|
|
66
|
+
{children}
|
|
67
|
+
</View>
|
|
68
|
+
);
|
|
69
|
+
});
|
package/src/Carousel.tsx
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
|
|
2
|
+
import { Pressable, ScrollView, View, Text, useWindowDimensions, type NativeScrollEvent, type NativeSyntheticEvent, type ViewProps } from 'react-native';
|
|
3
|
+
import { useTheme } from './ElvoraProvider';
|
|
4
|
+
|
|
5
|
+
export interface CarouselProps extends Omit<ViewProps, 'onChange'> {
|
|
6
|
+
items: ReactNode[];
|
|
7
|
+
current?: number;
|
|
8
|
+
defaultCurrent?: number;
|
|
9
|
+
onChange?: (index: number) => void;
|
|
10
|
+
autoplay?: number;
|
|
11
|
+
dots?: boolean;
|
|
12
|
+
loop?: boolean;
|
|
13
|
+
height?: number;
|
|
14
|
+
width?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function Carousel(props: CarouselProps) {
|
|
18
|
+
const {
|
|
19
|
+
items,
|
|
20
|
+
current: currentProp,
|
|
21
|
+
defaultCurrent = 0,
|
|
22
|
+
onChange,
|
|
23
|
+
autoplay = 0,
|
|
24
|
+
dots = true,
|
|
25
|
+
loop = true,
|
|
26
|
+
height = 200,
|
|
27
|
+
width: widthProp,
|
|
28
|
+
style,
|
|
29
|
+
...rest
|
|
30
|
+
} = props;
|
|
31
|
+
const theme = useTheme();
|
|
32
|
+
const { width: windowWidth } = useWindowDimensions();
|
|
33
|
+
const width = widthProp ?? windowWidth - 32;
|
|
34
|
+
const [internal, setInternal] = useState(defaultCurrent);
|
|
35
|
+
const current = currentProp ?? internal;
|
|
36
|
+
const scrollRef = useRef<ScrollView>(null);
|
|
37
|
+
|
|
38
|
+
const setCurrent = useCallback(
|
|
39
|
+
(next: number) => {
|
|
40
|
+
const total = items.length;
|
|
41
|
+
const wrapped = loop ? (next + total) % total : Math.max(0, Math.min(total - 1, next));
|
|
42
|
+
if (currentProp === undefined) setInternal(wrapped);
|
|
43
|
+
onChange?.(wrapped);
|
|
44
|
+
scrollRef.current?.scrollTo({ x: wrapped * width, animated: true });
|
|
45
|
+
},
|
|
46
|
+
[items.length, loop, currentProp, onChange, width],
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (!autoplay || items.length < 2) return;
|
|
51
|
+
const id = setInterval(() => setCurrent(current + 1), autoplay);
|
|
52
|
+
return () => clearInterval(id);
|
|
53
|
+
}, [autoplay, current, items.length, setCurrent]);
|
|
54
|
+
|
|
55
|
+
const handleMomentumScrollEnd = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
56
|
+
const next = Math.round(e.nativeEvent.contentOffset.x / width);
|
|
57
|
+
if (next !== current) {
|
|
58
|
+
if (currentProp === undefined) setInternal(next);
|
|
59
|
+
onChange?.(next);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<View style={[{ width, borderRadius: 8, overflow: 'hidden' }, style]} {...rest}>
|
|
65
|
+
<ScrollView
|
|
66
|
+
ref={scrollRef}
|
|
67
|
+
horizontal
|
|
68
|
+
pagingEnabled
|
|
69
|
+
showsHorizontalScrollIndicator={false}
|
|
70
|
+
onMomentumScrollEnd={handleMomentumScrollEnd}
|
|
71
|
+
style={{ height }}
|
|
72
|
+
>
|
|
73
|
+
{items.map((item, idx) => (
|
|
74
|
+
<View key={idx} style={{ width, height, alignItems: 'center', justifyContent: 'center' }}>
|
|
75
|
+
{typeof item === 'string' ? <Text style={{ color: theme.colors.fg }}>{item}</Text> : item}
|
|
76
|
+
</View>
|
|
77
|
+
))}
|
|
78
|
+
</ScrollView>
|
|
79
|
+
{dots ? (
|
|
80
|
+
<View style={{ flexDirection: 'row', justifyContent: 'center', paddingVertical: 8, gap: 6 }}>
|
|
81
|
+
{items.map((_, idx) => (
|
|
82
|
+
<Pressable
|
|
83
|
+
key={idx}
|
|
84
|
+
accessibilityRole="button"
|
|
85
|
+
accessibilityLabel={`Go to slide ${idx + 1}`}
|
|
86
|
+
onPress={() => setCurrent(idx)}
|
|
87
|
+
style={{
|
|
88
|
+
width: 8,
|
|
89
|
+
height: 8,
|
|
90
|
+
borderRadius: 4,
|
|
91
|
+
backgroundColor: idx === current ? theme.colors.intent.primary.solid : theme.colors.intent.neutral.subtle,
|
|
92
|
+
}}
|
|
93
|
+
/>
|
|
94
|
+
))}
|
|
95
|
+
</View>
|
|
96
|
+
) : null}
|
|
97
|
+
</View>
|
|
98
|
+
);
|
|
99
|
+
}
|