@expokit/cli 0.4.4 → 0.5.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 (239) hide show
  1. package/README.md +30 -3
  2. package/bin/expo-kit.js +749 -288
  3. package/bin/lib.mjs +310 -3
  4. package/package.json +1 -1
  5. package/registry/_docs/action-sheet.json +1 -1
  6. package/registry/_docs/alert.json +6 -0
  7. package/registry/_docs/bottom-sheet.json +6 -0
  8. package/registry/_docs/bullet.json +6 -0
  9. package/registry/_docs/dialog.json +1 -1
  10. package/registry/_docs/drawer.json +3 -3
  11. package/registry/_docs/glass-search-bar.json +1 -1
  12. package/registry/_docs/grain.json +6 -1
  13. package/registry/_docs/isometric-bar.json +6 -0
  14. package/registry/_docs/liquidglass-tab.json +1 -1
  15. package/registry/_docs/password-input.json +1 -1
  16. package/registry/_docs/pixel-bar.json +6 -0
  17. package/registry/_docs/polar-area.json +6 -0
  18. package/registry/_docs/qr-code-button.json +9 -3
  19. package/registry/_docs/scratch-card.json +7 -1
  20. package/registry/_docs/search-bar.json +1 -1
  21. package/registry/_docs/select.json +2 -2
  22. package/registry/_docs/slide-to-confirm.json +2 -2
  23. package/registry/_docs/spinner.json +1 -1
  24. package/registry/_docs/stateful-button.json +2 -2
  25. package/registry/_docs/swipe-deck.json +1 -1
  26. package/registry/_docs/time-picker.json +2 -2
  27. package/registry/_docs/timeline-chart.json +6 -0
  28. package/registry/_docs/toast.json +15 -3
  29. package/registry/_docs/treemap.json +6 -0
  30. package/registry/_docs/waterfall.json +6 -0
  31. package/registry/accordion/files/src/components/ui/accordion.tsx +6 -4
  32. package/registry/accordion/files/src/lib/kit.tsx +363 -11
  33. package/registry/accordion.json +1 -0
  34. package/registry/action-sheet/files/src/components/ui/action-sheet.tsx +43 -48
  35. package/registry/action-sheet/files/src/lib/kit.tsx +363 -11
  36. package/registry/action-sheet.json +1 -0
  37. package/registry/alert/files/src/components/ui/alert.tsx +19 -12
  38. package/registry/alert/files/src/lib/kit.tsx +363 -11
  39. package/registry/alert.json +3 -4
  40. package/registry/assistant-app.json +1 -0
  41. package/registry/avatar/files/src/components/ui/avatar.tsx +84 -66
  42. package/registry/avatar/files/src/lib/kit.tsx +363 -11
  43. package/registry/avatar.json +1 -0
  44. package/registry/badge/files/src/components/ui/badge.tsx +34 -34
  45. package/registry/badge/files/src/lib/kit.tsx +363 -11
  46. package/registry/badge.json +1 -0
  47. package/registry/bottom-nav/files/src/components/ui/bottom-nav.tsx +28 -27
  48. package/registry/bottom-nav/files/src/lib/kit.tsx +363 -11
  49. package/registry/bottom-nav.json +5 -0
  50. package/registry/bottom-sheet/files/src/components/ui/bottom-sheet.tsx +353 -289
  51. package/registry/bottom-sheet/files/src/lib/kit.tsx +363 -11
  52. package/registry/bottom-sheet.json +5 -1
  53. package/registry/bullet.json +1 -0
  54. package/registry/button/files/src/components/ui/button.tsx +18 -16
  55. package/registry/button/files/src/lib/kit.tsx +363 -11
  56. package/registry/button-3d/files/src/components/ui/button-3d.tsx +2 -2
  57. package/registry/button-3d/files/src/lib/kit.tsx +363 -11
  58. package/registry/button-3d.json +1 -0
  59. package/registry/button.json +1 -0
  60. package/registry/calendar/files/src/components/ui/calendar.tsx +74 -76
  61. package/registry/calendar/files/src/lib/kit.tsx +363 -11
  62. package/registry/calendar.json +1 -0
  63. package/registry/card/files/src/components/ui/card.tsx +3 -3
  64. package/registry/card/files/src/lib/kit.tsx +363 -11
  65. package/registry/card-stack.json +1 -0
  66. package/registry/card.json +1 -0
  67. package/registry/carousel.json +1 -0
  68. package/registry/chat.json +7 -0
  69. package/registry/checkbox/files/src/components/ui/checkbox.tsx +4 -4
  70. package/registry/checkbox/files/src/lib/kit.tsx +363 -11
  71. package/registry/checkbox.json +1 -0
  72. package/registry/checkout.json +1 -0
  73. package/registry/chip-group/files/src/components/ui/chip-group.tsx +22 -22
  74. package/registry/chip-group/files/src/lib/kit.tsx +363 -11
  75. package/registry/chip-group.json +1 -0
  76. package/registry/confetti/files/src/components/ui/confetti.tsx +15 -14
  77. package/registry/confetti.json +3 -6
  78. package/registry/coupon/files/src/components/ui/coupon.tsx +42 -42
  79. package/registry/coupon/files/src/components/ui/flip-card.tsx +6 -5
  80. package/registry/coupon/files/src/components/ui/tilt-card.tsx +76 -91
  81. package/registry/coupon/files/src/lib/kit.tsx +363 -11
  82. package/registry/coupon/files/src/lib/ticket-shape.tsx +54 -44
  83. package/registry/coupon.json +1 -0
  84. package/registry/cube-pager.json +1 -0
  85. package/registry/dashboard.json +1 -0
  86. package/registry/date-picker/files/src/components/ui/calendar.tsx +74 -76
  87. package/registry/date-picker/files/src/components/ui/date-picker.tsx +55 -56
  88. package/registry/date-picker/files/src/lib/kit.tsx +363 -11
  89. package/registry/date-picker.json +1 -0
  90. package/registry/dialog/files/src/components/ui/dialog.tsx +60 -56
  91. package/registry/dialog/files/src/lib/kit.tsx +363 -11
  92. package/registry/dialog.json +1 -0
  93. package/registry/donut-shop.json +1 -0
  94. package/registry/drawer/files/src/components/ui/drawer.tsx +73 -69
  95. package/registry/drawer/files/src/lib/kit.tsx +363 -11
  96. package/registry/drawer.json +1 -0
  97. package/registry/fab.json +1 -0
  98. package/registry/fintech-app.json +1 -0
  99. package/registry/flip-calendar/files/src/components/ui/flip-calendar.tsx +92 -86
  100. package/registry/flip-calendar/files/src/lib/kit.tsx +363 -11
  101. package/registry/flip-calendar.json +7 -0
  102. package/registry/flip-card/files/src/components/ui/flip-card.tsx +6 -5
  103. package/registry/flip-card/files/src/lib/kit.tsx +363 -11
  104. package/registry/flip-card.json +1 -0
  105. package/registry/floating-input/files/src/components/ui/floating-input.tsx +33 -33
  106. package/registry/floating-input/files/src/lib/kit.tsx +363 -11
  107. package/registry/floating-input.json +1 -0
  108. package/registry/food-app.json +1 -0
  109. package/registry/gift-card.json +1 -0
  110. package/registry/glass-button.json +5 -0
  111. package/registry/glass-nav-bar.json +1 -0
  112. package/registry/glass-search-bar.json +1 -0
  113. package/registry/glass-segmented.json +1 -0
  114. package/registry/glass-sheet.json +1 -0
  115. package/registry/glass-tab-bar.json +5 -0
  116. package/registry/glass.json +1 -0
  117. package/registry/grain/files/src/components/ui/grain.tsx +16 -14
  118. package/registry/grain.json +8 -6
  119. package/registry/inbox.json +1 -0
  120. package/registry/isometric-bar.json +1 -0
  121. package/registry/kit/files/src/lib/kit.tsx +363 -11
  122. package/registry/kit.json +1 -0
  123. package/registry/liquidglass-tab/files/src/app/_layout.tsx +0 -1
  124. package/registry/liquidglass-tab/files/src/components/GlassTabBar.tsx +23 -9
  125. package/registry/liquidglass-tab/files/src/components/TabScreen.tsx +2 -2
  126. package/registry/liquidglass-tab/files/src/lib/tab-icons.ts +1 -1
  127. package/registry/liquidglass-tab.json +1 -0
  128. package/registry/list-item/files/src/components/ui/list-item.tsx +42 -42
  129. package/registry/list-item/files/src/lib/kit.tsx +363 -11
  130. package/registry/list-item.json +1 -0
  131. package/registry/loyalty-card.json +1 -0
  132. package/registry/morphing-button-group.json +1 -0
  133. package/registry/number-ticker.json +1 -0
  134. package/registry/offers.json +1 -0
  135. package/registry/onboarding.json +1 -0
  136. package/registry/orb-reveal.json +1 -0
  137. package/registry/otp-input/files/src/components/ui/otp-input.tsx +48 -29
  138. package/registry/otp-input/files/src/lib/kit.tsx +363 -11
  139. package/registry/otp-input.json +1 -0
  140. package/registry/parallax-layers.json +1 -0
  141. package/registry/parallax-scroll.json +1 -0
  142. package/registry/password-input/files/src/components/ui/floating-input.tsx +33 -33
  143. package/registry/password-input/files/src/components/ui/password-input.tsx +29 -25
  144. package/registry/password-input/files/src/lib/kit.tsx +363 -11
  145. package/registry/password-input.json +1 -0
  146. package/registry/payment-card/files/src/components/ui/flip-card.tsx +6 -5
  147. package/registry/payment-card/files/src/components/ui/payment-card.tsx +17 -17
  148. package/registry/payment-card/files/src/components/ui/tilt-card.tsx +76 -91
  149. package/registry/payment-card/files/src/lib/kit.tsx +363 -11
  150. package/registry/payment-card.json +1 -0
  151. package/registry/paywall.json +1 -0
  152. package/registry/pixel-bar.json +1 -0
  153. package/registry/polar-area.json +1 -0
  154. package/registry/profile.json +1 -0
  155. package/registry/progress/files/src/components/ui/progress.tsx +29 -29
  156. package/registry/progress/files/src/lib/kit.tsx +363 -11
  157. package/registry/progress-steps/files/src/components/ui/progress-steps.tsx +20 -20
  158. package/registry/progress-steps/files/src/lib/kit.tsx +363 -11
  159. package/registry/progress-steps.json +1 -0
  160. package/registry/progress.json +1 -0
  161. package/registry/qr-code-button.json +1 -0
  162. package/registry/radio-group/files/src/components/ui/radio-group.tsx +19 -19
  163. package/registry/radio-group/files/src/lib/kit.tsx +363 -11
  164. package/registry/radio-group.json +1 -0
  165. package/registry/rating.json +1 -0
  166. package/registry/ring-carousel.json +1 -0
  167. package/registry/ruler-time-picker/files/src/components/ui/ruler-time-picker.tsx +98 -82
  168. package/registry/ruler-time-picker/files/src/lib/kit.tsx +363 -11
  169. package/registry/ruler-time-picker.json +7 -0
  170. package/registry/scratch-card.json +1 -0
  171. package/registry/search-bar/files/src/components/ui/search-bar.tsx +25 -21
  172. package/registry/search-bar/files/src/lib/kit.tsx +363 -11
  173. package/registry/search-bar.json +1 -0
  174. package/registry/search-results.json +4 -0
  175. package/registry/segmented-control/files/src/components/ui/segmented-control.tsx +8 -7
  176. package/registry/segmented-control/files/src/lib/kit.tsx +363 -11
  177. package/registry/segmented-control.json +1 -0
  178. package/registry/select/files/src/components/ui/select.tsx +93 -88
  179. package/registry/select/files/src/lib/kit.tsx +363 -11
  180. package/registry/select.json +6 -0
  181. package/registry/settings.json +1 -0
  182. package/registry/sign-in.json +1 -0
  183. package/registry/skeleton/files/src/components/ui/skeleton.tsx +149 -80
  184. package/registry/skeleton/files/src/lib/kit.tsx +363 -11
  185. package/registry/skeleton.json +1 -0
  186. package/registry/slide-to-confirm.json +1 -0
  187. package/registry/slider/files/src/components/ui/slider.tsx +103 -96
  188. package/registry/slider/files/src/lib/kit.tsx +363 -11
  189. package/registry/slider.json +5 -0
  190. package/registry/spin-wheel.json +1 -0
  191. package/registry/spinner/files/src/components/ui/spinner.tsx +39 -29
  192. package/registry/spinner/files/src/lib/kit.tsx +363 -11
  193. package/registry/spinner.json +6 -0
  194. package/registry/stateful-button/files/src/components/ui/stateful-button.tsx +39 -32
  195. package/registry/stateful-button/files/src/lib/kit.tsx +363 -11
  196. package/registry/stateful-button.json +5 -0
  197. package/registry/stepper/files/src/components/ui/stepper.tsx +38 -36
  198. package/registry/stepper/files/src/lib/kit.tsx +363 -11
  199. package/registry/stepper.json +1 -0
  200. package/registry/swipe-deck.json +1 -0
  201. package/registry/swipeable-row.json +4 -0
  202. package/registry/switch/files/src/components/ui/switch.tsx +39 -42
  203. package/registry/switch/files/src/lib/kit.tsx +363 -11
  204. package/registry/switch.json +1 -0
  205. package/registry/tabs/files/src/components/ui/tabs.tsx +59 -62
  206. package/registry/tabs/files/src/lib/kit.tsx +363 -11
  207. package/registry/tabs.json +1 -0
  208. package/registry/text-animation/files/src/components/ui/text-animation.tsx +55 -42
  209. package/registry/text-animation/files/src/lib/kit.tsx +363 -11
  210. package/registry/text-animation.json +7 -0
  211. package/registry/textarea/files/src/components/ui/textarea.tsx +10 -10
  212. package/registry/textarea/files/src/lib/kit.tsx +363 -11
  213. package/registry/textarea.json +1 -0
  214. package/registry/ticket-card.json +1 -0
  215. package/registry/ticket-shape/files/src/lib/kit.tsx +363 -11
  216. package/registry/ticket-shape/files/src/lib/ticket-shape.tsx +54 -44
  217. package/registry/ticket-shape.json +1 -0
  218. package/registry/tilt-card/files/src/components/ui/tilt-card.tsx +76 -91
  219. package/registry/tilt-card/files/src/lib/kit.tsx +363 -11
  220. package/registry/tilt-card.json +1 -0
  221. package/registry/time-picker/files/src/components/ui/time-picker.tsx +122 -119
  222. package/registry/time-picker/files/src/lib/kit.tsx +363 -11
  223. package/registry/time-picker.json +1 -0
  224. package/registry/timeline/files/src/components/ui/avatar.tsx +84 -66
  225. package/registry/timeline/files/src/components/ui/timeline.tsx +44 -41
  226. package/registry/timeline/files/src/lib/kit.tsx +363 -11
  227. package/registry/timeline-chart.json +1 -0
  228. package/registry/timeline.json +1 -0
  229. package/registry/toast/files/src/components/ui/toast.tsx +541 -463
  230. package/registry/toast/files/src/lib/kit.tsx +363 -11
  231. package/registry/toast.json +7 -5
  232. package/registry/treemap.json +1 -0
  233. package/registry/waterfall.json +1 -0
  234. package/registry/wheel-list.json +1 -0
  235. package/schema/components.schema.json +1 -0
  236. package/registry/alert/files/src/components/ui/button.tsx +0 -290
  237. package/registry/confetti/files/src/lib/kit.tsx +0 -320
  238. package/registry/grain/files/src/lib/kit.tsx +0 -320
  239. package/registry/toast/files/src/components/ui/button.tsx +0 -290
