@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@retray-dev/ui-kit",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "Personal UI Kit for React Native / Expo",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"module": "./dist/index.mjs",
|
|
@@ -14,13 +14,20 @@
|
|
|
14
14
|
},
|
|
15
15
|
"files": [
|
|
16
16
|
"dist",
|
|
17
|
-
"src"
|
|
17
|
+
"src",
|
|
18
|
+
"COMPONENTS.md"
|
|
18
19
|
],
|
|
19
20
|
"scripts": {
|
|
20
21
|
"build": "tsup",
|
|
21
22
|
"dev": "tsup --watch",
|
|
22
23
|
"typecheck": "tsc --noEmit",
|
|
23
|
-
"
|
|
24
|
+
"lint": "eslint src",
|
|
25
|
+
"lint:fix": "eslint src --fix",
|
|
26
|
+
"format": "prettier --write src",
|
|
27
|
+
"format:check": "prettier --check src",
|
|
28
|
+
"lint:all": "pnpm lint && pnpm --filter retray-ui-kit-example lint",
|
|
29
|
+
"format:all": "pnpm format && pnpm --filter retray-ui-kit-example format",
|
|
30
|
+
"deploy": "pnpm typecheck && pnpm build && npm publish --access public"
|
|
24
31
|
},
|
|
25
32
|
"keywords": [
|
|
26
33
|
"react-native",
|
|
@@ -31,12 +38,44 @@
|
|
|
31
38
|
"license": "MIT",
|
|
32
39
|
"peerDependencies": {
|
|
33
40
|
"react": ">=17",
|
|
34
|
-
"react-native": ">=0.70"
|
|
41
|
+
"react-native": ">=0.70",
|
|
42
|
+
"expo-haptics": ">=14.0.0",
|
|
43
|
+
"expo-linear-gradient": ">=13.0.0",
|
|
44
|
+
"@gorhom/bottom-sheet": ">=5.0.0",
|
|
45
|
+
"react-native-reanimated": ">=4.0.0",
|
|
46
|
+
"react-native-gesture-handler": ">=2.0.0",
|
|
47
|
+
"react-native-worklets": ">=0.5.0",
|
|
48
|
+
"react-native-safe-area-context": ">=4.0.0"
|
|
49
|
+
},
|
|
50
|
+
"pnpm": {
|
|
51
|
+
"overrides": {
|
|
52
|
+
"fast-xml-parser": "^5.5.7",
|
|
53
|
+
"react": "19.1.0",
|
|
54
|
+
"react-native": "0.81.5",
|
|
55
|
+
"react-native-worklets": "0.5.1"
|
|
56
|
+
},
|
|
57
|
+
"onlyBuiltDependencies": [
|
|
58
|
+
"esbuild"
|
|
59
|
+
]
|
|
35
60
|
},
|
|
36
61
|
"devDependencies": {
|
|
37
|
-
"@
|
|
62
|
+
"@gorhom/bottom-sheet": "^5.0.0",
|
|
63
|
+
"@types/react": "^19.1.0",
|
|
64
|
+
"expo-haptics": "~15.0.8",
|
|
65
|
+
"expo-linear-gradient": "~14.1.5",
|
|
38
66
|
"react": "18.2.0",
|
|
39
67
|
"react-native": "0.74.0",
|
|
68
|
+
"react-native-gesture-handler": "~2.28.0",
|
|
69
|
+
"react-native-reanimated": "~4.1.1",
|
|
70
|
+
"react-native-worklets": "~0.5.0",
|
|
71
|
+
"react-native-safe-area-context": "~5.6.2",
|
|
72
|
+
"eslint": "^9.0.0",
|
|
73
|
+
"@eslint/js": "^9.0.0",
|
|
74
|
+
"typescript-eslint": "^8.0.0",
|
|
75
|
+
"eslint-plugin-react": "^7.37.0",
|
|
76
|
+
"eslint-plugin-react-hooks": "^5.0.0",
|
|
77
|
+
"eslint-config-prettier": "^10.0.0",
|
|
78
|
+
"prettier": "^3.0.0",
|
|
40
79
|
"tsup": "^8.0.0",
|
|
41
80
|
"typescript": "^5.4.0"
|
|
42
81
|
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import React, { useState, useRef } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
View,
|
|
4
|
+
Text,
|
|
5
|
+
TouchableOpacity,
|
|
6
|
+
Animated,
|
|
7
|
+
StyleSheet,
|
|
8
|
+
LayoutChangeEvent,
|
|
9
|
+
ViewStyle,
|
|
10
|
+
} from 'react-native'
|
|
11
|
+
import ReanimatedAnimated, {
|
|
12
|
+
useSharedValue,
|
|
13
|
+
useAnimatedStyle,
|
|
14
|
+
withTiming,
|
|
15
|
+
Easing,
|
|
16
|
+
} from 'react-native-reanimated'
|
|
17
|
+
import * as Haptics from 'expo-haptics'
|
|
18
|
+
import { useTheme } from '../../theme'
|
|
19
|
+
|
|
20
|
+
export interface AccordionItem {
|
|
21
|
+
value: string
|
|
22
|
+
trigger: string
|
|
23
|
+
content: React.ReactNode
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface AccordionProps {
|
|
27
|
+
items: AccordionItem[]
|
|
28
|
+
/**
|
|
29
|
+
* - `'single'` (default): only one item can be open at a time. Opening another closes the current one.
|
|
30
|
+
* - `'multiple'`: any number of items can be open simultaneously.
|
|
31
|
+
*/
|
|
32
|
+
type?: 'single' | 'multiple'
|
|
33
|
+
/** Item value(s) that should be open on first render. */
|
|
34
|
+
defaultValue?: string | string[]
|
|
35
|
+
style?: ViewStyle
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function AccordionItemComponent({
|
|
39
|
+
item,
|
|
40
|
+
isOpen,
|
|
41
|
+
onToggle,
|
|
42
|
+
}: {
|
|
43
|
+
item: AccordionItem
|
|
44
|
+
isOpen: boolean
|
|
45
|
+
onToggle: () => void
|
|
46
|
+
}) {
|
|
47
|
+
const { colors } = useTheme()
|
|
48
|
+
const animatedHeight = useSharedValue(0)
|
|
49
|
+
const animatedRotation = useSharedValue(0)
|
|
50
|
+
const contentHeight = useRef(0)
|
|
51
|
+
const scale = useRef(new Animated.Value(1)).current
|
|
52
|
+
|
|
53
|
+
const toggle = (open: boolean) => {
|
|
54
|
+
const easing = open ? Easing.out(Easing.ease) : Easing.in(Easing.ease)
|
|
55
|
+
animatedHeight.value = withTiming(open ? contentHeight.current : 0, { duration: 220, easing })
|
|
56
|
+
animatedRotation.value = withTiming(open ? 1 : 0, { duration: 220, easing })
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
React.useEffect(() => {
|
|
60
|
+
toggle(isOpen)
|
|
61
|
+
}, [isOpen])
|
|
62
|
+
|
|
63
|
+
const onLayout = (e: LayoutChangeEvent) => {
|
|
64
|
+
if (contentHeight.current === 0) {
|
|
65
|
+
contentHeight.current = e.nativeEvent.layout.height
|
|
66
|
+
if (isOpen) animatedHeight.value = contentHeight.current
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const heightStyle = useAnimatedStyle(() => ({
|
|
71
|
+
height: animatedHeight.value,
|
|
72
|
+
overflow: 'hidden',
|
|
73
|
+
}))
|
|
74
|
+
|
|
75
|
+
const rotationStyle = useAnimatedStyle(() => ({
|
|
76
|
+
transform: [{ rotate: `${animatedRotation.value * 180}deg` }],
|
|
77
|
+
}))
|
|
78
|
+
|
|
79
|
+
const handlePressIn = () => {
|
|
80
|
+
Animated.spring(scale, { toValue: 0.95, useNativeDriver: true, speed: 40, bounciness: 0 }).start()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const handlePressOut = () => {
|
|
84
|
+
Animated.spring(scale, { toValue: 1, useNativeDriver: true, speed: 40, bounciness: 4 }).start()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<View style={[styles.item, { borderBottomColor: colors.border }]}>
|
|
89
|
+
<Animated.View style={{ transform: [{ scale }] }}>
|
|
90
|
+
<TouchableOpacity
|
|
91
|
+
style={styles.trigger}
|
|
92
|
+
onPress={() => {
|
|
93
|
+
Haptics.selectionAsync()
|
|
94
|
+
onToggle()
|
|
95
|
+
}}
|
|
96
|
+
onPressIn={handlePressIn}
|
|
97
|
+
onPressOut={handlePressOut}
|
|
98
|
+
activeOpacity={1}
|
|
99
|
+
touchSoundDisabled={true}
|
|
100
|
+
>
|
|
101
|
+
<Text style={[styles.triggerText, { color: colors.foreground }]}>{item.trigger}</Text>
|
|
102
|
+
<ReanimatedAnimated.Text
|
|
103
|
+
style={[styles.chevron, { color: colors.foreground }, rotationStyle]}
|
|
104
|
+
>
|
|
105
|
+
▾
|
|
106
|
+
</ReanimatedAnimated.Text>
|
|
107
|
+
</TouchableOpacity>
|
|
108
|
+
</Animated.View>
|
|
109
|
+
|
|
110
|
+
<ReanimatedAnimated.View style={heightStyle}>
|
|
111
|
+
<View style={styles.content} onLayout={onLayout}>
|
|
112
|
+
{item.content}
|
|
113
|
+
</View>
|
|
114
|
+
</ReanimatedAnimated.View>
|
|
115
|
+
</View>
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function Accordion({ items, type = 'single', defaultValue, style }: AccordionProps) {
|
|
120
|
+
const [openValues, setOpenValues] = useState<string[]>(() => {
|
|
121
|
+
if (!defaultValue) return []
|
|
122
|
+
return Array.isArray(defaultValue) ? defaultValue : [defaultValue]
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
const toggle = (value: string) => {
|
|
126
|
+
if (type === 'single') {
|
|
127
|
+
setOpenValues((prev) => (prev.includes(value) ? [] : [value]))
|
|
128
|
+
} else {
|
|
129
|
+
setOpenValues((prev) =>
|
|
130
|
+
prev.includes(value) ? prev.filter((v) => v !== value) : [...prev, value]
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<View style={style}>
|
|
137
|
+
{items.map((item) => (
|
|
138
|
+
<AccordionItemComponent
|
|
139
|
+
key={item.value}
|
|
140
|
+
item={item}
|
|
141
|
+
isOpen={openValues.includes(item.value)}
|
|
142
|
+
onToggle={() => toggle(item.value)}
|
|
143
|
+
/>
|
|
144
|
+
))}
|
|
145
|
+
</View>
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const styles = StyleSheet.create({
|
|
150
|
+
item: {
|
|
151
|
+
borderBottomWidth: 1,
|
|
152
|
+
},
|
|
153
|
+
trigger: {
|
|
154
|
+
flexDirection: 'row',
|
|
155
|
+
justifyContent: 'space-between',
|
|
156
|
+
alignItems: 'center',
|
|
157
|
+
paddingVertical: 16,
|
|
158
|
+
},
|
|
159
|
+
triggerText: {
|
|
160
|
+
fontSize: 15,
|
|
161
|
+
fontWeight: '500',
|
|
162
|
+
flex: 1,
|
|
163
|
+
},
|
|
164
|
+
chevron: {
|
|
165
|
+
fontSize: 16,
|
|
166
|
+
marginLeft: 8,
|
|
167
|
+
},
|
|
168
|
+
content: {
|
|
169
|
+
paddingBottom: 16,
|
|
170
|
+
position: 'absolute',
|
|
171
|
+
width: '100%',
|
|
172
|
+
},
|
|
173
|
+
})
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { View, Text, StyleSheet, ViewStyle } from 'react-native'
|
|
3
|
+
import { useTheme } from '../../theme'
|
|
4
|
+
|
|
5
|
+
export type AlertVariant = 'default' | 'destructive'
|
|
6
|
+
|
|
7
|
+
export interface AlertProps {
|
|
8
|
+
title?: string
|
|
9
|
+
description?: string
|
|
10
|
+
variant?: AlertVariant
|
|
11
|
+
icon?: React.ReactNode
|
|
12
|
+
style?: ViewStyle
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function Alert({ title, description, variant = 'default', icon, style }: AlertProps) {
|
|
16
|
+
const { colors } = useTheme()
|
|
17
|
+
|
|
18
|
+
const borderColor = variant === 'destructive' ? colors.destructive : colors.border
|
|
19
|
+
const titleColor = variant === 'destructive' ? colors.destructive : colors.foreground
|
|
20
|
+
const descColor = variant === 'destructive' ? colors.destructive : colors.mutedForeground
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<View style={[styles.container, { backgroundColor: colors.card, borderColor }, style]}>
|
|
24
|
+
{icon ? <View style={styles.icon}>{icon}</View> : null}
|
|
25
|
+
<View style={styles.content}>
|
|
26
|
+
{title ? <Text style={[styles.title, { color: titleColor }]}>{title}</Text> : null}
|
|
27
|
+
{description ? (
|
|
28
|
+
<Text style={[styles.description, { color: descColor }]}>{description}</Text>
|
|
29
|
+
) : null}
|
|
30
|
+
</View>
|
|
31
|
+
</View>
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const styles = StyleSheet.create({
|
|
36
|
+
container: {
|
|
37
|
+
flexDirection: 'row',
|
|
38
|
+
borderWidth: 1,
|
|
39
|
+
borderRadius: 8,
|
|
40
|
+
padding: 16,
|
|
41
|
+
gap: 12,
|
|
42
|
+
},
|
|
43
|
+
icon: {
|
|
44
|
+
marginTop: 2,
|
|
45
|
+
},
|
|
46
|
+
content: {
|
|
47
|
+
flex: 1,
|
|
48
|
+
gap: 4,
|
|
49
|
+
},
|
|
50
|
+
title: {
|
|
51
|
+
fontSize: 14,
|
|
52
|
+
fontWeight: '500',
|
|
53
|
+
lineHeight: 20,
|
|
54
|
+
},
|
|
55
|
+
description: {
|
|
56
|
+
fontSize: 14,
|
|
57
|
+
lineHeight: 20,
|
|
58
|
+
},
|
|
59
|
+
})
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import React, { useState } from 'react'
|
|
2
|
+
import { View, Text, Image, StyleSheet, ViewStyle } from 'react-native'
|
|
3
|
+
import { useTheme } from '../../theme'
|
|
4
|
+
|
|
5
|
+
export type AvatarSize = 'sm' | 'md' | 'lg' | 'xl'
|
|
6
|
+
|
|
7
|
+
export interface AvatarProps {
|
|
8
|
+
/** Remote image URI. Falls back to `fallback` initials on error or when omitted. */
|
|
9
|
+
src?: string
|
|
10
|
+
/** Up to 2 characters shown when the image is unavailable. Auto-uppercased. Defaults to `'?'`. */
|
|
11
|
+
fallback?: string
|
|
12
|
+
size?: AvatarSize
|
|
13
|
+
style?: ViewStyle
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const sizeMap: Record<AvatarSize, number> = {
|
|
17
|
+
sm: 24,
|
|
18
|
+
md: 32,
|
|
19
|
+
lg: 48,
|
|
20
|
+
xl: 64,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const fontSizeMap: Record<AvatarSize, number> = {
|
|
24
|
+
sm: 10,
|
|
25
|
+
md: 13,
|
|
26
|
+
lg: 18,
|
|
27
|
+
xl: 24,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function Avatar({ src, fallback, size = 'md', style }: AvatarProps) {
|
|
31
|
+
const { colors } = useTheme()
|
|
32
|
+
const [imageError, setImageError] = useState(false)
|
|
33
|
+
const dimension = sizeMap[size]
|
|
34
|
+
const showFallback = !src || imageError
|
|
35
|
+
|
|
36
|
+
const containerStyle: ViewStyle = {
|
|
37
|
+
width: dimension,
|
|
38
|
+
height: dimension,
|
|
39
|
+
borderRadius: dimension / 2,
|
|
40
|
+
backgroundColor: colors.muted,
|
|
41
|
+
overflow: 'hidden',
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<View style={[styles.base, containerStyle, style]}>
|
|
46
|
+
{!showFallback ? (
|
|
47
|
+
<Image
|
|
48
|
+
source={{ uri: src }}
|
|
49
|
+
style={{ width: dimension, height: dimension }}
|
|
50
|
+
onError={() => setImageError(true)}
|
|
51
|
+
/>
|
|
52
|
+
) : (
|
|
53
|
+
<Text
|
|
54
|
+
style={[styles.fallback, { color: colors.mutedForeground, fontSize: fontSizeMap[size] }]}
|
|
55
|
+
>
|
|
56
|
+
{fallback?.slice(0, 2).toUpperCase() ?? '?'}
|
|
57
|
+
</Text>
|
|
58
|
+
)}
|
|
59
|
+
</View>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const styles = StyleSheet.create({
|
|
64
|
+
base: {
|
|
65
|
+
alignItems: 'center',
|
|
66
|
+
justifyContent: 'center',
|
|
67
|
+
},
|
|
68
|
+
fallback: {
|
|
69
|
+
fontWeight: '500',
|
|
70
|
+
},
|
|
71
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { View, Text, StyleSheet, ViewStyle } from 'react-native'
|
|
3
|
+
import { useTheme } from '../../theme'
|
|
4
|
+
|
|
5
|
+
export type BadgeVariant = 'default' | 'secondary' | 'destructive' | 'outline'
|
|
6
|
+
|
|
7
|
+
export interface BadgeProps {
|
|
8
|
+
label: string
|
|
9
|
+
variant?: BadgeVariant
|
|
10
|
+
style?: ViewStyle
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function Badge({ label, variant = 'default', style }: BadgeProps) {
|
|
14
|
+
const { colors } = useTheme()
|
|
15
|
+
|
|
16
|
+
const containerStyle: ViewStyle = {
|
|
17
|
+
default: { backgroundColor: colors.primary },
|
|
18
|
+
secondary: { backgroundColor: colors.secondary },
|
|
19
|
+
destructive: { backgroundColor: colors.destructive },
|
|
20
|
+
outline: { backgroundColor: 'transparent', borderWidth: 1, borderColor: colors.border },
|
|
21
|
+
}[variant]
|
|
22
|
+
|
|
23
|
+
const textColor = {
|
|
24
|
+
default: colors.primaryForeground,
|
|
25
|
+
secondary: colors.secondaryForeground,
|
|
26
|
+
destructive: colors.destructiveForeground,
|
|
27
|
+
outline: colors.foreground,
|
|
28
|
+
}[variant]
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<View style={[styles.container, containerStyle, style]}>
|
|
32
|
+
<Text style={[styles.label, { color: textColor }]} allowFontScaling={true}>{label}</Text>
|
|
33
|
+
</View>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const styles = StyleSheet.create({
|
|
38
|
+
container: {
|
|
39
|
+
borderRadius: 6,
|
|
40
|
+
paddingHorizontal: 8,
|
|
41
|
+
paddingVertical: 2,
|
|
42
|
+
alignSelf: 'flex-start',
|
|
43
|
+
},
|
|
44
|
+
label: {
|
|
45
|
+
fontSize: 12,
|
|
46
|
+
fontWeight: '500',
|
|
47
|
+
},
|
|
48
|
+
})
|
|
@@ -1,49 +1,49 @@
|
|
|
1
|
-
import React from 'react'
|
|
1
|
+
import React, { useRef } from 'react'
|
|
2
2
|
import {
|
|
3
3
|
TouchableOpacity,
|
|
4
4
|
Text,
|
|
5
|
+
Animated,
|
|
5
6
|
ActivityIndicator,
|
|
6
7
|
StyleSheet,
|
|
7
8
|
TouchableOpacityProps,
|
|
8
9
|
ViewStyle,
|
|
9
10
|
TextStyle,
|
|
10
11
|
} from 'react-native'
|
|
12
|
+
import * as Haptics from 'expo-haptics'
|
|
13
|
+
import { useTheme } from '../../theme'
|
|
11
14
|
|
|
12
15
|
export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost'
|
|
13
16
|
export type ButtonSize = 'sm' | 'md' | 'lg'
|
|
14
17
|
|
|
15
18
|
export interface ButtonProps extends TouchableOpacityProps {
|
|
16
19
|
label: string
|
|
20
|
+
/**
|
|
21
|
+
* - `primary`: filled with `primary` token — main CTA
|
|
22
|
+
* - `secondary`: filled with `secondary` token — less prominent
|
|
23
|
+
* - `outline`: transparent with border — alternative actions
|
|
24
|
+
* - `ghost`: fully transparent — in-context or low-emphasis actions
|
|
25
|
+
*/
|
|
17
26
|
variant?: ButtonVariant
|
|
18
27
|
size?: ButtonSize
|
|
28
|
+
/** Replaces the label with a spinner and forces `disabled`. */
|
|
19
29
|
loading?: boolean
|
|
20
30
|
fullWidth?: boolean
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
secondary: { backgroundColor: '#6B7280' },
|
|
26
|
-
outline: { backgroundColor: 'transparent', borderWidth: 1.5, borderColor: '#000' },
|
|
27
|
-
ghost: { backgroundColor: 'transparent' },
|
|
31
|
+
/** Icon rendered alongside the label. */
|
|
32
|
+
icon?: React.ReactNode
|
|
33
|
+
/** Side the icon appears on. Defaults to `'left'`. */
|
|
34
|
+
iconPosition?: 'left' | 'right'
|
|
28
35
|
}
|
|
29
36
|
|
|
30
37
|
const containerSizeStyles: Record<ButtonSize, ViewStyle> = {
|
|
31
|
-
sm: { paddingHorizontal:
|
|
32
|
-
md: { paddingHorizontal:
|
|
33
|
-
lg: { paddingHorizontal:
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const labelVariantStyles: Record<ButtonVariant, TextStyle> = {
|
|
37
|
-
primary: { color: '#fff' },
|
|
38
|
-
secondary: { color: '#fff' },
|
|
39
|
-
outline: { color: '#000' },
|
|
40
|
-
ghost: { color: '#000' },
|
|
38
|
+
sm: { paddingHorizontal: 16, paddingVertical: 10 },
|
|
39
|
+
md: { paddingHorizontal: 20, paddingVertical: 14 },
|
|
40
|
+
lg: { paddingHorizontal: 28, paddingVertical: 18 },
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
const labelSizeStyles: Record<ButtonSize, TextStyle> = {
|
|
44
|
-
sm: { fontSize:
|
|
45
|
-
md: { fontSize:
|
|
46
|
-
lg: { fontSize:
|
|
44
|
+
sm: { fontSize: 14 },
|
|
45
|
+
md: { fontSize: 16 },
|
|
46
|
+
lg: { fontSize: 18 },
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
export function Button({
|
|
@@ -52,37 +52,83 @@ export function Button({
|
|
|
52
52
|
size = 'md',
|
|
53
53
|
loading = false,
|
|
54
54
|
fullWidth = false,
|
|
55
|
+
icon,
|
|
56
|
+
iconPosition = 'left',
|
|
55
57
|
disabled,
|
|
56
58
|
style,
|
|
59
|
+
onPress,
|
|
57
60
|
...props
|
|
58
61
|
}: ButtonProps) {
|
|
62
|
+
const { colors } = useTheme()
|
|
59
63
|
const isDisabled = disabled || loading
|
|
64
|
+
const scale = useRef(new Animated.Value(1)).current
|
|
65
|
+
|
|
66
|
+
const handlePressIn = () => {
|
|
67
|
+
if (isDisabled) return
|
|
68
|
+
Animated.spring(scale, {
|
|
69
|
+
toValue: 0.95,
|
|
70
|
+
useNativeDriver: true,
|
|
71
|
+
speed: 40,
|
|
72
|
+
bounciness: 0,
|
|
73
|
+
}).start()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const handlePressOut = () => {
|
|
77
|
+
Animated.spring(scale, { toValue: 1, useNativeDriver: true, speed: 40, bounciness: 4 }).start()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const handlePress: TouchableOpacityProps['onPress'] = (e) => {
|
|
81
|
+
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)
|
|
82
|
+
onPress?.(e)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const containerVariantStyle: ViewStyle = {
|
|
86
|
+
primary: { backgroundColor: colors.primary },
|
|
87
|
+
secondary: { backgroundColor: colors.secondary },
|
|
88
|
+
outline: { backgroundColor: 'transparent', borderWidth: 1.5, borderColor: colors.border },
|
|
89
|
+
ghost: { backgroundColor: 'transparent' },
|
|
90
|
+
}[variant]
|
|
91
|
+
|
|
92
|
+
const labelVariantStyle: TextStyle = {
|
|
93
|
+
primary: { color: colors.primaryForeground },
|
|
94
|
+
secondary: { color: colors.secondaryForeground },
|
|
95
|
+
outline: { color: colors.foreground },
|
|
96
|
+
ghost: { color: colors.foreground },
|
|
97
|
+
}[variant]
|
|
98
|
+
|
|
99
|
+
const spinnerColor =
|
|
100
|
+
variant === 'primary' || variant === 'secondary' ? colors.primaryForeground : colors.foreground
|
|
60
101
|
|
|
61
102
|
return (
|
|
62
|
-
<
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
103
|
+
<Animated.View style={[fullWidth && styles.fullWidth, { transform: [{ scale }] }]}>
|
|
104
|
+
<TouchableOpacity
|
|
105
|
+
style={[
|
|
106
|
+
styles.base,
|
|
107
|
+
containerVariantStyle,
|
|
108
|
+
containerSizeStyles[size],
|
|
109
|
+
fullWidth && styles.fullWidth,
|
|
110
|
+
isDisabled && styles.disabled,
|
|
111
|
+
style,
|
|
112
|
+
]}
|
|
113
|
+
disabled={isDisabled}
|
|
114
|
+
activeOpacity={1}
|
|
115
|
+
touchSoundDisabled={true}
|
|
116
|
+
onPress={handlePress}
|
|
117
|
+
onPressIn={handlePressIn}
|
|
118
|
+
onPressOut={handlePressOut}
|
|
119
|
+
{...props}
|
|
120
|
+
>
|
|
121
|
+
{loading ? (
|
|
122
|
+
<ActivityIndicator size="small" color={spinnerColor} />
|
|
123
|
+
) : (
|
|
124
|
+
<>
|
|
125
|
+
{icon && iconPosition === 'left' && <>{icon}</>}
|
|
126
|
+
<Text style={[styles.label, labelVariantStyle, labelSizeStyles[size], icon ? styles.labelWithIcon : undefined]}>{label}</Text>
|
|
127
|
+
{icon && iconPosition === 'right' && <>{icon}</>}
|
|
128
|
+
</>
|
|
129
|
+
)}
|
|
130
|
+
</TouchableOpacity>
|
|
131
|
+
</Animated.View>
|
|
86
132
|
)
|
|
87
133
|
}
|
|
88
134
|
|
|
@@ -102,4 +148,7 @@ const styles = StyleSheet.create({
|
|
|
102
148
|
label: {
|
|
103
149
|
fontWeight: '600',
|
|
104
150
|
},
|
|
151
|
+
labelWithIcon: {
|
|
152
|
+
marginHorizontal: 6,
|
|
153
|
+
},
|
|
105
154
|
})
|