@lodev09/react-native-true-sheet 4.0.0-beta.2 → 4.0.0-beta.20

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 (203) hide show
  1. package/README.md +25 -4
  2. package/android/build.gradle +22 -1
  3. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContainerView.kt +50 -16
  4. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContentView.kt +302 -184
  5. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContentViewManager.kt +5 -1
  6. package/android/src/main/java/com/lodev09/truesheet/TrueSheetFooterView.kt +21 -110
  7. package/android/src/main/java/com/lodev09/truesheet/TrueSheetOverlayView.kt +116 -0
  8. package/android/src/main/java/com/lodev09/truesheet/TrueSheetOverlayViewManager.kt +45 -0
  9. package/android/src/main/java/com/lodev09/truesheet/TrueSheetPackage.kt +2 -1
  10. package/android/src/main/java/com/lodev09/truesheet/TrueSheetStateUpdater.kt +18 -0
  11. package/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt +119 -50
  12. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt +360 -163
  13. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewManager.kt +54 -12
  14. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetBottomSheetView.kt +22 -9
  15. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetCoordinatorLayout.kt +84 -5
  16. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDetentCalculator.kt +104 -39
  17. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDimView.kt +3 -0
  18. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetOverlayRootView.kt +137 -0
  19. package/android/src/main/java/com/lodev09/truesheet/events/TrueSheetLifecycleEvents.kt +15 -0
  20. package/android/src/main/java/com/lodev09/truesheet/utils/KeyboardUtils.kt +15 -2
  21. package/android/src/main/java/com/lodev09/truesheet/utils/ScreenUtils.kt +0 -12
  22. package/android/src/main/java/com/lodev09/truesheet/utils/ViewUtils.kt +19 -7
  23. package/android/src/main/jni/TrueSheetSpec.h +1 -0
  24. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewComponentDescriptor.h +1 -9
  25. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewShadowNode.cpp +110 -30
  26. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewShadowNode.h +30 -7
  27. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewState.cpp +2 -2
  28. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewState.h +9 -8
  29. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewComponentDescriptor.h +3 -5
  30. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewShadowNode.cpp +9 -3
  31. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewShadowNode.h +3 -6
  32. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewState.cpp +1 -1
  33. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewState.h +4 -6
  34. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetInsets.cpp +1 -1
  35. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetInsets.h +1 -1
  36. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetLayoutUtils.h +43 -0
  37. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetOverlayViewComponentDescriptor.h +21 -0
  38. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetOverlayViewShadowNode.cpp +17 -0
  39. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetOverlayViewShadowNode.h +33 -0
  40. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewComponentDescriptor.h +3 -5
  41. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewShadowNode.cpp +7 -40
  42. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewShadowNode.h +3 -7
  43. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewState.cpp +3 -5
  44. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewState.h +4 -5
  45. package/ios/TrueSheetContainerView.h +27 -7
  46. package/ios/TrueSheetContainerView.mm +31 -11
  47. package/ios/TrueSheetContentView.h +43 -15
  48. package/ios/TrueSheetContentView.mm +211 -189
  49. package/ios/TrueSheetFooterView.h +6 -0
  50. package/ios/TrueSheetFooterView.mm +93 -17
  51. package/ios/TrueSheetModule.h +2 -7
  52. package/ios/TrueSheetModule.mm +7 -16
  53. package/ios/TrueSheetOverlayView.h +32 -0
  54. package/ios/TrueSheetOverlayView.mm +161 -0
  55. package/ios/TrueSheetView.mm +212 -55
  56. package/ios/TrueSheetViewController.h +31 -3
  57. package/ios/TrueSheetViewController.mm +393 -94
  58. package/ios/core/TrueSheetBlurView.h +0 -2
  59. package/ios/core/TrueSheetBlurView.mm +3 -36
  60. package/ios/core/TrueSheetDetentCalculator.h +7 -1
  61. package/ios/core/TrueSheetDetentCalculator.mm +27 -4
  62. package/ios/core/TrueSheetGrabberView.mm +13 -3
  63. package/ios/core/TrueSheetKeyboardObserver.mm +2 -0
  64. package/ios/core/TrueSheetOverlayContainerView.h +31 -0
  65. package/ios/core/TrueSheetOverlayContainerView.mm +35 -0
  66. package/ios/events/TrueSheetLifecycleEvents.h +2 -0
  67. package/ios/events/TrueSheetLifecycleEvents.mm +8 -0
  68. package/ios/tvos/TrueSheetStubs.mm +12 -0
  69. package/ios/utils/UIView+ScrollEdgeInteraction.h +1 -0
  70. package/ios/utils/UIView+ScrollEdgeInteraction.mm +24 -2
  71. package/lib/module/TrueSheet.js +173 -39
  72. package/lib/module/TrueSheet.js.map +1 -1
  73. package/lib/module/TrueSheet.web.js +218 -243
  74. package/lib/module/TrueSheet.web.js.map +1 -1
  75. package/lib/module/TrueSheetBackground.js +15 -0
  76. package/lib/module/TrueSheetBackground.js.map +1 -0
  77. package/lib/module/TrueSheetOverlay.js +30 -0
  78. package/lib/module/TrueSheetOverlay.js.map +1 -0
  79. package/lib/module/TrueSheetOverlay.web.js +35 -0
  80. package/lib/module/TrueSheetOverlay.web.js.map +1 -0
  81. package/lib/module/TrueSheetPeek.web.js +20 -15
  82. package/lib/module/TrueSheetPeek.web.js.map +1 -1
  83. package/lib/module/detentBackgrounds.js +21 -0
  84. package/lib/module/detentBackgrounds.js.map +1 -0
  85. package/lib/module/fabric/TrueSheetContentViewNativeComponent.ts +1 -1
  86. package/lib/module/fabric/TrueSheetOverlayViewNativeComponent.ts +12 -0
  87. package/lib/module/fabric/TrueSheetViewNativeComponent.ts +14 -10
  88. package/lib/module/index.js +1 -0
  89. package/lib/module/index.js.map +1 -1
  90. package/lib/module/mocks/index.js +18 -2
  91. package/lib/module/mocks/index.js.map +1 -1
  92. package/lib/module/mocks/navigationExpoRouter.js.map +1 -1
  93. package/lib/module/navigation/base-types.js +2 -0
  94. package/lib/module/navigation/base-types.js.map +1 -0
  95. package/lib/module/navigation/createTrueSheetRouter.js +0 -9
  96. package/lib/module/navigation/createTrueSheetRouter.js.map +1 -1
  97. package/lib/module/navigation/expo-router/base-types.js +2 -0
  98. package/lib/module/navigation/expo-router/base-types.js.map +1 -0
  99. package/lib/module/navigation/expo-router/index.js +10 -7
  100. package/lib/module/navigation/expo-router/index.js.map +1 -1
  101. package/lib/module/navigation/expo-router/types.js +4 -0
  102. package/lib/module/navigation/expo-router/types.js.map +1 -0
  103. package/lib/module/navigation/index.js.map +1 -1
  104. package/lib/module/navigation/screen/useSheetScreenState.js +1 -0
  105. package/lib/module/navigation/screen/useSheetScreenState.js.map +1 -1
  106. package/lib/module/web/constants.js +1 -1
  107. package/lib/module/web/constants.js.map +1 -1
  108. package/lib/module/web/layout.js +168 -0
  109. package/lib/module/web/layout.js.map +1 -0
  110. package/lib/module/web/vaul/constants.js +4 -0
  111. package/lib/module/web/vaul/constants.js.map +1 -1
  112. package/lib/module/web/vaul/context.js.map +1 -1
  113. package/lib/module/web/vaul/helpers.js +1 -0
  114. package/lib/module/web/vaul/helpers.js.map +1 -1
  115. package/lib/module/web/vaul/index.js +44 -6
  116. package/lib/module/web/vaul/index.js.map +1 -1
  117. package/lib/module/web/vaul/use-snap-points.js +6 -8
  118. package/lib/module/web/vaul/use-snap-points.js.map +1 -1
  119. package/lib/typescript/src/TrueSheet.d.ts +6 -0
  120. package/lib/typescript/src/TrueSheet.d.ts.map +1 -1
  121. package/lib/typescript/src/TrueSheet.types.d.ts +135 -34
  122. package/lib/typescript/src/TrueSheet.types.d.ts.map +1 -1
  123. package/lib/typescript/src/TrueSheet.web.d.ts.map +1 -1
  124. package/lib/typescript/src/TrueSheetBackground.d.ts +3 -0
  125. package/lib/typescript/src/TrueSheetBackground.d.ts.map +1 -0
  126. package/lib/typescript/src/TrueSheetOverlay.d.ts +8 -0
  127. package/lib/typescript/src/TrueSheetOverlay.d.ts.map +1 -0
  128. package/lib/typescript/src/TrueSheetOverlay.web.d.ts +8 -0
  129. package/lib/typescript/src/TrueSheetOverlay.web.d.ts.map +1 -0
  130. package/lib/typescript/src/TrueSheetPeek.web.d.ts +8 -4
  131. package/lib/typescript/src/TrueSheetPeek.web.d.ts.map +1 -1
  132. package/lib/typescript/src/detentBackgrounds.d.ts +10 -0
  133. package/lib/typescript/src/detentBackgrounds.d.ts.map +1 -0
  134. package/lib/typescript/src/fabric/TrueSheetOverlayViewNativeComponent.d.ts +6 -0
  135. package/lib/typescript/src/fabric/TrueSheetOverlayViewNativeComponent.d.ts.map +1 -0
  136. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts +11 -7
  137. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts.map +1 -1
  138. package/lib/typescript/src/index.d.ts +1 -0
  139. package/lib/typescript/src/index.d.ts.map +1 -1
  140. package/lib/typescript/src/mocks/index.d.ts +4 -0
  141. package/lib/typescript/src/mocks/index.d.ts.map +1 -1
  142. package/lib/typescript/src/mocks/navigationExpoRouter.d.ts +3 -3
  143. package/lib/typescript/src/mocks/navigationExpoRouter.d.ts.map +1 -1
  144. package/lib/typescript/src/navigation/base-types.d.ts +9 -0
  145. package/lib/typescript/src/navigation/base-types.d.ts.map +1 -0
  146. package/lib/typescript/src/navigation/createTrueSheetRouter.d.ts +2 -13
  147. package/lib/typescript/src/navigation/createTrueSheetRouter.d.ts.map +1 -1
  148. package/lib/typescript/src/navigation/expo-router/base-types.d.ts +8 -0
  149. package/lib/typescript/src/navigation/expo-router/base-types.d.ts.map +1 -0
  150. package/lib/typescript/src/navigation/expo-router/index.d.ts +8 -6
  151. package/lib/typescript/src/navigation/expo-router/index.d.ts.map +1 -1
  152. package/lib/typescript/src/navigation/expo-router/types.d.ts +44 -0
  153. package/lib/typescript/src/navigation/expo-router/types.d.ts.map +1 -0
  154. package/lib/typescript/src/navigation/index.d.ts +3 -4
  155. package/lib/typescript/src/navigation/index.d.ts.map +1 -1
  156. package/lib/typescript/src/navigation/screen/useSheetScreenState.d.ts +2 -1
  157. package/lib/typescript/src/navigation/screen/useSheetScreenState.d.ts.map +1 -1
  158. package/lib/typescript/src/navigation/types.d.ts +41 -5
  159. package/lib/typescript/src/navigation/types.d.ts.map +1 -1
  160. package/lib/typescript/src/web/constants.d.ts +1 -1
  161. package/lib/typescript/src/web/constants.d.ts.map +1 -1
  162. package/lib/typescript/src/web/layout.d.ts +19 -0
  163. package/lib/typescript/src/web/layout.d.ts.map +1 -0
  164. package/lib/typescript/src/web/vaul/constants.d.ts +1 -0
  165. package/lib/typescript/src/web/vaul/constants.d.ts.map +1 -1
  166. package/lib/typescript/src/web/vaul/context.d.ts +1 -0
  167. package/lib/typescript/src/web/vaul/context.d.ts.map +1 -1
  168. package/lib/typescript/src/web/vaul/helpers.d.ts +1 -0
  169. package/lib/typescript/src/web/vaul/helpers.d.ts.map +1 -1
  170. package/lib/typescript/src/web/vaul/index.d.ts +7 -1
  171. package/lib/typescript/src/web/vaul/index.d.ts.map +1 -1
  172. package/lib/typescript/src/web/vaul/use-snap-points.d.ts.map +1 -1
  173. package/package.json +17 -8
  174. package/react-native.config.js +1 -0
  175. package/src/TrueSheet.tsx +206 -48
  176. package/src/TrueSheet.types.ts +142 -35
  177. package/src/TrueSheet.web.tsx +270 -290
  178. package/src/TrueSheetBackground.tsx +17 -0
  179. package/src/TrueSheetOverlay.tsx +24 -0
  180. package/src/TrueSheetOverlay.web.tsx +33 -0
  181. package/src/TrueSheetPeek.web.tsx +25 -23
  182. package/src/detentBackgrounds.ts +24 -0
  183. package/src/fabric/TrueSheetContentViewNativeComponent.ts +1 -1
  184. package/src/fabric/TrueSheetOverlayViewNativeComponent.ts +12 -0
  185. package/src/fabric/TrueSheetViewNativeComponent.ts +14 -10
  186. package/src/index.ts +1 -0
  187. package/src/mocks/index.ts +17 -2
  188. package/src/mocks/navigationExpoRouter.ts +6 -5
  189. package/src/navigation/base-types.ts +20 -0
  190. package/src/navigation/createTrueSheetRouter.ts +5 -33
  191. package/src/navigation/expo-router/base-types.ts +18 -0
  192. package/src/navigation/expo-router/index.ts +28 -30
  193. package/src/navigation/expo-router/types.ts +116 -0
  194. package/src/navigation/index.ts +6 -7
  195. package/src/navigation/screen/useSheetScreenState.ts +2 -0
  196. package/src/navigation/types.ts +75 -22
  197. package/src/web/constants.ts +1 -1
  198. package/src/web/layout.ts +196 -0
  199. package/src/web/vaul/constants.ts +4 -0
  200. package/src/web/vaul/context.ts +1 -0
  201. package/src/web/vaul/helpers.ts +5 -0
  202. package/src/web/vaul/index.tsx +67 -5
  203. package/src/web/vaul/use-snap-points.ts +6 -14
