@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/Stepper.tsx
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { useState, type ReactNode } from 'react';
|
|
2
|
+
import { Pressable, Text, View, type ViewProps } from 'react-native';
|
|
3
|
+
import { useTheme } from './ElvoraProvider';
|
|
4
|
+
import { Button } from './Button';
|
|
5
|
+
|
|
6
|
+
export interface StepperStep {
|
|
7
|
+
key: string;
|
|
8
|
+
label: ReactNode;
|
|
9
|
+
description?: ReactNode;
|
|
10
|
+
content?: ReactNode;
|
|
11
|
+
optional?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface StepperProps extends Omit<ViewProps, 'children'> {
|
|
15
|
+
steps: StepperStep[];
|
|
16
|
+
active?: number;
|
|
17
|
+
defaultActive?: number;
|
|
18
|
+
onChange?: (index: number) => void;
|
|
19
|
+
orientation?: 'horizontal' | 'vertical';
|
|
20
|
+
showNavigation?: boolean;
|
|
21
|
+
nextLabel?: string;
|
|
22
|
+
prevLabel?: string;
|
|
23
|
+
finishLabel?: string;
|
|
24
|
+
onFinish?: () => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function Stepper(props: StepperProps) {
|
|
28
|
+
const {
|
|
29
|
+
steps,
|
|
30
|
+
active: activeProp,
|
|
31
|
+
defaultActive = 0,
|
|
32
|
+
onChange,
|
|
33
|
+
orientation = 'vertical',
|
|
34
|
+
showNavigation = true,
|
|
35
|
+
nextLabel = 'Next',
|
|
36
|
+
prevLabel = 'Back',
|
|
37
|
+
finishLabel = 'Finish',
|
|
38
|
+
onFinish,
|
|
39
|
+
style,
|
|
40
|
+
...rest
|
|
41
|
+
} = props;
|
|
42
|
+
const theme = useTheme();
|
|
43
|
+
const [internal, setInternal] = useState(defaultActive);
|
|
44
|
+
const active = activeProp ?? internal;
|
|
45
|
+
const total = steps.length;
|
|
46
|
+
const go = (idx: number) => {
|
|
47
|
+
const clamped = Math.max(0, Math.min(total - 1, idx));
|
|
48
|
+
if (activeProp === undefined) setInternal(clamped);
|
|
49
|
+
onChange?.(clamped);
|
|
50
|
+
};
|
|
51
|
+
const isVertical = orientation === 'vertical';
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<View style={style} {...rest}>
|
|
55
|
+
<View style={{ flexDirection: isVertical ? 'column' : 'row', alignItems: 'flex-start', gap: 8 }}>
|
|
56
|
+
{steps.map((step, idx) => {
|
|
57
|
+
const done = idx < active;
|
|
58
|
+
const isActive = idx === active;
|
|
59
|
+
const circleBg = done || isActive ? theme.colors.intent.primary.solid : theme.colors.intent.neutral.subtle;
|
|
60
|
+
const circleFg = done || isActive ? theme.colors.intent.primary.solidFg : theme.colors.fg;
|
|
61
|
+
return (
|
|
62
|
+
<View key={step.key} style={{ flexDirection: 'row', flex: isVertical ? undefined : 1, gap: 8 }}>
|
|
63
|
+
<Pressable
|
|
64
|
+
accessibilityRole="button"
|
|
65
|
+
accessibilityLabel={`Go to step ${idx + 1}`}
|
|
66
|
+
onPress={() => go(idx)}
|
|
67
|
+
style={{
|
|
68
|
+
width: 28,
|
|
69
|
+
height: 28,
|
|
70
|
+
borderRadius: 14,
|
|
71
|
+
backgroundColor: circleBg,
|
|
72
|
+
alignItems: 'center',
|
|
73
|
+
justifyContent: 'center',
|
|
74
|
+
}}
|
|
75
|
+
>
|
|
76
|
+
<Text style={{ color: circleFg, fontWeight: '600', fontSize: 12 }}>{done ? '✓' : idx + 1}</Text>
|
|
77
|
+
</Pressable>
|
|
78
|
+
<View style={{ flex: 1 }}>
|
|
79
|
+
<Text style={{ fontWeight: '500', color: isActive ? theme.colors.fg : theme.colors.fgSubtle }}>
|
|
80
|
+
{step.label}
|
|
81
|
+
{step.optional ? <Text style={{ color: theme.colors.fgSubtle, fontSize: 11 }}> (optional)</Text> : null}
|
|
82
|
+
</Text>
|
|
83
|
+
{step.description ? (
|
|
84
|
+
<Text style={{ color: theme.colors.fgSubtle, fontSize: 12 }}>{step.description}</Text>
|
|
85
|
+
) : null}
|
|
86
|
+
{isVertical && isActive && step.content ? <View style={{ marginTop: 8 }}>{step.content}</View> : null}
|
|
87
|
+
</View>
|
|
88
|
+
</View>
|
|
89
|
+
);
|
|
90
|
+
})}
|
|
91
|
+
</View>
|
|
92
|
+
{!isVertical && steps[active]?.content ? <View style={{ marginTop: 12 }}>{steps[active]!.content}</View> : null}
|
|
93
|
+
{showNavigation ? (
|
|
94
|
+
<View style={{ flexDirection: 'row', justifyContent: 'flex-end', gap: 8, marginTop: 12 }}>
|
|
95
|
+
{active > 0 ? (
|
|
96
|
+
<Button variant="outline" size="sm" onPress={() => go(active - 1)}>
|
|
97
|
+
{prevLabel}
|
|
98
|
+
</Button>
|
|
99
|
+
) : null}
|
|
100
|
+
{active < total - 1 ? (
|
|
101
|
+
<Button size="sm" onPress={() => go(active + 1)}>
|
|
102
|
+
{nextLabel}
|
|
103
|
+
</Button>
|
|
104
|
+
) : (
|
|
105
|
+
<Button size="sm" onPress={onFinish}>
|
|
106
|
+
{finishLabel}
|
|
107
|
+
</Button>
|
|
108
|
+
)}
|
|
109
|
+
</View>
|
|
110
|
+
) : null}
|
|
111
|
+
</View>
|
|
112
|
+
);
|
|
113
|
+
}
|
package/src/Steps.tsx
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
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 Step {
|
|
7
|
+
title: ReactNode;
|
|
8
|
+
description?: ReactNode;
|
|
9
|
+
status?: 'wait' | 'process' | 'finish' | 'error';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface StepsProps extends Omit<ViewProps, 'onChange'> {
|
|
13
|
+
steps: Step[];
|
|
14
|
+
current?: number;
|
|
15
|
+
orientation?: 'horizontal' | 'vertical';
|
|
16
|
+
onChange?: (index: number) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Steps — multi-step progress indicator for RN. */
|
|
20
|
+
export function Steps(props: StepsProps) {
|
|
21
|
+
const { steps, current = 0, orientation = 'horizontal', onChange, style, ...rest } = props;
|
|
22
|
+
const theme = useTheme();
|
|
23
|
+
const intent = theme.colors.intent.primary;
|
|
24
|
+
const isHorizontal = orientation === 'horizontal';
|
|
25
|
+
|
|
26
|
+
const resolveStatus = (i: number, override?: Step['status']) => {
|
|
27
|
+
if (override) return override;
|
|
28
|
+
if (i < current) return 'finish';
|
|
29
|
+
if (i === current) return 'process';
|
|
30
|
+
return 'wait';
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const dotStyle = (s: ReturnType<typeof resolveStatus>) => {
|
|
34
|
+
switch (s) {
|
|
35
|
+
case 'finish':
|
|
36
|
+
return { backgroundColor: intent.solid, color: intent.solidFg, borderColor: intent.solid };
|
|
37
|
+
case 'process':
|
|
38
|
+
return { backgroundColor: theme.colors.surfaceElevated, color: intent.solid, borderColor: intent.solid };
|
|
39
|
+
case 'error':
|
|
40
|
+
return {
|
|
41
|
+
backgroundColor: theme.colors.surfaceElevated,
|
|
42
|
+
color: theme.colors.intent.danger.solid,
|
|
43
|
+
borderColor: theme.colors.intent.danger.solid,
|
|
44
|
+
};
|
|
45
|
+
default:
|
|
46
|
+
return {
|
|
47
|
+
backgroundColor: theme.colors.surfaceElevated,
|
|
48
|
+
color: theme.colors.fgMuted,
|
|
49
|
+
borderColor: theme.colors.border,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<View
|
|
56
|
+
style={[
|
|
57
|
+
{
|
|
58
|
+
flexDirection: isHorizontal ? 'row' : 'column',
|
|
59
|
+
alignItems: isHorizontal ? 'flex-start' : 'stretch',
|
|
60
|
+
gap: isHorizontal ? 0 : 12,
|
|
61
|
+
},
|
|
62
|
+
style,
|
|
63
|
+
]}
|
|
64
|
+
{...rest}
|
|
65
|
+
>
|
|
66
|
+
{steps.map((step, i) => {
|
|
67
|
+
const status = resolveStatus(i, step.status);
|
|
68
|
+
const dot = dotStyle(status);
|
|
69
|
+
const isLast = i === steps.length - 1;
|
|
70
|
+
return (
|
|
71
|
+
<Fragment key={i}>
|
|
72
|
+
<Pressable
|
|
73
|
+
accessibilityRole={onChange ? 'button' : undefined}
|
|
74
|
+
accessibilityState={{ selected: status === 'process' }}
|
|
75
|
+
onPress={() => onChange?.(i)}
|
|
76
|
+
disabled={!onChange}
|
|
77
|
+
style={{
|
|
78
|
+
flex: isHorizontal ? 1 : undefined,
|
|
79
|
+
flexDirection: isHorizontal ? 'column' : 'row',
|
|
80
|
+
alignItems: isHorizontal ? 'center' : 'flex-start',
|
|
81
|
+
gap: 8,
|
|
82
|
+
}}
|
|
83
|
+
>
|
|
84
|
+
<View
|
|
85
|
+
style={{
|
|
86
|
+
width: 28,
|
|
87
|
+
height: 28,
|
|
88
|
+
borderRadius: 14,
|
|
89
|
+
borderWidth: 2,
|
|
90
|
+
borderColor: dot.borderColor,
|
|
91
|
+
backgroundColor: dot.backgroundColor,
|
|
92
|
+
alignItems: 'center',
|
|
93
|
+
justifyContent: 'center',
|
|
94
|
+
}}
|
|
95
|
+
>
|
|
96
|
+
{status === 'finish' ? (
|
|
97
|
+
<Icon name="check" size={14} color={dot.color} />
|
|
98
|
+
) : status === 'error' ? (
|
|
99
|
+
<Icon name="x" size={14} color={dot.color} />
|
|
100
|
+
) : (
|
|
101
|
+
<Text style={{ color: dot.color, fontSize: 13, fontWeight: '600' }}>{i + 1}</Text>
|
|
102
|
+
)}
|
|
103
|
+
</View>
|
|
104
|
+
<View style={{ alignItems: isHorizontal ? 'center' : 'flex-start' }}>
|
|
105
|
+
<Text
|
|
106
|
+
style={{
|
|
107
|
+
fontSize: 14,
|
|
108
|
+
fontWeight: '600',
|
|
109
|
+
color: status === 'wait' ? theme.colors.fgMuted : theme.colors.fg,
|
|
110
|
+
}}
|
|
111
|
+
>
|
|
112
|
+
{step.title}
|
|
113
|
+
</Text>
|
|
114
|
+
{step.description ? (
|
|
115
|
+
<Text style={{ fontSize: 12, color: theme.colors.fgMuted, marginTop: 2 }}>{step.description}</Text>
|
|
116
|
+
) : null}
|
|
117
|
+
</View>
|
|
118
|
+
</Pressable>
|
|
119
|
+
{!isLast ? (
|
|
120
|
+
isHorizontal ? (
|
|
121
|
+
<View
|
|
122
|
+
style={{
|
|
123
|
+
flex: 1,
|
|
124
|
+
height: 2,
|
|
125
|
+
backgroundColor: status === 'finish' ? intent.solid : theme.colors.border,
|
|
126
|
+
alignSelf: 'center',
|
|
127
|
+
marginTop: 13,
|
|
128
|
+
}}
|
|
129
|
+
/>
|
|
130
|
+
) : (
|
|
131
|
+
<View
|
|
132
|
+
style={{
|
|
133
|
+
width: 2,
|
|
134
|
+
minHeight: 18,
|
|
135
|
+
backgroundColor: status === 'finish' ? intent.solid : theme.colors.border,
|
|
136
|
+
marginLeft: 13,
|
|
137
|
+
}}
|
|
138
|
+
/>
|
|
139
|
+
)
|
|
140
|
+
) : null}
|
|
141
|
+
</Fragment>
|
|
142
|
+
);
|
|
143
|
+
})}
|
|
144
|
+
</View>
|
|
145
|
+
);
|
|
146
|
+
}
|
package/src/Switch.tsx
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { forwardRef, type ReactNode } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Pressable,
|
|
4
|
+
Switch as RNSwitch,
|
|
5
|
+
Text,
|
|
6
|
+
View,
|
|
7
|
+
type PressableProps,
|
|
8
|
+
type StyleProp,
|
|
9
|
+
type ViewStyle,
|
|
10
|
+
} from 'react-native';
|
|
11
|
+
import { useControllableState } from '@elvora/core/react';
|
|
12
|
+
import { useTheme } from './ElvoraProvider';
|
|
13
|
+
|
|
14
|
+
export interface SwitchProps extends Omit<PressableProps, 'onPress' | 'children'> {
|
|
15
|
+
isDisabled?: boolean;
|
|
16
|
+
isChecked?: boolean;
|
|
17
|
+
defaultChecked?: boolean;
|
|
18
|
+
onChange?: (checked: boolean) => void;
|
|
19
|
+
children?: ReactNode;
|
|
20
|
+
style?: StyleProp<ViewStyle>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const Switch = forwardRef<View, SwitchProps>(function Switch(props, ref) {
|
|
24
|
+
const { isDisabled = false, isChecked, defaultChecked = false, onChange, children, style, ...rest } = props;
|
|
25
|
+
const theme = useTheme();
|
|
26
|
+
const [checked, setChecked] = useControllableState({
|
|
27
|
+
value: isChecked,
|
|
28
|
+
defaultValue: defaultChecked,
|
|
29
|
+
onChange,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<Pressable
|
|
34
|
+
ref={ref}
|
|
35
|
+
accessibilityRole="switch"
|
|
36
|
+
accessibilityState={{ checked: Boolean(checked), disabled: isDisabled }}
|
|
37
|
+
disabled={isDisabled}
|
|
38
|
+
onPress={() => setChecked(!checked)}
|
|
39
|
+
style={[{ flexDirection: 'row', alignItems: 'center', gap: 8 }, style]}
|
|
40
|
+
{...rest}
|
|
41
|
+
>
|
|
42
|
+
<RNSwitch
|
|
43
|
+
value={Boolean(checked)}
|
|
44
|
+
disabled={isDisabled}
|
|
45
|
+
onValueChange={setChecked}
|
|
46
|
+
trackColor={{ false: theme.colors.intent.neutral.subtle, true: theme.colors.intent.primary.solid }}
|
|
47
|
+
thumbColor="#fff"
|
|
48
|
+
/>
|
|
49
|
+
{children ? <Text style={{ color: theme.colors.fg, fontSize: 14 }}>{children}</Text> : null}
|
|
50
|
+
</Pressable>
|
|
51
|
+
);
|
|
52
|
+
});
|
package/src/Table.tsx
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { useMemo, useState, type ReactNode } from 'react';
|
|
2
|
+
import { Pressable, ScrollView, View, Text, type ViewProps } from 'react-native';
|
|
3
|
+
import { useTheme } from './ElvoraProvider';
|
|
4
|
+
|
|
5
|
+
export type SortOrder = 'asc' | 'desc' | null;
|
|
6
|
+
|
|
7
|
+
export interface TableColumn<Row = Record<string, unknown>> {
|
|
8
|
+
key: string;
|
|
9
|
+
title: ReactNode;
|
|
10
|
+
dataIndex?: keyof Row | string;
|
|
11
|
+
render?: (value: unknown, row: Row, index: number) => ReactNode;
|
|
12
|
+
sortable?: boolean;
|
|
13
|
+
sorter?: (a: Row, b: Row) => number;
|
|
14
|
+
width?: number;
|
|
15
|
+
align?: 'left' | 'center' | 'right';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface TableProps<Row = Record<string, unknown>> extends Omit<ViewProps, 'children'> {
|
|
19
|
+
columns: TableColumn<Row>[];
|
|
20
|
+
dataSource: Row[];
|
|
21
|
+
rowKey?: keyof Row | ((row: Row, index: number) => string | number);
|
|
22
|
+
size?: 'sm' | 'md' | 'lg';
|
|
23
|
+
bordered?: boolean;
|
|
24
|
+
striped?: boolean;
|
|
25
|
+
emptyState?: ReactNode;
|
|
26
|
+
onRowPress?: (row: Row, index: number) => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function defaultCompare(a: unknown, b: unknown): number {
|
|
30
|
+
if (a == null && b == null) return 0;
|
|
31
|
+
if (a == null) return -1;
|
|
32
|
+
if (b == null) return 1;
|
|
33
|
+
if (typeof a === 'number' && typeof b === 'number') return a - b;
|
|
34
|
+
return String(a).localeCompare(String(b));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function Table<Row extends Record<string, unknown>>(props: TableProps<Row>) {
|
|
38
|
+
const {
|
|
39
|
+
columns,
|
|
40
|
+
dataSource,
|
|
41
|
+
rowKey,
|
|
42
|
+
size = 'md',
|
|
43
|
+
bordered,
|
|
44
|
+
striped,
|
|
45
|
+
emptyState,
|
|
46
|
+
onRowPress,
|
|
47
|
+
style,
|
|
48
|
+
...rest
|
|
49
|
+
} = props;
|
|
50
|
+
const theme = useTheme();
|
|
51
|
+
const [sortKey, setSortKey] = useState<string | null>(null);
|
|
52
|
+
const [sortOrder, setSortOrder] = useState<SortOrder>(null);
|
|
53
|
+
|
|
54
|
+
const sortedRows = useMemo(() => {
|
|
55
|
+
if (!sortKey || !sortOrder) return dataSource;
|
|
56
|
+
const col = columns.find((c) => c.key === sortKey);
|
|
57
|
+
if (!col || !col.sortable) return dataSource;
|
|
58
|
+
const arr = [...dataSource];
|
|
59
|
+
const compare = col.sorter
|
|
60
|
+
? col.sorter
|
|
61
|
+
: (a: Row, b: Row) => defaultCompare(a[col.dataIndex as keyof Row], b[col.dataIndex as keyof Row]);
|
|
62
|
+
arr.sort((a, b) => (sortOrder === 'asc' ? compare(a, b) : -compare(a, b)));
|
|
63
|
+
return arr;
|
|
64
|
+
}, [columns, dataSource, sortKey, sortOrder]);
|
|
65
|
+
|
|
66
|
+
const toggleSort = (key: string) => {
|
|
67
|
+
if (sortKey !== key) {
|
|
68
|
+
setSortKey(key);
|
|
69
|
+
setSortOrder('asc');
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (sortOrder === 'asc') setSortOrder('desc');
|
|
73
|
+
else if (sortOrder === 'desc') {
|
|
74
|
+
setSortKey(null);
|
|
75
|
+
setSortOrder(null);
|
|
76
|
+
} else setSortOrder('asc');
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const cellPadding = size === 'sm' ? 8 : size === 'lg' ? 16 : 12;
|
|
80
|
+
const borderColor = theme.colors.border;
|
|
81
|
+
|
|
82
|
+
const getRowKey = (row: Row, idx: number): string | number => {
|
|
83
|
+
if (typeof rowKey === 'function') return rowKey(row, idx);
|
|
84
|
+
if (rowKey) return String((row as Record<string, unknown>)[rowKey as string] ?? idx);
|
|
85
|
+
return idx;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
if (dataSource.length === 0 && emptyState) {
|
|
89
|
+
return <View accessibilityRole="text">{typeof emptyState === 'string' ? <Text>{emptyState}</Text> : emptyState}</View>;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={style} {...rest}>
|
|
94
|
+
<View
|
|
95
|
+
style={{
|
|
96
|
+
borderWidth: bordered ? 1 : 0,
|
|
97
|
+
borderColor,
|
|
98
|
+
borderRadius: 8,
|
|
99
|
+
backgroundColor: theme.colors.surfaceElevated,
|
|
100
|
+
overflow: 'hidden',
|
|
101
|
+
}}
|
|
102
|
+
>
|
|
103
|
+
<View
|
|
104
|
+
style={{
|
|
105
|
+
flexDirection: 'row',
|
|
106
|
+
backgroundColor: theme.colors.intent.neutral.subtle,
|
|
107
|
+
borderBottomWidth: 1,
|
|
108
|
+
borderBottomColor: borderColor,
|
|
109
|
+
}}
|
|
110
|
+
>
|
|
111
|
+
{columns.map((col) => (
|
|
112
|
+
<Pressable
|
|
113
|
+
key={col.key}
|
|
114
|
+
accessibilityRole={col.sortable ? 'button' : undefined}
|
|
115
|
+
onPress={col.sortable ? () => toggleSort(col.key) : undefined}
|
|
116
|
+
style={{
|
|
117
|
+
width: col.width ?? 120,
|
|
118
|
+
padding: cellPadding,
|
|
119
|
+
alignItems: col.align === 'center' ? 'center' : col.align === 'right' ? 'flex-end' : 'flex-start',
|
|
120
|
+
}}
|
|
121
|
+
>
|
|
122
|
+
<Text style={{ color: theme.colors.fg, fontWeight: '600', fontSize: 14 }}>
|
|
123
|
+
{typeof col.title === 'string' ? col.title : null}
|
|
124
|
+
{col.sortable
|
|
125
|
+
? sortKey === col.key
|
|
126
|
+
? sortOrder === 'asc'
|
|
127
|
+
? ' ↑'
|
|
128
|
+
: sortOrder === 'desc'
|
|
129
|
+
? ' ↓'
|
|
130
|
+
: ''
|
|
131
|
+
: ' ⇅'
|
|
132
|
+
: ''}
|
|
133
|
+
</Text>
|
|
134
|
+
</Pressable>
|
|
135
|
+
))}
|
|
136
|
+
</View>
|
|
137
|
+
<ScrollView>
|
|
138
|
+
{sortedRows.map((row, rowIndex) => {
|
|
139
|
+
const isStriped = striped && rowIndex % 2 === 1;
|
|
140
|
+
return (
|
|
141
|
+
<Pressable
|
|
142
|
+
key={getRowKey(row, rowIndex)}
|
|
143
|
+
onPress={onRowPress ? () => onRowPress(row, rowIndex) : undefined}
|
|
144
|
+
style={{
|
|
145
|
+
flexDirection: 'row',
|
|
146
|
+
backgroundColor: isStriped ? theme.colors.intent.neutral.subtle : 'transparent',
|
|
147
|
+
borderBottomWidth: 1,
|
|
148
|
+
borderBottomColor: borderColor,
|
|
149
|
+
}}
|
|
150
|
+
>
|
|
151
|
+
{columns.map((col) => {
|
|
152
|
+
const value = col.dataIndex !== undefined ? (row as Record<string, unknown>)[col.dataIndex as string] : undefined;
|
|
153
|
+
const content = col.render ? col.render(value, row, rowIndex) : (value as ReactNode);
|
|
154
|
+
return (
|
|
155
|
+
<View
|
|
156
|
+
key={col.key}
|
|
157
|
+
style={{
|
|
158
|
+
width: col.width ?? 120,
|
|
159
|
+
padding: cellPadding,
|
|
160
|
+
alignItems: col.align === 'center' ? 'center' : col.align === 'right' ? 'flex-end' : 'flex-start',
|
|
161
|
+
}}
|
|
162
|
+
>
|
|
163
|
+
{typeof content === 'string' || typeof content === 'number' ? (
|
|
164
|
+
<Text style={{ color: theme.colors.fg, fontSize: 14 }}>{String(content)}</Text>
|
|
165
|
+
) : (
|
|
166
|
+
content
|
|
167
|
+
)}
|
|
168
|
+
</View>
|
|
169
|
+
);
|
|
170
|
+
})}
|
|
171
|
+
</Pressable>
|
|
172
|
+
);
|
|
173
|
+
})}
|
|
174
|
+
</ScrollView>
|
|
175
|
+
</View>
|
|
176
|
+
</ScrollView>
|
|
177
|
+
);
|
|
178
|
+
}
|
package/src/Tabs.tsx
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { useState, type ReactNode } from 'react';
|
|
2
|
+
import { Pressable, View, Text, ScrollView, type ViewProps, type ViewStyle } from 'react-native';
|
|
3
|
+
import type { Orientation } from '@elvora/core';
|
|
4
|
+
import { useTheme } from './ElvoraProvider';
|
|
5
|
+
|
|
6
|
+
export interface TabsTab {
|
|
7
|
+
value: string;
|
|
8
|
+
label: ReactNode;
|
|
9
|
+
isDisabled?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface TabsProps extends ViewProps {
|
|
13
|
+
tabs: TabsTab[];
|
|
14
|
+
value?: string;
|
|
15
|
+
defaultValue?: string;
|
|
16
|
+
onChange?: (value: string) => void;
|
|
17
|
+
variant?: 'underline' | 'pill' | 'solid';
|
|
18
|
+
orientation?: Orientation;
|
|
19
|
+
/** Render function for each tab's panel content. */
|
|
20
|
+
renderPanel: (value: string) => ReactNode;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Tabs — RN-friendly tab strip + panel renderer. Doesn't need WAI-ARIA roles,
|
|
25
|
+
* uses native accessibility roles. Composition is data-driven rather than
|
|
26
|
+
* via slot components.
|
|
27
|
+
*/
|
|
28
|
+
export function Tabs(props: TabsProps) {
|
|
29
|
+
const {
|
|
30
|
+
tabs,
|
|
31
|
+
value: valueProp,
|
|
32
|
+
defaultValue,
|
|
33
|
+
onChange,
|
|
34
|
+
variant = 'underline',
|
|
35
|
+
orientation = 'horizontal',
|
|
36
|
+
renderPanel,
|
|
37
|
+
style,
|
|
38
|
+
...rest
|
|
39
|
+
} = props;
|
|
40
|
+
const theme = useTheme();
|
|
41
|
+
const intent = theme.colors.intent.primary;
|
|
42
|
+
const [internal, setInternal] = useState<string>(defaultValue ?? tabs[0]?.value ?? '');
|
|
43
|
+
const current = valueProp ?? internal;
|
|
44
|
+
|
|
45
|
+
const setValue = (v: string) => {
|
|
46
|
+
if (valueProp === undefined) setInternal(v);
|
|
47
|
+
onChange?.(v);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const horizontal = orientation === 'horizontal';
|
|
51
|
+
|
|
52
|
+
const tabStyle = (active: boolean, disabled?: boolean): ViewStyle => {
|
|
53
|
+
if (variant === 'pill') {
|
|
54
|
+
return {
|
|
55
|
+
backgroundColor: active ? intent.subtle : 'transparent',
|
|
56
|
+
borderRadius: theme.radii.full,
|
|
57
|
+
paddingHorizontal: 14,
|
|
58
|
+
paddingVertical: 8,
|
|
59
|
+
opacity: disabled ? 0.5 : 1,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
if (variant === 'solid') {
|
|
63
|
+
return {
|
|
64
|
+
backgroundColor: active ? intent.solid : 'transparent',
|
|
65
|
+
borderRadius: theme.radii.md,
|
|
66
|
+
paddingHorizontal: 14,
|
|
67
|
+
paddingVertical: 8,
|
|
68
|
+
opacity: disabled ? 0.5 : 1,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
borderBottomWidth: horizontal ? 2 : 0,
|
|
73
|
+
borderRightWidth: !horizontal ? 2 : 0,
|
|
74
|
+
borderColor: active ? intent.solid : 'transparent',
|
|
75
|
+
paddingHorizontal: 14,
|
|
76
|
+
paddingVertical: 10,
|
|
77
|
+
opacity: disabled ? 0.5 : 1,
|
|
78
|
+
};
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<View style={style} {...rest}>
|
|
83
|
+
<ScrollView
|
|
84
|
+
horizontal={horizontal}
|
|
85
|
+
showsHorizontalScrollIndicator={false}
|
|
86
|
+
contentContainerStyle={{
|
|
87
|
+
flexDirection: horizontal ? 'row' : 'column',
|
|
88
|
+
gap: 4,
|
|
89
|
+
borderBottomWidth: variant === 'underline' && horizontal ? 1 : 0,
|
|
90
|
+
borderBottomColor: theme.colors.border,
|
|
91
|
+
}}
|
|
92
|
+
>
|
|
93
|
+
{tabs.map((t) => {
|
|
94
|
+
const active = t.value === current;
|
|
95
|
+
const labelColor =
|
|
96
|
+
variant === 'solid' && active
|
|
97
|
+
? intent.solidFg
|
|
98
|
+
: variant === 'pill' && active
|
|
99
|
+
? intent.fg
|
|
100
|
+
: active
|
|
101
|
+
? theme.colors.fg
|
|
102
|
+
: theme.colors.fgMuted;
|
|
103
|
+
return (
|
|
104
|
+
<Pressable
|
|
105
|
+
key={t.value}
|
|
106
|
+
accessibilityRole="tab"
|
|
107
|
+
accessibilityState={{ selected: active, disabled: t.isDisabled }}
|
|
108
|
+
disabled={t.isDisabled}
|
|
109
|
+
onPress={() => setValue(t.value)}
|
|
110
|
+
style={tabStyle(active, t.isDisabled)}
|
|
111
|
+
>
|
|
112
|
+
<Text style={{ color: labelColor, fontSize: 14, fontWeight: '500' }}>{t.label}</Text>
|
|
113
|
+
</Pressable>
|
|
114
|
+
);
|
|
115
|
+
})}
|
|
116
|
+
</ScrollView>
|
|
117
|
+
<View accessibilityRole="none" style={{ paddingVertical: 12 }}>
|
|
118
|
+
{renderPanel(current)}
|
|
119
|
+
</View>
|
|
120
|
+
</View>
|
|
121
|
+
);
|
|
122
|
+
}
|
package/src/Tag.tsx
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { forwardRef, type ReactNode } from 'react';
|
|
2
|
+
import { Pressable, Text, View, type StyleProp, type ViewStyle } from 'react-native';
|
|
3
|
+
import type { ElvoraSize, ElvoraStatus, ElvoraTone } from '@elvora/core';
|
|
4
|
+
import { useTheme } from './ElvoraProvider';
|
|
5
|
+
import { Icon } from './Icon';
|
|
6
|
+
|
|
7
|
+
export interface TagProps {
|
|
8
|
+
status?: ElvoraStatus;
|
|
9
|
+
size?: Exclude<ElvoraSize, 'xl'>;
|
|
10
|
+
tone?: ElvoraTone;
|
|
11
|
+
onClose?: () => void;
|
|
12
|
+
closeLabel?: string;
|
|
13
|
+
children?: ReactNode;
|
|
14
|
+
style?: StyleProp<ViewStyle>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const sizeMap = {
|
|
18
|
+
xs: { padX: 6, padY: 2, font: 11 },
|
|
19
|
+
sm: { padX: 8, padY: 3, font: 12 },
|
|
20
|
+
md: { padX: 10, padY: 4, font: 13 },
|
|
21
|
+
lg: { padX: 12, padY: 6, font: 14 },
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const statusIntent = {
|
|
25
|
+
neutral: 'neutral',
|
|
26
|
+
info: 'info',
|
|
27
|
+
success: 'success',
|
|
28
|
+
warning: 'warning',
|
|
29
|
+
error: 'danger',
|
|
30
|
+
} as const;
|
|
31
|
+
|
|
32
|
+
export const Tag = forwardRef<View, TagProps>(function Tag(props, ref) {
|
|
33
|
+
const { status = 'neutral', size = 'sm', tone = 'subtle', onClose, closeLabel = 'Remove', children, style } = props;
|
|
34
|
+
const theme = useTheme();
|
|
35
|
+
const intent = theme.colors.intent[statusIntent[status]];
|
|
36
|
+
const dims = sizeMap[size];
|
|
37
|
+
|
|
38
|
+
let bg = intent.subtle;
|
|
39
|
+
let fg = intent.fg;
|
|
40
|
+
let border = 'transparent';
|
|
41
|
+
if (tone === 'solid') {
|
|
42
|
+
bg = intent.solid;
|
|
43
|
+
fg = intent.solidFg;
|
|
44
|
+
} else if (tone === 'outline') {
|
|
45
|
+
bg = 'transparent';
|
|
46
|
+
fg = intent.fg;
|
|
47
|
+
border = intent.border;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<View
|
|
52
|
+
ref={ref}
|
|
53
|
+
style={[
|
|
54
|
+
{
|
|
55
|
+
flexDirection: 'row',
|
|
56
|
+
alignItems: 'center',
|
|
57
|
+
paddingHorizontal: dims.padX,
|
|
58
|
+
paddingVertical: dims.padY,
|
|
59
|
+
backgroundColor: bg,
|
|
60
|
+
borderRadius: Number(theme.radii.md),
|
|
61
|
+
borderWidth: tone === 'outline' ? 1 : 0,
|
|
62
|
+
borderColor: border,
|
|
63
|
+
alignSelf: 'flex-start',
|
|
64
|
+
gap: 6,
|
|
65
|
+
},
|
|
66
|
+
style,
|
|
67
|
+
]}
|
|
68
|
+
>
|
|
69
|
+
<Text style={{ color: fg, fontSize: dims.font, fontWeight: '500' }}>{children}</Text>
|
|
70
|
+
{onClose ? (
|
|
71
|
+
<Pressable
|
|
72
|
+
accessibilityRole="button"
|
|
73
|
+
accessibilityLabel={closeLabel}
|
|
74
|
+
onPress={onClose}
|
|
75
|
+
hitSlop={6}
|
|
76
|
+
style={{ marginLeft: 2 }}
|
|
77
|
+
>
|
|
78
|
+
<Icon name="x" size={dims.font} color={fg} />
|
|
79
|
+
</Pressable>
|
|
80
|
+
) : null}
|
|
81
|
+
</View>
|
|
82
|
+
);
|
|
83
|
+
});
|