@expokit/cli 0.4.3 → 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 (242) hide show
  1. package/README.md +30 -3
  2. package/bin/expo-kit.js +749 -288
  3. package/bin/lib.mjs +313 -4
  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/flip-calendar.json +106 -0
  12. package/registry/_docs/glass-search-bar.json +1 -1
  13. package/registry/_docs/grain.json +6 -1
  14. package/registry/_docs/isometric-bar.json +6 -0
  15. package/registry/_docs/liquidglass-tab.json +1 -1
  16. package/registry/_docs/password-input.json +1 -1
  17. package/registry/_docs/paywall.json +144 -0
  18. package/registry/_docs/pixel-bar.json +6 -0
  19. package/registry/_docs/polar-area.json +6 -0
  20. package/registry/_docs/qr-code-button.json +9 -3
  21. package/registry/_docs/ruler-time-picker.json +147 -0
  22. package/registry/_docs/scratch-card.json +7 -1
  23. package/registry/_docs/search-bar.json +1 -1
  24. package/registry/_docs/select.json +2 -2
  25. package/registry/_docs/slide-to-confirm.json +2 -2
  26. package/registry/_docs/spinner.json +1 -1
  27. package/registry/_docs/stateful-button.json +2 -2
  28. package/registry/_docs/swipe-deck.json +1 -1
  29. package/registry/_docs/time-picker.json +2 -2
  30. package/registry/_docs/timeline-chart.json +6 -0
  31. package/registry/_docs/toast.json +15 -3
  32. package/registry/_docs/treemap.json +6 -0
  33. package/registry/_docs/waterfall.json +6 -0
  34. package/registry/accordion/files/src/components/ui/accordion.tsx +6 -4
  35. package/registry/accordion/files/src/lib/kit.tsx +462 -12
  36. package/registry/accordion.json +1 -0
  37. package/registry/action-sheet/files/src/components/ui/action-sheet.tsx +50 -58
  38. package/registry/action-sheet/files/src/lib/kit.tsx +462 -12
  39. package/registry/action-sheet.json +1 -0
  40. package/registry/alert/files/src/components/ui/alert.tsx +20 -13
  41. package/registry/alert/files/src/lib/kit.tsx +462 -12
  42. package/registry/alert.json +3 -4
  43. package/registry/assistant-app.json +1 -0
  44. package/registry/avatar/files/src/components/ui/avatar.tsx +89 -74
  45. package/registry/avatar/files/src/lib/kit.tsx +462 -12
  46. package/registry/avatar.json +1 -0
  47. package/registry/badge/files/src/components/ui/badge.tsx +36 -36
  48. package/registry/badge/files/src/lib/kit.tsx +462 -12
  49. package/registry/badge.json +1 -0
  50. package/registry/bottom-nav/files/src/components/ui/bottom-nav.tsx +39 -44
  51. package/registry/bottom-nav/files/src/lib/kit.tsx +462 -12
  52. package/registry/bottom-nav.json +5 -0
  53. package/registry/bottom-sheet/files/src/components/ui/bottom-sheet.tsx +357 -300
  54. package/registry/bottom-sheet/files/src/lib/kit.tsx +462 -12
  55. package/registry/bottom-sheet.json +5 -1
  56. package/registry/bullet.json +1 -0
  57. package/registry/button/files/src/components/ui/button.tsx +23 -34
  58. package/registry/button/files/src/lib/kit.tsx +462 -12
  59. package/registry/button-3d/files/src/components/ui/button-3d.tsx +6 -9
  60. package/registry/button-3d/files/src/lib/kit.tsx +462 -12
  61. package/registry/button-3d.json +1 -0
  62. package/registry/button.json +1 -0
  63. package/registry/calendar/files/src/components/ui/calendar.tsx +77 -79
  64. package/registry/calendar/files/src/lib/kit.tsx +462 -12
  65. package/registry/calendar.json +1 -0
  66. package/registry/card/files/src/components/ui/card.tsx +7 -6
  67. package/registry/card/files/src/lib/kit.tsx +462 -12
  68. package/registry/card-stack.json +1 -0
  69. package/registry/card.json +1 -0
  70. package/registry/carousel.json +1 -0
  71. package/registry/chat.json +7 -0
  72. package/registry/checkbox/files/src/components/ui/checkbox.tsx +4 -4
  73. package/registry/checkbox/files/src/lib/kit.tsx +462 -12
  74. package/registry/checkbox.json +1 -0
  75. package/registry/checkout.json +1 -0
  76. package/registry/chip-group/files/src/components/ui/chip-group.tsx +24 -24
  77. package/registry/chip-group/files/src/lib/kit.tsx +462 -12
  78. package/registry/chip-group.json +1 -0
  79. package/registry/confetti/files/src/components/ui/confetti.tsx +15 -14
  80. package/registry/confetti.json +3 -6
  81. package/registry/coupon/files/src/components/ui/coupon.tsx +47 -52
  82. package/registry/coupon/files/src/components/ui/flip-card.tsx +9 -14
  83. package/registry/coupon/files/src/components/ui/tilt-card.tsx +80 -101
  84. package/registry/coupon/files/src/lib/kit.tsx +462 -12
  85. package/registry/coupon/files/src/lib/ticket-shape.tsx +54 -44
  86. package/registry/coupon.json +1 -0
  87. package/registry/cube-pager.json +1 -0
  88. package/registry/dashboard.json +1 -0
  89. package/registry/date-picker/files/src/components/ui/calendar.tsx +77 -79
  90. package/registry/date-picker/files/src/components/ui/date-picker.tsx +64 -71
  91. package/registry/date-picker/files/src/lib/kit.tsx +462 -12
  92. package/registry/date-picker.json +1 -0
  93. package/registry/dialog/files/src/components/ui/dialog.tsx +66 -68
  94. package/registry/dialog/files/src/lib/kit.tsx +462 -12
  95. package/registry/dialog.json +1 -0
  96. package/registry/donut-shop.json +1 -0
  97. package/registry/drawer/files/src/components/ui/drawer.tsx +76 -88
  98. package/registry/drawer/files/src/lib/kit.tsx +462 -12
  99. package/registry/drawer.json +1 -0
  100. package/registry/fab.json +1 -0
  101. package/registry/fintech-app.json +1 -0
  102. package/registry/flip-calendar/files/src/components/ui/flip-calendar.tsx +632 -0
  103. package/registry/flip-calendar/files/src/lib/kit.tsx +672 -0
  104. package/registry/flip-calendar.json +24 -0
  105. package/registry/flip-card/files/src/components/ui/flip-card.tsx +9 -14
  106. package/registry/flip-card/files/src/lib/kit.tsx +462 -12
  107. package/registry/flip-card.json +1 -0
  108. package/registry/floating-input/files/src/components/ui/floating-input.tsx +33 -33
  109. package/registry/floating-input/files/src/lib/kit.tsx +462 -12
  110. package/registry/floating-input.json +1 -0
  111. package/registry/food-app.json +1 -0
  112. package/registry/gift-card.json +1 -0
  113. package/registry/glass-button.json +5 -0
  114. package/registry/glass-nav-bar.json +1 -0
  115. package/registry/glass-search-bar.json +1 -0
  116. package/registry/glass-segmented.json +1 -0
  117. package/registry/glass-sheet.json +1 -0
  118. package/registry/glass-tab-bar.json +5 -0
  119. package/registry/glass.json +1 -0
  120. package/registry/grain/files/src/components/ui/grain.tsx +16 -14
  121. package/registry/grain.json +8 -6
  122. package/registry/inbox.json +1 -0
  123. package/registry/isometric-bar.json +1 -0
  124. package/registry/kit/files/src/lib/kit.tsx +462 -12
  125. package/registry/kit.json +1 -0
  126. package/registry/liquidglass-tab/files/src/app/_layout.tsx +0 -1
  127. package/registry/liquidglass-tab/files/src/components/GlassTabBar.tsx +29 -16
  128. package/registry/liquidglass-tab/files/src/components/TabScreen.tsx +33 -19
  129. package/registry/liquidglass-tab/files/src/lib/tab-icons.ts +1 -1
  130. package/registry/liquidglass-tab.json +1 -0
  131. package/registry/list-item/files/src/components/ui/list-item.tsx +43 -43
  132. package/registry/list-item/files/src/lib/kit.tsx +462 -12
  133. package/registry/list-item.json +1 -0
  134. package/registry/loyalty-card.json +1 -0
  135. package/registry/morphing-button-group.json +1 -0
  136. package/registry/number-ticker.json +1 -0
  137. package/registry/offers.json +1 -0
  138. package/registry/onboarding.json +1 -0
  139. package/registry/orb-reveal.json +1 -0
  140. package/registry/otp-input/files/src/components/ui/otp-input.tsx +49 -30
  141. package/registry/otp-input/files/src/lib/kit.tsx +462 -12
  142. package/registry/otp-input.json +1 -0
  143. package/registry/parallax-layers.json +1 -0
  144. package/registry/parallax-scroll.json +1 -0
  145. package/registry/password-input/files/src/components/ui/floating-input.tsx +33 -33
  146. package/registry/password-input/files/src/components/ui/password-input.tsx +29 -25
  147. package/registry/password-input/files/src/lib/kit.tsx +462 -12
  148. package/registry/password-input.json +1 -0
  149. package/registry/payment-card/files/src/components/ui/flip-card.tsx +9 -14
  150. package/registry/payment-card/files/src/components/ui/payment-card.tsx +26 -20
  151. package/registry/payment-card/files/src/components/ui/tilt-card.tsx +80 -101
  152. package/registry/payment-card/files/src/lib/kit.tsx +462 -12
  153. package/registry/payment-card.json +1 -0
  154. package/registry/paywall.json +23 -0
  155. package/registry/pixel-bar.json +1 -0
  156. package/registry/polar-area.json +1 -0
  157. package/registry/profile.json +1 -0
  158. package/registry/progress/files/src/components/ui/progress.tsx +31 -29
  159. package/registry/progress/files/src/lib/kit.tsx +462 -12
  160. package/registry/progress-steps/files/src/components/ui/progress-steps.tsx +21 -21
  161. package/registry/progress-steps/files/src/lib/kit.tsx +462 -12
  162. package/registry/progress-steps.json +1 -0
  163. package/registry/progress.json +1 -0
  164. package/registry/qr-code-button.json +1 -0
  165. package/registry/radio-group/files/src/components/ui/radio-group.tsx +19 -19
  166. package/registry/radio-group/files/src/lib/kit.tsx +462 -12
  167. package/registry/radio-group.json +1 -0
  168. package/registry/rating.json +1 -0
  169. package/registry/ring-carousel.json +1 -0
  170. package/registry/ruler-time-picker/files/src/components/ui/ruler-time-picker.tsx +832 -0
  171. package/registry/ruler-time-picker/files/src/lib/kit.tsx +672 -0
  172. package/registry/ruler-time-picker.json +26 -0
  173. package/registry/scratch-card.json +1 -0
  174. package/registry/search-bar/files/src/components/ui/search-bar.tsx +28 -28
  175. package/registry/search-bar/files/src/lib/kit.tsx +462 -12
  176. package/registry/search-bar.json +1 -0
  177. package/registry/search-results.json +4 -0
  178. package/registry/segmented-control/files/src/components/ui/segmented-control.tsx +11 -17
  179. package/registry/segmented-control/files/src/lib/kit.tsx +462 -12
  180. package/registry/segmented-control.json +1 -0
  181. package/registry/select/files/src/components/ui/select.tsx +107 -104
  182. package/registry/select/files/src/lib/kit.tsx +462 -12
  183. package/registry/select.json +6 -0
  184. package/registry/settings.json +1 -0
  185. package/registry/sign-in.json +1 -0
  186. package/registry/skeleton/files/src/components/ui/skeleton.tsx +149 -80
  187. package/registry/skeleton/files/src/lib/kit.tsx +462 -12
  188. package/registry/skeleton.json +1 -0
  189. package/registry/slide-to-confirm.json +1 -0
  190. package/registry/slider/files/src/components/ui/slider.tsx +106 -108
  191. package/registry/slider/files/src/lib/kit.tsx +462 -12
  192. package/registry/slider.json +5 -0
  193. package/registry/spin-wheel.json +1 -0
  194. package/registry/spinner/files/src/components/ui/spinner.tsx +39 -29
  195. package/registry/spinner/files/src/lib/kit.tsx +462 -12
  196. package/registry/spinner.json +6 -0
  197. package/registry/stateful-button/files/src/components/ui/stateful-button.tsx +39 -32
  198. package/registry/stateful-button/files/src/lib/kit.tsx +462 -12
  199. package/registry/stateful-button.json +5 -0
  200. package/registry/stepper/files/src/components/ui/stepper.tsx +40 -38
  201. package/registry/stepper/files/src/lib/kit.tsx +462 -12
  202. package/registry/stepper.json +1 -0
  203. package/registry/swipe-deck.json +1 -0
  204. package/registry/swipeable-row.json +4 -0
  205. package/registry/switch/files/src/components/ui/switch.tsx +41 -47
  206. package/registry/switch/files/src/lib/kit.tsx +462 -12
  207. package/registry/switch.json +1 -0
  208. package/registry/tabs/files/src/components/ui/tabs.tsx +61 -64
  209. package/registry/tabs/files/src/lib/kit.tsx +462 -12
  210. package/registry/tabs.json +1 -0
  211. package/registry/text-animation/files/src/components/ui/text-animation.tsx +55 -42
  212. package/registry/text-animation/files/src/lib/kit.tsx +462 -12
  213. package/registry/text-animation.json +7 -0
  214. package/registry/textarea/files/src/components/ui/textarea.tsx +10 -10
  215. package/registry/textarea/files/src/lib/kit.tsx +462 -12
  216. package/registry/textarea.json +1 -0
  217. package/registry/ticket-card.json +1 -0
  218. package/registry/ticket-shape/files/src/lib/kit.tsx +462 -12
  219. package/registry/ticket-shape/files/src/lib/ticket-shape.tsx +54 -44
  220. package/registry/ticket-shape.json +1 -0
  221. package/registry/tilt-card/files/src/components/ui/tilt-card.tsx +80 -101
  222. package/registry/tilt-card/files/src/lib/kit.tsx +462 -12
  223. package/registry/tilt-card.json +1 -0
  224. package/registry/time-picker/files/src/components/ui/time-picker.tsx +136 -131
  225. package/registry/time-picker/files/src/lib/kit.tsx +462 -12
  226. package/registry/time-picker.json +1 -0
  227. package/registry/timeline/files/src/components/ui/avatar.tsx +89 -74
  228. package/registry/timeline/files/src/components/ui/timeline.tsx +49 -57
  229. package/registry/timeline/files/src/lib/kit.tsx +462 -12
  230. package/registry/timeline-chart.json +1 -0
  231. package/registry/timeline.json +1 -0
  232. package/registry/toast/files/src/components/ui/toast.tsx +543 -465
  233. package/registry/toast/files/src/lib/kit.tsx +462 -12
  234. package/registry/toast.json +7 -5
  235. package/registry/treemap.json +1 -0
  236. package/registry/waterfall.json +1 -0
  237. package/registry/wheel-list.json +1 -0
  238. package/schema/components.schema.json +1 -0
  239. package/registry/alert/files/src/components/ui/button.tsx +0 -303
  240. package/registry/confetti/files/src/lib/kit.tsx +0 -222
  241. package/registry/grain/files/src/lib/kit.tsx +0 -222
  242. package/registry/toast/files/src/components/ui/button.tsx +0 -303
