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

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 (32) hide show
  1. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContainerView.kt +0 -5
  2. package/android/src/main/java/com/lodev09/truesheet/TrueSheetFooterView.kt +5 -95
  3. package/android/src/main/java/com/lodev09/truesheet/TrueSheetOverlayView.kt +11 -1
  4. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt +18 -46
  5. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetCoordinatorLayout.kt +1 -1
  6. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDimView.kt +3 -0
  7. package/android/src/main/java/com/lodev09/truesheet/utils/ScreenUtils.kt +0 -12
  8. package/ios/TrueSheetFooterView.mm +27 -11
  9. package/ios/TrueSheetView.mm +1 -0
  10. package/lib/module/TrueSheet.web.js +49 -277
  11. package/lib/module/TrueSheet.web.js.map +1 -1
  12. package/lib/module/web/layout.js +168 -0
  13. package/lib/module/web/layout.js.map +1 -0
  14. package/lib/module/web/vaul/helpers.js +1 -0
  15. package/lib/module/web/vaul/helpers.js.map +1 -1
  16. package/lib/module/web/vaul/index.js +9 -4
  17. package/lib/module/web/vaul/index.js.map +1 -1
  18. package/lib/module/web/vaul/use-snap-points.js +6 -8
  19. package/lib/module/web/vaul/use-snap-points.js.map +1 -1
  20. package/lib/typescript/src/TrueSheet.web.d.ts.map +1 -1
  21. package/lib/typescript/src/web/layout.d.ts +19 -0
  22. package/lib/typescript/src/web/layout.d.ts.map +1 -0
  23. package/lib/typescript/src/web/vaul/helpers.d.ts +1 -0
  24. package/lib/typescript/src/web/vaul/helpers.d.ts.map +1 -1
  25. package/lib/typescript/src/web/vaul/index.d.ts.map +1 -1
  26. package/lib/typescript/src/web/vaul/use-snap-points.d.ts.map +1 -1
  27. package/package.json +1 -1
  28. package/src/TrueSheet.web.tsx +78 -333
  29. package/src/web/layout.ts +196 -0
  30. package/src/web/vaul/helpers.ts +5 -0
  31. package/src/web/vaul/index.tsx +21 -4
  32. package/src/web/vaul/use-snap-points.ts +6 -14
@@ -57,6 +57,7 @@ import {
57
57
  DEFAULT_MAX_WIDTH,
58
58
  } from './web/constants';
59
59
  import { getDOMElement } from './web/dom';
60
+ import { observeSheetLayout } from './web/layout';
60
61
  import { Drawer } from './web/vaul';
61
62
  import { DEFAULT_PEEK_HEIGHT, DRAG_CLASS, TRANSITIONS } from './web/vaul/constants';
62
63
 
@@ -172,8 +173,6 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
172
173
  const validDetentsRef = useRef(validDetents);
173
174
  validDetentsRef.current = validDetents;
174
175
 
