@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
@@ -5,23 +5,24 @@ import { createElement, forwardRef, isValidElement, useCallback, useEffect, useI
5
5
  import { StyleSheet, useColorScheme, useWindowDimensions, View } from 'react-native';
6
6
  import { measurePeekContentHeight, TrueSheetPeekContext } from "./TrueSheetPeek.web.js";
7
7
  import { usePortalContainer, useRegisterSheet, useSheetStack } from "./TrueSheetProvider.web.js";
8
- import { COLOR_SURFACE_CONTAINER_LOW_DARK, COLOR_SURFACE_CONTAINER_LOW_LIGHT, DEFAULT_ANCHOR_OFFSET, DEFAULT_CORNER_RADIUS, DEFAULT_DETACHED_OFFSET, DEFAULT_FORM_SHEET_HEIGHT_RATIO, DEFAULT_FORM_SHEET_WIDTH, DEFAULT_GRABBER_COLOR_DARK, DEFAULT_GRABBER_COLOR_LIGHT, DEFAULT_GRABBER_HEIGHT, DEFAULT_GRABBER_TOP_MARGIN, DEFAULT_GRABBER_WIDTH, DEFAULT_MAX_WIDTH } from "./web/constants.js";
8
+ import { COLOR_SURFACE_CONTAINER_LOW_DARK, COLOR_SURFACE_CONTAINER_LOW_LIGHT, DEFAULT_PLACEMENT_OFFSET, DEFAULT_CORNER_RADIUS, DEFAULT_DETACHED_OFFSET, DEFAULT_FORM_SHEET_HEIGHT_RATIO, DEFAULT_FORM_SHEET_WIDTH, DEFAULT_GRABBER_COLOR_DARK, DEFAULT_GRABBER_COLOR_LIGHT, DEFAULT_GRABBER_HEIGHT, DEFAULT_GRABBER_TOP_MARGIN, DEFAULT_GRABBER_WIDTH, DEFAULT_MAX_WIDTH } from "./web/constants.js";
9
9
  import { getDOMElement } from "./web/dom.js";
10
+ import { TrueSheetBackground } from "./TrueSheetBackground.js";
11
+ import { getDetentBackgroundIndex, normalizeDetentBackground } from "./detentBackgrounds.js";
12
+ import { observeSheetLayout } from "./web/layout.js";
10
13
  import { Drawer } from "./web/vaul/index.js";
11
- import { DEFAULT_PEEK_HEIGHT, TRANSITIONS } from "./web/vaul/constants.js";
14
+ import { DEFAULT_PEEK_HEIGHT, DRAG_CLASS, TRANSITIONS } from "./web/vaul/constants.js";
12
15
 
13
- // First vertically scrollable descendant — web mirror of native's
14
- // findScrollView (RN-web ScrollView/FlatList render a node with
15
- // `overflow-y: auto`; its first child is the content container).
16
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
17
- const findVerticalScroller = root => {
18
- for (const el of root.querySelectorAll('*')) {
19
- const {
20
- overflowY
21
- } = window.getComputedStyle(el);
22
- if (overflowY === 'auto' || overflowY === 'scroll') return el;
23
- }
24
- return null;
16
+ // Resolves the user's `scrollableRef` to its scrollable DOM element — RN-web
17
+ // ScrollView refs expose the scroll node directly (with helpers attached);
18
+ // list refs (e.g. FlatList) expose it via getScrollableNode(). The node's
19
+ // first child is the content container.
20
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
21
+ const getScrollableElement = scrollable => {
22
+ if (!scrollable) return null;
23
+ if (scrollable instanceof HTMLElement) return scrollable;
24
+ const node = scrollable.getScrollableNode?.();
25
+ return node instanceof HTMLElement ? node : null;
25
26
  };
26
27
  const TrueSheetComponent = /*#__PURE__*/forwardRef((props, ref) => {
27
28
  const {
@@ -29,13 +30,19 @@ const TrueSheetComponent = /*#__PURE__*/forwardRef((props, ref) => {
29
30
  name,
30
31
  dismissible = true,
31
32
  draggable = true,
33
+ dismissThreshold = 'half',
32
34
  cornerRadius,
33
35
  style,
34
36
  backgroundColor: backgroundColorProp,
37
+ detentBackgrounds,
38
+ background,
39
+ backgroundStyle,
35
40
  maxContentHeight,
36
41
  maxContentWidth,
37
- anchor = 'center',
38
- anchorOffset = DEFAULT_ANCHOR_OFFSET,
42
+ placement = 'automatic',
43
+ placementOffset = DEFAULT_PLACEMENT_OFFSET,
44
+ scrollableRef,
45
+ scrollableOptions,
39
46
  grabber = true,
40
47
  grabberOptions,
41
48
  accessibilityOptions,
@@ -60,6 +67,7 @@ const TrueSheetComponent = /*#__PURE__*/forwardRef((props, ref) => {
60
67
  onDidPresent,
61
68
  onWillDismiss,
62
69
  onDidDismiss,
70
+ onDismissAttempt,
63
71
  onDetentChange,
64
72
  onDragBegin,
65
73
  onDragChange,
@@ -91,9 +99,13 @@ const TrueSheetComponent = /*#__PURE__*/forwardRef((props, ref) => {
91
99
  // is explicitly set.
92
100
  const effectiveDetached = isFormSheet || detached;
93
101
  const colorScheme = useColorScheme();
94
- const backgroundColor = backgroundColorProp ?? (colorScheme === 'dark' ? COLOR_SURFACE_CONTAINER_LOW_DARK : COLOR_SURFACE_CONTAINER_LOW_LIGHT);
95
102
  const shouldAutoPresent = initialDetentIndex >= 0 && initialDetentIndex < validDetents.length;
96
103
  const [isOpen, setIsOpen] = useState(shouldAutoPresent);
104
+ const isOpenRef = useRef(isOpen);
105
+ isOpenRef.current = isOpen;
106
+ const isPresentingRef = useRef(shouldAutoPresent);
107
+ const [displayedBackgroundIndex, setDisplayedBackgroundIndex] = useState(shouldAutoPresent ? initialDetentIndex : 0);
108
+ const backgroundColor = normalizeDetentBackground(detentBackgrounds?.[Math.min(displayedBackgroundIndex, validDetents.length - 1)])?.color ?? backgroundColorProp ?? (colorScheme === 'dark' ? COLOR_SURFACE_CONTAINER_LOW_DARK : COLOR_SURFACE_CONTAINER_LOW_LIGHT);
97
109
  const [activeSnapPoint, setActiveSnapPoint] = useState(() => validDetents[shouldAutoPresent ? initialDetentIndex : 0] ?? null);
98
110
 
99
111
  // Keep activeSnapPoint valid if detents change (e.g., prop updates).
@@ -103,7 +115,6 @@ const TrueSheetComponent = /*#__PURE__*/forwardRef((props, ref) => {
103
115
  }, [validDetents]);
104
116
  const validDetentsRef = useRef(validDetents);
105
117
  validDetentsRef.current = validDetents;
106
- const hasAutoDetent = validDetents.includes('auto');
107
118
  const handleSetActiveSnapPoint = useCallback(snapPoint => {
108
119
  setActiveSnapPoint(snapPoint == null ? null : typeof snapPoint === 'number' || snapPoint === 'auto' || snapPoint === 'peek' ? snapPoint : null);
109
120
  }, []);
@@ -122,24 +133,43 @@ const TrueSheetComponent = /*#__PURE__*/forwardRef((props, ref) => {
122
133
  e.preventDefault();
123
134
  return;
124
135
  }
136
+ if (!(target instanceof Element)) return;
137
+ const wrapper = drawerContentRef.current?.closest('[data-vaul-detached-wrapper]');
138
+ if (!wrapper) return;
125
139
  // The footer is rendered via vaul's `detachedSiblings` as a sibling of
126
140
  // Drawer.Content inside [data-vaul-detached-wrapper], so Radix treats
127
141
  // clicks on it as "outside" the content. Don't dismiss for clicks that
128
142
  // landed inside the wrapper.
129
- if (target instanceof Element) {
130
- const wrapper = drawerContentRef.current?.closest('[data-vaul-detached-wrapper]');
131
- if (wrapper && wrapper.contains(target)) {
132
- e.preventDefault();
133
- }
143
+ if (wrapper.contains(target)) {
144
+ e.preventDefault();
145
+ return;
134
146
  }
147
+ // Every sheet runs Radix non-modal and portals its overlay + wrapper as
148
+ // siblings into the shared container, so a press on a sheet stacked above
149
+ // this one (its content or its dim overlay) is also delivered here as
150
+ // "outside". Sheets present in document order, so anything after our
151
+ // wrapper belongs to a descendant. Radix may dispatch on the deferred
152
+ // click (touch), after a dismissing descendant has left the DOM — treat
153
+ // disconnected as above too.
154
+ /* eslint-disable no-bitwise */
155
+ const above = Node.DOCUMENT_POSITION_FOLLOWING | Node.DOCUMENT_POSITION_DISCONNECTED;
156
+ if (wrapper.compareDocumentPosition(target) & above) {
157
+ e.preventDefault();
158
+ }
159
+ /* eslint-enable no-bitwise */
135
160
  };
136
161
  const dismissAboveRef = useRef(async () => {});
162
+ const dismissDescendantsRef = useRef(() => {});
137
163
  const methods = useMemo(() => ({
138
164
  present: async (index = 0) => {
139
165
  const detent = validDetentsRef.current[index];
140
166
  if (detent === undefined) {
141
167
  throw new Error(`TrueSheet: present index (${index}) is out of bounds. detents array has ${validDetentsRef.current.length} item(s)`);
142
168
  }
169
+ if (!isOpenRef.current) {
170
+ isPresentingRef.current = true;
171
+ setDisplayedBackgroundIndex(index);
172
+ }
143
173
  setActiveSnapPoint(detent);
144
174
  setIsOpen(true);
145
175
  },
@@ -185,17 +215,40 @@ const TrueSheetComponent = /*#__PURE__*/forwardRef((props, ref) => {
185
215
  const [peekContentHeight, setPeekContentHeight] = useState(0);
186
216
  const contentRef = useRef(null);
187
217
  const peekElRef = useRef(null);
218
+ const measurePeek = useCallback(() => {
219
+ const peekEl = getDOMElement(peekElRef.current);
220
+ const contentEl = getDOMElement(contentRef.current);
221
+ if (peekEl && contentEl) {
222
+ setPeekContentHeight(measurePeekContentHeight(peekEl, contentEl));
223
+ }
224
+ }, []);
225
+
226
+ // Last-attached wins — during a screen swap the incoming peek mounts before
227
+ // the outgoing one unmounts, so the outgoing detach must not clear it.
188
228
  const peekContext = useMemo(() => ({
189
229
  contentRef,
190
- peekRef: peekElRef,
191
- setPeekContentHeight
192
- }), []);
230
+ attachPeek: view => {
231
+ peekElRef.current = view;
232
+ measurePeek();
233
+ },
234
+ detachPeek: view => {
235
+ if (peekElRef.current !== view) return;
236
+ peekElRef.current = null;
237
+ setPeekContentHeight(0);
238
+ },
239
+ measurePeek: view => {
240
+ if (peekElRef.current === view) measurePeek();
241
+ }
242
+ }), [measurePeek]);
243
+
244
+ // The peek's own onLayout only fires when *it* resizes. Content added above
245
+ // it moves it without resizing it, so re-measure its offset whenever the
246
+ // content view's layout changes.
247
+ const handleContentLayout = measurePeek;
193
248
 
194
249
  // An absolute (floating) header overlaps the content, so it contributes no
195
250
  // height to the 'auto' detent measurement.
196
251
  const absoluteHeader = headerOptions?.position === 'absolute';
197
- const absoluteHeaderRef = useRef(absoluteHeader);
198
- absoluteHeaderRef.current = absoluteHeader;
199
252
  const resolvedHeaderStyle = absoluteHeader ? [absoluteHeaderStyle, headerStyle] : headerStyle;
200
253
 
201
254
  // Same for an absolute (floating) footer — rendered via vaul's
@@ -222,115 +275,54 @@ const TrueSheetComponent = /*#__PURE__*/forwardRef((props, ref) => {
222
275
  // A relative footer is laid out below the content, so it's pushed off-screen
223
276
  // at the peek detent and contributes no height.
224
277
  const peekHeight = (header ? headerHeight : 0) + (footer && absoluteFooter ? footerHeight : 0) + peekContentHeight || DEFAULT_PEEK_HEIGHT;
225
-
226
- // Web mirror of native's scrollable handling for 'auto' detents: a plugged
227
- // scrollable keeps the sized (bounded) layout so its viewport is capped to
228
- // the visible sheet and can scroll, while the 'auto' height is measured with
229
- // the viewport replaced by the scrollable's content size — mirrors native
230
- // `naturalHeight`.
231
- const [hasBoundedScrollable, setHasBoundedScrollable] = useState(false);
232
- const [scrollableAutoHeight, setScrollableAutoHeight] = useState(0);
233
- const pinnedScrollerRef = useRef(null);
234
-
235
- // Natural content height (header + content + footer, with a pinned
236
- // scrollable's viewport replaced by its content size) — the height the
237
- // content wants regardless of the sheet's bounds.
238
- const measureNaturalHeight = useCallback(() => {
239
- const contentEl = getDOMElement(contentRef.current);
240
- if (!contentEl || !contentEl.isConnected) return 0;
241
- const headerEl = absoluteHeaderRef.current ? null : getDOMElement(headerElRef.current);
242
- const footerEl = absoluteFooterRef.current ? null : getDOMElement(footerElRef.current);
243
- let height = (headerEl?.offsetHeight ?? 0) + (footerEl?.offsetHeight ?? 0) + contentEl.offsetHeight;
244
- const scroller = pinnedScrollerRef.current;
245
- const scrollContent = scroller?.firstElementChild;
246
- if (scroller?.isConnected && scrollContent instanceof HTMLElement) {
247
- height += scrollContent.offsetHeight - scroller.clientHeight;
248
- }
249
- return Math.max(0, height);
250
- }, []);
278
+ const [measuredContentHeight, setMeasuredContentHeight] = useState(0);
279
+ const naturalHeightRef = useRef(0);
280
+ const insetBehavior = scrollableOptions?.contentInsetAdjustment ?? 'automatic';
281
+ const footerInsetAdjustment = Boolean(footer) && absoluteFooter && (insetBehavior === 'automatic' || insetBehavior === 'footer');
282
+ const measureContent = validDetents.includes('auto') || isFormSheet;
251
283
  useEffect(() => {
252
- if (!isOpen || !hasAutoDetent) return undefined;
284
+ if (!isOpen) return undefined;
253
285
  let canceled = false;
254
286
  let rafId = 0;
255
- let mutationObserver = null;
256
- let resizeObserver = null;
287
+ let observer;
257
288
  const attach = () => {
258
289
  if (canceled) return;
259
- const drawerEl = drawerContentRef.current;
260
- if (!drawerEl || !drawerEl.isConnected) {
290
+ const layoutEl = sizedLayoutRef.current;
291
+ const drawer = drawerContentRef.current;
292
+ if (!drawer || !layoutEl?.isConnected) {
261
293
  // Radix Presence defers the portal mount; poll until the drawer is live.
262
294
  rafId = window.requestAnimationFrame(attach);
263
295
  return;
264
296
  }
265
- const measure = () => setScrollableAutoHeight(measureNaturalHeight());
266
- let observed = null;
267
-
268
- // (Re)pin the first vertical scrollable and observe the nodes whose size
269
- // feeds the natural height — content growth inside a bounded scroller is
270
- // invisible to the sheet's own layout (mirrors native's contentSize
271
- // observation). Resolves the content node live: the layout-branch swap
272
- // (natural flow ↔ sized) remounts it.
273
- const observe = () => {
274
- const contentEl = getDOMElement(contentRef.current);
275
- const headerEl = getDOMElement(headerElRef.current);
276
-
277
- // Skip mutations that don't change the observed topology — e.g. a
278
- // virtualized list mounting rows inside the pinned scroller, which
279
- // would otherwise trigger a full-subtree rescan (findVerticalScroller
280
- // resolves computed styles) on every scroll frame. Size changes are
281
- // already covered by the ResizeObserver.
282
- if (observed && observed.contentEl === contentEl && contentEl?.isConnected && observed.headerEl === headerEl && observed.scroller.isConnected && observed.scrollContent === observed.scroller.firstElementChild) {
283
- return;
297
+ observer = observeSheetLayout({
298
+ drawer,
299
+ layout: layoutEl,
300
+ getContent: () => getDOMElement(contentRef.current),
301
+ getHeader: () => getDOMElement(headerElRef.current),
302
+ getFooter: () => getDOMElement(footerElRef.current),
303
+ getScrollable: () => getScrollableElement(scrollableRef?.current),
304
+ absoluteHeader,
305
+ absoluteFooter,
306
+ footerInsetAdjustment,
307
+ measureContent,
308
+ onHeightChange: height => {
309
+ naturalHeightRef.current = height;
310
+ setMeasuredContentHeight(height);
284
311
  }
285
- const scroller = contentEl && contentEl.isConnected ? findVerticalScroller(contentEl) : null;
286
- pinnedScrollerRef.current = scroller;
287
- setHasBoundedScrollable(scroller != null);
288
- observed = scroller ? {
289
- contentEl,
290
- headerEl,
291
- scroller,
292
- scrollContent: scroller.firstElementChild
293
- } : null;
294
- resizeObserver?.disconnect();
295
- resizeObserver = new ResizeObserver(measure);
296
- if (contentEl?.isConnected) resizeObserver.observe(contentEl);
297
- if (headerEl) resizeObserver.observe(headerEl);
298
- if (scroller) {
299
- resizeObserver.observe(scroller);
300
- if (scroller.firstElementChild) resizeObserver.observe(scroller.firstElementChild);
301
- }
302
- measure();
303
- };
304
- observe();
305
- // Watch the whole drawer subtree — catches the scrollable mounting/
306
- // unmounting AND the layout-branch swap, which remounts the content node
307
- // (a content-scoped observer would go stale after the swap).
308
- mutationObserver = new MutationObserver(observe);
309
- mutationObserver.observe(drawerEl, {
310
- childList: true,
311
- subtree: true
312
312
  });
313
313
  };
314
314
  rafId = window.requestAnimationFrame(attach);
315
315
  return () => {
316
316
  canceled = true;
317
317
  window.cancelAnimationFrame(rafId);
318
- mutationObserver?.disconnect();
319
- resizeObserver?.disconnect();
318
+ observer?.disconnect();
320
319
  };
321
- }, [isOpen, hasAutoDetent, measureNaturalHeight]);
320
+ }, [isOpen, absoluteHeader, absoluteFooter, footerInsetAdjustment, measureContent, scrollableRef]);
322
321
 
323
322
  // Below the last detent a vertical touch pan moves the sheet, not the
324
323
  // content — `[data-vaul-scroll-locked]` disables vertical touch panning on
325
324
  // the scroll container and everything inside it (see vaul/style.css).
326
325
  const isScrollLocked = validDetents.length > 0 && activeSnapPoint !== validDetents[validDetents.length - 1];
327
-
328
- // Vaul measures the auto-size wrapper's offsetHeight (always, post fork).
329
- // Track it here so the form sheet can size its card to fit content,
330
- // clamped between a minimum ratio of the viewport and a maximum derived
331
- // from `detachedOffset` (the breathing room left at top + bottom of the
332
- // floating card).
333
- const [measuredContentHeight, setMeasuredContentHeight] = useState(0);
334
326
  const effectiveMaxContentHeight = useMemo(() => {
335
327
  if (maxContentHeight !== undefined) return maxContentHeight;
336
328
  if (!isFormSheet) return undefined;
@@ -393,16 +385,14 @@ const TrueSheetComponent = /*#__PURE__*/forwardRef((props, ref) => {
393
385
  effectiveDetachedOffset,
394
386
  effectiveMaxContentHeight,
395
387
  peekHeight,
396
- peekContentHeight,
397
- measuredContentHeight
388
+ peekContentHeight
398
389
  });
399
390
  geometryInputsRef.current = {
400
391
  effectiveDetached,
401
392
  effectiveDetachedOffset,
402
393
  effectiveMaxContentHeight,
403
394
  peekHeight,
404
- peekContentHeight,
405
- measuredContentHeight
395
+ peekContentHeight
406
396
  };
407
397
  const computeDetentGeometry = useCallback(() => {
408
398
  const inputs = geometryInputsRef.current;
@@ -410,12 +400,9 @@ const TrueSheetComponent = /*#__PURE__*/forwardRef((props, ref) => {
410
400
  const effectiveH = inputs.effectiveDetached ? windowH - inputs.effectiveDetachedOffset : windowH;
411
401
  // Matches vaul's height ceiling: min(effectiveH, maxContentHeight).
412
402
  const ceiling = inputs.effectiveMaxContentHeight !== undefined ? Math.min(effectiveH, inputs.effectiveMaxContentHeight) : effectiveH;
413
- // 'auto' resolves to the content's natural height. Read it live — the
414
- // `measuredContentHeight` state lags mount by a frame (vaul's initial
415
- // ref-callback measure runs while the portal subtree is still detached,
416
- // so it reads 0 until the ResizeObserver fires). Falls back to the state
417
- // value, then to vaul's pre-measure fallback (effectiveHeight / 2).
418
- const measuredHeight = measureNaturalHeight() || inputs.measuredContentHeight;
403
+ // Reuse the intrinsic measurement during position events so animation
404
+ // frames don't clone the content tree again.
405
+ const measuredHeight = naturalHeightRef.current;
419
406
  const autoHeight = Math.min(measuredHeight > 0 ? measuredHeight : effectiveH / 2, ceiling);
420
407
 
421
408
  // 'peek' is measured live from the DOM (offsetHeight is layout-based, so
@@ -446,7 +433,7 @@ const TrueSheetComponent = /*#__PURE__*/forwardRef((props, ref) => {
446
433
  positions,
447
434
  values
448
435
  };
449
- }, [measureNaturalHeight]);
436
+ }, []);
450
437
 
451
438
  // Detent info for lifecycle events. Position/detent come from the active
452
439
  // detent's target geometry — not the live DOM rect — so willPresent (drawer
@@ -533,50 +520,20 @@ const TrueSheetComponent = /*#__PURE__*/forwardRef((props, ref) => {
533
520
  detent: values[count - 1]
534
521
  };
535
522
  }, [computeDetentGeometry]);
536
-
537
- // Mirror native synchronous layout: while dragging (and through the post-
538
- // release settle) the sized layout tracks the sheet's visible height in
539
- // realtime, clamped to the smallest detent — dragging below it slides the
540
- // sheet out without resizing. Once settled, the inline height is cleared so
541
- // the CSS calc() owns sizing again (adapts to viewport resizes at rest).
542
523
  const sizedLayoutRef = useRef(null);
543
524
  const isDraggingRef = useRef(false);
544
- const isSettlingAfterDragRef = useRef(false);
545
- const updateSizedLayoutHeight = useCallback(position => {
546
- const node = sizedLayoutRef.current;
547
- if (!node) return;
548
- const {
549
- effectiveH,
550
- ceiling,
551
- positions
552
- } = computeDetentGeometry();
553
- if (positions.length === 0) return;
554
- if (isSettlingAfterDragRef.current) {
555
- const snap = activeSnapPointRef.current;
556
- const index = snap != null ? validDetentsRef.current.indexOf(snap) : -1;
557
- const target = index >= 0 ? positions[index] : null;
558
- if (target != null && Math.abs(position - target) < 1) {
559
- isSettlingAfterDragRef.current = false;
560
- // Restore the calc — height is an inline style, so clearing it would
561
- // leave the div unsized (React won't re-apply it without a render)
562
- node.style.height = SIZED_LAYOUT_HEIGHT;
563
- return;
564
- }
565
- }
566
-
567
- // positions[0] is the smallest detent's top-Y — its height is the clamp floor.
568
- const minHeight = effectiveH - positions[0];
569
- const height = Math.min(Math.max(effectiveH - position, minHeight), ceiling);
570
- node.style.height = `${height}px`;
571
- }, [computeDetentGeometry]);
572
525
  const handlePositionChange = useCallback(position => {
573
- if (isDraggingRef.current || isSettlingAfterDragRef.current) {
574
- updateSizedLayoutHeight(position);
575
- }
576
526
  const {
577
527
  index,
578
528
  detent
579
529
  } = interpolateFromPosition(position);
530
+ if (isOpenRef.current && !isPresentingRef.current) {
531
+ const {
532
+ effectiveH,
533
+ positions
534
+ } = computeDetentGeometry();
535
+ setDisplayedBackgroundIndex(current => getDetentBackgroundIndex(effectiveH - position, positions.map(top => effectiveH - top), current));
536
+ }
580
537
  onPositionChangeRef.current?.({
581
538
  nativeEvent: {
582
539
  index,
@@ -585,7 +542,7 @@ const TrueSheetComponent = /*#__PURE__*/forwardRef((props, ref) => {
585
542
  realtime: true
586
543
  }
587
544
  });
588
- }, [interpolateFromPosition, updateSizedLayoutHeight]);
545
+ }, [interpolateFromPosition, computeDetentGeometry]);
589
546
 
590
547
  // Fire onMount once after first render. React-mount is the earliest point
591
548
  // the component is ready for imperative calls, matching the native
@@ -611,6 +568,9 @@ const TrueSheetComponent = /*#__PURE__*/forwardRef((props, ref) => {
611
568
  if (isOpen === wasOpen) return undefined;
612
569
  const present = isOpen;
613
570
  if (!present) {
571
+ // Mirror native: dismissing a sheet takes every sheet stacked above it
572
+ // (iOS dismisses from the presenter; Android runs dismissStack first).
573
+ dismissDescendantsRef.current();
614
574
  // Pair willBlur with willDismiss on dismiss — mirrors native iOS
615
575
  // emitWillDismissEvents (blur fires before dismiss). willPresent is
616
576
  // deferred to `start()` below (needs the mounted drawer's geometry).
@@ -623,6 +583,7 @@ const TrueSheetComponent = /*#__PURE__*/forwardRef((props, ref) => {
623
583
  }
624
584
  const fireDone = () => {
625
585
  if (present) {
586
+ isPresentingRef.current = false;
626
587
  onDidPresentRef.current?.({
627
588
  nativeEvent: computeDetentInfo()
628
589
  });
@@ -728,12 +689,9 @@ const TrueSheetComponent = /*#__PURE__*/forwardRef((props, ref) => {
728
689
  nativeEvent: computeDetentInfo(true)
729
690
  });
730
691
  }, [computeDetentInfo]);
731
- const handleRelease = useCallback((_event, open) => {
692
+ const handleRelease = useCallback(() => {
732
693
  if (!isDraggingRef.current) return;
733
694
  isDraggingRef.current = false;
734
- // Track the sized layout through the settle animation only when the sheet
735
- // stays open — a dismissal slides out at the min-clamped height.
736
- isSettlingAfterDragRef.current = open;
737
695
  onDragEndRef.current?.({
738
696
  nativeEvent: computeDetentInfo(true)
739
697
  });
@@ -744,10 +702,18 @@ const TrueSheetComponent = /*#__PURE__*/forwardRef((props, ref) => {
744
702
  descendants
745
703
  } = useSheetStack(methodsRef, drawerContentRef, isOpen, isFormSheet);
746
704
  dismissAboveRef.current = dismissAbove;
705
+ // Captured per render: by the time the dismiss effect runs, this sheet has
706
+ // already been popped from the stack, so `dismissAbove` can't find it.
707
+ dismissDescendantsRef.current = () => {
708
+ for (let i = descendants.length - 1; i >= 0; i--) {
709
+ descendants[i].ref.current?.dismiss();
710
+ }
711
+ };
747
712
 
748
713
  // Mirror Android: translate this sheet down to match the deepest descendant's
749
714
  // top so the whole stack visually aligns. Cascades because every ancestor
750
715
  // re-runs whenever the stack (and thus its descendants) changes.
716
+ const wasCascadedRef = useRef(false);
751
717
  useEffect(() => {
752
718
  const parent = drawerContentRef.current;
753
719
  if (!parent) return;
@@ -758,7 +724,7 @@ const TrueSheetComponent = /*#__PURE__*/forwardRef((props, ref) => {
758
724
  // out — nothing to align with anymore.
759
725
  if (!isOpen) return;
760
726
  const parentWrapper = parent.closest('[data-vaul-detached-wrapper]');
761
- const transition = `transform ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`;
727
+ const transition = `transform ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')}), --snap-point-height ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`;
762
728
  const wrapperTransition = `clip-path ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`;
763
729
  const CLIP_NONE = 'inset(0px round 0px)';
764
730
 
@@ -781,22 +747,29 @@ const TrueSheetComponent = /*#__PURE__*/forwardRef((props, ref) => {
781
747
  parentWrapper.style.clipPath = next;
782
748
  };
783
749
  if (descendants.length === 0) {
750
+ if (!wasCascadedRef.current) return;
751
+ wasCascadedRef.current = false;
784
752
  parent.style.transition = transition;
785
753
  parent.style.transform = '';
786
754
  setClip(CLIP_NONE);
787
755
  return;
788
756
  }
757
+ wasCascadedRef.current = true;
789
758
 
790
- // Track only the immediate child's snap point. Walking deeper descendants
791
- // would push this sheet further when a grandchild opens, even when our
792
- // own child didn't move (e.g., child skipped its cascade for a page
793
- // grandchild) — leaving a visible gap between this sheet and its child.
759
+ // Track the immediate child's *actual* top: its cascade transform when it
760
+ // was itself pushed behind a grandchild, else its own snap point. Mirrors
761
+ // Android's addTranslation propagating extra translation up the stack —
762
+ // a grandchild opening pushes this sheet only as far as our child moved.
763
+ const readTranslateY = el => {
764
+ const match = el.style.transform.match(/translate3d\([^,]*,\s*(-?\d*\.?\d+)px/);
765
+ return match ? Number.parseFloat(match[1]) : null;
766
+ };
794
767
  const computeTargetY = () => {
795
768
  const parentSnap = Number.parseFloat(parent.style.getPropertyValue('--snap-point-height')) || 0;
796
769
  const node = descendants[0]?.nodeRef.current;
797
770
  if (!node) return parentSnap;
798
- const childSnap = Number.parseFloat(node.style.getPropertyValue('--snap-point-height')) || 0;
799
- return Math.max(parentSnap, childSnap);
771
+ const childTop = readTranslateY(node) ?? (Number.parseFloat(node.style.getPropertyValue('--snap-point-height')) || 0);
772
+ return Math.max(parentSnap, childTop);
800
773
  };
801
774
 
802
775
  // When a form-sheet parent has a form-sheet descendant, clip the parent to
@@ -830,18 +803,26 @@ const TrueSheetComponent = /*#__PURE__*/forwardRef((props, ref) => {
830
803
  };
831
804
  const apply = () => {
832
805
  applyFormClip();
833
- // Mirror iOS: a page-sheet child fully covers a form-sheet parent, so the
834
- // cascade push-down has no visible effect — and would briefly peek above
835
- // the page during the present animation. Leave the parent put.
836
- const child = descendants[0];
837
- if (isFormSheet && child && !child.isFormSheetRef.current) {
806
+ // Mirror iPad/Android (`isExpanded`): a parent at full height (detent 1)
807
+ // stays put and the child simply overlays it. Full height is the sheet's
808
+ // own ceiling — the viewport for a page sheet, the capped content-fit
809
+ // height for a form sheet — so compare against where detent 1 lands.
810
+ const parentSnap = Number.parseFloat(parent.style.getPropertyValue('--snap-point-height')) || 0;
811
+ const {
812
+ effectiveH,
813
+ ceiling
814
+ } = computeDetentGeometry();
815
+ if (parentSnap <= effectiveH - ceiling + 0.5) {
838
816
  parent.style.transition = transition;
839
817
  parent.style.transform = '';
840
818
  return;
841
819
  }
820
+ // Mirror Android: stay put while the child is being dragged (its live
821
+ // transform would drag us along during drag-to-dismiss). The release
822
+ // snap rewrites the child's style and re-applies.
823
+ if (descendants[0]?.nodeRef.current?.classList.contains(DRAG_CLASS)) return;
842
824
  const targetY = computeTargetY();
843
- const match = parent.style.transform.match(/translate3d\([^,]*,\s*(-?\d*\.?\d+)px/);
844
- const currentY = match ? Number.parseFloat(match[1]) : 0;
825
+ const currentY = readTranslateY(parent) ?? 0;
845
826
  if (Math.abs(currentY - targetY) < 0.5) return;
846
827
  parent.style.transition = transition;
847
828
  parent.style.transform = `translate3d(0, ${targetY}px, 0)`;
@@ -855,11 +836,17 @@ const TrueSheetComponent = /*#__PURE__*/forwardRef((props, ref) => {
855
836
  attributes: true,
856
837
  attributeFilter: ['style']
857
838
  });
839
+ // The child's own cascade lands in a later frame — follow it when it moves.
840
+ const childNode = descendants[0]?.nodeRef.current;
841
+ if (childNode) observer.observe(childNode, {
842
+ attributes: true,
843
+ attributeFilter: ['style']
844
+ });
858
845
  return () => {
859
846
  cancelAnimationFrame(raf);
860
847
  observer.disconnect();
861
848
  };
862
- }, [descendants, activeSnapPoint, cornerRadius, isFormSheet, isOpen]);
849
+ }, [descendants, activeSnapPoint, cornerRadius, isFormSheet, isOpen, computeDetentGeometry]);
863
850
 
864
851
  // Focus/blur events fire when a descendant sheet appears on top of this one
865
852
  // (blur) or when all descendants are dismissed (focus). will-events fire
@@ -870,12 +857,19 @@ const TrueSheetComponent = /*#__PURE__*/forwardRef((props, ref) => {
870
857
  const onDidBlurRef = useRef(onDidBlur);
871
858
  const onWillFocusRef = useRef(onWillFocus);
872
859
  const onDidFocusRef = useRef(onDidFocus);
860
+ const onDismissAttemptRef = useRef(onDismissAttempt);
873
861
  useEffect(() => {
874
862
  onWillBlurRef.current = onWillBlur;
875
863
  onDidBlurRef.current = onDidBlur;
876
864
  onWillFocusRef.current = onWillFocus;
877
865
  onDidFocusRef.current = onDidFocus;
866
+ onDismissAttemptRef.current = onDismissAttempt;
878
867
  });
868
+ const handleDismissAttempt = useCallback(() => {
869
+ onDismissAttemptRef.current?.({
870
+ nativeEvent: null
871
+ });
872
+ }, []);
879
873
  const prevDescendantCountRef = useRef(0);
880
874
  useEffect(() => {
881
875
  const prevCount = prevDescendantCountRef.current;
@@ -924,11 +918,6 @@ const TrueSheetComponent = /*#__PURE__*/forwardRef((props, ref) => {
924
918
  window.cancelAnimationFrame(rafId);
925
919
  };
926
920
  }, [isOpen, descendants.length]);
927
-
928
- // Definite-height flex layout (per-detent sizing) unless content must be
929
- // measured in natural flow: 'auto' detents (without a plugged scrollable —
930
- // those measure via `measureNaturalHeight`) and form-sheet content-fit sizing.
931
- const useSizedLayout = (!hasAutoDetent || hasBoundedScrollable) && !isFormSheet;
932
921
  const effectiveCornerRadius = cornerRadius ?? DEFAULT_CORNER_RADIUS;
933
922
 
934
923
  // Shadow cast upward from the sheet's top edge toward the background. Matches
@@ -942,6 +931,13 @@ const TrueSheetComponent = /*#__PURE__*/forwardRef((props, ref) => {
942
931
  // wrapper edges). drop-shadow on the wrapper follows the post-clip silhouette
943
932
  // and isn't clipped by the wrapper itself.
944
933
  const dropShadow = elevation > 0 ? `drop-shadow(0 ${-elevation}px ${elevation * 3}px rgba(0, 0, 0, 0.15))` : undefined;
934
+ const backgroundLayerStyle = {
935
+ position: 'absolute',
936
+ inset: 0,
937
+ pointerEvents: 'none',
938
+ backgroundColor: backgroundColor,
939
+ transition: isPresentingRef.current ? 'none' : 'background-color 200ms'
940
+ };
945
941
  const mergedContentStyle = useMemo(() => ({
946
942
  position: 'fixed',
947
943
  top: 0,
@@ -952,7 +948,6 @@ const TrueSheetComponent = /*#__PURE__*/forwardRef((props, ref) => {
952
948
  flexDirection: 'column',
953
949
  borderTopLeftRadius: effectiveCornerRadius,
954
950
  borderTopRightRadius: effectiveCornerRadius,
955
- backgroundColor: backgroundColor,
956
951
  // Clip children to the rounded top so headers/content with their own
957
952
  // background don't bleed past the corners. `clip` (not `hidden`): a
958
953
  // hidden box is still a scroll container, so browser focus-reveal (e.g.
@@ -962,8 +957,11 @@ const TrueSheetComponent = /*#__PURE__*/forwardRef((props, ref) => {
962
957
  // Lift content above iOS home indicator / bottom safe area when enabled.
963
958
  // A relative footer owns the inset instead (see resolvedFooterStyle); an
964
959
  // absolute footer floats over the content, so the content keeps its lift.
965
- paddingBottom: insetAdjustment === 'automatic' && !(footerOwnsInset && !absoluteFooter) ? 'env(safe-area-inset-bottom, 0px)' : 0
966
- }), [backgroundColor, effectiveCornerRadius, insetAdjustment, footerOwnsInset, absoluteFooter]);
960
+ // A plugged scrollable keeps the lift too (contentInsetAdjustment
961
+ // 'automatic' | 'safe-area') — 'footer' | 'never' scroll edge-to-edge
962
+ // behind the indicator with user-applied content padding.
963
+ paddingBottom: insetAdjustment === 'automatic' && !(footerOwnsInset && !absoluteFooter) && (!scrollableRef || insetBehavior === 'automatic' || insetBehavior === 'safe-area') ? 'env(safe-area-inset-bottom, 0px)' : 0
964
+ }), [effectiveCornerRadius, insetAdjustment, footerOwnsInset, absoluteFooter, scrollableRef, insetBehavior]);
967
965
  const defaultGrabberColor = colorScheme === 'dark' ? DEFAULT_GRABBER_COLOR_DARK : DEFAULT_GRABBER_COLOR_LIGHT;
968
966
  const grabberHeight = grabberOptions?.height ?? DEFAULT_GRABBER_HEIGHT;
969
967
 
@@ -994,7 +992,7 @@ const TrueSheetComponent = /*#__PURE__*/forwardRef((props, ref) => {
994
992
  // `maxContentWidth` is ignored ('form' is absolute).
995
993
  // - presentation='page' → `maxContentWidth` (any viewport) or
996
994
  // DEFAULT_MAX_WIDTH (tablet/landscape readability cap).
997
- // Detached without a width constraint applies anchorOffset on both edges so
995
+ // Detached without a width constraint applies placementOffset on both edges so
998
996
  // the floating card breathes from the viewport sides.
999
997
  const wrapperStyle = useMemo(() => {
1000
998
  // Mobile portrait ignores width sizing entirely (matches iOS/Android:
@@ -1006,23 +1004,23 @@ const TrueSheetComponent = /*#__PURE__*/forwardRef((props, ref) => {
1006
1004
  const next = {};
1007
1005
  if (dropShadow) next.filter = dropShadow;
1008
1006
  if (!needsMargins) return next;
1009
- let marginLeft;
1010
- let marginRight;
1007
+ let marginInlineStart;
1008
+ let marginInlineEnd;
1011
1009
  if (isFormSheet) {
1012
- marginLeft = 'auto';
1013
- marginRight = 'auto';
1010
+ marginInlineStart = 'auto';
1011
+ marginInlineEnd = 'auto';
1014
1012
  } else if (maxWidth == null) {
1015
- marginLeft = anchorOffset;
1016
- marginRight = anchorOffset;
1013
+ marginInlineStart = placementOffset;
1014
+ marginInlineEnd = placementOffset;
1017
1015
  } else {
1018
- marginLeft = anchor === 'left' ? anchorOffset : 'auto';
1019
- marginRight = anchor === 'right' ? anchorOffset : 'auto';
1016
+ marginInlineStart = placement === 'leading' ? placementOffset : 'auto';
1017
+ marginInlineEnd = placement === 'trailing' ? placementOffset : 'auto';
1020
1018
  }
1021
1019
  if (maxWidth != null) next.maxWidth = maxWidth;
1022
- next.marginLeft = marginLeft;
1023
- next.marginRight = marginRight;
1020
+ next.marginInlineStart = marginInlineStart;
1021
+ next.marginInlineEnd = marginInlineEnd;
1024
1022
  return next;
1025
- }, [isLandscapeOrTablet, isFormSheet, maxContentWidth, presentation, anchor, anchorOffset, effectiveDetached, dropShadow]);
1023
+ }, [isLandscapeOrTablet, isFormSheet, maxContentWidth, presentation, placement, placementOffset, effectiveDetached, dropShadow]);
1026
1024
 
1027
1025
  // Absolute-position the grabber so it overlays the content top-edge
1028
1026
  // instead of consuming flow height — mirrors native iOS/Android, where
@@ -1052,6 +1050,8 @@ const TrueSheetComponent = /*#__PURE__*/forwardRef((props, ref) => {
1052
1050
  onDrag: handleDrag,
1053
1051
  onRelease: handleRelease,
1054
1052
  dismissible: dismissible,
1053
+ dismissThreshold: dismissThreshold,
1054
+ onDismissAttempt: handleDismissAttempt,
1055
1055
  draggable: draggable,
1056
1056
  repositionInputs: false,
1057
1057
  modal: dimmed,
@@ -1063,11 +1063,7 @@ const TrueSheetComponent = /*#__PURE__*/forwardRef((props, ref) => {
1063
1063
  peekHeight: peekHeight,
1064
1064
  initialAnimated: initialDetentAnimated,
1065
1065
  detachedWrapperStyle: wrapperStyle,
1066
- onContentHeightChange: setMeasuredContentHeight,
1067
- contentHeight:
1068
- // Bounded scrollable → the auto-size wrapper measures 0 (sized
1069
- // layout is absolute), so feed vaul the natural height instead.
1070
- hasAutoDetent && hasBoundedScrollable && scrollableAutoHeight > 0 ? scrollableAutoHeight : undefined,
1066
+ contentHeight: measuredContentHeight,
1071
1067
  activeSnapPoint: activeSnapPoint,
1072
1068
  setActiveSnapPoint: handleSetActiveSnapPoint,
1073
1069
  ...snapPointsProps,
@@ -1088,31 +1084,30 @@ const TrueSheetComponent = /*#__PURE__*/forwardRef((props, ref) => {
1088
1084
  children: /*#__PURE__*/isValidElement(footer) ? footer : /*#__PURE__*/createElement(footer)
1089
1085
  })
1090
1086
  }) : undefined,
1091
- children: [/*#__PURE__*/_jsx(Drawer.Title, {
1087
+ children: [/*#__PURE__*/_jsx("div", {
1088
+ "aria-hidden": true,
1089
+ style: backgroundLayerStyle
1090
+ }), /*#__PURE__*/_jsx(Drawer.Title, {
1092
1091
  style: visuallyHiddenStyle,
1093
1092
  children: accessibilityOptions?.paneTitle ?? 'Sheet'
1094
1093
  }), grabber && /*#__PURE__*/_jsx(Drawer.Handle, {
1095
1094
  style: handleStyle
1096
- }), useSizedLayout ?
1097
- /*#__PURE__*/
1098
- // vaul wraps children in `[data-vaul-auto-size-wrapper]` (display:
1099
- // flow-root) which doesn't honor descendant flex layout. Use an
1100
- // absolute fill sized to the visible portion (via vaul's
1101
- // `--snap-point-height` var) so the inner flex column has a
1102
- // definite height for flex layouts to fill — mirrors native, where
1103
- // the container is sized to the sheet's visible height per detent.
1104
- _jsxs("div", {
1095
+ }), /*#__PURE__*/_jsxs("div", {
1105
1096
  ref: sizedLayoutRef,
1106
1097
  style: sizedLayoutStyle,
1107
1098
  "data-vaul-scroll-locked": isScrollLocked ? '' : undefined,
1108
- children: [header && /*#__PURE__*/_jsx(View, {
1099
+ children: [/*#__PURE__*/_jsx(TrueSheetBackground, {
1100
+ background: background,
1101
+ backgroundStyle: backgroundStyle
1102
+ }), header && /*#__PURE__*/_jsx(View, {
1109
1103
  ref: headerElRef,
1110
1104
  style: resolvedHeaderStyle,
1111
1105
  onLayout: handleHeaderLayout,
1112
1106
  children: /*#__PURE__*/isValidElement(header) ? header : /*#__PURE__*/createElement(header)
1113
1107
  }), /*#__PURE__*/_jsx(View, {
1114
1108
  ref: contentRef,
1115
- style: hasAutoDetent && hasBoundedScrollable ? [contentFillStyle, style] : style,
1109
+ style: [contentStyle, style],
1110
+ onLayout: handleContentLayout,
1116
1111
  children: children
1117
1112
  }), footer && !absoluteFooter && /*#__PURE__*/_jsx(View, {
1118
1113
  ref: footerElRef,
@@ -1120,26 +1115,6 @@ const TrueSheetComponent = /*#__PURE__*/forwardRef((props, ref) => {
1120
1115
  onLayout: handleFooterLayout,
1121
1116
  children: /*#__PURE__*/isValidElement(footer) ? footer : /*#__PURE__*/createElement(footer)
1122
1117
  })]
1123
- }) :
1124
- /*#__PURE__*/
1125
- // Natural flow so vaul can measure content height — required for
1126
- // 'auto' detents and form-sheet content-fit sizing.
1127
- _jsxs(_Fragment, {
1128
- children: [header && /*#__PURE__*/_jsx(View, {
1129
- ref: headerElRef,
1130
- style: resolvedHeaderStyle,
1131
- onLayout: handleHeaderLayout,
1132
- children: /*#__PURE__*/isValidElement(header) ? header : /*#__PURE__*/createElement(header)
1133
- }), /*#__PURE__*/_jsx(View, {
1134
- ref: contentRef,
1135
- style: style,
1136
- children: children
1137
- }), footer && !absoluteFooter && /*#__PURE__*/_jsx(View, {
1138
- ref: footerElRef,
1139
- style: resolvedFooterStyle,
1140
- onLayout: handleFooterLayout,
1141
- children: /*#__PURE__*/isValidElement(footer) ? footer : /*#__PURE__*/createElement(footer)
1142
- })]
1143
1118
  })]
1144
1119
  })]
1145
1120
  })
@@ -1173,10 +1148,12 @@ const sizedLayoutStyle = {
1173
1148
  right: 0,
1174
1149
  height: SIZED_LAYOUT_HEIGHT,
1175
1150
  display: 'flex',
1176
- flexDirection: 'column'
1151
+ flexDirection: 'column',
1152
+ boxSizing: 'border-box',
1153
+ paddingBottom: 'inherit'
1177
1154
  };
1178
- const contentFillStyle = {
1179
- flex: 1,
1155
+ const contentStyle = {
1156
+ flexShrink: 1,
1180
1157
  minHeight: 0
1181
1158
  };
1182
1159
  const visuallyHiddenStyle = {