@@ -2,25 +2,36 @@ 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
- import { alpha, motion, useKit } from '@/lib/kit';
34
+ import { alpha, motion, useKit, useLayout } from '@/lib/kit';
24
35
 
25
36
  export type SnapPoint = number | `${number}%`;
26
37
 
@@ -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,10 +165,12 @@ 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();
172
+ // Full width on portrait phones; a centred, width-capped card on foldables, tablets and landscape.
173
+ const { sheetWidth } = useLayout();
129
174
 
130
175
  // No snap points: one detent at the content's own height, measured before the sheet slides in.
131
176
  const auto = !snapPoints || snapPoints.length === 0;
@@ -148,89 +193,97 @@ export function BottomSheet({
148
193
  // eslint-disable-next-line react-hooks/exhaustive-deps
149
194
  [snapKey, screen],
150
195
  );
151
- const max = heights[heights.length - 1];
196
+ const max = heights[heights.length - 1] ?? 0;
152
197
  // translateY for each snap (0 = top snap) and for closed.
153
198
  const stops = heights.map((h) => max - h);
154
199
  const closed = max + 24;
155
200
  const last = stops.length - 1;
156
201
 
157
- // Native driver: sheet position, keyboard lift, grabber stretch — separate values.
158
- const y = useRef(new Animated.Value(closed)).current;
159
- const lift = useRef(new Animated.Value(0)).current;
160
- 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>();
161
214
 
162
215
  const [mounted, setMounted] = useState(open);
163
216
  const [expanded, setExpanded] = useState(false); // at the top snap → body scrolls
164
217
  const [kbPad, setKbPad] = useState(0);
165
218
  const shown = useRef(false); // auto: slid in once measured
166
- const index = useRef(initialSnap);
167
- const scrollY = useRef(0);
168
219
  const rootRef = useRef<ComponentRef<typeof View>>(null);
169
220
 
170
221
  const bodyDrags = dragFromContent ?? scrollable;
171
- const live = useRef({ stops, closed, max, dismissible, onClose, onSnapChange, haptics, reduceMotion, scrollable, bodyDrags, expanded });
172
- live.current = { stops, closed, max, dismissible, onClose, onSnapChange, haptics, reduceMotion, scrollable, bodyDrags, expanded };
173
-
174
- const pos = useRef(closed); // last known resting / dragged position (JS mirror of `y`)
175
- const moving = useRef(false);
176
-
177
- const settle = (to: number, velocity = 0, after?: () => void) => {
178
- pos.current = to;
179
- moving.current = true;
180
- const anim = live.current.reduceMotion
181
- ? Animated.timing(y, { toValue: to, duration: 200, easing: Easing.out(Easing.cubic), useNativeDriver: true })
182
- : Animated.spring(y, {
183
- toValue: to,
184
- velocity,
185
- useNativeDriver: true,
186
- ...SNAP_SPRING,
187
- overshootClamping: to === live.current.closed,
188
- });
189
- anim.start(({ finished }) => {
190
- moving.current = false;
191
- if (finished) after?.();
192
- });
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
+ );
193
242
  };
