@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.
Files changed (33) hide show
  1. package/COMPONENTS.md +8 -7
  2. package/dist/index.d.mts +47 -23
  3. package/dist/index.d.ts +47 -23
  4. package/dist/index.js +702 -634
  5. package/dist/index.mjs +695 -627
  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/DetailRow/DetailRow.tsx +13 -8
  14. package/src/components/IconButton/IconButton.tsx +20 -18
  15. package/src/components/Input/Input.tsx +39 -22
  16. package/src/components/ListItem/ListItem.tsx +22 -34
  17. package/src/components/MediaCard/MediaCard.tsx +24 -24
  18. package/src/components/MenuItem/MenuItem.tsx +52 -39
  19. package/src/components/MonthPicker/MonthPicker.tsx +12 -2
  20. package/src/components/Pressable/Pressable.tsx +27 -46
  21. package/src/components/Progress/Progress.tsx +21 -12
  22. package/src/components/RadioGroup/RadioGroup.tsx +52 -26
  23. package/src/components/Select/Select.tsx +17 -15
  24. package/src/components/Sheet/Sheet.tsx +4 -1
  25. package/src/components/Skeleton/Skeleton.tsx +24 -13
  26. package/src/components/Slider/Slider.tsx +11 -1
  27. package/src/components/Switch/Switch.tsx +44 -49
  28. package/src/components/Tabs/Tabs.tsx +39 -31
  29. package/src/components/Textarea/Textarea.tsx +29 -12
  30. package/src/components/Toggle/Toggle.tsx +39 -45
  31. package/src/utils/animations.ts +58 -0
  32. package/src/utils/useColorTransition.ts +40 -0
  33. package/src/utils/usePressScale.ts +73 -0
