@elvora/react-native 1.0.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +40 -0
  3. package/dist/index.cjs +5785 -0
  4. package/dist/index.cjs.map +1 -0
  5. package/dist/index.d.cts +1253 -0
  6. package/dist/index.d.ts +1253 -0
  7. package/dist/index.js +5683 -0
  8. package/dist/index.js.map +1 -0
  9. package/package.json +88 -0
  10. package/src/Accordion.tsx +11 -0
  11. package/src/Affix.tsx +20 -0
  12. package/src/Alert.tsx +102 -0
  13. package/src/Anchor.tsx +58 -0
  14. package/src/AutoComplete.tsx +122 -0
  15. package/src/Avatar.tsx +58 -0
  16. package/src/BackTop.tsx +71 -0
  17. package/src/Backdrop.tsx +32 -0
  18. package/src/Badge.tsx +87 -0
  19. package/src/Box.tsx +67 -0
  20. package/src/Breadcrumb.tsx +46 -0
  21. package/src/Button.test.tsx +39 -0
  22. package/src/Button.tsx +127 -0
  23. package/src/ButtonGroup.tsx +74 -0
  24. package/src/Calendar.tsx +165 -0
  25. package/src/Card.tsx +69 -0
  26. package/src/Carousel.tsx +99 -0
  27. package/src/Cascader.tsx +160 -0
  28. package/src/Checkbox.tsx +85 -0
  29. package/src/ChipInput.tsx +130 -0
  30. package/src/Collapse.tsx +120 -0
  31. package/src/ColorPicker.tsx +114 -0
  32. package/src/Container.tsx +22 -0
  33. package/src/DataGrid.tsx +170 -0
  34. package/src/DatePicker.tsx +195 -0
  35. package/src/DateRangePicker.tsx +249 -0
  36. package/src/Descriptions.tsx +98 -0
  37. package/src/Divider.tsx +32 -0
  38. package/src/Drawer.tsx +103 -0
  39. package/src/Dropdown.tsx +15 -0
  40. package/src/ElvoraProvider.tsx +31 -0
  41. package/src/Empty.tsx +34 -0
  42. package/src/FloatButton.tsx +78 -0
  43. package/src/Form.tsx +119 -0
  44. package/src/Grid.tsx +68 -0
  45. package/src/Icon.tsx +49 -0
  46. package/src/IconButton.tsx +28 -0
  47. package/src/Image.tsx +68 -0
  48. package/src/ImageList.tsx +58 -0
  49. package/src/Input.tsx +87 -0
  50. package/src/Label.tsx +46 -0
  51. package/src/List.tsx +82 -0
  52. package/src/Mentions.tsx +148 -0
  53. package/src/Menu.tsx +77 -0
  54. package/src/Modal.tsx +114 -0
  55. package/src/NumberInput.tsx +156 -0
  56. package/src/Pagination.tsx +148 -0
  57. package/src/PaginationVariants.tsx +64 -0
  58. package/src/Popover.tsx +74 -0
  59. package/src/ProForm.tsx +219 -0
  60. package/src/ProLayout.tsx +151 -0
  61. package/src/ProTable.tsx +91 -0
  62. package/src/Progress.tsx +92 -0
  63. package/src/QRCode.tsx +65 -0
  64. package/src/Radio.tsx +98 -0
  65. package/src/Rate.tsx +66 -0
  66. package/src/Result.tsx +64 -0
  67. package/src/Segmented.tsx +75 -0
  68. package/src/Select.tsx +146 -0
  69. package/src/Skeleton.tsx +49 -0
  70. package/src/Slider.tsx +122 -0
  71. package/src/SpeedDial.tsx +87 -0
  72. package/src/Spinner.tsx +29 -0
  73. package/src/Splitter.tsx +91 -0
  74. package/src/Stack.tsx +38 -0
  75. package/src/Statistic.tsx +60 -0
  76. package/src/Stepper.tsx +113 -0
  77. package/src/Steps.tsx +146 -0
  78. package/src/Switch.tsx +52 -0
  79. package/src/Table.tsx +178 -0
  80. package/src/Tabs.tsx +122 -0
  81. package/src/Tag.tsx +83 -0
  82. package/src/Textarea.tsx +22 -0
  83. package/src/TimePicker.tsx +187 -0
  84. package/src/Timeline.tsx +92 -0
  85. package/src/Toast.tsx +140 -0
  86. package/src/ToggleButton.tsx +66 -0
  87. package/src/Tooltip.tsx +56 -0
  88. package/src/Tour.tsx +118 -0
  89. package/src/Transfer.tsx +219 -0
  90. package/src/Tree.tsx +144 -0
  91. package/src/TreeSelect.tsx +221 -0
  92. package/src/Upload.tsx +109 -0
  93. package/src/Watermark.tsx +76 -0
  94. package/src/index.ts +221 -0
  95. package/src/smoke.test.tsx +113 -0
  96. package/src/test/react-native-stub.tsx +413 -0
  97. package/src/test/react-native-svg-stub.tsx +33 -0
  98. package/src/test/setup.ts +7 -0
@@ -0,0 +1,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
+ });