@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/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
+ }
@@ -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
+ });
@@ -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
+ }