@@ -56,16 +56,23 @@ export function MonthPicker({ value, onChange, locale = 'en', formatLabel, style
56
56
  }
57
57
 
58
58
  return (
59
- <View style={[styles.container, style]}>
59
+ <View style={[styles.container, style]} accessibilityRole="adjustable" accessibilityLabel={getLabel()}>
60
60
  <TouchableOpacity
61
61
  style={styles.arrow}
62
62
  onPress={handlePrev}
63
63
  activeOpacity={0.6}
64
64
  touchSoundDisabled={true}
65
+ accessibilityRole="button"
66
+ accessibilityLabel="Previous month"
67
+ hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
65
68
  >
66
69
  <Entypo name="chevron-left" size={22} color={colors.foreground} />
67
70
  </TouchableOpacity>
68
- <Text style={[styles.label, { color: colors.foreground }]} allowFontScaling={true}>
71
+ <Text
72
+ style={[styles.label, { color: colors.foreground }]}
73
+ allowFontScaling={true}
74
+ accessibilityLiveRegion="polite"
75
+ >
69
76
  {getLabel()}
70
77
  </Text>
71
78
  <TouchableOpacity
@@ -73,6 +80,9 @@ export function MonthPicker({ value, onChange, locale = 'en', formatLabel, style
73
80
  onPress={handleNext}
74
81
  activeOpacity={0.6}
75
82
  touchSoundDisabled={true}
83
+ accessibilityRole="button"
84
+ accessibilityLabel="Next month"
85
+ hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
76
86
  >
77
87
  <Entypo name="chevron-right" size={22} color={colors.foreground} />
78
88
  </TouchableOpacity>
@@ -1,15 +1,9 @@
1
- import React, { useRef } from 'react'
2
- import {
3
- TouchableOpacity,
4
- Animated,
5
- ViewStyle,
6
- Platform,
7
- TouchableOpacityProps,
8
- } from 'react-native'
1
+ import React from 'react'
2
+ import { TouchableOpacity, Platform, ViewStyle, TouchableOpacityProps } from 'react-native'
3
+ import Animated from 'react-native-reanimated'
9
4
  import { impactLight } from '../../utils/haptics'
10
- import { useHover } from '../../utils/hover'
11
-
12
- const nativeDriver = Platform.OS !== 'web'
5
+ import { usePressScale } from '../../utils/usePressScale'
6
+ import { PRESS_SCALE, SPRINGS } from '../../utils/animations'
13
7
 
14
8
  export interface PressableProps extends Omit<TouchableOpacityProps, 'activeOpacity'> {
15
9
  /** Children content to render inside the pressable. */
@@ -18,7 +12,10 @@ export interface PressableProps extends Omit<TouchableOpacityProps, 'activeOpaci
18
12
  onPress?: () => void
19
13
  /** Scale value on press. Defaults to `0.98` (MediaCard-style). */
20
14
  pressScale?: number
21
- /** Bounciness of the spring animation on release. Defaults to `4`. */
15
+ /**
16
+ * @deprecated Use Reanimated spring config via `pressOutSpring` instead. Ignored.
17
+ * Kept for backwards compatibility with v6.x consumers.
18
+ */
22
19
  bounciness?: number
23
20
  /** Enable haptic feedback on press. Defaults to `true`. */
24
21
  haptics?: boolean
@@ -31,42 +28,29 @@ export interface PressableProps extends Omit<TouchableOpacityProps, 'activeOpaci
31
28
  }
32
29
 
33
30
  /**
34
- * Generic pressable with beautiful spring bounce effect matching MediaCard interaction.
35
- * Use for custom pressable content that needs consistent press feel.
31
+ * Generic pressable with a calibrated spring bounce Apple HIG / Airbnb feel.
32
+ * All animation runs on the UI thread via Reanimated v4 worklets.
33
+ *
34
+ * Use this for any custom pressable surface that needs consistent press feel
35
+ * (cards, list rows, image tiles, etc).
36
36
  */
37
37
  export function Pressable({
38
38
  children,
39
39
  onPress,
40
- pressScale = 0.98,
41
- bounciness = 4,
40
+ pressScale = PRESS_SCALE.card,
42
41
  haptics = true,
43
42
  style,
44
43
  disabled,
45
44
  hoverScale = 1.02,
46
45
  ...touchableProps
47
46
  }: PressableProps) {
48
- const scale = useRef(new Animated.Value(1)).current
49
- const { hovered, hoverHandlers } = useHover()
50
-
51
- const handlePressIn = () => {
52
- if (disabled) return
53
- Animated.spring(scale, {
54
- toValue: pressScale,
55
- useNativeDriver: nativeDriver,
56
- speed: 40,
57
- bounciness: 0,
58
- }).start()
59
- }
60
-
61
- const handlePressOut = () => {
62
- if (disabled) return
63
- Animated.spring(scale, {
64
- toValue: 1,
65
- useNativeDriver: nativeDriver,
66
- speed: 40,
67
- bounciness,
68
- }).start()
69
- }
47
+ const { animatedStyle, onPressIn, onPressOut, hoverHandlers } = usePressScale({
48
+ pressScale,
49
+ hoverScale,
50
+ pressInSpring: SPRINGS.surfacePressIn,
51
+ pressOutSpring: SPRINGS.surfacePressOut,
52
+ disabled,
53
+ })
70
54
 
71
55
  const handlePress = () => {
72
56
  if (disabled || !onPress) return
@@ -74,23 +58,20 @@ export function Pressable({
74
58
  onPress()
75
59
  }
76
60
 
77
- const hoverScaleValue = hovered && hoverScale !== 1 ? hoverScale : 1
78
-
79
61
  return (
80
62
  <Animated.View
81
- style={[
82
- { transform: [{ scale: Animated.multiply(scale, hoverScaleValue) }] },
83
- style,
84
- ]}
63
+ style={[animatedStyle, style]}
85
64
  {...(Platform.OS === 'web' ? hoverHandlers : {})}
86
65
  >
87
66
  <TouchableOpacity
88
67
  onPress={handlePress}
89
- onPressIn={handlePressIn}
90
- onPressOut={handlePressOut}
68
+ onPressIn={onPressIn}
69
+ onPressOut={onPressOut}
91
70
  activeOpacity={1}
92
71
  disabled={disabled}
93
72
  touchSoundDisabled={true}
73
+ accessibilityRole="button"
74
+ accessibilityState={{ disabled: !!disabled }}
94
75
  {...touchableProps}
95
76
  >
96
77
  {children}
@@ -1,7 +1,13 @@
1
- import React, { useRef, useState, useEffect } from 'react'
2
- import { View, Animated, StyleSheet, ViewStyle } from 'react-native'
1
+ import React, { useEffect, useState } from 'react'
2
+ import { View, StyleSheet, ViewStyle } from 'react-native'
3
+ import Animated, {
4
+ useSharedValue,
5
+ useAnimatedStyle,
6
+ withSpring,
7
+ } from 'react-native-reanimated'
3
8
  import { useTheme } from '../../theme'
4
9
  import { vs } from '../../utils/scaling'
10
+ import { SPRINGS } from '../../utils/animations'
5
11
 
6
12
  export type ProgressVariant = 'default' | 'success' | 'warning' | 'destructive'
7
13
 
@@ -10,23 +16,23 @@ export interface ProgressProps {
10
16
  max?: number
11
17
  variant?: ProgressVariant
12
18
  style?: ViewStyle
19
+ accessibilityLabel?: string
13
20
  }
14
21
 
15
- export function Progress({ value = 0, max = 100, variant = 'default', style }: ProgressProps) {
22
+ export function Progress({ value = 0, max = 100, variant = 'default', style, accessibilityLabel }: ProgressProps) {
16
23
  const { colors } = useTheme()
17
24
  const percent = Math.min(Math.max((value / max) * 100, 0), 100)
18
25
  const [trackWidth, setTrackWidth] = useState(0)
19
- const animatedWidth = useRef(new Animated.Value(0)).current
26
+ const animatedWidth = useSharedValue(0)
20
27
 
21
28
  useEffect(() => {
22
29
  if (trackWidth === 0) return
23
- Animated.spring(animatedWidth, {
24
- toValue: (percent / 100) * trackWidth,
25
- useNativeDriver: false,
26
- speed: 20,
27
- bounciness: 0,
28
- }).start()
29
- }, [percent, trackWidth])
30
+ animatedWidth.value = withSpring((percent / 100) * trackWidth, SPRINGS.glide)
31
+ }, [percent, trackWidth, animatedWidth])
32
+
33
+ const indicatorAnimatedStyle = useAnimatedStyle(() => ({
34
+ width: animatedWidth.value,
35
+ }))
30
36
 
31
37
  const indicatorColor =
32
38
  variant === 'success' ? colors.success
@@ -38,9 +44,12 @@ export function Progress({ value = 0, max = 100, variant = 'default', style }: P
38
44
  <View
39
45
  style={[styles.track, { backgroundColor: colors.surface }, style]}
40
46
  onLayout={(e) => setTrackWidth(e.nativeEvent.layout.width)}
47
+ accessibilityRole="progressbar"
48
+ accessibilityLabel={accessibilityLabel}
49
+ accessibilityValue={{ min: 0, max: 100, now: Math.round(percent) }}
41
50
  >
42
51
  <Animated.View
43
- style={[styles.indicator, { width: animatedWidth, backgroundColor: indicatorColor }]}
52
+ style={[styles.indicator, { backgroundColor: indicatorColor }, indicatorAnimatedStyle]}
44
53
  />
45
54
  </View>
46
55
  )
@@ -1,10 +1,18 @@
1
- import React, { useRef } 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
+ useSharedValue,
6
+ withSpring,
7
+ interpolateColor,
8
+ } from 'react-native-reanimated'
9
+ import { useEffect } from 'react'
3
10
  import { selectionAsync as hapticSelection } from '../../utils/haptics'
4
-
5
- const nativeDriver = Platform.OS !== 'web'
6
11
  import { useTheme } from '../../theme'
7
12
  import { s, vs, ms, mvs } from '../../utils/scaling'
13
+ import { usePressScale } from '../../utils/usePressScale'
14
+ import { useColorTransition } from '../../utils/useColorTransition'
15
+ import { SPRINGS, PRESS_SCALE } from '../../utils/animations'
8
16
 
9
17
  export interface RadioOption {
10
18
  label: string
@@ -18,6 +26,7 @@ export interface RadioGroupProps {
18
26
  onValueChange?: (value: string) => void
19
27
  orientation?: 'vertical' | 'horizontal'
20
28
  style?: ViewStyle
29
+ accessibilityLabel?: string
21
30
  }
22
31
 
23
32
  function RadioItem({
@@ -30,16 +39,26 @@ function RadioItem({
30
39
  onSelect: () => void
31
40
  }) {
32
41
  const { colors } = useTheme()
33
- const scale = useRef(new Animated.Value(1)).current
42
+ const { animatedStyle: scaleStyle, onPressIn, onPressOut } = usePressScale({
43
+ pressScale: PRESS_SCALE.button,
44
+ disabled: option.disabled,
45
+ })
46
+ const colorProgress = useColorTransition(selected)
47
+
48
+ // Pop-in animation for the inner dot
49
+ const dotScale = useSharedValue(selected ? 1 : 0)
50
+ useEffect(() => {
51
+ dotScale.value = withSpring(selected ? 1 : 0, SPRINGS.elastic)
52
+ }, [selected, dotScale])
34
53
 
35
- const handlePressIn = () => {
36
- if (option.disabled) return
37
- Animated.spring(scale, { toValue: 0.95, useNativeDriver: nativeDriver, speed: 40, bounciness: 0 }).start()
38
- }
54
+ const radioStyle = useAnimatedStyle(() => ({
55
+ borderColor: interpolateColor(colorProgress.value, [0, 1], [colors.border, colors.primary]),
56
+ }))
39
57
 
40
- const handlePressOut = () => {
41
- Animated.spring(scale, { toValue: 1, useNativeDriver: nativeDriver, speed: 40, bounciness: 4 }).start()
42
- }
58
+ const dotStyle = useAnimatedStyle(() => ({
59
+ transform: [{ scale: dotScale.value }],
60
+ opacity: dotScale.value,
61
+ }))
43
62
 
44
63
  return (
45
64
  <TouchableOpacity
@@ -50,23 +69,25 @@ function RadioItem({
50
69
  onSelect()
51
70
  }
52
71
  }}
53
- onPressIn={handlePressIn}
54
- onPressOut={handlePressOut}
72
+ onPressIn={onPressIn}
73
+ onPressOut={onPressOut}
55
74
  activeOpacity={1}
56
75
  touchSoundDisabled={true}
57
76
  disabled={option.disabled}
77
+ accessibilityRole="radio"
78
+ accessibilityLabel={option.label}
79
+ accessibilityState={{ checked: selected, disabled: !!option.disabled }}
58
80
  >
59
- <Animated.View
60
- style={[
61
- styles.radio,
62
- {
63
- borderColor: selected ? colors.primary : colors.border,
64
- opacity: option.disabled ? 0.45 : 1,
65
- transform: [{ scale }],
66
- },
67
- ]}
68
- >
69
- {selected ? <View style={[styles.dot, { backgroundColor: colors.primary }]} /> : null}
81
+ <Animated.View style={scaleStyle}>
82
+ <Animated.View
83
+ style={[
84
+ styles.radio,
85
+ { opacity: option.disabled ? 0.45 : 1 },
86
+ radioStyle,
87
+ ]}
88
+ >
89
+ <Animated.View style={[styles.dot, { backgroundColor: colors.primary }, dotStyle]} />
90
+ </Animated.View>
70
91
  </Animated.View>
71
92
  <Text
72
93
  style={[
@@ -87,9 +108,14 @@ export function RadioGroup({
87
108
  onValueChange,
88
109
  orientation = 'vertical',
89
110
  style,
111
+ accessibilityLabel,
90
112
  }: RadioGroupProps) {
91
113
  return (
92
- <View style={[styles.container, orientation === 'horizontal' && styles.horizontal, style]}>
114
+ <View
115
+ style={[styles.container, orientation === 'horizontal' && styles.horizontal, style]}
116
+ accessibilityRole="radiogroup"
117
+ accessibilityLabel={accessibilityLabel}
118
+ >
93
119
  {options.map((option) => (
94
120
  <RadioItem
95
121
  key={option.value}
@@ -1,15 +1,17 @@
1
1
  import React, { useRef, useState } from 'react'
2
- import { View, Text, TouchableOpacity, Modal, Animated, StyleSheet, ViewStyle, Platform } from 'react-native'
2
+ import { View, Text, TouchableOpacity, Modal, StyleSheet, ViewStyle, Platform } from 'react-native'
3
+ import Animated from 'react-native-reanimated'
3
4
  import { Picker } from '@react-native-picker/picker'
4
5
  import { Entypo } from '@expo/vector-icons'
5
6
  import { selectionAsync as hapticSelection } from '../../utils/haptics'
6
7
  import { useTheme } from '../../theme'
7
8
  import { s, vs, ms } from '../../utils/scaling'
9
+ import { usePressScale } from '../../utils/usePressScale'
10
+ import { PRESS_SCALE } from '../../utils/animations'
8
11
 
9
12
  const isIOS = Platform.OS === 'ios'
10
13
  const isAndroid = Platform.OS === 'android'
11
14
  const isWeb = Platform.OS === 'web'
12
- const nativeDriver = Platform.OS !== 'web'
13
15
 
14
16
  export interface SelectOption {
15
17
  label: string
@@ -26,6 +28,7 @@ export interface SelectProps {
26
28
  error?: string
27
29
  disabled?: boolean
28
30
  style?: ViewStyle
31
+ accessibilityLabel?: string
29
32
  }
30
33
 
31
34
  export function Select({
@@ -37,24 +40,19 @@ export function Select({
37
40
  error,
38
41
  disabled,
39
42
  style,
43
+ accessibilityLabel,
40
44
  }: SelectProps) {
41
45
  const { colors } = useTheme()
42
- const scale = useRef(new Animated.Value(1)).current
46
+ const { animatedStyle, onPressIn, onPressOut } = usePressScale({
47
+ pressScale: PRESS_SCALE.button,
48
+ disabled,
49
+ })
43
50
  const [pickerVisible, setPickerVisible] = useState(false)
44
51
  const [pendingValue, setPendingValue] = useState<string | undefined>(value)
45
52
  const pickerRef = useRef<React.ElementRef<typeof Picker>>(null)
46
53
 
47
54
  const selected = options.find((o) => o.value === value)
48
55
 
49
- const handlePressIn = () => {
50
- if (disabled) return
51
- Animated.spring(scale, { toValue: 0.95, useNativeDriver: nativeDriver, speed: 40, bounciness: 0 }).start()
52
- }
53
-
54
- const handlePressOut = () => {
55
- Animated.spring(scale, { toValue: 1, useNativeDriver: nativeDriver, speed: 40, bounciness: 4 }).start()
56
- }
57
-
58
56
  const handleOpen = () => {
59
57
  if (disabled) return
60
58
  hapticSelection()
@@ -84,7 +82,7 @@ export function Select({
84
82
 
85
83
  {/* Trigger button — shown on iOS and Android only */}
86
84
  {!isWeb ? (
87
- <Animated.View style={{ transform: [{ scale }], opacity: disabled ? 0.45 : 1 }}>
85
+ <Animated.View style={[animatedStyle, { opacity: disabled ? 0.45 : 1 }]}>
88
86
  <TouchableOpacity
89
87
  style={[
90
88
  styles.trigger,
@@ -94,10 +92,14 @@ export function Select({
94
92
  },
95
93
  ]}
96
94
  onPress={handleOpen}
97
- onPressIn={handlePressIn}
98
- onPressOut={handlePressOut}
95
+ onPressIn={onPressIn}
96
+ onPressOut={onPressOut}
99
97
  activeOpacity={1}
100
98
  touchSoundDisabled={true}
99
+ accessibilityRole="combobox"
100
+ accessibilityLabel={accessibilityLabel ?? label}
101
+ accessibilityValue={{ text: selected?.label ?? placeholder }}
102
+ accessibilityState={{ disabled: !!disabled, expanded: pickerVisible }}
101
103
  >
102
104
  <Text
103
105
  style={[
@@ -139,7 +139,7 @@ export function Sheet({
139
139
  const showHeader = !!(title || effectiveSubtitle || showCloseButton)
140
140
 
141
141
  const headerNode = showHeader ? (
142
- <View style={styles.header}>
142
+ <View style={styles.header} accessibilityRole="header">
143
143
  <View style={styles.headerRow}>
144
144
  {title ? (
145
145
  <Text style={[styles.title, { color: colors.foreground }]} allowFontScaling={true}>
@@ -152,6 +152,9 @@ export function Sheet({
152
152
  style={styles.closeButton}
153
153
  activeOpacity={0.6}
154
154
  touchSoundDisabled={true}
155
+ accessibilityRole="button"
156
+ accessibilityLabel="Close"
157
+ hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
155
158
  >
156
159
  <AntDesign name="close" size={ms(18)} color={colors.foregroundMuted} />
157
160
  </TouchableOpacity>
@@ -1,8 +1,16 @@
1
- import React, { useEffect, useRef, useState } from 'react'
2
- import { Animated, StyleSheet, View, ViewStyle } from 'react-native'
1
+ import React, { useEffect, useState } from 'react'
2
+ import { StyleSheet, View, ViewStyle } from 'react-native'
3
+ import Animated, {
4
+ useSharedValue,
5
+ useAnimatedStyle,
6
+ withRepeat,
7
+ withTiming,
8
+ Easing,
9
+ } from 'react-native-reanimated'
3
10
  import { LinearGradient } from 'expo-linear-gradient'
4
11
  import { useTheme } from '../../theme'
5
12
  import { s } from '../../utils/scaling'
13
+ import { TIMINGS } from '../../utils/animations'
6
14
 
7
15
  // circle: circular avatar placeholder text: short line preset base: custom dimensions
8
16
  export type SkeletonPreset = 'base' | 'circle' | 'text'
@@ -27,24 +35,24 @@ export function Skeleton({
27
35
  style,
28
36
  }: SkeletonProps) {
29
37
  const { colors, colorScheme } = useTheme()
30
- const shimmerAnim = useRef(new Animated.Value(0)).current
38
+ const shimmer = useSharedValue(0)
31
39
  const [containerWidth, setContainerWidth] = useState(300)
32
40
 
33
41
  const shimmerHighlight =
34
42
  colorScheme === 'dark' ? 'rgba(255,255,255,0.08)' : 'rgba(255,255,255,0.7)'
35
43
 
36
44
  useEffect(() => {
37
- const animation = Animated.loop(
38
- Animated.timing(shimmerAnim, { toValue: 1, duration: 1200, useNativeDriver: true })
45
+ // Repeats indefinitely on the UI thread — zero JS bridge cost per frame.
46
+ shimmer.value = withRepeat(
47
+ withTiming(1, { duration: TIMINGS.shimmer.duration, easing: Easing.linear }),
48
+ -1,
49
+ false,
39
50
  )
40
- animation.start()
41
- return () => animation.stop()
42
- }, [shimmerAnim])
51
+ }, [shimmer])
43
52
 
44
- const translateX = shimmerAnim.interpolate({
45
- inputRange: [0, 1],
46
- outputRange: [-containerWidth, containerWidth],
47
- })
53
+ const shimmerStyle = useAnimatedStyle(() => ({
54
+ transform: [{ translateX: -containerWidth + shimmer.value * (containerWidth * 2) }],
55
+ }))
48
56
 
49
57
  // Resolve dimensions by preset
50
58
  const resolvedWidth: number | string =
@@ -70,8 +78,11 @@ export function Skeleton({
70
78
  style,
71
79
  ]}
72
80
  onLayout={(e) => setContainerWidth(e.nativeEvent.layout.width)}
81
+ accessibilityRole="progressbar"
82
+ accessibilityLabel="Loading"
83
+ accessibilityState={{ busy: true }}
73
84
  >
74
- <Animated.View style={[StyleSheet.absoluteFill, { transform: [{ translateX }] }]}>
85
+ <Animated.View style={[StyleSheet.absoluteFill, shimmerStyle]}>
75
86
  <LinearGradient
76
87
  colors={['transparent', shimmerHighlight, 'transparent']}
77
88
  start={{ x: 0, y: 0 }}
@@ -46,7 +46,17 @@ export function Slider({
46
46
  }
47
47
 
48
48
  return (
49
- <View style={[styles.wrapper, style]} accessibilityLabel={accessibilityLabel}>
49
+ <View
50
+ style={[styles.wrapper, style]}
51
+ accessibilityRole="adjustable"
52
+ accessibilityLabel={accessibilityLabel ?? label}
53
+ accessibilityValue={{
54
+ min: minimumValue,
55
+ max: maximumValue,
56
+ now: value,
57
+ text: formatValue(value),
58
+ }}
59
+ >
50
60
  {label || showValue ? (
51
61
  <View style={styles.header}>
52
62
  {label ? (
@@ -1,63 +1,62 @@
1
- import React, { useEffect, useRef } from 'react'
2
- import { TouchableOpacity, Animated, StyleSheet, ViewStyle, Platform, View } from 'react-native'
1
+ import React, { useEffect } from 'react'
2
+ import { TouchableOpacity, StyleSheet, ViewStyle, View } from 'react-native'
3
+ import Animated, {
4
+ useSharedValue,
5
+ useAnimatedStyle,
6
+ withSpring,
7
+ withTiming,
8
+ interpolateColor,
9
+ } from 'react-native-reanimated'
3
10
  import { Feather } from '@expo/vector-icons'
4
-
5
- const nativeDriver = Platform.OS !== 'web'
6
11
  import { selectionAsync as hapticSelection } from '../../utils/haptics'
7
12
  import { useTheme } from '../../theme'
8
13
  import { s } from '../../utils/scaling'
14
+ import { SPRINGS, TIMINGS, EASINGS } from '../../utils/animations'
9
15
 
10
16
  const TRACK_WIDTH = s(52)
11
17
  const TRACK_HEIGHT = s(30)
12
18
  const THUMB_SIZE = s(24)
13
19
  const THUMB_OFFSET = s(3)
14
20
  const THUMB_TRAVEL = TRACK_WIDTH - THUMB_SIZE - THUMB_OFFSET * 2
21
+ const ICON_SIZE = s(13)
15
22
 
16
23
  export interface SwitchProps {
17
24
  checked?: boolean
18
25
  onCheckedChange?: (checked: boolean) => void
19
26
  disabled?: boolean
20
27
  style?: ViewStyle
28
+ accessibilityLabel?: string
21
29
  }
22
30
 
23
- const ICON_SIZE = s(13)
24
-
25
- export function Switch({ checked = false, onCheckedChange, disabled, style }: SwitchProps) {
31
+ export function Switch({ checked = false, onCheckedChange, disabled, style, accessibilityLabel }: SwitchProps) {
26
32
  const { colors } = useTheme()
27
- const translateX = useRef(new Animated.Value(checked ? THUMB_TRAVEL : 0)).current
28
- const trackOpacity = useRef(new Animated.Value(checked ? 1 : 0)).current
29
- const checkOpacity = useRef(new Animated.Value(checked ? 1 : 0)).current
30
- const crossOpacity = useRef(new Animated.Value(checked ? 0 : 1)).current
33
+
34
+ // Single 0→1 progress drives thumb position, track color, and icon crossfade — all UI thread.
35
+ const progress = useSharedValue(checked ? 1 : 0)
31
36
 
32
37
  useEffect(() => {
33
- Animated.parallel([
34
- Animated.spring(translateX, {
35
- toValue: checked ? THUMB_TRAVEL : 0,
36
- useNativeDriver: nativeDriver,
37
- bounciness: 4,
38
- }),
39
- Animated.timing(trackOpacity, {
40
- toValue: checked ? 1 : 0,
41
- duration: 150,
42
- useNativeDriver: false,
43
- }),
44
- Animated.timing(checkOpacity, {
45
- toValue: checked ? 1 : 0,
46
- duration: 120,
47
- useNativeDriver: true,
48
- }),
49
- Animated.timing(crossOpacity, {
50
- toValue: checked ? 0 : 1,
51
- duration: 120,
52
- useNativeDriver: true,
53
- }),
54
- ]).start()
55
- }, [checked, translateX, trackOpacity, checkOpacity, crossOpacity])
38
+ progress.value = withSpring(checked ? 1 : 0, SPRINGS.elastic)
39
+ }, [checked, progress])
40
+
41
+ const thumbStyle = useAnimatedStyle(() => ({
42
+ transform: [{ translateX: progress.value * THUMB_TRAVEL }],
43
+ }))
44
+
45
+ const trackStyle = useAnimatedStyle(() => ({
46
+ backgroundColor: interpolateColor(
47
+ progress.value,
48
+ [0, 1],
49
+ [colors.surfaceStrong, colors.primary],
50
+ ),
51
+ }))
52
+
53
+ const checkIconStyle = useAnimatedStyle(() => ({
54
+ opacity: withTiming(checked ? 1 : 0, { duration: TIMINGS.state.duration, easing: EASINGS.standard }),
55
+ }))
56
56
 
57
- const trackColor = trackOpacity.interpolate({
58
- inputRange: [0, 1],
59
- outputRange: [colors.surface, colors.primary],
60
- })
57
+ const crossIconStyle = useAnimatedStyle(() => ({
58
+ opacity: withTiming(checked ? 0 : 1, { duration: TIMINGS.state.duration, easing: EASINGS.standard }),
59
+ }))
61
60
 
62
61
  return (
63
62
  <View style={[{ opacity: disabled ? 0.45 : 1 }, style]}>
@@ -69,19 +68,18 @@ export function Switch({ checked = false, onCheckedChange, disabled, style }: Sw
69
68
  disabled={disabled}
70
69
  activeOpacity={0.8}
71
70
  touchSoundDisabled={true}
72
- style={styles.wrapper}
71
+ accessibilityRole="switch"
72
+ accessibilityLabel={accessibilityLabel}
73
+ accessibilityState={{ checked, disabled: !!disabled }}
73
74
  >
74
- <Animated.View style={[styles.track, { backgroundColor: trackColor }]}>
75
+ <Animated.View style={[styles.track, trackStyle]}>
75
76
  <Animated.View
76
- style={[
77
- styles.thumb,
78
- { backgroundColor: colors.primaryForeground, transform: [{ translateX }] },
79
- ]}
77
+ style={[styles.thumb, { backgroundColor: colors.primaryForeground }, thumbStyle]}
80
78
  >
81
- <Animated.View style={[styles.iconWrapper, { opacity: checkOpacity }]}>
79
+ <Animated.View style={[styles.iconWrapper, checkIconStyle]}>
82
80
  <Feather name="check" size={ICON_SIZE} color={colors.primary} />
83
81
  </Animated.View>
84
- <Animated.View style={[styles.iconWrapper, { opacity: crossOpacity }]}>
82
+ <Animated.View style={[styles.iconWrapper, crossIconStyle]}>
85
83
  <Feather name="x" size={ICON_SIZE} color={colors.foregroundMuted} />
86
84
  </Animated.View>
87
85
  </Animated.View>
@@ -92,13 +90,10 @@ export function Switch({ checked = false, onCheckedChange, disabled, style }: Sw
92
90
  }
93
91
 
94
92
  const styles = StyleSheet.create({
95
- wrapper: {},
96
93
  track: {
97
94
  width: TRACK_WIDTH,
98
95
  height: TRACK_HEIGHT,
99
96
  borderRadius: TRACK_HEIGHT / 2,
100
- // No justifyContent/alignItems — thumb uses absolute positioning
101
- // so the track's flex layout doesn't interfere with translateX animation
102
97
  },
103
98
  thumb: {
104
99
  position: 'absolute',