175
- const hasAutoDetent = validDetents.includes('auto');
176
-
177
176
  const handleSetActiveSnapPoint = useCallback((snapPoint: number | string | null) => {
178
177
  setActiveSnapPoint(
179
178
  snapPoint == null
@@ -334,9 +333,6 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
334
333
  // An absolute (floating) header overlaps the content, so it contributes no
335
334
  // height to the 'auto' detent measurement.
336
335
  const absoluteHeader = headerOptions?.position === 'absolute';
337
- const absoluteHeaderRef = useRef(absoluteHeader);
338
- absoluteHeaderRef.current = absoluteHeader;
339
-
340
336
  const resolvedHeaderStyle = absoluteHeader ? [absoluteHeaderStyle, headerStyle] : headerStyle;
341
337
 
342
338
  // Same for an absolute (floating) footer — rendered via vaul's
@@ -372,113 +368,48 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
372
368
  (footer && absoluteFooter ? footerHeight : 0) +
373
369
  peekContentHeight || DEFAULT_PEEK_HEIGHT;
374
370
 
375
- // Web mirror of native's scrollable handling for 'auto' detents: a plugged
376
- // scrollable keeps the sized (bounded) layout so its viewport is capped to
377
- // the visible sheet and can scroll, while the 'auto' height is measured with
378
- // the viewport replaced by the scrollable's content size — mirrors native
379
- // `naturalHeight`.
380
- const [hasBoundedScrollable, setHasBoundedScrollable] = useState(false);
381
- const [scrollableAutoHeight, setScrollableAutoHeight] = useState(0);
382
- const detectedScrollerRef = useRef<HTMLElement | null>(null);
383
- // Bottom padding applied to the plugged scrollable for an absolute footer
384
- // (see the footer inset effect below)
385
- const footerInsetRef = useRef(0);
386
-
387
- // Natural content height (header + content + footer, with a detected
388
- // scrollable's viewport replaced by its content size) — the height the
389
- // content wants regardless of the sheet's bounds. An absolute footer's inset
390
- // on the scrollable counts like a relative footer's height, so the content
391
- // ends above the footer (mirrors native's auto detent).
392
- const measureNaturalHeight = useCallback(() => {
393
- const contentEl = getDOMElement(contentRef.current);
394
- if (!contentEl || !contentEl.isConnected) return 0;
395
- const headerEl = absoluteHeaderRef.current ? null : getDOMElement(headerElRef.current);
396
- const footerEl = absoluteFooterRef.current ? null : getDOMElement(footerElRef.current);
397
- let height =
398
- (headerEl?.offsetHeight ?? 0) + (footerEl?.offsetHeight ?? 0) + contentEl.offsetHeight;
399
- const scroller = detectedScrollerRef.current;
400
- const scrollContent = scroller?.firstElementChild;
401
- if (scroller?.isConnected && scrollContent instanceof HTMLElement) {
402
- height += scrollContent.offsetHeight - scroller.clientHeight + footerInsetRef.current;
403
- }
404
- return Math.max(0, height);
405
- }, []);
371
+ const [measuredContentHeight, setMeasuredContentHeight] = useState(0);
372
+ const naturalHeightRef = useRef(0);
373
+ const insetBehavior = scrollableOptions?.contentInsetAdjustment ?? 'automatic';
374
+ const footerInsetAdjustment =
375
+ Boolean(footer) &&
376
+ absoluteFooter &&
377
+ (insetBehavior === 'automatic' || insetBehavior === 'footer');
378
+ const measureContent = validDetents.includes('auto') || isFormSheet;
406
379
 
407
380
  useEffect(() => {
408
- if (!isOpen || !hasAutoDetent) return undefined;
381
+ if (!isOpen) return undefined;
409
382
 
410
383
  let canceled = false;
411
384
  let rafId = 0;
412
- let mutationObserver: MutationObserver | null = null;
413
- let resizeObserver: ResizeObserver | null = null;
385
+ let observer: ReturnType<typeof observeSheetLayout> | undefined;
414
386
 
415
387
  const attach = () => {
416
388
  if (canceled) return;
417
- const drawerEl = drawerContentRef.current;
418
- if (!drawerEl || !drawerEl.isConnected) {
389
+ const layoutEl = sizedLayoutRef.current;
390
+ const drawer = drawerContentRef.current;
391
+ if (!drawer || !layoutEl?.isConnected) {
419
392
  // Radix Presence defers the portal mount; poll until the drawer is live.
420
393
  rafId = window.requestAnimationFrame(attach);
421
394
  return;
422
395
  }
423
396
 
424
- const measure = () => setScrollableAutoHeight(measureNaturalHeight());
425
-
426
- let observed: {
427
- contentEl: HTMLElement | null;
428
- headerEl: HTMLElement | null;
429
- scroller: HTMLElement;
430
- scrollContent: Element | null;
431
- } | null = null;
432
-
433
- // (Re)resolve the plugged scrollable and observe the nodes whose size
434
- // feeds the natural height — content growth inside a bounded scroller is
435
- // invisible to the sheet's own layout (mirrors native's contentSize
436
- // observation). Resolves the content node live: the layout-branch swap
437
- // (natural flow ↔ sized) remounts it.
438
- const observe = () => {
439
- const contentEl = getDOMElement(contentRef.current);
440
- const headerEl = getDOMElement(headerElRef.current);
441
-
442
- // Skip mutations that don't change the observed topology — e.g. a
443
- // virtualized list mounting rows inside the plugged scroller. Size
444
- // changes are already covered by the ResizeObserver.
445
- if (
446
- observed &&
447
- observed.contentEl === contentEl &&
448
- contentEl?.isConnected &&
449
- observed.headerEl === headerEl &&
450
- observed.scroller.isConnected &&
451
- observed.scrollContent === observed.scroller.firstElementChild
452
- ) {
453
- return;
454
- }
455
-
456
- const resolved =
457
- contentEl && contentEl.isConnected ? getScrollableElement(scrollableRef?.current) : null;
458
- const scroller = resolved?.isConnected ? resolved : null;
459
- detectedScrollerRef.current = scroller;
460
- setHasBoundedScrollable(scroller != null);
461
- observed = scroller
462
- ? { contentEl, headerEl, scroller, scrollContent: scroller.firstElementChild }
463
- : null;
464
-
465
- resizeObserver?.disconnect();
466
- resizeObserver = new ResizeObserver(measure);
467
- if (contentEl?.isConnected) resizeObserver.observe(contentEl);
468
- if (headerEl) resizeObserver.observe(headerEl);
469
- if (scroller) {
470
- resizeObserver.observe(scroller);
471
- if (scroller.firstElementChild) resizeObserver.observe(scroller.firstElementChild);
472
- }
473
- measure();
474
- };
475
-
476
- observe();
477
- // Watch the whole drawer subtree — catches the scrollable mounting/
478
- // unmounting AND the layout-branch swap, which remounts the content node
479
- // (a content-scoped observer would go stale after the swap).
480
- mutationObserver = new MutationObserver(observe);
481
- mutationObserver.observe(drawerEl, { childList: true, subtree: true });
397
+ observer = observeSheetLayout({
398
+ drawer,
399
+ layout: layoutEl,
400
+ getContent: () => getDOMElement(contentRef.current),
401
+ getHeader: () => getDOMElement(headerElRef.current),
402
+ getFooter: () => getDOMElement(footerElRef.current),
403
+ getScrollable: () => getScrollableElement(scrollableRef?.current),
404
+ absoluteHeader,
405
+ absoluteFooter,
406
+ footerInsetAdjustment,
407
+ measureContent,
408
+ onHeightChange: (height) => {
409
+ naturalHeightRef.current = height;
410
+ setMeasuredContentHeight(height);
411
+ },
412
+ });
482
413
  };
483
414
 
484
415
  rafId = window.requestAnimationFrame(attach);
@@ -486,10 +417,16 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
486
417
  return () => {
487
418
  canceled = true;
488
419
  window.cancelAnimationFrame(rafId);
489
- mutationObserver?.disconnect();
490
- resizeObserver?.disconnect();
420
+ observer?.disconnect();
491
421
  };
492
- }, [isOpen, hasAutoDetent, measureNaturalHeight, scrollableRef]);
422
+ }, [
423
+ isOpen,
424
+ absoluteHeader,
425
+ absoluteFooter,
426
+ footerInsetAdjustment,
427
+ measureContent,
428
+ scrollableRef,
429
+ ]);
493
430
 
494
431
  // Below the last detent a vertical touch pan moves the sheet, not the
495
432
  // content — `[data-vaul-scroll-locked]` disables vertical touch panning on
@@ -497,13 +434,6 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
497
434
  const isScrollLocked =
498
435
  validDetents.length > 0 && activeSnapPoint !== validDetents[validDetents.length - 1];
499
436
 
500
- // Vaul measures the auto-size wrapper's offsetHeight (always, post fork).
501
- // Track it here so the form sheet can size its card to fit content,
502
- // clamped between a minimum ratio of the viewport and a maximum derived
503
- // from `detachedOffset` (the breathing room left at top + bottom of the
504
- // floating card).
505
- const [measuredContentHeight, setMeasuredContentHeight] = useState(0);
506
-
507
437
  const effectiveMaxContentHeight = useMemo<number | undefined>(() => {
508
438
  if (maxContentHeight !== undefined) return maxContentHeight;
509
439
  if (!isFormSheet) return undefined;
@@ -570,7 +500,6 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
570
500
  effectiveMaxContentHeight,
571
501
  peekHeight,
572
502
  peekContentHeight,
573
- measuredContentHeight,
574
503
  });
575
504
  geometryInputsRef.current = {
576
505
  effectiveDetached,
@@ -578,7 +507,6 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
578
507
  effectiveMaxContentHeight,
579
508
  peekHeight,
580
509
  peekContentHeight,
581
- measuredContentHeight,
582
510
  };
583
511
 
584
512
  const computeDetentGeometry = useCallback(() => {
@@ -592,12 +520,9 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
592
520
  inputs.effectiveMaxContentHeight !== undefined
593
521
  ? Math.min(effectiveH, inputs.effectiveMaxContentHeight)
594
522
  : effectiveH;
595
- // 'auto' resolves to the content's natural height. Read it live — the
596
- // `measuredContentHeight` state lags mount by a frame (vaul's initial
597
- // ref-callback measure runs while the portal subtree is still detached,
598
- // so it reads 0 until the ResizeObserver fires). Falls back to the state
599
- // value, then to vaul's pre-measure fallback (effectiveHeight / 2).
600
- const measuredHeight = measureNaturalHeight() || inputs.measuredContentHeight;
523
+ // Reuse the intrinsic measurement during position events so animation
524
+ // frames don't clone the content tree again.
525
+ const measuredHeight = naturalHeightRef.current;
601
526
  const autoHeight = Math.min(measuredHeight > 0 ? measuredHeight : effectiveH / 2, ceiling);
602
527
 
603
528
  // 'peek' is measured live from the DOM (offsetHeight is layout-based, so
@@ -633,7 +558,7 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
633
558
  values.push(effectiveH > 0 ? h / effectiveH : 0);
634
559
  }
635
560
  return { windowH, effectiveH, ceiling, positions, values };
636
- }, [measureNaturalHeight]);
561
+ }, []);
637
562
 