@@ -31,15 +31,20 @@ import type {
31
31
  TrueSheetStaticMethods,
32
32
  WillBlurEvent,
33
33
  WillDismissEvent,
34
+ DismissAttemptEvent,
34
35
  WillFocusEvent,
35
36
  WillPresentEvent,
36
37
  } from './TrueSheet.types';
37
- import { measurePeekContentHeight, TrueSheetPeekContext } from './TrueSheetPeek.web';
38
+ import {
39
+ measurePeekContentHeight,
40
+ TrueSheetPeekContext,
41
+ type TrueSheetPeekContextValue,
42
+ } from './TrueSheetPeek.web';
38
43
  import { usePortalContainer, useRegisterSheet, useSheetStack } from './TrueSheetProvider.web';
39
44
  import {
40
45
  COLOR_SURFACE_CONTAINER_LOW_DARK,
41
46
  COLOR_SURFACE_CONTAINER_LOW_LIGHT,
42
- DEFAULT_ANCHOR_OFFSET,
47
+ DEFAULT_PLACEMENT_OFFSET,
43
48
  DEFAULT_CORNER_RADIUS,
44
49
  DEFAULT_DETACHED_OFFSET,
45
50
  DEFAULT_FORM_SHEET_HEIGHT_RATIO,
@@ -52,18 +57,21 @@ import {
52
57
  DEFAULT_MAX_WIDTH,
53
58
  } from './web/constants';
54
59
  import { getDOMElement } from './web/dom';
60
+ import { TrueSheetBackground } from './TrueSheetBackground';
61
+ import { getDetentBackgroundIndex, normalizeDetentBackground } from './detentBackgrounds';
62
+ import { observeSheetLayout } from './web/layout';
55
63
  import { Drawer } from './web/vaul';
56
- import { DEFAULT_PEEK_HEIGHT, TRANSITIONS } from './web/vaul/constants';
57
-
58
- // First vertically scrollable descendant — web mirror of native's
59
- // findScrollView (RN-web ScrollView/FlatList render a node with
60
- // `overflow-y: auto`; its first child is the content container).
61
- const findVerticalScroller = (root: HTMLElement): HTMLElement | null => {
62
- for (const el of root.querySelectorAll<HTMLElement>('*')) {
63
- const { overflowY } = window.getComputedStyle(el);
64
- if (overflowY === 'auto' || overflowY === 'scroll') return el;
65
- }
66
- return null;
64
+ import { DEFAULT_PEEK_HEIGHT, DRAG_CLASS, TRANSITIONS } from './web/vaul/constants';
65
+
66
+ // Resolves the user's `scrollableRef` to its scrollable DOM element — RN-web
67
+ // ScrollView refs expose the scroll node directly (with helpers attached);
68
+ // list refs (e.g. FlatList) expose it via getScrollableNode(). The node's
69
+ // first child is the content container.
70
+ const getScrollableElement = (scrollable: unknown): HTMLElement | null => {
71
+ if (!scrollable) return null;
72
+ if (scrollable instanceof HTMLElement) return scrollable;
73
+ const node = (scrollable as { getScrollableNode?: () => unknown }).getScrollableNode?.();
74
+ return node instanceof HTMLElement ? node : null;
67
75
  };
68
76
 
69
77
  const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props, ref) => {
@@ -75,10 +83,15 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
75
83
  cornerRadius,
76
84
  style,
77
85
  backgroundColor: backgroundColorProp,
86
+ detentBackgrounds,
87
+ background,
88
+ backgroundStyle,
78
89
  maxContentHeight,
79
90
  maxContentWidth,
80
- anchor = 'center',
81
- anchorOffset = DEFAULT_ANCHOR_OFFSET,
91
+ placement = 'automatic',
92
+ placementOffset = DEFAULT_PLACEMENT_OFFSET,
93
+ scrollableRef,
94
+ scrollableOptions,
82
95
  grabber = true,
83
96
  grabberOptions,
84
97
  accessibilityOptions,
@@ -103,6 +116,7 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
103
116
  onDidPresent,
104
117
  onWillDismiss,
105
118
  onDidDismiss,
119
+ onDismissAttempt,
106
120
  onDetentChange,
107
121
  onDragBegin,
108
122
  onDragChange,
@@ -143,12 +157,20 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
143
157
  const effectiveDetached = isFormSheet || detached;
144
158
 
145
159
  const colorScheme = useColorScheme();
160
+ const shouldAutoPresent = initialDetentIndex >= 0 && initialDetentIndex < validDetents.length;
161
+ const [isOpen, setIsOpen] = useState(shouldAutoPresent);
162
+ const isOpenRef = useRef(isOpen);
163
+ isOpenRef.current = isOpen;
164
+ const isPresentingRef = useRef(shouldAutoPresent);
165
+ const [displayedBackgroundIndex, setDisplayedBackgroundIndex] = useState(
166
+ shouldAutoPresent ? initialDetentIndex : 0
167
+ );
146
168
  const backgroundColor =
169
+ normalizeDetentBackground(
170
+ detentBackgrounds?.[Math.min(displayedBackgroundIndex, validDetents.length - 1)]
171
+ )?.color ??
147
172
  backgroundColorProp ??
148
173
  (colorScheme === 'dark' ? COLOR_SURFACE_CONTAINER_LOW_DARK : COLOR_SURFACE_CONTAINER_LOW_LIGHT);
149
-
150
- const shouldAutoPresent = initialDetentIndex >= 0 && initialDetentIndex < validDetents.length;
151
- const [isOpen, setIsOpen] = useState(shouldAutoPresent);
152
174
  const [activeSnapPoint, setActiveSnapPoint] = useState<SheetDetent | null>(
153
175
  () => validDetents[shouldAutoPresent ? initialDetentIndex : 0] ?? null
154
176
  );
@@ -164,8 +186,6 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
164
186
  const validDetentsRef = useRef(validDetents);
165
187
  validDetentsRef.current = validDetents;
166
188
 
167
- const hasAutoDetent = validDetents.includes('auto');
168
-
169
189
  const handleSetActiveSnapPoint = useCallback((snapPoint: number | string | null) => {
170
190
  setActiveSnapPoint(
171
191
  snapPoint == null
@@ -196,19 +216,34 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
196
216
  e.preventDefault();
197
217
  return;
198
218
  }
219
+ if (!(target instanceof Element)) return;
220
+ const wrapper = drawerContentRef.current?.closest('[data-vaul-detached-wrapper]');
221
+ if (!wrapper) return;
199
222
  // The footer is rendered via vaul's `detachedSiblings` as a sibling of
200
223
  // Drawer.Content inside [data-vaul-detached-wrapper], so Radix treats
201
224
  // clicks on it as "outside" the content. Don't dismiss for clicks that
202
225
  // landed inside the wrapper.
203
- if (target instanceof Element) {
204
- const wrapper = drawerContentRef.current?.closest('[data-vaul-detached-wrapper]');
205
- if (wrapper && wrapper.contains(target)) {
206
- e.preventDefault();
207
- }
226
+ if (wrapper.contains(target)) {
227
+ e.preventDefault();
228
+ return;
208
229
  }
230
+ // Every sheet runs Radix non-modal and portals its overlay + wrapper as
231
+ // siblings into the shared container, so a press on a sheet stacked above
232
+ // this one (its content or its dim overlay) is also delivered here as
233
+ // "outside". Sheets present in document order, so anything after our
234
+ // wrapper belongs to a descendant. Radix may dispatch on the deferred
235
+ // click (touch), after a dismissing descendant has left the DOM — treat
236
+ // disconnected as above too.
237
+ /* eslint-disable no-bitwise */
238
+ const above = Node.DOCUMENT_POSITION_FOLLOWING | Node.DOCUMENT_POSITION_DISCONNECTED;
239
+ if (wrapper.compareDocumentPosition(target) & above) {
240
+ e.preventDefault();
241
+ }
242
+ /* eslint-enable no-bitwise */
209
243
  };
210
244
 
211
245
  const dismissAboveRef = useRef<(animated?: boolean) => Promise<void>>(async () => {});
246
+ const dismissDescendantsRef = useRef<() => void>(() => {});
212
247
 
213
248
  const methods = useMemo<TrueSheetMethods>(
214
249
  () => ({
@@ -219,6 +254,10 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
219
254
  `TrueSheet: present index (${index}) is out of bounds. detents array has ${validDetentsRef.current.length} item(s)`
220
255
  );
221
256
  }
257
+ if (!isOpenRef.current) {
258
+ isPresentingRef.current = true;
259
+ setDisplayedBackgroundIndex(index);
260
+ }
222
261
  setActiveSnapPoint(detent);
223
262
  setIsOpen(true);
224
263
  },
@@ -272,15 +311,45 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
272
311
  // within the content — measured by the peek against `contentRef`.
273
312
  const [peekContentHeight, setPeekContentHeight] = useState(0);
274
313
  const contentRef = useRef<View>(null);
275
- const peekElRef = useRef<View>(null);
276
- const peekContext = useMemo(() => ({ contentRef, peekRef: peekElRef, setPeekContentHeight }), []);
314
+ const peekElRef = useRef<View | null>(null);
315
+
316
+ const measurePeek = useCallback(() => {
317
+ const peekEl = getDOMElement(peekElRef.current);
318
+ const contentEl = getDOMElement(contentRef.current);
319
+ if (peekEl && contentEl) {
320
+ setPeekContentHeight(measurePeekContentHeight(peekEl, contentEl));
321
+ }
322
+ }, []);
323
+
324
+ // Last-attached wins — during a screen swap the incoming peek mounts before
325
+ // the outgoing one unmounts, so the outgoing detach must not clear it.
326
+ const peekContext = useMemo<TrueSheetPeekContextValue>(
327
+ () => ({
328
+ contentRef,
329
+ attachPeek: (view) => {
330
+ peekElRef.current = view;
331
+ measurePeek();
332
+ },
333
+ detachPeek: (view) => {
334
+ if (peekElRef.current !== view) return;
335
+ peekElRef.current = null;
336
+ setPeekContentHeight(0);
337
+ },
338
+ measurePeek: (view) => {
339
+ if (peekElRef.current === view) measurePeek();
340
+ },
341
+ }),
342
+ [measurePeek]
343
+ );
344
+
345
+ // The peek's own onLayout only fires when *it* resizes. Content added above
346
+ // it moves it without resizing it, so re-measure its offset whenever the
347
+ // content view's layout changes.
348
+ const handleContentLayout = measurePeek;
277
349
 
278
350
  // An absolute (floating) header overlaps the content, so it contributes no
279
351
  // height to the 'auto' detent measurement.
280
352
  const absoluteHeader = headerOptions?.position === 'absolute';
281
- const absoluteHeaderRef = useRef(absoluteHeader);
282
- absoluteHeaderRef.current = absoluteHeader;
283
-
284
353
  const resolvedHeaderStyle = absoluteHeader ? [absoluteHeaderStyle, headerStyle] : headerStyle;
285
354
 
286
355
  // Same for an absolute (floating) footer — rendered via vaul's
@@ -316,109 +385,48 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
316
385
  (footer && absoluteFooter ? footerHeight : 0) +
317
386
  peekContentHeight || DEFAULT_PEEK_HEIGHT;
318
387
 
319
- // Web mirror of native's scrollable handling for 'auto' detents: a plugged
320
- // scrollable keeps the sized (bounded) layout so its viewport is capped to
321
- // the visible sheet and can scroll, while the 'auto' height is measured with
322
- // the viewport replaced by the scrollable's content size — mirrors native
323
- // `naturalHeight`.
324
- const [hasBoundedScrollable, setHasBoundedScrollable] = useState(false);
325
- const [scrollableAutoHeight, setScrollableAutoHeight] = useState(0);
326
- const pinnedScrollerRef = useRef<HTMLElement | null>(null);
327
-
328
- // Natural content height (header + content + footer, with a pinned
329
- // scrollable's viewport replaced by its content size) — the height the
330
- // content wants regardless of the sheet's bounds.
331
- const measureNaturalHeight = useCallback(() => {
332
- const contentEl = getDOMElement(contentRef.current);
333
- if (!contentEl || !contentEl.isConnected) return 0;
334
- const headerEl = absoluteHeaderRef.current ? null : getDOMElement(headerElRef.current);
335
- const footerEl = absoluteFooterRef.current ? null : getDOMElement(footerElRef.current);
336
- let height =
337
- (headerEl?.offsetHeight ?? 0) + (footerEl?.offsetHeight ?? 0) + contentEl.offsetHeight;
338
- const scroller = pinnedScrollerRef.current;
339
- const scrollContent = scroller?.firstElementChild;
340
- if (scroller?.isConnected && scrollContent instanceof HTMLElement) {
341
- height += scrollContent.offsetHeight - scroller.clientHeight;
342
- }
343
- return Math.max(0, height);
344
- }, []);
388
+ const [measuredContentHeight, setMeasuredContentHeight] = useState(0);
389
+ const naturalHeightRef = useRef(0);
390
+ const insetBehavior = scrollableOptions?.contentInsetAdjustment ?? 'automatic';
391
+ const footerInsetAdjustment =
392
+ Boolean(footer) &&
393
+ absoluteFooter &&
394
+ (insetBehavior === 'automatic' || insetBehavior === 'footer');
395
+ const measureContent = validDetents.includes('auto') || isFormSheet;
345
396
 
346
397
  useEffect(() => {
347
- if (!isOpen || !hasAutoDetent) return undefined;
398
+ if (!isOpen) return undefined;
348
399
 
349
400
  let canceled = false;
350
401
  let rafId = 0;
351
- let mutationObserver: MutationObserver | null = null;
352
- let resizeObserver: ResizeObserver | null = null;
402
+ let observer: ReturnType<typeof observeSheetLayout> | undefined;
353
403
 
354
404
  const attach = () => {
355
405
  if (canceled) return;
356
- const drawerEl = drawerContentRef.current;
357
- if (!drawerEl || !drawerEl.isConnected) {
406
+ const layoutEl = sizedLayoutRef.current;
407
+ const drawer = drawerContentRef.current;
408
+ if (!drawer || !layoutEl?.isConnected) {
358
409
  // Radix Presence defers the portal mount; poll until the drawer is live.
359
410
  rafId = window.requestAnimationFrame(attach);
360
411
  return;
361
412
  }
362
413
 
363
- const measure = () => setScrollableAutoHeight(measureNaturalHeight());
364
-
365
- let observed: {
366
- contentEl: HTMLElement | null;
367
- headerEl: HTMLElement | null;
368
- scroller: HTMLElement;
369
- scrollContent: Element | null;
370
- } | null = null;
371
-
372
- // (Re)pin the first vertical scrollable and observe the nodes whose size
373
- // feeds the natural height — content growth inside a bounded scroller is
374
- // invisible to the sheet's own layout (mirrors native's contentSize
375
- // observation). Resolves the content node live: the layout-branch swap
376
- // (natural flow ↔ sized) remounts it.
377
- const observe = () => {
378
- const contentEl = getDOMElement(contentRef.current);
379
- const headerEl = getDOMElement(headerElRef.current);
380
-
381
- // Skip mutations that don't change the observed topology — e.g. a
382
- // virtualized list mounting rows inside the pinned scroller, which
383
- // would otherwise trigger a full-subtree rescan (findVerticalScroller
384
- // resolves computed styles) on every scroll frame. Size changes are
385
- // already covered by the ResizeObserver.
386
- if (
387
- observed &&
388
- observed.contentEl === contentEl &&
389
- contentEl?.isConnected &&
390
- observed.headerEl === headerEl &&
391
- observed.scroller.isConnected &&
392
- observed.scrollContent === observed.scroller.firstElementChild
393
- ) {
394
- return;
395
- }
396
-
397
- const scroller =
398
- contentEl && contentEl.isConnected ? findVerticalScroller(contentEl) : null;
399
- pinnedScrollerRef.current = scroller;
400
- setHasBoundedScrollable(scroller != null);
401
- observed = scroller
402
- ? { contentEl, headerEl, scroller, scrollContent: scroller.firstElementChild }
403
- : null;
404
-
405
- resizeObserver?.disconnect();
406
- resizeObserver = new ResizeObserver(measure);
407
- if (contentEl?.isConnected) resizeObserver.observe(contentEl);
408
- if (headerEl) resizeObserver.observe(headerEl);
409
- if (scroller) {
410
- resizeObserver.observe(scroller);
411
- if (scroller.firstElementChild) resizeObserver.observe(scroller.firstElementChild);
412
- }
413
- measure();
414
- };
415
-
416
- observe();
417
- // Watch the whole drawer subtree — catches the scrollable mounting/
418
- // unmounting AND the layout-branch swap, which remounts the content node
419
- // (a content-scoped observer would go stale after the swap).
420
- mutationObserver = new MutationObserver(observe);
421
- mutationObserver.observe(drawerEl, { childList: true, subtree: true });
414
+ observer = observeSheetLayout({
415
+ drawer,
416
+ layout: layoutEl,
417
+ getContent: () => getDOMElement(contentRef.current),
418
+ getHeader: () => getDOMElement(headerElRef.current),
419
+ getFooter: () => getDOMElement(footerElRef.current),
420
+ getScrollable: () => getScrollableElement(scrollableRef?.current),
421
+ absoluteHeader,
422
+ absoluteFooter,
423
+ footerInsetAdjustment,
424
+ measureContent,
425
+ onHeightChange: (height) => {
426
+ naturalHeightRef.current = height;
427
+ setMeasuredContentHeight(height);
428
+ },
429
+ });
422
430
  };
423
431
 
424
432
  rafId = window.requestAnimationFrame(attach);
@@ -426,10 +434,16 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
426
434
  return () => {
427
435
  canceled = true;
428
436
  window.cancelAnimationFrame(rafId);
429
- mutationObserver?.disconnect();
430
- resizeObserver?.disconnect();
437
+ observer?.disconnect();
431
438
  };
432
- }, [isOpen, hasAutoDetent, measureNaturalHeight]);
439
+ }, [
440
+ isOpen,
441
+ absoluteHeader,
442
+ absoluteFooter,
443
+ footerInsetAdjustment,
444
+ measureContent,
445
+ scrollableRef,
446
+ ]);
433
447
 
434
448
  // Below the last detent a vertical touch pan moves the sheet, not the
435
449
  // content — `[data-vaul-scroll-locked]` disables vertical touch panning on
@@ -437,13 +451,6 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
437
451
  const isScrollLocked =
438
452
  validDetents.length > 0 && activeSnapPoint !== validDetents[validDetents.length - 1];
439
453
 
440
- // Vaul measures the auto-size wrapper's offsetHeight (always, post fork).
441
- // Track it here so the form sheet can size its card to fit content,
442
- // clamped between a minimum ratio of the viewport and a maximum derived
443
- // from `detachedOffset` (the breathing room left at top + bottom of the
444
- // floating card).
445
- const [measuredContentHeight, setMeasuredContentHeight] = useState(0);
446
-
447
454
  const effectiveMaxContentHeight = useMemo<number | undefined>(() => {
448
455
  if (maxContentHeight !== undefined) return maxContentHeight;
449
456
  if (!isFormSheet) return undefined;
@@ -510,7 +517,6 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
510
517
  effectiveMaxContentHeight,
511
518
  peekHeight,
512
519
  peekContentHeight,
513
- measuredContentHeight,
514
520
  });
515
521
  geometryInputsRef.current = {
516
522
  effectiveDetached,
@@ -518,7 +524,6 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
518
524
  effectiveMaxContentHeight,
519
525
  peekHeight,
520
526
  peekContentHeight,
521
- measuredContentHeight,
522
527
  };
523
528
 
524
529
  const computeDetentGeometry = useCallback(() => {
@@ -532,12 +537,9 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
532
537
  inputs.effectiveMaxContentHeight !== undefined
533
538
  ? Math.min(effectiveH, inputs.effectiveMaxContentHeight)
534
539
  : effectiveH;
535
- // 'auto' resolves to the content's natural height. Read it live — the
536
- // `measuredContentHeight` state lags mount by a frame (vaul's initial
537
- // ref-callback measure runs while the portal subtree is still detached,
538
- // so it reads 0 until the ResizeObserver fires). Falls back to the state
539
- // value, then to vaul's pre-measure fallback (effectiveHeight / 2).
540
- const measuredHeight = measureNaturalHeight() || inputs.measuredContentHeight;
540
+ // Reuse the intrinsic measurement during position events so animation
541
+ // frames don't clone the content tree again.
542
+ const measuredHeight = naturalHeightRef.current;
541
543
  const autoHeight = Math.min(measuredHeight > 0 ? measuredHeight : effectiveH / 2, ceiling);
542
544
 
543
545
  // 'peek' is measured live from the DOM (offsetHeight is layout-based, so
@@ -573,7 +575,7 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
573
575
  values.push(effectiveH > 0 ? h / effectiveH : 0);
574
576
  }
575
577
  return { windowH, effectiveH, ceiling, positions, values };
576
- }, [measureNaturalHeight]);
578
+ }, []);
577
579
 
