@retray-dev/ui-kit 7.0.1 → 9.0.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 +554 -11
  2. package/EXAMPLES.md +2 -2
  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,205 @@
1
+ import React, { useState } from 'react'
2
+ import { View, Text, TouchableOpacity, StyleSheet, ViewStyle, ScrollView } from 'react-native'
3
+ import Animated from 'react-native-reanimated'
4
+ import { useTheme } from '../../theme'
5
+ import { renderIcon } from '../../utils/icons'
6
+ import { usePressScale } from '../../utils/usePressScale'
7
+ import { selectionAsync as hapticSelection } from '../../utils/haptics'
8
+ import { PRESS_SCALE } from '../../utils/animations'
9
+ import { s, vs, ms, mvs } from '../../utils/scaling'
10
+ import { RADIUS } from '../../tokens'
11
+
12
+ export interface SelectableGridItem<T extends string | number = string> {
13
+ /** Unique value emitted on selection. */
14
+ value: T
15
+ /** Label rendered under the icon. */
16
+ label?: string
17
+ /** Icon name resolved via the icon registry. */
18
+ iconName?: string
19
+ /** Custom icon node — overrides `iconName`. */
20
+ icon?: React.ReactNode
21
+ disabled?: boolean
22
+ }
23
+
24
+ export interface SelectableGridProps<T extends string | number = string> {
25
+ items: SelectableGridItem<T>[]
26
+ /** Selected value(s). Array when `multiple`. */
27
+ value: T | T[] | null
28
+ onChange: (value: T) => void
29
+ /** Allow multiple selections. `value` should be an array. Defaults to false. */
30
+ multiple?: boolean
31
+ /** Columns per row. Defaults to 4. Ignored when `orientation='horizontal'`. */
32
+ numColumns?: number
33
+ /** Gap between cells (dp). Defaults to 12. */
34
+ gap?: number
35
+ /** Layout orientation. 'grid' (default) wraps into rows. 'horizontal' creates a single scrollable row. */
36
+ orientation?: 'grid' | 'horizontal'
37
+ style?: ViewStyle
38
+ }
39
+
40
+ function isSelected<T extends string | number>(value: T | T[] | null, candidate: T): boolean {
41
+ if (value == null) return false
42
+ return Array.isArray(value) ? value.includes(candidate) : value === candidate
43
+ }
44
+
45
+ interface CellProps<T extends string | number> {
46
+ item: SelectableGridItem<T>
47
+ selected: boolean
48
+ width: number
49
+ onPress: () => void
50
+ }
51
+
52
+ function Cell<T extends string | number>({ item, selected, width, onPress }: CellProps<T>) {
53
+ const { colors } = useTheme()
54
+ const { animatedStyle, onPressIn, onPressOut, hoverHandlers } = usePressScale({
55
+ pressScale: PRESS_SCALE.chip,
56
+ disabled: item.disabled,
57
+ })
58
+
59
+ const iconColor = selected ? colors.primary : colors.foregroundSubtle
60
+ const iconNode = item.icon ?? (item.iconName ? renderIcon(item.iconName, ms(24), iconColor) : null)
61
+
62
+ return (
63
+ <Animated.View style={[{ width }, animatedStyle]}>
64
+ <TouchableOpacity
65
+ onPress={onPress}
66
+ onPressIn={onPressIn}
67
+ onPressOut={onPressOut}
68
+ disabled={item.disabled}
69
+ activeOpacity={1}
70
+ touchSoundDisabled={true}
71
+ accessibilityRole="button"
72
+ accessibilityState={{ selected, disabled: item.disabled }}
73
+ accessibilityLabel={item.label ?? String(item.value)}
74
+ {...hoverHandlers}
75
+ style={[
76
+ styles.cell,
77
+ {
78
+ backgroundColor: selected ? colors.primary + '14' : colors.surface,
79
+ borderColor: selected ? colors.primary : 'transparent',
80
+ },
81
+ item.disabled && styles.cellDisabled,
82
+ ]}
83
+ >
84
+ {iconNode}
85
+ {item.label ? (
86
+ <Text
87
+ style={[styles.label, { color: selected ? colors.primary : colors.foreground }]}
88
+ numberOfLines={1}
89
+ allowFontScaling={true}
90
+ >
91
+ {item.label}
92
+ </Text>
93
+ ) : null}
94
+ </TouchableOpacity>
95
+ </Animated.View>
96
+ )
97
+ }
98
+
99
+ /**
100
+ * Grid of selectable cells (icon + label) — for store / category / emoji pickers
101
+ * where a list would be the wrong shape. Single or multi select.
102
+ *
103
+ * @example
104
+ * <SelectableGrid
105
+ * items={categories}
106
+ * value={selected}
107
+ * onChange={setSelected}
108
+ * numColumns={4}
109
+ * />
110
+ */
111
+ export function SelectableGrid<T extends string | number = string>({
112
+ items,
113
+ value,
114
+ onChange,
115
+ multiple = false,
116
+ numColumns = 4,
117
+ gap = 12,
118
+ orientation = 'grid',
119
+ style,
120
+ }: SelectableGridProps<T>) {
121
+ const [containerWidth, setContainerWidth] = useState(0)
122
+ const gapPx = s(gap)
123
+ // Compute exact cell width so `numColumns` always fits — percentage widths + gap
124
+ // overflow and wrap one short. -0.5 guards against sub-pixel rounding overflow.
125
+ const cellWidth = containerWidth > 0 ? (containerWidth - gapPx * (numColumns - 1)) / numColumns - 0.5 : 0
126
+ // Horizontal mode: fixed 72dp cell width (same scale as grid cells)
127
+ const horizCellWidth = s(72)
128
+
129
+ const handlePress = (item: SelectableGridItem<T>) => {
130
+ if (item.disabled) return
131
+ hapticSelection()
132
+ onChange(item.value)
133
+ }
134
+
135
+ if (orientation === 'horizontal') {
136
+ return (
137
+ <ScrollView
138
+ horizontal
139
+ showsHorizontalScrollIndicator={false}
140
+ contentContainerStyle={[styles.horizontal, { gap: gapPx }, style]}
141
+ accessibilityRole={multiple ? undefined : 'radiogroup'}
142
+ >
143
+ {items.map((item) => (
144
+ <Cell
145
+ key={String(item.value)}
146
+ item={item}
147
+ selected={isSelected(value, item.value)}
148
+ width={horizCellWidth}
149
+ onPress={() => handlePress(item)}
150
+ />
151
+ ))}
152
+ </ScrollView>
153
+ )
154
+ }
155
+
156
+ return (
157
+ <View
158
+ style={[styles.grid, { gap: gapPx }, style]}
159
+ onLayout={(e) => setContainerWidth(e.nativeEvent.layout.width)}
160
+ accessibilityRole={multiple ? undefined : 'radiogroup'}
161
+ >
162
+ {cellWidth > 0
163
+ ? items.map((item) => (
164
+ <Cell
165
+ key={String(item.value)}
166
+ item={item}
167
+ selected={isSelected(value, item.value)}
168
+ width={cellWidth}
169
+ onPress={() => handlePress(item)}
170
+ />
171
+ ))
172
+ : null}
173
+ </View>
174
+ )
175
+ }
176
+
177
+ const styles = StyleSheet.create({
178
+ grid: {
179
+ flexDirection: 'row',
180
+ flexWrap: 'wrap',
181
+ },
182
+ horizontal: {
183
+ flexDirection: 'row',
184
+ paddingHorizontal: s(4),
185
+ },
186
+ cell: {
187
+ flex: 1,
188
+ borderRadius: RADIUS.md,
189
+ borderWidth: 2,
190
+ alignItems: 'center',
191
+ justifyContent: 'center',
192
+ gap: vs(4),
193
+ paddingHorizontal: s(12),
194
+ paddingVertical: vs(12),
195
+ },
196
+ cellDisabled: {
197
+ opacity: 0.4,
198
+ },
199
+ label: {
200
+ fontFamily: 'Sohne-Medium',
201
+ fontSize: ms(12),
202
+ lineHeight: mvs(15),
203
+ textAlign: 'center',
204
+ },
205
+ })
@@ -0,0 +1 @@
1
+ export * from './SelectableGrid'
@@ -1,5 +1,5 @@
1
1
  import React, { useCallback, useEffect, useRef } from 'react'