@@ -2,23 +2,34 @@ import * as Haptics from 'expo-haptics';
2
2
  import React, { useEffect, useMemo, useRef, useState, type ComponentRef } from 'react';
3
3
  import {
4
4
  AccessibilityInfo,
5
- Animated,
6
- Easing,
7
5
  Keyboard,
8
6
  Modal,
9
- PanResponder,
10
7
  Platform,
11
8
  Pressable,
12
- ScrollView,
13
9
  StyleSheet,
14
10
  Text,
15
11
  View,
16
12
  useWindowDimensions,
17
13
  type KeyboardEvent as RNKeyboardEvent,
18
- type PanResponderGestureState,
19
14
  type StyleProp,
20
15
  type ViewStyle,
21
16
  } from 'react-native';
17
+ import { GestureDetector, GestureHandlerRootView, useNativeGesture, usePanGesture } from 'react-native-gesture-handler';
18
+ import Animated, {
19
+ Easing,
20
+ Extrapolation,
21
+ ReduceMotion,
22
+ cancelAnimation,
23
+ interpolate,
24
+ scrollTo,
25
+ useAnimatedRef,
26
+ useAnimatedScrollHandler,
27
+ useAnimatedStyle,
28
+ useSharedValue,
29
+ withSpring,
30
+ withTiming,
31
+ } from 'react-native-reanimated';
32
+ import { scheduleOnRN } from 'react-native-worklets';
22
33
 