638
563
  // Detent info for lifecycle events. Position/detent come from the active
639
564
  // detent's target geometry — not the live DOM rect — so willPresent (drawer
@@ -712,149 +637,19 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
712
637
  [computeDetentGeometry]
713
638
  );
714
639
 
715
- // Mirror native synchronous layout: while dragging (and through the post-
716
- // release settle) the sized layout tracks the sheet's visible height in
717
- // realtime, clamped to the smallest detent — dragging below it slides the
718
- // sheet out without resizing. Once settled, the inline height is cleared so
719
- // the CSS calc() owns sizing again (adapts to viewport resizes at rest).
720
640
  const sizedLayoutRef = useRef<HTMLDivElement>(null);
721
641
  const isDraggingRef = useRef(false);
722
- const isSettlingAfterDragRef = useRef(false);
723
- const updateSizedLayoutHeight = useCallback(
724
- (position: number) => {
725
- const node = sizedLayoutRef.current;
726
- if (!node) return;
727
-
728
- const { effectiveH, ceiling, positions } = computeDetentGeometry();
729
- if (positions.length === 0) return;
730
-
731
- if (isSettlingAfterDragRef.current) {
732
- const snap = activeSnapPointRef.current;
733
- const index = snap != null ? validDetentsRef.current.indexOf(snap) : -1;
734
- const target = index >= 0 ? positions[index]! : null;
735
- if (target != null && Math.abs(position - target) < 1) {
736
- isSettlingAfterDragRef.current = false;
737
- // Restore the calc — height is an inline style, so clearing it would
738
- // leave the div unsized (React won't re-apply it without a render)
739
- node.style.height = SIZED_LAYOUT_HEIGHT;
740
- return;
741
- }
742
- }
743
-
744
- // positions[0] is the smallest detent's top-Y — its height is the clamp floor.
745
- const minHeight = effectiveH - positions[0]!;
746
- const height = Math.min(Math.max(effectiveH - position, minHeight), ceiling);
747
- node.style.height = `${height}px`;
748
- },
749
- [computeDetentGeometry]
750
- );
751
642
 
