@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,22 @@
1
+ import { forwardRef } from 'react';
2
+ import { type TextInput } from 'react-native';
3
+ import { Input, type InputProps } from './Input';
4
+
5
+ export interface TextareaProps extends InputProps {
6
+ rows?: number;
7
+ }
8
+
9
+ /** Multi-line input. Sets `multiline` and numberOfLines on the RN TextInput. */
10
+ export const Textarea = forwardRef<TextInput, TextareaProps>(function Textarea(props, ref) {
11
+ const { rows = 3, style, ...rest } = props;
12
+ return (
13
+ <Input
14
+ ref={ref}
15
+ multiline
16
+ numberOfLines={rows}
17
+ textAlignVertical="top"
18
+ style={[{ minHeight: rows * 24 + 16 }, style]}
19
+ {...rest}
20
+ />
21
+ );
22
+ });
@@ -0,0 +1,187 @@
1
+ import { useState } from 'react';
2
+ import { Modal, Pressable, ScrollView, View, Text, type ViewProps } from 'react-native';
3
+ import { useTheme } from './ElvoraProvider';
4
+
5
+ export interface TimeValue {
6
+ hours: number;
7
+ minutes: number;
8
+ seconds?: number;
9
+ }
10
+
11
+ export interface TimePickerProps extends Omit<ViewProps, 'onChange'> {
12
+ value?: TimeValue | null;
13
+ defaultValue?: TimeValue | null;
14
+ onChange?: (value: TimeValue | null) => void;
15
+ showSeconds?: boolean;
16
+ use12Hours?: boolean;
17
+ minuteStep?: number;
18
+ placeholder?: string;
19
+ isDisabled?: boolean;
20
+ }
21
+
22
+ const pad = (n: number) => String(n).padStart(2, '0');
23
+
24
+ function formatTime(t: TimeValue | null, use12: boolean, showSec: boolean): string {
25
+ if (!t) return '';
26
+ const h = use12 ? ((t.hours + 11) % 12) + 1 : t.hours;
27
+ const main = `${pad(h)}:${pad(t.minutes)}${showSec ? `:${pad(t.seconds ?? 0)}` : ''}`;
28
+ return use12 ? `${main} ${t.hours >= 12 ? 'PM' : 'AM'}` : main;
29
+ }
30
+
31
+ interface ColProps {
32
+ values: number[];
33
+ current?: number;
34
+ format?: (v: number) => string;
35
+ onSelect: (v: number) => void;
36
+ }
37
+
38
+ function Col({ values, current, format, onSelect }: ColProps) {
39
+ const theme = useTheme();
40
+ return (
41
+ <ScrollView style={{ height: 168, width: 56, borderRightWidth: 1, borderRightColor: theme.colors.border }}>
42
+ {values.map((v) => {
43
+ const active = v === current;
44
+ return (
45
+ <Pressable
46
+ key={v}
47
+ onPress={() => onSelect(v)}
48
+ style={{
49
+ paddingVertical: 6,
50
+ paddingHorizontal: 10,
51
+ alignItems: 'center',
52
+ backgroundColor: active ? theme.colors.intent.primary.solid : 'transparent',
53
+ borderRadius: Number(theme.radii.sm),
54
+ }}
55
+ >
56
+ <Text style={{ fontSize: 13, color: active ? theme.colors.intent.primary.solidFg : theme.colors.fg }}>
57
+ {format ? format(v) : pad(v)}
58
+ </Text>
59
+ </Pressable>
60
+ );
61
+ })}
62
+ </ScrollView>
63
+ );
64
+ }
65
+
66
+ export function TimePicker(props: TimePickerProps) {
67
+ const {
68
+ value: valueProp,
69
+ defaultValue = null,
70
+ onChange,
71
+ showSeconds = false,
72
+ use12Hours = false,
73
+ minuteStep = 1,
74
+ placeholder,
75
+ isDisabled,
76
+ style,
77
+ ...rest
78
+ } = props;
79
+ const theme = useTheme();
80
+ const [internal, setInternal] = useState<TimeValue | null>(defaultValue);
81
+ const value = valueProp !== undefined ? valueProp : internal;
82
+ const [isOpen, setOpen] = useState(false);
83
+
84
+ const set = (next: TimeValue | null) => {
85
+ if (valueProp === undefined) setInternal(next);
86
+ onChange?.(next);
87
+ };
88
+
89
+ const hours = Array.from({ length: use12Hours ? 12 : 24 }, (_, i) => (use12Hours ? i + 1 : i));
90
+ const minutes = Array.from({ length: Math.ceil(60 / minuteStep) }, (_, i) => i * minuteStep);
91
+ const seconds = Array.from({ length: 60 }, (_, i) => i);
92
+
93
+ const display = formatTime(value, use12Hours, showSeconds);
94
+ const currentHourCol = value ? (use12Hours ? ((value.hours + 11) % 12) + 1 : value.hours) : undefined;
95
+
96
+ return (
97
+ <View style={style} {...rest}>
98
+ <Pressable
99
+ accessibilityRole="button"
100
+ disabled={isDisabled}
101
+ onPress={() => setOpen(true)}
102
+ style={({ pressed }) => ({
103
+ paddingHorizontal: 12,
104
+ paddingVertical: 8,
105
+ borderWidth: 1,
106
+ borderColor: theme.colors.border,
107
+ borderRadius: Number(theme.radii.md),
108
+ backgroundColor: pressed ? theme.colors.intent.neutral.subtle : theme.colors.surfaceElevated,
109
+ opacity: isDisabled ? 0.6 : 1,
110
+ })}
111
+ >
112
+ <Text style={{ color: display ? theme.colors.fg : theme.colors.fgMuted, fontSize: 14 }}>
113
+ {display || placeholder || (showSeconds ? 'HH:MM:SS' : 'HH:MM')}
114
+ </Text>
115
+ </Pressable>
116
+ <Modal transparent visible={isOpen} animationType="fade" onRequestClose={() => setOpen(false)}>
117
+ <Pressable
118
+ style={{ flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: 'rgba(0,0,0,0.4)' }}
119
+ onPress={() => setOpen(false)}
120
+ >
121
+ <Pressable
122
+ onPress={() => undefined}
123
+ style={{
124
+ backgroundColor: theme.colors.surfaceElevated,
125
+ borderRadius: Number(theme.radii.md),
126
+ flexDirection: 'row',
127
+ padding: 8,
128
+ }}
129
+ >
130
+ <Col
131
+ values={hours}
132
+ current={currentHourCol}
133
+ format={(v) => (use12Hours ? pad(v) : pad(v))}
134
+ onSelect={(h) => {
135
+ let actual = h;
136
+ if (use12Hours) {
137
+ const isPM = (value?.hours ?? 0) >= 12;
138
+ actual = (h % 12) + (isPM ? 12 : 0);
139
+ }
140
+ set({ hours: actual, minutes: value?.minutes ?? 0, seconds: showSeconds ? value?.seconds ?? 0 : undefined });
141
+ }}
142
+ />
143
+ <Col
144
+ values={minutes}
145
+ current={value?.minutes}
146
+ onSelect={(m) => set({ hours: value?.hours ?? 0, minutes: m, seconds: showSeconds ? value?.seconds ?? 0 : undefined })}
147
+ />
148
+ {showSeconds ? (
149
+ <Col
150
+ values={seconds}
151
+ current={value?.seconds}
152
+ onSelect={(s) => set({ hours: value?.hours ?? 0, minutes: value?.minutes ?? 0, seconds: s })}
153
+ />
154
+ ) : null}
155
+ {use12Hours ? (
156
+ <View style={{ paddingHorizontal: 4 }}>
157
+ {(['AM', 'PM'] as const).map((p) => {
158
+ const isPM = (value?.hours ?? 0) >= 12;
159
+ const active = p === 'PM' ? isPM : !isPM;
160
+ return (
161
+ <Pressable
162
+ key={p}
163
+ onPress={() => {
164
+ const base = value?.hours ?? 0;
165
+ const next = p === 'PM' && base < 12 ? base + 12 : p === 'AM' && base >= 12 ? base - 12 : base;
166
+ set({ hours: next, minutes: value?.minutes ?? 0, seconds: showSeconds ? value?.seconds ?? 0 : undefined });
167
+ }}
168
+ style={{
169
+ paddingHorizontal: 10,
170
+ paddingVertical: 6,
171
+ marginVertical: 4,
172
+ borderRadius: Number(theme.radii.sm),
173
+ backgroundColor: active ? theme.colors.intent.primary.solid : 'transparent',
174
+ }}
175
+ >
176
+ <Text style={{ color: active ? theme.colors.intent.primary.solidFg : theme.colors.fg, fontSize: 13 }}>{p}</Text>
177
+ </Pressable>
178
+ );
179
+ })}
180
+ </View>
181
+ ) : null}
182
+ </Pressable>
183
+ </Pressable>
184
+ </Modal>
185
+ </View>
186
+ );
187
+ }
@@ -0,0 +1,92 @@
1
+ import { type ReactNode } from 'react';
2
+ import { View, Text, type ViewProps } from 'react-native';
3
+ import { useTheme } from './ElvoraProvider';
4
+
5
+ export type TimelineItemStatus = 'default' | 'success' | 'danger' | 'warning' | 'info' | 'primary';
6
+
7
+ export interface TimelineItem {
8
+ key: string;
9
+ children: ReactNode;
10
+ label?: ReactNode;
11
+ status?: TimelineItemStatus;
12
+ dot?: ReactNode;
13
+ }
14
+
15
+ export interface TimelineProps extends Omit<ViewProps, 'children'> {
16
+ items: TimelineItem[];
17
+ pending?: ReactNode;
18
+ reverse?: boolean;
19
+ }
20
+
21
+ function colorFor(status: TimelineItemStatus | undefined, theme: ReturnType<typeof useTheme>): string {
22
+ switch (status) {
23
+ case 'success':
24
+ return theme.colors.intent.success.solid;
25
+ case 'danger':
26
+ return theme.colors.intent.danger.solid;
27
+ case 'warning':
28
+ return theme.colors.intent.warning.solid;
29
+ case 'info':
30
+ return theme.colors.intent.info.solid;
31
+ case 'primary':
32
+ return theme.colors.intent.primary.solid;
33
+ default:
34
+ return theme.colors.intent.neutral.solid;
35
+ }
36
+ }
37
+
38
+ export function Timeline(props: TimelineProps) {
39
+ const { items, pending, reverse, style, ...rest } = props;
40
+ const theme = useTheme();
41
+ const list = reverse ? [...items].reverse() : items;
42
+
43
+ return (
44
+ <View style={style} {...rest}>
45
+ {list.map((item, idx) => {
46
+ const isLast = idx === list.length - 1 && !pending;
47
+ return (
48
+ <View key={item.key} style={{ flexDirection: 'row', paddingBottom: isLast ? 0 : 16, gap: 12 }}>
49
+ <View style={{ alignItems: 'center' }}>
50
+ <View
51
+ style={{
52
+ width: 12,
53
+ height: 12,
54
+ borderRadius: 6,
55
+ backgroundColor: colorFor(item.status, theme),
56
+ }}
57
+ />
58
+ {!isLast ? <View style={{ flex: 1, width: 2, backgroundColor: theme.colors.border, marginTop: 4 }} /> : null}
59
+ </View>
60
+ <View style={{ flex: 1 }}>
61
+ {item.label ? (
62
+ <Text style={{ color: theme.colors.fgSubtle, marginBottom: 2 }}>
63
+ {typeof item.label === 'string' ? item.label : null}
64
+ </Text>
65
+ ) : null}
66
+ {typeof item.children === 'string' || typeof item.children === 'number' ? (
67
+ <Text style={{ color: theme.colors.fg }}>{String(item.children)}</Text>
68
+ ) : (
69
+ item.children
70
+ )}
71
+ </View>
72
+ </View>
73
+ );
74
+ })}
75
+ {pending ? (
76
+ <View style={{ flexDirection: 'row', gap: 12 }}>
77
+ <View
78
+ style={{
79
+ width: 12,
80
+ height: 12,
81
+ borderRadius: 6,
82
+ backgroundColor: theme.colors.intent.primary.solid,
83
+ }}
84
+ />
85
+ <Text style={{ color: theme.colors.fgSubtle }}>
86
+ {typeof pending === 'string' ? pending : ''}
87
+ </Text>
88
+ </View>
89
+ ) : null}
90
+ </View>
91
+ );
92
+ }
package/src/Toast.tsx ADDED
@@ -0,0 +1,140 @@
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ type ReactNode,
10
+ } from 'react';
11
+ import { View, type ViewStyle } from 'react-native';
12
+ import { generateId } from '@elvora/core';
13
+ import type { ElvoraStatus } from '@elvora/core';
14
+ import { useTheme } from './ElvoraProvider';
15
+ import { Alert } from './Alert';
16
+
17
+ export interface ToastOptions {
18
+ id?: string;
19
+ status?: ElvoraStatus;
20
+ title?: ReactNode;
21
+ description?: ReactNode;
22
+ /** Duration in ms. `null` for sticky. */
23
+ duration?: number | null;
24
+ action?: ReactNode;
25
+ }
26
+
27
+ interface ToastInstance {
28
+ id: string;
29
+ status: ElvoraStatus;
30
+ title?: ReactNode;
31
+ description?: ReactNode;
32
+ action?: ReactNode;
33
+ duration: number | null;
34
+ }
35
+
36
+ export interface ToastApi {
37
+ show: (opts: ToastOptions) => string;
38
+ dismiss: (id: string) => void;
39
+ dismissAll: () => void;
40
+ }
41
+
42
+ const ToastContext = createContext<ToastApi | null>(null);
43
+
44
+ export interface ToastProviderProps {
45
+ children: ReactNode;
46
+ defaultDuration?: number;
47
+ placement?: 'top' | 'bottom';
48
+ }
49
+
50
+ /** ToastProvider — mount once at the root of the RN app. */
51
+ export function ToastProvider({ children, defaultDuration = 4000, placement = 'top' }: ToastProviderProps) {
52
+ const [toasts, setToasts] = useState<ToastInstance[]>([]);
53
+ const timersRef = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
54
+ const theme = useTheme();
55
+
56
+ const dismiss = useCallback((id: string) => {
57
+ setToasts((curr) => curr.filter((t) => t.id !== id));
58
+ const timer = timersRef.current[id];
59
+ if (timer) {
60
+ clearTimeout(timer);
61
+ delete timersRef.current[id];
62
+ }
63
+ }, []);
64
+
65
+ const show = useCallback<ToastApi['show']>(
66
+ (opts) => {
67
+ const id = opts.id ?? generateId('toast');
68
+ const inst: ToastInstance = {
69
+ id,
70
+ status: opts.status ?? 'info',
71
+ title: opts.title,
72
+ description: opts.description,
73
+ action: opts.action,
74
+ duration: opts.duration === undefined ? defaultDuration : opts.duration,
75
+ };
76
+ setToasts((curr) => [...curr, inst]);
77
+ if (inst.duration !== null && inst.duration > 0) {
78
+ timersRef.current[id] = setTimeout(() => dismiss(id), inst.duration);
79
+ }
80
+ return id;
81
+ },
82
+ [defaultDuration, dismiss],
83
+ );
84
+
85
+ const dismissAll = useCallback(() => {
86
+ setToasts([]);
87
+ for (const t of Object.values(timersRef.current)) clearTimeout(t);
88
+ timersRef.current = {};
89
+ }, []);
90
+
91
+ useEffect(
92
+ () => () => {
93
+ for (const t of Object.values(timersRef.current)) clearTimeout(t);
94
+ },
95
+ [],
96
+ );
97
+
98
+ const api = useMemo<ToastApi>(() => ({ show, dismiss, dismissAll }), [show, dismiss, dismissAll]);
99
+
100
+ const overlay: ViewStyle = {
101
+ position: 'absolute',
102
+ left: 0,
103
+ right: 0,
104
+ alignItems: 'center',
105
+ paddingHorizontal: 16,
106
+ pointerEvents: 'box-none',
107
+ zIndex: theme.tokens.zIndex.toast,
108
+ ...(placement === 'top' ? { top: 24 } : { bottom: 24 }),
109
+ };
110
+
111
+ return (
112
+ <ToastContext.Provider value={api}>
113
+ {children}
114
+ {toasts.length > 0 ? (
115
+ <View style={overlay}>
116
+ {toasts.map((t) => (
117
+ <View key={t.id} style={{ marginVertical: 4, width: '100%', maxWidth: 420 }}>
118
+ <Alert
119
+ status={t.status}
120
+ tone="solid"
121
+ title={t.title}
122
+ description={t.description}
123
+ onClose={() => dismiss(t.id)}
124
+ >
125
+ {t.action}
126
+ </Alert>
127
+ </View>
128
+ ))}
129
+ </View>
130
+ ) : null}
131
+ </ToastContext.Provider>
132
+ );
133
+ }
134
+
135
+ /** useToast — must be inside ToastProvider. */
136
+ export function useToast(): ToastApi {
137
+ const ctx = useContext(ToastContext);
138
+ if (!ctx) throw new Error('useToast must be used inside <ToastProvider>');
139
+ return ctx;
140
+ }
@@ -0,0 +1,66 @@
1
+ import { useState, type ReactNode } from 'react';
2
+ import { Pressable, Text, type ViewStyle } from 'react-native';
3
+ import type { ElvoraSize } from '@elvora/core';
4
+ import { useTheme } from './ElvoraProvider';
5
+
6
+ export interface ToggleButtonProps {
7
+ selected?: boolean;
8
+ defaultSelected?: boolean;
9
+ onChange?: (selected: boolean) => void;
10
+ isDisabled?: boolean;
11
+ size?: ElvoraSize;
12
+ value?: string;
13
+ children?: ReactNode;
14
+ style?: ViewStyle;
15
+ }
16
+
17
+ const sizePadding: Record<ElvoraSize, { padX: number; padY: number; font: number }> = {
18
+ xs: { padX: 8, padY: 4, font: 11 },
19
+ sm: { padX: 10, padY: 6, font: 12 },
20
+ md: { padX: 14, padY: 8, font: 13 },
21
+ lg: { padX: 18, padY: 10, font: 14 },
22
+ xl: { padX: 22, padY: 12, font: 16 },
23
+ };
24
+
25
+ export function ToggleButton(props: ToggleButtonProps) {
26
+ const { selected: selectedProp, defaultSelected = false, onChange, isDisabled, size = 'md', children, style } = props;
27
+ const theme = useTheme();
28
+ const [internal, setInternal] = useState(defaultSelected);
29
+ const selected = selectedProp ?? internal;
30
+ const intent = theme.colors.intent.primary;
31
+ const pad = sizePadding[size];
32
+
33
+ return (
34
+ <Pressable
35
+ accessibilityRole="button"
36
+ accessibilityState={{ selected, disabled: isDisabled }}
37
+ disabled={isDisabled}
38
+ onPress={() => {
39
+ if (selectedProp === undefined) setInternal(!selected);
40
+ onChange?.(!selected);
41
+ }}
42
+ style={({ pressed }) => [
43
+ {
44
+ paddingHorizontal: pad.padX,
45
+ paddingVertical: pad.padY,
46
+ borderRadius: 6,
47
+ borderWidth: 1,
48
+ borderColor: selected ? intent.solid : theme.colors.border,
49
+ backgroundColor: selected ? intent.subtle : theme.colors.surfaceElevated,
50
+ opacity: isDisabled ? 0.5 : pressed ? 0.9 : 1,
51
+ },
52
+ style,
53
+ ]}
54
+ >
55
+ {typeof children === 'string' ? (
56
+ <Text
57
+ style={{ color: selected ? intent.fg : theme.colors.fg, fontSize: pad.font, fontWeight: '500' }}
58
+ >
59
+ {children}
60
+ </Text>
61
+ ) : (
62
+ children
63
+ )}
64
+ </Pressable>
65
+ );
66
+ }
@@ -0,0 +1,56 @@
1
+ import { Children, cloneElement, isValidElement, useState, type ReactElement, type ReactNode } from 'react';
2
+ import { Modal, Pressable, Text, View, type StyleProp, type ViewStyle } from 'react-native';
3
+ import type { Placement } from '@elvora/core';
4
+ import { useTheme } from './ElvoraProvider';
5
+
6
+ export interface TooltipProps {
7
+ label: ReactNode;
8
+ placement?: Placement;
9
+ /** Single child element. On long-press, the tooltip opens; tap dismisses. */
10
+ children: ReactElement;
11
+ style?: StyleProp<ViewStyle>;
12
+ }
13
+
14
+ /**
15
+ * React Native Tooltip — opens on long-press with a centered overlay (most
16
+ * accessible cross-platform pattern; mobile has no hover state). For
17
+ * positioning relative to the trigger, use a dedicated popover library.
18
+ */
19
+ export function Tooltip({ label, children, style }: TooltipProps) {
20
+ const theme = useTheme();
21
+ const [open, setOpen] = useState(false);
22
+ const onlyChild = Children.only(children);
23
+ if (!isValidElement(onlyChild)) return onlyChild;
24
+
25
+ const trigger = cloneElement(onlyChild as ReactElement<Record<string, unknown>>, {
26
+ onLongPress: () => setOpen(true),
27
+ });
28
+
29
+ return (
30
+ <>
31
+ {trigger}
32
+ <Modal visible={open} transparent animationType="fade" onRequestClose={() => setOpen(false)}>
33
+ <Pressable
34
+ onPress={() => setOpen(false)}
35
+ style={{ flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: 'rgba(0,0,0,0.4)' }}
36
+ >
37
+ <View
38
+ accessibilityRole="alert"
39
+ style={[
40
+ {
41
+ backgroundColor: theme.colors.fg,
42
+ paddingHorizontal: 12,
43
+ paddingVertical: 8,
44
+ borderRadius: Number(theme.radii.md),
45
+ maxWidth: '80%',
46
+ },
47
+ style,
48
+ ]}
49
+ >
50
+ <Text style={{ color: theme.colors.background, fontSize: 13 }}>{label}</Text>
51
+ </View>
52
+ </Pressable>
53
+ </Modal>
54
+ </>
55
+ );
56
+ }
package/src/Tour.tsx ADDED
@@ -0,0 +1,118 @@
1
+ import { useState, type ReactNode } from 'react';
2
+ import { Modal, Pressable, View, Text } from 'react-native';
3
+ import { useTheme } from './ElvoraProvider';
4
+
5
+ export interface TourStep {
6
+ title: ReactNode;
7
+ description?: ReactNode;
8
+ nextLabel?: ReactNode;
9
+ prevLabel?: ReactNode;
10
+ }
11
+
12
+ export interface TourProps {
13
+ open?: boolean;
14
+ defaultOpen?: boolean;
15
+ steps: TourStep[];
16
+ current?: number;
17
+ defaultCurrent?: number;
18
+ onChange?: (index: number) => void;
19
+ onClose?: () => void;
20
+ finishLabel?: ReactNode;
21
+ }
22
+
23
+ /**
24
+ * Tour (native) — full-screen modal walkthrough. The web variant supports a
25
+ * spotlight on a DOM element; on native we render each step in a centered
26
+ * card since there is no DOM to highlight by selector.
27
+ */
28
+ export function Tour(props: TourProps) {
29
+ const {
30
+ open: openProp,
31
+ defaultOpen,
32
+ steps,
33
+ current: currentProp,
34
+ defaultCurrent = 0,
35
+ onChange,
36
+ onClose,
37
+ finishLabel = 'Finish',
38
+ } = props;
39
+ const theme = useTheme();
40
+ const [internalOpen, setInternalOpen] = useState(defaultOpen ?? false);
41
+ const [internalCurrent, setInternalCurrent] = useState(defaultCurrent);
42
+ const open = openProp ?? internalOpen;
43
+ const current = currentProp ?? internalCurrent;
44
+ const step = steps[current];
45
+
46
+ if (!open || !step) return null;
47
+
48
+ const close = () => {
49
+ if (openProp === undefined) setInternalOpen(false);
50
+ onClose?.();
51
+ };
52
+
53
+ const goTo = (next: number) => {
54
+ if (next < 0 || next >= steps.length) return;
55
+ if (currentProp === undefined) setInternalCurrent(next);
56
+ onChange?.(next);
57
+ };
58
+
59
+ return (
60
+ <Modal visible transparent animationType="fade" onRequestClose={close}>
61
+ <View style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.55)', alignItems: 'center', justifyContent: 'center', padding: 24 }}>
62
+ <View
63
+ style={{
64
+ width: '100%',
65
+ maxWidth: 360,
66
+ backgroundColor: theme.colors.surfaceElevated,
67
+ borderRadius: 12,
68
+ padding: 16,
69
+ borderWidth: 1,
70
+ borderColor: theme.colors.border,
71
+ }}
72
+ >
73
+ <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
74
+ <Text style={{ color: theme.colors.fg, fontWeight: '600', fontSize: 16 }}>
75
+ {typeof step.title === 'string' ? step.title : ''}
76
+ </Text>
77
+ <Pressable accessibilityRole="button" accessibilityLabel="Close tour" onPress={close} style={{ padding: 6 }}>
78
+ <Text style={{ color: theme.colors.fg, fontSize: 18 }}>×</Text>
79
+ </Pressable>
80
+ </View>
81
+ {step.description ? (
82
+ <Text style={{ color: theme.colors.fgSubtle, marginBottom: 12 }}>
83
+ {typeof step.description === 'string' ? step.description : ''}
84
+ </Text>
85
+ ) : null}
86
+ <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
87
+ <Text style={{ color: theme.colors.fgSubtle, fontSize: 12 }}>{`${current + 1} / ${steps.length}`}</Text>
88
+ <View style={{ flexDirection: 'row', gap: 8 }}>
89
+ {current > 0 ? (
90
+ <Pressable
91
+ onPress={() => goTo(current - 1)}
92
+ style={{ paddingVertical: 8, paddingHorizontal: 12, borderRadius: 6, borderWidth: 1, borderColor: theme.colors.border }}
93
+ >
94
+ <Text style={{ color: theme.colors.fg }}>{typeof step.prevLabel === 'string' ? step.prevLabel : 'Previous'}</Text>
95
+ </Pressable>
96
+ ) : null}
97
+ {current < steps.length - 1 ? (
98
+ <Pressable
99
+ onPress={() => goTo(current + 1)}
100
+ style={{ paddingVertical: 8, paddingHorizontal: 12, borderRadius: 6, backgroundColor: theme.colors.intent.primary.solid }}
101
+ >
102
+ <Text style={{ color: theme.colors.intent.primary.solidFg }}>{typeof step.nextLabel === 'string' ? step.nextLabel : 'Next'}</Text>
103
+ </Pressable>
104
+ ) : (
105
+ <Pressable
106
+ onPress={close}
107
+ style={{ paddingVertical: 8, paddingHorizontal: 12, borderRadius: 6, backgroundColor: theme.colors.intent.primary.solid }}
108
+ >
109
+ <Text style={{ color: theme.colors.intent.primary.solidFg }}>{typeof finishLabel === 'string' ? finishLabel : 'Finish'}</Text>
110
+ </Pressable>
111
+ )}
112
+ </View>
113
+ </View>
114
+ </View>
115
+ </View>
116
+ </Modal>
117
+ );
118
+ }