@retray-dev/ui-kit 7.0.1 → 9.1.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 (234) hide show
  1. package/COMPONENTS.md +567 -14
  2. package/EXAMPLES.md +21 -14
  3. package/README.md +14 -8
  4. package/dist/Accordion.js +57 -5
  5. package/dist/Accordion.mjs +4 -3
  6. package/dist/AlertBanner.js +4 -1
  7. package/dist/AlertBanner.mjs +3 -2
  8. package/dist/AppHeader.d.mts +40 -0
  9. package/dist/AppHeader.d.ts +40 -0
  10. package/dist/AppHeader.js +515 -0
  11. package/dist/AppHeader.mjs +10 -0
  12. package/dist/Avatar.js +39 -29
  13. package/dist/Avatar.mjs +2 -1
  14. package/dist/Badge.js +11 -1
  15. package/dist/Badge.mjs +2 -1
  16. package/dist/Button.d.mts +8 -3
  17. package/dist/Button.d.ts +8 -3
  18. package/dist/Button.js +126 -108
  19. package/dist/Button.mjs +6 -5
  20. package/dist/ButtonGroup.mjs +1 -0
  21. package/dist/Card.js +90 -70
  22. package/dist/Card.mjs +5 -4
  23. package/dist/CategoryStrip.js +79 -22
  24. package/dist/CategoryStrip.mjs +6 -6
  25. package/dist/Checkbox.js +118 -86
  26. package/dist/Checkbox.mjs +5 -5
  27. package/dist/Chip.js +113 -80
  28. package/dist/Chip.mjs +5 -5
  29. package/dist/ConfirmDialog.js +140 -110
  30. package/dist/ConfirmDialog.mjs +7 -6
  31. package/dist/CurrencyDisplay.mjs +1 -0
  32. package/dist/CurrencyInput.d.mts +1 -1
  33. package/dist/CurrencyInput.d.ts +1 -1
  34. package/dist/CurrencyInput.js +9 -5
  35. package/dist/CurrencyInput.mjs +5 -4
  36. package/dist/DetailRow.mjs +1 -0
  37. package/dist/EmptyState.js +131 -111
  38. package/dist/EmptyState.mjs +7 -6
  39. package/dist/ErrorBoundary.d.mts +42 -0
  40. package/dist/ErrorBoundary.d.ts +42 -0
  41. package/dist/ErrorBoundary.js +351 -0
  42. package/dist/ErrorBoundary.mjs +7 -0
  43. package/dist/Form.mjs +1 -0
  44. package/dist/HolographicCard.d.mts +55 -0
  45. package/dist/HolographicCard.d.ts +55 -0
  46. package/dist/HolographicCard.js +316 -0
  47. package/dist/HolographicCard.mjs +191 -0
  48. package/dist/IconButton.d.mts +8 -3
  49. package/dist/IconButton.d.ts +8 -3
  50. package/dist/IconButton.js +115 -98
  51. package/dist/IconButton.mjs +5 -4
  52. package/dist/ImageViewer.d.mts +23 -0
  53. package/dist/ImageViewer.d.ts +23 -0
  54. package/dist/ImageViewer.js +582 -0
  55. package/dist/ImageViewer.mjs +8 -0
  56. package/dist/Input.mjs +4 -3
  57. package/dist/LabelValue.mjs +1 -0
  58. package/dist/ListGroup.mjs +1 -0
  59. package/dist/ListItem.js +131 -117
  60. package/dist/ListItem.mjs +6 -5
  61. package/dist/MediaCard.js +54 -6
  62. package/dist/MediaCard.mjs +6 -5
  63. package/dist/MenuGroup.mjs +1 -0
  64. package/dist/MenuItem.js +91 -79
  65. package/dist/MenuItem.mjs +6 -5
  66. package/dist/MonthPicker.d.mts +10 -2
  67. package/dist/MonthPicker.d.ts +10 -2
  68. package/dist/MonthPicker.js +80 -17
  69. package/dist/MonthPicker.mjs +3 -2
  70. package/dist/PagerDots.d.mts +35 -0
  71. package/dist/PagerDots.d.ts +35 -0
  72. package/dist/PagerDots.js +392 -0
  73. package/dist/PagerDots.mjs +7 -0
  74. package/dist/Pressable.d.mts +5 -5
  75. package/dist/Pressable.d.ts +5 -5
  76. package/dist/Pressable.js +97 -86
  77. package/dist/Pressable.mjs +5 -4
  78. package/dist/PricingCard.d.mts +50 -0
  79. package/dist/PricingCard.d.ts +50 -0
  80. package/dist/PricingCard.js +636 -0
  81. package/dist/PricingCard.mjs +11 -0
  82. package/dist/Progress.mjs +3 -2
  83. package/dist/RadioGroup.js +81 -30
  84. package/dist/RadioGroup.mjs +5 -5
  85. package/dist/RetrayProvider.d.mts +2 -0
  86. package/dist/RetrayProvider.d.ts +2 -0
  87. package/dist/RetrayProvider.js +214 -0
  88. package/dist/RetrayProvider.mjs +5 -0
  89. package/dist/Select.js +51 -4
  90. package/dist/Select.mjs +5 -4
  91. package/dist/SelectableGrid.d.mts +44 -0
  92. package/dist/SelectableGrid.d.ts +44 -0
  93. package/dist/SelectableGrid.js +448 -0
  94. package/dist/SelectableGrid.mjs +9 -0
  95. package/dist/Separator.mjs +1 -0
  96. package/dist/Sheet.d.mts +13 -1
  97. package/dist/Sheet.d.ts +13 -1
  98. package/dist/Sheet.js +115 -5
  99. package/dist/Sheet.mjs +4 -2
  100. package/dist/Skeleton.d.mts +50 -0
  101. package/dist/Skeleton.d.ts +50 -0
  102. package/dist/Skeleton.js +61 -0
  103. package/dist/Skeleton.mjs +4 -2
  104. package/dist/Slider.js +51 -4
  105. package/dist/Slider.mjs +3 -2
  106. package/dist/Spinner.js +28 -7
  107. package/dist/Spinner.mjs +2 -1
  108. package/dist/Switch.js +98 -48
  109. package/dist/Switch.mjs +4 -3
  110. package/dist/TabBar.d.mts +42 -0
  111. package/dist/TabBar.d.ts +42 -0
  112. package/dist/TabBar.js +361 -0
  113. package/dist/TabBar.mjs +6 -0
  114. package/dist/Tabs.js +92 -62
  115. package/dist/Tabs.mjs +5 -4
  116. package/dist/Text.js +16 -0
  117. package/dist/Text.mjs +2 -1
  118. package/dist/Textarea.mjs +4 -3
  119. package/dist/Toast.d.mts +7 -7
  120. package/dist/Toast.d.ts +7 -7
  121. package/dist/Toast.mjs +1 -0
  122. package/dist/Toggle.d.mts +6 -3
  123. package/dist/Toggle.d.ts +6 -3
  124. package/dist/Toggle.js +135 -120
  125. package/dist/Toggle.mjs +5 -5
  126. package/dist/VirtualList.mjs +1 -0
  127. package/dist/{chunk-7H2OR44A.mjs → chunk-26BCI223.mjs} +1 -1
  128. package/dist/{chunk-CRYBX2CM.mjs → chunk-2TFTAWVJ.mjs} +44 -59
  129. package/dist/chunk-3DKJ2GIC.mjs +30 -0
  130. package/dist/{chunk-KWCPOM6W.mjs → chunk-3U4SSNWP.mjs} +32 -48
  131. package/dist/chunk-4I7D47FH.mjs +139 -0
  132. package/dist/chunk-4K625MVM.mjs +142 -0
  133. package/dist/{chunk-MN7OG7IY.mjs → chunk-6OAZJ577.mjs} +6 -4
  134. package/dist/{chunk-L7E7TVEZ.mjs → chunk-756RAKE4.mjs} +2 -2
  135. package/dist/{chunk-HSPSMN6U.mjs → chunk-7QHVVCB3.mjs} +2 -2
  136. package/dist/{chunk-URLL5JBR.mjs → chunk-A3A6KNQN.mjs} +3 -3
  137. package/dist/chunk-AJ7ZDNBT.mjs +120 -0
  138. package/dist/{chunk-FTLJOUOQ.mjs → chunk-AV4EMIRH.mjs} +25 -28
  139. package/dist/chunk-AZJF2BLK.mjs +115 -0
  140. package/dist/chunk-BNP626TY.mjs +159 -0
  141. package/dist/{chunk-5IKW3VNC.mjs → chunk-DVK4G2GT.mjs} +17 -1
  142. package/dist/{chunk-6LQYY7HC.mjs → chunk-EH745HE5.mjs} +2 -2
  143. package/dist/chunk-EJ7ZPXOH.mjs +163 -0
  144. package/dist/{chunk-RKLHUDZS.mjs → chunk-GD6KXMG5.mjs} +29 -15
  145. package/dist/{chunk-RR2VQLKE.mjs → chunk-GQYFLP3D.mjs} +14 -17
  146. package/dist/{chunk-Y6MXOREN.mjs → chunk-ID72TK46.mjs} +8 -17
  147. package/dist/{chunk-NQGVLMWG.mjs → chunk-JMOZEC77.mjs} +1 -1
  148. package/dist/{chunk-GCWOGZYL.mjs → chunk-JT7HKXRB.mjs} +39 -29
  149. package/dist/{chunk-LWG526VX.mjs → chunk-KIHCWCWL.mjs} +47 -62
  150. package/dist/chunk-LXJIIOYQ.mjs +104 -0
  151. package/dist/{chunk-SBZYEV4S.mjs → chunk-M6ZXVBTK.mjs} +5 -2
  152. package/dist/{chunk-XDMN67KV.mjs → chunk-MAC465BB.mjs} +10 -8
  153. package/dist/chunk-MBMXYJJV.mjs +36 -0
  154. package/dist/chunk-MLF3EZFW.mjs +119 -0
  155. package/dist/chunk-NA7PARID.mjs +147 -0
  156. package/dist/{chunk-QXGYKWI7.mjs → chunk-O3HA6TYM.mjs} +9 -4
  157. package/dist/{chunk-63357L2X.mjs → chunk-OB4JUQ3O.mjs} +1 -1
  158. package/dist/{chunk-AU2VDY4P.mjs → chunk-PFZTM6D5.mjs} +52 -4
  159. package/dist/chunk-QKH5ZOD5.mjs +97 -0
  160. package/dist/{chunk-KZJRQOIU.mjs → chunk-TERDKCLE.mjs} +11 -1
  161. package/dist/{chunk-U4N7WF4Z.mjs → chunk-UREA2GYY.mjs} +28 -23
  162. package/dist/{chunk-TAJ2PQ2O.mjs → chunk-VGTDN7SW.mjs} +7 -6
  163. package/dist/{chunk-URDE3EUU.mjs → chunk-VQ57HWPL.mjs} +27 -15
  164. package/dist/chunk-WBOOUHSS.mjs +62 -0
  165. package/dist/{chunk-GNGLDL6Z.mjs → chunk-WJLKJMKR.mjs} +18 -0
  166. package/dist/{chunk-YZJAFS4P.mjs → chunk-X4G6APW6.mjs} +22 -19
  167. package/dist/chunk-Y6FXYEAI.mjs +8 -0
  168. package/dist/chunk-YFZ3ELX5.mjs +16 -0
  169. package/dist/{chunk-QCNARS3X.mjs → chunk-YNROWHQJ.mjs} +1 -1
  170. package/dist/chunk-Z4BVUWW6.mjs +196 -0
  171. package/dist/{chunk-GPOUINK5.mjs → chunk-ZJKGQMYH.mjs} +10 -27
  172. package/dist/index-wt-orHUi.d.mts +85 -0
  173. package/dist/index-wt-orHUi.d.ts +85 -0
  174. package/dist/index.d.mts +59 -51
  175. package/dist/index.d.ts +59 -51
  176. package/dist/index.js +1940 -744
  177. package/dist/index.mjs +49 -39
  178. package/package.json +35 -5
  179. package/src/components/Accordion/Accordion.tsx +12 -1
  180. package/src/components/AlertBanner/AlertBanner.tsx +5 -0
  181. package/src/components/AppHeader/AppHeader.tsx +172 -0
  182. package/src/components/AppHeader/index.ts +1 -0
  183. package/src/components/Avatar/Avatar.tsx +10 -2
  184. package/src/components/Badge/Badge.tsx +8 -1
  185. package/src/components/Button/Button.tsx +20 -27
  186. package/src/components/Card/Card.tsx +12 -23
  187. package/src/components/CategoryStrip/CategoryStrip.tsx +17 -21
  188. package/src/components/Checkbox/Checkbox.tsx +26 -40
  189. package/src/components/Chip/Chip.tsx +24 -33
  190. package/src/components/CurrencyInput/CurrencyInput.tsx +10 -8
  191. package/src/components/EmptyState/EmptyState.tsx +2 -1
  192. package/src/components/ErrorBoundary/ErrorBoundary.tsx +153 -0
  193. package/src/components/ErrorBoundary/index.ts +1 -0
  194. package/src/components/HolographicCard/HolographicCard.tsx +315 -0
  195. package/src/components/HolographicCard/index.ts +1 -0
  196. package/src/components/IconButton/IconButton.tsx +19 -27
  197. package/src/components/ImageViewer/ImageViewer.tsx +290 -0
  198. package/src/components/ImageViewer/index.ts +1 -0
  199. package/src/components/ListItem/ListItem.tsx +70 -67
  200. package/src/components/MediaCard/MediaCard.tsx +8 -2
  201. package/src/components/MenuItem/MenuItem.tsx +10 -25
  202. package/src/components/MonthPicker/MonthPicker.tsx +39 -13
  203. package/src/components/MonthPicker/index.ts +1 -1
  204. package/src/components/PagerDots/PagerDots.tsx +200 -0
  205. package/src/components/PagerDots/index.ts +1 -0
  206. package/src/components/Pressable/Pressable.tsx +19 -35
  207. package/src/components/PricingCard/PricingCard.tsx +220 -0
  208. package/src/components/PricingCard/index.ts +1 -0
  209. package/src/components/RadioGroup/RadioGroup.tsx +14 -27
  210. package/src/components/RetrayProvider/RetrayProvider.tsx +59 -0
  211. package/src/components/RetrayProvider/index.ts +1 -0
  212. package/src/components/SelectableGrid/SelectableGrid.tsx +205 -0
  213. package/src/components/SelectableGrid/index.ts +1 -0
  214. package/src/components/Sheet/Sheet.tsx +65 -1
  215. package/src/components/Skeleton/Skeleton.tsx +142 -1
  216. package/src/components/Spinner/Spinner.tsx +17 -2
  217. package/src/components/Switch/Switch.tsx +30 -58
  218. package/src/components/TabBar/TabBar.tsx +169 -0
  219. package/src/components/TabBar/index.ts +1 -0
  220. package/src/components/Tabs/Tabs.tsx +23 -26
  221. package/src/components/Text/Text.tsx +2 -0
  222. package/src/components/Toggle/Toggle.tsx +35 -51
  223. package/src/fonts.ts +4 -1
  224. package/src/index.ts +23 -2
  225. package/src/utils/animations.ts +29 -1
  226. package/src/utils/fontGuard.ts +34 -0
  227. package/src/utils/haptics.ts +211 -9
  228. package/src/utils/pressable.ts +66 -0
  229. package/dist/chunk-76PFOSM2.mjs +0 -41
  230. package/dist/chunk-DITNP6PL.mjs +0 -106
  231. package/dist/chunk-JBLL7U3U.mjs +0 -64
  232. package/dist/chunk-LG4DO3DK.mjs +0 -174
  233. package/dist/chunk-RMMK64W5.mjs +0 -54
  234. package/dist/chunk-RTC3CFXF.mjs +0 -29
