@lodev09/react-native-true-sheet 3.11.0-beta.0 → 3.11.0-beta.10

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 (43) hide show
  1. package/LICENSE +1 -1
  2. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewManager.kt +3 -3
  3. package/ios/TrueSheetFooterView.mm +10 -1
  4. package/ios/TrueSheetView.mm +1 -1
  5. package/ios/TrueSheetViewController.h +1 -1
  6. package/ios/TrueSheetViewController.mm +10 -4
  7. package/lib/module/TrueSheet.js +2 -2
  8. package/lib/module/TrueSheet.js.map +1 -1
  9. package/lib/module/TrueSheet.web.js +226 -51
  10. package/lib/module/TrueSheet.web.js.map +1 -1
  11. package/lib/module/TrueSheetProvider.web.js +5 -2
  12. package/lib/module/TrueSheetProvider.web.js.map +1 -1
  13. package/lib/module/fabric/TrueSheetViewNativeComponent.ts +1 -1
  14. package/lib/module/web/constants.js +1 -1
  15. package/lib/module/web/constants.js.map +1 -1
  16. package/lib/module/web/vaul/index.js +102 -127
  17. package/lib/module/web/vaul/index.js.map +1 -1
  18. package/lib/module/web/vaul/style.css +1 -1
  19. package/lib/typescript/src/TrueSheet.types.d.ts +6 -4
  20. package/lib/typescript/src/TrueSheet.types.d.ts.map +1 -1
  21. package/lib/typescript/src/TrueSheet.web.d.ts.map +1 -1
  22. package/lib/typescript/src/TrueSheetProvider.web.d.ts +2 -1
  23. package/lib/typescript/src/TrueSheetProvider.web.d.ts.map +1 -1
  24. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts +1 -1
  25. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts.map +1 -1
  26. package/lib/typescript/src/navigation/types.d.ts +1 -1
  27. package/lib/typescript/src/navigation/types.d.ts.map +1 -1
  28. package/lib/typescript/src/web/constants.d.ts +1 -1
  29. package/lib/typescript/src/web/constants.d.ts.map +1 -1
  30. package/lib/typescript/src/web/vaul/index.d.ts +8 -2
  31. package/lib/typescript/src/web/vaul/index.d.ts.map +1 -1
  32. package/lib/typescript/src/web/vaul/use-prevent-scroll.d.ts +2 -2
  33. package/lib/typescript/src/web/vaul/use-prevent-scroll.d.ts.map +1 -1
  34. package/package.json +6 -1
  35. package/src/TrueSheet.tsx +2 -2
  36. package/src/TrueSheet.types.ts +6 -4
  37. package/src/TrueSheet.web.tsx +225 -55
  38. package/src/TrueSheetProvider.web.tsx +11 -2
  39. package/src/fabric/TrueSheetViewNativeComponent.ts +1 -1
  40. package/src/navigation/types.ts +1 -1
  41. package/src/web/constants.ts +1 -1
  42. package/src/web/vaul/index.tsx +550 -552
  43. package/src/web/vaul/style.css +1 -1
@@ -76,8 +76,7 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
76
76
  footer,
77
77
  footerStyle,
78
78
  scrollable = false,
79
- scrollableOptions,
80
- pageSizing = true,
79
+ presentation = 'page',
81
80
  detached = false,
82
81
  detachedOffset = DEFAULT_DETACHED_OFFSET,
83
82
  elevation = 4,
@@ -116,6 +115,11 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
116
115
 
117
116
  const { width: windowWidth, height: windowHeight } = useWindowDimensions();
118
117
  const isLandscapeOrTablet = windowWidth >= 600 || windowWidth > windowHeight;
118
+ const isFormSheet = isLandscapeOrTablet && presentation === 'form';
119
+
120
+ // presentation='form' implies a floating/detached sheet on web — mirrors iOS
121
+ // form-sheet semantics where the sheet is never edge-attached.
122
+ const effectiveDetached = presentation === 'form' || detached;
119
123
 
120
124
  const colorScheme = useColorScheme();
121
125
  const backgroundColor =
@@ -167,6 +171,17 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
167
171
  // in another screen's tree when navigating) should not close the drawer.
