@lodev09/react-native-true-sheet 3.10.1 → 3.11.0-beta.1

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