752
643
  const handlePositionChange = useCallback(
753
644
  (position: number) => {
754
- if (isDraggingRef.current || isSettlingAfterDragRef.current) {
755
- updateSizedLayoutHeight(position);
756
- }
757
645
  const { index, detent } = interpolateFromPosition(position);
758
646
  onPositionChangeRef.current?.({
759
647
  nativeEvent: { index, position, detent, realtime: true },
760
648
  } as PositionChangeEvent);
761
649
  },
762
- [interpolateFromPosition, updateSizedLayoutHeight]
650
+ [interpolateFromPosition]
763
651
  );
764
652
 
765
- // Web mirror of native's footer inset (`contentInsetAdjustment`
766
- // 'automatic' | 'footer'): pad the plugged scrollable by how much the
767
- // absolute footer covers it. Measured from layout offsets (not client rects)
768
- // so in-flight transforms don't skew it — the footer's bottom sits at the
769
- // visible bottom of the layout container, so only a scroller reaching that
770
- // edge gets an inset, and a safe-area lift (or a short scroller) shrinks it.
771
- // Padding on the scroll container extends its scrollable overflow, like a
772
- // native content inset.
773
- const insetBehavior = scrollableOptions?.contentInsetAdjustment ?? 'automatic';
774
- const footerInsetAdjustment =
775
- Boolean(footer) &&
776
- absoluteFooter &&
777
- (insetBehavior === 'automatic' || insetBehavior === 'footer');
778
-
779
- useEffect(() => {
780
- if (!isOpen || !footerInsetAdjustment || !scrollableRef) return undefined;
781
-
782
- let canceled = false;
783
- let rafId = 0;
784
- let mutationObserver: MutationObserver | null = null;
785
- let resizeObserver: ResizeObserver | null = null;
786
- let scroller: HTMLElement | null = null;
787
-
788
- const reset = () => {
789
- if (scroller) scroller.style.paddingBottom = '';
790
- footerInsetRef.current = 0;
791
- };
792
-
793
- const apply = () => {
794
- const footerEl = getDOMElement(footerElRef.current);
795
- const container = sizedLayoutRef.current ?? drawerContentRef.current;
796
- if (!scroller?.isConnected || !footerEl || !container) return;
797
-
798
- // Measure without our own padding so an unbounded scroller (sized by its
799
- // content) doesn't grow by the inset and feed back into the overlap.
800
- scroller.style.paddingBottom = '';
801
- let bottom = scroller.offsetTop + scroller.offsetHeight;
802
- let el = scroller.offsetParent as HTMLElement | null;
803
- while (el && el !== container) {
804
- bottom += el.offsetTop;
805
- el = el.offsetParent as HTMLElement | null;
806
- }
807
- const footerTop = container.offsetHeight - footerEl.offsetHeight;
808
- const inset = el ? Math.max(0, bottom - footerTop) : 0;
809
- footerInsetRef.current = inset;
810
- scroller.style.paddingBottom = inset > 0 ? `${inset}px` : '';
811
- };
812
-
813
- // (Re)resolve the plugged scrollable — a conditional remount swaps the node
814
- const observe = () => {
815
- const resolved = getScrollableElement(scrollableRef.current);
816
- const next = resolved?.isConnected ? resolved : null;
817
- if (next === scroller && resizeObserver) return;
818
-
819
- reset();
820
- resizeObserver?.disconnect();
821
- resizeObserver = null;
822
- scroller = next;
823
- if (!scroller) return;
824
-
825
- resizeObserver = new ResizeObserver(apply);
826
- resizeObserver.observe(scroller);
827
- const footerEl = getDOMElement(footerElRef.current);
828
- if (footerEl) resizeObserver.observe(footerEl);
829
- const container = sizedLayoutRef.current ?? drawerContentRef.current;
830
- if (container) resizeObserver.observe(container);
831
- apply();
832
- };
833
-
834
- const attach = () => {
835
- if (canceled) return;
836
- const drawerEl = drawerContentRef.current;
837
- if (!drawerEl || !drawerEl.isConnected) {
838
- // Radix Presence defers the portal mount; poll until the drawer is live.
839
- rafId = window.requestAnimationFrame(attach);
840
- return;
841
- }
842
- observe();
843
- mutationObserver = new MutationObserver(observe);
844
- mutationObserver.observe(drawerEl, { childList: true, subtree: true });
845
- };
846
-
847
- rafId = window.requestAnimationFrame(attach);
848
-
849
- return () => {
850
- canceled = true;
851
- window.cancelAnimationFrame(rafId);
852
- mutationObserver?.disconnect();
853
- resizeObserver?.disconnect();
854
- reset();
855
- };
856
- }, [isOpen, footerInsetAdjustment, scrollableRef]);
857
-
858
653
  // Fire onMount once after first render. React-mount is the earliest point