23
34
  import { alpha, motion, useKit, useLayout } from '@/lib/kit';
24
35
 
@@ -32,50 +43,79 @@ export interface BottomSheetProps {
32
43
  * Heights the sheet rests at: points or percentages of the screen (sorted for you). Omit to size
33
44
  * the sheet to its content (capped below the status bar; taller content scrolls).
34
45
  */
35
- snapPoints?: SnapPoint[];
46
+ snapPoints?: SnapPoint[] | undefined;
47
+
36
48
  /** Which snap point it opens at (default 0, the lowest). */
37
- initialSnap?: number;
49
+ initialSnap?: number | undefined;
50
+
38
51
  /** Controlled snap index: change it while open to move the sheet. */
39
- snapIndex?: number;
40
- onSnapChange?: (index: number) => void;
52
+ snapIndex?: number | undefined;
53
+
54
+ onSnapChange?: ((index: number) => void) | undefined;
55
+
41
56
  /** Allow closing by dragging down / tapping the backdrop (default true). */
42
- dismissible?: boolean;
57
+ dismissible?: boolean | undefined;
58
+
43
59
  /** Heading under the grabber. */
44
- title?: string;
60
+ title?: string | undefined;
61
+
45
62
  /** Muted line under the title. */
46
- subtitle?: string;
63
+ subtitle?: string | undefined;
64
+
47
65
  /** Node at the trailing end of the title row (e.g. a Reset or Done link). */
48
- headerRight?: React.ReactNode;
66
+ headerRight?: React.ReactNode | undefined;
67
+
49
68
  /** Round ✕ button in the header (default: shown with a title when dismissible and there's no headerRight). */
50
- showClose?: boolean;
69
+ showClose?: boolean | undefined;
70
+
71
+ /** Screen-reader name of the close button (default: the kit's `labels.close`). */
72
+ closeLabel?: string | undefined;
73
+
51
74
  /** Show the grabber pill (default true). */
52
- handle?: boolean;
75
+ handle?: boolean | undefined;
76
+
53
77
  /**
54
78
  * Wrap the body in a ScrollView. Below the top snap point a vertical drag on the body resizes
55
79
  * the sheet; at the top snap point the body scrolls, and pulling down from its top collapses it.
56
80
  */
57
- scrollable?: boolean;
81
+ scrollable?: boolean | undefined;
82
+
58
83
  /** Let vertical drags on the body move the sheet (default: true when `scrollable`). Keep false if children contain their own ScrollView. */
59
- dragFromContent?: boolean;
84
+ dragFromContent?: boolean | undefined;
85
+
60
86
  /** Pinned to the bottom of the visible sheet at every snap point (e.g. an Apply button). */
61
- footer?: React.ReactNode;
87
+ footer?: React.ReactNode | undefined;
88
+
62
89
  /** Lift the sheet above the on-screen keyboard (default true). */
63
- avoidKeyboard?: boolean;
90
+ avoidKeyboard?: boolean | undefined;
91
+
64
92
  /** Light impact on open and a selection tick on each snap (default true). */
65
- haptics?: boolean;
93
+ haptics?: boolean | undefined;
94
+
66
95
  /** Max backdrop dim at the highest snap point, 0–1 (default 1 = theme overlay). */
67
- backdropOpacity?: number;
96
+ backdropOpacity?: number | undefined;
97
+
68
98
  /** Extra bottom padding for the home indicator (default: platform safe inset). */
69
- bottomInset?: number;
70
- accessibilityLabel?: string;
71
- children?: React.ReactNode;
72
- style?: StyleProp<ViewStyle>;
73
- contentStyle?: StyleProp<ViewStyle>;
99
+ bottomInset?: number | undefined;
100
+
101
+ accessibilityLabel?: string | undefined;
102
+
103
+ children?: React.ReactNode | undefined;
104
+
105
+ style?: StyleProp<ViewStyle> | undefined;
106
+
107
+ contentStyle?: StyleProp<ViewStyle> | undefined;
108
+
74
109
  }
