@lodev09/react-native-true-sheet 3.10.0 → 3.11.0-beta.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 (111) hide show
  1. package/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt +11 -4
  2. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt +10 -5
  3. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetStackManager.kt +2 -5
  4. package/lib/module/TrueSheet.js +3 -0
  5. package/lib/module/TrueSheet.js.map +1 -1
  6. package/lib/module/TrueSheet.web.js +604 -376
  7. package/lib/module/TrueSheet.web.js.map +1 -1
  8. package/lib/module/TrueSheetProvider.js.map +1 -1
  9. package/lib/module/TrueSheetProvider.web.js +141 -100
  10. package/lib/module/TrueSheetProvider.web.js.map +1 -1
  11. package/lib/module/mocks/index.js.map +1 -1
  12. package/lib/module/reanimated/ReanimatedTrueSheet.web.js.map +1 -1
  13. package/lib/module/reanimated/useReanimatedPositionChangeHandler.web.js +2 -3
  14. package/lib/module/reanimated/useReanimatedPositionChangeHandler.web.js.map +1 -1
  15. package/lib/module/web/constants.js +16 -0
  16. package/lib/module/web/constants.js.map +1 -0
  17. package/lib/module/web/vaul/README.md +9 -0
  18. package/lib/module/web/vaul/browser.js +30 -0
  19. package/lib/module/web/vaul/browser.js.map +1 -0
  20. package/lib/module/web/vaul/constants.js +14 -0
  21. package/lib/module/web/vaul/constants.js.map +1 -0
  22. package/lib/module/web/vaul/context.js +57 -0
  23. package/lib/module/web/vaul/context.js.map +1 -0
  24. package/lib/module/web/vaul/helpers.js +93 -0
  25. package/lib/module/web/vaul/helpers.js.map +1 -0
  26. package/lib/module/web/vaul/index.js +1206 -0
  27. package/lib/module/web/vaul/index.js.map +1 -0
  28. package/lib/module/web/vaul/style.css +264 -0
  29. package/lib/module/web/vaul/types.js +2 -0
  30. package/lib/module/web/vaul/types.js.map +1 -0
  31. package/lib/module/web/vaul/use-composed-refs.js +34 -0
  32. package/lib/module/web/vaul/use-composed-refs.js.map +1 -0
  33. package/lib/module/web/vaul/use-controllable-state.js +54 -0
  34. package/lib/module/web/vaul/use-controllable-state.js.map +1 -0
  35. package/lib/module/web/vaul/use-position-fixed.js +123 -0
  36. package/lib/module/web/vaul/use-position-fixed.js.map +1 -0
  37. package/lib/module/web/vaul/use-prevent-scroll.js +258 -0
  38. package/lib/module/web/vaul/use-prevent-scroll.js.map +1 -0
  39. package/lib/module/web/vaul/use-scale-background.js +57 -0
  40. package/lib/module/web/vaul/use-scale-background.js.map +1 -0
  41. package/lib/module/web/vaul/use-snap-points.js +294 -0
  42. package/lib/module/web/vaul/use-snap-points.js.map +1 -0
  43. package/lib/typescript/src/TrueSheet.d.ts +2 -2
  44. package/lib/typescript/src/TrueSheet.d.ts.map +1 -1
  45. package/lib/typescript/src/TrueSheet.types.d.ts +79 -36
  46. package/lib/typescript/src/TrueSheet.types.d.ts.map +1 -1
  47. package/lib/typescript/src/TrueSheet.web.d.ts +3 -11
  48. package/lib/typescript/src/TrueSheet.web.d.ts.map +1 -1
  49. package/lib/typescript/src/TrueSheetProvider.d.ts +2 -2
  50. package/lib/typescript/src/TrueSheetProvider.d.ts.map +1 -1
  51. package/lib/typescript/src/TrueSheetProvider.web.d.ts +17 -14
  52. package/lib/typescript/src/TrueSheetProvider.web.d.ts.map +1 -1
  53. package/lib/typescript/src/mocks/index.d.ts +1 -2
  54. package/lib/typescript/src/mocks/index.d.ts.map +1 -1
  55. package/lib/typescript/src/navigation/types.d.ts +1 -1
  56. package/lib/typescript/src/navigation/types.d.ts.map +1 -1
  57. package/lib/typescript/src/reanimated/ReanimatedTrueSheet.web.d.ts +0 -1
  58. package/lib/typescript/src/reanimated/ReanimatedTrueSheet.web.d.ts.map +1 -1
  59. package/lib/typescript/src/reanimated/useReanimatedPositionChangeHandler.web.d.ts +2 -3
  60. package/lib/typescript/src/reanimated/useReanimatedPositionChangeHandler.web.d.ts.map +1 -1
  61. package/lib/typescript/src/web/constants.d.ts +14 -0
  62. package/lib/typescript/src/web/constants.d.ts.map +1 -0
  63. package/lib/typescript/src/web/vaul/browser.d.ts +8 -0
  64. package/lib/typescript/src/web/vaul/browser.d.ts.map +1 -0
  65. package/lib/typescript/src/web/vaul/constants.d.ts +12 -0
  66. package/lib/typescript/src/web/vaul/constants.d.ts.map +1 -0
  67. package/lib/typescript/src/web/vaul/context.d.ts +45 -0
  68. package/lib/typescript/src/web/vaul/context.d.ts.map +1 -0
  69. package/lib/typescript/src/web/vaul/helpers.d.ts +17 -0
  70. package/lib/typescript/src/web/vaul/helpers.d.ts.map +1 -0
  71. package/lib/typescript/src/web/vaul/index.d.ts +210 -0
  72. package/lib/typescript/src/web/vaul/index.d.ts.map +1 -0
  73. package/lib/typescript/src/web/vaul/types.d.ts +7 -0
  74. package/lib/typescript/src/web/vaul/types.d.ts.map +1 -0
  75. package/lib/typescript/src/web/vaul/use-composed-refs.d.ts +14 -0
  76. package/lib/typescript/src/web/vaul/use-composed-refs.d.ts.map +1 -0
  77. package/lib/typescript/src/web/vaul/use-controllable-state.d.ts +9 -0
  78. package/lib/typescript/src/web/vaul/use-controllable-state.d.ts.map +1 -0
  79. package/lib/typescript/src/web/vaul/use-position-fixed.d.ts +19 -0
  80. package/lib/typescript/src/web/vaul/use-position-fixed.d.ts.map +1 -0
  81. package/lib/typescript/src/web/vaul/use-prevent-scroll.d.ts +18 -0
  82. package/lib/typescript/src/web/vaul/use-prevent-scroll.d.ts.map +1 -0
  83. package/lib/typescript/src/web/vaul/use-scale-background.d.ts +2 -0
  84. package/lib/typescript/src/web/vaul/use-scale-background.d.ts.map +1 -0
  85. package/lib/typescript/src/web/vaul/use-snap-points.d.ts +37 -0
  86. package/lib/typescript/src/web/vaul/use-snap-points.d.ts.map +1 -0
  87. package/package.json +4 -4
  88. package/src/TrueSheet.tsx +9 -1
  89. package/src/TrueSheet.types.ts +88 -38
  90. package/src/TrueSheet.web.tsx +675 -450
  91. package/src/TrueSheetProvider.tsx +2 -4
  92. package/src/TrueSheetProvider.web.tsx +193 -125
  93. package/src/mocks/index.ts +1 -2
  94. package/src/navigation/types.ts +0 -1
  95. package/src/reanimated/ReanimatedTrueSheet.web.tsx +0 -1
  96. package/src/reanimated/useReanimatedPositionChangeHandler.web.ts +2 -3
  97. package/src/web/constants.ts +13 -0
  98. package/src/web/vaul/README.md +9 -0
  99. package/src/web/vaul/browser.ts +38 -0
  100. package/src/web/vaul/constants.ts +18 -0
  101. package/src/web/vaul/context.ts +87 -0
  102. package/src/web/vaul/helpers.ts +124 -0
  103. package/src/web/vaul/index.tsx +1590 -0
  104. package/src/web/vaul/style.css +264 -0
  105. package/src/web/vaul/types.ts +7 -0
  106. package/src/web/vaul/use-composed-refs.ts +35 -0
  107. package/src/web/vaul/use-controllable-state.ts +66 -0
  108. package/src/web/vaul/use-position-fixed.ts +147 -0
  109. package/src/web/vaul/use-prevent-scroll.ts +309 -0
  110. package/src/web/vaul/use-scale-background.ts +64 -0
  111. package/src/web/vaul/use-snap-points.ts +433 -0