859
654
  // the component is ready for imperative calls, matching the native
860
655
  // "ready for present" contract. Declared before the present/dismiss effect
@@ -980,17 +775,11 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
980
775
  }
981
776
  onDragChangeRef.current?.({ nativeEvent: computeDetentInfo(true) } as DragChangeEvent);
982
777
  }, [computeDetentInfo]);
983
- const handleRelease = useCallback(
984
- (_event: unknown, open: boolean) => {
985
- if (!isDraggingRef.current) return;
986
- isDraggingRef.current = false;
987
- // Track the sized layout through the settle animation only when the sheet
988
- // stays open — a dismissal slides out at the min-clamped height.
989
- isSettlingAfterDragRef.current = open;
990
- onDragEndRef.current?.({ nativeEvent: computeDetentInfo(true) } as DragEndEvent);
991
- },
992
- [computeDetentInfo]
993
- );
778
+ const handleRelease = useCallback(() => {
779
+ if (!isDraggingRef.current) return;
780
+ isDraggingRef.current = false;
781
+ onDragEndRef.current?.({ nativeEvent: computeDetentInfo(true) } as DragEndEvent);
782
+ }, [computeDetentInfo]);
994
783
 
995
784
  const { isNested, dismissAbove, descendants } = useSheetStack(
996
785
  methodsRef,
@@ -1010,6 +799,7 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
1010
799
  // Mirror Android: translate this sheet down to match the deepest descendant's
1011
800
  // top so the whole stack visually aligns. Cascades because every ancestor
1012
801
  // re-runs whenever the stack (and thus its descendants) changes.
802
+ const wasCascadedRef = useRef(false);
1013
803
  useEffect(() => {
1014
804
  const parent = drawerContentRef.current;
1015
805
  if (!parent) return;
@@ -1021,7 +811,7 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
1021
811
  if (!isOpen) return;
1022
812
  const parentWrapper = parent.closest<HTMLElement>('[data-vaul-detached-wrapper]');
1023
813
 
1024
- const transition = `transform ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`;
814
+ const transition = `transform ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')}), --snap-point-height ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`;
1025
815
  const wrapperTransition = `clip-path ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`;
1026
816
  const CLIP_NONE = 'inset(0px round 0px)';
1027
817
 
@@ -1045,11 +835,14 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
1045
835
  };
1046
836
 
1047
837
  if (descendants.length === 0) {
838
+ if (!wasCascadedRef.current) return;
839
+ wasCascadedRef.current = false;
1048
840
  parent.style.transition = transition;
1049
841
  parent.style.transform = '';
1050
842
  setClip(CLIP_NONE);
1051
843
  return;
1052
844
  }
845
+ wasCascadedRef.current = true;
1053
846
 
1054
847
  // Track the immediate child's *actual* top: its cascade transform when it
1055
848
  // was itself pushed behind a grandchild, else its own snap point. Mirrors
@@ -1209,11 +1002,6 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
1209
1002
  };
1210
1003
  }, [isOpen, descendants.length]);