578
580
  // Detent info for lifecycle events. Position/detent come from the active
579
581
  // detent's target geometry — not the live DOM rect — so willPresent (drawer
@@ -652,54 +654,27 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
652
654
  [computeDetentGeometry]
653
655
  );
654
656
 
655
- // Mirror native synchronous layout: while dragging (and through the post-
656
- // release settle) the sized layout tracks the sheet's visible height in
657
- // realtime, clamped to the smallest detent — dragging below it slides the
658
- // sheet out without resizing. Once settled, the inline height is cleared so
659
- // the CSS calc() owns sizing again (adapts to viewport resizes at rest).
660
657
  const sizedLayoutRef = useRef<HTMLDivElement>(null);
661
658
  const isDraggingRef = useRef(false);
662
- const isSettlingAfterDragRef = useRef(false);
663
- const updateSizedLayoutHeight = useCallback(
664
- (position: number) => {
665
- const node = sizedLayoutRef.current;
666
- if (!node) return;
667
-
668
- const { effectiveH, ceiling, positions } = computeDetentGeometry();
669
- if (positions.length === 0) return;
670
-
671
- if (isSettlingAfterDragRef.current) {
672
- const snap = activeSnapPointRef.current;
673
- const index = snap != null ? validDetentsRef.current.indexOf(snap) : -1;
674
- const target = index >= 0 ? positions[index]! : null;
675
- if (target != null && Math.abs(position - target) < 1) {
676
- isSettlingAfterDragRef.current = false;
677
- // Restore the calc — height is an inline style, so clearing it would
678
- // leave the div unsized (React won't re-apply it without a render)
679
- node.style.height = SIZED_LAYOUT_HEIGHT;
680
- return;
681
- }
682
- }
683
-
684
- // positions[0] is the smallest detent's top-Y — its height is the clamp floor.
685
- const minHeight = effectiveH - positions[0]!;
686
- const height = Math.min(Math.max(effectiveH - position, minHeight), ceiling);
687
- node.style.height = `${height}px`;
688
- },
689
- [computeDetentGeometry]
690
- );
691
659
 