2
- import { View, Text, TouchableOpacity, StyleSheet, ViewStyle, Dimensions, Platform } from 'react-native'
2
+ import { View, Text, TouchableOpacity, StyleSheet, ViewStyle, Dimensions, Platform, Modal, ScrollView, useWindowDimensions, Pressable } from 'react-native'
3
3
  import {
4
4
  BottomSheetModal,
5
5
  BottomSheetView,
@@ -16,6 +16,7 @@ import { AntDesign } from '@expo/vector-icons'
16
16
  import { impactMedium } from '../../utils/haptics'
17
17
  import { useTheme } from '../../theme'
18
18
  import { s, vs, ms, mvs } from '../../utils/scaling'
19
+ import { BREAKPOINTS, RADIUS, SHADOWS } from '../../tokens'
19
20
 
20
21
  const SCREEN_HEIGHT = Dimensions.get('window').height
21
22
  const DEFAULT_MAX_HEIGHT = SCREEN_HEIGHT * 0.85
@@ -93,6 +94,18 @@ export interface SheetProps {
93
94
  * When omitted, sheet uses dynamic sizing (auto-fits content).
94
95
  */
95
96
  snapPoints?: (string | number)[]
97
+ /**
98
+ * When true, render as a centered modal dialog on wide screens (width ≥
99
+ * `BREAKPOINTS.wide`) instead of a bottom sheet. On narrow screens it stays a
100
+ * bottom sheet. Use for store/category/picker dialogs that should feel native
101
+ * on tablets and web.
102
+ *
103
+ * Note: the centered-dialog path uses a plain RN `Modal`, so `SheetTextInput`
104
+ * is not required there — use a regular `TextInput`.
105
+ */
106
+ responsive?: boolean
107
+ /** Max width of the centered dialog (dp). Only applies when `responsive`. Defaults to 480. */
108
+ dialogMaxWidth?: number
96
109
  }
97
110
 
98
111
  export function SheetHeader({ children, style }: SheetHeaderProps) {
@@ -130,10 +143,14 @@ export function Sheet({
130
143
  android_keyboardInputMode = 'adjustPan',
131
144
  footer,
132
145
  snapPoints,
146
+ responsive = false,
147
+ dialogMaxWidth = 480,
133
148
  }: SheetProps) {
134
149
  const { colors } = useTheme()
135
150
  const insets = useSafeAreaInsets()
151
+ const { width: windowWidth } = useWindowDimensions()
136
152
  const ref = useRef<BottomSheetModal>(null)
153
+ const asDialog = responsive && windowWidth >= BREAKPOINTS.wide
137
154
 
138
155
  // 'interactive' + 'adjustPan' works properly with enableDynamicSizing on both platforms
139
156
  // 'fillParent' + 'adjustResize' causes restore issues (transparent gap when keyboard dismisses)
@@ -217,6 +234,35 @@ export function Sheet({
217
234
  )
218
235
  }, [effectiveFooter])
219
236
 
237
+ // Centered dialog path for wide screens — plain RN Modal, same header/content/footer.
238
+ if (asDialog) {
239
+ return (
240
+ <Modal visible={open} transparent animationType="fade" onRequestClose={onClose}>
241
+ <Pressable style={styles.dialogBackdrop} onPress={onClose} accessibilityRole="button" accessibilityLabel="Close">
242
+ {/* Inner Pressable swallows presses so taps inside the card don't close it. */}
243
+ <Pressable
244
+ style={[
245
+ styles.dialogCard,
246
+ { backgroundColor: colors.card, maxWidth: dialogMaxWidth, maxHeight: SCREEN_HEIGHT * 0.85 },
247
+ ]}
248
+ onPress={() => {}}
249
+ >
250
+ {headerNode}
251
+ <ScrollView
252
+ contentContainerStyle={[styles.dialogContent, style]}
253
+ style={contentStyle}
254
+ showsVerticalScrollIndicator={true}
255
+ bounces={false}
256
+ >
257
+ {contentNode}
258
+ </ScrollView>
259
+ {effectiveFooter}
260
+ </Pressable>
261
+ </Pressable>
262
+ </Modal>
263
+ )
264
+ }
265
+
220
266
  const useScroll = scrollable || !!maxHeight
221
267
  const effectiveMaxHeight = maxHeight ?? DEFAULT_MAX_HEIGHT
222
268
 
@@ -323,5 +369,23 @@ const styles = StyleSheet.create({
323
369
  flexDirection: 'row',
324
370
  gap: s(12),
325
371
  },
372
+ dialogBackdrop: {
373
+ flex: 1,
374
+ backgroundColor: 'rgba(0,0,0,0.5)',
375
+ alignItems: 'center',
376
+ justifyContent: 'center',
377
+ padding: s(24),
378
+ },
379
+ dialogCard: {
380
+ width: '100%',
381
+ borderRadius: RADIUS.lg,
382
+ paddingTop: vs(16),
383
+ overflow: 'hidden',
384
+ ...SHADOWS.xl,
385
+ },
386
+ dialogContent: {
387
+ paddingHorizontal: s(16),
388
+ paddingBottom: vs(16),
389
+ },
326
390
  })
327
391
 
@@ -9,8 +9,9 @@ import Animated, {
9
9
  } from 'react-native-reanimated'
10
10
  import { LinearGradient } from 'expo-linear-gradient'
11
11
  import { useTheme } from '../../theme'
12
- import { s } from '../../utils/scaling'
12
+ import { s, vs } from '../../utils/scaling'
13
13
  import { TIMINGS } from '../../utils/animations'
14
+ import { RADIUS } from '../../tokens'
14
15
 
15
16
  // circle: circular avatar placeholder text: short line preset base: custom dimensions
16
17
  export type SkeletonPreset = 'base' | 'circle' | 'text'
@@ -94,8 +95,148 @@ export function Skeleton({
94
95
  )
95
96
  }
96
97
 
98
+ // ─── Per-component skeletons ───────────────────────────────────────────────────
99
+ // Loading placeholders that mirror a component's footprint, so grids/lists don't
100
+ // reflow when real data arrives.
101
+
102
+ const aspectRatioMap = {
103
+ '1:1': 1,
104
+ '4:3': 3 / 4,
105
+ '16:9': 9 / 16,
106
+ '4:5': 5 / 4,
107
+ '3:2': 2 / 3,
108
+ } as const
109
+
110
+ export type MediaCardSkeletonAspectRatio = keyof typeof aspectRatioMap
111
+
112
+ export interface MediaCardSkeletonProps {
113
+ /** Image aspect ratio — match your `MediaCard`. Defaults to `'4:3'`. */
114
+ aspectRatio?: MediaCardSkeletonAspectRatio
115
+ /** Show the subtitle/caption line below the title. Defaults to true. */
116
+ showSubtitle?: boolean
117
+ style?: ViewStyle
118
+ }
119
+
120
+ /** Loading placeholder matching `<MediaCard>` — image block + title/subtitle lines. */
121
+ export function MediaCardSkeleton({ aspectRatio = '4:3', showSubtitle = true, style }: MediaCardSkeletonProps) {
122
+ const ratio = aspectRatioMap[aspectRatio]
123
+ return (
124
+ <View style={style}>
125
+ <View style={{ paddingTop: `${ratio * 100}%` as `${number}%` }}>
126
+ <View style={StyleSheet.absoluteFill}>
127
+ <Skeleton width="100%" height={undefined as unknown as number} style={skeletonStyles.fill} borderRadius={RADIUS.md} />
128
+ </View>
129
+ </View>
130
+ <View style={skeletonStyles.meta}>
131
+ <Skeleton width="70%" height={vs(14)} borderRadius={RADIUS.xs} />
132
+ {showSubtitle ? <Skeleton width="45%" height={vs(12)} borderRadius={RADIUS.xs} /> : null}
133
+ </View>
134
+ </View>
135
+ )
136
+ }
137
+
138
+ export interface ListItemSkeletonProps {
139
+ /** Render a circular leading avatar placeholder. Defaults to true. */
140
+ showAvatar?: boolean
141
+ /** Render a secondary subtitle line. Defaults to true. */
142
+ showSubtitle?: boolean
143
+ style?: ViewStyle
144
+ }
145
+
146
+ /** Loading placeholder matching `<ListItem>` — leading circle + title/subtitle lines. */
147
+ export function ListItemSkeleton({ showAvatar = true, showSubtitle = true, style }: ListItemSkeletonProps) {
148
+ return (
149
+ <View style={[skeletonStyles.row, style]}>
150
+ {showAvatar ? <Skeleton preset="circle" diameter={40} /> : null}
151
+ <View style={skeletonStyles.rowText}>
152
+ <Skeleton width="60%" height={vs(14)} borderRadius={RADIUS.xs} />
153
+ {showSubtitle ? <Skeleton width="40%" height={vs(12)} borderRadius={RADIUS.xs} /> : null}
154
+ </View>
155
+ </View>
156
+ )
157
+ }
158
+
159
+ export interface ListSkeletonProps {
160
+ /** Number of placeholder rows/cells. Defaults to 6. */
161
+ count?: number
162
+ /** 1 = stacked list of `ListItemSkeleton`; >1 = grid of `MediaCardSkeleton`. Defaults to 1. */
163
+ columns?: number
164
+ /** Gap between items (dp). Defaults to 12. */
165
+ gap?: number
166
+ /** Grid only — aspect ratio of each `MediaCardSkeleton`. Defaults to `'4:3'`. */
167
+ aspectRatio?: MediaCardSkeletonAspectRatio
168
+ /** List only — show the leading avatar circle. Defaults to true. */
169
+ showAvatar?: boolean
170
+ style?: ViewStyle
171
+ }
172
+
173
+ /**
174
+ * Repeated loading placeholder for a `VirtualList` / list / grid. `columns={1}`
175
+ * renders stacked `ListItemSkeleton`s; `columns>1` renders a wrapping grid of
176
+ * `MediaCardSkeleton`s. Render this as the list's content while `data` is empty.
177
+ */
178
+ export function ListSkeleton({
179
+ count = 6,
180
+ columns = 1,
181
+ gap = 12,
182
+ aspectRatio = '4:3',
183
+ showAvatar = true,
184
+ style,
185
+ }: ListSkeletonProps) {
186
+ if (columns <= 1) {
187
+ return (
188
+ <View style={[{ gap: vs(gap) }, style]}>
189
+ {Array.from({ length: count }).map((_, i) => (
190
+ <ListItemSkeleton key={i} showAvatar={showAvatar} />
191
+ ))}
192
+ </View>
193
+ )
194
+ }
195
+ const widthPct = `${100 / columns}%` as `${number}%`
196
+ // Gutter via per-cell padding + marginBottom (not container `gap`) so percentage
197
+ // widths sum to exactly 100% and never wrap one short.
198
+ return (
199
+ <View style={[skeletonStyles.grid, { marginHorizontal: -s(gap) / 2 }, style]}>
200
+ {Array.from({ length: count }).map((_, i) => (
201
+ <View key={i} style={{ width: widthPct, paddingHorizontal: s(gap) / 2, marginBottom: vs(gap) }}>
202
+ <MediaCardSkeleton aspectRatio={aspectRatio} />
203
+ </View>
204
+ ))}
205
+ </View>
206
+ )
207
+ }
208
+
209
+ Skeleton.MediaCard = MediaCardSkeleton
210
+ Skeleton.ListItem = ListItemSkeleton
211
+ Skeleton.List = ListSkeleton
212
+
97
213
  const styles = StyleSheet.create({
98
214
  base: {
99
215
  overflow: 'hidden',
100
216
  },
101
217
  })
218
+
219
+ const skeletonStyles = StyleSheet.create({
220
+ grid: {
221
+ flexDirection: 'row',
222
+ flexWrap: 'wrap',
223
+ },
224
+ fill: {
225
+ width: '100%',
226
+ height: '100%',
227
+ },
228
+ meta: {
229
+ paddingTop: vs(8),
230
+ gap: vs(6),
231
+ },
232
+ row: {
233
+ flexDirection: 'row',
234
+ alignItems: 'center',
235
+ gap: s(12),
236
+ paddingVertical: vs(8),
237
+ },
238
+ rowText: {
239
+ flex: 1,
240
+ gap: vs(6),
241
+ },
242
+ })
@@ -25,10 +25,16 @@ const labelFontSize: Record<SpinnerSize, number> = {
25
25
 
26
26
  export function Spinner({ size = 'md', color, label, ...props }: SpinnerProps) {
27
27
  const { colors } = useTheme()
28
+ const a11yLabel = label || 'Loading'
28
29
 
29
30
  if (label) {
30
31
  return (
31
- <View style={styles.wrapper}>
32
+ <View
33
+ style={styles.wrapper}
34
+ accessibilityRole="progressbar"
35
+ accessibilityLabel={a11yLabel}
36
+ accessibilityState={{ busy: true }}
37
+ >
32
38
  <ActivityIndicator size={sizeMap[size]} color={color ?? colors.primary} {...props} />
33
39
  <Text
34
40
  style={[styles.label, { color: colors.foregroundMuted, fontSize: labelFontSize[size] }]}
@@ -40,7 +46,16 @@ export function Spinner({ size = 'md', color, label, ...props }: SpinnerProps) {
40
46
  )
41
47
  }
42
48
 
43
- return <ActivityIndicator size={sizeMap[size]} color={color ?? colors.primary} {...props} />
49
+ return (
50
+ <ActivityIndicator
51
+ size={sizeMap[size]}
52
+ color={color ?? colors.primary}
53
+ accessibilityRole="progressbar"
54
+ accessibilityLabel={a11yLabel}
55
+ accessibilityState={{ busy: true }}
56
+ {...props}
57
+ />
58
+ )
44
59
  }
45
60
 
46
61
  const styles = StyleSheet.create({
@@ -1,17 +1,11 @@
1
- import React, { useEffect } from 'react'
1
+ import React from 'react'
2
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
+ import { EaseView } from 'react-native-ease'
10
4
  import { Feather } from '@expo/vector-icons'
11
5
  import { selectionAsync as hapticSelection } from '../../utils/haptics'
12
6
  import { useTheme } from '../../theme'
13
7
  import { s } from '../../utils/scaling'
14
- import { SPRINGS, TIMINGS, EASINGS } from '../../utils/animations'
8
+ import { COLOR_TRANSITION, OPACITY_TRANSITION, SPRING_ELASTIC } from '../../utils/animations'
15
9
 
16
10
  const TRACK_WIDTH = s(52)
17
11
  const TRACK_HEIGHT = s(30)
@@ -31,45 +25,6 @@ export interface SwitchProps {
31
25
  export function Switch({ checked = false, onCheckedChange, disabled, style, accessibilityLabel }: SwitchProps) {
32
26
  const { colors } = useTheme()
33
27
 
34
- const progress = useSharedValue(checked ? 1 : 0)
35
-
36
- useEffect(() => {
37
- progress.value = withSpring(checked ? 1 : 0, SPRINGS.elastic)
38
- }, [checked, progress])
39
-
40
- const thumbStyle = useAnimatedStyle(() => ({
41
- transform: [{ translateX: progress.value * THUMB_TRAVEL }],
42
- }))
43
-
44
- const trackStyle = useAnimatedStyle(() => ({
45
- backgroundColor: interpolateColor(
46
- progress.value,
47
- [0, 1],
48
- [colors.surfaceStrong, colors.primary],
49
- ),
50
- }))
51
-
52
- // AUDIT FIX: the off-state track used surfaceStrong (~#ebebeb in light mode)
53
- // with no border — nearly invisible on white page/card surfaces. A 1.5px border
54
- // that fades out as the track fills gives the off state clear visual definition
55
- // without adding visual weight to the on state.
56
- const trackBorderStyle = useAnimatedStyle(() => ({
57
- borderWidth: 1.5,
58
- borderColor: interpolateColor(
59
- progress.value,
60
- [0, 1],
61
- [colors.border, 'transparent'],
62
- ),
63
- }))
64
-
65
- const checkIconStyle = useAnimatedStyle(() => ({
66
- opacity: withTiming(checked ? 1 : 0, { duration: TIMINGS.state.duration, easing: EASINGS.standard }),
67
- }))
68
-
69
- const crossIconStyle = useAnimatedStyle(() => ({
70
- opacity: withTiming(checked ? 0 : 1, { duration: TIMINGS.state.duration, easing: EASINGS.standard }),
71
- }))
72
-
73
28
  return (
74
29
  <View style={[{ opacity: disabled ? 0.45 : 1, alignSelf: 'flex-start' }, style]}>
75
30
  <TouchableOpacity
@@ -85,19 +40,36 @@ export function Switch({ checked = false, onCheckedChange, disabled, style, acce
85
40
  accessibilityState={{ checked, disabled: !!disabled }}
86
41
  style={styles.touchable}
87
42
  >
88
- <Animated.View style={[styles.track, trackStyle]}>
89
- <Animated.View style={[styles.trackBorder, trackBorderStyle]} pointerEvents="none" />
90
- <Animated.View
91
- style={[styles.thumb, { backgroundColor: colors.primaryForeground }, thumbStyle]}
43
+ <EaseView
44
+ style={styles.track}
45
+ animate={{ backgroundColor: checked ? colors.primary : colors.surfaceStrong }}
46
+ transition={COLOR_TRANSITION}
47
+ >
48
+ {/*
49
+ AUDIT FIX: the off-state track used surfaceStrong (~#ebebeb in light mode)
50
+ with no border — nearly invisible on white page/card surfaces. A 1.5px border
51
+ that fades out as the track fills gives the off state clear visual definition
52
+ without adding visual weight to the on state.
53
+ */}
54
+ <EaseView
55
+ style={[styles.trackBorder, { borderWidth: 1.5 }]}
56
+ pointerEvents="none"
57
+ animate={{ borderColor: checked ? 'transparent' : colors.border }}
58
+ transition={COLOR_TRANSITION}
59
+ />
60
+ <EaseView
61
+ style={[styles.thumb, { backgroundColor: colors.primaryForeground }]}
62
+ animate={{ translateX: checked ? THUMB_TRAVEL : 0 }}
63
+ transition={SPRING_ELASTIC}
92
64
  >
93
- <Animated.View style={[styles.iconWrapper, checkIconStyle]}>
65
+ <EaseView style={styles.iconWrapper} animate={{ opacity: checked ? 1 : 0 }} transition={OPACITY_TRANSITION}>
94
66
  <Feather name="check" size={ICON_SIZE} color={colors.primary} />
95
- </Animated.View>
96
- <Animated.View style={[styles.iconWrapper, crossIconStyle]}>
67
+ </EaseView>
68
+ <EaseView style={styles.iconWrapper} animate={{ opacity: checked ? 0 : 1 }} transition={OPACITY_TRANSITION}>
97
69
  <Feather name="x" size={ICON_SIZE} color={colors.foregroundMuted} />
98
- </Animated.View>
99
- </Animated.View>
100
- </Animated.View>
70
+ </EaseView>
71
+ </EaseView>
72
+ </EaseView>
101
73
  </TouchableOpacity>
102
74
  </View>
103
75
  )