75
110
 
111
+
76
112
  const DEFAULT_BOTTOM_INSET = Platform.select({ ios: 20, android: 12, default: 16 }) ?? 16;
77
113
  /** Kit gentle, firmed up — glides between detents without bouncing past them. */
78
114
  const SNAP_SPRING = { ...motion.gentle, damping: 29, stiffness: 220, mass: 0.9 };
115
+ /** The sheet handles reduced motion itself, so Reanimated must not also skip its animations. */
116
+ const KEEP = ReduceMotion.Never;
117
+ const SNAPPY = { ...motion.snappy, reduceMotion: KEEP };
118
+ const BOUNCY = { ...motion.bouncy, reduceMotion: KEEP };
79
119
  /** Extra sheet below the screen edge, so pulling up past the top snap never shows a gap. */
80
120
  const OVERDRAG = 120;
81
121
  /** Never let the sheet's top go closer than this to the top of the screen. */
@@ -87,6 +127,7 @@ function toPx(p: SnapPoint, screen: number) {
87
127
 
88
128
  /** Diminishing resistance past the limits (approaches `dimension`). */
89
129
  function rubber(distance: number, dimension: number) {
130
+ 'worklet';
90
131
  return (distance * dimension * 0.55) / (dimension + 0.55 * distance);
91
132
  }
92
133
 
@@ -95,7 +136,8 @@ function rubber(distance: number, dimension: number) {
95
136
  * settles on the snap point projected from your release velocity (a fling skips ahead or
96
137
  * dismisses) with a haptic tick, and the scrim dims in step with its height. Content can scroll
97
138
  * once the sheet is fully expanded, a footer stays pinned at every detent, and the keyboard lifts
98
- * it. PanResponder only.
139
+ * it. Drags and snaps run on the UI thread (Reanimated + Gesture Handler), so a busy JS thread
140
+ * never stalls them.
99
141
  */
100
142
  export function BottomSheet({
101
143
  open,
@@ -109,6 +151,7 @@ export function BottomSheet({
109
151
  subtitle,
110
152
  headerRight,
111
153
  showClose,
154
+ closeLabel,
112
155
  handle = true,
113
156
  scrollable = false,
114
157
  dragFromContent,
@@ -122,7 +165,7 @@ export function BottomSheet({
122
165
  style,
123
166
  contentStyle,
124
167
  }: BottomSheetProps) {
125
- const { colors, radius, dark, font } = useKit();
168
+ const { colors, radius, dark, font, labels } = useKit();
126
169
  const ff = font.family ? { fontFamily: font.family } : null;
127
170
  const reduceMotion = useReduceMotion();
128
171
  const { height: screen } = useWindowDimensions();
@@ -150,89 +193,97 @@ export function BottomSheet({
150
193
  // eslint-disable-next-line react-hooks/exhaustive-deps
151
194
  [snapKey, screen],
152
195
  );
153
- const max = heights[heights.length - 1];
196
+ const max = heights[heights.length - 1] ?? 0;
154
197
  // translateY for each snap (0 = top snap) and for closed.
155
198
  const stops = heights.map((h) => max - h);
156
199
  const closed = max + 24;
157
200
  const last = stops.length - 1;
158
201
 
159
- // Native driver: sheet position, keyboard lift, grabber stretch — separate values.
160
- const y = useRef(new Animated.Value(closed)).current;
161
- const lift = useRef(new Animated.Value(0)).current;
162
- const grip = useRef(new Animated.Value(0)).current;
202
+ // UI-thread values: sheet position, keyboard lift, grabber stretch, the body's scroll offset.
203
+ const y = useSharedValue(closed);
204
+ const lift = useSharedValue(0);
205
+ const grip = useSharedValue(0);
206
+ const index = useSharedValue(initialSnap);
207
+ const busy = useSharedValue(false); // a snap animation is running
208
+ const scrollY = useSharedValue(0);
209
+ // Per-drag: where the sheet was when the finger took it, which of sheet / list it moves, and the list offset to hold.
210
+ const origin = useSharedValue(0);
211
+ const scrolling = useSharedValue(false);
212
+ const scrollHold = useSharedValue(0);
213
+ const scrollRef = useAnimatedRef<Animated.ScrollView>();
163
214
 
164
215
  const [mounted, setMounted] = useState(open);
165
216
  const [expanded, setExpanded] = useState(false); // at the top snap → body scrolls
166
217
  const [kbPad, setKbPad] = useState(0);
167
218
  const shown = useRef(false); // auto: slid in once measured
168
- const index = useRef(initialSnap);
169
- const scrollY = useRef(0);
170
219
  const rootRef = useRef<ComponentRef<typeof View>>(null);
171
220
 
172
221
  const bodyDrags = dragFromContent ?? scrollable;
173
- const live = useRef({ stops, closed, max, dismissible, onClose, onSnapChange, haptics, reduceMotion, scrollable, bodyDrags, expanded });
174
- live.current = { stops, closed, max, dismissible, onClose, onSnapChange, haptics, reduceMotion, scrollable, bodyDrags, expanded };
175
-
176
- const pos = useRef(closed); // last known resting / dragged position (JS mirror of `y`)
177
- const moving = useRef(false);
178
-
179
- const settle = (to: number, velocity = 0, after?: () => void) => {
180
- pos.current = to;
181
- moving.current = true;
182
- const anim = live.current.reduceMotion
183
- ? Animated.timing(y, { toValue: to, duration: 200, easing: Easing.out(Easing.cubic), useNativeDriver: true })
184
- : Animated.spring(y, {
185
- toValue: to,
186
- velocity,
187
- useNativeDriver: true,
188
- ...SNAP_SPRING,
189
- overshootClamping: to === live.current.closed,
190
- });
191
- anim.start(({ finished }) => {
192
- moving.current = false;
193
- if (finished) after?.();
194
- });
222
+
223
+ const announce = (k: number) => {
224
+ onSnapChange?.(k);
225
+ if (haptics) Haptics.selectionAsync().catch(() => {});
226
+ };
227
+
228
+ const settle = (to: number, velocity = 0, close = false) => {
229
+ 'worklet';
230
+ busy.set(true);
231
+ const done = (finished?: boolean) => {
232
+ 'worklet';
233
+ if (!finished) return;
234
+ busy.set(false);
235
+ if (close) scheduleOnRN(onClose);
236
+ };
237
+ y.set(
238
+ reduceMotion
239
+ ? withTiming(to, { duration: 200, easing: Easing.out(Easing.cubic), reduceMotion: KEEP }, done)
240
+ : withSpring(to, { ...SNAP_SPRING, velocity, overshootClamping: to === closed, reduceMotion: KEEP }, done),
241
+ );
195
242
  };
196
243
 
197
244
  const snapTo = (i: number, velocity = 0) => {
198
- const s = live.current.stops;
199
- const k = Math.max(0, Math.min(s.length - 1, i));
200
- if (index.current !== k) {
201
- live.current.onSnapChange?.(k);
202
- if (live.current.haptics) Haptics.selectionAsync().catch(() => {});
203
- }
204
- index.current = k;
205
- setExpanded(k === s.length - 1);
206
- settle(s[k], velocity);
245
+ 'worklet';
246
+ const k = Math.max(0, Math.min(last, i));
247
+ if (index.get() !== k) scheduleOnRN(announce, k);
248
+ index.set(k);
249
+ scheduleOnRN(setExpanded, k === last);
250
+ settle(stops[k] ?? closed, velocity);
207
251
  };
208
252
 
209
- const dismiss = (velocity = 0) => settle(live.current.closed, velocity, () => live.current.onClose());
253
+ const dismiss = (velocity = 0) => {
254
+ 'worklet';
255
+ settle(closed, velocity, true);
256
+ };
210
257
 
211
258
  const enter = () => {
212
259
  shown.current = true;
213
- y.setValue(closed);
214
- lift.setValue(0);
215
- pos.current = closed;
216
- scrollY.current = 0;
217
- index.current = Math.max(0, Math.min(last, snapIndex ?? initialSnap));
218
- setExpanded(index.current === last);
260
+ const k = Math.max(0, Math.min(last, snapIndex ?? initialSnap));
261
+ y.set(closed);
262
+ lift.set(0);
263
+ scrollY.set(0);
264
+ index.set(k);
265
+ scheduleOnRN(setExpanded, k === last);
219
266
  if (haptics) Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {});
220
- settle(stops[index.current]);
267
+ settle(stops[k] ?? closed);
221
268
  };
222
269
 
270
+ // Mount as soon as `open` turns on (render-time update, so no extra effect pass).
271
+ if (open && !mounted) setMounted(true);
272
+
223
273
  useEffect(() => {
224
274
  if (open) {
225
- setMounted(true);
226
275
  shown.current = false;
227
276
  // Off-screen until there is a height to open to.
228
- y.setValue(screen);
277
+ y.set(screen);
229
278
  if (measured) enter();
230
279
  } else if (mounted) {
231
280
  // Exit is quicker than the entrance: a short ease-in, no spring.
232
281
  Keyboard.dismiss();
233
- Animated.timing(lift, { toValue: 0, duration: 160, useNativeDriver: true }).start();
234
- Animated.timing(y, { toValue: closed, duration: 200, easing: Easing.in(Easing.quad), useNativeDriver: true }).start(() =>
235
- setMounted(false),
282
+ lift.set(withTiming(0, { duration: 160, reduceMotion: KEEP }));
283
+ y.set(
284
+ withTiming(closed, { duration: 200, easing: Easing.in(Easing.quad), reduceMotion: KEEP }, (finished) => {
285
+ if (finished) scheduleOnRN(setMounted, false);
286
+ }),
236
287
  );
237
288
  }
238
289
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -242,16 +293,13 @@ export function BottomSheet({
242
293
  useEffect(() => {
243
294
  if (!open || !auto || !measured) return;
244
295
  if (!shown.current) enter();
245
- else if (!moving.current && index.current === 0) {
246
- pos.current = 0;
247
- y.setValue(0);
248
- }
296
+ else if (!busy.get() && index.get() === 0) y.set(0);
249
297
  // eslint-disable-next-line react-hooks/exhaustive-deps
250
298
  }, [open, auto, measured, autoH]);
251
299
 
252
300
  // Controlled snap index.
253
301
  useEffect(() => {
254
- if (open && mounted && snapIndex !== undefined && snapIndex !== index.current) snapTo(snapIndex);
302
+ if (open && mounted && snapIndex !== undefined && snapIndex !== index.get()) snapTo(snapIndex);
255
303
  // eslint-disable-next-line react-hooks/exhaustive-deps
256
304
  }, [snapIndex]);
257
305
 
@@ -261,11 +309,11 @@ export function BottomSheet({
261
309
  if (!mounted || !avoidKeyboard) return;
262
310
  const ios = Platform.OS === 'ios';
263
311
  const apply = (overlap: number, duration: number) => {
264
- const visible = live.current.max - pos.current;
312
+ const visible = max - y.get();
265
313
  const room = Math.max(0, screen - TOP_GAP - visible);
266
314
  const up = Math.min(overlap, room);
267
315
  setKbPad(overlap - up);
268
- Animated.timing(lift, { toValue: -up, duration, easing: Easing.out(Easing.cubic), useNativeDriver: true }).start();
316
+ lift.set(withTiming(-up, { duration, easing: Easing.out(Easing.cubic), reduceMotion: KEEP }));
269
317
  };
270
318
  const show = Keyboard.addListener(ios ? 'keyboardWillShow' : 'keyboardDidShow', (e: RNKeyboardEvent) => {
271
319
  const duration = e.duration || 220;
@@ -275,97 +323,122 @@ export function BottomSheet({
275
323
  });
276
324
  const hide = Keyboard.addListener(ios ? 'keyboardWillHide' : 'keyboardDidHide', (e: RNKeyboardEvent) => {
277
325
  setKbPad(0);
278
- Animated.timing(lift, { toValue: 0, duration: e?.duration || 200, easing: Easing.out(Easing.cubic), useNativeDriver: true }).start();
326
+ lift.set(withTiming(0, { duration: e?.duration || 200, easing: Easing.out(Easing.cubic), reduceMotion: KEEP }));
279
327
  });
280
328
  return () => {
281
329
  show.remove();
282
330
  hide.remove();
283
331
  };
284
- }, [mounted, avoidKeyboard, screen, lift]);
332
+ }, [mounted, avoidKeyboard, screen, max, y, lift]);
285
333
 
286
334
  // Escape closes on web (the Modal handles Android back).
287
335
  useEffect(() => {
288
336
  if (!mounted || Platform.OS !== 'web' || typeof document === 'undefined') return;
289
337
  const onKey = (e: KeyboardEvent) => {
290
- if (e.key === 'Escape' && live.current.dismissible) dismiss();
338
+ if (e.key === 'Escape' && dismissible) dismiss();
291
339
  };
292
340
  document.addEventListener('keydown', onKey);
293
341
  return () => document.removeEventListener('keydown', onKey);
294
342
  // eslint-disable-next-line react-hooks/exhaustive-deps
295
- }, [mounted]);
296
-
297
- // One drag model for the grabber/header (claims any touch) and the body (captures vertical
298
- // drags only, so taps, horizontal scrollers and — at the top snap — scrolling still work).
299
- const pans = useRef(
300
- (() => {
301
- const origin = { start: 0 };
302
- const base = {
303
- onPanResponderTerminationRequest: () => false,
304
- onPanResponderGrant: () => {
305
- origin.start = pos.current;
306
- Animated.spring(grip, { toValue: 1, useNativeDriver: true, ...motion.snappy }).start();
307
- if (!moving.current) return;
308
- moving.current = false;
309
- y.stopAnimation((v) => {
310
- if (typeof v === 'number') origin.start = v;
311
- });
312
- },
313
- onPanResponderMove: (_: unknown, g: PanResponderGestureState) => {
314
- const { closed: bottom, stops: s, dismissible: canClose } = live.current;
315
- let next = origin.start + g.dy;
316
- if (next < 0) next = -rubber(-next, OVERDRAG);
317
- if (!canClose && next > s[0]) next = s[0] + rubber(next - s[0], OVERDRAG);
318
- pos.current = Math.min(next, bottom + 40);
319
- y.setValue(pos.current);
320
- },
321
- onPanResponderRelease: (_: unknown, g: PanResponderGestureState) => {
322
- Animated.spring(grip, { toValue: 0, useNativeDriver: true, ...motion.bouncy }).start();
323
- const { stops: s, closed: bottom, dismissible: canClose } = live.current;
324
- const current = origin.start + g.dy;
325
- const projected = current + g.vy * 220;
326
- const targets = canClose ? [...s, bottom] : s;
327
- let best = 0;
328
- targets.forEach((t, i) => {
329
- if (Math.abs(t - projected) < Math.abs(targets[best] - projected)) best = i;
330
- });
331
- if (canClose && (best === s.length || (g.vy > 1.6 && current > s[0]))) dismiss(g.vy);
332
- else snapTo(Math.min(best, s.length - 1), g.vy);
333
- },
334
- onPanResponderTerminate: () => {
335
- Animated.spring(grip, { toValue: 0, useNativeDriver: true, ...motion.bouncy }).start();
336
- snapTo(index.current);
337
- },
338
- };
339
- const grab = PanResponder.create({
340
- ...base,
341
- onStartShouldSetPanResponder: () => true,
342
- onMoveShouldSetPanResponder: () => true,
343
- });
344
- const body = PanResponder.create({
345
- ...base,
346
- onMoveShouldSetPanResponderCapture: (_, g) => {
347
- const l = live.current;
348
- if (!l.bodyDrags) return false;
349
- if (Math.abs(g.dy) < 8 || Math.abs(g.dy) < Math.abs(g.dx) * 1.3) return false;
350
- if (!l.scrollable || !l.expanded) return true;
351
- // Fully expanded: the list scrolls; pulling down from its very top collapses the sheet.
352
- return g.dy > 0 && scrollY.current <= 0;
353
- },
354
- });
355
- return { grab, body };
356
- })(),
357
- ).current;
343
+ }, [mounted, dismissible]);
358
344
 
359
- if (!mounted) return null;
345
+ // One drag model, run on the UI thread, for the grabber/header and the body.
346
+ // `dy` is how far the finger already travelled before the drag activated: the sheet picks up from here, no jump.
347
+ const take = (dy: number) => {
348
+ 'worklet';
349
+ cancelAnimation(y);
350
+ busy.set(false);
351
+ origin.set(y.get() - dy);
352
+ };
353
+ const follow = (dy: number) => {
354
+ 'worklet';
355
+ let next = origin.get() + dy;
356
+ if (next < 0) next = -rubber(-next, OVERDRAG);
357
+ const top = stops[0] ?? 0;
358
+ if (!dismissible && next > top) next = top + rubber(next - top, OVERDRAG);
359
+ y.set(Math.min(next, closed + 40));
360
+ };
361
+ const release = (dy: number, vy: number) => {
362
+ 'worklet';
363
+ const current = origin.get() + dy;
364
+ // Project where the fling would carry the sheet (vy is points per second).
365
+ const projected = current + vy * 0.22;
366
+ const targets = dismissible ? [...stops, closed] : stops;
367
+ let best = 0;
368
+ targets.forEach((t, i) => {
369
+ if (Math.abs(t - projected) < Math.abs((targets[best] ?? t) - projected)) best = i;
370
+ });
371
+ if (dismissible && (best === stops.length || (vy > 1600 && current > (stops[0] ?? 0)))) dismiss(vy);
372
+ else snapTo(Math.min(best, last), vy);
373
+ };
374
+
375
+ // Grabber and header: any vertical drag moves the sheet; taps still reach the close button.
376
+ const grab = usePanGesture({
377
+ activeOffsetY: [-4, 4],
378
+ onBegin: () => {
379
+ grip.set(withSpring(1, SNAPPY));
380
+ },
381
+ onActivate: (e) => take(e.translationY),
382
+ onUpdate: (e) => follow(e.translationY),
383
+ onDeactivate: (e) => {
384
+ if (e.canceled) snapTo(index.get());
385
+ else release(e.translationY, e.velocityY);
386
+ },
387
+ onFinalize: () => {
388
+ grip.set(withSpring(0, BOUNCY));
389
+ },
390
+ });
391
+
392
+ // Body: vertical drags only, so taps and horizontal scrollers still work. At the top snap of a
393
+ // scrollable sheet the list scrolls instead, and a pull down from its very top moves the sheet.
394
+ const scrolls = scrollable || auto; // auto height scrolls when its content is taller than the screen allows
395
+ const handoff = scrollable && expanded;
396
+ const scrollGesture = useNativeGesture({ enabled: scrolls });
397
+ const body = usePanGesture({
398
+ enabled: bodyDrags,
399
+ activeOffsetY: [-8, 8],
400
+ failOffsetX: [-8, 8],
401
+ simultaneousWith: scrollGesture,
402
+ onActivate: (e) => {
403
+ scrollHold.set(scrollY.get());
404
+ scrolling.set(handoff && (e.translationY < 0 || scrollY.get() > 0));
405
+ if (!scrolling.get()) take(e.translationY);
406
+ },
407
+ onUpdate: (e) => {
408
+ if (scrolling.get()) {
409
+ // Scrolled back to the top and still pulling down: hand the drag to the sheet.
410
+ if (scrollY.get() > 0 || e.changeY <= 0) return;
411
+ scrolling.set(false);
412
+ scrollHold.set(0);
413
+ origin.set(y.get() - e.translationY);
414
+ }
415
+ if (scrolls) scrollTo(scrollRef, 0, scrollHold.get(), false);
416
+ follow(e.translationY);
417
+ },
418
+ onDeactivate: (e) => {
419
+ if (scrolling.get()) return;
420
+ if (e.canceled) snapTo(index.get());
421
+ else release(e.translationY, e.velocityY);
422
+ },
423
+ });
360
424
 
361
- const lowest = Math.max(1, stops[0]);
362
- const dim = y.interpolate({
363
- inputRange: [0, lowest, closed],
364
- outputRange: [backdropOpacity, backdropOpacity * (heights.length > 1 ? 0.7 : 1), 0],
365
- extrapolate: 'clamp',
425
+ const onScroll = useAnimatedScrollHandler((e) => {
426
+ scrollY.set(e.contentOffset.y);
366
427
  });
428
+
429
+ const lowest = Math.max(1, stops[0] ?? 0);
430
+ const restDim = backdropOpacity * (heights.length > 1 ? 0.7 : 1);
431
+ const dimStyle = useAnimatedStyle(() => ({
432
+ opacity: interpolate(y.get(), [0, lowest, closed], [backdropOpacity, restDim, 0], Extrapolation.CLAMP),
433
+ }));
434
+ const sheetStyle = useAnimatedStyle(() => ({ transform: [{ translateY: y.get() + lift.get() }] }));
435
+ const gripStyle = useAnimatedStyle(() => ({ transform: [{ scaleX: 1 + 0.3 * grip.get() }] }));
367
436
  // Keeps the footer on the screen's bottom edge at every detent; it follows the sheet down when closing.
368
- const footerY = y.interpolate({ inputRange: [0, lowest], outputRange: [0, -lowest], extrapolate: 'clamp' });
437
+ const footerStyle = useAnimatedStyle(() => ({
438
+ transform: [{ translateY: interpolate(y.get(), [0, lowest], [0, -lowest], Extrapolation.CLAMP) }],
439
+ }));
440
+
441
+ if (!mounted) return null;
369
442
 
370
443
  const surface = dark ? colors.popover : colors.card;
371
444
  const hairline = alpha(colors.foreground, dark ? 0.1 : 0.06);
@@ -373,23 +446,24 @@ export function BottomSheet({
373
446
  const closeButton = showClose ?? (!!title && dismissible && !headerRight);
374
447
  const hasHeader = !!(title || subtitle || headerRight || closeButton);
375
448
 
376
- // Auto height always scrolls when its content is taller than the screen allows.
377
- const scrolls = scrollable || auto;
378
- const body = scrolls ? (
379
- <ScrollView
380
- style={styles.flex}
381
- onContentSizeChange={auto ? (_w, h) => setContentH(Math.ceil(h)) : undefined}
382
- contentContainerStyle={[styles.content, { paddingBottom: 12 + (footer ? footerH : 0) + kbPad }, contentStyle]}
383
- scrollEnabled={expanded}
384
- bounces={false}
385
- overScrollMode="never"
386
- showsVerticalScrollIndicator={expanded && (!auto || headerH + contentH + bottomInset > screen - TOP_GAP)}
387
- keyboardShouldPersistTaps="handled"
388
- scrollEventThrottle={16}
389
- onScroll={(e) => (scrollY.current = e.nativeEvent.contentOffset.y)}
390
- >
391
- {children}
392
- </ScrollView>
449
+ const content = scrolls ? (
450
+ <GestureDetector gesture={scrollGesture}>
451
+ <Animated.ScrollView
452
+ ref={scrollRef}
453
+ style={styles.flex}
454
+ onContentSizeChange={auto ? (_w, h) => setContentH(Math.ceil(h)) : undefined}
455
+ contentContainerStyle={[styles.content, { paddingBottom: 12 + (footer ? footerH : 0) + kbPad }, contentStyle]}
456
+ scrollEnabled={expanded}
457
+ bounces={false}
458
+ overScrollMode="never"
459
+ showsVerticalScrollIndicator={expanded && (!auto || headerH + contentH + bottomInset > screen - TOP_GAP)}
460
+ keyboardShouldPersistTaps="handled"
461
+ scrollEventThrottle={16}
462
+ onScroll={onScroll}
463
+ >
464
+ {children}
465
+ </Animated.ScrollView>
466
+ </GestureDetector>
393
467
  ) : (
394
468
  <View
395
469
  onLayout={auto ? (e) => setContentH(Math.ceil(e.nativeEvent.layout.height)) : undefined}
@@ -407,130 +481,120 @@ export function BottomSheet({
407
481
  statusBarTranslucent
408
482
  onRequestClose={() => (dismissible ? dismiss() : snapTo(0))}
409
483
  >
410
- <View ref={rootRef} style={StyleSheet.absoluteFill} pointerEvents="box-none">
411
- <Animated.View style={[StyleSheet.absoluteFill, { backgroundColor: colors.overlay, opacity: dim }]}>
412
- <Pressable
413
- style={StyleSheet.absoluteFill}
414
- accessibilityRole="button"
415
- accessibilityLabel="Close sheet"
416
- onPress={() => dismissible && dismiss()}
417
- />
418
- </Animated.View>
419
- <View style={styles.dock} pointerEvents="box-none">
420
- <Animated.View
421
- accessibilityViewIsModal
422
- accessibilityLabel={accessibilityLabel ?? title}
423
- onAccessibilityEscape={() => dismissible && dismiss()}
424
- style={[
425
- styles.sheet,
426
- {
427
- height: max + OVERDRAG,
428
- width: sheetWidth,
429
- opacity: measured ? 1 : 0,
430
- transform: [{ translateY: Animated.add(y, lift) }],
431
- },
432
- style,
433
- ]}
434
- >
435
- <View
436
- style={[
437
- styles.surface,
438
- {
439
- paddingBottom: OVERDRAG + (footer ? 0 : bottomInset),
440
- backgroundColor: surface,
441
- borderTopLeftRadius: corner,
442
- borderTopRightRadius: corner,
443
- borderColor: hairline,
444
- // Ambient + contact shadow as boxShadow: identical on iOS, Android and web.
445
- boxShadow: `0px -8px 64px rgba(0,0,0,${dark ? 0.5 : 0.12}), 0px -1px 6px rgba(0,0,0,${dark ? 0.35 : 0.06})`,
446
- },
447
- ]}
484
+ {/* A Modal is its own native root, so gestures inside it need their own handler root. */}
485
+ <GestureHandlerRootView style={StyleSheet.absoluteFill}>
486
+ <View ref={rootRef} style={StyleSheet.absoluteFill} pointerEvents="box-none">
487
+ <Animated.View style={[StyleSheet.absoluteFill, { backgroundColor: colors.overlay }, dimStyle]}>
488
+ <Pressable
489
+ style={StyleSheet.absoluteFill}
490
+ accessibilityRole="button"
491
+ accessibilityLabel={labels.closeSheet}
492
+ onPress={() => dismissible && dismiss()}
493
+ />
494
+ </Animated.View>
495
+ <View style={styles.dock} pointerEvents="box-none">
496
+ <Animated.View
497
+ accessibilityViewIsModal
498
+ accessibilityLabel={accessibilityLabel ?? title}
499
+ onAccessibilityEscape={() => dismissible && dismiss()}
500
+ style={[styles.sheet, { height: max + OVERDRAG, width: sheetWidth, opacity: measured ? 1 : 0 }, sheetStyle, style]}
448
501
  >
449
- <View {...pans.grab.panHandlers} style={styles.grab} onLayout={auto ? (e) => setHeaderH(Math.ceil(e.nativeEvent.layout.height)) : undefined}>
450
- {handle ? (
451
- <View
452
- style={styles.handleArea}
453
- accessible
454
- accessibilityRole="adjustable"
455
- accessibilityLabel="Sheet grabber"
456
- accessibilityHint="Swipe up or down to resize"
457
- accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
458
- onAccessibilityAction={(e) => {
459
- if (e.nativeEvent.actionName === 'increment') snapTo(index.current + 1);
460
- else if (index.current === 0 && dismissible) dismiss();
461
- else snapTo(index.current - 1);
462
- }}
463
- >
464
- <Animated.View
465
- style={[
466
- styles.handle,
467
- {
468
- borderRadius: radius.capsule(5),
469
- backgroundColor: alpha(colors.foreground, dark ? 0.26 : 0.18),
470
- transform: [{ scaleX: grip.interpolate({ inputRange: [0, 1], outputRange: [1, 1.3] }) }],
471
- },
472
- ]}
473
- />
474
- </View>
475
- ) : null}
476
- {hasHeader ? (
477
- <View style={[styles.header, !handle && { paddingTop: 20 }]}>
478
- <View style={styles.headerText}>
479
- {title ? (
480
- <Text accessibilityRole="header" style={[styles.title, ff, { color: colors.foreground }]} numberOfLines={1}>
481
- {title}
482
- </Text>
483
- ) : null}
484
- {subtitle ? (
485
- <Text style={[styles.subtitle, ff, { color: colors.mutedForeground }]} numberOfLines={1}>
486
- {subtitle}
487
- </Text>
488
- ) : null}
489
- </View>
490
- {headerRight}
491
- {closeButton ? (
492
- <Pressable
493
- onPress={() => (dismissible ? dismiss() : onClose())}
494
- hitSlop={10}
495
- accessibilityRole="button"
496
- accessibilityLabel="Close"
497
- style={({ pressed }) => [styles.close, { backgroundColor: alpha(colors.foreground, pressed ? 0.14 : 0.08) }]}
502
+ <View
503
+ style={[
504
+ styles.surface,
505
+ {
506
+ paddingBottom: OVERDRAG + (footer ? 0 : bottomInset),
507
+ backgroundColor: surface,
508
+ borderTopLeftRadius: corner,
509
+ borderTopRightRadius: corner,
510
+ borderColor: hairline,
511
+ // Ambient + contact shadow as boxShadow: identical on iOS, Android and web.
512
+ boxShadow: `0px -8px 64px rgba(0,0,0,${dark ? 0.5 : 0.12}), 0px -1px 6px rgba(0,0,0,${dark ? 0.35 : 0.06})`,
513
+ },
514
+ ]}
515
+ >
516
+ <GestureDetector gesture={grab}>
517
+ <View style={styles.grab} onLayout={auto ? (e) => setHeaderH(Math.ceil(e.nativeEvent.layout.height)) : undefined}>
518
+ {handle ? (
519
+ <View
520
+ style={styles.handleArea}
521
+ accessible
522
+ accessibilityRole="adjustable"
523
+ accessibilityLabel={labels.sheetGrabber}
524
+ accessibilityHint={labels.sheetResizeHint}
525
+ accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
526
+ onAccessibilityAction={(e) => {
527
+ const at = index.get();
528
+ if (e.nativeEvent.actionName === 'increment') snapTo(at + 1);
529
+ else if (at === 0 && dismissible) dismiss();
530
+ else snapTo(at - 1);
531
+ }}
498
532
  >
499
- <View style={[styles.closeBar, { backgroundColor: colors.mutedForeground, transform: [{ rotate: '45deg' }] }]} />
500
- <View style={[styles.closeBar, { backgroundColor: colors.mutedForeground, transform: [{ rotate: '-45deg' }] }]} />
501
- </Pressable>
533
+ <Animated.View
534
+ style={[
535
+ styles.handle,
536
+ { borderRadius: radius.capsule(5), backgroundColor: alpha(colors.foreground, dark ? 0.26 : 0.18) },
537
+ gripStyle,
538
+ ]}
539
+ />
540
+ </View>
541
+ ) : null}
542
+ {hasHeader ? (
543
+ <View style={[styles.header, !handle && { paddingTop: 20 }]}>
544
+ <View style={styles.headerText}>
545
+ {title ? (
546
+ <Text accessibilityRole="header" style={[styles.title, ff, { color: colors.foreground }]} numberOfLines={1}>
547
+ {title}
548
+ </Text>
549
+ ) : null}
550
+ {subtitle ? (
551
+ <Text style={[styles.subtitle, ff, { color: colors.mutedForeground }]} numberOfLines={1}>
552
+ {subtitle}
553
+ </Text>
554
+ ) : null}
555
+ </View>
556
+ {headerRight}
557
+ {closeButton ? (
558
+ <Pressable
559
+ onPress={() => (dismissible ? dismiss() : onClose())}
560
+ hitSlop={10}
561
+ accessibilityRole="button"
562
+ accessibilityLabel={closeLabel ?? labels.close}
563
+ style={({ pressed }) => [styles.close, { backgroundColor: alpha(colors.foreground, pressed ? 0.14 : 0.08) }]}
564
+ >
565
+ <View style={[styles.closeBar, { backgroundColor: colors.mutedForeground, transform: [{ rotate: '45deg' }] }]} />
566
+ <View style={[styles.closeBar, { backgroundColor: colors.mutedForeground, transform: [{ rotate: '-45deg' }] }]} />
567
+ </Pressable>
568
+ ) : null}
569
+ </View>
502
570
  ) : null}
503
571
  </View>
572
+ </GestureDetector>
573
+ <GestureDetector gesture={body}>
574
+ <View style={styles.flex}>{content}</View>
575
+ </GestureDetector>
576
+ {footer ? (
577
+ <Animated.View
578
+ onLayout={(e) => setFooterH(e.nativeEvent.layout.height)}
579
+ style={[
580
+ styles.footer,
581
+ { bottom: OVERDRAG, paddingBottom: bottomInset, backgroundColor: surface, borderTopColor: hairline },
582
+ footerStyle,
583
+ ]}
584
+ >
585
+ {footer}
586
+ </Animated.View>
504
587
  ) : null}
505
588
  </View>
506
- <View {...pans.body.panHandlers} style={styles.flex}>
507
- {body}
508
- </View>
509
- {footer ? (
510
- <Animated.View
511
- onLayout={(e) => setFooterH(e.nativeEvent.layout.height)}
512
- style={[
513
- styles.footer,
514
- {
515
- bottom: OVERDRAG,
516
- paddingBottom: bottomInset,
517
- backgroundColor: surface,
518
- borderTopColor: hairline,
519
- transform: [{ translateY: footerY }],
520
- },
521
- ]}
522
- >
523
- {footer}
524
- </Animated.View>
525
- ) : null}
526
- </View>
527
- </Animated.View>
589
+ </Animated.View>
590
+ </View>
528
591
  </View>
529
- </View>
592
+ </GestureHandlerRootView>
530
593
  </Modal>
531
594
  );
532
595
  }
533
596
 
597
+
534
598
  function useReduceMotion() {
535
599
  const [reduce, setReduce] = useState(false);
536
600
  useEffect(() => {