692
660
  const handlePositionChange = useCallback(
693
661
  (position: number) => {
694
- if (isDraggingRef.current || isSettlingAfterDragRef.current) {
695
- updateSizedLayoutHeight(position);
696
- }
697
662
  const { index, detent } = interpolateFromPosition(position);
663
+ if (isOpenRef.current && !isPresentingRef.current) {
664
+ const { effectiveH, positions } = computeDetentGeometry();
665
+ setDisplayedBackgroundIndex((current) =>
666
+ getDetentBackgroundIndex(
667
+ effectiveH - position,
668
+ positions.map((top) => effectiveH - top),
669
+ current
670
+ )
671
+ );
672
+ }
698
673
  onPositionChangeRef.current?.({
699
674
  nativeEvent: { index, position, detent, realtime: true },
700
675
  } as PositionChangeEvent);
701
676
  },
702
- [interpolateFromPosition, updateSizedLayoutHeight]
677
+ [interpolateFromPosition, computeDetentGeometry]
703
678
  );
704
679
 
705
680
  // Fire onMount once after first render. React-mount is the earliest point
@@ -726,6 +701,9 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
726
701
 
727
702
  const present = isOpen;
728
703
  if (!present) {
704
+ // Mirror native: dismissing a sheet takes every sheet stacked above it
705
+ // (iOS dismisses from the presenter; Android runs dismissStack first).
706
+ dismissDescendantsRef.current();
729
707
  // Pair willBlur with willDismiss on dismiss — mirrors native iOS
730
708
  // emitWillDismissEvents (blur fires before dismiss). willPresent is
731
709
  // deferred to `start()` below (needs the mounted drawer's geometry).
@@ -735,6 +713,7 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
735
713
 
736
714
  const fireDone = () => {
737
715
  if (present) {
716
+ isPresentingRef.current = false;
738
717
  onDidPresentRef.current?.({ nativeEvent: computeDetentInfo() } as DidPresentEvent);
739
718
  onDidFocusRef.current?.({ nativeEvent: null } as DidFocusEvent);
740
719
  } else {
@@ -824,17 +803,11 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
824
803
  }
825
804
  onDragChangeRef.current?.({ nativeEvent: computeDetentInfo(true) } as DragChangeEvent);
826
805
  }, [computeDetentInfo]);