194
243
 
195
244
  const snapTo = (i: number, velocity = 0) => {
196
- const s = live.current.stops;
197
- const k = Math.max(0, Math.min(s.length - 1, i));
198
- if (index.current !== k) {
199
- live.current.onSnapChange?.(k);
200
- if (live.current.haptics) Haptics.selectionAsync().catch(() => {});
201
- }
202
- index.current = k;
203
- setExpanded(k === s.length - 1);
204
- 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);
205
251
  };
206
252
 
207
- 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
+ };
208
257
 
209
258
  const enter = () => {
210
259
  shown.current = true;
211
- y.setValue(closed);
212
- lift.setValue(0);
213
- pos.current = closed;
214
- scrollY.current = 0;
215
- index.current = Math.max(0, Math.min(last, snapIndex ?? initialSnap));
216
- 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);
217
266
  if (haptics) Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {});
218
- settle(stops[index.current]);
267
+ settle(stops[k] ?? closed);
219
268
  };
220
269
 
270
+ // Mount as soon as `open` turns on (render-time update, so no extra effect pass).
271
+ if (open && !mounted) setMounted(true);
272
+
221
273
  useEffect(() => {
222
274
  if (open) {
223
- setMounted(true);
224
275
  shown.current = false;
225
276
  // Off-screen until there is a height to open to.
226
- y.setValue(screen);
277
+ y.set(screen);
227
278
  if (measured) enter();
228
279
  } else if (mounted) {
229
280
  // Exit is quicker than the entrance: a short ease-in, no spring.
230
281
  Keyboard.dismiss();
231
- Animated.timing(lift, { toValue: 0, duration: 160, useNativeDriver: true }).start();
232
- Animated.timing(y, { toValue: closed, duration: 200, easing: Easing.in(Easing.quad), useNativeDriver: true }).start(() =>
233
- 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
+ }),
234
287
  );
235
288
  }