168
172
  if (portalContainer && !portalContainer.contains(target)) {
169
173
  e.preventDefault();
174
+ return;
175
+ }
176
+ // The footer is rendered via vaul's `detachedSiblings` as a sibling of
177
+ // Drawer.Content inside [data-vaul-detached-wrapper], so Radix treats
178
+ // clicks on it as "outside" the content. Don't dismiss for clicks that
179
+ // landed inside the wrapper.
180
+ if (target instanceof Element) {
181
+ const wrapper = drawerContentRef.current?.closest('[data-vaul-detached-wrapper]');
182
+ if (wrapper && wrapper.contains(target)) {
183
+ e.preventDefault();
184
+ }
170
185
  }
171
186
  };
172
187
 
@@ -260,7 +275,7 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
260
275
  if (count === 0) return { index: -1, detent: 0 };
261
276
 
262
277
  const windowH = window.innerHeight;
263
- const effectiveH = detached ? windowH - detachedOffset : windowH;
278
+ const effectiveH = effectiveDetached ? windowH - detachedOffset : windowH;
264
279
  // Matches vaul's height ceiling: min(effectiveH, maxContentHeight).
265
280
  const ceiling =
266
281
  maxContentHeight !== undefined ? Math.min(effectiveH, maxContentHeight) : effectiveH;
@@ -329,7 +344,7 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
329
344
 
330
345
  return { index: count - 1, detent: values[count - 1]! };
331
346
  },
332
- [detached, detachedOffset, maxContentHeight]
347
+ [effectiveDetached, detachedOffset, maxContentHeight]
333
348
  );
334
349
 
