@lodev09/react-native-true-sheet 4.0.0-beta.15 → 4.0.0-beta.16

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.
@@ -32,6 +32,11 @@ class TrueSheetOverlayView(private val reactContext: ThemedReactContext) :
32
32
  private var lastWidth = 0
33
33
  private var lastHeight = 0
34
34
 
35
+ // Fabric hands back a new StateWrapper on every state commit — including our
36
+ // own pushes — so pushState dedupes against the last pushed size to break the loop
37
+ private var lastStateWidth = 0
38
+ private var lastStateHeight = 0
39
+
35
40
  var stateWrapper: StateWrapper? = null
36
41
  set(value) {
37
42
  field = value
@@ -101,6 +106,11 @@ class TrueSheetOverlayView(private val reactContext: ThemedReactContext) :
101
106
 
102
107
  private fun pushState() {
103
108
  if (lastWidth == 0 && lastHeight == 0) return
104
- stateWrapper?.let { TrueSheetStateUpdater.updateContainerSize(it, lastWidth, lastHeight) }
109
+ if (lastWidth == lastStateWidth && lastHeight == lastStateHeight) return
110
+ val sw = stateWrapper ?: return
111
+
112
+ lastStateWidth = lastWidth
113
+ lastStateHeight = lastHeight
114
+ TrueSheetStateUpdater.updateContainerSize(sw, lastWidth, lastHeight)
105
115
  }
106
116
  }
@@ -139,6 +139,9 @@ class TrueSheetDimView(private val reactContext: ThemedReactContext) :
139
139
 
140
140
  override fun onTouchEvent(event: MotionEvent): Boolean {
141
141
  if (blockGestures) {
142
+ if (event.actionMasked == MotionEvent.ACTION_DOWN) {
143
+ parent?.requestDisallowInterceptTouchEvent(true)
144
+ }
142
145
  // When dimmed, consume touch and trigger click on ACTION_UP
143
146
  if (event.action == MotionEvent.ACTION_UP) {
144
147
  performClick()
@@ -155,6 +155,8 @@ using namespace facebook::react;
155
155
  _lastHeight = height;
156
156
  [self.delegate footerViewDidChangeSize:CGSizeMake(layoutMetrics.frame.size.width, height)];
157
157
  }
158
+
159
+ [self applyKeyboardOffset];
158
160
  }
159
161
 
160
162
  - (void)prepareForRecycle {
@@ -184,6 +186,20 @@ using namespace facebook::react;
184
186
  return controller.absoluteFooter && controller.footerAvoidsKeyboard;
185
187
  }
186
188
 
189
+ - (CGFloat)keyboardSlideForHeight:(CGFloat)height {
190
+ UIWindow *window = self.window;
191
+ if (!window) {
192
+ return 0;
193
+ }
194
+
195
+ // Measure in the transform's coordinate space, excluding the footer's current slide.
196
+ CGFloat footerBottom = self.center.y + self.bounds.size.height / 2;
197
+ CGPoint keyboardTopInWindow = CGPointMake(CGRectGetMidX(window.bounds), CGRectGetMaxY(window.bounds) - height);
198
+ CGFloat keyboardTop = [self.superview convertPoint:keyboardTopInWindow fromView:window].y;
199
+ CGFloat keyboardOffset = self.keyboardObserver.viewController.footerKeyboardOffset;
200
+ return MAX(0, footerBottom - keyboardTop + keyboardOffset);
201
+ }
202
+
187
203
  // Yoga owns the footer's frame (pinned to the container's bottom edge), so the
188
204
  // keyboard slide is carried by a transform instead of layout.
189
205
  - (void)keyboardWillShow:(CGFloat)height duration:(NSTimeInterval)duration curve:(UIViewAnimationOptions)curve {
@@ -193,18 +209,19 @@ using namespace facebook::react;
193
209
 
194
210
  [self setKeyboardVisible:YES];
195
211
 
196
- CGFloat keyboardOffset = self.keyboardObserver.viewController.footerKeyboardOffset;
197
- CGFloat slide = MAX(0, height + keyboardOffset);
212
+ CGFloat slide = [self keyboardSlideForHeight:height];
198
213
  _currentKeyboardOffset = slide;
199
214
 
200
215
  [UIView animateWithDuration:duration
201
- delay:0
202
- options:curve | UIViewAnimationOptionBeginFromCurrentState
203
- animations:^{
204
- self.transform = CGAffineTransformMakeTranslation(0, -slide);
205
- [self syncEdgeEffect];
206
- }
207
- completion:nil];
216
+ delay:0
217
+ options:curve | UIViewAnimationOptionBeginFromCurrentState
218
+ animations:^{
219
+ self.transform = CGAffineTransformMakeTranslation(0, -slide);
220
+ [self syncEdgeEffect];
221
+ }
222
+ completion:^(BOOL finished) {
223
+ [self applyKeyboardOffset];
224
+ }];
208
225
  }
