@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
@@ -16,11 +16,11 @@ const weightMap: Record<DetailRowLabelWeight, string> = {
16
16
 
17
17
  export interface DetailRowProps {
18
18
  label: React.ReactNode
19
- value: string
19
+ value: string | React.ReactNode
20
20
  /** Dotted/dashed/solid line between label and value. Defaults to 'dotted'. */
21
21
  separator?: DetailRowSeparator
22
22
  labelWeight?: DetailRowLabelWeight
23
- /** Semantic color key or hex string for value text. */
23
+ /** Semantic color key or hex string for value text. Only applies when value is a string. */
24
24
  valueColor?: string
25
25
  /** Node rendered left of the label (e.g. Avatar, Icon). */
26
26
  leftIcon?: React.ReactNode
@@ -34,6 +34,7 @@ export interface DetailRowProps {
34
34
  rightIconColor?: string
35
35
  style?: ViewStyle
36
36
  labelStyle?: TextStyle
37
+ /** Only applies when value is a string. */
37
38
  valueStyle?: TextStyle
38
39
  }
39
40
 
@@ -91,12 +92,16 @@ export function DetailRow({
91
92
  </View>
92
93
  {separatorStyle ? <View style={separatorStyle} /> : <View style={styles.spacer} />}
93
94
  <View style={styles.valueSide}>
94
- <Text
95
- style={[styles.valueText, { color: valueColor ?? colors.foreground }, valueStyle]}
96
- allowFontScaling={true}
97
- >
98
- {value}
99
- </Text>
95
+ {typeof value === 'string' ? (
96
+ <Text
97
+ style={[styles.valueText, { color: valueColor ?? colors.foreground }, valueStyle]}
98
+ allowFontScaling={true}
99
+ >
100
+ {value}
101
+ </Text>
102
+ ) : (
103
+ value
104
+ )}
100
105
  {resolvedRightIcon ? <View style={styles.icon}>{resolvedRightIcon}</View> : null}
101
106
  </View>
102
107
  </View>
@@ -1,21 +1,20 @@
1
- import React, { useRef } from 'react'
1
+ import React from 'react'
2
2
  import {
3
3
  TouchableOpacity,
4
- Animated,
5
4
  ActivityIndicator,
6
5
  StyleSheet,
7
6
  View,
8
7
  Text,
9
8
  TouchableOpacityProps,
10
9
  ViewStyle,
11
- Platform,
12
10
  } from 'react-native'
13
-
14
- const nativeDriver = Platform.OS !== 'web'
11
+ import Animated from 'react-native-reanimated'
15
12
  import { impactLight } from '../../utils/haptics'
16
13
  import { useTheme } from '../../theme'
17
14
  import { s, ms } from '../../utils/scaling'
18
15
  import { renderIcon } from '../../utils/icons'
16
+ import { usePressScale } from '../../utils/usePressScale'
17
+ import { PRESS_SCALE } from '../../utils/animations'
19
18
 
20
19
  // primary: filled primary
21
20
  // secondary: filled surface — icon on neutral bg (Airbnb icon-button-circle)
@@ -56,20 +55,16 @@ export function IconButton({
56
55
  disabled,
57
56
  style,
58
57
  onPress,
58
+ accessibilityLabel,
59
+ accessibilityHint,
59
60
  ...props
60
61
  }: IconButtonProps) {
61
62
  const { colors } = useTheme()
62
63
  const isDisabled = disabled || loading
63
- const scale = useRef(new Animated.Value(1)).current
64
-
65
- const handlePressIn = () => {
66
- if (isDisabled) return
67
- Animated.spring(scale, { toValue: 0.95, useNativeDriver: nativeDriver, speed: 40, bounciness: 0 }).start()
68
- }
69
-
70
- const handlePressOut = () => {
71
- Animated.spring(scale, { toValue: 1, useNativeDriver: nativeDriver, speed: 40, bounciness: 4 }).start()
72
- }
64
+ const { animatedStyle, onPressIn, onPressOut, hoverHandlers } = usePressScale({
65
+ pressScale: PRESS_SCALE.button,
66
+ disabled: isDisabled,
67
+ })
73
68
 
74
69
  const handlePress: TouchableOpacityProps['onPress'] = (e) => {
75
70
  impactLight()
@@ -109,7 +104,10 @@ export function IconButton({
109
104
  const showCount = typeof badge === 'number' && badge > 0
110
105
 
111
106
  return (
112
- <Animated.View style={[styles.wrapper, { transform: [{ scale }] }]}>
107
+ <Animated.View
108
+ style={[styles.wrapper, animatedStyle]}
109
+ {...hoverHandlers}
110
+ >
113
111
  <TouchableOpacity
114
112
  style={[
115
113
  styles.base,
@@ -122,8 +120,12 @@ export function IconButton({
122
120
  activeOpacity={1}
123
121
  touchSoundDisabled={true}
124
122
  onPress={handlePress}
125
- onPressIn={handlePressIn}
126
- onPressOut={handlePressOut}
123
+ onPressIn={onPressIn}
124
+ onPressOut={onPressOut}
125
+ accessibilityRole="button"
126
+ accessibilityLabel={accessibilityLabel ?? iconName ?? 'icon button'}
127
+ accessibilityHint={accessibilityHint}
128
+ accessibilityState={{ disabled: isDisabled, busy: loading }}
127
129
  {...props}
128
130
  >
129
131
  {loading ? (
@@ -1,9 +1,15 @@
1
- import React, { useState, useRef } from 'react'
2
- import { TextInput, View, Text, Animated, StyleSheet, TextInputProps, ViewStyle, TextStyle, TouchableOpacity, Platform, Easing } from 'react-native'
1
+ import React, { useState } from 'react'
2
+ import { TextInput, View, Text, StyleSheet, TextInputProps, ViewStyle, TextStyle, TouchableOpacity, Platform } from 'react-native'
3
+ import Animated, {
4
+ useAnimatedStyle,
5
+ interpolateColor,
6
+ } from 'react-native-reanimated'
3
7
  import { AntDesign } from '@expo/vector-icons'
4
8
  import { useTheme } from '../../theme'
5
9
  import { s, vs, ms } from '../../utils/scaling'
6
10
  import { renderIcon } from '../../utils/icons'
11
+ import { useColorTransition } from '../../utils/useColorTransition'
12
+ import { TIMINGS } from '../../utils/animations'
7
13
 
8
14
  const webInputResetStyle: any =
9
15
  Platform.OS === 'web'
@@ -48,11 +54,15 @@ export interface InputProps extends TextInputProps {
48
54
  inputWrapperStyle?: ViewStyle
49
55
  }
50
56
 
51
- export function Input({ label, error, hint, disabled, prefix, suffix, prefixStyle, suffixStyle, prefixIcon, suffixIcon, prefixIconColor, suffixIconColor, type = 'text', containerStyle, inputWrapperStyle, style, onFocus, onBlur, secureTextEntry, editable, ...props }: InputProps) {
57
+ export function Input({ label, error, hint, disabled, prefix, suffix, prefixStyle, suffixStyle, prefixIcon, suffixIcon, prefixIconColor, suffixIconColor, type = 'text', containerStyle, inputWrapperStyle, style, onFocus, onBlur, secureTextEntry, editable, accessibilityLabel, ...props }: InputProps) {
52
58
  const { colors } = useTheme()
53
59
  const [focused, setFocused] = useState(false)
54
60
  const [showPassword, setShowPassword] = useState(false)
55
- const focusAnim = useRef(new Animated.Value(0)).current
61
+
62
+ // Asymmetric durations — focus snaps in, blurs out subtly. Runs on UI thread.
63
+ const focusProgress = useColorTransition(focused, {
64
+ duration: focused ? TIMINGS.focusIn.duration : TIMINGS.focusOut.duration,
65
+ })
56
66
 
57
67
  const isDisabled = disabled || editable === false
58
68
  const isPassword = type === 'password'
@@ -62,30 +72,34 @@ export function Input({ label, error, hint, disabled, prefix, suffix, prefixStyl
62
72
  ? renderIcon(prefixIcon, 20, prefixIconColor ?? colors.foregroundMuted)
63
73
  : prefix
64
74
 
65
- // If type is password and no suffix override is provided, add the toggle button
66
75
  const effectiveSuffix: React.ReactNode = isPassword && !suffix && !suffixIcon ? (
67
- <TouchableOpacity onPress={() => setShowPassword(!showPassword)} style={styles.passwordToggle} activeOpacity={0.6}>
76
+ <TouchableOpacity
77
+ onPress={() => setShowPassword(!showPassword)}
78
+ style={styles.passwordToggle}
79
+ activeOpacity={0.6}
80
+ accessibilityRole="button"
81
+ accessibilityLabel={showPassword ? 'Hide password' : 'Show password'}
82
+ >
68
83
  <AntDesign name={showPassword ? 'eye' : 'eye-invisible'} size={20} color={colors.foregroundMuted} />
69
84
  </TouchableOpacity>
70
85
  ) : suffixIcon
71
86
  ? renderIcon(suffixIcon, 20, suffixIconColor ?? colors.foregroundMuted)
72
87
  : suffix
73
88
 
89
+ const borderColorStyle = useAnimatedStyle(() => ({
90
+ borderColor: error
91
+ ? colors.destructive
92
+ : interpolateColor(focusProgress.value, [0, 1], [colors.border, colors.primary]),
93
+ }))
94
+
74
95
  return (
75
96
  <View style={[styles.container, isDisabled && styles.containerDisabled, containerStyle]}>
76
97
  {label ? <Text style={[styles.label, { color: colors.foreground }]} allowFontScaling={true}>{label}</Text> : null}
77
98
  <Animated.View
78
99
  style={[
79
100
  styles.inputWrapper,
80
- {
81
- borderColor: error
82
- ? colors.destructive
83
- : focusAnim.interpolate({
84
- inputRange: [0, 1],
85
- outputRange: [colors.border, colors.primary],
86
- }),
87
- backgroundColor: isDisabled ? colors.surface : colors.background,
88
- },
101
+ { backgroundColor: isDisabled ? colors.surface : colors.background },
102
+ borderColorStyle,
89
103
  inputWrapperStyle,
90
104
  ]}
91
105
  >
@@ -101,26 +115,23 @@ export function Input({ label, error, hint, disabled, prefix, suffix, prefixStyl
101
115
  <TextInput
102
116
  style={[
103
117
  styles.input,
104
- {
105
- color: colors.foreground,
106
- },
118
+ { color: colors.foreground },
107
119
  webInputResetStyle,
108
120
  style,
109
121
  ]}
110
122
  onFocus={(e) => {
111
123
  setFocused(true)
112
- Animated.timing(focusAnim, { toValue: 1, duration: 120, easing: Easing.out(Easing.ease), useNativeDriver: false }).start()
113
124
  onFocus?.(e)
114
125
  }}
115
126
  onBlur={(e) => {
116
127
  setFocused(false)
117
- Animated.timing(focusAnim, { toValue: 0, duration: 80, easing: Easing.out(Easing.ease), useNativeDriver: false }).start()
118
128
  onBlur?.(e)
119
129
  }}
120
130
  placeholderTextColor={colors.foregroundMuted}
121
131
  allowFontScaling={true}
122
132
  secureTextEntry={effectiveSecure}
123
133
  editable={isDisabled ? false : editable}
134
+ accessibilityLabel={accessibilityLabel ?? label}
124
135
  {...props}
125
136
  />
126
137
  {effectiveSuffix ? (
@@ -134,7 +145,13 @@ export function Input({ label, error, hint, disabled, prefix, suffix, prefixStyl
134
145
  ) : null}
135
146
  </Animated.View>
136
147
  {error ? (
137
- <Text style={[styles.helperText, { color: colors.destructive }]} allowFontScaling={true}>{error}</Text>
148
+ <Text
149
+ style={[styles.helperText, { color: colors.destructive }]}
150
+ allowFontScaling={true}
151
+ accessibilityLiveRegion="polite"
152
+ >
153
+ {error}
154
+ </Text>
138
155
  ) : null}
139
156
  {!error && hint ? (
140
157
  <Text style={[styles.helperText, { color: colors.foregroundMuted }]} allowFontScaling={true}>{hint}</Text>
@@ -152,7 +169,7 @@ const styles = StyleSheet.create({
152
169
  },
153
170
  label: {
154
171
  fontFamily: 'Poppins-Medium',
155
- fontSize: ms(14), // caption size for input labels
172
+ fontSize: ms(14),
156
173
  },
157
174
  inputWrapper: {
158
175
  flexDirection: 'row',
@@ -1,22 +1,21 @@
1
- import React, { useRef } from 'react'
1
+ import React from 'react'
2
2
  import {
3
3
  TouchableOpacity,
4
- Animated,
5
4
  View,
6
5
  Text,
7
6
  StyleSheet,
8
7
  ViewStyle,
9
8
  TextStyle,
10
- Platform,
11
9
  } from 'react-native'
10
+ import Animated from 'react-native-reanimated'
12
11
  import { Entypo } from '@expo/vector-icons'
13
12
  import { selectionAsync as hapticSelection } from '../../utils/haptics'
14
13
  import { useTheme } from '../../theme'
15
14
  import { s, vs, ms, mvs } from '../../utils/scaling'
16
15
  import { renderIcon } from '../../utils/icons'
17
16
  import { RADIUS } from '../../tokens'
18
-
19
- const nativeDriver = Platform.OS !== 'web'
17
+ import { usePressScale } from '../../utils/usePressScale'
18
+ import { SPRINGS, PRESS_SCALE } from '../../utils/animations'
20
19
 
21
20
  export type ListItemVariant = 'plain' | 'card'
22
21
 
@@ -78,6 +77,8 @@ export interface ListItemProps {
78
77
  subtitleStyle?: TextStyle
79
78
  /** Style applied to the caption Text. */
80
79
  captionStyle?: TextStyle
80
+ /** Accessibility label override. Defaults to the title. */
81
+ accessibilityLabel?: string
81
82
  }
82
83
 
83
84
  export function ListItem({
@@ -101,30 +102,15 @@ export function ListItem({
101
102
  titleStyle,
102
103
  subtitleStyle,
103
104
  captionStyle,
105
+ accessibilityLabel,
104
106
  }: ListItemProps) {
105
107
  const { colors } = useTheme()
106
- const scale = useRef(new Animated.Value(1)).current
107
-
108
- const handlePressIn = () => {
109
- if (!onPress || disabled) return
110
- Animated.spring(scale, {
111
- toValue: 0.97,
112
- useNativeDriver: nativeDriver,
113
- stiffness: 350,
114
- damping: 28,
115
- mass: 0.9,
116
- }).start()
117
- }
118
-
119
- const handlePressOut = () => {
120
- Animated.spring(scale, {
121
- toValue: 1,
122
- useNativeDriver: nativeDriver,
123
- stiffness: 220,
124
- damping: 20,
125
- mass: 0.9,
126
- }).start()
127
- }
108
+ const { animatedStyle, onPressIn, onPressOut, hoverHandlers } = usePressScale({
109
+ pressScale: PRESS_SCALE.row,
110
+ pressInSpring: SPRINGS.surfacePressIn,
111
+ pressOutSpring: SPRINGS.surfacePressOut,
112
+ disabled: !onPress || disabled,
113
+ })
128
114
 
129
115
  const handlePress = () => {
130
116
  hapticSelection()
@@ -154,16 +140,21 @@ export function ListItem({
154
140
  }
155
141
  : {}
156
142
 
143
+ const a11yLabel = accessibilityLabel ?? [title, subtitle, caption].filter(Boolean).join('. ')
144
+
157
145
  return (
158
- <Animated.View style={[{ transform: [{ scale }] }, disabled && styles.disabled]}>
146
+ <Animated.View style={[animatedStyle, disabled && styles.disabled]} {...hoverHandlers}>
159
147
  <TouchableOpacity
160
148
  style={[styles.container, cardStyle, style]}
161
149
  onPress={onPress ? handlePress : undefined}
162
- onPressIn={handlePressIn}
163
- onPressOut={handlePressOut}
150
+ onPressIn={onPressIn}
151
+ onPressOut={onPressOut}
164
152
  disabled={disabled}
165
153
  activeOpacity={1}
166
154
  touchSoundDisabled={true}
155
+ accessibilityRole={onPress ? 'button' : undefined}
156
+ accessibilityLabel={onPress ? a11yLabel : undefined}
157
+ accessibilityState={onPress ? { disabled: !!disabled } : undefined}
167
158
  >
168
159
  {effectiveLeft ? (
169
160
  <View style={styles.leftContainer}>{effectiveLeft}</View>
@@ -219,7 +210,7 @@ export function ListItem({
219
210
  <View
220
211
  style={[
221
212
  styles.separator,
222
- {
213
+ {
223
214
  backgroundColor: colors.border,
224
215
  marginLeft: effectiveLeft ? s(44) + s(12) : 0
225
216
  },
@@ -275,9 +266,6 @@ const styles = StyleSheet.create({
275
266
  fontFamily: 'Poppins-Regular',
276
267
  fontSize: ms(14),
277
268
  },
278
- chevron: {
279
- marginLeft: s(4),
280
- },
281
269
  separator: {
282
270
  height: StyleSheet.hairlineWidth,
283
271
  marginRight: 0,
@@ -1,24 +1,24 @@
1
- import React, { useRef, useState } from 'react'
1
+ import React from 'react'
2
2
  import {
3
3
  View,
4
4
  Image,
5
5
  Text,
6
6
  TouchableOpacity,
7
- Animated,
8
7
  StyleSheet,
9
8
  ViewStyle,
10
9
  ImageSourcePropType,
11
10
  Platform,
12
11
  } from 'react-native'
12
+ import Animated from 'react-native-reanimated'
13
13
  import { impactLight } from '../../utils/haptics'
14
14
  import { useTheme } from '../../theme'
15
15
  import { s, vs, ms, mvs } from '../../utils/scaling'
16
16
  import { renderIcon } from '../../utils/icons'
17
17
  import { useHover } from '../../utils/hover'
18
+ import { usePressScale } from '../../utils/usePressScale'
19
+ import { SPRINGS, PRESS_SCALE } from '../../utils/animations'
18
20
  import { RADIUS, SHADOWS } from '../../tokens'
19
21
 
20
- const nativeDriver = Platform.OS !== 'web'
21
-
22
22
  export type MediaCardAspectRatio = '1:1' | '4:3' | '16:9' | '4:5' | '3:2'
23
23
 
24
24
  const aspectRatioMap: Record<MediaCardAspectRatio, number> = {
@@ -57,6 +57,8 @@ export interface MediaCardProps {
57
57
  imageStyle?: ViewStyle
58
58
  /** Additional content rendered below caption. */
59
59
  footer?: React.ReactNode
60
+ /** Accessibility label override. Defaults to title (and subtitle if present). */
61
+ accessibilityLabel?: string
60
62
  }
61
63
 
62
64
  export function MediaCard({
@@ -74,20 +76,16 @@ export function MediaCard({
74
76
  style,
75
77
  imageStyle,
76
78
  footer,
79
+ accessibilityLabel,
77
80
  }: MediaCardProps) {
78
81
  const { colors } = useTheme()
79
- const scale = useRef(new Animated.Value(1)).current
80
82
  const { hovered, hoverHandlers } = useHover()
81
-
82
- const handlePressIn = () => {
83
- if (!onPress) return
84
- Animated.spring(scale, { toValue: 0.98, useNativeDriver: nativeDriver, speed: 40, bounciness: 0 }).start()
85
- }
86
-
87
- const handlePressOut = () => {
88
- if (!onPress) return
89
- Animated.spring(scale, { toValue: 1, useNativeDriver: nativeDriver, speed: 40, bounciness: 4 }).start()
90
- }
83
+ const { animatedStyle, onPressIn, onPressOut } = usePressScale({
84
+ pressScale: PRESS_SCALE.card,
85
+ pressInSpring: SPRINGS.surfacePressIn,
86
+ pressOutSpring: SPRINGS.surfacePressOut,
87
+ disabled: !onPress,
88
+ })
91
89
 
92
90
  const handlePress = () => {
93
91
  if (!onPress) return
@@ -102,6 +100,8 @@ export function MediaCard({
102
100
  ? renderIcon(actionIconName, 18, actionActive ? colors.primary : colors.background)
103
101
  : actionIcon ?? renderIcon('heart', 18, actionActive ? colors.primary : colors.background)
104
102
 
103
+ const a11yLabel = accessibilityLabel ?? [title, subtitle].filter(Boolean).join('. ')
104
+
105
105
  const cardContent = (
106
106
  <View
107
107
  style={[
@@ -111,7 +111,6 @@ export function MediaCard({
111
111
  ]}
112
112
  {...(Platform.OS === 'web' ? hoverHandlers : {})}
113
113
  >
114
- {/* Image area */}
115
114
  <View style={[styles.imageContainer, imageStyle]}>
116
115
  <View style={{ paddingTop: `${ratio * 100}%` as any }}>
117
116
  <View style={StyleSheet.absoluteFill}>
@@ -127,27 +126,27 @@ export function MediaCard({
127
126
  </View>
128
127
  </View>
129
128
 
130
- {/* Badge — top left */}
131
129
  {badge && (
132
130
  <View style={styles.badgeContainer}>
133
131
  {badge}
134
132
  </View>
135
133
  )}
136
134
 
137
- {/* Action icon — top right */}
138
135
  {(onActionPress || actionIcon || actionIconName) && (
139
136
  <TouchableOpacity
140
137
  style={[styles.actionButton, { backgroundColor: 'rgba(0,0,0,0.24)' }]}
141
138
  onPress={() => { impactLight(); onActionPress?.() }}
142
139
  activeOpacity={0.8}
143
140
  touchSoundDisabled={true}
141
+ accessibilityRole="button"
142
+ accessibilityLabel={actionIconName ?? 'action'}
143
+ accessibilityState={{ selected: actionActive }}
144
144
  >
145
145
  {resolvedActionIcon}
146
146
  </TouchableOpacity>
147
147
  )}
148
148
  </View>
149
149
 
150
- {/* Metadata */}
151
150
  {(title || subtitle || caption || footer) && (
152
151
  <View style={styles.meta}>
153
152
  {title ? (
@@ -173,13 +172,15 @@ export function MediaCard({
173
172
 
174
173
  if (onPress) {
175
174
  return (
176
- <Animated.View style={{ transform: [{ scale }] }}>
175
+ <Animated.View style={animatedStyle}>
177
176
  <TouchableOpacity
178
177
  onPress={handlePress}
179
- onPressIn={handlePressIn}
180
- onPressOut={handlePressOut}
178
+ onPressIn={onPressIn}
179
+ onPressOut={onPressOut}
181
180
  activeOpacity={1}
182
181
  touchSoundDisabled={true}
182
+ accessibilityRole="button"
183
+ accessibilityLabel={a11yLabel}
183
184
  >
184
185
  {cardContent}
185
186
  </TouchableOpacity>
@@ -192,12 +193,11 @@ export function MediaCard({
192
193
 
193
194
  const styles = StyleSheet.create({
194
195
  card: {
195
- borderRadius: RADIUS.md, // 14px — Airbnb property card spec
196
+ borderRadius: RADIUS.md,
196
197
  overflow: 'hidden',
197
198
  backgroundColor: 'transparent',
198
199
  },
199
200
  cardHovered: {
200
- // Web hover: lift shadow
201
201
  ...SHADOWS.md,
202
202
  },
203
203
  imageContainer: {
@@ -1,27 +1,28 @@
1
- import React, { useRef } from 'react'
1
+ import React from 'react'
2
2
  import {
3
3
  TouchableOpacity,
4
- Animated,
5
4
  View,
6
5
  Text,
7
6
  StyleSheet,
8
7
  ViewStyle,
9
8
  TextStyle,
10
- Platform,
11
9
  } from 'react-native'
10
+ import Animated from 'react-native-reanimated'
12
11
  import { Entypo } from '@expo/vector-icons'
13
12
  import { selectionAsync as hapticSelection } from '../../utils/haptics'
14
13
  import { useTheme } from '../../theme'
15
14
  import { s, vs, ms } from '../../utils/scaling'
16
15
  import { renderIcon } from '../../utils/icons'
17
16
  import { RADIUS } from '../../tokens'
18
-
19
- const nativeDriver = Platform.OS !== 'web'
17
+ import { usePressScale } from '../../utils/usePressScale'
18
+ import { SPRINGS, PRESS_SCALE } from '../../utils/animations'
20
19
 
21
20
  export type MenuItemVariant = 'plain' | 'card'
22
21
 
23
22
  export interface MenuItemProps {
24
23
  label: string
24
+ /** Secondary text rendered below the label. */
25
+ subtitle?: string
25
26
  /**
26
27
  * Icon name from `@expo/vector-icons` rendered on the left.
27
28
  * See https://icons.expo.fyi.
@@ -55,10 +56,13 @@ export interface MenuItemProps {
55
56
  style?: ViewStyle
56
57
  /** Style applied to the label Text. */
57
58
  labelStyle?: TextStyle
59
+ /** Accessibility label override. Defaults to label. */
60
+ accessibilityLabel?: string
58
61
  }
59
62
 
60
63
  export function MenuItem({
61
64
  label,
65
+ subtitle,
62
66
  iconName,
63
67
  icon,
64
68
  iconColor,
@@ -70,30 +74,15 @@ export function MenuItem({
70
74
  showSeparator = false,
71
75
  style,
72
76
  labelStyle,
77
+ accessibilityLabel,
73
78
  }: MenuItemProps) {
74
79
  const { colors } = useTheme()
75
- const scale = useRef(new Animated.Value(1)).current
76
-
77
- const handlePressIn = () => {
78
- if (disabled) return
79
- Animated.spring(scale, {
80
- toValue: 0.97,
81
- useNativeDriver: nativeDriver,
82
- stiffness: 350,
83
- damping: 28,
84
- mass: 0.9,
85
- }).start()
86
- }
87
-
88
- const handlePressOut = () => {
89
- Animated.spring(scale, {
90
- toValue: 1,
91
- useNativeDriver: nativeDriver,
92
- stiffness: 220,
93
- damping: 20,
94
- mass: 0.9,
95
- }).start()
96
- }
80
+ const { animatedStyle, onPressIn, onPressOut, hoverHandlers } = usePressScale({
81
+ pressScale: PRESS_SCALE.row,
82
+ pressInSpring: SPRINGS.surfacePressIn,
83
+ pressOutSpring: SPRINGS.surfacePressOut,
84
+ disabled,
85
+ })
97
86
 
98
87
  const handlePress = () => {
99
88
  hapticSelection()
@@ -119,31 +108,47 @@ export function MenuItem({
119
108
  }
120
109
  : {}
121
110
 
111
+ const a11yLabel = accessibilityLabel ?? (subtitle ? `${label}. ${subtitle}` : label)
112
+
122
113
  return (
123
- <Animated.View style={[{ transform: [{ scale }] }, disabled && styles.disabled]}>
114
+ <Animated.View style={[animatedStyle, disabled && styles.disabled]} {...hoverHandlers}>
124
115
  <TouchableOpacity
125
116
  style={[styles.container, cardStyle, style]}
126
117
  onPress={handlePress}
127
- onPressIn={handlePressIn}
128
- onPressOut={handlePressOut}
118
+ onPressIn={onPressIn}
119
+ onPressOut={onPressOut}
129
120
  disabled={disabled}
130
121
  activeOpacity={1}
131
122
  touchSoundDisabled={true}
123
+ accessibilityRole="button"
124
+ accessibilityLabel={a11yLabel}
125
+ accessibilityState={{ disabled }}
132
126
  >
133
127
  {resolvedIcon ? (
134
128
  <View style={styles.iconContainer}>{resolvedIcon}</View>
135
129
  ) : null}
136
130
 
137
- <Text
138
- style={[styles.label, { color: colors.foreground }, labelStyle]}
139
- numberOfLines={1}
140
- allowFontScaling={true}
141
- >
142
- {label}
143
- </Text>
131
+ <View style={styles.labelContainer}>
132
+ <Text
133
+ style={[styles.label, { color: colors.foreground }, labelStyle]}
134
+ numberOfLines={1}
135
+ allowFontScaling={true}
136
+ >
137
+ {label}
138
+ </Text>
139
+ {subtitle ? (
140
+ <Text
141
+ style={[styles.subtitle, { color: colors.foregroundMuted }]}
142
+ numberOfLines={1}
143
+ allowFontScaling={true}
144
+ >
145
+ {subtitle}
146
+ </Text>
147
+ ) : null}
148
+ </View>
144
149
 
145
150
  {rightRender !== undefined ? (
146
- <View
151
+ <View
147
152
  style={styles.rightContainer}
148
153
  onStartShouldSetResponder={() => true}
149
154
  onResponderRelease={() => {}}
@@ -186,10 +191,18 @@ const styles = StyleSheet.create({
186
191
  justifyContent: 'center',
187
192
  flexShrink: 0,
188
193
  },
194
+ labelContainer: {
195
+ flex: 1,
196
+ justifyContent: 'center',
197
+ },
189
198
  label: {
190
199
  fontFamily: 'Poppins-Medium',
191
200
  fontSize: ms(15),
192
- flex: 1,
201
+ },
202
+ subtitle: {
203
+ fontFamily: 'Poppins-Regular',
204
+ fontSize: ms(12),
205
+ marginTop: vs(1),
193
206
  },
194
207
  rightContainer: {
195
208
  alignItems: 'flex-end',