335
350
  const handlePositionChange = useCallback(
@@ -366,8 +381,15 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
366
381
 
367
382
  const present = !wasOpen && isOpen;
368
383
  if (present) {
384
+ // Pair willFocus with willPresent on initial present — mirrors native
385
+ // iOS where viewWillAppear dispatches both. The descendant-stack focus
386
+ // effect handles subsequent gained/lost transitions.
369
387
  onWillPresentRef.current?.({ nativeEvent: computeDetentInfo() } as WillPresentEvent);
388
+ onWillFocusRef.current?.({ nativeEvent: null } as WillFocusEvent);
370
389
  } else if (wasOpen && !isOpen) {
390
+ // Pair willBlur with willDismiss on dismiss — mirrors native iOS
391
+ // emitWillDismissEvents (blur fires before dismiss).
392
+ onWillBlurRef.current?.({ nativeEvent: null } as WillBlurEvent);
371
393
  onWillDismissRef.current?.({ nativeEvent: null } as WillDismissEvent);
372
394
  } else {
373
395
  return undefined;
@@ -376,7 +398,9 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
376
398
  const fireDone = () => {
377
399
  if (present) {
378
400
  onDidPresentRef.current?.({ nativeEvent: computeDetentInfo() } as DidPresentEvent);
401
+ onDidFocusRef.current?.({ nativeEvent: null } as DidFocusEvent);
379
402
  } else {
403
+ onDidBlurRef.current?.({ nativeEvent: null } as DidBlurEvent);
380
404
  onDidDismissRef.current?.({ nativeEvent: null } as DidDismissEvent);
381
405
  }
382
406
  };
@@ -459,7 +483,8 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
459
483
  const { isNested, dismissAbove, descendants } = useSheetStack(
460
484
  methodsRef,
461
485
  drawerContentRef,
462
- isOpen
486
+ isOpen,
487
+ isFormSheet
463
488
  );
464
489
  dismissAboveRef.current = dismissAbove;
465
490
 
@@ -469,28 +494,97 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
469
494
  useEffect(() => {
470
495
  const parent = drawerContentRef.current;
471
496
  if (!parent) return;
497
+ // Skip while dismissing: this sheet's stack pop changes `descendants`,
498
+ // which would re-fire the effect and write `wrapper.style.transition =
499
+ // 'clip-path …'`, clobbering vaul's just-written `'transform …'` for the
500
+ // dismiss animation. Vaul fully owns this sheet's transitions on the way
501
+ // out — nothing to align with anymore.
502
+ if (!isOpen) return;
503
+ const parentWrapper = parent.closest<HTMLElement>('[data-vaul-detached-wrapper]');
472
504
 
473
505
  const transition = `transform ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`;
506
+ const wrapperTransition = `clip-path ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`;
507
+ const CLIP_NONE = 'inset(0px round 0px)';
508
+
509
+ // Animate clip-path on the wrapper. Dedupes via DOM read so repeat ticks
510
+ // (mutation observer) skip identical writes. Seeds CLIP_NONE before the
511
+ // first inset() so the browser interpolates between two inset() shapes —
512
+ // `none → inset()` interpolates inconsistently.
513
+ const setClip = (next: string) => {
514
+ if (!parentWrapper) return;
515
+ const current = parentWrapper.style.clipPath;
516
+ if (current === next) return;
517
+ if (next === CLIP_NONE && !current) return;
518
+ if (!current) {
519
+ parentWrapper.style.transition = '';
520
+ parentWrapper.style.clipPath = CLIP_NONE;
521
+ // eslint-disable-next-line no-void
522
+ void parentWrapper.offsetHeight;
523
+ }
524
+ parentWrapper.style.transition = wrapperTransition;
525
+ parentWrapper.style.clipPath = next;
526
+ };
474
527
 
475
528
  if (descendants.length === 0) {
476
529
  parent.style.transition = transition;
477
530
  parent.style.transform = '';
531
+ setClip(CLIP_NONE);
478
532
  return;
479
533
  }
480
534
 
535
+ // Track only the immediate child's snap point. Walking deeper descendants
536
+ // would push this sheet further when a grandchild opens, even when our
537
+ // own child didn't move (e.g., child skipped its cascade for a page
538
+ // grandchild) — leaving a visible gap between this sheet and its child.
481
539
  const computeTargetY = () => {
482
540
  const parentSnap = parseFloat(parent.style.getPropertyValue('--snap-point-height')) || 0;
483
- let targetY = parentSnap;
484
- for (const d of descendants) {
485
- const node = d.nodeRef.current;
486
- if (!node) continue;
487
- const snap = parseFloat(node.style.getPropertyValue('--snap-point-height')) || 0;
488
- if (snap > targetY) targetY = snap;
541
+ const node = descendants[0]?.nodeRef.current;
542
+ if (!node) return parentSnap;
543
+ const childSnap = parseFloat(node.style.getPropertyValue('--snap-point-height')) || 0;
544
+ return Math.max(parentSnap, childSnap);
545
+ };
546
+
547
+ // When a form-sheet parent has a form-sheet descendant, clip the parent to
548
+ // the child card's viewport box so it doesn't peek above/around. Only
549
+ // applies when this sheet is itself form — a page parent should remain
550
+ // visible behind/around a floating form child. Geometry comes from the
551
+ // child's inline styles (not getBoundingClientRect) to read the at-rest
552
+ // box, unskewed by vaul's slide-in.
553
+ const applyFormClip = () => {
554
+ const form = isFormSheet ? descendants.find((d) => d.isFormSheetRef.current) : undefined;
555
+ if (!form) {
556
+ setClip(CLIP_NONE);
557
+ return;
489
558
  }
490
- return targetY;
559
+ const childDrawer = form.nodeRef.current;
560
+ const childWrapper = childDrawer?.closest<HTMLElement>('[data-vaul-detached-wrapper]');
561
+ if (!parentWrapper || !childDrawer || !childWrapper) return;
562
+ const snapY = parseFloat(childDrawer.style.getPropertyValue('--snap-point-height')) || 0;
563
+ const childBottomGap = parseFloat(childWrapper.style.bottom) || 0;
564
+ const childMaxW = parseFloat(childWrapper.style.maxWidth) || window.innerWidth;
565
+ const formLeft = (window.innerWidth - childMaxW) / 2;
566
+ const formRight = (window.innerWidth + childMaxW) / 2;
567
+ const formBottom = window.innerHeight - childBottomGap;
568
+ const rect = parentWrapper.getBoundingClientRect();
569
+ const top = Math.max(0, snapY - rect.top);
570
+ const left = Math.max(0, formLeft - rect.left);
571
+ const right = Math.max(0, rect.right - formRight);
572
+ const bottom = Math.max(0, rect.bottom - formBottom);
573
+ const radius = cornerRadius ?? DEFAULT_CORNER_RADIUS;
574
+ setClip(`inset(${top}px ${right}px ${bottom}px ${left}px round ${radius}px)`);
491
575
  };
492
576
 
493
577
  const apply = () => {
578
+ applyFormClip();
579
+ // Mirror iOS: a page-sheet child fully covers a form-sheet parent, so the
580
+ // cascade push-down has no visible effect — and would briefly peek above
581
+ // the page during the present animation. Leave the parent put.
582
+ const child = descendants[0];
583
+ if (isFormSheet && child && !child.isFormSheetRef.current) {
584
+ parent.style.transition = transition;
585
+ parent.style.transform = '';
586
+ return;
587
+ }
494
588
  const targetY = computeTargetY();
495
589
  const match = parent.style.transform.match(/translate3d\([^,]*,\s*(-?\d*\.?\d+)px/);
496
590
  const currentY = match ? parseFloat(match[1]!) : 0;
@@ -510,7 +604,7 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
510
604
  cancelAnimationFrame(raf);
511
605
  observer.disconnect();
512
606
  };
513
- }, [descendants, activeSnapPoint]);
607
+ }, [descendants, activeSnapPoint, cornerRadius, isFormSheet, isOpen]);
514
608
 
515
609
  // Focus/blur events fire when a descendant sheet appears on top of this one
516
610
  // (blur) or when all descendants are dismissed (focus). will-events fire
@@ -579,8 +673,17 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
579
673
  // Shadow cast upward from the sheet's top edge toward the background. Matches
580
674
  // Android's `elevation` semantics roughly — the sheet "lifts" off whatever is
581
675
  // behind it. Scales linearly so higher elevation reads as more separation.
582
- const boxShadow =
583
- elevation > 0 ? `0 ${-elevation}px ${elevation * 3}px rgba(0, 0, 0, 0.15)` : undefined;
676
+ // Applied to the vaul wrapper (not the drawer) as `filter: drop-shadow`: the
677
+ // wrapper clips the drawer (overflow: hidden + contain: paint), which would
678
+ // cut off `box-shadow` on the drawer at the wrapper edges — visible in
679
+ // detached mode (bottom blur clipped in the floating gap) and when the
680
+ // wrapper is narrowed by maxWidth/anchor margins (lateral blur clipped at
681
+ // wrapper edges). drop-shadow on the wrapper follows the post-clip silhouette
682
+ // and isn't clipped by the wrapper itself.
683
+ const dropShadow =
684
+ elevation > 0
685
+ ? `drop-shadow(0 ${-elevation}px ${elevation * 3}px rgba(0, 0, 0, 0.15))`
686
+ : undefined;
584
687
 
585
688
  const mergedContentStyle = useMemo<React.CSSProperties>(
586
689
  () => ({
@@ -594,11 +697,13 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
594
697
  borderTopLeftRadius: effectiveCornerRadius,
595
698
  borderTopRightRadius: effectiveCornerRadius,
596
699
  backgroundColor: backgroundColor as string,
597
- boxShadow,
700
+ // Clip children to the rounded top so headers/content with their own
701
+ // background don't bleed past the corners.
702
+ overflow: 'hidden',
598
703
  // Lift content above iOS home indicator / bottom safe area when enabled.
599
704
  paddingBottom: insetAdjustment === 'automatic' ? 'env(safe-area-inset-bottom, 0px)' : 0,
600
705
  }),
601
- [backgroundColor, effectiveCornerRadius, boxShadow, insetAdjustment]
706
+ [backgroundColor, effectiveCornerRadius, insetAdjustment]
602
707
  );
603
708
 
604
709
  const defaultGrabberColor =
@@ -622,38 +727,68 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
622
727
  []
623
728
  );
624
729
 
625
- // Form-sheet style (iOS pageSizing=false): centered floating card with a
626
- // default width and a height capped to a fraction of the window. We reuse
627
- // the existing detached mechanic so drag/snap math stays correct — the
628
- // wrapper is bottom-attached with a computed offset that centers it
629
- // vertically.
630
- const isFormSheet = isLandscapeOrTablet && maxContentWidth == null && !pageSizing;
631
-
632
- const effectiveMaxContentHeight =
633
- maxContentHeight ?? (isFormSheet ? windowHeight * DEFAULT_FORM_SHEET_HEIGHT_RATIO : undefined);
634
-
635
- const effectiveDetached = isFormSheet || detached;
636
-
637
- const effectiveDetachedOffset = isFormSheet
638
- ? Math.max(0, (windowHeight - (effectiveMaxContentHeight ?? 0)) / 2)
639
- : detachedOffset;
730
+ // Form-sheet style (presentation='form'): centered floating card with a
731
+ // default width and a height fit to content. We reuse the existing detached
732
+ // mechanic so drag/snap math stays correct — the wrapper is bottom-attached
733
+ // with a computed offset that centers it vertically. `presentation` is
734
+ // absolute: when 'form', `maxContentWidth` is ignored and the card uses
735
+ // DEFAULT_FORM_SHEET_WIDTH.
736
+
737
+ // Vaul measures the auto-size wrapper's offsetHeight (always, post fork).
738
+ // Track it here so the form sheet can size its card to fit content,
739
+ // clamped between a minimum ratio of the viewport and a maximum derived
740
+ // from `detachedOffset` (the breathing room left at top + bottom of the
741
+ // floating card).
742
+ const [measuredContentHeight, setMeasuredContentHeight] = useState(0);
743
+
744
+ const effectiveMaxContentHeight = useMemo<number | undefined>(() => {
745
+ if (maxContentHeight !== undefined) return maxContentHeight;
746
+ if (!isFormSheet) return undefined;
747
+ const min = windowHeight * DEFAULT_FORM_SHEET_HEIGHT_RATIO;
748
+ const max = Math.max(min, windowHeight - 2 * detachedOffset);
749
+ if (measuredContentHeight <= 0) return min;
750
+ return Math.max(min, Math.min(measuredContentHeight, max));
751
+ }, [maxContentHeight, isFormSheet, windowHeight, detachedOffset, measuredContentHeight]);
752
+
753
+ // Center the form sheet using the actual visible drawer height. Vaul
754
+ // auto-sizes to content (capped by `maxContentHeight`), so when content is
755
+ // shorter than `effectiveMaxContentHeight`'s min-clamped floor, using that
756
+ // for the offset would push a small sheet below the viewport center.
757
+ const effectiveDetachedOffset = useMemo(() => {
758
+ if (!isFormSheet) return detachedOffset;
759
+ const max = Math.max(0, windowHeight - 2 * detachedOffset);
760
+ const visibleHeight =
761
+ measuredContentHeight > 0
762
+ ? Math.min(measuredContentHeight, max)
763
+ : (effectiveMaxContentHeight ?? 0);
764
+ return Math.max(0, (windowHeight - visibleHeight) / 2);
765
+ }, [isFormSheet, windowHeight, detachedOffset, measuredContentHeight, effectiveMaxContentHeight]);
640
766
 
641
767
  // The wrapper holds all horizontal sizing/anchoring so its rounded-bottom
642
768
  // clip (when detached) aligns with the drawer's horizontal bounds on
643
769
  // desktop — otherwise its corners sit at the far viewport edges.
644
- // Mirrors iOS setupSheetSizing: maxContentWidth wins (forces pageSizing
645
- // off). pageSizing on constrain to readable width (page-sheet);
646
- // pageSizing off with no maxContentWidth form-sheet width.
770
+ // - presentation='form' DEFAULT_FORM_SHEET_WIDTH on tablet/landscape;
771
+ // `maxContentWidth` is ignored ('form' is absolute).
772
+ // - presentation='page' `maxContentWidth` (any viewport) or
773
+ // DEFAULT_MAX_WIDTH (tablet/landscape readability cap).
647
774
  // Detached without a width constraint applies anchorOffset on both edges so
648
775
  // the floating card breathes from the viewport sides.
649
776
  const wrapperStyle = useMemo<React.CSSProperties | undefined>(() => {
777
+ // Mobile portrait ignores width sizing entirely (matches iOS/Android:
778
+ // both apply `maxContentWidth` only when not on a portrait phone).
779
+ // `detached` + `detachedOffset` are still respected via the wrapper.
650
780
  const maxWidth = isLandscapeOrTablet
651
- ? isFormSheet
781
+ ? presentation === 'form'
652
782
  ? DEFAULT_FORM_SHEET_WIDTH
653
- : (maxContentWidth ?? (pageSizing ? DEFAULT_MAX_WIDTH : undefined))
783
+ : (maxContentWidth ?? DEFAULT_MAX_WIDTH)
654
784
  : undefined;
655
785
 
656
- if (maxWidth == null && !detached) return undefined;
786
+ const needsMargins = maxWidth != null || effectiveDetached;
787
+ if (!needsMargins && !dropShadow) return undefined;
788
+
789
+ const next: React.CSSProperties = {};
790
+ if (dropShadow) next.filter = dropShadow;
791
+ if (!needsMargins) return next;
657
792
 
658
793
  let marginLeft: number | string;
659
794
  let marginRight: number | string;
@@ -668,29 +803,40 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
668
803
  marginRight = anchor === 'right' ? anchorOffset : 'auto';
669
804
  }
670
805
 
671
- return {
672
- ...(maxWidth != null && { maxWidth }),
673
- marginLeft,
674
- marginRight,
675
- };
806
+ if (maxWidth != null) next.maxWidth = maxWidth;
807
+ next.marginLeft = marginLeft;
808
+ next.marginRight = marginRight;
809
+ return next;
676
810
  }, [
677
811
  isLandscapeOrTablet,
678
812
  isFormSheet,
679
813
  maxContentWidth,
680
- pageSizing,
814
+ presentation,
681
815
  anchor,
682
816
  anchorOffset,
683
- detached,
817
+ effectiveDetached,
818
+ dropShadow,
684
819
  ]);