@@ -1,571 +1,796 @@
1
+ /// <reference lib="dom" />
1
2
  import {
2
3
  createElement,
3
- Fragment,
4
4
  forwardRef,
5
5
  isValidElement,
6
6
  useCallback,
7
- useContext,
8
7
  useEffect,
9
- useId,
10
8
  useImperativeHandle,
11
9
  useMemo,
12
10
  useRef,
13
11
  useState,
14
12
  } from 'react';
15
- import { View, StyleSheet, useColorScheme, useWindowDimensions } from 'react-native';
16
-
17
- import BottomSheet, {
18
- BottomSheetBackdrop,
19
- type BottomSheetBackdropProps,
20
- BottomSheetFooter,
21
- type BottomSheetFooterProps,
22
- BottomSheetModal,
23
- type BottomSheetProps,
24
- BottomSheetView,
25
- type SNAP_POINT_TYPE,
26
- } from '@gorhom/bottom-sheet';
27
- import { useDerivedValue, useSharedValue } from 'react-native-reanimated';
28
-
29
- import { BottomSheetContext, type TrueSheetRefMethods } from './TrueSheetProvider.web';
13
+ import { View, useColorScheme, useWindowDimensions } from 'react-native';
14
+
15
+ import { Drawer } from './web/vaul';
16
+ import { TRANSITIONS } from './web/vaul/constants';
30
17
  import type {
31
- TrueSheetProps,
32
18
  DetentChangeEvent,
19
+ DetentInfoEventPayload,
33
20
  DidBlurEvent,
34
21
  DidDismissEvent,
35
22
  DidFocusEvent,
36
23
  DidPresentEvent,
24
+ DragBeginEvent,
25
+ DragChangeEvent,
26
+ DragEndEvent,
37
27
  MountEvent,
38
28
  PositionChangeEvent,
29
+ SheetDetent,
30
+ TrueSheetMethods,
31
+ TrueSheetProps,
32
+ TrueSheetStaticMethods,
39
33
  WillBlurEvent,
40
34
  WillDismissEvent,
41
35
  WillFocusEvent,
42
36
  WillPresentEvent,
43
- DragBeginEvent,
44
- DragChangeEvent,
45
- DragEndEvent,
46
37
  } from './TrueSheet.types';