827
- const handleRelease = useCallback(
828
- (_event: unknown, open: boolean) => {
829
- if (!isDraggingRef.current) return;
830
- isDraggingRef.current = false;
831
- // Track the sized layout through the settle animation only when the sheet
832
- // stays open — a dismissal slides out at the min-clamped height.
833
- isSettlingAfterDragRef.current = open;
834
- onDragEndRef.current?.({ nativeEvent: computeDetentInfo(true) } as DragEndEvent);
835
- },
836
- [computeDetentInfo]
837
- );
806
+ const handleRelease = useCallback(() => {
807
+ if (!isDraggingRef.current) return;
808
+ isDraggingRef.current = false;
809
+ onDragEndRef.current?.({ nativeEvent: computeDetentInfo(true) } as DragEndEvent);
810
+ }, [computeDetentInfo]);
838
811
 
839
812
  const { isNested, dismissAbove, descendants } = useSheetStack(
840
813
  methodsRef,
@@ -843,10 +816,18 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
843
816
  isFormSheet
844
817
  );
845
818
  dismissAboveRef.current = dismissAbove;
819
+ // Captured per render: by the time the dismiss effect runs, this sheet has
820
+ // already been popped from the stack, so `dismissAbove` can't find it.
821
+ dismissDescendantsRef.current = () => {
822
+ for (let i = descendants.length - 1; i >= 0; i--) {
823
+ descendants[i]!.ref.current?.dismiss();
824
+ }
825
+ };
846
826
 