@@ -0,0 +1,290 @@
1
+ import React, { useState, useCallback } from 'react'
2
+ import {
3
+ Modal,
4
+ View,
5
+ TouchableOpacity,
6
+ StyleSheet,
7
+ useWindowDimensions,
8
+ ImageSourcePropType,
9
+ ScrollView,
10
+ NativeSyntheticEvent,
11
+ NativeScrollEvent,
12
+ } from 'react-native'
13
+ import { GestureHandlerRootView, GestureDetector, Gesture } from 'react-native-gesture-handler'
14
+ import Animated, {
15
+ useSharedValue,
16
+ useAnimatedStyle,
17
+ withTiming,
18
+ runOnJS,
19
+ } from 'react-native-reanimated'
20
+ import { useSafeAreaInsets } from 'react-native-safe-area-context'
21
+ import { renderIcon } from '../../utils/icons'
22
+ import { PagerDots } from '../PagerDots'
23
+ import { s, vs } from '../../utils/scaling'
24
+
25
+ const MAX_SCALE = 3
26
+ const DOUBLE_TAP_SCALE = 2.5
27
+
28
+ interface ZoomableImageProps {
29
+ source: ImageSourcePropType
30
+ width: number
31
+ height: number
32
+ /** Reports whether this page is currently zoomed in, so the pager can lock paging. */
33
+ onZoomChange: (zoomed: boolean) => void
34
+ }
35
+
36
+ function ZoomableImage({ source, width, height, onZoomChange }: ZoomableImageProps) {
37
+ const scale = useSharedValue(1)
38
+ const savedScale = useSharedValue(1)
39
+ const translateX = useSharedValue(0)
40
+ const translateY = useSharedValue(0)
41
+ const savedX = useSharedValue(0)
42
+ const savedY = useSharedValue(0)
43
+
44
+ const reportZoom = useCallback((zoomed: boolean) => onZoomChange(zoomed), [onZoomChange])
45
+
46
+ const reset = () => {
47
+ 'worklet'
48
+ scale.value = withTiming(1)
49
+ savedScale.value = 1
50
+ translateX.value = withTiming(0)
51
+ translateY.value = withTiming(0)
52
+ savedX.value = 0
53
+ savedY.value = 0
54
+ runOnJS(reportZoom)(false)
55
+ }
56
+
57
+ const pinch = Gesture.Pinch()
58
+ .onUpdate((e) => {
59
+ scale.value = Math.max(1, Math.min(savedScale.value * e.scale, MAX_SCALE))
60
+ })
61
+ .onEnd(() => {
62
+ savedScale.value = scale.value
63
+ if (scale.value <= 1) {
64
+ reset()
65
+ } else {
66
+ runOnJS(reportZoom)(true)
67
+ }
68
+ })
69
+
70
+ const pan = Gesture.Pan()
71
+ .onUpdate((e) => {
72
+ if (scale.value <= 1) return
73
+ translateX.value = savedX.value + e.translationX
74
+ translateY.value = savedY.value + e.translationY
75
+ })
76
+ .onEnd(() => {
77
+ savedX.value = translateX.value
78
+ savedY.value = translateY.value
79
+ })
80
+
81
+ const doubleTap = Gesture.Tap()
82
+ .numberOfTaps(2)
83
+ .onEnd(() => {
84
+ if (scale.value > 1) {
85
+ reset()
86
+ } else {
87
+ scale.value = withTiming(DOUBLE_TAP_SCALE)
88
+ savedScale.value = DOUBLE_TAP_SCALE
89
+ runOnJS(reportZoom)(true)
90
+ }
91
+ })
92
+
93
+ const composed = Gesture.Exclusive(doubleTap, Gesture.Simultaneous(pinch, pan))
94
+
95
+ const animatedStyle = useAnimatedStyle(() => ({
96
+ transform: [
97
+ { translateX: translateX.value },
98
+ { translateY: translateY.value },
99
+ { scale: scale.value },
100
+ ],
101
+ }))
102
+
103
+ return (
104
+ <GestureDetector gesture={composed}>
105
+ <Animated.View style={[{ width, height }, styles.imageWrap]}>
106
+ <Animated.Image
107
+ source={source}
108
+ style={[{ width, height }, animatedStyle]}
109
+ resizeMode="contain"
110
+ />
111
+ </Animated.View>
112
+ </GestureDetector>
113
+ )
114
+ }
115
+
116
+ export interface ImageViewerProps {
117
+ /** Images to show — URI strings via `{ uri }` or `require()` sources. */
118
+ images: ImageSourcePropType[]
119
+ visible: boolean
120
+ onClose: () => void
121
+ /** Page to open on. Defaults to 0. */
122
+ initialIndex?: number
123
+ }
124
+
125
+ /**
126
+ * Full-screen zoomable image gallery. Horizontal paging + pinch / double-tap
127
+ * zoom + pan. Page dots and a close button overlay the images.
128
+ *
129
+ * Requires `react-native-gesture-handler` (already a peer dependency).
130
+ *
131
+ * @example
132
+ * <ImageViewer images={pages} visible={open} initialIndex={page} onClose={() => setOpen(false)} />
133
+ */
134
+ export function ImageViewer({ images, visible, onClose, initialIndex = 0 }: ImageViewerProps) {
135
+ const { width, height } = useWindowDimensions()
136
+ const insets = useSafeAreaInsets()
137
+ const [index, setIndex] = useState(initialIndex)
138
+ const [pagingEnabled, setPagingEnabled] = useState(true)
139
+ const scrollRef = React.useRef<ScrollView>(null)
140
+
141
+ // Reset to the requested page each time the viewer is opened. State updates are
142
+ // deferred to the next frame (also when contentOffset must land on the page),
143
+ // so this never sets state synchronously during the effect.
144
+ React.useEffect(() => {
145
+ if (!visible) return
146
+ const handle = requestAnimationFrame(() => {
147
+ setIndex(initialIndex)
148
+ setPagingEnabled(true)
149
+ scrollRef.current?.scrollTo({ x: initialIndex * width, animated: false })
150
+ })
151
+ return () => cancelAnimationFrame(handle)
152
+ }, [visible, initialIndex, width])
153
+
154
+ // Swipe-down-to-dismiss. Only active when no image is zoomed (pagingEnabled).
155
+ // Drags the whole gallery down + fades the black backdrop; releases past
156
+ // threshold → close, otherwise springs back.
157
+ const dragY = useSharedValue(0)
158
+ const DISMISS_THRESHOLD = height * 0.18
159
+
160
+ const closeViewer = useCallback(() => onClose(), [onClose])
161
+
162
+ const swipeDown = Gesture.Pan()
163
+ .enabled(pagingEnabled)
164
+ .activeOffsetY(12)
165
+ .failOffsetX([-16, 16])
166
+ .onUpdate((e) => {
167
+ dragY.value = Math.max(0, e.translationY)
168
+ })
169
+ .onEnd((e) => {
170
+ if (e.translationY > DISMISS_THRESHOLD || e.velocityY > 800) {
171
+ runOnJS(closeViewer)()
172
+ } else {
173
+ dragY.value = withTiming(0)
174
+ }
175
+ })
176
+
177
+ // Reset drag offset whenever the viewer opens.
178
+ React.useEffect(() => {
179
+ if (visible) dragY.value = 0
180
+ }, [visible, dragY])
181
+
182
+ const dismissStyle = useAnimatedStyle(() => ({
183
+ transform: [{ translateY: dragY.value }],
184
+ }))
185
+
186
+ const backdropStyle = useAnimatedStyle(() => ({
187
+ opacity: 1 - Math.min(dragY.value / (height * 0.5), 0.85),
188
+ }))
189
+
190
+ const onMomentumEnd = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
191
+ const page = Math.round(e.nativeEvent.contentOffset.x / width)
192
+ setIndex(page)
193
+ }
194
+
195
+ const goTo = (page: number) => {
196
+ scrollRef.current?.scrollTo({ x: page * width, animated: true })
197
+ setIndex(page)
198
+ }
199
+
200
+ return (
201
+ <Modal visible={visible} transparent={false} animationType="fade" onRequestClose={onClose} statusBarTranslucent>
202
+ <GestureHandlerRootView style={styles.root}>
203
+ <Animated.View style={[styles.backdrop, backdropStyle]} pointerEvents="none" />
204
+ <Animated.View style={[styles.container, dismissStyle]}>
205
+ <GestureDetector gesture={swipeDown}>
206
+ <Animated.View style={styles.root}>
207
+ <ScrollView
208
+ ref={scrollRef}
209
+ horizontal
210
+ pagingEnabled
211
+ scrollEnabled={pagingEnabled}
212
+ showsHorizontalScrollIndicator={false}
213
+ onMomentumScrollEnd={onMomentumEnd}
214
+ bounces={false}
215
+ >
216
+ {images.map((source, i) => (
217
+ <ZoomableImage
218
+ key={i}
219
+ source={source}
220
+ width={width}
221
+ height={height}
222
+ onZoomChange={(zoomed) => setPagingEnabled(!zoomed)}
223
+ />
224
+ ))}
225
+ </ScrollView>
226
+ </Animated.View>
227
+ </GestureDetector>
228
+
229
+ <TouchableOpacity
230
+ style={[styles.closeButton, { top: insets.top + vs(8) }]}
231
+ onPress={onClose}
232
+ activeOpacity={0.7}
233
+ touchSoundDisabled={true}
234
+ accessibilityRole="button"
235
+ accessibilityLabel="Close"
236
+ hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
237
+ >
238
+ {renderIcon('x', 26, '#fff')}
239
+ </TouchableOpacity>
240
+
241
+ {images.length > 1 ? (
242
+ <View style={[styles.dots, { bottom: insets.bottom + vs(16) }]} pointerEvents="box-none">
243
+ <PagerDots
244
+ count={images.length}
245
+ activeIndex={index}
246
+ onDotPress={goTo}
247
+ activeColor="#fff"
248
+ inactiveColor="rgba(255,255,255,0.4)"
249
+ />
250
+ </View>
251
+ ) : null}
252
+ </Animated.View>
253
+ </GestureHandlerRootView>
254
+ </Modal>
255
+ )
256
+ }
257
+
258
+ const styles = StyleSheet.create({
259
+ root: {
260
+ flex: 1,
261
+ },
262
+ container: {
263
+ flex: 1,
264
+ },
265
+ backdrop: {
266
+ ...StyleSheet.absoluteFillObject,
267
+ backgroundColor: '#000',
268
+ },
269
+ imageWrap: {
270
+ alignItems: 'center',
271
+ justifyContent: 'center',
272
+ overflow: 'hidden',
273
+ },
274
+ closeButton: {
275
+ position: 'absolute',
276
+ right: s(12),
277
+ width: s(40),
278
+ height: s(40),
279
+ borderRadius: s(20),
280
+ backgroundColor: 'rgba(0,0,0,0.4)',
281
+ alignItems: 'center',
282
+ justifyContent: 'center',
283
+ },
284
+ dots: {
285
+ position: 'absolute',
286
+ left: 0,
287
+ right: 0,
288
+ alignItems: 'center',
289
+ },
290
+ })
@@ -0,0 +1 @@
1
+ export * from './ImageViewer'
@@ -1,21 +1,18 @@
1
1
  import React from 'react'
