@retray-dev/ui-kit 6.1.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.
Files changed (32) hide show
  1. package/COMPONENTS.md +4 -4
  2. package/dist/index.d.mts +42 -21
  3. package/dist/index.d.ts +42 -21
  4. package/dist/index.js +679 -628
  5. package/dist/index.mjs +672 -621
  6. package/package.json +1 -1
  7. package/src/components/Accordion/Accordion.tsx +10 -12
  8. package/src/components/Button/Button.tsx +20 -18
  9. package/src/components/Card/Card.tsx +21 -33
  10. package/src/components/CategoryStrip/CategoryStrip.tsx +45 -38
  11. package/src/components/Checkbox/Checkbox.tsx +31 -50
  12. package/src/components/Chip/Chip.tsx +34 -71
  13. package/src/components/IconButton/IconButton.tsx +20 -18
  14. package/src/components/Input/Input.tsx +39 -22
  15. package/src/components/ListItem/ListItem.tsx +22 -34
  16. package/src/components/MediaCard/MediaCard.tsx +24 -24
  17. package/src/components/MenuItem/MenuItem.tsx +22 -31
  18. package/src/components/MonthPicker/MonthPicker.tsx +12 -2
  19. package/src/components/Pressable/Pressable.tsx +27 -46
  20. package/src/components/Progress/Progress.tsx +21 -12
  21. package/src/components/RadioGroup/RadioGroup.tsx +52 -26
  22. package/src/components/Select/Select.tsx +17 -15
  23. package/src/components/Sheet/Sheet.tsx +4 -1
  24. package/src/components/Skeleton/Skeleton.tsx +24 -13
  25. package/src/components/Slider/Slider.tsx +11 -1
  26. package/src/components/Switch/Switch.tsx +44 -49
  27. package/src/components/Tabs/Tabs.tsx +39 -31
  28. package/src/components/Textarea/Textarea.tsx +29 -12
  29. package/src/components/Toggle/Toggle.tsx +39 -45
  30. package/src/utils/animations.ts +58 -0
  31. package/src/utils/useColorTransition.ts +40 -0
  32. 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, Animated, StyleSheet, ViewStyle, Platform } from 'react-native'
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: any) => void
50
+ onLayout: (e: LayoutChangeEvent) => void
46
51
  variant: TabsVariant
47
52
  }) {
48
53
  const { colors } = useTheme()
49
- const scale = useRef(new Animated.Value(1)).current
50
-
51
- const handlePressIn = () => {
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={handlePressIn}
70
- onPressOut={handlePressOut}
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={{ transform: [{ scale }] }}>
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
- const pillX = useRef(new Animated.Value(0)).current
103
- const pillWidth = useRef(new Animated.Value(0)).current
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
- Animated.parallel([
111
- Animated.spring(pillX, { toValue: layout.x, useNativeDriver: false, stiffness: 380, damping: 38, mass: 1.0 }),
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.setValue(layout.x)
116
- pillWidth.setValue(layout.width)
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 style={[
133
- variant === 'pill' ? [styles.list, { backgroundColor: colors.surface }] : styles.listUnderline,
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: pillX,
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
- borderColor: error
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 style={[styles.helperText, { color: colors.destructive }]} allowFontScaling={true}>{error}</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: 1,
132
+ borderWidth: 2,
116
133
  borderRadius: 8,
117
134
  paddingHorizontal: s(14),
118
135
  paddingVertical: vs(11),
@@ -1,12 +1,17 @@
1
- import React, { useRef, useEffect } from 'react'
2
- import { TouchableOpacity, Animated, StyleSheet, TouchableOpacityProps, ViewStyle, View, Easing, Platform } from 'react-native'
3
-
4
- const nativeDriver = Platform.OS !== 'web'
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 scale = useRef(new Animated.Value(1)).current
66
- // 0 = unpressed, 1 = pressed — used to interpolate colors (JS thread)
67
- const pressAnim = useRef(new Animated.Value(pressed ? 1 : 0)).current
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 backgroundColor = pressAnim.interpolate({
95
- inputRange: [0, 1],
96
- outputRange: ['transparent', colors.surfaceStrong],
97
- })
77
+ const inactiveBorder = variant === 'outline' ? colors.border : 'transparent'
98
78
 
99
- const textColor = pressAnim.interpolate({
100
- inputRange: [0, 1],
101
- outputRange: [colors.foreground, colors.primary],
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 style={[{ transform: [{ scale }] }, disabled && styles.disabled, style]}>
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={handlePressIn}
136
- onPressOut={handlePressOut}
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
- { borderColor, backgroundColor, borderWidth: 2 },
135
+ { borderWidth: 2 },
136
+ surfaceStyle,
147
137
  ]}
148
138
  >
149
139
  <View style={styles.inner}>
150
140
  <LeftIcon />
151
- {label ? <Animated.Text style={[styles.label, { color: textColor }]} allowFontScaling={true}>{label}</Animated.Text> : null}
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
+ }