@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/Textarea.tsx
ADDED
|
@@ -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
|
+
}
|
package/src/Timeline.tsx
ADDED
|
@@ -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
|
+
}
|
package/src/Tooltip.tsx
ADDED
|
@@ -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
|
+
}
|