@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@retray-dev/ui-kit",
3
- "version": "6.1.0",
3
+ "version": "6.2.0",
4
4
  "description": "Personal UI Kit for React Native / Expo",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
@@ -11,17 +11,13 @@ import Animated, {
11
11
  useDerivedValue,
12
12
  useAnimatedStyle,
13
13
  withTiming,
14
- Easing,
15
- type EasingFunction,
16
14
  } from 'react-native-reanimated'
17
-
18
- const easingExpand: EasingFunction = Easing.bezier(0.23, 1, 0.32, 1) as unknown as EasingFunction
19
- const easingCollapse: EasingFunction = Easing.in(Easing.ease)
20
15
  import { Entypo } from '@expo/vector-icons'
21
16
  import { selectionAsync as hapticSelection } from '../../utils/haptics'
22
17
  import { useTheme } from '../../theme'
23
18
  import { s, vs, ms } from '../../utils/scaling'
24
19
  import { renderIcon } from '../../utils/icons'
20
+ import { TIMINGS, EASINGS } from '../../utils/animations'
25
21
 
26
22
  export interface AccordionItem {
27
23
  value: string
@@ -71,20 +67,19 @@ function AccordionItemComponent({
71
67
  isExpanded.value = isOpen
72
68
  }, [isOpen])
73
69
 
74
- // Derived animated height — pattern from Reanimated docs:
75
- // height * Number(isExpanded) gives 0 when closed and the measured height when open.
76
- // withTiming wraps it so every change animates automatically.
70
+ // Derived animated height — height * Number(isExpanded) gives 0 when closed and
71
+ // the measured height when open. `withTiming` wraps it so every change animates.
77
72
  const derivedHeight = useDerivedValue(() =>
78
73
  withTiming(height.value * Number(isExpanded.value), {
79
- duration: 220,
80
- easing: isExpanded.value ? easingExpand : easingCollapse,
74
+ duration: isExpanded.value ? TIMINGS.expand.duration : TIMINGS.collapse.duration,
75
+ easing: isExpanded.value ? EASINGS.expand : EASINGS.collapse,
81
76
  })
82
77
  )
83
78
 
84
79
  const derivedRotation = useDerivedValue(() =>
85
80
  withTiming(isExpanded.value ? 1 : 0, {
86
- duration: 220,
87
- easing: isExpanded.value ? easingExpand : easingCollapse,
81
+ duration: isExpanded.value ? TIMINGS.expand.duration : TIMINGS.collapse.duration,
82
+ easing: isExpanded.value ? EASINGS.expand : EASINGS.collapse,
88
83
  })
89
84
  )
90
85
 
@@ -105,6 +100,9 @@ function AccordionItemComponent({
105
100
  hapticSelection()
106
101
  onToggle()
107
102
  }}
103
+ accessibilityRole="button"
104
+ accessibilityState={{ expanded: isOpen }}
105
+ accessibilityLabel={item.trigger}
108
106
  >
109
107
  <View style={styles.triggerContent}>
110
108
  {resolvedIcon ? <View style={styles.icon}>{resolvedIcon}</View> : null}
@@ -1,22 +1,21 @@
1
- import React, { useRef } from 'react'
1
+ import React from 'react'
2
2
  import {
3
3
  TouchableOpacity,
4
4
  Text,
5
- Animated,
6
5
  ActivityIndicator,
7
6
  StyleSheet,
8
7
  TouchableOpacityProps,
9
8
  ViewStyle,
10
9
  TextStyle,
11
- Platform,
12
10
  } from 'react-native'
13
-
14
- const nativeDriver = Platform.OS !== 'web'
11
+ import Animated from 'react-native-reanimated'
15
12
  import { impactMedium } from '../../utils/haptics'
16
13
  import { useTheme } from '../../theme'
17
14
  import { s, vs, ms, mvs } from '../../utils/scaling'
18
15
  import { renderIcon } from '../../utils/icons'
19
16
  import { RADIUS, TYPOGRAPHY } from '../../tokens'
17
+ import { usePressScale } from '../../utils/usePressScale'
18
+ import { PRESS_SCALE } from '../../utils/animations'
20
19
 
21
20
  // primary: filled primary — main CTA (pill-shaped, Airbnb-style)
22
21
  // secondary: outlined primary border — alternative actions
@@ -65,20 +64,16 @@ export function Button({
65
64
  disabled,
66
65
  style,
67
66
  onPress,
67
+ accessibilityLabel,
68
+ accessibilityHint,
68
69
  ...props
69
70
  }: ButtonProps) {
70
71
  const { colors } = useTheme()
71
72
  const isDisabled = disabled || loading
72
- const scale = useRef(new Animated.Value(1)).current
73
-
74
- const handlePressIn = () => {
75
- if (isDisabled) return
76
- Animated.spring(scale, { toValue: 0.95, useNativeDriver: nativeDriver, stiffness: 600, damping: 35, mass: 0.8 }).start()
77
- }
78
-
79
- const handlePressOut = () => {
80
- Animated.spring(scale, { toValue: 1, useNativeDriver: nativeDriver, stiffness: 280, damping: 22, mass: 0.8 }).start()
81
- }
73
+ const { animatedStyle, onPressIn, onPressOut, hoverHandlers } = usePressScale({
74
+ pressScale: PRESS_SCALE.button,
75
+ disabled: isDisabled,
76
+ })
82
77
 
83
78
  const handlePress: TouchableOpacityProps['onPress'] = (e) => {
84
79
  impactMedium()
@@ -116,7 +111,10 @@ export function Button({
116
111
  const { flex, ...restStyle } = flatStyle || {}
117
112
 
118
113
  return (
119
- <Animated.View style={[fullWidth && styles.fullWidth, flex !== undefined && { flex }, { transform: [{ scale }] }]}>
114
+ <Animated.View
115
+ style={[fullWidth && styles.fullWidth, flex !== undefined && { flex }, animatedStyle]}
116
+ {...hoverHandlers}
117
+ >
120
118
  <TouchableOpacity
121
119
  style={[
122
120
  styles.base,
@@ -130,8 +128,12 @@ export function Button({
130
128
  activeOpacity={1}
131
129
  touchSoundDisabled={true}
132
130
  onPress={handlePress}
133
- onPressIn={handlePressIn}
134
- onPressOut={handlePressOut}
131
+ onPressIn={onPressIn}
132
+ onPressOut={onPressOut}
133
+ accessibilityRole="button"
134
+ accessibilityLabel={accessibilityLabel ?? label}
135
+ accessibilityHint={accessibilityHint}
136
+ accessibilityState={{ disabled: isDisabled, busy: loading }}
135
137
  {...props}
136
138
  >
137
139
  {loading ? (
@@ -1,21 +1,24 @@
1
- import React, { useRef } from 'react'
2
- import { View, Text, TouchableOpacity, Animated, StyleSheet, ViewStyle, TextStyle, Platform } from 'react-native'
1
+ import React from 'react'
2
+ import { View, Text, TouchableOpacity, StyleSheet, ViewStyle, TextStyle } from 'react-native'
3
+ import Animated from 'react-native-reanimated'
3
4
  import { impactLight } from '../../utils/haptics'
4
5
  import { useTheme } from '../../theme'
5
6
  import { s, vs, ms, mvs } from '../../utils/scaling'
6
7
  import { RADIUS } from '../../tokens'
7
-
8
- const nativeDriver = Platform.OS !== 'web'
8
+ import { usePressScale } from '../../utils/usePressScale'
9
+ import { SPRINGS, PRESS_SCALE } from '../../utils/animations'
9
10
 
10
11
  export type CardVariant = 'elevated' | 'outlined' | 'filled'
11
12
 
12
13
  export interface CardProps {
13
14
  children: React.ReactNode
14
- /** Visual style variant. `'elevated'` (default) has shadow, `'outlined'` has border only, `'filled'` uses accent background. */
15
+ /** Visual style variant. `'elevated'` (default) has shadow, `'outlined'` has border only, `'filled'` uses accent background. */
15
16
  variant?: CardVariant
16
17
  /** Makes the card tappable. Adds press animation and haptic feedback. */
17
18
  onPress?: () => void
18
19
  style?: ViewStyle
20
+ /** Accessibility label for the card (when interactive). */
21
+ accessibilityLabel?: string
19
22
  }
20
23
 
21
24
  export interface CardHeaderProps {
@@ -43,31 +46,14 @@ export interface CardFooterProps {
43
46
  style?: ViewStyle
44
47
  }
45
48
 
46
- export function Card({ children, variant = 'elevated', onPress, style }: CardProps) {
49
+ export function Card({ children, variant = 'elevated', onPress, style, accessibilityLabel }: CardProps) {
47
50
  const { colors } = useTheme()
48
- const scale = useRef(new Animated.Value(1)).current
49
-
50
- const handlePressIn = () => {
51
- if (!onPress) return
52
- Animated.spring(scale, {
53
- toValue: 0.98,
54
- useNativeDriver: nativeDriver,
55
- stiffness: 400,
56
- damping: 30,
57
- mass: 1.0,
58
- }).start()
59
- }
60
-
61
- const handlePressOut = () => {
62
- if (!onPress) return
63
- Animated.spring(scale, {
64
- toValue: 1,
65
- useNativeDriver: nativeDriver,
66
- stiffness: 250,
67
- damping: 24,
68
- mass: 1.0,
69
- }).start()
70
- }
51
+ const { animatedStyle, onPressIn, onPressOut, hoverHandlers } = usePressScale({
52
+ pressScale: PRESS_SCALE.card,
53
+ pressInSpring: SPRINGS.surfacePressIn,
54
+ pressOutSpring: SPRINGS.surfacePressOut,
55
+ disabled: !onPress,
56
+ })
71
57
 
72
58
  const handlePress = () => {
73
59
  if (!onPress) return
@@ -107,13 +93,15 @@ export function Card({ children, variant = 'elevated', onPress, style }: CardPro
107
93
 
108
94
  if (onPress) {
109
95
  return (
110
- <Animated.View style={{ transform: [{ scale }] }}>
96
+ <Animated.View style={animatedStyle} {...hoverHandlers}>
111
97
  <TouchableOpacity
112
98
  onPress={handlePress}
113
- onPressIn={handlePressIn}
114
- onPressOut={handlePressOut}
99
+ onPressIn={onPressIn}
100
+ onPressOut={onPressOut}
115
101
  activeOpacity={1}
116
102
  touchSoundDisabled={true}
103
+ accessibilityRole="button"
104
+ accessibilityLabel={accessibilityLabel}
117
105
  >
118
106
  {cardContent}
119
107
  </TouchableOpacity>
@@ -150,7 +138,7 @@ export function CardFooter({ children, style }: CardFooterProps) {
150
138
 
151
139
  const styles = StyleSheet.create({
152
140
  card: {
153
- borderRadius: RADIUS.md, // 14px — Airbnb property card spec
141
+ borderRadius: RADIUS.md,
154
142
  borderWidth: 1,
155
143
  },
156
144
  header: {
@@ -1,22 +1,25 @@
1
- import React, { useRef } from 'react'
1
+ import React from 'react'
2
2
  import {
3
3
  ScrollView,
4
4
  TouchableOpacity,
5
- Animated,
6
5
  Text,
7
6
  View,
8
7
  StyleSheet,
9
8
  ViewStyle,
10
- Platform,
11
9
  } from 'react-native'
10
+ import Animated, {
11
+ useAnimatedStyle,
12
+ interpolateColor,
13
+ } from 'react-native-reanimated'
12
14
  import { selectionAsync as hapticSelection } from '../../utils/haptics'
13
15
  import { useTheme } from '../../theme'
14
16
  import { s, vs, ms } from '../../utils/scaling'
15
17
  import { renderIcon } from '../../utils/icons'
18
+ import { usePressScale } from '../../utils/usePressScale'
19
+ import { useColorTransition } from '../../utils/useColorTransition'
20
+ import { PRESS_SCALE } from '../../utils/animations'
16
21
  import { RADIUS } from '../../tokens'
17
22
 
18
- const nativeDriver = Platform.OS !== 'web'
19
-
20
23
  export interface CategoryItem {
21
24
  label: string
22
25
  value: string
@@ -36,6 +39,7 @@ export interface CategoryStripProps {
36
39
  style?: ViewStyle
37
40
  /** Style applied to each pill item. */
38
41
  itemStyle?: ViewStyle
42
+ accessibilityLabel?: string
39
43
  }
40
44
 
41
45
  function CategoryChip({
@@ -48,52 +52,52 @@ function CategoryChip({
48
52
  onPress: () => void
49
53
  }) {
50
54
  const { colors } = useTheme()
51
- const scale = useRef(new Animated.Value(1)).current
52
-
53
- const handlePressIn = () => {
54
- Animated.spring(scale, { toValue: 0.95, useNativeDriver: nativeDriver, speed: 40, bounciness: 0 }).start()
55
- }
55
+ const { animatedStyle: scaleStyle, onPressIn, onPressOut, hoverHandlers } = usePressScale({
56
+ pressScale: PRESS_SCALE.chip,
57
+ })
58
+ const progress = useColorTransition(selected)
56
59
 
57
- const handlePressOut = () => {
58
- Animated.spring(scale, { toValue: 1, useNativeDriver: nativeDriver, speed: 40, bounciness: 4 }).start()
59
- }
60
+ const surfaceStyle = useAnimatedStyle(() => ({
61
+ backgroundColor: interpolateColor(progress.value, [0, 1], [colors.surface, colors.primary]),
62
+ borderColor: interpolateColor(progress.value, [0, 1], [colors.border, colors.primary]),
63
+ }))
60
64
 
61
- const bgColor = selected ? colors.primary : colors.surface
62
- const textColor = selected ? colors.primaryForeground : colors.foregroundSubtle
63
- const borderColor = selected ? colors.primary : colors.border
65
+ const textColorStyle = useAnimatedStyle(() => ({
66
+ color: interpolateColor(progress.value, [0, 1], [colors.foregroundSubtle, colors.primaryForeground]),
67
+ }))
64
68
 
69
+ // Static color for icon — icon families take a static color prop, not animated.
70
+ const iconColor = selected ? colors.primaryForeground : colors.foregroundSubtle
65
71
  const resolvedIcon =
66
72
  typeof item.icon === 'string'
67
- ? renderIcon(item.icon, 16, textColor)
73
+ ? renderIcon(item.icon, 16, iconColor)
68
74
  : item.icon ?? null
69
75
 
70
76
  return (
71
- <Animated.View style={{ transform: [{ scale }] }}>
77
+ <Animated.View style={scaleStyle} {...hoverHandlers}>
72
78
  <TouchableOpacity
73
- style={[
74
- styles.chip,
75
- {
76
- backgroundColor: bgColor,
77
- borderColor,
78
- },
79
- ]}
80
79
  onPress={onPress}
81
- onPressIn={handlePressIn}
82
- onPressOut={handlePressOut}
80
+ onPressIn={onPressIn}
81
+ onPressOut={onPressOut}
83
82
  activeOpacity={1}
84
83
  touchSoundDisabled={true}
84
+ accessibilityRole="button"
85
+ accessibilityLabel={item.label}
86
+ accessibilityState={{ selected }}
85
87
  >
86
- {resolvedIcon && <View style={styles.chipIcon}>{resolvedIcon}</View>}
87
- <Text style={[styles.chipLabel, { color: textColor }]} allowFontScaling={true}>
88
- {item.label}
89
- </Text>
90
- {item.badge !== undefined && item.badge > 0 && (
91
- <View style={[styles.chipBadge, { backgroundColor: colors.primary }]}>
92
- <Text style={[styles.chipBadgeText, { color: colors.primaryForeground }]}>
93
- {Math.min(item.badge, 99)}
94
- </Text>
95
- </View>
96
- )}
88
+ <Animated.View style={[styles.chip, surfaceStyle]}>
89
+ {resolvedIcon && <View style={styles.chipIcon}>{resolvedIcon}</View>}
90
+ <Animated.Text style={[styles.chipLabel, textColorStyle]} allowFontScaling={true}>
91
+ {item.label}
92
+ </Animated.Text>
93
+ {item.badge !== undefined && item.badge > 0 && (
94
+ <View style={[styles.chipBadge, { backgroundColor: colors.primary }]}>
95
+ <Text style={[styles.chipBadgeText, { color: colors.primaryForeground }]}>
96
+ {Math.min(item.badge, 99)}
97
+ </Text>
98
+ </View>
99
+ )}
100
+ </Animated.View>
97
101
  </TouchableOpacity>
98
102
  </Animated.View>
99
103
  )
@@ -106,6 +110,7 @@ export function CategoryStrip({
106
110
  multiSelect = false,
107
111
  style,
108
112
  itemStyle,
113
+ accessibilityLabel,
109
114
  }: CategoryStripProps) {
110
115
  const selected = Array.isArray(value) ? value : value ? [value] : []
111
116
 
@@ -128,6 +133,8 @@ export function CategoryStrip({
128
133
  showsHorizontalScrollIndicator={false}
129
134
  contentContainerStyle={[styles.container, style]}
130
135
  style={styles.scroll}
136
+ accessibilityRole={multiSelect ? undefined : 'radiogroup'}
137
+ accessibilityLabel={accessibilityLabel}
131
138
  >
132
139
  {categories.map((cat) => (
133
140
  <View key={cat.value} style={itemStyle}>
@@ -1,10 +1,16 @@
1
- import React, { useRef, useEffect } from 'react'
2
- import { TouchableOpacity, Animated, View, Text, StyleSheet, ViewStyle, Platform } from 'react-native'
1
+ import React from 'react'
2
+ import { TouchableOpacity, View, Text, StyleSheet, ViewStyle } from 'react-native'
3
+ import Animated, {
4
+ useAnimatedStyle,
5
+ interpolateColor,
6
+ withTiming,
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, mvs } from '../../utils/scaling'
11
+ import { usePressScale } from '../../utils/usePressScale'
12
+ import { useColorTransition } from '../../utils/useColorTransition'
13
+ import { PRESS_SCALE, TIMINGS, EASINGS } from '../../utils/animations'
8
14
 
9
15
  export interface CheckboxProps {
10
16
  checked?: boolean
@@ -12,6 +18,7 @@ export interface CheckboxProps {
12
18
  label?: string
13
19
  disabled?: boolean
14
20
  style?: ViewStyle
21
+ accessibilityLabel?: string
15
22
  }
16
23
 
17
24
  export function Checkbox({
@@ -20,45 +27,23 @@ export function Checkbox({
20
27
  label,
21
28
  disabled,
22
29
  style,
30
+ accessibilityLabel,
23
31
  }: CheckboxProps) {
24
32
  const { colors } = useTheme()
25
- const scale = useRef(new Animated.Value(1)).current
26
- const bgOpacity = useRef(new Animated.Value(checked ? 1 : 0)).current
27
- const checkOpacity = useRef(new Animated.Value(checked ? 1 : 0)).current
28
-
29
- useEffect(() => {
30
- Animated.parallel([
31
- Animated.timing(bgOpacity, {
32
- toValue: checked ? 1 : 0,
33
- duration: 150,
34
- useNativeDriver: false,
35
- }),
36
- Animated.timing(checkOpacity, {
37
- toValue: checked ? 1 : 0,
38
- duration: 120,
39
- useNativeDriver: false,
40
- }),
41
- ]).start()
42
- }, [checked, bgOpacity, checkOpacity])
43
-
44
- const borderColor = bgOpacity.interpolate({
45
- inputRange: [0, 1],
46
- outputRange: [colors.border, colors.primary],
47
- })
48
-
49
- const backgroundColor = bgOpacity.interpolate({
50
- inputRange: [0, 1],
51
- outputRange: ['transparent', colors.primary],
33
+ const { animatedStyle: scaleStyle, onPressIn, onPressOut } = usePressScale({
34
+ pressScale: PRESS_SCALE.button,
35
+ disabled,
52
36
  })
37
+ const progress = useColorTransition(checked)
53
38
 
54
- const handlePressIn = () => {
55
- if (disabled) return
56
- Animated.spring(scale, { toValue: 0.95, useNativeDriver: nativeDriver, speed: 40, bounciness: 0 }).start()
57
- }
39
+ const boxStyle = useAnimatedStyle(() => ({
40
+ borderColor: interpolateColor(progress.value, [0, 1], [colors.border, colors.primary]),
41
+ backgroundColor: interpolateColor(progress.value, [0, 1], ['transparent', colors.primary]),
42
+ }))
58
43
 
59
- const handlePressOut = () => {
60
- Animated.spring(scale, { toValue: 1, useNativeDriver: nativeDriver, speed: 40, bounciness: 0 }).start()
61
- }
44
+ const checkStyle = useAnimatedStyle(() => ({
45
+ opacity: withTiming(checked ? 1 : 0, { duration: TIMINGS.state.duration, easing: EASINGS.standard }),
46
+ }))
62
47
 
63
48
  return (
64
49
  <TouchableOpacity
@@ -67,24 +52,20 @@ export function Checkbox({
67
52
  hapticSelection()
68
53
  onCheckedChange?.(!checked)
69
54
  }}
70
- onPressIn={handlePressIn}
71
- onPressOut={handlePressOut}
55
+ onPressIn={onPressIn}
56
+ onPressOut={onPressOut}
72
57
  disabled={disabled}
73
58
  activeOpacity={1}
74
59
  touchSoundDisabled={true}
60
+ accessibilityRole="checkbox"
61
+ accessibilityLabel={accessibilityLabel ?? label}
62
+ accessibilityState={{ checked, disabled: !!disabled }}
75
63
  >
76
- <Animated.View style={{ transform: [{ scale }] }}>
64
+ <Animated.View style={scaleStyle}>
77
65
  <Animated.View
78
- style={[
79
- styles.box,
80
- {
81
- borderColor,
82
- backgroundColor,
83
- opacity: disabled ? 0.45 : 1,
84
- },
85
- ]}
66
+ style={[styles.box, { opacity: disabled ? 0.45 : 1 }, boxStyle]}
86
67
  >
87
- <Animated.View style={{ opacity: checkOpacity }}>
68
+ <Animated.View style={checkStyle}>
88
69
  <View style={[styles.checkmark, { borderColor: colors.primaryForeground }]} />
89
70
  </Animated.View>
90
71
  </Animated.View>
@@ -1,20 +1,16 @@
1
- import React, { useEffect, useRef } from 'react'
2
- import {
3
- TouchableOpacity,
4
- Animated,
5
- View,
6
- Text,
7
- StyleSheet,
8
- ViewStyle,
9
- Platform,
10
- Easing,
11
- } from 'react-native'
1
+ import React from 'react'
2
+ import { TouchableOpacity, View, StyleSheet, ViewStyle } from 'react-native'
3
+ import Animated, {
4
+ useAnimatedStyle,
5
+ interpolateColor,
6
+ } from 'react-native-reanimated'
12
7
  import { selectionAsync as hapticSelection } from '../../utils/haptics'
13
8
  import { useTheme } from '../../theme'
14
9
  import { s, vs, ms, mvs } from '../../utils/scaling'
15
10
  import { renderIcon } from '../../utils/icons'
16
-
17
- const nativeDriver = Platform.OS !== 'web'
11
+ import { usePressScale } from '../../utils/usePressScale'
12
+ import { useColorTransition } from '../../utils/useColorTransition'
13
+ import { PRESS_SCALE } from '../../utils/animations'
18
14
 
19
15
  export interface ChipProps {
20
16
  label: string
@@ -25,6 +21,7 @@ export interface ChipProps {
25
21
  /** Icon name from @expo/vector-icons resolved automatically. */
26
22
  iconName?: string
27
23
  style?: ViewStyle
24
+ accessibilityLabel?: string
28
25
  }
29
26
 
30
27
  export interface ChipOption {
@@ -46,74 +43,46 @@ export interface ChipGroupProps {
46
43
  style?: ViewStyle
47
44
  }
48
45
 
49
- export function Chip({ label, selected = false, onPress, icon, iconName, style }: ChipProps) {
46
+ export function Chip({ label, selected = false, onPress, icon, iconName, style, accessibilityLabel }: ChipProps) {
50
47
  const { colors } = useTheme()
51
- const scale = useRef(new Animated.Value(1)).current
52
- const pressAnim = useRef(new Animated.Value(selected ? 1 : 0)).current
53
-
54
- useEffect(() => {
55
- Animated.timing(pressAnim, {
56
- toValue: selected ? 1 : 0,
57
- duration: 150,
58
- easing: Easing.out(Easing.ease),
59
- useNativeDriver: false,
60
- }).start()
61
- }, [selected, pressAnim])
62
-
63
- const handlePressIn = () => {
64
- Animated.spring(scale, {
65
- toValue: 0.95,
66
- useNativeDriver: nativeDriver,
67
- speed: 40,
68
- bounciness: 0,
69
- }).start()
70
- }
48
+ const { animatedStyle: scaleStyle, onPressIn, onPressOut, hoverHandlers } = usePressScale({
49
+ pressScale: PRESS_SCALE.chip,
50
+ })
51
+ const colorProgress = useColorTransition(selected)
71
52
 
72
- const handlePressOut = () => {
73
- Animated.spring(scale, {
74
- toValue: 1,
75
- useNativeDriver: nativeDriver,
76
- speed: 40,
77
- bounciness: 4,
78
- }).start()
79
- }
53
+ const surfaceStyle = useAnimatedStyle(() => ({
54
+ backgroundColor: interpolateColor(colorProgress.value, [0, 1], [colors.surface, colors.primary]),
55
+ borderColor: interpolateColor(colorProgress.value, [0, 1], [colors.border, colors.primary]),
56
+ }))
57
+
58
+ const textStyle = useAnimatedStyle(() => ({
59
+ color: interpolateColor(colorProgress.value, [0, 1], [colors.foreground, colors.primaryForeground]),
60
+ }))
80
61
 
81
62
  const handlePress = () => {
82
63
  hapticSelection()
83
64
  onPress?.()
84
65
  }
85
66
 
86
- const backgroundColor = pressAnim.interpolate({
87
- inputRange: [0, 1],
88
- outputRange: [colors.surface, colors.primary],
89
- })
90
-
91
- const textColor = pressAnim.interpolate({
92
- inputRange: [0, 1],
93
- outputRange: [colors.foreground, colors.primaryForeground],
94
- })
95
-
96
- const borderColor = pressAnim.interpolate({
97
- inputRange: [0, 1],
98
- outputRange: [colors.border, colors.primary],
99
- })
100
-
101
67
  const resolvedIcon = iconName
102
68
  ? renderIcon(iconName, ms(13), selected ? colors.primaryForeground : colors.foreground)
103
69
  : icon
104
70
 
105
71
  return (
106
- <Animated.View style={[styles.wrapper, { transform: [{ scale }] }, style]}>
72
+ <Animated.View style={[styles.wrapper, scaleStyle, style]} {...hoverHandlers}>
107
73
  <TouchableOpacity
108
74
  onPress={handlePress}
109
- onPressIn={handlePressIn}
110
- onPressOut={handlePressOut}
75
+ onPressIn={onPressIn}
76
+ onPressOut={onPressOut}
111
77
  activeOpacity={1}
112
78
  touchSoundDisabled={true}
79
+ accessibilityRole="button"
80
+ accessibilityLabel={accessibilityLabel ?? label}
81
+ accessibilityState={{ selected }}
113
82
  >
114
- <Animated.View style={[styles.chip, { backgroundColor, borderColor }]}>
83
+ <Animated.View style={[styles.chip, surfaceStyle]}>
115
84
  {resolvedIcon ? <View style={styles.chipIcon}>{resolvedIcon}</View> : null}
116
- <Animated.Text style={[styles.label, { color: textColor }]} allowFontScaling={true}>
85
+ <Animated.Text style={[styles.label, textStyle]} allowFontScaling={true}>
117
86
  {label}
118
87
  </Animated.Text>
119
88
  </Animated.View>
@@ -129,18 +98,12 @@ export function ChipGroup({ options, value, onValueChange, multiSelect = false,
129
98
  return
130
99
  }
131
100
 
132
- // Multiselect logic
133
101
  const currentArray = Array.isArray(value) ? value : value ? [value] : []
134
102
  const isSelected = currentArray.includes(optionValue)
135
103
 
136
- let newArray: (string | number)[]
137
- if (isSelected) {
138
- // Remove from selection
139
- newArray = currentArray.filter((v) => v !== optionValue)
140
- } else {
141
- // Add to selection
142
- newArray = [...currentArray, optionValue]
143
- }
104
+ const newArray: (string | number)[] = isSelected
105
+ ? currentArray.filter((v) => v !== optionValue)
106
+ : [...currentArray, optionValue]
144
107
 
145
108
  onValueChange?.(newArray)
146
109
  }