685
820
 
821
+ // Absolute-position the grabber so it overlays the content top-edge
822
+ // instead of consuming flow height — mirrors native iOS/Android, where
823
+ // the grabber sits in the rounded corner zone above the content and
824
+ // doesn't push the header down or inflate the 'auto' detent measurement.
686
825
  const handleStyle = useMemo<React.CSSProperties>(
687
826
  () => ({
827
+ position: 'absolute',
828
+ top: grabberOptions?.topMargin ?? DEFAULT_GRABBER_TOP_MARGIN,
829
+ left: '50%',
830
+ transform: 'translateX(-50%)',
688
831
  height: grabberHeight,
689
832
  width: grabberOptions?.width ?? DEFAULT_GRABBER_WIDTH,
690
833
  borderRadius: grabberOptions?.cornerRadius ?? grabberHeight / 2,
691
834
  backgroundColor: (grabberOptions?.color ?? defaultGrabberColor) as string,
692
835
  opacity: 1,
693
- marginTop: grabberOptions?.topMargin ?? DEFAULT_GRABBER_TOP_MARGIN,
836
+ // Above absolute-positioned headers (which often use zIndex:1 to overlay
837
+ // scroll content) so the grabber stays draggable — critical when
838
+ // `handleOnly` mode means only the grabber can drag the sheet.
839
+ zIndex: 2,
694
840
  }),
695
841
  [grabberOptions, grabberHeight, defaultGrabberColor]
696
842
  );
