@retray-dev/ui-kit 6.0.0 → 6.2.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 +8 -7
- package/dist/index.d.mts +47 -23
- package/dist/index.d.ts +47 -23
- package/dist/index.js +702 -634
- package/dist/index.mjs +695 -627
- package/package.json +1 -1
- package/src/components/Accordion/Accordion.tsx +10 -12
- package/src/components/Button/Button.tsx +20 -18
- package/src/components/Card/Card.tsx +21 -33
- package/src/components/CategoryStrip/CategoryStrip.tsx +45 -38
- package/src/components/Checkbox/Checkbox.tsx +31 -50
- package/src/components/Chip/Chip.tsx +34 -71
- package/src/components/DetailRow/DetailRow.tsx +13 -8
- package/src/components/IconButton/IconButton.tsx +20 -18
- package/src/components/Input/Input.tsx +39 -22
- package/src/components/ListItem/ListItem.tsx +22 -34
- package/src/components/MediaCard/MediaCard.tsx +24 -24
- package/src/components/MenuItem/MenuItem.tsx +52 -39
- package/src/components/MonthPicker/MonthPicker.tsx +12 -2
- package/src/components/Pressable/Pressable.tsx +27 -46
- package/src/components/Progress/Progress.tsx +21 -12
- package/src/components/RadioGroup/RadioGroup.tsx +52 -26
- package/src/components/Select/Select.tsx +17 -15
- package/src/components/Sheet/Sheet.tsx +4 -1
- package/src/components/Skeleton/Skeleton.tsx +24 -13
- package/src/components/Slider/Slider.tsx +11 -1
- package/src/components/Switch/Switch.tsx +44 -49
- package/src/components/Tabs/Tabs.tsx +39 -31
- package/src/components/Textarea/Textarea.tsx +29 -12
- package/src/components/Toggle/Toggle.tsx +39 -45
- package/src/utils/animations.ts +58 -0
- package/src/utils/useColorTransition.ts +40 -0
- package/src/utils/usePressScale.ts +73 -0
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import React, { useState, useRef, useEffect } from 'react'
|
|
2
|
-
import { View, TouchableOpacity, Text,
|
|
2
|
+
import { View, TouchableOpacity, Text, StyleSheet, ViewStyle, LayoutChangeEvent } from 'react-native'
|
|
3
|
+
import Animated, {
|
|
4
|
+
useSharedValue,
|
|
5
|
+
useAnimatedStyle,
|
|
6
|
+
withSpring,
|
|
7
|
+
} from 'react-native-reanimated'
|
|
3
8
|
import { selectionAsync as hapticSelection } from '../../utils/haptics'
|
|
4
|
-
|
|
5
|
-
const nativeDriver = Platform.OS !== 'web'
|
|
6
9
|
import { useTheme } from '../../theme'
|
|
7
10
|
import { s, vs, ms } from '../../utils/scaling'
|
|
11
|
+
import { usePressScale } from '../../utils/usePressScale'
|
|
12
|
+
import { SPRINGS, PRESS_SCALE } from '../../utils/animations'
|
|
8
13
|
|
|
9
14
|
export interface TabItem {
|
|
10
15
|
label: string
|
|
@@ -42,20 +47,13 @@ function TabTrigger({
|
|
|
42
47
|
tab: TabItem
|
|
43
48
|
isActive: boolean
|
|
44
49
|
onPress: () => void
|
|
45
|
-
onLayout: (e:
|
|
50
|
+
onLayout: (e: LayoutChangeEvent) => void
|
|
46
51
|
variant: TabsVariant
|
|
47
52
|
}) {
|
|
48
53
|
const { colors } = useTheme()
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
Animated.spring(scale, { toValue: 0.95, useNativeDriver: nativeDriver, stiffness: 600, damping: 35, mass: 0.8 }).start()
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const handlePressOut = () => {
|
|
56
|
-
Animated.spring(scale, { toValue: 1, useNativeDriver: nativeDriver, stiffness: 280, damping: 22, mass: 0.8 }).start()
|
|
57
|
-
}
|
|
58
|
-
|
|
54
|
+
const { animatedStyle, onPressIn, onPressOut } = usePressScale({
|
|
55
|
+
pressScale: PRESS_SCALE.button,
|
|
56
|
+
})
|
|
59
57
|
const isUnderline = variant === 'underline'
|
|
60
58
|
|
|
61
59
|
return (
|
|
@@ -66,13 +64,16 @@ function TabTrigger({
|
|
|
66
64
|
isUnderline && isActive && { borderBottomColor: colors.primary },
|
|
67
65
|
]}
|
|
68
66
|
onPress={onPress}
|
|
69
|
-
onPressIn={
|
|
70
|
-
onPressOut={
|
|
67
|
+
onPressIn={onPressIn}
|
|
68
|
+
onPressOut={onPressOut}
|
|
71
69
|
onLayout={onLayout}
|
|
72
70
|
activeOpacity={1}
|
|
73
71
|
touchSoundDisabled={true}
|
|
72
|
+
accessibilityRole="tab"
|
|
73
|
+
accessibilityState={{ selected: isActive }}
|
|
74
|
+
accessibilityLabel={tab.label}
|
|
74
75
|
>
|
|
75
|
-
<Animated.View style={
|
|
76
|
+
<Animated.View style={animatedStyle}>
|
|
76
77
|
<View style={styles.triggerInner}>
|
|
77
78
|
{tab.icon ? (
|
|
78
79
|
(typeof tab.icon === 'function' ? (tab.icon as any)(isActive) : tab.icon) as React.ReactNode
|
|
@@ -99,21 +100,20 @@ export function Tabs({ tabs, variant = 'pill', value, onValueChange, children, s
|
|
|
99
100
|
const active = value ?? internal
|
|
100
101
|
|
|
101
102
|
const tabLayouts = useRef<Record<string, { x: number; width: number }>>({})
|
|
102
|
-
|
|
103
|
-
const
|
|
103
|
+
// Shared values drive the pill position on the UI thread — no JS bridge cost on slide.
|
|
104
|
+
const pillX = useSharedValue(0)
|
|
105
|
+
const pillWidth = useSharedValue(0)
|
|
104
106
|
const initialised = useRef(false)
|
|
105
107
|
|
|
106
108
|
const animatePill = (tabValue: string, animate: boolean) => {
|
|
107
109
|
const layout = tabLayouts.current[tabValue]
|
|
108
110
|
if (!layout) return
|
|
109
111
|
if (animate) {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
Animated.spring(pillWidth, { toValue: layout.width, useNativeDriver: false, stiffness: 380, damping: 38, mass: 1.0 }),
|
|
113
|
-
]).start()
|
|
112
|
+
pillX.value = withSpring(layout.x, SPRINGS.glide)
|
|
113
|
+
pillWidth.value = withSpring(layout.width, SPRINGS.glide)
|
|
114
114
|
} else {
|
|
115
|
-
pillX.
|
|
116
|
-
pillWidth.
|
|
115
|
+
pillX.value = layout.x
|
|
116
|
+
pillWidth.value = layout.width
|
|
117
117
|
}
|
|
118
118
|
}
|
|
119
119
|
|
|
@@ -127,11 +127,19 @@ export function Tabs({ tabs, variant = 'pill', value, onValueChange, children, s
|
|
|
127
127
|
onValueChange?.(v)
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
const pillAnimatedStyle = useAnimatedStyle(() => ({
|
|
131
|
+
transform: [{ translateX: pillX.value }],
|
|
132
|
+
width: pillWidth.value,
|
|
133
|
+
}))
|
|
134
|
+
|
|
130
135
|
return (
|
|
131
136
|
<View style={style}>
|
|
132
|
-
<View
|
|
133
|
-
|
|
134
|
-
|
|
137
|
+
<View
|
|
138
|
+
style={[
|
|
139
|
+
variant === 'pill' ? [styles.list, { backgroundColor: colors.surface }] : styles.listUnderline,
|
|
140
|
+
]}
|
|
141
|
+
accessibilityRole="tablist"
|
|
142
|
+
>
|
|
135
143
|
{variant === 'pill' && (
|
|
136
144
|
<Animated.View
|
|
137
145
|
style={[
|
|
@@ -141,8 +149,7 @@ export function Tabs({ tabs, variant = 'pill', value, onValueChange, children, s
|
|
|
141
149
|
position: 'absolute',
|
|
142
150
|
top: 4,
|
|
143
151
|
bottom: 4,
|
|
144
|
-
left:
|
|
145
|
-
width: pillWidth,
|
|
152
|
+
left: 0,
|
|
146
153
|
borderRadius: 8,
|
|
147
154
|
shadowColor: '#000',
|
|
148
155
|
shadowOffset: { width: 0, height: 1 },
|
|
@@ -150,6 +157,7 @@ export function Tabs({ tabs, variant = 'pill', value, onValueChange, children, s
|
|
|
150
157
|
shadowRadius: 2,
|
|
151
158
|
elevation: 2,
|
|
152
159
|
},
|
|
160
|
+
pillAnimatedStyle,
|
|
153
161
|
]}
|
|
154
162
|
/>
|
|
155
163
|
)}
|
|
@@ -178,7 +186,7 @@ export function Tabs({ tabs, variant = 'pill', value, onValueChange, children, s
|
|
|
178
186
|
|
|
179
187
|
export function TabsContent({ value, activeValue, children, style }: TabsContentProps) {
|
|
180
188
|
if (value !== activeValue) return null
|
|
181
|
-
return <View style={style}>{children}</View>
|
|
189
|
+
return <View style={style} accessibilityRole="none">{children}</View>
|
|
182
190
|
}
|
|
183
191
|
|
|
184
192
|
const styles = StyleSheet.create({
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import React, { useState } from 'react'
|
|
2
2
|
import { TextInput, View, Text, StyleSheet, TextInputProps, ViewStyle, Platform } from 'react-native'
|
|
3
|
+
import Animated, {
|
|
4
|
+
useAnimatedStyle,
|
|
5
|
+
interpolateColor,
|
|
6
|
+
} from 'react-native-reanimated'
|
|
3
7
|
import { useTheme } from '../../theme'
|
|
4
8
|
import { s, vs, ms } from '../../utils/scaling'
|
|
5
9
|
import { renderIcon } from '../../utils/icons'
|
|
10
|
+
import { useColorTransition } from '../../utils/useColorTransition'
|
|
11
|
+
import { TIMINGS } from '../../utils/animations'
|
|
6
12
|
|
|
7
13
|
const webInputResetStyle: any =
|
|
8
14
|
Platform.OS === 'web'
|
|
@@ -39,29 +45,33 @@ export function Textarea({
|
|
|
39
45
|
style,
|
|
40
46
|
onFocus,
|
|
41
47
|
onBlur,
|
|
48
|
+
accessibilityLabel,
|
|
42
49
|
...props
|
|
43
50
|
}: TextareaProps) {
|
|
44
51
|
const { colors } = useTheme()
|
|
45
52
|
const [focused, setFocused] = useState(false)
|
|
53
|
+
const focusProgress = useColorTransition(focused, {
|
|
54
|
+
duration: focused ? TIMINGS.focusIn.duration : TIMINGS.focusOut.duration,
|
|
55
|
+
})
|
|
46
56
|
|
|
47
57
|
const resolvedPrefixIcon = prefixIcon
|
|
48
58
|
? renderIcon(prefixIcon, ms(16), prefixIconColor ?? colors.foregroundMuted)
|
|
49
59
|
: prefixIconNode
|
|
50
60
|
|
|
61
|
+
const borderColorStyle = useAnimatedStyle(() => ({
|
|
62
|
+
borderColor: error
|
|
63
|
+
? colors.destructive
|
|
64
|
+
: interpolateColor(focusProgress.value, [0, 1], [colors.border, colors.primary]),
|
|
65
|
+
}))
|
|
66
|
+
|
|
51
67
|
return (
|
|
52
68
|
<View style={[styles.container, containerStyle]}>
|
|
53
69
|
{label ? <Text style={[styles.label, { color: colors.foreground }]} allowFontScaling={true}>{label}</Text> : null}
|
|
54
|
-
<View
|
|
70
|
+
<Animated.View
|
|
55
71
|
style={[
|
|
56
72
|
styles.inputWrapper,
|
|
57
|
-
{
|
|
58
|
-
|
|
59
|
-
? colors.destructive
|
|
60
|
-
: focused
|
|
61
|
-
? (colors.ring ?? colors.primary)
|
|
62
|
-
: colors.border,
|
|
63
|
-
backgroundColor: colors.background,
|
|
64
|
-
},
|
|
73
|
+
{ backgroundColor: colors.background },
|
|
74
|
+
borderColorStyle,
|
|
65
75
|
]}
|
|
66
76
|
>
|
|
67
77
|
{resolvedPrefixIcon ? <View style={styles.prefixIcon}>{resolvedPrefixIcon}</View> : null}
|
|
@@ -88,11 +98,18 @@ export function Textarea({
|
|
|
88
98
|
}}
|
|
89
99
|
placeholderTextColor={colors.foregroundMuted}
|
|
90
100
|
allowFontScaling={true}
|
|
101
|
+
accessibilityLabel={accessibilityLabel ?? label}
|
|
91
102
|
{...props}
|
|
92
103
|
/>
|
|
93
|
-
</View>
|
|
104
|
+
</Animated.View>
|
|
94
105
|
{error ? (
|
|
95
|
-
<Text
|
|
106
|
+
<Text
|
|
107
|
+
style={[styles.helperText, { color: colors.destructive }]}
|
|
108
|
+
allowFontScaling={true}
|
|
109
|
+
accessibilityLiveRegion="polite"
|
|
110
|
+
>
|
|
111
|
+
{error}
|
|
112
|
+
</Text>
|
|
96
113
|
) : null}
|
|
97
114
|
{!error && hint ? (
|
|
98
115
|
<Text style={[styles.helperText, { color: colors.foregroundMuted }]} allowFontScaling={true}>{hint}</Text>
|
|
@@ -112,7 +129,7 @@ const styles = StyleSheet.create({
|
|
|
112
129
|
marginBottom: vs(2),
|
|
113
130
|
},
|
|
114
131
|
inputWrapper: {
|
|
115
|
-
borderWidth:
|
|
132
|
+
borderWidth: 2,
|
|
116
133
|
borderRadius: 8,
|
|
117
134
|
paddingHorizontal: s(14),
|
|
118
135
|
paddingVertical: vs(11),
|
|
@@ -1,12 +1,17 @@
|
|
|
1
|
-
import React
|
|
2
|
-
import { TouchableOpacity,
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { TouchableOpacity, StyleSheet, TouchableOpacityProps, ViewStyle, View } from 'react-native'
|
|
3
|
+
import Animated, {
|
|
4
|
+
useAnimatedStyle,
|
|
5
|
+
interpolateColor,
|
|
6
|
+
} from 'react-native-reanimated'
|
|
5
7
|
import { FontAwesome5 } from '@expo/vector-icons'
|
|
6
8
|
import { selectionAsync as hapticSelection } from '../../utils/haptics'
|
|
7
9
|
import { useTheme } from '../../theme'
|
|
8
10
|
import { s, vs, ms } from '../../utils/scaling'
|
|
9
11
|
import { renderIcon } from '../../utils/icons'
|
|
12
|
+
import { usePressScale } from '../../utils/usePressScale'
|
|
13
|
+
import { useColorTransition } from '../../utils/useColorTransition'
|
|
14
|
+
import { PRESS_SCALE } from '../../utils/animations'
|
|
10
15
|
|
|
11
16
|
export type ToggleVariant = 'default' | 'outline'
|
|
12
17
|
export type ToggleSize = 'sm' | 'md' | 'lg'
|
|
@@ -59,47 +64,26 @@ export function Toggle({
|
|
|
59
64
|
activeIconColor,
|
|
60
65
|
disabled,
|
|
61
66
|
style,
|
|
67
|
+
accessibilityLabel,
|
|
62
68
|
...props
|
|
63
69
|
}: ToggleProps) {
|
|
64
70
|
const { colors } = useTheme()
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
useEffect(() => {
|
|
70
|
-
Animated.timing(pressAnim, {
|
|
71
|
-
toValue: pressed ? 1 : 0,
|
|
72
|
-
duration: 150,
|
|
73
|
-
easing: Easing.out(Easing.ease),
|
|
74
|
-
useNativeDriver: false,
|
|
75
|
-
}).start()
|
|
76
|
-
}, [pressed, pressAnim])
|
|
77
|
-
|
|
78
|
-
const handlePressIn = () => {
|
|
79
|
-
if (disabled) return
|
|
80
|
-
Animated.spring(scale, { toValue: 0.95, useNativeDriver: nativeDriver, stiffness: 600, damping: 35, mass: 0.8 }).start()
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const handlePressOut = () => {
|
|
84
|
-
Animated.spring(scale, { toValue: 1, useNativeDriver: nativeDriver, stiffness: 280, damping: 22, mass: 0.8 }).start()
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// Keep borderWidth constant at 2 to prevent layout jumps when pressing.
|
|
88
|
-
// Animate borderColor and backgroundColor instead.
|
|
89
|
-
const borderColor = pressAnim.interpolate({
|
|
90
|
-
inputRange: [0, 1],
|
|
91
|
-
outputRange: [variant === 'outline' ? colors.border : 'transparent', colors.primary],
|
|
71
|
+
const { animatedStyle: scaleStyle, onPressIn, onPressOut, hoverHandlers } = usePressScale({
|
|
72
|
+
pressScale: PRESS_SCALE.button,
|
|
73
|
+
disabled,
|
|
92
74
|
})
|
|
75
|
+
const progress = useColorTransition(pressed)
|
|
93
76
|
|
|
94
|
-
const
|
|
95
|
-
inputRange: [0, 1],
|
|
96
|
-
outputRange: ['transparent', colors.surfaceStrong],
|
|
97
|
-
})
|
|
77
|
+
const inactiveBorder = variant === 'outline' ? colors.border : 'transparent'
|
|
98
78
|
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
})
|
|
79
|
+
const surfaceStyle = useAnimatedStyle(() => ({
|
|
80
|
+
borderColor: interpolateColor(progress.value, [0, 1], [inactiveBorder, colors.primary]),
|
|
81
|
+
backgroundColor: interpolateColor(progress.value, [0, 1], ['transparent', colors.surfaceStrong]),
|
|
82
|
+
}))
|
|
83
|
+
|
|
84
|
+
const textStyle = useAnimatedStyle(() => ({
|
|
85
|
+
color: interpolateColor(progress.value, [0, 1], [colors.foreground, colors.primary]),
|
|
86
|
+
}))
|
|
103
87
|
|
|
104
88
|
const iconSize = iconSizeMap[size]
|
|
105
89
|
|
|
@@ -121,34 +105,44 @@ export function Toggle({
|
|
|
121
105
|
const custom = renderProp(icon)
|
|
122
106
|
if (custom) return <>{custom}</>
|
|
123
107
|
|
|
124
|
-
// Default: empty circle to signal an action is available
|
|
125
108
|
return <FontAwesome5 name="circle" size={iconSize} color={colors.foregroundMuted} />
|
|
126
109
|
}
|
|
127
110
|
|
|
128
111
|
return (
|
|
129
|
-
<Animated.View
|
|
112
|
+
<Animated.View
|
|
113
|
+
style={[scaleStyle, disabled && styles.disabled, style]}
|
|
114
|
+
{...hoverHandlers}
|
|
115
|
+
>
|
|
130
116
|
<TouchableOpacity
|
|
131
117
|
onPress={() => {
|
|
132
118
|
hapticSelection()
|
|
133
119
|
onPressedChange?.(!pressed)
|
|
134
120
|
}}
|
|
135
|
-
onPressIn={
|
|
136
|
-
onPressOut={
|
|
121
|
+
onPressIn={onPressIn}
|
|
122
|
+
onPressOut={onPressOut}
|
|
137
123
|
disabled={disabled}
|
|
138
124
|
activeOpacity={1}
|
|
139
125
|
touchSoundDisabled={true}
|
|
126
|
+
accessibilityRole="button"
|
|
127
|
+
accessibilityLabel={accessibilityLabel ?? label}
|
|
128
|
+
accessibilityState={{ selected: pressed, disabled: !!disabled }}
|
|
140
129
|
{...props}
|
|
141
130
|
>
|
|
142
131
|
<Animated.View
|
|
143
132
|
style={[
|
|
144
133
|
styles.base,
|
|
145
134
|
sizeStyles[size],
|
|
146
|
-
{
|
|
135
|
+
{ borderWidth: 2 },
|
|
136
|
+
surfaceStyle,
|
|
147
137
|
]}
|
|
148
138
|
>
|
|
149
139
|
<View style={styles.inner}>
|
|
150
140
|
<LeftIcon />
|
|
151
|
-
{label ?
|
|
141
|
+
{label ? (
|
|
142
|
+
<Animated.Text style={[styles.label, textStyle]} allowFontScaling={true}>
|
|
143
|
+
{label}
|
|
144
|
+
</Animated.Text>
|
|
145
|
+
) : null}
|
|
152
146
|
</View>
|
|
153
147
|
</Animated.View>
|
|
154
148
|
</TouchableOpacity>
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Easing } from 'react-native-reanimated'
|
|
2
|
+
|
|
3
|
+
// ─── Spring presets ──────────────────────────────────────────────────────────
|
|
4
|
+
// Tuned for the "Apple HIG / Airbnb" press-feel: snap inward fast, settle out elastically.
|
|
5
|
+
// `stiffness`/`damping`/`mass` model — Reanimated v4 default physics units.
|
|
6
|
+
//
|
|
7
|
+
// pressIn: high stiffness, heavy damping → fast, controlled compression
|
|
8
|
+
// pressOut: lower stiffness, less damping → soft, elastic rebound
|
|
9
|
+
// settle: pillows / drawers / large surfaces → calm, never twitchy
|
|
10
|
+
export const SPRINGS = {
|
|
11
|
+
/** Tight, premium press feel — Buttons, Toggle, Tabs triggers. */
|
|
12
|
+
pressIn: { stiffness: 600, damping: 35, mass: 0.8 },
|
|
13
|
+
pressOut: { stiffness: 280, damping: 22, mass: 0.8 },
|
|
14
|
+
|
|
15
|
+
/** Slightly softer for larger surfaces — Card, ListItem, MenuItem. */
|
|
16
|
+
surfacePressIn: { stiffness: 380, damping: 30, mass: 0.95 },
|
|
17
|
+
surfacePressOut: { stiffness: 220, damping: 20, mass: 0.95 },
|
|
18
|
+
|
|
19
|
+
/** Settled transitions for moving indicators — Tabs pill, Switch thumb. */
|
|
20
|
+
glide: { stiffness: 380, damping: 38, mass: 1.0 },
|
|
21
|
+
|
|
22
|
+
/** Elastic indicator — Switch thumb, RadioGroup dot. */
|
|
23
|
+
elastic: { stiffness: 320, damping: 22, mass: 0.7 },
|
|
24
|
+
} as const
|
|
25
|
+
|
|
26
|
+
// ─── Timing presets ──────────────────────────────────────────────────────────
|
|
27
|
+
// All timings target the UI thread via Reanimated `withTiming`.
|
|
28
|
+
export const TIMINGS = {
|
|
29
|
+
/** Color/opacity transitions on toggles, checkboxes, switches. */
|
|
30
|
+
state: { duration: 160 },
|
|
31
|
+
/** Focus ring on inputs. */
|
|
32
|
+
focusIn: { duration: 140 },
|
|
33
|
+
focusOut: { duration: 100 },
|
|
34
|
+
/** Accordion / collapsible content. */
|
|
35
|
+
expand: { duration: 240 },
|
|
36
|
+
collapse: { duration: 200 },
|
|
37
|
+
/** Skeleton shimmer cycle (full pass). */
|
|
38
|
+
shimmer: { duration: 1400 },
|
|
39
|
+
} as const
|
|
40
|
+
|
|
41
|
+
// ─── Easing presets ──────────────────────────────────────────────────────────
|
|
42
|
+
export const EASINGS = {
|
|
43
|
+
/** Material-style ease-out — natural deceleration for state changes. */
|
|
44
|
+
standard: Easing.bezier(0.2, 0, 0, 1),
|
|
45
|
+
/** Strong ease-out for expanding surfaces (Accordion open). */
|
|
46
|
+
expand: Easing.bezier(0.23, 1, 0.32, 1),
|
|
47
|
+
/** Quick ease-in for collapsing. */
|
|
48
|
+
collapse: Easing.in(Easing.ease),
|
|
49
|
+
} as const
|
|
50
|
+
|
|
51
|
+
// ─── Press scale tokens ──────────────────────────────────────────────────────
|
|
52
|
+
// Per-component press intensities — taken from DESIGN.md.
|
|
53
|
+
export const PRESS_SCALE = {
|
|
54
|
+
button: 0.95,
|
|
55
|
+
card: 0.98,
|
|
56
|
+
row: 0.97,
|
|
57
|
+
chip: 0.94,
|
|
58
|
+
} as const
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { useEffect } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
useSharedValue,
|
|
4
|
+
withTiming,
|
|
5
|
+
interpolateColor,
|
|
6
|
+
type SharedValue,
|
|
7
|
+
} from 'react-native-reanimated'
|
|
8
|
+
import { TIMINGS, EASINGS } from './animations'
|
|
9
|
+
|
|
10
|
+
export interface UseColorTransitionOptions {
|
|
11
|
+
/** Animation duration in ms. Defaults to `160`. */
|
|
12
|
+
duration?: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Drives a 0→1 `SharedValue` based on a boolean state, animated via `withTiming` on the UI thread.
|
|
17
|
+
* Use with Reanimated's `interpolateColor` inside a `useAnimatedStyle` to drive borderColor/backgroundColor/etc.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* const progress = useColorTransition(focused)
|
|
21
|
+
* const animatedStyle = useAnimatedStyle(() => ({
|
|
22
|
+
* borderColor: interpolateColor(progress.value, [0, 1], [colors.border, colors.primary]),
|
|
23
|
+
* }))
|
|
24
|
+
*/
|
|
25
|
+
export function useColorTransition(
|
|
26
|
+
active: boolean,
|
|
27
|
+
options: UseColorTransitionOptions = {},
|
|
28
|
+
): SharedValue<number> {
|
|
29
|
+
const { duration = TIMINGS.state.duration } = options
|
|
30
|
+
const progress = useSharedValue(active ? 1 : 0)
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
progress.value = withTiming(active ? 1 : 0, { duration, easing: EASINGS.standard })
|
|
34
|
+
}, [active, duration, progress])
|
|
35
|
+
|
|
36
|
+
return progress
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Re-export interpolateColor for ergonomic consumer access
|
|
40
|
+
export { interpolateColor }
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { useCallback } from 'react'
|
|
2
|
+
import { Platform } from 'react-native'
|
|
3
|
+
import {
|
|
4
|
+
useSharedValue,
|
|
5
|
+
useAnimatedStyle,
|
|
6
|
+
withSpring,
|
|
7
|
+
} from 'react-native-reanimated'
|
|
8
|
+
import { SPRINGS, PRESS_SCALE } from './animations'
|
|
9
|
+
import { useHover } from './hover'
|
|
10
|
+
|
|
11
|
+
export interface SpringConfig {
|
|
12
|
+
stiffness?: number
|
|
13
|
+
damping?: number
|
|
14
|
+
mass?: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface UsePressScaleOptions {
|
|
18
|
+
/** Scale value while pressed. Defaults to `0.95` (button). */
|
|
19
|
+
pressScale?: number
|
|
20
|
+
/** Scale value while hovered on web. Defaults to `1.02`. Set to `1` to disable. */
|
|
21
|
+
hoverScale?: number
|
|
22
|
+
/** Spring config for press-in. Defaults to `SPRINGS.pressIn`. */
|
|
23
|
+
pressInSpring?: SpringConfig
|
|
24
|
+
/** Spring config for press-out. Defaults to `SPRINGS.pressOut`. */
|
|
25
|
+
pressOutSpring?: SpringConfig
|
|
26
|
+
/** Disable all interaction animations (still returns stable handlers). */
|
|
27
|
+
disabled?: boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Unified press + hover scale primitive.
|
|
32
|
+
* All animation lives on the UI thread via Reanimated v4 worklets — zero JS-thread cost.
|
|
33
|
+
*
|
|
34
|
+
* Returns:
|
|
35
|
+
* - `animatedStyle`: spread onto an `Animated.View` (from `react-native-reanimated`)
|
|
36
|
+
* - `onPressIn` / `onPressOut`: bind to a `TouchableOpacity`
|
|
37
|
+
* - `hoverHandlers`: spread for web hover scaling (no-op on native)
|
|
38
|
+
*/
|
|
39
|
+
export function usePressScale({
|
|
40
|
+
pressScale = PRESS_SCALE.button,
|
|
41
|
+
hoverScale = 1.02,
|
|
42
|
+
pressInSpring = SPRINGS.pressIn,
|
|
43
|
+
pressOutSpring = SPRINGS.pressOut,
|
|
44
|
+
disabled = false,
|
|
45
|
+
}: UsePressScaleOptions = {}) {
|
|
46
|
+
const scale = useSharedValue(1)
|
|
47
|
+
const { hovered, hoverHandlers } = useHover()
|
|
48
|
+
|
|
49
|
+
const onPressIn = useCallback(() => {
|
|
50
|
+
if (disabled) return
|
|
51
|
+
scale.value = withSpring(pressScale, pressInSpring)
|
|
52
|
+
}, [disabled, pressScale, pressInSpring, scale])
|
|
53
|
+
|
|
54
|
+
const onPressOut = useCallback(() => {
|
|
55
|
+
if (disabled) return
|
|
56
|
+
scale.value = withSpring(1, pressOutSpring)
|
|
57
|
+
}, [disabled, pressOutSpring, scale])
|
|
58
|
+
|
|
59
|
+
const hoverActive = Platform.OS === 'web' && hovered && hoverScale !== 1 && !disabled
|
|
60
|
+
|
|
61
|
+
const animatedStyle = useAnimatedStyle(() => ({
|
|
62
|
+
transform: [
|
|
63
|
+
{ scale: scale.value * (hoverActive ? hoverScale : 1) },
|
|
64
|
+
],
|
|
65
|
+
}))
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
animatedStyle,
|
|
69
|
+
onPressIn,
|
|
70
|
+
onPressOut,
|
|
71
|
+
hoverHandlers,
|
|
72
|
+
}
|
|
73
|
+
}
|