1211
1004
 
1212
- // Definite-height flex layout (per-detent sizing) unless content must be
1213
- // measured in natural flow: 'auto' detents (without a plugged scrollable —
1214
- // those measure via `measureNaturalHeight`) and form-sheet content-fit sizing.
1215
- const useSizedLayout = (!hasAutoDetent || hasBoundedScrollable) && !isFormSheet;
1216
-
1217
1005
  const effectiveCornerRadius = cornerRadius ?? DEFAULT_CORNER_RADIUS;
1218
1006
 
1219
1007
  // Shadow cast upward from the sheet's top edge toward the background. Matches
@@ -1399,14 +1187,7 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
1399
1187
  peekHeight={peekHeight}
1400
1188
  initialAnimated={initialDetentAnimated}
1401
1189
  detachedWrapperStyle={wrapperStyle}
1402
- onContentHeightChange={setMeasuredContentHeight}
1403
- contentHeight={
1404
- // Bounded scrollable → the auto-size wrapper measures 0 (sized
1405
- // layout is absolute), so feed vaul the natural height instead.
1406
- hasAutoDetent && hasBoundedScrollable && scrollableAutoHeight > 0
1407
- ? scrollableAutoHeight
1408
- : undefined
1409
- }
1190
+ contentHeight={measuredContentHeight}
1410
1191
  activeSnapPoint={activeSnapPoint}
1411
1192
  setActiveSnapPoint={handleSetActiveSnapPoint}
1412
1193
  {...snapPointsProps}
@@ -1431,64 +1212,29 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
1431
1212
  {accessibilityOptions?.paneTitle ?? 'Sheet'}
1432
1213
  </Drawer.Title>
1433
1214
  {grabber && <Drawer.Handle style={handleStyle} />}