847
827
  // Mirror Android: translate this sheet down to match the deepest descendant's
848
828
  // top so the whole stack visually aligns. Cascades because every ancestor
849
829
  // re-runs whenever the stack (and thus its descendants) changes.
830
+ const wasCascadedRef = useRef(false);
850
831
  useEffect(() => {
851
832
  const parent = drawerContentRef.current;
852
833
  if (!parent) return;
@@ -858,7 +839,7 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
858
839
  if (!isOpen) return;
859
840
  const parentWrapper = parent.closest<HTMLElement>('[data-vaul-detached-wrapper]');
860
841
 
861
- const transition = `transform ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`;
842
+ const transition = `transform ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')}), --snap-point-height ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`;
862
843
  const wrapperTransition = `clip-path ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`;
863
844
  const CLIP_NONE = 'inset(0px round 0px)';
864
845
 
@@ -882,23 +863,32 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
882
863
  };
883
864
 
884
865
  if (descendants.length === 0) {
866
+ if (!wasCascadedRef.current) return;
867
+ wasCascadedRef.current = false;
885
868
  parent.style.transition = transition;
886
869
  parent.style.transform = '';
887
870
  setClip(CLIP_NONE);
888
871
  return;
889
872
  }
890
-
891
- // Track only the immediate child's snap point. Walking deeper descendants
892
- // would push this sheet further when a grandchild opens, even when our
893
- // own child didn't move (e.g., child skipped its cascade for a page
894
- // grandchild) — leaving a visible gap between this sheet and its child.
873
+ wasCascadedRef.current = true;
874
+
875
+ // Track the immediate child's *actual* top: its cascade transform when it
876
+ // was itself pushed behind a grandchild, else its own snap point. Mirrors
877
+ // Android's addTranslation propagating extra translation up the stack —
878
+ // a grandchild opening pushes this sheet only as far as our child moved.
879
+ const readTranslateY = (el: HTMLElement) => {
880
+ const match = el.style.transform.match(/translate3d\([^,]*,\s*(-?\d*\.?\d+)px/);
881
+ return match ? Number.parseFloat(match[1]!) : null;
882
+ };
895
883
  const computeTargetY = () => {
896
884
  const parentSnap =
897
885
  Number.parseFloat(parent.style.getPropertyValue('--snap-point-height')) || 0;
898
886
  const node = descendants[0]?.nodeRef.current;
899
887
  if (!node) return parentSnap;
900
- const childSnap = Number.parseFloat(node.style.getPropertyValue('--snap-point-height')) || 0;
901
- return Math.max(parentSnap, childSnap);
888
+ const childTop =
889
+ readTranslateY(node) ??
890
+ (Number.parseFloat(node.style.getPropertyValue('--snap-point-height')) || 0);
891
+ return Math.max(parentSnap, childTop);
902
892
  };
903
893
 
904
894
  // When a form-sheet parent has a form-sheet descendant, clip the parent to
@@ -934,18 +924,24 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
934
924
 
935
925
  const apply = () => {
936
926
  applyFormClip();
937
- // Mirror iOS: a page-sheet child fully covers a form-sheet parent, so the
938
- // cascade push-down has no visible effect — and would briefly peek above
939
- // the page during the present animation. Leave the parent put.
940
- const child = descendants[0];
941
- if (isFormSheet && child && !child.isFormSheetRef.current) {
927
+ // Mirror iPad/Android (`isExpanded`): a parent at full height (detent 1)
928
+ // stays put and the child simply overlays it. Full height is the sheet's
929
+ // own ceiling — the viewport for a page sheet, the capped content-fit
930
+ // height for a form sheet — so compare against where detent 1 lands.
931
+ const parentSnap =
932
+ Number.parseFloat(parent.style.getPropertyValue('--snap-point-height')) || 0;
933
+ const { effectiveH, ceiling } = computeDetentGeometry();
934
+ if (parentSnap <= effectiveH - ceiling + 0.5) {
942
935
  parent.style.transition = transition;
943
936
  parent.style.transform = '';
944
937
  return;
945
938
  }
939
+ // Mirror Android: stay put while the child is being dragged (its live
940
+ // transform would drag us along during drag-to-dismiss). The release
941
+ // snap rewrites the child's style and re-applies.
942
+ if (descendants[0]?.nodeRef.current?.classList.contains(DRAG_CLASS)) return;
946
943
  const targetY = computeTargetY();
947
- const match = parent.style.transform.match(/translate3d\([^,]*,\s*(-?\d*\.?\d+)px/);
948
- const currentY = match ? Number.parseFloat(match[1]!) : 0;
944
+ const currentY = readTranslateY(parent) ?? 0;
949
945
  if (Math.abs(currentY - targetY) < 0.5) return;
950
946
  parent.style.transition = transition;
951
947
  parent.style.transform = `translate3d(0, ${targetY}px, 0)`;
@@ -957,12 +953,15 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
957
953
  // inline style changes.
958
954
  const observer = new MutationObserver(apply);
959
955
  observer.observe(parent, { attributes: true, attributeFilter: ['style'] });
956
+ // The child's own cascade lands in a later frame — follow it when it moves.
957
+ const childNode = descendants[0]?.nodeRef.current;
958
+ if (childNode) observer.observe(childNode, { attributes: true, attributeFilter: ['style'] });
960
959
 
961
960
  return () => {
962
961
  cancelAnimationFrame(raf);
963
962
  observer.disconnect();
964
963
  };
965
- }, [descendants, activeSnapPoint, cornerRadius, isFormSheet, isOpen]);
964
+ }, [descendants, activeSnapPoint, cornerRadius, isFormSheet, isOpen, computeDetentGeometry]);
966
965
 
967
966
  // Focus/blur events fire when a descendant sheet appears on top of this one
968
967
  // (blur) or when all descendants are dismissed (focus). will-events fire
@@ -973,12 +972,17 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
973
972
  const onDidBlurRef = useRef(onDidBlur);
974
973
  const onWillFocusRef = useRef(onWillFocus);
975
974
  const onDidFocusRef = useRef(onDidFocus);
975
+ const onDismissAttemptRef = useRef(onDismissAttempt);
976
976
  useEffect(() => {
977
977
  onWillBlurRef.current = onWillBlur;
978
978
  onDidBlurRef.current = onDidBlur;
979
979
  onWillFocusRef.current = onWillFocus;
980
980
  onDidFocusRef.current = onDidFocus;
981
+ onDismissAttemptRef.current = onDismissAttempt;
981
982
  });
983
+ const handleDismissAttempt = useCallback(() => {
984
+ onDismissAttemptRef.current?.({ nativeEvent: null } as DismissAttemptEvent);
985
+ }, []);
982
986
 
983
987
  const prevDescendantCountRef = useRef(0);
984
988
  useEffect(() => {
@@ -1026,11 +1030,6 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
1026
1030
  };
1027
1031
  }, [isOpen, descendants.length]);