47
-
48
- const DEFAULT_CORNER_RADIUS = 16;
49
- const DEFAULT_ELEVATION = 4;
50
-
51
- const DEFAULT_MAX_WIDTH = 640;
52
- // M3 baseline surfaceContainerLow
53
- const COLOR_SURFACE_CONTAINER_LOW_LIGHT = '#F7F2FA';
54
- const COLOR_SURFACE_CONTAINER_LOW_DARK = '#1D1B20';
55
-
56
- const DEFAULT_ANCHOR_OFFSET = 16;
57
- const DEFAULT_DETACHED_OFFSET = 16;
58
- const DEFAULT_GRABBER_COLOR_LIGHT = 'rgba(0, 0, 0, 0.3)';
59
- const DEFAULT_GRABBER_COLOR_DARK = 'rgba(255, 255, 255, 0.3)';
60
- const DEFAULT_GRABBER_WIDTH = 32;
61
- const DEFAULT_GRABBER_HEIGHT = 4;
62
-
63
- /**
64
- * Converts elevation to CSS box-shadow based on Material Design 3 elevation system.
65
- * Uses a combination of ambient and key shadows for realistic depth.
66
- */
67
- const getElevationShadow = (elevation: number): string => {
68
- if (elevation <= 0) return 'none';
69
-
70
- const ambientY = elevation * 0.5;
71
- const ambientBlur = elevation * 1.5;
72
- const ambientOpacity = 0.08 + elevation * 0.01;
73
-
74
- const keyY = elevation;
75
- const keyBlur = elevation * 2;
76
- const keyOpacity = 0.12 + elevation * 0.02;
77
-
78
- return `0px ${ambientY}px ${ambientBlur}px rgba(0, 0, 0, ${ambientOpacity}), 0px ${keyY}px ${keyBlur}px rgba(0, 0, 0, ${keyOpacity})`;
79
- };
80
-
81
- const renderSlot = (slot: TrueSheetProps['header'] | TrueSheetProps['footer']) => {
82
- if (!slot) return null;
83
- if (isValidElement(slot)) return slot;
84
- return createElement(slot);
85
- };
86
-
87
- const TrueSheetComponent = forwardRef<TrueSheetRefMethods, TrueSheetProps>((props, ref) => {
38
+ import { usePortalContainer, useRegisterSheet, useSheetStack } from './TrueSheetProvider.web';
39
+ import {
40
+ COLOR_SURFACE_CONTAINER_LOW_DARK,
41
+ COLOR_SURFACE_CONTAINER_LOW_LIGHT,
42
+ DEFAULT_ANCHOR_OFFSET,
43
+ DEFAULT_CORNER_RADIUS,
44
+ DEFAULT_DETACHED_OFFSET,
45
+ DEFAULT_GRABBER_COLOR_DARK,
46
+ DEFAULT_GRABBER_COLOR_LIGHT,
47
+ DEFAULT_GRABBER_HEIGHT,
48
+ DEFAULT_GRABBER_TOP_MARGIN,
49
+ DEFAULT_GRABBER_WIDTH,
50
+ DEFAULT_FORM_SHEET_HEIGHT_RATIO,
51
+ DEFAULT_FORM_SHEET_WIDTH,
52
+ DEFAULT_MAX_WIDTH,
53
+ } from './web/constants';
54
+
55
+ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props, ref) => {
88
56
  const {
57
+ children,
89
58
  name,
90
- detents = [0.5, 1],
91
59
  dismissible = true,
92
60
  draggable = true,
93
- dimmed = true,
94
- dimmedDetentIndex = 0,
95
- children,
96
- scrollable = false,
97
- initialDetentIndex = -1,
61
+ cornerRadius,
62
+ style,
98
63
  backgroundColor: backgroundColorProp,
99
- cornerRadius = DEFAULT_CORNER_RADIUS,
100
- elevation = DEFAULT_ELEVATION,
101
- grabber = true,
102
- grabberOptions,
103
64
  maxContentHeight,
104
65
  maxContentWidth,
105
66
  anchor = 'center',
106
67
  anchorOffset = DEFAULT_ANCHOR_OFFSET,
68
+ grabber = true,
69
+ grabberOptions,
70
+ detents = [0.5, 1],
71
+ dimmed = true,
72
+ dimmedDetentIndex = 0,
73
+ initialDetentIndex = -1,
107
74
  header,
108
75
  headerStyle,
109
76
  footer,
110
77
  footerStyle,
111
- onMount,
78
+ scrollable = false,
79
+ scrollableOptions,
80
+ pageSizing = true,
81
+ detached = false,
82
+ detachedOffset = DEFAULT_DETACHED_OFFSET,
83
+ elevation = 4,
84
+ insetAdjustment = 'automatic',
85
+ initialDetentAnimated = true,
86
+ onPositionChange,
112
87
  onWillPresent,
113
88
  onDidPresent,
114
89
  onWillDismiss,
115
90
  onDidDismiss,
116
91
  onDetentChange,
117
- onPositionChange,
118
92
  onDragBegin,
119
93
  onDragChange,
120
94
  onDragEnd,
95
+ onMount,
121
96
  onWillFocus,
122
97
  onDidFocus,
123
98
  onWillBlur,
124
99
  onDidBlur,
125
- detached,
126
- detachedOffset = DEFAULT_DETACHED_OFFSET,
127
- stackBehavior = 'switch',
128
- style,
129
100
  } = props;
130
101
 
102
+ const validDetents = useMemo(
103
+ () => detents.filter((d): d is SheetDetent => typeof d === 'number' || d === 'auto'),
104
+ [detents]
105
+ );
106
+
107
+ const snapPointsProps = useMemo<
108
+ { snapPoints: SheetDetent[]; fadeFromIndex: number } | { snapPoints?: undefined }
109
+ >(() => {
110
+ if (validDetents.length === 0) return {};
111
+ return {
112
+ snapPoints: validDetents,
113
+ fadeFromIndex: Math.min(dimmedDetentIndex, validDetents.length - 1),
114
+ };
115
+ }, [validDetents, dimmedDetentIndex]);
116
+
117
+ const { width: windowWidth, height: windowHeight } = useWindowDimensions();
118
+ const isLandscapeOrTablet = windowWidth >= 600 || windowWidth > windowHeight;
119
+
131
120
  const colorScheme = useColorScheme();
132
121
  const backgroundColor =
133
122
  backgroundColorProp ??
134
123
  (colorScheme === 'dark' ? COLOR_SURFACE_CONTAINER_LOW_DARK : COLOR_SURFACE_CONTAINER_LOW_LIGHT);
135
124
 
136
- const { width: windowWidth, height: windowHeight } = useWindowDimensions();
137
- const isLandscapeOrTablet = windowWidth >= 600 || windowWidth > windowHeight;
138
- const defaultName = useId();
139
- const sheetName = name ?? defaultName;
140
- const bottomSheetContext = useContext(BottomSheetContext);
141
- const bottomSheetModalRef = useRef<BottomSheetModal>(null);
142
- const bottomSheetRef = useRef<BottomSheet>(null);
143
- const initialDetentIndexRef = useRef(initialDetentIndex);
144
- const currentIndexRef = useRef(0);
145
- const isPresenting = useRef(false);
146
- const isDismissing = useRef(false);
147
- const isMinimized = useRef(false);
148
- const isDragging = useRef(false);
149
-
150
- const presentResolver = useRef<(() => void) | null>(null);
151
- const dismissResolver = useRef<(() => void) | null>(null);
152
-
153
- const animatedPosition = useSharedValue(windowHeight);
154
- const animatedIndex = useSharedValue(0);
155
-
156
- const [snapIndex, setSnapIndex] = useState(initialDetentIndex);
157
- const [isMounted, setIsMounted] = useState(false);
158
-
159
- const isNonModal = stackBehavior === 'none';
160
-
161
- useDerivedValue(() => {
162
- onPositionChange?.({
163
- nativeEvent: {
164
- position: animatedPosition.value,
165
- index: animatedIndex.value,
166
- detent: detents[animatedIndex.value] ?? 0,
167
- realtime: true,
168
- },
169
- } as PositionChangeEvent);
170
- });
125
+ const shouldAutoPresent = initialDetentIndex >= 0 && initialDetentIndex < validDetents.length;
126
+ const [isOpen, setIsOpen] = useState(shouldAutoPresent);
127
+ const [activeSnapPoint, setActiveSnapPoint] = useState<SheetDetent | null>(
128
+ () => validDetents[shouldAutoPresent ? initialDetentIndex : 0] ?? null
129
+ );
171
130
 
172
- const hasAutoDetent = detents.includes('auto');
131
+ // Keep activeSnapPoint valid if detents change (e.g., prop updates).
132
+ useEffect(() => {
133
+ if (validDetents.length === 0) return;
134
+ setActiveSnapPoint((current) =>
135
+ current != null && validDetents.includes(current) ? current : validDetents[0]!
136
+ );
137
+ }, [validDetents]);
138
+
139
+ const validDetentsRef = useRef(validDetents);
140
+ validDetentsRef.current = validDetents;
141
+
142
+ const handleSetActiveSnapPoint = useCallback((snapPoint: number | string | null) => {
143
+ setActiveSnapPoint(
144
+ snapPoint == null
145
+ ? null
146
+ : typeof snapPoint === 'number' || snapPoint === 'auto'
147
+ ? (snapPoint as SheetDetent)
148
+ : null
149
+ );
150
+ }, []);
173
151
 
174
- const containerHeight = maxContentHeight ?? windowHeight;
175
- const snapPoints = useMemo(
176
- () =>
177
- detents
178
- .filter((detent): detent is number => detent !== 'auto' && typeof detent === 'number')
179
- .map((detent) => Math.min(1, Math.max(0.1, detent)) * containerHeight),
180
- [detents, containerHeight]
152
+ const handleOpenChange = useCallback(
153
+ (open: boolean) => {
154
+ if (!open && isOpen) {
155
+ setIsOpen(false);
156
+ }
157
+ },
158
+ [isOpen]
181
159
  );
182
160
 
183
- const handleChange = useCallback(
184
- (index: number, _position: number, _type: SNAP_POINT_TYPE) => {
185
- const previousIndex = currentIndexRef.current;
186
- currentIndexRef.current = index;
187
-
188
- // Handle drag end
189
- if (isDragging.current && !isPresenting.current) {
190
- isDragging.current = false;
191
- onDragEnd?.({
192
- nativeEvent: {
193
- index,
194
- position: animatedPosition.value,
195
- detent: detents[index] ?? 0,
196
- },
197
- } as DragEndEvent);
198
- }
161
+ const portalContainer = usePortalContainer();
199
162
 
200
- if (!isPresenting.current && !isMinimized.current && previousIndex !== index && index >= 0) {
201
- onDetentChange?.({
202
- nativeEvent: {
203
- index,
204
- position: animatedPosition.value,
205
- detent: detents[index] ?? 0,
206
- },
207
- } as DetentChangeEvent);
208
- }
163
+ const handlePointerDownOutside = (e: Event) => {
164
+ const target = e.target;
165
+ if (!(target instanceof Node)) return;
166
+ // Pointer down that landed outside this sheet's portal container (e.g.,
167
+ // in another screen's tree when navigating) should not close the drawer.
168
+ if (portalContainer && !portalContainer.contains(target)) {
169
+ e.preventDefault();
170
+ }
171
+ };
209
172
 
210
- if (isPresenting.current) {
211
- isPresenting.current = false;
173
+ const dismissAboveRef = useRef<(animated?: boolean) => Promise<void>>(async () => {});
212
174
 
213
- // Resolve present promise
214
- if (presentResolver.current) {
215
- presentResolver.current();
216
- presentResolver.current = null;
175
+ const methods = useMemo<TrueSheetMethods>(
176
+ () => ({
177
+ present: async (index = 0) => {
178
+ const detent = validDetentsRef.current[index];
179
+ if (detent === undefined) {
180
+ throw new Error(
181
+ `TrueSheet: present index (${index}) is out of bounds. detents array has ${validDetentsRef.current.length} item(s)`
182
+ );
217
183
  }
184
+ setActiveSnapPoint(detent);
185
+ setIsOpen(true);
186
+ },
187
+ dismiss: async () => {
188
+ setIsOpen(false);
189
+ },
190
+ resize: async (index) => {
191
+ const detent = validDetentsRef.current[index];
192
+ if (detent === undefined) {
193
+ throw new Error(
194
+ `TrueSheet: resize index (${index}) is out of bounds. detents array has ${validDetentsRef.current.length} item(s)`
195
+ );
196
+ }
197
+ setActiveSnapPoint(detent);
198
+ },
199
+ dismissStack: async (animated) => {
200
+ await dismissAboveRef.current(animated);
201
+ },
202
+ }),
203
+ []
204
+ );
205
+
206
+ useImperativeHandle(ref, () => methods, [methods]);
207
+
208
+ const methodsRef = useRef<TrueSheetMethods | null>(methods);
209
+ useRegisterSheet(name, methodsRef);
210
+
211
+ const drawerContentRef = useRef<HTMLDivElement | null>(null);
212
+
213
+ // Present/dismiss events. The sheet settles via a CSS `transform` transition
214
+ // on either the drawer (snap-points on autopresent) or the wrapper (whole-
215
+ // card slide on reopen/dismiss). `Animation.finished` from the Web Animations
216
+ // API tracks whichever is actually running — reflects what the browser is
217
+ // doing, doesn't miss when no transition runs (same-value change), and
218
+ // handles interruptions correctly (a drag/resnap mid-present resolves only
219
+ // once all transform animations drain).
220
+ const onWillPresentRef = useRef(onWillPresent);
221
+ const onDidPresentRef = useRef(onDidPresent);
222
+ const onWillDismissRef = useRef(onWillDismiss);
223
+ const onDidDismissRef = useRef(onDidDismiss);
224
+ const onDetentChangeRef = useRef(onDetentChange);
225
+ const onDragBeginRef = useRef(onDragBegin);
226
+ const onDragChangeRef = useRef(onDragChange);
227
+ const onDragEndRef = useRef(onDragEnd);
228
+ const onPositionChangeRef = useRef(onPositionChange);
229
+ const activeSnapPointRef = useRef(activeSnapPoint);
230
+ useEffect(() => {
231
+ onWillPresentRef.current = onWillPresent;
232
+ onDidPresentRef.current = onDidPresent;
233
+ onWillDismissRef.current = onWillDismiss;
234
+ onDidDismissRef.current = onDidDismiss;
235
+ onDetentChangeRef.current = onDetentChange;
236
+ onDragBeginRef.current = onDragBegin;
237
+ onDragChangeRef.current = onDragChange;
238
+ onDragEndRef.current = onDragEnd;
239
+ onPositionChangeRef.current = onPositionChange;
240
+ activeSnapPointRef.current = activeSnapPoint;
241
+ });
218
242
 
219
- onDidPresent?.({
220
- nativeEvent: {
221
- index,
222
- position: animatedPosition.value,
223
- detent: detents[index] ?? 0,
224
- },
225
- } as DidPresentEvent);
243
+ const computeDetentInfo = useCallback((): DetentInfoEventPayload => {
244
+ const snap = activeSnapPointRef.current;
245
+ const index = snap != null ? validDetentsRef.current.indexOf(snap) : -1;
246
+ const position = drawerContentRef.current?.getBoundingClientRect().top ?? 0;
247
+ const detent = typeof snap === 'number' ? snap : 0;
248
+ return { index, position, detent };
249
+ }, []);
226
250
 
227
- onDidFocus?.({ nativeEvent: null } as DidFocusEvent);
251
+ // Mirror Android: interpolate fractional index and detent from the drawer's
252
+ // top-Y so continuous position updates (drag, animation) carry smooth values
253
+ // between detent boundaries. Numeric detent d → top-Y = (1 - d) * effectiveH.
254
+ // 'auto' resolves to the [data-vaul-auto-size-wrapper] element's measured
255
+ // offsetHeight — same signal vaul uses to compute its snap offset.
256
+ const interpolateFromPosition = useCallback(
257
+ (position: number): { index: number; detent: number } => {
258
+ const snaps = validDetentsRef.current;
259
+ const count = snaps.length;
260
+ if (count === 0) return { index: -1, detent: 0 };
261
+
262
+ const windowH = window.innerHeight;
263
+ const effectiveH = detached ? windowH - detachedOffset : windowH;
264
+ // Matches vaul's height ceiling: min(effectiveH, maxContentHeight).
265
+ const ceiling =
266
+ maxContentHeight !== undefined ? Math.min(effectiveH, maxContentHeight) : effectiveH;
267
+
268
+ const autoWrapper = drawerContentRef.current?.querySelector<HTMLElement>(
269
+ '[data-vaul-auto-size-wrapper]'
270
+ );
271
+ const autoHeight = Math.min(autoWrapper?.offsetHeight ?? ceiling / 2, ceiling);
272
+
273
+ const positions: number[] = [];
274
+ const values: number[] = [];
275
+ for (let i = 0; i < count; i++) {
276
+ const d = snaps[i];
277
+ if (typeof d === 'number') {
278
+ const h = Math.min(d * effectiveH, ceiling);
279
+ positions.push(effectiveH - h);
280
+ values.push(effectiveH > 0 ? h / effectiveH : 0);
281
+ } else {
282
+ positions.push(effectiveH - autoHeight);
283
+ values.push(effectiveH > 0 ? autoHeight / effectiveH : 0);
284
+ }
228
285
  }
229
286
 
230
- // Fire onDidBlur when sheet reaches minimized state (index -1 but still mounted)
231
- if (isMinimized.current && index === -1) {
232
- onDidBlur?.({ nativeEvent: null } as DidBlurEvent);
287
+ // Absorb subpixel drift from getBoundingClientRect so at-rest positions
288
+ // don't sneak into the below-first branch and emit near-zero negatives
289
+ // like `-1e-8` (which render as "-1" via JS scientific-notation toString).
290
+ const epsilon = 0.5;
291
+ const firstPos = positions[0]!;
292
+ const lastPos = positions[count - 1]!;
293
+
294
+ if (position > firstPos + epsilon) {
295
+ // Two ranges: index spans the full animation (windowH of wrapper
296
+ // travel) so it's smooth for driving dependent animations end-to-end;
297
+ // detent tracks the sheet's visible-height ratio (windowH - firstPos)
298
+ // so its 0–values[0] fade has fine resolution while the sheet is still
299
+ // in view. Both clamp to keep outputs in [-1, 0].
300
+ const indexRaw = (position - firstPos) / windowH;
301
+ const detentRaw = (position - firstPos) / Math.max(1, windowH - firstPos);
302
+ const indexProgress = Math.max(0, Math.min(1, indexRaw));
303
+ const detentProgress = Math.max(0, Math.min(1, detentRaw));
304
+ return {
305
+ index: -indexProgress,
306
+ detent: Math.max(0, values[0]! * (1 - detentProgress)),
307
+ };
233
308
  }
234
309
 
235
- // Fire onDidFocus when sheet is restored from minimized state
236
- if (isMinimized.current && index >= 0) {
237
- isMinimized.current = false;
238
- onDidFocus?.({ nativeEvent: null } as DidFocusEvent);
310
+ if (count === 1) return { index: 0, detent: values[0]! };
311
+
312
+ // Clamp into the segment range so subpixel drift at the boundaries
313
+ // resolves cleanly to the nearest segment edge (index 0 or count-1).
314
+ const clamped = Math.max(lastPos, Math.min(firstPos, position));
315
+
316
+ for (let i = 0; i < count - 1; i++) {
317
+ const pos = positions[i]!;
318
+ const nextPos = positions[i + 1]!;
319
+ if (clamped >= nextPos && clamped <= pos) {
320
+ const range = pos - nextPos;
321
+ const progress = range > 0 ? (pos - clamped) / range : 0;
322
+ const clampedProgress = Math.max(0, Math.min(1, progress));
323
+ return {
324
+ index: i + clampedProgress,
325
+ detent: values[i]! + clampedProgress * (values[i + 1]! - values[i]!),
326
+ };
327
+ }
239
328
  }
329
+
330
+ return { index: count - 1, detent: values[count - 1]! };
240
331
  },
241
- [detents, animatedPosition]
332
+ [detached, detachedOffset, maxContentHeight]
242
333
  );
243
334
 
244
- const handleDismiss = useCallback(() => {
245
- // Remove from stack when dismissed
246
- bottomSheetContext?.removeFromStack(sheetName);
335
+ const handlePositionChange = useCallback(
336
+ (position: number) => {
337
+ const { index, detent } = interpolateFromPosition(position);
338
+ onPositionChangeRef.current?.({
339
+ nativeEvent: { index, position, detent, realtime: true },
340
+ } as PositionChangeEvent);
341
+ },
342
+ [interpolateFromPosition]
343
+ );
247
344
 
248
- // Resolve dismiss promise
249
- if (dismissResolver.current) {
250
- dismissResolver.current();
251
- dismissResolver.current = null;
345
+ // Fire onMount once after first render. React-mount is the earliest point
346
+ // the component is ready for imperative calls, matching the native
347
+ // "ready for present" contract. Declared before the present/dismiss effect
348
+ // so it fires first during autopresent (onMount → onWillPresent).
349
+ const onMountRef = useRef(onMount);
350
+ useEffect(() => {
351
+ onMountRef.current = onMount;
352
+ });
353
+ useEffect(() => {
354
+ onMountRef.current?.({ nativeEvent: null } as MountEvent);
355
+ }, []);
356
+
357
+ // Start at `false` so a mount with `isOpen=true` (autopresent via
358
+ // `initialDetentIndex`) is detected as a false→true transition and fires
359
+ // `onWillPresent`.
360
+ const wasOpenRef = useRef(false);
361
+ useEffect(() => {
362
+ const wasOpen = wasOpenRef.current;
363
+ wasOpenRef.current = isOpen;
364
+
365
+ if (!isOpen && !wasOpen) return undefined;
366
+
367
+ const present = !wasOpen && isOpen;
368
+ if (present) {
369
+ onWillPresentRef.current?.({ nativeEvent: computeDetentInfo() } as WillPresentEvent);
370
+ } else if (wasOpen && !isOpen) {
371
+ onWillDismissRef.current?.({ nativeEvent: null } as WillDismissEvent);
372
+ } else {
373
+ return undefined;
252
374
  }
253
375
 
254
- onDidDismiss?.({ nativeEvent: null } as DidDismissEvent);
255
-
256
- // Reset states since sheet is being dismissed
257
- isMinimized.current = false;
258
- isDismissing.current = false;
259
- isDragging.current = false;
260
- }, [sheetName]);
261
-
262
- const handleAnimate = useCallback(
263
- (_fromIndex: number, toIndex: number) => {
264
- // Detect drag begin (when not presenting or dismissing)
265
- if (!isPresenting.current && !isDismissing.current && !isDragging.current && toIndex >= 0) {
266
- isDragging.current = true;
267
- onDragBegin?.({
268
- nativeEvent: {
269
- index: currentIndexRef.current,
270
- position: animatedPosition.value,
271
- detent: detents[currentIndexRef.current] ?? 0,
272
- },
273
- } as DragBeginEvent);
376
+ const fireDone = () => {
377
+ if (present) {
378
+ onDidPresentRef.current?.({ nativeEvent: computeDetentInfo() } as DidPresentEvent);
379
+ } else {
380
+ onDidDismissRef.current?.({ nativeEvent: null } as DidDismissEvent);
274
381
  }
382
+ };
275
383
 
276
- // Drag change during animation
277
- if (isDragging.current && toIndex >= 0) {
278
- onDragChange?.({
279
- nativeEvent: {
280
- index: toIndex,
281
- position: animatedPosition.value,
282
- detent: detents[toIndex] ?? 0,
283
- },
284
- } as DragChangeEvent);
384
+ let canceled = false;
385
+ let rafId = 0;
386
+
387
+ const start = () => {
388
+ if (canceled) return;
389
+ const drawer = drawerContentRef.current;
390
+ if (!drawer) {
391
+ // Drawer hasn't mounted yet (Radix Presence defers the portal mount
392
+ // past the first effect pass). Poll until the ref is populated.
393
+ rafId = window.requestAnimationFrame(start);
394
+ return;
285
395
  }
286
-
287
- if (isPresenting.current) {
288
- // Fire onMount on first present (before willPresent, matching native)
289
- if (!isMounted) {
290
- setIsMounted(true);
291
- onMount?.({ nativeEvent: null } as MountEvent);
396
+ const wrapper = drawer.closest<HTMLElement>('[data-vaul-detached-wrapper]') ?? null;
397
+ const targets = wrapper ? [drawer, wrapper] : [drawer];
398
+
399
+ const waitForSettle = (): void => {
400
+ if (canceled) return;
401
+ // Force style recalc so transitions queued by vaul's effects this
402
+ // commit are registered in `getAnimations()`. RAF callbacks run BEFORE
403
+ // the frame's style recalc, and ignoring this returns a stale empty
404
+ // list — we'd fire `did` immediately with nothing queued.
405
+
406
+ drawer.offsetHeight;
407
+ const pending = targets.flatMap((el) =>
408
+ el.getAnimations().filter((a) => a.playState !== 'finished')
409
+ );
410
+ if (pending.length === 0) {
411
+ fireDone();
412
+ return;
292
413
  }
414
+ // allSettled: resolve even when a transition is canceled (drag /
415
+ // resnap), then re-check — a replacement transition may have started.
416
+ Promise.allSettled(pending.map((a) => a.finished)).then(() => {
417
+ if (!canceled) waitForSettle();
418
+ });
419
+ };
420
+
421
+ waitForSettle();
422
+ };
293
423
 
294
- onWillPresent?.({
295
- nativeEvent: {
296
- index: toIndex,
297
- position: animatedPosition.value,
298
- detent: detents[toIndex] ?? 0,
299
- },
300
- } as WillPresentEvent);
424
+ rafId = window.requestAnimationFrame(start);
301
425
 
302
- // Focus events fire together with present events
303
- onWillFocus?.({ nativeEvent: null } as WillFocusEvent);
304
- }
426
+ return () => {
427
+ canceled = true;
428
+ window.cancelAnimationFrame(rafId);
429
+ };
430
+ }, [isOpen, computeDetentInfo]);
305
431
 
306
- // Detect if sheet is being restored (will focus)
307
- if (isMinimized.current && toIndex >= 0) {
308
- onWillFocus?.({ nativeEvent: null } as WillFocusEvent);
309
- }
432
+ // Fire onDetentChange only while open→open. Present/dismiss have their own
433
+ // events and carry detent info via onDidPresent, so we skip those edges.
434
+ const detentChangeStateRef = useRef({ isOpen, activeSnapPoint });
435
+ useEffect(() => {
436
+ const prev = detentChangeStateRef.current;
437
+ detentChangeStateRef.current = { isOpen, activeSnapPoint };
438
+ if (!prev.isOpen || !isOpen) return;
439
+ if (prev.activeSnapPoint === activeSnapPoint) return;
440
+ onDetentChangeRef.current?.({ nativeEvent: computeDetentInfo() } as DetentChangeEvent);
441
+ }, [isOpen, activeSnapPoint, computeDetentInfo]);
442
+
443
+ // Vaul's `onDrag` fires once per pointermove while dragging; the first tick
444
+ // after an idle gap marks the drag boundary, so track it via a ref.
445
+ const isDraggingRef = useRef(false);
446
+ const handleDrag = useCallback(() => {
447
+ if (!isDraggingRef.current) {
448
+ isDraggingRef.current = true;
449
+ onDragBeginRef.current?.({ nativeEvent: computeDetentInfo() } as DragBeginEvent);
450
+ }
451
+ onDragChangeRef.current?.({ nativeEvent: computeDetentInfo() } as DragChangeEvent);
452
+ }, [computeDetentInfo]);
453
+ const handleRelease = useCallback(() => {
454
+ if (!isDraggingRef.current) return;
455
+ isDraggingRef.current = false;
456
+ onDragEndRef.current?.({ nativeEvent: computeDetentInfo() } as DragEndEvent);
457
+ }, [computeDetentInfo]);
458
+
459
+ const { isNested, dismissAbove, descendants } = useSheetStack(
460
+ methodsRef,
461
+ drawerContentRef,
462
+ isOpen
463
+ );
464
+ dismissAboveRef.current = dismissAbove;
310
465
 
311
- if (toIndex === -1 && !isPresenting.current) {
312
- isMinimized.current = true;
313
- onWillBlur?.({ nativeEvent: null } as WillBlurEvent);
466
+ // Mirror Android: translate this sheet down to match the deepest descendant's
467
+ // top so the whole stack visually aligns. Cascades because every ancestor
468
+ // re-runs whenever the stack (and thus its descendants) changes.
469
+ useEffect(() => {
470
+ const parent = drawerContentRef.current;
471
+ if (!parent) return;
314
472
 
315
- // Only fire willDismiss if this is an actual dismiss (not being backgrounded by another sheet)
316
- const sheetsAbove = bottomSheetContext?.getSheetsAbove(sheetName) ?? [];
317
- if (sheetsAbove.length === 0) {
318
- onWillDismiss?.({ nativeEvent: null } as WillDismissEvent);
319
- }
473
+ const transition = `transform ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`;
474
+
475
+ if (descendants.length === 0) {
476
+ parent.style.transition = transition;
477
+ parent.style.transform = '';
478
+ return;
479
+ }
480
+
481
+ const computeTargetY = () => {
482
+ const parentSnap = parseFloat(parent.style.getPropertyValue('--snap-point-height')) || 0;
483
+ let targetY = parentSnap;
484
+ for (const d of descendants) {
485
+ const node = d.nodeRef.current;
486
+ if (!node) continue;
487
+ const snap = parseFloat(node.style.getPropertyValue('--snap-point-height')) || 0;
488
+ if (snap > targetY) targetY = snap;
320
489
  }
321
- },
322
- [detents, animatedPosition, sheetName, bottomSheetContext, isMounted, onMount]
323
- );
490
+ return targetY;
491
+ };
492
+
493
+ const apply = () => {
494
+ const targetY = computeTargetY();
495
+ const match = parent.style.transform.match(/translate3d\([^,]*,\s*(-?\d*\.?\d+)px/);
496
+ const currentY = match ? parseFloat(match[1]!) : 0;
497
+ if (Math.abs(currentY - targetY) < 0.5) return;
498
+ parent.style.transition = transition;
499
+ parent.style.transform = `translate3d(0, ${targetY}px, 0)`;
500
+ };
501
+
502
+ const raf = requestAnimationFrame(apply);
503
+ // Vaul re-runs snapToPoint on window resize (e.g., mobile keyboard open)
504
+ // which clobbers the cascade transform. Re-apply whenever the parent's
505
+ // inline style changes.
506
+ const observer = new MutationObserver(apply);
507
+ observer.observe(parent, { attributes: true, attributeFilter: ['style'] });
508
+
509
+ return () => {
510
+ cancelAnimationFrame(raf);
511
+ observer.disconnect();
512
+ };
513
+ }, [descendants, activeSnapPoint]);
514
+
515
+ // Focus/blur events fire when a descendant sheet appears on top of this one
516
+ // (blur) or when all descendants are dismissed (focus). will-events fire
517
+ // synchronously at the transition boundary; did-events fire once the cascade
518
+ // transform drains. Intermediate count changes (1↔2) don't re-fire — this
519
+ // sheet stays blurred throughout.
520
+ const onWillBlurRef = useRef(onWillBlur);
521
+ const onDidBlurRef = useRef(onDidBlur);
522
+ const onWillFocusRef = useRef(onWillFocus);
523
+ const onDidFocusRef = useRef(onDidFocus);
524
+ useEffect(() => {
525
+ onWillBlurRef.current = onWillBlur;
526
+ onDidBlurRef.current = onDidBlur;
527
+ onWillFocusRef.current = onWillFocus;
528
+ onDidFocusRef.current = onDidFocus;
529
+ });
324
530
 
325
- const backdropComponent = useCallback(
326
- (backdropProps: BottomSheetBackdropProps) => {
327
- if (!dimmed) {
328
- return null;
531
+ const prevDescendantCountRef = useRef(0);
532
+ useEffect(() => {
533
+ const prevCount = prevDescendantCountRef.current;
534
+ const count = descendants.length;
535
+ prevDescendantCountRef.current = count;
536
+ if (!isOpen) return;
537
+ const gained = count > 0 && prevCount === 0;
538
+ const lost = count === 0 && prevCount > 0;
539
+ if (!gained && !lost) return;
540
+
541
+ if (gained) {
542
+ onWillBlurRef.current?.({ nativeEvent: null } as WillBlurEvent);
543
+ } else {
544
+ onWillFocusRef.current?.({ nativeEvent: null } as WillFocusEvent);
545
+ }
546
+
547
+ const drawer = drawerContentRef.current;
548
+ if (!drawer) return;
549
+
550
+ let canceled = false;
551
+ const fireDone = () => {
552
+ if (canceled) return;
553
+ if (gained) onDidBlurRef.current?.({ nativeEvent: null } as DidBlurEvent);
554
+ else onDidFocusRef.current?.({ nativeEvent: null } as DidFocusEvent);
555
+ };
556
+ const rafId = window.requestAnimationFrame(() => {
557
+ if (canceled) return;
558
+ // Force style recalc so the cascade effect's queued transform registers.
559
+
560
+ drawer.offsetHeight;
561
+ const pending = drawer.getAnimations().filter((a) => a.playState !== 'finished');
562
+ if (pending.length === 0) {
563
+ fireDone();
564
+ return;
329
565
  }
566
+ Promise.allSettled(pending.map((a) => a.finished)).then(() => {
567
+ if (!canceled) fireDone();
568
+ });
569
+ });
330
570
 
331
- // When not dismissible, collapse to below dimmed index instead of dismissing
332
- const pressBehavior = dismissible
333
- ? 'close'
334
- : dimmedDetentIndex > 0
335
- ? dimmedDetentIndex - 1
336
- : 'none';
337
-
338
- return (
339
- <BottomSheetBackdrop
340
- {...backdropProps}
341
- opacity={0.5}
342
- appearsOnIndex={dimmedDetentIndex}
343
- disappearsOnIndex={dimmedDetentIndex - 1}
344
- pressBehavior={pressBehavior}
345
- />
346
- );
347
- },
348
- [dimmed, dimmedDetentIndex, dismissible]
349
- );
571
+ return () => {
572
+ canceled = true;
573
+ window.cancelAnimationFrame(rafId);
574
+ };
575
+ }, [isOpen, descendants.length]);
576
+
577
+ const effectiveCornerRadius = cornerRadius ?? DEFAULT_CORNER_RADIUS;
350
578
 
351
- const indicatorHeight = grabberOptions?.height ?? DEFAULT_GRABBER_HEIGHT;
579
+ // Shadow cast upward from the sheet's top edge toward the background. Matches
580
+ // Android's `elevation` semantics roughly — the sheet "lifts" off whatever is
581
+ // behind it. Scales linearly so higher elevation reads as more separation.
582
+ const boxShadow =
583
+ elevation > 0 ? `0 ${-elevation}px ${elevation * 3}px rgba(0, 0, 0, 0.15)` : undefined;
352
584
 
353
- const handleStyle = useMemo(
354
- () =>
355
- grabber
356
- ? [styles.handle, { paddingTop: grabberOptions?.topMargin }]
357
- : { display: 'none' as const },
358
- [grabber, grabberOptions?.topMargin]
585
+ const mergedContentStyle = useMemo<React.CSSProperties>(
586
+ () => ({
587
+ position: 'fixed',
588
+ top: 0,
589
+ left: 0,
590
+ right: 0,
591
+ bottom: 0,
592
+ display: 'flex',
593
+ flexDirection: 'column',
594
+ borderTopLeftRadius: effectiveCornerRadius,
595
+ borderTopRightRadius: effectiveCornerRadius,
596
+ backgroundColor: backgroundColor as string,
597
+ boxShadow,
598
+ // Lift content above iOS home indicator / bottom safe area when enabled.
599
+ paddingBottom: insetAdjustment === 'automatic' ? 'env(safe-area-inset-bottom, 0px)' : 0,
600
+ }),
601
+ [backgroundColor, effectiveCornerRadius, boxShadow, insetAdjustment]
359
602
  );
360
603
 
361
604
  const defaultGrabberColor =
362
605
  colorScheme === 'dark' ? DEFAULT_GRABBER_COLOR_DARK : DEFAULT_GRABBER_COLOR_LIGHT;
363
606
 
364
- const handleIndicatorStyle = useMemo(
607
+ const grabberHeight = grabberOptions?.height ?? DEFAULT_GRABBER_HEIGHT;
608
+
609
+ // Footer is rendered inside the wrapper via `detachedSiblings`, so it
610
+ // follows the wrapper on dismiss and drag-overshoot. Positioning is
611
+ // relative to the wrapper (contain: paint creates the containing block).
612
+ const footerFloatStyle = useMemo<React.CSSProperties>(
365
613
  () => ({
366
- height: indicatorHeight,
367
- borderRadius: grabberOptions?.cornerRadius ?? indicatorHeight / 2,
368
- width: grabberOptions?.width ?? DEFAULT_GRABBER_WIDTH,
369
- backgroundColor: grabberOptions?.color ?? defaultGrabberColor,
614
+ position: 'fixed',
615
+ left: 0,
616
+ right: 0,
617
+ bottom: 0,
618
+ // Wrapper has `pointer-events: none` to let clicks fall through; the
619
+ // footer must opt back in.
620
+ pointerEvents: 'auto',
370
621
  }),
371
- [grabberOptions, indicatorHeight, defaultGrabberColor]
372
- );
373
-
374
- const footerComponent = useMemo(
375
- () =>
376
- footer
377
- ? (footerProps: BottomSheetFooterProps) => (
378
- <BottomSheetFooter
379
- style={StyleSheet.flatten([styles.footer, footerStyle])}
380
- {...footerProps}
381
- >
382
- {renderSlot(footer)}
383
- </BottomSheetFooter>
384
- )
385
- : undefined,
386
- [footer, footerStyle]
622
+ []
387
623
  );
388
624
 
389
- // For scrollable, we render the child directly
390
- const ContainerComponent = scrollable ? Fragment : BottomSheetView;
391
-
392
- const sheetMethodsRef = useRef<TrueSheetRefMethods>({
393
- present: (index = 0) => {
394
- return new Promise<void>((resolve) => {
395
- presentResolver.current = resolve;
396
- setSnapIndex(index);
397
- isPresenting.current = true;
398
- if (isNonModal) {
399
- bottomSheetRef.current?.snapToIndex(index);
400
- } else {
401
- bottomSheetContext?.pushToStack(sheetName);
402
- bottomSheetModalRef.current?.present();
403
- }
404
- });
405
- },
406
- dismiss: () => {
407
- return new Promise<void>((resolve) => {
408
- dismissResolver.current = resolve;
409
- isDismissing.current = true;
410
- if (isNonModal) {
411
- bottomSheetRef.current?.close();
412
- } else {
413
- bottomSheetModalRef.current?.dismiss();
414
- }
415
- });
416
- },
417
- dismissStack: () => {
418
- return new Promise<void>((resolve) => {
419
- // Dismiss only sheets above, keeping this sheet presented
420
- const sheetsAbove = bottomSheetContext?.getSheetsAbove(sheetName) ?? [];
421
- const immediateChild = sheetsAbove[sheetsAbove.length - 1];
422
- if (immediateChild) {
423
- // Dismiss the immediate child - gorhom will dismiss all sheets above it
424
- bottomSheetContext?.dismiss(immediateChild).then(resolve);
425
- return;
426
- }
427
-
428
- resolve();
429
- });
430
- },
431
- resize: async (index: number) => {
432
- if (isNonModal) {
433
- bottomSheetRef.current?.snapToIndex(index);
434
- } else {
435
- bottomSheetModalRef.current?.snapToIndex(index);
436
- }
437
- },
438
- });
439
-
440
- useImperativeHandle(ref, () => sheetMethodsRef.current);
441
-
442
- // Register with context provider
443
- useEffect(() => {
444
- bottomSheetContext?.register(sheetName, sheetMethodsRef);
445
- return () => {
446
- bottomSheetContext?.unregister(sheetName);
447
- };
448
- }, [sheetName]);
449
-
450
- // Auto-present on mount if initialDetentIndex is set
451
- useEffect(() => {
452
- if (initialDetentIndexRef.current >= 0) {
453
- sheetMethodsRef.current.present(initialDetentIndexRef.current);
625
+ // Form-sheet style (iOS pageSizing=false): centered floating card with a
626
+ // default width and a height capped to a fraction of the window. We reuse
627
+ // the existing detached mechanic so drag/snap math stays correct — the
628
+ // wrapper is bottom-attached with a computed offset that centers it
629
+ // vertically.
630
+ const isFormSheet = isLandscapeOrTablet && maxContentWidth == null && !pageSizing;
631
+
632
+ const effectiveMaxContentHeight =
633
+ maxContentHeight ?? (isFormSheet ? windowHeight * DEFAULT_FORM_SHEET_HEIGHT_RATIO : undefined);
634
+
635
+ const effectiveDetached = isFormSheet || detached;
636
+
637
+ const effectiveDetachedOffset = isFormSheet
638
+ ? Math.max(0, (windowHeight - (effectiveMaxContentHeight ?? 0)) / 2)
639
+ : detachedOffset;
640
+
641
+ // The wrapper holds all horizontal sizing/anchoring so its rounded-bottom
642
+ // clip (when detached) aligns with the drawer's horizontal bounds on
643
+ // desktop otherwise its corners sit at the far viewport edges.
644
+ // Mirrors iOS setupSheetSizing: maxContentWidth wins (forces pageSizing
645
+ // off). pageSizing on → constrain to readable width (page-sheet);
646
+ // pageSizing off with no maxContentWidth → form-sheet width.
647
+ // Detached without a width constraint applies anchorOffset on both edges so
648
+ // the floating card breathes from the viewport sides.
649
+ const wrapperStyle = useMemo<React.CSSProperties | undefined>(() => {
650
+ const maxWidth = isLandscapeOrTablet
651
+ ? isFormSheet
652
+ ? DEFAULT_FORM_SHEET_WIDTH
653
+ : (maxContentWidth ?? (pageSizing ? DEFAULT_MAX_WIDTH : undefined))
654
+ : undefined;
655
+
656
+ if (maxWidth == null && !detached) return undefined;
657
+
658
+ let marginLeft: number | string;
659
+ let marginRight: number | string;
660
+ if (isFormSheet) {
661
+ marginLeft = 'auto';
662
+ marginRight = 'auto';
663
+ } else if (maxWidth == null) {
664
+ marginLeft = anchorOffset;
665
+ marginRight = anchorOffset;
666
+ } else {
667
+ marginLeft = anchor === 'left' ? anchorOffset : 'auto';
668
+ marginRight = anchor === 'right' ? anchorOffset : 'auto';
454
669
  }
455
- }, []);
456
670
 
457
- const sheetContent = (
458
- <ContainerComponent>
459
- {header && <View style={headerStyle}>{renderSlot(header)}</View>}
460
- {scrollable ? children : <View style={style}>{children}</View>}
461
- </ContainerComponent>
462
- );
463
-
464
- const sharedProps: Omit<BottomSheetProps, 'children'> = {
465
- style: [
466
- styles.root,
467
- {
468
- backgroundColor,
469
- borderTopLeftRadius: cornerRadius,
470
- borderTopRightRadius: cornerRadius,
471
- borderBottomLeftRadius: detached ? cornerRadius : 0,
472
- borderBottomRightRadius: detached ? cornerRadius : 0,
473
- boxShadow: getElevationShadow(elevation),
474
- maxWidth: isLandscapeOrTablet ? (maxContentWidth ?? DEFAULT_MAX_WIDTH) : undefined,
475
- marginLeft: isLandscapeOrTablet ? (anchor === 'left' ? anchorOffset : 'auto') : undefined,
476
- marginRight: isLandscapeOrTablet ? (anchor === 'right' ? anchorOffset : 'auto') : undefined,
477
- marginHorizontal: detached ? anchorOffset : undefined,
478
- },
479
- ],
480
- backgroundComponent: null,
481
- index: snapIndex,
482
- enablePanDownToClose: dismissible,
483
- enableContentPanningGesture: draggable,
484
- enableHandlePanningGesture: draggable,
485
- animatedPosition,
486
- animatedIndex,
487
- handleStyle,
488
- handleIndicatorStyle,
489
- onChange: handleChange,
490
- onAnimate: handleAnimate,
491
- enableDynamicSizing: hasAutoDetent,
492
- maxDynamicContentSize: maxContentHeight,
493
- snapPoints: snapPoints.length > 0 ? snapPoints : undefined,
671
+ return {
672
+ ...(maxWidth != null && { maxWidth }),
673
+ marginLeft,
674
+ marginRight,
675
+ };
676
+ }, [
677
+ isLandscapeOrTablet,
678
+ isFormSheet,
679
+ maxContentWidth,
680
+ pageSizing,
681
+ anchor,
682
+ anchorOffset,
494
683
  detached,
495
- bottomInset: detached ? detachedOffset : undefined,
496
- backdropComponent,
497
- footerComponent,
498
- };
684
+ ]);
499
685
 
500
- if (isNonModal) {
501
- return (
502
- <BottomSheet ref={bottomSheetRef} onClose={handleDismiss} {...sharedProps}>
503
- {sheetContent}
504
- </BottomSheet>
505
- );
506
- }
686
+ const handleStyle = useMemo<React.CSSProperties>(
687
+ () => ({
688
+ height: grabberHeight,
689
+ width: grabberOptions?.width ?? DEFAULT_GRABBER_WIDTH,
690
+ borderRadius: grabberOptions?.cornerRadius ?? grabberHeight / 2,
691
+ backgroundColor: (grabberOptions?.color ?? defaultGrabberColor) as string,
692
+ opacity: 1,
693
+ marginTop: grabberOptions?.topMargin ?? DEFAULT_GRABBER_TOP_MARGIN,
694
+ }),
695
+ [grabberOptions, grabberHeight, defaultGrabberColor]
696
+ );
507
697
 
508
698
  return (
509
- <BottomSheetModal
510
- ref={bottomSheetModalRef}
511
- name={sheetName}
512
- animateOnMount
513
- onDismiss={handleDismiss}
514
- stackBehavior={stackBehavior}
515
- {...sharedProps}
699
+ <Drawer.Root
700
+ open={isOpen}
701
+ onOpenChange={handleOpenChange}
702
+ onPositionChange={handlePositionChange}
703
+ onDrag={handleDrag}
704
+ onRelease={handleRelease}
705
+ dismissible={dismissible}
706
+ draggable={draggable}
707
+ handleOnly={scrollable && scrollableOptions?.scrollingExpandsSheet === false}
708
+ repositionInputs={false}
709
+ modal={dimmed}
710
+ nested={isNested}
711
+ detached={effectiveDetached}
712
+ detachedOffset={effectiveDetachedOffset}
713
+ detachedRadius={effectiveCornerRadius}
714
+ maxContentHeight={effectiveMaxContentHeight}
715
+ initialAnimated={initialDetentAnimated}
716
+ detachedWrapperStyle={wrapperStyle}
717
+ activeSnapPoint={activeSnapPoint}
718
+ setActiveSnapPoint={handleSetActiveSnapPoint}
719
+ {...snapPointsProps}
516
720
  >
517
- {sheetContent}
518
- </BottomSheetModal>
721
+ <Drawer.Portal container={portalContainer ?? undefined}>
722
+ <Drawer.Overlay style={overlayStyle} />
723
+ <Drawer.Content
724
+ ref={drawerContentRef}
725
+ style={mergedContentStyle}
726
+ onPointerDownOutside={handlePointerDownOutside}
727
+ detachedSiblings={
728
+ footer ? (
729
+ <div style={footerFloatStyle}>
730
+ <View style={footerStyle}>
731
+ {isValidElement(footer) ? footer : createElement(footer)}
732
+ </View>
733
+ </div>
734
+ ) : undefined
735
+ }
736
+ >
737
+ <Drawer.Title style={visuallyHiddenStyle}>Sheet</Drawer.Title>
738
+ {grabber && <Drawer.Handle style={handleStyle} />}
739
+ {header && (
740
+ <View style={headerStyle}>
741
+ {isValidElement(header) ? header : createElement(header)}
742
+ </View>
743
+ )}
744
+ {scrollable ? (
745
+ <div style={scrollableContainerStyle}>
746
+ <View style={style}>{children}</View>
747
+ </div>
748
+ ) : (
749
+ <View style={style}>{children}</View>
750
+ )}
751
+ </Drawer.Content>
752
+ </Drawer.Portal>
753
+ </Drawer.Root>
519
754
  );
520
755
  });
521
756
 
522
- const STATIC_METHOD_ERROR =
523
- 'Static methods are not supported on web. Use the useTrueSheet() hook instead.';
524
-
525
- interface TrueSheetStatic {
526
- present: (name: string, index?: number) => Promise<void>;
527
- dismiss: (name: string) => Promise<void>;
528
- dismissStack: (name: string) => Promise<void>;
529
- resize: (name: string, index: number) => Promise<void>;
530
- dismissAll: () => Promise<void>;
531
- }
532
-
533
- export const TrueSheet = TrueSheetComponent as typeof TrueSheetComponent & TrueSheetStatic;
534
-
535
- TrueSheet.present = async () => {
536
- throw new Error(STATIC_METHOD_ERROR);
757
+ const overlayStyle: React.CSSProperties = {
758
+ position: 'fixed',
759
+ inset: 0,
760
+ backgroundColor: 'rgba(0, 0, 0, 0.5)',
537
761
  };
538
762
 
539
- TrueSheet.dismiss = async () => {
540
- throw new Error(STATIC_METHOD_ERROR);
763
+ const scrollableContainerStyle: React.CSSProperties = {
764
+ flex: 1,
765
+ minHeight: 0,
766
+ overflowY: 'auto',
767
+ overscrollBehavior: 'contain',
768
+ touchAction: 'pan-y',
541
769
  };
542
770
 
543
- TrueSheet.dismissStack = async () => {
544
- throw new Error(STATIC_METHOD_ERROR);
771
+ const visuallyHiddenStyle: React.CSSProperties = {
772
+ position: 'absolute',
773
+ width: 1,
774
+ height: 1,
775
+ padding: 0,
776
+ margin: -1,
777
+ overflow: 'hidden',
778
+ clip: 'rect(0, 0, 0, 0)',
779
+ whiteSpace: 'nowrap',
780
+ border: 0,
545
781
  };
546
782
 
547
- TrueSheet.resize = async () => {
548
- throw new Error(STATIC_METHOD_ERROR);
549
- };
783
+ const STATIC_METHOD_ERROR =
784
+ 'Static methods are not supported on web. Use the useTrueSheet() hook instead.';
550
785
 
551
- TrueSheet.dismissAll = async () => {
786
+ export const TrueSheet = TrueSheetComponent as typeof TrueSheetComponent & TrueSheetStaticMethods;
787
+
788
+ const rejectStatic = async (): Promise<never> => {
552
789
  throw new Error(STATIC_METHOD_ERROR);
553
790
  };
554
791
 
555
- const styles = StyleSheet.create({
556
- root: {
557
- overflow: 'hidden',
558
- },
559
- handle: {
560
- position: 'absolute',
561
- top: 0,
562
- left: 0,
563
- right: 0,
564
- zIndex: 999,
565
- paddingVertical: 10,
566
- pointerEvents: 'none',
567
- },
568
- footer: {
569
- pointerEvents: 'box-none',
570
- },
571
- });
792
+ TrueSheet.present = rejectStatic;
793
+ TrueSheet.dismiss = rejectStatic;
794
+ TrueSheet.dismissStack = rejectStatic;
795
+ TrueSheet.resize = rejectStatic;
796
+ TrueSheet.dismissAll = rejectStatic;