2
2
  import {
3
- TouchableOpacity,
4
3
  View,
5
4
  Text,
6
5
  StyleSheet,
7
6
  ViewStyle,
8
7
  TextStyle,
9
8
  } from 'react-native'
10
- import Animated from 'react-native-reanimated'
11
9
  import { Entypo } from '@expo/vector-icons'
12
10
  import { selectionAsync as hapticSelection } from '../../utils/haptics'
13
11
  import { useTheme } from '../../theme'
14
12
  import { s, vs, ms, mvs } from '../../utils/scaling'
15
13
  import { renderIcon } from '../../utils/icons'
16
14
  import { RADIUS } from '../../tokens'
17
- import { usePressScale } from '../../utils/usePressScale'
18
- import { SPRINGS, PRESS_SCALE } from '../../utils/animations'
15
+ import { PressableRow } from '../../utils/pressable'
19
16
 
20
17
  export type ListItemVariant = 'plain' | 'card'
21
18
 
@@ -105,12 +102,6 @@ function ListItemBase({
105
102
  accessibilityLabel,
106
103
  }: ListItemProps) {
107
104
  const { colors } = useTheme()
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
- })
114
105
 
115
106
  const handlePress = () => {
116
107
  hapticSelection()
@@ -142,79 +133,91 @@ function ListItemBase({
142
133
 
143
134
  const a11yLabel = accessibilityLabel ?? [title, subtitle, caption].filter(Boolean).join('. ')
144
135
 
145
- return (
146
- <Animated.View style={[animatedStyle, disabled && styles.disabled]} {...hoverHandlers}>
147
- <TouchableOpacity
148
- style={[styles.container, cardStyle, style]}
149
- onPress={onPress ? handlePress : undefined}
150
- onPressIn={onPressIn}
151
- onPressOut={onPressOut}
152
- disabled={disabled}
153
- activeOpacity={1}
154
- touchSoundDisabled={true}
155
- accessibilityRole={onPress ? 'button' : undefined}
156
- accessibilityLabel={onPress ? a11yLabel : undefined}
157
- accessibilityState={onPress ? { disabled: !!disabled } : undefined}
158
- >
159
- {effectiveLeft ? (
160
- <View style={styles.leftContainer}>{effectiveLeft}</View>
161
- ) : null}
136
+ const content = (
137
+ <>
138
+ {effectiveLeft ? (
139
+ <View style={styles.leftContainer}>{effectiveLeft}</View>
140
+ ) : null}
162
141
 
163
- <View style={styles.content}>
142
+ <View style={styles.content}>
143
+ <Text
144
+ style={[styles.title, { color: colors.foreground }, titleStyle]}
145
+ numberOfLines={2}
146
+ allowFontScaling={true}
147
+ >
148
+ {title}
149
+ </Text>
150
+ {subtitle ? (
164
151
  <Text
165
- style={[styles.title, { color: colors.foreground }, titleStyle]}
152
+ style={[styles.subtitle, { color: colors.foregroundMuted }, subtitleStyle]}
166
153
  numberOfLines={2}
167
154
  allowFontScaling={true}
168
155
  >
169
- {title}
156
+ {subtitle}
170
157
  </Text>
171
- {subtitle ? (
172
- <Text
173
- style={[styles.subtitle, { color: colors.foregroundMuted }, subtitleStyle]}
174
- numberOfLines={2}
175
- allowFontScaling={true}
176
- >
177
- {subtitle}
178
- </Text>
179
- ) : null}
180
- {caption ? (
158
+ ) : null}
159
+ {caption ? (
160
+ <Text
161
+ style={[styles.caption, { color: colors.foregroundMuted }, captionStyle]}
162
+ numberOfLines={1}
163
+ allowFontScaling={true}
164
+ >
165
+ {caption}
166
+ </Text>
167
+ ) : null}
168
+ </View>
169
+
170
+ {effectiveRight !== undefined ? (
171
+ <View style={styles.rightContainer}>
172
+ {typeof effectiveRight === 'string' ? (
181
173
  <Text
182
- style={[styles.caption, { color: colors.foregroundMuted }, captionStyle]}
183
- numberOfLines={1}
174
+ style={[styles.rightText, { color: colors.foregroundMuted }]}
184
175
  allowFontScaling={true}
185
176
  >
186
- {caption}
177
+ {effectiveRight}
187
178
  </Text>
188
- ) : null}
179
+ ) : (
180
+ effectiveRight
181
+ )}
189
182
  </View>
183
+ ) : showChevron ? (
184
+ <Entypo name="chevron-with-circle-right" size={20} color={colors.foregroundMuted} />
185
+ ) : null}
186
+ </>
187
+ )
190
188
 
191
- {effectiveRight !== undefined ? (
192
- <View style={styles.rightContainer}>
193
- {typeof effectiveRight === 'string' ? (
194
- <Text
195
- style={[styles.rightText, { color: colors.foregroundMuted }]}
196
- allowFontScaling={true}
197
- >
198
- {effectiveRight}
199
- </Text>
200
- ) : (
201
- effectiveRight
202
- )}
203
- </View>
204
- ) : showChevron ? (
205
- <Entypo name="chevron-with-circle-right" size={20} color={colors.foregroundMuted} />
189
+ if (onPress) {
190
+ return (
191
+ <View style={disabled && styles.disabled}>
192
+ <PressableRow
193
+ style={[styles.container, cardStyle, style]}
194
+ onPress={handlePress}
195
+ enabled={!disabled}
196
+ rippleColor="transparent"
197
+ touchSoundDisabled
198
+ activateOnHover
199
+ accessibilityRole="button"
200
+ accessibilityLabel={a11yLabel}
201
+ accessibilityState={{ disabled: !!disabled }}
202
+ >
203
+ {content}
204
+ </PressableRow>
205
+ {showSeparator ? (
206
+ <View style={[styles.separator, { backgroundColor: colors.separator }]} />
206
207
  ) : null}
207
- </TouchableOpacity>
208
+ </View>
209
+ )
210
+ }
208
211
 
212
+ return (
213
+ <View style={[disabled && styles.disabled]}>
214
+ <View style={[styles.container, cardStyle, style]}>
215
+ {content}
216
+ </View>
209
217
  {showSeparator ? (
210
- <View
211
- style={[
212
- styles.separator,
213
- { backgroundColor: colors.separator },
214
- ]}
215
- />
218
+ <View style={[styles.separator, { backgroundColor: colors.separator }]} />
216
219
  ) : null}
217
- </Animated.View>
220
+ </View>
218
221
  )
219
222
  }
220
223
 
@@ -135,10 +135,16 @@ function MediaCardBase({
135
135
  {(onActionPress || actionIcon || actionIconName) && (
136
136
  <TouchableOpacity
137
137
  style={[styles.actionButton, { backgroundColor: 'rgba(0,0,0,0.24)' }]}
138
- onPress={() => { impactLight(); onActionPress?.() }}
138
+ onPress={(e) => {
139
+ // Stop propagation to prevent triggering parent onPress
140
+ e?.stopPropagation?.()
141
+ impactLight()
142
+ onActionPress?.()
143
+ }}
139
144
  activeOpacity={0.8}
140
145
  touchSoundDisabled={true}
141
- accessibilityRole="button"
146
+ // On web, avoid nested <button> by using a non-button role when parent is pressable
147
+ accessibilityRole={Platform.OS === 'web' && onPress ? undefined : 'button'}
142
148
  accessibilityLabel={actionIconName ?? 'action'}
143
149
  accessibilityState={{ selected: actionActive }}
144
150
  >
@@ -1,21 +1,18 @@
1
1
  import React from 'react'
2
2
  import {
3
- TouchableOpacity,
4
3
  View,
5
4
  Text,
6
5
  StyleSheet,
7
6
  ViewStyle,
8
7
  TextStyle,
9
8
  } from 'react-native'
10
- import Animated from 'react-native-reanimated'
11
9
  import { Entypo } from '@expo/vector-icons'
12
10
  import { selectionAsync as hapticSelection } from '../../utils/haptics'
13
11
  import { useTheme } from '../../theme'
14
12
  import { s, vs, ms } from '../../utils/scaling'
15
13
  import { renderIcon } from '../../utils/icons'
16
14
  import { RADIUS } from '../../tokens'
17
- import { usePressScale } from '../../utils/usePressScale'
18
- import { SPRINGS, PRESS_SCALE } from '../../utils/animations'
15
+ import { PressableRow } from '../../utils/pressable'
19
16
 
20
17
  export type MenuItemVariant = 'plain' | 'card'
21
18
 
@@ -77,12 +74,6 @@ function MenuItemBase({
77
74
  accessibilityLabel,
78
75
  }: MenuItemProps) {
79
76
  const { colors } = useTheme()
80
- const { animatedStyle, onPressIn, onPressOut, hoverHandlers } = usePressScale({
81
- pressScale: PRESS_SCALE.row,
82
- pressInSpring: SPRINGS.surfacePressIn,
83
- pressOutSpring: SPRINGS.surfacePressOut,
84
- disabled,
85
- })
86
77
 
87
78
  const handlePress = () => {
88
79
  hapticSelection()
@@ -111,15 +102,14 @@ function MenuItemBase({
111
102
  const a11yLabel = accessibilityLabel ?? (subtitle ? `${label}. ${subtitle}` : label)
112
103
 
113
104
  return (
114
- <Animated.View style={[animatedStyle, disabled && styles.disabled]} {...hoverHandlers}>
115
- <TouchableOpacity
105
+ <View style={disabled && styles.disabled}>
106
+ <PressableRow
116
107
  style={[styles.container, cardStyle, style]}
117
108
  onPress={handlePress}
118
- onPressIn={onPressIn}
119
- onPressOut={onPressOut}
120
- disabled={disabled}
121
- activeOpacity={1}
122
- touchSoundDisabled={true}
109
+ enabled={!disabled}
110
+ rippleColor="transparent"
111
+ touchSoundDisabled
112
+ activateOnHover
123
113
  accessibilityRole="button"
124
114
  accessibilityLabel={a11yLabel}
125
115
  accessibilityState={{ disabled }}
@@ -158,17 +148,12 @@ function MenuItemBase({
158
148
  ) : showChevron ? (
159
149
  <Entypo name="chevron-right" size={18} color={colors.foregroundMuted} />
160
150
  ) : null}
161
- </TouchableOpacity>
151
+ </PressableRow>
162
152
 
163
153
  {showSeparator ? (
164
- <View
165
- style={[
166
- styles.separator,
167
- { backgroundColor: colors.separator },
168
- ]}
169
- />
154
+ <View style={[styles.separator, { backgroundColor: colors.separator }]} />
170
155
  ) : null}
171
- </Animated.View>
156
+ </View>
172
157
  )
173
158
  }
174
159
 
@@ -18,9 +18,27 @@ export interface MonthPickerValue {
18
18
  year: number
19
19
  }
20
20
 
21
+ /** Convert a JS `Date` to a `MonthPickerValue` (uses local time). */
22
+ export function dateToMonthPickerValue(date: Date): MonthPickerValue {
23
+ return { month: date.getMonth() + 1, year: date.getFullYear() }
24
+ }
25
+
26
+ /** Convert a `MonthPickerValue` to a `Date` at the first day of that month (local time). */
27
+ export function monthPickerValueToDate(value: MonthPickerValue): Date {
28
+ return new Date(value.year, value.month - 1, 1)
29
+ }
30
+
31
+ // Absolute month index — lets us compare/clamp values with simple arithmetic.
32
+ const toIndex = (v: MonthPickerValue) => v.year * 12 + (v.month - 1)
33
+ const fromIndex = (i: number): MonthPickerValue => ({ year: Math.floor(i / 12), month: (i % 12) + 1 })
34
+
21
35
  export interface MonthPickerProps {
22
36
  value: MonthPickerValue
23
37
  onChange: (value: MonthPickerValue) => void
38
+ /** Earliest selectable month (inclusive). Prev arrow disables at this bound. */
39
+ minValue?: MonthPickerValue
40
+ /** Latest selectable month (inclusive). Next arrow disables at this bound — e.g. cap at the current month. */
41
+ maxValue?: MonthPickerValue
24
42
  /** BCP 47 locale tag. Built-in: 'en' | 'es' | 'pt' | 'fr'. For other locales supply formatLabel. */
25
43
  locale?: string
26
44
  /** Custom label formatter. Takes precedence over locale. */
@@ -28,9 +46,16 @@ export interface MonthPickerProps {
28
46
  style?: ViewStyle
29
47
  }
30
48
 
31
- export function MonthPicker({ value, onChange, locale = 'en', formatLabel, style }: MonthPickerProps) {
49
+ export function MonthPicker({ value, onChange, minValue, maxValue, locale = 'en', formatLabel, style }: MonthPickerProps) {
32
50
  const { colors } = useTheme()
33
51
 
52
+ const index = toIndex(value)
53
+ const minIndex = minValue ? toIndex(minValue) : -Infinity
54
+ const maxIndex = maxValue ? toIndex(maxValue) : Infinity
55
+
56
+ const prevDisabled = index - 1 < minIndex
57
+ const nextDisabled = index + 1 > maxIndex
58
+
34
59
  const getLabel = (): string => {
35
60
  if (formatLabel) return formatLabel(value)
36
61
  const names = MONTH_NAMES[locale] ?? MONTH_NAMES.en
@@ -38,32 +63,28 @@ export function MonthPicker({ value, onChange, locale = 'en', formatLabel, style
38
63
  }
39
64
 
40
65
  const handlePrev = () => {
66
+ if (prevDisabled) return
41
67
  hapticSelection()
42
- if (value.month === 1) {
43
- onChange({ month: 12, year: value.year - 1 })
44
- } else {
45
- onChange({ month: value.month - 1, year: value.year })
46
- }
68
+ onChange(fromIndex(index - 1))
47
69
  }
48
70
 
49
71
  const handleNext = () => {
72
+ if (nextDisabled) return
50
73
  hapticSelection()
51
- if (value.month === 12) {
52
- onChange({ month: 1, year: value.year + 1 })
53
- } else {
54
- onChange({ month: value.month + 1, year: value.year })
55
- }
74
+ onChange(fromIndex(index + 1))
56
75
  }
57
76
 
58
77
  return (
59
78
  <View style={[styles.container, style]} accessibilityRole="adjustable" accessibilityLabel={getLabel()}>
60
79
  <TouchableOpacity
61
- style={styles.arrow}
80
+ style={[styles.arrow, prevDisabled && styles.arrowDisabled]}
62
81
  onPress={handlePrev}
82
+ disabled={prevDisabled}
63
83
  activeOpacity={0.6}
64
84
  touchSoundDisabled={true}
65
85
  accessibilityRole="button"
66
86
  accessibilityLabel="Previous month"
87
+ accessibilityState={{ disabled: prevDisabled }}
67
88
  hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
68
89
  >
69
90
  <Entypo name="chevron-left" size={22} color={colors.foreground} />
@@ -76,12 +97,14 @@ export function MonthPicker({ value, onChange, locale = 'en', formatLabel, style
76
97
  {getLabel()}
77
98
  </Text>
78
99
  <TouchableOpacity
79
- style={styles.arrow}
100
+ style={[styles.arrow, nextDisabled && styles.arrowDisabled]}
80
101
  onPress={handleNext}
102
+ disabled={nextDisabled}
81
103
  activeOpacity={0.6}
82
104
  touchSoundDisabled={true}
83
105
  accessibilityRole="button"
84
106
  accessibilityLabel="Next month"
107
+ accessibilityState={{ disabled: nextDisabled }}
85
108
  hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
86
109
  >
87
110
  <Entypo name="chevron-right" size={22} color={colors.foreground} />
@@ -102,6 +125,9 @@ const styles = StyleSheet.create({
102
125
  alignItems: 'center',
103
126
  justifyContent: 'center',
104
127
  },
128
+ arrowDisabled: {
129
+ opacity: 0.3,
130
+ },
105
131
  label: {
106
132
  fontFamily: 'Sohne-Medium',
107
133
  fontSize: ms(17),
@@ -1,2 +1,2 @@
1
- export { MonthPicker } from './MonthPicker'
1
+ export { MonthPicker, dateToMonthPickerValue, monthPickerValueToDate } from './MonthPicker'
2
2
  export type { MonthPickerProps, MonthPickerValue } from './MonthPicker'