1028
1032
 
1029
- // Definite-height flex layout (per-detent sizing) unless content must be
1030
- // measured in natural flow: 'auto' detents (without a plugged scrollable —
1031
- // those measure via `measureNaturalHeight`) and form-sheet content-fit sizing.
1032
- const useSizedLayout = (!hasAutoDetent || hasBoundedScrollable) && !isFormSheet;
1033
-
1034
1033
  const effectiveCornerRadius = cornerRadius ?? DEFAULT_CORNER_RADIUS;
1035
1034
 
1036
1035
  // Shadow cast upward from the sheet's top edge toward the background. Matches
@@ -1048,6 +1047,14 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
1048
1047
  ? `drop-shadow(0 ${-elevation}px ${elevation * 3}px rgba(0, 0, 0, 0.15))`
1049
1048
  : undefined;
1050
1049
 
1050
+ const backgroundLayerStyle: React.CSSProperties = {
1051
+ position: 'absolute',
1052
+ inset: 0,
1053
+ pointerEvents: 'none',
1054
+ backgroundColor: backgroundColor as string,
1055
+ transition: isPresentingRef.current ? 'none' : 'background-color 200ms',
1056
+ };
1057
+
1051
1058
  const mergedContentStyle = useMemo<React.CSSProperties>(
1052
1059
  () => ({
1053
1060
  position: 'fixed',
@@ -1059,7 +1066,6 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
1059
1066
  flexDirection: 'column',
1060
1067
  borderTopLeftRadius: effectiveCornerRadius,
1061
1068
  borderTopRightRadius: effectiveCornerRadius,
1062
- backgroundColor: backgroundColor as string,
1063
1069
  // Clip children to the rounded top so headers/content with their own
1064
1070
  // background don't bleed past the corners. `clip` (not `hidden`): a
1065
1071
  // hidden box is still a scroll container, so browser focus-reveal (e.g.
@@ -1069,12 +1075,24 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
1069
1075
  // Lift content above iOS home indicator / bottom safe area when enabled.
1070
1076
  // A relative footer owns the inset instead (see resolvedFooterStyle); an
1071
1077
  // absolute footer floats over the content, so the content keeps its lift.
1078
+ // A plugged scrollable keeps the lift too (contentInsetAdjustment
1079
+ // 'automatic' | 'safe-area') — 'footer' | 'never' scroll edge-to-edge
1080
+ // behind the indicator with user-applied content padding.
1072
1081
  paddingBottom:
1073
- insetAdjustment === 'automatic' && !(footerOwnsInset && !absoluteFooter)
1082
+ insetAdjustment === 'automatic' &&
1083
+ !(footerOwnsInset && !absoluteFooter) &&
1084
+ (!scrollableRef || insetBehavior === 'automatic' || insetBehavior === 'safe-area')
1074
1085
  ? 'env(safe-area-inset-bottom, 0px)'
1075
1086
  : 0,
1076
1087
  }),
1077
- [backgroundColor, effectiveCornerRadius, insetAdjustment, footerOwnsInset, absoluteFooter]
1088
+ [
1089
+ effectiveCornerRadius,
1090
+ insetAdjustment,
1091
+ footerOwnsInset,
1092
+ absoluteFooter,
1093
+ scrollableRef,
1094
+ insetBehavior,
1095
+ ]
1078
1096
  );
1079
1097
 
1080
1098
  const defaultGrabberColor =
@@ -1112,7 +1130,7 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
1112
1130
  // `maxContentWidth` is ignored ('form' is absolute).
1113
1131
  // - presentation='page' → `maxContentWidth` (any viewport) or
1114
1132
  // DEFAULT_MAX_WIDTH (tablet/landscape readability cap).
1115
- // Detached without a width constraint applies anchorOffset on both edges so
1133
+ // Detached without a width constraint applies placementOffset on both edges so
1116
1134
  // the floating card breathes from the viewport sides.
