@retray-dev/ui-kit 0.1.0 → 1.5.0
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/COMPONENTS.md +710 -0
- package/LICENSE +21 -0
- package/README.md +150 -0
- package/dist/index.d.mts +345 -4
- package/dist/index.d.ts +345 -4
- package/dist/index.js +1644 -58
- package/dist/index.mjs +1590 -58
- package/package.json +44 -5
- package/src/components/Accordion/Accordion.tsx +173 -0
- package/src/components/Accordion/index.ts +2 -0
- package/src/components/Alert/Alert.tsx +59 -0
- package/src/components/Alert/index.ts +2 -0
- package/src/components/Avatar/Avatar.tsx +71 -0
- package/src/components/Avatar/index.ts +2 -0
- package/src/components/Badge/Badge.tsx +48 -0
- package/src/components/Badge/index.ts +2 -0
- package/src/components/Button/Button.tsx +94 -45
- package/src/components/Card/Card.tsx +103 -0
- package/src/components/Card/index.ts +9 -0
- package/src/components/Checkbox/Checkbox.tsx +98 -0
- package/src/components/Checkbox/index.ts +2 -0
- package/src/components/EmptyState/EmptyState.tsx +67 -0
- package/src/components/EmptyState/index.ts +2 -0
- package/src/components/Input/Input.tsx +28 -35
- package/src/components/Progress/Progress.tsx +52 -0
- package/src/components/Progress/index.ts +2 -0
- package/src/components/RadioGroup/RadioGroup.tsx +132 -0
- package/src/components/RadioGroup/index.ts +2 -0
- package/src/components/Select/Select.tsx +232 -0
- package/src/components/Select/index.ts +2 -0
- package/src/components/Separator/Separator.tsx +33 -0
- package/src/components/Separator/index.ts +2 -0
- package/src/components/Sheet/Sheet.tsx +115 -0
- package/src/components/Sheet/index.ts +2 -0
- package/src/components/Skeleton/Skeleton.tsx +63 -0
- package/src/components/Skeleton/index.ts +2 -0
- package/src/components/Slider/Slider.tsx +143 -0
- package/src/components/Slider/index.ts +2 -0
- package/src/components/Spinner/Spinner.tsx +21 -0
- package/src/components/Spinner/index.ts +2 -0
- package/src/components/Switch/Switch.tsx +86 -0
- package/src/components/Switch/index.ts +2 -0
- package/src/components/Tabs/Tabs.tsx +196 -0
- package/src/components/Tabs/index.ts +2 -0
- package/src/components/Text/Text.tsx +10 -4
- package/src/components/Textarea/Textarea.tsx +89 -0
- package/src/components/Textarea/index.ts +2 -0
- package/src/components/Toast/Toast.tsx +200 -0
- package/src/components/Toast/index.ts +2 -0
- package/src/components/Toggle/Toggle.tsx +92 -0
- package/src/components/Toggle/index.ts +2 -0
- package/src/index.ts +26 -0
- package/src/theme/ThemeProvider.tsx +47 -0
- package/src/theme/colors.ts +45 -0
- package/src/theme/index.ts +4 -0
- package/src/theme/types.ts +33 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import React, { useEffect, useRef } from 'react'
|
|
2
|
+
import { TouchableOpacity, Animated, StyleSheet, ViewStyle } from 'react-native'
|
|
3
|
+
import * as Haptics from 'expo-haptics'
|
|
4
|
+
import { useTheme } from '../../theme'
|
|
5
|
+
|
|
6
|
+
const TRACK_WIDTH = 56
|
|
7
|
+
const TRACK_HEIGHT = 32
|
|
8
|
+
const THUMB_SIZE = 24
|
|
9
|
+
const THUMB_OFFSET = 4
|
|
10
|
+
const THUMB_TRAVEL = TRACK_WIDTH - THUMB_SIZE - THUMB_OFFSET * 2
|
|
11
|
+
|
|
12
|
+
export interface SwitchProps {
|
|
13
|
+
checked?: boolean
|
|
14
|
+
onCheckedChange?: (checked: boolean) => void
|
|
15
|
+
disabled?: boolean
|
|
16
|
+
style?: ViewStyle
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function Switch({ checked = false, onCheckedChange, disabled, style }: SwitchProps) {
|
|
20
|
+
const { colors } = useTheme()
|
|
21
|
+
const translateX = useRef(new Animated.Value(checked ? THUMB_TRAVEL : 0)).current
|
|
22
|
+
const trackOpacity = useRef(new Animated.Value(checked ? 1 : 0)).current
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
Animated.parallel([
|
|
26
|
+
Animated.spring(translateX, {
|
|
27
|
+
toValue: checked ? THUMB_TRAVEL : 0,
|
|
28
|
+
useNativeDriver: true,
|
|
29
|
+
bounciness: 4,
|
|
30
|
+
}),
|
|
31
|
+
Animated.timing(trackOpacity, {
|
|
32
|
+
toValue: checked ? 1 : 0,
|
|
33
|
+
duration: 150,
|
|
34
|
+
useNativeDriver: false,
|
|
35
|
+
}),
|
|
36
|
+
]).start()
|
|
37
|
+
}, [checked, translateX, trackOpacity])
|
|
38
|
+
|
|
39
|
+
const trackColor = trackOpacity.interpolate({
|
|
40
|
+
inputRange: [0, 1],
|
|
41
|
+
outputRange: [colors.muted, colors.primary],
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<TouchableOpacity
|
|
46
|
+
onPress={() => {
|
|
47
|
+
Haptics.selectionAsync()
|
|
48
|
+
onCheckedChange?.(!checked)
|
|
49
|
+
}}
|
|
50
|
+
disabled={disabled}
|
|
51
|
+
activeOpacity={0.8}
|
|
52
|
+
touchSoundDisabled={true}
|
|
53
|
+
style={[styles.wrapper, { opacity: disabled ? 0.45 : 1 }, style]}
|
|
54
|
+
>
|
|
55
|
+
<Animated.View style={[styles.track, { backgroundColor: trackColor }]}>
|
|
56
|
+
<Animated.View
|
|
57
|
+
style={[
|
|
58
|
+
styles.thumb,
|
|
59
|
+
{ backgroundColor: colors.primaryForeground, transform: [{ translateX }] },
|
|
60
|
+
]}
|
|
61
|
+
/>
|
|
62
|
+
</Animated.View>
|
|
63
|
+
</TouchableOpacity>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const styles = StyleSheet.create({
|
|
68
|
+
wrapper: {},
|
|
69
|
+
track: {
|
|
70
|
+
width: TRACK_WIDTH,
|
|
71
|
+
height: TRACK_HEIGHT,
|
|
72
|
+
borderRadius: TRACK_HEIGHT / 2,
|
|
73
|
+
justifyContent: 'center',
|
|
74
|
+
paddingHorizontal: THUMB_OFFSET,
|
|
75
|
+
},
|
|
76
|
+
thumb: {
|
|
77
|
+
width: THUMB_SIZE,
|
|
78
|
+
height: THUMB_SIZE,
|
|
79
|
+
borderRadius: THUMB_SIZE / 2,
|
|
80
|
+
shadowColor: '#000',
|
|
81
|
+
shadowOffset: { width: 0, height: 1 },
|
|
82
|
+
shadowOpacity: 0.15,
|
|
83
|
+
shadowRadius: 2,
|
|
84
|
+
elevation: 2,
|
|
85
|
+
},
|
|
86
|
+
})
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import React, { useState, useRef, useEffect } from 'react'
|
|
2
|
+
import { View, TouchableOpacity, Text, Animated, StyleSheet, ViewStyle } from 'react-native'
|
|
3
|
+
import * as Haptics from 'expo-haptics'
|
|
4
|
+
import { useTheme } from '../../theme'
|
|
5
|
+
|
|
6
|
+
export interface TabItem {
|
|
7
|
+
label: string
|
|
8
|
+
value: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface TabsProps {
|
|
12
|
+
tabs: TabItem[]
|
|
13
|
+
/**
|
|
14
|
+
* Controlled active tab value. When omitted the component manages state internally
|
|
15
|
+
* (uncontrolled), defaulting to the first tab.
|
|
16
|
+
*/
|
|
17
|
+
value?: string
|
|
18
|
+
onValueChange?: (value: string) => void
|
|
19
|
+
children?: React.ReactNode
|
|
20
|
+
style?: ViewStyle
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface TabsContentProps {
|
|
24
|
+
value: string
|
|
25
|
+
activeValue: string
|
|
26
|
+
children: React.ReactNode
|
|
27
|
+
style?: ViewStyle
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function TabTrigger({
|
|
31
|
+
tab,
|
|
32
|
+
isActive,
|
|
33
|
+
onPress,
|
|
34
|
+
onLayout,
|
|
35
|
+
}: {
|
|
36
|
+
tab: TabItem
|
|
37
|
+
isActive: boolean
|
|
38
|
+
onPress: () => void
|
|
39
|
+
onLayout: (e: any) => void
|
|
40
|
+
}) {
|
|
41
|
+
const { colors } = useTheme()
|
|
42
|
+
const scale = useRef(new Animated.Value(1)).current
|
|
43
|
+
|
|
44
|
+
const handlePressIn = () => {
|
|
45
|
+
Animated.spring(scale, { toValue: 0.95, useNativeDriver: true, speed: 40, bounciness: 0 }).start()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const handlePressOut = () => {
|
|
49
|
+
Animated.spring(scale, { toValue: 1, useNativeDriver: true, speed: 40, bounciness: 4 }).start()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<TouchableOpacity
|
|
54
|
+
style={styles.trigger}
|
|
55
|
+
onPress={onPress}
|
|
56
|
+
onPressIn={handlePressIn}
|
|
57
|
+
onPressOut={handlePressOut}
|
|
58
|
+
onLayout={onLayout}
|
|
59
|
+
activeOpacity={1}
|
|
60
|
+
touchSoundDisabled={true}
|
|
61
|
+
>
|
|
62
|
+
<Animated.View style={{ transform: [{ scale }] }}>
|
|
63
|
+
<Text
|
|
64
|
+
style={[
|
|
65
|
+
styles.triggerLabel,
|
|
66
|
+
{ color: isActive ? colors.foreground : colors.mutedForeground },
|
|
67
|
+
isActive && styles.activeTriggerLabel,
|
|
68
|
+
]}
|
|
69
|
+
>
|
|
70
|
+
{tab.label}
|
|
71
|
+
</Text>
|
|
72
|
+
</Animated.View>
|
|
73
|
+
</TouchableOpacity>
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function Tabs({ tabs, value, onValueChange, children, style }: TabsProps) {
|
|
78
|
+
const [internal, setInternal] = useState(tabs[0]?.value ?? '')
|
|
79
|
+
const { colors } = useTheme()
|
|
80
|
+
const active = value ?? internal
|
|
81
|
+
|
|
82
|
+
const tabLayouts = useRef<Record<string, { x: number; width: number }>>({})
|
|
83
|
+
const pillX = useRef(new Animated.Value(0)).current
|
|
84
|
+
const pillWidth = useRef(new Animated.Value(0)).current
|
|
85
|
+
const initialised = useRef(false)
|
|
86
|
+
|
|
87
|
+
const animatePill = (tabValue: string, animate: boolean) => {
|
|
88
|
+
const layout = tabLayouts.current[tabValue]
|
|
89
|
+
if (!layout) return
|
|
90
|
+
if (animate) {
|
|
91
|
+
Animated.parallel([
|
|
92
|
+
Animated.spring(pillX, {
|
|
93
|
+
toValue: layout.x,
|
|
94
|
+
useNativeDriver: false,
|
|
95
|
+
speed: 20,
|
|
96
|
+
bounciness: 0,
|
|
97
|
+
}),
|
|
98
|
+
Animated.spring(pillWidth, {
|
|
99
|
+
toValue: layout.width,
|
|
100
|
+
useNativeDriver: false,
|
|
101
|
+
speed: 20,
|
|
102
|
+
bounciness: 0,
|
|
103
|
+
}),
|
|
104
|
+
]).start()
|
|
105
|
+
} else {
|
|
106
|
+
pillX.setValue(layout.x)
|
|
107
|
+
pillWidth.setValue(layout.width)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
if (initialised.current) {
|
|
113
|
+
animatePill(active, true)
|
|
114
|
+
}
|
|
115
|
+
}, [active])
|
|
116
|
+
|
|
117
|
+
const handlePress = (v: string) => {
|
|
118
|
+
Haptics.selectionAsync()
|
|
119
|
+
if (!value) setInternal(v)
|
|
120
|
+
onValueChange?.(v)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<View style={style}>
|
|
125
|
+
<View style={[styles.list, { backgroundColor: colors.muted }]}>
|
|
126
|
+
<Animated.View
|
|
127
|
+
style={[
|
|
128
|
+
styles.pill,
|
|
129
|
+
{
|
|
130
|
+
backgroundColor: colors.background,
|
|
131
|
+
position: 'absolute',
|
|
132
|
+
top: 4,
|
|
133
|
+
bottom: 4,
|
|
134
|
+
left: pillX,
|
|
135
|
+
width: pillWidth,
|
|
136
|
+
borderRadius: 6,
|
|
137
|
+
shadowColor: '#000',
|
|
138
|
+
shadowOffset: { width: 0, height: 1 },
|
|
139
|
+
shadowOpacity: 0.1,
|
|
140
|
+
shadowRadius: 2,
|
|
141
|
+
elevation: 2,
|
|
142
|
+
},
|
|
143
|
+
]}
|
|
144
|
+
/>
|
|
145
|
+
{tabs.map((tab) => (
|
|
146
|
+
<TabTrigger
|
|
147
|
+
key={tab.value}
|
|
148
|
+
tab={tab}
|
|
149
|
+
isActive={tab.value === active}
|
|
150
|
+
onPress={() => handlePress(tab.value)}
|
|
151
|
+
onLayout={(e) => {
|
|
152
|
+
const { x, width } = e.nativeEvent.layout
|
|
153
|
+
tabLayouts.current[tab.value] = { x, width }
|
|
154
|
+
if (tab.value === active) {
|
|
155
|
+
animatePill(tab.value, false)
|
|
156
|
+
initialised.current = true
|
|
157
|
+
}
|
|
158
|
+
}}
|
|
159
|
+
/>
|
|
160
|
+
))}
|
|
161
|
+
</View>
|
|
162
|
+
{children}
|
|
163
|
+
</View>
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function TabsContent({ value, activeValue, children, style }: TabsContentProps) {
|
|
168
|
+
if (value !== activeValue) return null
|
|
169
|
+
return <View style={style}>{children}</View>
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const styles = StyleSheet.create({
|
|
173
|
+
list: {
|
|
174
|
+
flexDirection: 'row',
|
|
175
|
+
borderRadius: 8,
|
|
176
|
+
padding: 4,
|
|
177
|
+
gap: 4,
|
|
178
|
+
},
|
|
179
|
+
pill: {},
|
|
180
|
+
trigger: {
|
|
181
|
+
flex: 1,
|
|
182
|
+
paddingVertical: 8,
|
|
183
|
+
paddingHorizontal: 12,
|
|
184
|
+
borderRadius: 6,
|
|
185
|
+
alignItems: 'center',
|
|
186
|
+
justifyContent: 'center',
|
|
187
|
+
zIndex: 1,
|
|
188
|
+
},
|
|
189
|
+
triggerLabel: {
|
|
190
|
+
fontSize: 14,
|
|
191
|
+
fontWeight: '400',
|
|
192
|
+
},
|
|
193
|
+
activeTriggerLabel: {
|
|
194
|
+
fontWeight: '500',
|
|
195
|
+
},
|
|
196
|
+
})
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import React from 'react'
|
|
2
|
-
import { Text as RNText,
|
|
2
|
+
import { Text as RNText, TextProps as RNTextProps, TextStyle } from 'react-native'
|
|
3
|
+
import { useTheme } from '../../theme'
|
|
3
4
|
|
|
4
5
|
export type TextVariant = 'h1' | 'h2' | 'h3' | 'body' | 'caption' | 'label'
|
|
5
6
|
|
|
@@ -9,18 +10,23 @@ export interface TextProps extends RNTextProps {
|
|
|
9
10
|
}
|
|
10
11
|
|
|
11
12
|
const variantStyles: Record<TextVariant, TextStyle> = {
|
|
12
|
-
h1: { fontSize: 32, fontWeight: '700', lineHeight:
|
|
13
|
+
h1: { fontSize: 32, fontWeight: '700', lineHeight: 44 },
|
|
13
14
|
h2: { fontSize: 24, fontWeight: '700', lineHeight: 32 },
|
|
14
15
|
h3: { fontSize: 20, fontWeight: '600', lineHeight: 28 },
|
|
15
16
|
body: { fontSize: 16, fontWeight: '400', lineHeight: 24 },
|
|
16
|
-
caption: { fontSize: 12, fontWeight: '400', lineHeight: 18
|
|
17
|
+
caption: { fontSize: 12, fontWeight: '400', lineHeight: 18 },
|
|
17
18
|
label: { fontSize: 14, fontWeight: '500', lineHeight: 20 },
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
export function Text({ variant = 'body', color, style, children, ...props }: TextProps) {
|
|
22
|
+
const { colors } = useTheme()
|
|
23
|
+
|
|
24
|
+
const defaultColor = variant === 'caption' ? colors.mutedForeground : colors.foreground
|
|
25
|
+
|
|
21
26
|
return (
|
|
22
27
|
<RNText
|
|
23
|
-
style={[variantStyles[variant], color
|
|
28
|
+
style={[variantStyles[variant], { color: color ?? defaultColor }, style]}
|
|
29
|
+
allowFontScaling={true}
|
|
24
30
|
{...props}
|
|
25
31
|
>
|
|
26
32
|
{children}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import React, { useState } from 'react'
|
|
2
|
+
import { TextInput, View, Text, StyleSheet, TextInputProps, ViewStyle } from 'react-native'
|
|
3
|
+
import { useTheme } from '../../theme'
|
|
4
|
+
|
|
5
|
+
export interface TextareaProps extends TextInputProps {
|
|
6
|
+
label?: string
|
|
7
|
+
/** Red helper text below the textarea; also changes border to `destructive` color. Takes priority over `hint`. */
|
|
8
|
+
error?: string
|
|
9
|
+
/** Helper text shown below the textarea when there is no error. */
|
|
10
|
+
hint?: string
|
|
11
|
+
/** Number of visible text rows. Defaults to `4`. Controls `numberOfLines` and `minHeight`. */
|
|
12
|
+
rows?: number
|
|
13
|
+
/** Style for the outer container `View`. Use `style` (from `TextInputProps`) to style the `TextInput` itself. */
|
|
14
|
+
containerStyle?: ViewStyle
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function Textarea({
|
|
18
|
+
label,
|
|
19
|
+
error,
|
|
20
|
+
hint,
|
|
21
|
+
rows = 4,
|
|
22
|
+
containerStyle,
|
|
23
|
+
style,
|
|
24
|
+
onFocus,
|
|
25
|
+
onBlur,
|
|
26
|
+
...props
|
|
27
|
+
}: TextareaProps) {
|
|
28
|
+
const { colors } = useTheme()
|
|
29
|
+
const [focused, setFocused] = useState(false)
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<View style={[styles.container, containerStyle]}>
|
|
33
|
+
{label ? <Text style={[styles.label, { color: colors.foreground }]} allowFontScaling={true}>{label}</Text> : null}
|
|
34
|
+
<TextInput
|
|
35
|
+
multiline
|
|
36
|
+
numberOfLines={rows}
|
|
37
|
+
textAlignVertical="top"
|
|
38
|
+
style={[
|
|
39
|
+
styles.input,
|
|
40
|
+
{
|
|
41
|
+
borderColor: error ? colors.destructive : focused ? colors.ring : colors.border,
|
|
42
|
+
color: colors.foreground,
|
|
43
|
+
backgroundColor: colors.background,
|
|
44
|
+
minHeight: rows * 28,
|
|
45
|
+
},
|
|
46
|
+
style,
|
|
47
|
+
]}
|
|
48
|
+
onFocus={(e) => {
|
|
49
|
+
setFocused(true)
|
|
50
|
+
onFocus?.(e)
|
|
51
|
+
}}
|
|
52
|
+
onBlur={(e) => {
|
|
53
|
+
setFocused(false)
|
|
54
|
+
onBlur?.(e)
|
|
55
|
+
}}
|
|
56
|
+
placeholderTextColor={colors.mutedForeground}
|
|
57
|
+
allowFontScaling={true}
|
|
58
|
+
{...props}
|
|
59
|
+
/>
|
|
60
|
+
{error ? (
|
|
61
|
+
<Text style={[styles.helperText, { color: colors.destructive }]} allowFontScaling={true}>{error}</Text>
|
|
62
|
+
) : null}
|
|
63
|
+
{!error && hint ? (
|
|
64
|
+
<Text style={[styles.helperText, { color: colors.mutedForeground }]} allowFontScaling={true}>{hint}</Text>
|
|
65
|
+
) : null}
|
|
66
|
+
</View>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const styles = StyleSheet.create({
|
|
71
|
+
container: {
|
|
72
|
+
gap: 4,
|
|
73
|
+
},
|
|
74
|
+
label: {
|
|
75
|
+
fontSize: 14,
|
|
76
|
+
fontWeight: '500',
|
|
77
|
+
marginBottom: 4,
|
|
78
|
+
},
|
|
79
|
+
input: {
|
|
80
|
+
borderWidth: 1.5,
|
|
81
|
+
borderRadius: 8,
|
|
82
|
+
paddingHorizontal: 16,
|
|
83
|
+
paddingVertical: 14,
|
|
84
|
+
fontSize: 16,
|
|
85
|
+
},
|
|
86
|
+
helperText: {
|
|
87
|
+
fontSize: 12,
|
|
88
|
+
},
|
|
89
|
+
})
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import React, { createContext, useContext, useState, useCallback, useEffect } from 'react'
|
|
2
|
+
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'
|
|
3
|
+
import Animated, {
|
|
4
|
+
useSharedValue,
|
|
5
|
+
useAnimatedStyle,
|
|
6
|
+
withSpring,
|
|
7
|
+
withTiming,
|
|
8
|
+
runOnJS,
|
|
9
|
+
Easing,
|
|
10
|
+
} from 'react-native-reanimated'
|
|
11
|
+
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
|
|
12
|
+
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
|
13
|
+
import * as Haptics from 'expo-haptics'
|
|
14
|
+
import { useTheme } from '../../theme'
|
|
15
|
+
|
|
16
|
+
export type ToastVariant = 'default' | 'destructive' | 'success'
|
|
17
|
+
|
|
18
|
+
export interface ToastItem {
|
|
19
|
+
id: string
|
|
20
|
+
title?: string
|
|
21
|
+
description?: string
|
|
22
|
+
variant?: ToastVariant
|
|
23
|
+
/** Auto-dismiss delay in milliseconds. Defaults to `3000`. */
|
|
24
|
+
duration?: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface ToastContextValue {
|
|
28
|
+
toast: (item: Omit<ToastItem, 'id'>) => void
|
|
29
|
+
dismiss: (id: string) => void
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const ToastContext = createContext<ToastContextValue>({
|
|
33
|
+
toast: () => {},
|
|
34
|
+
dismiss: () => {},
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
export function useToast() {
|
|
38
|
+
return useContext(ToastContext)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const SWIPE_THRESHOLD = 80
|
|
42
|
+
const VELOCITY_THRESHOLD = 800
|
|
43
|
+
|
|
44
|
+
function ToastNotification({ item, onDismiss }: { item: ToastItem; onDismiss: () => void }) {
|
|
45
|
+
const { colors } = useTheme()
|
|
46
|
+
const translateY = useSharedValue(-80)
|
|
47
|
+
const translateX = useSharedValue(0)
|
|
48
|
+
const opacity = useSharedValue(0)
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
translateY.value = withTiming(0, { duration: 120, easing: Easing.out(Easing.exp) })
|
|
52
|
+
opacity.value = withTiming(1, { duration: 100 })
|
|
53
|
+
|
|
54
|
+
const timer = setTimeout(() => {
|
|
55
|
+
translateY.value = withTiming(-80, { duration: 200 })
|
|
56
|
+
opacity.value = withTiming(0, { duration: 200 }, (done) => {
|
|
57
|
+
if (done) runOnJS(onDismiss)()
|
|
58
|
+
})
|
|
59
|
+
}, item.duration ?? 3000)
|
|
60
|
+
|
|
61
|
+
return () => clearTimeout(timer)
|
|
62
|
+
}, [])
|
|
63
|
+
|
|
64
|
+
const panGesture = Gesture.Pan()
|
|
65
|
+
.onUpdate((e) => {
|
|
66
|
+
translateX.value = e.translationX
|
|
67
|
+
})
|
|
68
|
+
.onEnd((e) => {
|
|
69
|
+
const shouldDismiss =
|
|
70
|
+
Math.abs(translateX.value) > SWIPE_THRESHOLD ||
|
|
71
|
+
Math.abs(e.velocityX) > VELOCITY_THRESHOLD
|
|
72
|
+
if (shouldDismiss) {
|
|
73
|
+
const direction = translateX.value > 0 ? 1 : -1
|
|
74
|
+
translateX.value = withTiming(direction * 500, { duration: 200 }, (done) => {
|
|
75
|
+
if (done) runOnJS(onDismiss)()
|
|
76
|
+
})
|
|
77
|
+
opacity.value = withTiming(0, { duration: 150 })
|
|
78
|
+
} else {
|
|
79
|
+
translateX.value = withSpring(0, { damping: 20, stiffness: 300 })
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
const animatedStyle = useAnimatedStyle(() => ({
|
|
84
|
+
opacity: opacity.value,
|
|
85
|
+
transform: [{ translateY: translateY.value }, { translateX: translateX.value }],
|
|
86
|
+
}))
|
|
87
|
+
|
|
88
|
+
const bgColor = {
|
|
89
|
+
default: colors.foreground,
|
|
90
|
+
destructive: colors.destructive,
|
|
91
|
+
success: colors.success,
|
|
92
|
+
}[item.variant ?? 'default']
|
|
93
|
+
|
|
94
|
+
const textColor = {
|
|
95
|
+
default: colors.background,
|
|
96
|
+
destructive: colors.destructiveForeground,
|
|
97
|
+
success: colors.successForeground,
|
|
98
|
+
}[item.variant ?? 'default']
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<GestureDetector gesture={panGesture}>
|
|
102
|
+
<Animated.View style={[styles.toast, { backgroundColor: bgColor }, animatedStyle]}>
|
|
103
|
+
<View style={styles.toastContent}>
|
|
104
|
+
{item.title ? (
|
|
105
|
+
<Text style={[styles.toastTitle, { color: textColor }]}>{item.title}</Text>
|
|
106
|
+
) : null}
|
|
107
|
+
{item.description ? (
|
|
108
|
+
<Text style={[styles.toastDescription, { color: textColor, opacity: 0.85 }]}>
|
|
109
|
+
{item.description}
|
|
110
|
+
</Text>
|
|
111
|
+
) : null}
|
|
112
|
+
</View>
|
|
113
|
+
<TouchableOpacity onPress={onDismiss} style={styles.dismissButton} touchSoundDisabled={true}>
|
|
114
|
+
<Text style={[styles.dismissIcon, { color: textColor }]}>✕</Text>
|
|
115
|
+
</TouchableOpacity>
|
|
116
|
+
</Animated.View>
|
|
117
|
+
</GestureDetector>
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Must wrap the app root alongside ThemeProvider.
|
|
123
|
+
* Renders toasts in an absolute overlay at the top of the screen.
|
|
124
|
+
* Use `useToast()` anywhere inside to trigger toasts.
|
|
125
|
+
*/
|
|
126
|
+
export interface ToastProviderProps {
|
|
127
|
+
children: React.ReactNode
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function ToastProvider({ children }: ToastProviderProps) {
|
|
131
|
+
const [toasts, setToasts] = useState<ToastItem[]>([])
|
|
132
|
+
const insets = useSafeAreaInsets()
|
|
133
|
+
|
|
134
|
+
const toast = useCallback((item: Omit<ToastItem, 'id'>) => {
|
|
135
|
+
const id = Math.random().toString(36).slice(2)
|
|
136
|
+
if (item.variant === 'success') {
|
|
137
|
+
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success)
|
|
138
|
+
} else if (item.variant === 'destructive') {
|
|
139
|
+
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error)
|
|
140
|
+
} else {
|
|
141
|
+
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)
|
|
142
|
+
}
|
|
143
|
+
setToasts((prev) => [{ ...item, id }, ...prev].slice(0, 3))
|
|
144
|
+
}, [])
|
|
145
|
+
|
|
146
|
+
const dismiss = useCallback((id: string) => {
|
|
147
|
+
setToasts((prev) => prev.filter((t) => t.id !== id))
|
|
148
|
+
}, [])
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
<ToastContext.Provider value={{ toast, dismiss }}>
|
|
152
|
+
{children}
|
|
153
|
+
<View style={[styles.container, { top: insets.top + 8 }]} pointerEvents="box-none">
|
|
154
|
+
{toasts.map((item) => (
|
|
155
|
+
<ToastNotification key={item.id} item={item} onDismiss={() => dismiss(item.id)} />
|
|
156
|
+
))}
|
|
157
|
+
</View>
|
|
158
|
+
</ToastContext.Provider>
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const styles = StyleSheet.create({
|
|
163
|
+
container: {
|
|
164
|
+
position: 'absolute',
|
|
165
|
+
left: 16,
|
|
166
|
+
right: 16,
|
|
167
|
+
gap: 8,
|
|
168
|
+
zIndex: 9999,
|
|
169
|
+
},
|
|
170
|
+
toast: {
|
|
171
|
+
flexDirection: 'row',
|
|
172
|
+
alignItems: 'center',
|
|
173
|
+
borderRadius: 12,
|
|
174
|
+
paddingHorizontal: 16,
|
|
175
|
+
paddingVertical: 12,
|
|
176
|
+
shadowColor: '#000',
|
|
177
|
+
shadowOffset: { width: 0, height: 4 },
|
|
178
|
+
shadowOpacity: 0.15,
|
|
179
|
+
shadowRadius: 8,
|
|
180
|
+
elevation: 6,
|
|
181
|
+
},
|
|
182
|
+
toastContent: {
|
|
183
|
+
flex: 1,
|
|
184
|
+
gap: 4,
|
|
185
|
+
},
|
|
186
|
+
toastTitle: {
|
|
187
|
+
fontSize: 14,
|
|
188
|
+
fontWeight: '600',
|
|
189
|
+
},
|
|
190
|
+
toastDescription: {
|
|
191
|
+
fontSize: 13,
|
|
192
|
+
},
|
|
193
|
+
dismissButton: {
|
|
194
|
+
padding: 12,
|
|
195
|
+
marginLeft: 4,
|
|
196
|
+
},
|
|
197
|
+
dismissIcon: {
|
|
198
|
+
fontSize: 12,
|
|
199
|
+
},
|
|
200
|
+
})
|