236
289
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -240,16 +293,13 @@ export function BottomSheet({
240
293
  useEffect(() => {
241
294
  if (!open || !auto || !measured) return;
242
295
  if (!shown.current) enter();
243
- else if (!moving.current && index.current === 0) {
244
- pos.current = 0;
245
- y.setValue(0);
246
- }
296
+ else if (!busy.get() && index.get() === 0) y.set(0);
247
297
  // eslint-disable-next-line react-hooks/exhaustive-deps
248
298
  }, [open, auto, measured, autoH]);
249
299
 
250
300
  // Controlled snap index.
251
301
  useEffect(() => {
252
- if (open && mounted && snapIndex !== undefined && snapIndex !== index.current) snapTo(snapIndex);
302
+ if (open && mounted && snapIndex !== undefined && snapIndex !== index.get()) snapTo(snapIndex);
253
303
  // eslint-disable-next-line react-hooks/exhaustive-deps
254
304
  }, [snapIndex]);
255
305
 
@@ -259,11 +309,11 @@ export function BottomSheet({
259
309
  if (!mounted || !avoidKeyboard) return;
260
310
  const ios = Platform.OS === 'ios';
261
311
  const apply = (overlap: number, duration: number) => {
262
- const visible = live.current.max - pos.current;
312
+ const visible = max - y.get();
263
313
  const room = Math.max(0, screen - TOP_GAP - visible);
264
314
  const up = Math.min(overlap, room);
265
315
  setKbPad(overlap - up);
266
- 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 }));
267
317
  };
268
318
  const show = Keyboard.addListener(ios ? 'keyboardWillShow' : 'keyboardDidShow', (e: RNKeyboardEvent) => {
269
319
  const duration = e.duration || 220;
@@ -273,97 +323,122 @@ export function BottomSheet({
273
323
  });
274
324
  const hide = Keyboard.addListener(ios ? 'keyboardWillHide' : 'keyboardDidHide', (e: RNKeyboardEvent) => {
275
325
  setKbPad(0);
276
- 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 }));
277
327
  });