1117
1135
  const wrapperStyle = useMemo<React.CSSProperties | undefined>(() => {
1118
1136
  // Mobile portrait ignores width sizing entirely (matches iOS/Android:
@@ -1131,30 +1149,30 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
1131
1149
  if (dropShadow) next.filter = dropShadow;
1132
1150
  if (!needsMargins) return next;
1133
1151
 
1134
- let marginLeft: number | string;
1135
- let marginRight: number | string;
1152
+ let marginInlineStart: number | string;
1153
+ let marginInlineEnd: number | string;
1136
1154
  if (isFormSheet) {
1137
- marginLeft = 'auto';
1138
- marginRight = 'auto';
1155
+ marginInlineStart = 'auto';
1156
+ marginInlineEnd = 'auto';
1139
1157
  } else if (maxWidth == null) {
1140
- marginLeft = anchorOffset;
1141
- marginRight = anchorOffset;
1158
+ marginInlineStart = placementOffset;
1159
+ marginInlineEnd = placementOffset;
1142
1160
  } else {
1143
- marginLeft = anchor === 'left' ? anchorOffset : 'auto';
1144
- marginRight = anchor === 'right' ? anchorOffset : 'auto';
1161
+ marginInlineStart = placement === 'leading' ? placementOffset : 'auto';
1162
+ marginInlineEnd = placement === 'trailing' ? placementOffset : 'auto';
1145
1163
  }
1146
1164
 
1147
1165
  if (maxWidth != null) next.maxWidth = maxWidth;
1148
- next.marginLeft = marginLeft;
1149
- next.marginRight = marginRight;
1166
+ next.marginInlineStart = marginInlineStart;
1167
+ next.marginInlineEnd = marginInlineEnd;
1150
1168
  return next;
1151
1169
  }, [
1152
1170
  isLandscapeOrTablet,
1153
1171
  isFormSheet,
1154
1172
  maxContentWidth,
1155
1173
  presentation,
1156
- anchor,
1157
- anchorOffset,
1174
+ placement,
1175
+ placementOffset,
1158
1176
  effectiveDetached,
1159
1177
  dropShadow,
1160
1178
  ]);
@@ -1191,6 +1209,7 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
1191
1209
  onDrag={handleDrag}
1192
1210
  onRelease={handleRelease}
1193
1211
  dismissible={dismissible}
1212
+ onDismissAttempt={handleDismissAttempt}
1194
1213
  draggable={draggable}
1195
1214
  repositionInputs={false}
1196
1215
  modal={dimmed}
@@ -1202,14 +1221,7 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
1202
1221
  peekHeight={peekHeight}
1203
1222
  initialAnimated={initialDetentAnimated}
1204
1223
  detachedWrapperStyle={wrapperStyle}
1205
- onContentHeightChange={setMeasuredContentHeight}
1206
- contentHeight={
1207
- // Bounded scrollable → the auto-size wrapper measures 0 (sized
1208
- // layout is absolute), so feed vaul the natural height instead.
1209
- hasAutoDetent && hasBoundedScrollable && scrollableAutoHeight > 0
1210
- ? scrollableAutoHeight
1211
- : undefined
1212
- }
1224
+ contentHeight={measuredContentHeight}
1213
1225
  activeSnapPoint={activeSnapPoint}
1214
1226
  setActiveSnapPoint={handleSetActiveSnapPoint}
1215
1227
  {...snapPointsProps}
@@ -1230,66 +1242,35 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
1230
1242
  ) : undefined
1231
1243
  }
1232
1244
  >
1245
+ <div aria-hidden style={backgroundLayerStyle} />
1233
1246
  <Drawer.Title style={visuallyHiddenStyle}>
1234
1247
  {accessibilityOptions?.paneTitle ?? 'Sheet'}
1235
1248
  </Drawer.Title>
1236
1249
  {grabber && <Drawer.Handle style={handleStyle} />}
1237
- {useSizedLayout ? (
1238
- // vaul wraps children in `[data-vaul-auto-size-wrapper]` (display:
1239
- // flow-root) which doesn't honor descendant flex layout. Use an
1240
- // absolute fill sized to the visible portion (via vaul's
1241
- // `--snap-point-height` var) so the inner flex column has a
1242
- // definite height for flex layouts to fill — mirrors native, where
1243
- // the container is sized to the sheet's visible height per detent.
1244
- <div
1245
- ref={sizedLayoutRef}
1246
- style={sizedLayoutStyle}
1247
- data-vaul-scroll-locked={isScrollLocked ? '' : undefined}
1248
- >
1249
- {header && (
1250
- <View ref={headerElRef} style={resolvedHeaderStyle} onLayout={handleHeaderLayout}>
1251
- {isValidElement(header) ? header : createElement(header)}
1252
- </View>
1253
- )}
1254
- {/* Content lays out naturally like native — fill only for auto
1255
- detents with a plugged scrollable, where natural layout is
1256
- circular (sheet height derives from the scroll content size).
1257
- Mirrors the native shadow node's scrollableBounded behavior. */}
1250
+ <div
1251
+ ref={sizedLayoutRef}
1252
+ style={sizedLayoutStyle}
1253
+ data-vaul-scroll-locked={isScrollLocked ? '' : undefined}
1254
+ >
1255
+ <TrueSheetBackground background={background} backgroundStyle={backgroundStyle} />
1256
+ {header && (
1257
+ <View ref={headerElRef} style={resolvedHeaderStyle} onLayout={handleHeaderLayout}>
1258
+ {isValidElement(header) ? header : createElement(header)}
1259
+ </View>
1260
+ )}
1261
+ <View ref={contentRef} style={[contentStyle, style]} onLayout={handleContentLayout}>
1262
+ {children}
1263
+ </View>
1264
+ {footer && !absoluteFooter && (
1258
1265
  <View
1259
- ref={contentRef}
1260
- style={hasAutoDetent && hasBoundedScrollable ? [contentFillStyle, style] : style}
1266
+ ref={footerElRef}
1267
+ style={[relativeFooterStyle, resolvedFooterStyle]}
1268
+ onLayout={handleFooterLayout}
1261
1269
  >
1262
- {children}
1263
- </View>
1264
- {footer && !absoluteFooter && (
1265
- <View
1266
- ref={footerElRef}
1267
- style={[relativeFooterStyle, resolvedFooterStyle]}
1268
- onLayout={handleFooterLayout}
1269
- >
1270
- {isValidElement(footer) ? footer : createElement(footer)}
1271
- </View>
1272
- )}
1273
- </div>
1274
- ) : (
1275
- // Natural flow so vaul can measure content height — required for
1276
- // 'auto' detents and form-sheet content-fit sizing.
1277
- <>
1278
- {header && (
1279
- <View ref={headerElRef} style={resolvedHeaderStyle} onLayout={handleHeaderLayout}>
1280
- {isValidElement(header) ? header : createElement(header)}
1281
- </View>
1282
- )}
1283
- <View ref={contentRef} style={style}>
1284
- {children}
1270
+ {isValidElement(footer) ? footer : createElement(footer)}
1285
1271
  </View>
1286
- {footer && !absoluteFooter && (
1287
- <View ref={footerElRef} style={resolvedFooterStyle} onLayout={handleFooterLayout}>
1288
- {isValidElement(footer) ? footer : createElement(footer)}
1289
- </View>
1290
- )}
1291
- </>
1292
- )}
1272
+ )}
1273
+ </div>
1293
1274
  </Drawer.Content>
1294
1275
  </Drawer.Portal>
1295
1276
  </Drawer.Root>
@@ -1327,12 +1308,11 @@ const sizedLayoutStyle: React.CSSProperties = {
1327
1308
  height: SIZED_LAYOUT_HEIGHT,
1328
1309
  display: 'flex',
1329
1310
  flexDirection: 'column',
1311
+ boxSizing: 'border-box',
1312
+ paddingBottom: 'inherit',
1330
1313
  };
1331
1314
 
1332
- const contentFillStyle = {
1333
- flex: 1,
1334
- minHeight: 0,
1335
- } as const;
1315
+ const contentStyle = { flexShrink: 1, minHeight: 0 } as const;
1336
1316
 
1337
1317
  const visuallyHiddenStyle: React.CSSProperties = {
1338
1318
  position: 'absolute',