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

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