@@ -704,7 +850,6 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
704
850
  onRelease={handleRelease}
705
851
  dismissible={dismissible}
706
852
  draggable={draggable}
707
- handleOnly={scrollable && scrollableOptions?.scrollingExpandsSheet === false}
708
853
  repositionInputs={false}
709
854
  modal={dimmed}
710
855
  nested={isNested}
@@ -714,6 +859,7 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
714
859
  maxContentHeight={effectiveMaxContentHeight}
715
860
  initialAnimated={initialDetentAnimated}
716
861
  detachedWrapperStyle={wrapperStyle}
862
+ onContentHeightChange={setMeasuredContentHeight}
717
863
  activeSnapPoint={activeSnapPoint}
718
864
  setActiveSnapPoint={handleSetActiveSnapPoint}
719
865
  {...snapPointsProps}
@@ -736,17 +882,31 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
736
882
  >
737
883
  <Drawer.Title style={visuallyHiddenStyle}>Sheet</Drawer.Title>
738
884
  {grabber && <Drawer.Handle style={handleStyle} />}
739
- {header && (
740
- <View style={headerStyle}>
741
- {isValidElement(header) ? header : createElement(header)}
742
- </View>
743
- )}
744
885
  {scrollable ? (
745
- <div style={scrollableContainerStyle}>
746
- <View style={style}>{children}</View>
886
+ // vaul wraps children in `[data-vaul-auto-size-wrapper]` (display:
887
+ // flow-root) which doesn't honor descendant flex layout. Use an
888
+ // absolute fill sized to the visible portion (via vaul's
889
+ // `--snap-point-height` var) so the inner flex column has a
890
+ // definite height for the scroll container's flex:1 to fill.
891
+ <div style={scrollableLayoutStyle}>
892
+ {header && (
893
+ <View style={headerStyle}>
894
+ {isValidElement(header) ? header : createElement(header)}
895
+ </View>
896
+ )}
897
+ <div style={scrollableContainerStyle}>
898
+ <View style={style}>{children}</View>
899
+ </div>
747
900
  </div>
748
901
  ) : (
749
- <View style={style}>{children}</View>
902
+ <>
903
+ {header && (
904
+ <View style={headerStyle}>
905
+ {isValidElement(header) ? header : createElement(header)}
906
+ </View>
907
+ )}
908
+ <View style={style}>{children}</View>
909
+ </>
750
910
  )}