209
226
 
210
227
  - (void)keyboardWillHide:(NSTimeInterval)duration curve:(UIViewAnimationOptions)curve {
@@ -252,8 +269,7 @@ using namespace facebook::react;
252
269
 
253
270
  [self setKeyboardVisible:YES];
254
271
 
255
- CGFloat keyboardOffset = self.keyboardObserver.viewController.footerKeyboardOffset;
256
- CGFloat slide = MAX(0, height + keyboardOffset);
272
+ CGFloat slide = [self keyboardSlideForHeight:height];
257
273
  _currentKeyboardOffset = slide;
258
274
  self.transform = CGAffineTransformMakeTranslation(0, -slide);
259
275
  }
@@ -831,6 +831,7 @@ using namespace facebook::react;
831
831
 
832
832
  - (void)viewControllerSafeAreaInsetsDidChange {
833
833
  [self refreshFooterBottomInset];
834
+ [_containerView updateFooterKeyboardOffset];
834
835
  }
835
836
 
836
837
  - (void)viewControllerWillFocus {
@@ -7,6 +7,7 @@ import { measurePeekContentHeight, TrueSheetPeekContext } from "./TrueSheetPeek.
7
7
  import { usePortalContainer, useRegisterSheet, useSheetStack } from "./TrueSheetProvider.web.js";
8
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 { observeSheetLayout } from "./web/layout.js";
10
11
  import { Drawer } from "./web/vaul/index.js";
11
12
  import { DEFAULT_PEEK_HEIGHT, DRAG_CLASS, TRANSITIONS } from "./web/vaul/constants.js";
12
13
 
@@ -14,7 +15,7 @@ import { DEFAULT_PEEK_HEIGHT, DRAG_CLASS, TRANSITIONS } from "./web/vaul/constan
14
15
  // ScrollView refs expose the scroll node directly (with helpers attached);
15
16
  // list refs (e.g. FlatList) expose it via getScrollableNode(). The node's
16
17
  // first child is the content container.
17
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
18
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
18
19
  const getScrollableElement = scrollable => {
19
20
  if (!scrollable) return null;
20
21
  if (scrollable instanceof HTMLElement) return scrollable;
@@ -104,7 +105,6 @@ const TrueSheetComponent = /*#__PURE__*/forwardRef((props, ref) => {
104
105
  }, [validDetents]);
105
106
  const validDetentsRef = useRef(validDetents);
106
107
  validDetentsRef.current = validDetents;
107
- const hasAutoDetent = validDetents.includes('auto');
108
108
  const handleSetActiveSnapPoint = useCallback(snapPoint => {
109
109
  setActiveSnapPoint(snapPoint == null ? null : typeof snapPoint === 'number' || snapPoint === 'auto' || snapPoint === 'peek' ? snapPoint : null);
110
110
  }, []);
@@ -235,8 +235,6 @@ const TrueSheetComponent = /*#__PURE__*/forwardRef((props, ref) => {
235
235
  // An absolute (floating) header overlaps the content, so it contributes no
236
236
  // height to the 'auto' detent measurement.
237
237
  const absoluteHeader = headerOptions?.position === 'absolute';
238
- const absoluteHeaderRef = useRef(absoluteHeader);
239
- absoluteHeaderRef.current = absoluteHeader;
240
238
  const resolvedHeaderStyle = absoluteHeader ? [absoluteHeaderStyle, headerStyle] : headerStyle;
241
239
 
242
240
  // Same for an absolute (floating) footer — rendered via vaul's
@@ -263,119 +261,54 @@ const TrueSheetComponent = /*#__PURE__*/forwardRef((props, ref) => {
263
261
  // A relative footer is laid out below the content, so it's pushed off-screen
264
262
  // at the peek detent and contributes no height.
265
263
  const peekHeight = (header ? headerHeight : 0) + (footer && absoluteFooter ? footerHeight : 0) + peekContentHeight || DEFAULT_PEEK_HEIGHT;
266
-
267
- // Web mirror of native's scrollable handling for 'auto' detents: a plugged
268
- // scrollable keeps the sized (bounded) layout so its viewport is capped to
269
- // the visible sheet and can scroll, while the 'auto' height is measured with
270
- // the viewport replaced by the scrollable's content size — mirrors native
271
- // `naturalHeight`.
272
- const [hasBoundedScrollable, setHasBoundedScrollable] = useState(false);
273
- const [scrollableAutoHeight, setScrollableAutoHeight] = useState(0);
274
- const detectedScrollerRef = useRef(null);
275
- // Bottom padding applied to the plugged scrollable for an absolute footer
276
- // (see the footer inset effect below)
277
- const footerInsetRef = useRef(0);
278
-
279
- // Natural content height (header + content + footer, with a detected
280
- // scrollable's viewport replaced by its content size) — the height the
281
- // content wants regardless of the sheet's bounds. An absolute footer's inset
282
- // on the scrollable counts like a relative footer's height, so the content
283
- // ends above the footer (mirrors native's auto detent).
284
- const measureNaturalHeight = useCallback(() => {
285
- const contentEl = getDOMElement(contentRef.current);
286
- if (!contentEl || !contentEl.isConnected) return 0;
287
- const headerEl = absoluteHeaderRef.current ? null : getDOMElement(headerElRef.current);
288
- const footerEl = absoluteFooterRef.current ? null : getDOMElement(footerElRef.current);
289
- let height = (headerEl?.offsetHeight ?? 0) + (footerEl?.offsetHeight ?? 0) + contentEl.offsetHeight;
290
- const scroller = detectedScrollerRef.current;
291
- const scrollContent = scroller?.firstElementChild;
292
- if (scroller?.isConnected && scrollContent instanceof HTMLElement) {
293
- height += scrollContent.offsetHeight - scroller.clientHeight + footerInsetRef.current;
294
- }
295
- return Math.max(0, height);
296
- }, []);
264
+ const [measuredContentHeight, setMeasuredContentHeight] = useState(0);
265
+ const naturalHeightRef = useRef(0);
266
+ const insetBehavior = scrollableOptions?.contentInsetAdjustment ?? 'automatic';
267
+ const footerInsetAdjustment = Boolean(footer) && absoluteFooter && (insetBehavior === 'automatic' || insetBehavior === 'footer');
268
+ const measureContent = validDetents.includes('auto') || isFormSheet;
297
269
  useEffect(() => {
298
- if (!isOpen || !hasAutoDetent) return undefined;
270
+ if (!isOpen) return undefined;
299
271
  let canceled = false;
300
272
  let rafId = 0;
301
- let mutationObserver = null;
302
- let resizeObserver = null;
273
+ let observer;
303
274
  const attach = () => {
304
275
  if (canceled) return;
305
- const drawerEl = drawerContentRef.current;
306
- if (!drawerEl || !drawerEl.isConnected) {
276
+ const layoutEl = sizedLayoutRef.current;
277
+ const drawer = drawerContentRef.current;
278
+ if (!drawer || !layoutEl?.isConnected) {
307
279
  // Radix Presence defers the portal mount; poll until the drawer is live.
308
280
  rafId = window.requestAnimationFrame(attach);
309
281
  return;
310
282
  }
311
- const measure = () => setScrollableAutoHeight(measureNaturalHeight());
312
- let observed = null;
313
-
314
- // (Re)resolve the plugged scrollable and observe the nodes whose size
315
- // feeds the natural height — content growth inside a bounded scroller is
316
- // invisible to the sheet's own layout (mirrors native's contentSize
317
- // observation). Resolves the content node live: the layout-branch swap
318
- // (natural flow ↔ sized) remounts it.
319
- const observe = () => {
320
- const contentEl = getDOMElement(contentRef.current);
321
- const headerEl = getDOMElement(headerElRef.current);
322
-
323
- // Skip mutations that don't change the observed topology — e.g. a
324
- // virtualized list mounting rows inside the plugged scroller. Size
325
- // changes are already covered by the ResizeObserver.
326
- if (observed && observed.contentEl === contentEl && contentEl?.isConnected && observed.headerEl === headerEl && observed.scroller.isConnected && observed.scrollContent === observed.scroller.firstElementChild) {
327
- return;
283
+ observer = observeSheetLayout({
284
+ drawer,
285
+ layout: layoutEl,
286
+ getContent: () => getDOMElement(contentRef.current),
287
+ getHeader: () => getDOMElement(headerElRef.current),
288
+ getFooter: () => getDOMElement(footerElRef.current),
289
+ getScrollable: () => getScrollableElement(scrollableRef?.current),
290
+ absoluteHeader,
291
+ absoluteFooter,
292
+ footerInsetAdjustment,
293
+ measureContent,
294
+ onHeightChange: height => {
295
+ naturalHeightRef.current = height;
296
+ setMeasuredContentHeight(height);
328
297
  }
329
- const resolved = contentEl && contentEl.isConnected ? getScrollableElement(scrollableRef?.current) : null;
330
- const scroller = resolved?.isConnected ? resolved : null;
331
- detectedScrollerRef.current = scroller;
332
- setHasBoundedScrollable(scroller != null);
333
- observed = scroller ? {
334
- contentEl,
335
- headerEl,
336
- scroller,
337
- scrollContent: scroller.firstElementChild
338
- } : null;
339
- resizeObserver?.disconnect();
340
- resizeObserver = new ResizeObserver(measure);
341
- if (contentEl?.isConnected) resizeObserver.observe(contentEl);
342
- if (headerEl) resizeObserver.observe(headerEl);
343
- if (scroller) {
344
- resizeObserver.observe(scroller);
345
- if (scroller.firstElementChild) resizeObserver.observe(scroller.firstElementChild);
346
- }
347
- measure();
348
- };
349
- observe();
350
- // Watch the whole drawer subtree — catches the scrollable mounting/
351
- // unmounting AND the layout-branch swap, which remounts the content node
352
- // (a content-scoped observer would go stale after the swap).
353
- mutationObserver = new MutationObserver(observe);
354
- mutationObserver.observe(drawerEl, {
355
- childList: true,
356
- subtree: true
357
298
  });
358
299
  };
359
300
  rafId = window.requestAnimationFrame(attach);
360
301
  return () => {
361
302
  canceled = true;
362
303
  window.cancelAnimationFrame(rafId);
363
- mutationObserver?.disconnect();
364
- resizeObserver?.disconnect();
304
+ observer?.disconnect();
365
305
  };
366
- }, [isOpen, hasAutoDetent, measureNaturalHeight, scrollableRef]);
306
+ }, [isOpen, absoluteHeader, absoluteFooter, footerInsetAdjustment, measureContent, scrollableRef]);
367
307
 
368
308
  // Below the last detent a vertical touch pan moves the sheet, not the
369
309
  // content — `[data-vaul-scroll-locked]` disables vertical touch panning on
370
310
  // the scroll container and everything inside it (see vaul/style.css).
371
311
  const isScrollLocked = validDetents.length > 0 && activeSnapPoint !== validDetents[validDetents.length - 1];
372
-
373
- // Vaul measures the auto-size wrapper's offsetHeight (always, post fork).
374
- // Track it here so the form sheet can size its card to fit content,
375
- // clamped between a minimum ratio of the viewport and a maximum derived
376
- // from `detachedOffset` (the breathing room left at top + bottom of the
377
- // floating card).
378
- const [measuredContentHeight, setMeasuredContentHeight] = useState(0);
379
312
  const effectiveMaxContentHeight = useMemo(() => {
380
313
  if (maxContentHeight !== undefined) return maxContentHeight;
381
314
  if (!isFormSheet) return undefined;
@@ -438,16 +371,14 @@ const TrueSheetComponent = /*#__PURE__*/forwardRef((props, ref) => {
438
371
  effectiveDetachedOffset,
439
372
  effectiveMaxContentHeight,
440
373
  peekHeight,
441
- peekContentHeight,
442
- measuredContentHeight
374
+ peekContentHeight
443
375
  });
444
376
  geometryInputsRef.current = {
445
377
  effectiveDetached,
446
378
  effectiveDetachedOffset,
447
379
  effectiveMaxContentHeight,
448
380
  peekHeight,
449
- peekContentHeight,
450
- measuredContentHeight
381
+ peekContentHeight
451
382
  };
452
383
  const computeDetentGeometry = useCallback(() => {
453
384
  const inputs = geometryInputsRef.current;
@@ -455,12 +386,9 @@ const TrueSheetComponent = /*#__PURE__*/forwardRef((props, ref) => {
455
386
  const effectiveH = inputs.effectiveDetached ? windowH - inputs.effectiveDetachedOffset : windowH;
456
387
  // Matches vaul's height ceiling: min(effectiveH, maxContentHeight).
457
388
  const ceiling = inputs.effectiveMaxContentHeight !== undefined ? Math.min(effectiveH, inputs.effectiveMaxContentHeight) : effectiveH;
458
- // 'auto' resolves to the content's natural height. Read it live — the
459
- // `measuredContentHeight` state lags mount by a frame (vaul's initial
460
- // ref-callback measure runs while the portal subtree is still detached,
461
- // so it reads 0 until the ResizeObserver fires). Falls back to the state
462
- // value, then to vaul's pre-measure fallback (effectiveHeight / 2).
463
- const measuredHeight = measureNaturalHeight() || inputs.measuredContentHeight;
389
+ // Reuse the intrinsic measurement during position events so animation
390
+ // frames don't clone the content tree again.
391
+ const measuredHeight = naturalHeightRef.current;
464
392
  const autoHeight = Math.min(measuredHeight > 0 ? measuredHeight : effectiveH / 2, ceiling);
465
393
 
466
394
  // 'peek' is measured live from the DOM (offsetHeight is layout-based, so
@@ -491,7 +419,7 @@ const TrueSheetComponent = /*#__PURE__*/forwardRef((props, ref) => {
491
419
  positions,
492
420
  values
493
421
  };
494
- }, [measureNaturalHeight]);
422
+ }, []);
495
423
 
496
424
  // Detent info for lifecycle events. Position/detent come from the active
497
425
  // detent's target geometry — not the live DOM rect — so willPresent (drawer
@@ -578,46 +506,9 @@ const TrueSheetComponent = /*#__PURE__*/forwardRef((props, ref) => {
578
506
  detent: values[count - 1]
579
507
  };
580
508
  }, [computeDetentGeometry]);
581
-
582
- // Mirror native synchronous layout: while dragging (and through the post-
583
- // release settle) the sized layout tracks the sheet's visible height in
584
- // realtime, clamped to the smallest detent — dragging below it slides the
585
- // sheet out without resizing. Once settled, the inline height is cleared so
586
- // the CSS calc() owns sizing again (adapts to viewport resizes at rest).
587
509
  const sizedLayoutRef = useRef(null);
588
510
  const isDraggingRef = useRef(false);
589
- const isSettlingAfterDragRef = useRef(false);
590
- const updateSizedLayoutHeight = useCallback(position => {
591
- const node = sizedLayoutRef.current;
592
- if (!node) return;
593
- const {
594
- effectiveH,
595
- ceiling,
596
- positions
597
- } = computeDetentGeometry();
598
- if (positions.length === 0) return;
599
- if (isSettlingAfterDragRef.current) {
600
- const snap = activeSnapPointRef.current;
601
- const index = snap != null ? validDetentsRef.current.indexOf(snap) : -1;
602
- const target = index >= 0 ? positions[index] : null;
603
- if (target != null && Math.abs(position - target) < 1) {
604
- isSettlingAfterDragRef.current = false;
605
- // Restore the calc — height is an inline style, so clearing it would
606
- // leave the div unsized (React won't re-apply it without a render)
607
- node.style.height = SIZED_LAYOUT_HEIGHT;
608
- return;
609
- }
610
- }
611
-
612
- // positions[0] is the smallest detent's top-Y — its height is the clamp floor.
613
- const minHeight = effectiveH - positions[0];
614
- const height = Math.min(Math.max(effectiveH - position, minHeight), ceiling);
615
- node.style.height = `${height}px`;
616
- }, [computeDetentGeometry]);
617
511
  const handlePositionChange = useCallback(position => {
618
- if (isDraggingRef.current || isSettlingAfterDragRef.current) {
619
- updateSizedLayoutHeight(position);
620
- }
621
512
  const {
622
513
  index,
623
514
  detent
@@ -630,91 +521,7 @@ const TrueSheetComponent = /*#__PURE__*/forwardRef((props, ref) => {
630
521
  realtime: true
631
522
  }
632
523
  });
633
- }, [interpolateFromPosition, updateSizedLayoutHeight]);
634
-
635
- // Web mirror of native's footer inset (`contentInsetAdjustment`
636
- // 'automatic' | 'footer'): pad the plugged scrollable by how much the
637
- // absolute footer covers it. Measured from layout offsets (not client rects)
638
- // so in-flight transforms don't skew it — the footer's bottom sits at the
639
- // visible bottom of the layout container, so only a scroller reaching that
640
- // edge gets an inset, and a safe-area lift (or a short scroller) shrinks it.
641
- // Padding on the scroll container extends its scrollable overflow, like a
642
- // native content inset.
643
- const insetBehavior = scrollableOptions?.contentInsetAdjustment ?? 'automatic';
644
- const footerInsetAdjustment = Boolean(footer) && absoluteFooter && (insetBehavior === 'automatic' || insetBehavior === 'footer');
645
- useEffect(() => {
646
- if (!isOpen || !footerInsetAdjustment || !scrollableRef) return undefined;
647
- let canceled = false;
648
- let rafId = 0;
649
- let mutationObserver = null;
650
- let resizeObserver = null;
651
- let scroller = null;
652
- const reset = () => {
653
- if (scroller) scroller.style.paddingBottom = '';
654
- footerInsetRef.current = 0;
655
- };
656
- const apply = () => {
657
- const footerEl = getDOMElement(footerElRef.current);
658
- const container = sizedLayoutRef.current ?? drawerContentRef.current;
659
- if (!scroller?.isConnected || !footerEl || !container) return;
660
-
661
- // Measure without our own padding so an unbounded scroller (sized by its
662
- // content) doesn't grow by the inset and feed back into the overlap.
663
- scroller.style.paddingBottom = '';
664
- let bottom = scroller.offsetTop + scroller.offsetHeight;
665
- let el = scroller.offsetParent;
666
- while (el && el !== container) {
667
- bottom += el.offsetTop;
668
- el = el.offsetParent;
669
- }
670
- const footerTop = container.offsetHeight - footerEl.offsetHeight;
671
- const inset = el ? Math.max(0, bottom - footerTop) : 0;
672
- footerInsetRef.current = inset;
673
- scroller.style.paddingBottom = inset > 0 ? `${inset}px` : '';
674
- };
675
-
676
- // (Re)resolve the plugged scrollable — a conditional remount swaps the node
677
- const observe = () => {
678
- const resolved = getScrollableElement(scrollableRef.current);
679
- const next = resolved?.isConnected ? resolved : null;
680
- if (next === scroller && resizeObserver) return;
681
- reset();
682
- resizeObserver?.disconnect();
683
- resizeObserver = null;
684
- scroller = next;
685
- if (!scroller) return;
686
- resizeObserver = new ResizeObserver(apply);
687
- resizeObserver.observe(scroller);
688
- const footerEl = getDOMElement(footerElRef.current);
689
- if (footerEl) resizeObserver.observe(footerEl);
690
- const container = sizedLayoutRef.current ?? drawerContentRef.current;
691
- if (container) resizeObserver.observe(container);
692
- apply();
693
- };
694
- const attach = () => {
695
- if (canceled) return;
696
- const drawerEl = drawerContentRef.current;
697
- if (!drawerEl || !drawerEl.isConnected) {
698
- // Radix Presence defers the portal mount; poll until the drawer is live.
699
- rafId = window.requestAnimationFrame(attach);
700
- return;
701
- }
702
- observe();
703
- mutationObserver = new MutationObserver(observe);
704
- mutationObserver.observe(drawerEl, {
705
- childList: true,
706
- subtree: true
707
- });
708
- };
709
- rafId = window.requestAnimationFrame(attach);
710
- return () => {
711
- canceled = true;
712
- window.cancelAnimationFrame(rafId);
713
- mutationObserver?.disconnect();
714
- resizeObserver?.disconnect();
715
- reset();
716
- };
717
- }, [isOpen, footerInsetAdjustment, scrollableRef]);
524
+ }, [interpolateFromPosition]);
718
525
 
719
526
  // Fire onMount once after first render. React-mount is the earliest point
720
527
  // the component is ready for imperative calls, matching the native
@@ -860,12 +667,9 @@ const TrueSheetComponent = /*#__PURE__*/forwardRef((props, ref) => {
860
667
  nativeEvent: computeDetentInfo(true)
861
668
  });
862
669
  }, [computeDetentInfo]);
863
- const handleRelease = useCallback((_event, open) => {
670
+ const handleRelease = useCallback(() => {
864
671
  if (!isDraggingRef.current) return;
865
672
  isDraggingRef.current = false;
866
- // Track the sized layout through the settle animation only when the sheet
867
- // stays open — a dismissal slides out at the min-clamped height.
868
- isSettlingAfterDragRef.current = open;
869
673
  onDragEndRef.current?.({
870
674
  nativeEvent: computeDetentInfo(true)
871
675
  });
@@ -887,6 +691,7 @@ const TrueSheetComponent = /*#__PURE__*/forwardRef((props, ref) => {
887
691
  // Mirror Android: translate this sheet down to match the deepest descendant's
888
692
  // top so the whole stack visually aligns. Cascades because every ancestor
889
693
  // re-runs whenever the stack (and thus its descendants) changes.
694
+ const wasCascadedRef = useRef(false);
890
695
  useEffect(() => {
891
696
  const parent = drawerContentRef.current;
892
697
  if (!parent) return;
@@ -897,7 +702,7 @@ const TrueSheetComponent = /*#__PURE__*/forwardRef((props, ref) => {
897
702
  // out — nothing to align with anymore.
898
703
  if (!isOpen) return;
899
704
  const parentWrapper = parent.closest('[data-vaul-detached-wrapper]');
900
- const transition = `transform ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`;
705
+ const transition = `transform ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')}), --snap-point-height ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`;
901
706
  const wrapperTransition = `clip-path ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`;
902
707
  const CLIP_NONE = 'inset(0px round 0px)';
903
708
 
@@ -920,11 +725,14 @@ const TrueSheetComponent = /*#__PURE__*/forwardRef((props, ref) => {
920
725
  parentWrapper.style.clipPath = next;
921
726
  };
922
727
  if (descendants.length === 0) {
728
+ if (!wasCascadedRef.current) return;
729
+ wasCascadedRef.current = false;
923
730
  parent.style.transition = transition;
924
731
  parent.style.transform = '';
925
732
  setClip(CLIP_NONE);
926
733
  return;
927
734
  }
735
+ wasCascadedRef.current = true;
928
736
 
929
737
  // Track the immediate child's *actual* top: its cascade transform when it
930
738
  // was itself pushed behind a grandchild, else its own snap point. Mirrors
@@ -1088,11 +896,6 @@ const TrueSheetComponent = /*#__PURE__*/forwardRef((props, ref) => {
1088
896
  window.cancelAnimationFrame(rafId);
1089
897
  };
1090
898
  }, [isOpen, descendants.length]);
1091
-
1092
- // Definite-height flex layout (per-detent sizing) unless content must be
1093
- // measured in natural flow: 'auto' detents (without a plugged scrollable —
1094
- // those measure via `measureNaturalHeight`) and form-sheet content-fit sizing.
1095
- const useSizedLayout = (!hasAutoDetent || hasBoundedScrollable) && !isFormSheet;
1096
899
  const effectiveCornerRadius = cornerRadius ?? DEFAULT_CORNER_RADIUS;
1097
900
 
1098
901
  // Shadow cast upward from the sheet's top edge toward the background. Matches
@@ -1231,11 +1034,7 @@ const TrueSheetComponent = /*#__PURE__*/forwardRef((props, ref) => {
1231
1034
  peekHeight: peekHeight,
1232
1035
  initialAnimated: initialDetentAnimated,
1233
1036
  detachedWrapperStyle: wrapperStyle,
1234
- onContentHeightChange: setMeasuredContentHeight,
1235
- contentHeight:
1236
- // Bounded scrollable → the auto-size wrapper measures 0 (sized
1237
- // layout is absolute), so feed vaul the natural height instead.
1238
- hasAutoDetent && hasBoundedScrollable && scrollableAutoHeight > 0 ? scrollableAutoHeight : undefined,
1037
+ contentHeight: measuredContentHeight,
1239
1038
  activeSnapPoint: activeSnapPoint,
1240
1039
  setActiveSnapPoint: handleSetActiveSnapPoint,
1241
1040
  ...snapPointsProps,
@@ -1261,15 +1060,7 @@ const TrueSheetComponent = /*#__PURE__*/forwardRef((props, ref) => {
1261
1060
  children: accessibilityOptions?.paneTitle ?? 'Sheet'
1262
1061
  }), grabber && /*#__PURE__*/_jsx(Drawer.Handle, {
1263
1062
  style: handleStyle
1264
- }), useSizedLayout ?
1265
- /*#__PURE__*/
1266
- // vaul wraps children in `[data-vaul-auto-size-wrapper]` (display:
1267
- // flow-root) which doesn't honor descendant flex layout. Use an
1268
- // absolute fill sized to the visible portion (via vaul's
1269
- // `--snap-point-height` var) so the inner flex column has a
1270
- // definite height for flex layouts to fill — mirrors native, where
1271
- // the container is sized to the sheet's visible height per detent.
1272
- _jsxs("div", {
1063
+ }), /*#__PURE__*/_jsxs("div", {
1273
1064
  ref: sizedLayoutRef,
1274
1065
  style: sizedLayoutStyle,
1275
1066
  "data-vaul-scroll-locked": isScrollLocked ? '' : undefined,
@@ -1280,7 +1071,7 @@ const TrueSheetComponent = /*#__PURE__*/forwardRef((props, ref) => {
1280
1071
  children: /*#__PURE__*/isValidElement(header) ? header : /*#__PURE__*/createElement(header)
1281
1072
  }), /*#__PURE__*/_jsx(View, {
1282
1073
  ref: contentRef,
1283
- style: hasAutoDetent && hasBoundedScrollable ? [contentFillStyle, style] : style,
1074
+ style: [contentStyle, style],
1284
1075
  onLayout: handleContentLayout,
1285
1076
  children: children
1286
1077
  }), footer && !absoluteFooter && /*#__PURE__*/_jsx(View, {
@@ -1289,27 +1080,6 @@ const TrueSheetComponent = /*#__PURE__*/forwardRef((props, ref) => {
1289
1080
  onLayout: handleFooterLayout,
1290
1081
  children: /*#__PURE__*/isValidElement(footer) ? footer : /*#__PURE__*/createElement(footer)
1291
1082
  })]
1292
- }) :
1293
- /*#__PURE__*/
1294
- // Natural flow so vaul can measure content height — required for
1295
- // 'auto' detents and form-sheet content-fit sizing.
1296
- _jsxs(_Fragment, {
1297
- children: [header && /*#__PURE__*/_jsx(View, {
1298
- ref: headerElRef,
1299
- style: resolvedHeaderStyle,
1300
- onLayout: handleHeaderLayout,
1301
- children: /*#__PURE__*/isValidElement(header) ? header : /*#__PURE__*/createElement(header)
1302
- }), /*#__PURE__*/_jsx(View, {
1303
- ref: contentRef,
1304
- style: style,
1305
- onLayout: handleContentLayout,
1306
- children: children
1307
- }), footer && !absoluteFooter && /*#__PURE__*/_jsx(View, {
1308
- ref: footerElRef,
1309
- style: resolvedFooterStyle,
1310
- onLayout: handleFooterLayout,
1311
- children: /*#__PURE__*/isValidElement(footer) ? footer : /*#__PURE__*/createElement(footer)
1312
- })]
1313
1083
  })]
1314
1084
  })]
1315
1085
  })
@@ -1343,10 +1113,12 @@ const sizedLayoutStyle = {
1343
1113
  right: 0,
1344
1114
  height: SIZED_LAYOUT_HEIGHT,
1345
1115
  display: 'flex',
1346
- flexDirection: 'column'
1116
+ flexDirection: 'column',
1117
+ boxSizing: 'border-box',
1118
+ paddingBottom: 'inherit'
1347
1119
  };
1348
- const contentFillStyle = {
1349
- flex: 1,
1120
+ const contentStyle = {
1121
+ flexShrink: 1,
1350
1122
  minHeight: 0
1351
1123
  };
1352
1124
  const visuallyHiddenStyle = {