278
328
  return () => {
279
329
  show.remove();
280
330
  hide.remove();
281
331
  };
282
- }, [mounted, avoidKeyboard, screen, lift]);
332
+ }, [mounted, avoidKeyboard, screen, max, y, lift]);
283
333
 
284
334
  // Escape closes on web (the Modal handles Android back).
285
335
  useEffect(() => {
286
336
  if (!mounted || Platform.OS !== 'web' || typeof document === 'undefined') return;
287
337
  const onKey = (e: KeyboardEvent) => {
288
- if (e.key === 'Escape' && live.current.dismissible) dismiss();
338
+ if (e.key === 'Escape' && dismissible) dismiss();
289
339
  };
290
340
  document.addEventListener('keydown', onKey);
291
341
  return () => document.removeEventListener('keydown', onKey);
292
342
  // eslint-disable-next-line react-hooks/exhaustive-deps
293
- }, [mounted]);
294
-
295
- // One drag model for the grabber/header (claims any touch) and the body (captures vertical
296
- // drags only, so taps, horizontal scrollers and — at the top snap — scrolling still work).
297
- const pans = useRef(
298
- (() => {
299
- const origin = { start: 0 };
300
- const base = {
301
- onPanResponderTerminationRequest: () => false,
302
- onPanResponderGrant: () => {
303
- origin.start = pos.current;
304
- Animated.spring(grip, { toValue: 1, useNativeDriver: true, ...motion.snappy }).start();
305
- if (!moving.current) return;
306
- moving.current = false;
307
- y.stopAnimation((v) => {
308
- if (typeof v === 'number') origin.start = v;
309
- });
310
- },
311
- onPanResponderMove: (_: unknown, g: PanResponderGestureState) => {
312
- const { closed: bottom, stops: s, dismissible: canClose } = live.current;
313
- let next = origin.start + g.dy;
314
- if (next < 0) next = -rubber(-next, OVERDRAG);
315
- if (!canClose && next > s[0]) next = s[0] + rubber(next - s[0], OVERDRAG);
316
- pos.current = Math.min(next, bottom + 40);
317
- y.setValue(pos.current);
318
- },
319
- onPanResponderRelease: (_: unknown, g: PanResponderGestureState) => {
320
- Animated.spring(grip, { toValue: 0, useNativeDriver: true, ...motion.bouncy }).start();
321
- const { stops: s, closed: bottom, dismissible: canClose } = live.current;
322
- const current = origin.start + g.dy;
323
- const projected = current + g.vy * 220;
324
- const targets = canClose ? [...s, bottom] : s;
325
- let best = 0;
326
- targets.forEach((t, i) => {
327
- if (Math.abs(t - projected) < Math.abs(targets[best] - projected)) best = i;
328
- });
329
- if (canClose && (best === s.length || (g.vy > 1.6 && current > s[0]))) dismiss(g.vy);
330
- else snapTo(Math.min(best, s.length - 1), g.vy);
331
- },
332
- onPanResponderTerminate: () => {
333
- Animated.spring(grip, { toValue: 0, useNativeDriver: true, ...motion.bouncy }).start();
334
- snapTo(index.current);
335
- },
336
- };
337
- const grab = PanResponder.create({
338
- ...base,
339
- onStartShouldSetPanResponder: () => true,
340
- onMoveShouldSetPanResponder: () => true,
341
- });
342
- const body = PanResponder.create({
343
- ...base,
344
- onMoveShouldSetPanResponderCapture: (_, g) => {
345
- const l = live.current;
346
- if (!l.bodyDrags) return false;
347
- if (Math.abs(g.dy) < 8 || Math.abs(g.dy) < Math.abs(g.dx) * 1.3) return false;
348
- if (!l.scrollable || !l.expanded) return true;
349
- // Fully expanded: the list scrolls; pulling down from its very top collapses the sheet.
350
- return g.dy > 0 && scrollY.current <= 0;
351
- },
352
- });
353
- return { grab, body };
354
- })(),
355
- ).current;
343
+ }, [mounted, dismissible]);
356
344
 