1434
- {useSizedLayout ? (
1435
- // vaul wraps children in `[data-vaul-auto-size-wrapper]` (display:
1436
- // flow-root) which doesn't honor descendant flex layout. Use an
1437
- // absolute fill sized to the visible portion (via vaul's
1438
- // `--snap-point-height` var) so the inner flex column has a
1439
- // definite height for flex layouts to fill — mirrors native, where
1440
- // the container is sized to the sheet's visible height per detent.
1441
- <div
1442
- ref={sizedLayoutRef}
1443
- style={sizedLayoutStyle}
1444
- data-vaul-scroll-locked={isScrollLocked ? '' : undefined}
1445
- >
1446
- {header && (
1447
- <View ref={headerElRef} style={resolvedHeaderStyle} onLayout={handleHeaderLayout}>
1448
- {isValidElement(header) ? header : createElement(header)}
1449
- </View>
1450
- )}
1451
- {/* Content lays out naturally like native — fill only for auto
1452
- detents with a plugged scrollable, where natural layout is
1453
- circular (sheet height derives from the scroll content size).
1454
- Unlike native, CSS won't cap the content to the container
1455
- (no fit-content flex basis), so the fill is still needed here. */}
1215
+ <div
1216
+ ref={sizedLayoutRef}
1217
+ style={sizedLayoutStyle}
1218
+ data-vaul-scroll-locked={isScrollLocked ? '' : undefined}
1219
+ >
1220
+ {header && (
1221
+ <View ref={headerElRef} style={resolvedHeaderStyle} onLayout={handleHeaderLayout}>
1222
+ {isValidElement(header) ? header : createElement(header)}
1223
+ </View>
1224
+ )}
1225
+ <View ref={contentRef} style={[contentStyle, style]} onLayout={handleContentLayout}>
1226
+ {children}
1227
+ </View>
1228
+ {footer && !absoluteFooter && (
1456
1229
  <View
1457
- ref={contentRef}
1458
- style={hasAutoDetent && hasBoundedScrollable ? [contentFillStyle, style] : style}
1459
- onLayout={handleContentLayout}
1230
+ ref={footerElRef}
1231
+ style={[relativeFooterStyle, resolvedFooterStyle]}
1232
+ onLayout={handleFooterLayout}
1460
1233
  >
1461
- {children}
1462
- </View>
1463
- {footer && !absoluteFooter && (
1464
- <View
1465
- ref={footerElRef}
1466
- style={[relativeFooterStyle, resolvedFooterStyle]}
1467
- onLayout={handleFooterLayout}
1468
- >
1469
- {isValidElement(footer) ? footer : createElement(footer)}
1470
- </View>
1471
- )}
1472
- </div>
1473
- ) : (
1474
- // Natural flow so vaul can measure content height — required for
1475
- // 'auto' detents and form-sheet content-fit sizing.
1476
- <>
1477
- {header && (
1478
- <View ref={headerElRef} style={resolvedHeaderStyle} onLayout={handleHeaderLayout}>
1479
- {isValidElement(header) ? header : createElement(header)}
1480
- </View>
1481
- )}
1482
- <View ref={contentRef} style={style} onLayout={handleContentLayout}>
1483
- {children}
1234
+ {isValidElement(footer) ? footer : createElement(footer)}
1484
1235
  </View>
1485
- {footer && !absoluteFooter && (
1486
- <View ref={footerElRef} style={resolvedFooterStyle} onLayout={handleFooterLayout}>
1487
- {isValidElement(footer) ? footer : createElement(footer)}
1488
- </View>
1489
- )}
1490
- </>
1491
- )}
1236
+ )}
1237
+ </div>
1492
1238
  </Drawer.Content>
1493
1239
  </Drawer.Portal>
1494
1240
  </Drawer.Root>
@@ -1526,12 +1272,11 @@ const sizedLayoutStyle: React.CSSProperties = {
1526
1272
  height: SIZED_LAYOUT_HEIGHT,
1527
1273
  display: 'flex',
1528
1274
  flexDirection: 'column',
1275
+ boxSizing: 'border-box',
1276
+ paddingBottom: 'inherit',
1529
1277
  };
1530
1278
 
1531
- const contentFillStyle = {
1532
- flex: 1,
1533
- minHeight: 0,
1534
- } as const;
1279
+ const contentStyle = { flexShrink: 1, minHeight: 0 } as const;
1535
1280
 
1536
1281
  const visuallyHiddenStyle: React.CSSProperties = {
1537
1282
  position: 'absolute',