751
911
  </Drawer.Content>
752
912
  </Drawer.Portal>
@@ -760,6 +920,16 @@ const overlayStyle: React.CSSProperties = {
760
920
  backgroundColor: 'rgba(0, 0, 0, 0.5)',
761
921
  };
762
922
 
923
+ const scrollableLayoutStyle: React.CSSProperties = {
924
+ position: 'absolute',
925
+ top: 0,
926
+ left: 0,
927
+ right: 0,
928
+ height: 'calc(100% - var(--snap-point-height, 0px))',
929
+ display: 'flex',
930
+ flexDirection: 'column',
931
+ };
932
+
763
933
  const scrollableContainerStyle: React.CSSProperties = {
764
934
  flex: 1,
765
935
  minHeight: 0,
@@ -19,6 +19,7 @@ type NodeRef = RefObject<HTMLDivElement | null>;
19
19
  interface StackEntry {
20
20
  ref: SheetRef;
21
21
  nodeRef: NodeRef;
22
+ isFormSheetRef: RefObject<boolean>;
22
23
  }
23
24
 
24
25
  interface SheetContextValue {
@@ -166,10 +167,18 @@ export function useRegisterSheet(name: string | undefined, ref: SheetRef): void
166
167
  * Registers the sheet in the open stack while `isOpen` is true and returns
167
168
  * live data used by each sheet to render stacked visuals and dismiss children.
168
169
  */
169
- export function useSheetStack(ref: SheetRef, nodeRef: NodeRef, isOpen: boolean) {
170
+ export function useSheetStack(
171
+ ref: SheetRef,
172
+ nodeRef: NodeRef,
173
+ isOpen: boolean,
174
+ isFormSheet: boolean
175
+ ) {
170
176
  const ctx = useContext(SheetContext);
171
177
 
172
- const entry = useMemo<StackEntry>(() => ({ ref, nodeRef }), [ref, nodeRef]);
178
+ const isFormSheetRef = useRef(isFormSheet);
179
+ isFormSheetRef.current = isFormSheet;
180
+
181
+ const entry = useMemo<StackEntry>(() => ({ ref, nodeRef, isFormSheetRef }), [ref, nodeRef]);
173
182
 
174
183
  useEffect(() => {
175
184
  if (!ctx || !isOpen) return;
@@ -101,7 +101,7 @@ export interface NativeProps extends ViewProps {
101
101
  initialDetentAnimated?: WithDefault<boolean, true>;
102
102
  scrollable?: WithDefault<boolean, false>;
103
103
  scrollableOptions?: ScrollableOptionsType;
104
- pageSizing?: WithDefault<boolean, true>;
104
+ presentation?: WithDefault<'page' | 'form', 'page'>;
105
105
 
106
106
  // Event handlers
107
107
  onMount?: DirectEventHandler<null>;
@@ -136,7 +136,7 @@ export type TrueSheetNavigationSheetProps = Pick<
136
136
  | 'maxContentHeight'
137
137
  | 'maxContentWidth'
138
138
  | 'scrollable'
139
- | 'pageSizing'
139
+ | 'presentation'
140
140
  | 'header'
141
141
  | 'headerStyle'
142
142
  | 'footer'
@@ -10,4 +10,4 @@ export const DEFAULT_GRABBER_COLOR_LIGHT = 'rgba(0, 0, 0, 0.3)';
10
10
  export const DEFAULT_GRABBER_COLOR_DARK = 'rgba(255, 255, 255, 0.3)';
11
11
  export const DEFAULT_GRABBER_WIDTH = 32;
12
12
  export const DEFAULT_GRABBER_HEIGHT = 4;
13
- export const DEFAULT_GRABBER_TOP_MARGIN = 16;
13
+ export const DEFAULT_GRABBER_TOP_MARGIN = 8;