357
- 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
+ });
358
391
 
359
- const lowest = Math.max(1, stops[0]);
360
- const dim = y.interpolate({
361
- inputRange: [0, lowest, closed],
362
- outputRange: [backdropOpacity, backdropOpacity * (heights.length > 1 ? 0.7 : 1), 0],
363
- extrapolate: 'clamp',
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
+ },
364
423
  });
424
+
425
+ const onScroll = useAnimatedScrollHandler((e) => {
426
+ scrollY.set(e.contentOffset.y);
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() }] }));
365
436
  // Keeps the footer on the screen's bottom edge at every detent; it follows the sheet down when closing.
366
- 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;
367
442
 
368
443
  const surface = dark ? colors.popover : colors.card;
369
444
  const hairline = alpha(colors.foreground, dark ? 0.1 : 0.06);
@@ -371,23 +446,24 @@ export function BottomSheet({
371
446
  const closeButton = showClose ?? (!!title && dismissible && !headerRight);
372
447
  const hasHeader = !!(title || subtitle || headerRight || closeButton);
373
448
 
374
- // Auto height always scrolls when its content is taller than the screen allows.
375
- const scrolls = scrollable || auto;
376
- const body = scrolls ? (
377
- <ScrollView
378
- style={styles.flex}
379
- onContentSizeChange={auto ? (_w, h) => setContentH(Math.ceil(h)) : undefined}
380
- contentContainerStyle={[styles.content, { paddingBottom: 12 + (footer ? footerH : 0) + kbPad }, contentStyle]}
381
- scrollEnabled={expanded}
382
- bounces={false}
383
- overScrollMode="never"
384
- showsVerticalScrollIndicator={expanded && (!auto || headerH + contentH + bottomInset > screen - TOP_GAP)}
385
- keyboardShouldPersistTaps="handled"
386
- scrollEventThrottle={16}
387
- onScroll={(e) => (scrollY.current = e.nativeEvent.contentOffset.y)}
388
- >
389
- {children}
390
- </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>
391
467
  ) : (
392
468
  <View
393
469
  onLayout={auto ? (e) => setContentH(Math.ceil(e.nativeEvent.layout.height)) : undefined}
@@ -405,129 +481,120 @@ export function BottomSheet({
405
481
  statusBarTranslucent
406
482
  onRequestClose={() => (dismissible ? dismiss() : snapTo(0))}
407
483
  >
408
- <View ref={rootRef} style={StyleSheet.absoluteFill} pointerEvents="box-none">
409
- <Animated.View style={[StyleSheet.absoluteFill, { backgroundColor: colors.overlay, opacity: dim }]}>
410
- <Pressable
411
- style={StyleSheet.absoluteFill}
412
- accessibilityRole="button"
413
- accessibilityLabel="Close sheet"
414
- onPress={() => dismissible && dismiss()}
415
- />
416
- </Animated.View>
417
- <View style={styles.dock} pointerEvents="box-none">
418
- <Animated.View
419
- accessibilityViewIsModal
420
- accessibilityLabel={accessibilityLabel ?? title}
421
- onAccessibilityEscape={() => dismissible && dismiss()}
422
- style={[
423
- styles.sheet,
424
- {
425
- height: max + OVERDRAG,
426
- opacity: measured ? 1 : 0,
427
- shadowOpacity: dark ? 0.5 : 0.12,
428
- transform: [{ translateY: Animated.add(y, lift) }],
429
- },
430
- style,
431
- ]}
432
- >
433
- <View
434
- style={[
435
- styles.surface,
436
- {
437
- paddingBottom: OVERDRAG + (footer ? 0 : bottomInset),
438
- backgroundColor: surface,
439
- borderTopLeftRadius: corner,
440
- borderTopRightRadius: corner,
441
- borderColor: hairline,
442
- shadowOpacity: dark ? 0.35 : 0.06,
443
- },
444
- ]}
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]}
445
501
  >
446
- <View {...pans.grab.panHandlers} style={styles.grab} onLayout={auto ? (e) => setHeaderH(Math.ceil(e.nativeEvent.layout.height)) : undefined}>
447
- {handle ? (
448
- <View
449
- style={styles.handleArea}
450
- accessible
451
- accessibilityRole="adjustable"
452
- accessibilityLabel="Sheet grabber"
453
- accessibilityHint="Swipe up or down to resize"
454
- accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
455
- onAccessibilityAction={(e) => {
456
- if (e.nativeEvent.actionName === 'increment') snapTo(index.current + 1);
457
- else if (index.current === 0 && dismissible) dismiss();
458
- else snapTo(index.current - 1);
459
- }}
460
- >
461
- <Animated.View
462
- style={[
463
- styles.handle,
464
- {
465
- borderRadius: radius.capsule(5),
466
- backgroundColor: alpha(colors.foreground, dark ? 0.26 : 0.18),
467
- transform: [{ scaleX: grip.interpolate({ inputRange: [0, 1], outputRange: [1, 1.3] }) }],
468
- },
469
- ]}
470
- />
471
- </View>
472
- ) : null}
473
- {hasHeader ? (
474
- <View style={[styles.header, !handle && { paddingTop: 20 }]}>
475
- <View style={styles.headerText}>
476
- {title ? (
477
- <Text accessibilityRole="header" style={[styles.title, ff, { color: colors.foreground }]} numberOfLines={1}>
478
- {title}
479
- </Text>
480
- ) : null}
481
- {subtitle ? (
482
- <Text style={[styles.subtitle, ff, { color: colors.mutedForeground }]} numberOfLines={1}>
483
- {subtitle}
484
- </Text>
485
- ) : null}
486
- </View>
487
- {headerRight}
488
- {closeButton ? (
489
- <Pressable
490
- onPress={() => (dismissible ? dismiss() : onClose())}
491
- hitSlop={10}
492
- accessibilityRole="button"
493
- accessibilityLabel="Close"
494
- 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
+ }}
495
532
  >
496
- <View style={[styles.closeBar, { backgroundColor: colors.mutedForeground, transform: [{ rotate: '45deg' }] }]} />
497
- <View style={[styles.closeBar, { backgroundColor: colors.mutedForeground, transform: [{ rotate: '-45deg' }] }]} />
498
- </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>
499
570
  ) : null}
500
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>
501
587
  ) : null}
502
588
  </View>
503
- <View {...pans.body.panHandlers} style={styles.flex}>
504
- {body}
505
- </View>
506
- {footer ? (
507
- <Animated.View
508
- onLayout={(e) => setFooterH(e.nativeEvent.layout.height)}
509
- style={[
510
- styles.footer,
511
- {
512
- bottom: OVERDRAG,
513
- paddingBottom: bottomInset,
514
- backgroundColor: surface,
515
- borderTopColor: hairline,
516
- transform: [{ translateY: footerY }],
517
- },
518
- ]}
519
- >
520
- {footer}
521
- </Animated.View>
522
- ) : null}
523
- </View>
524
- </Animated.View>
589
+ </Animated.View>
590
+ </View>
525
591
  </View>
526
- </View>
592
+ </GestureHandlerRootView>
527
593
  </Modal>
528
594
  );
529
595
  }
530
596
 
597
+
531
598
  function useReduceMotion() {
532
599
  const [reduce, setReduce] = useState(false);
533
600
  useEffect(() => {
@@ -549,21 +616,11 @@ const styles = StyleSheet.create({
549
616
  // No text selection while dragging with a mouse on web.
550
617
  grab: Platform.OS === 'web' ? ({ userSelect: 'none', cursor: 'grab' } as object) : {},
551
618
  dock: { position: 'absolute', left: 0, right: 0, bottom: -OVERDRAG, alignItems: 'center' },
552
- sheet: {
553
- width: '100%',
554
- maxWidth: 640,
555
- shadowColor: '#000',
556
- shadowRadius: 32,
557
- shadowOffset: { width: 0, height: -8 },
558
- elevation: 24,
559
- },
619
+ sheet: { maxWidth: '100%' },
560
620
  surface: {
561
621
  flex: 1,
562
622
  borderWidth: StyleSheet.hairlineWidth,
563
623
  borderBottomWidth: 0,
564
- shadowColor: '#000',
565
- shadowRadius: 3,
566
- shadowOffset: { width: 0, height: -1 },
567
624
  },
568
625
  handleArea: { alignItems: 'center', paddingTop: 8, paddingBottom: 10, minHeight: 28 },
569
626
  handle: { width: 36, height: 5 },