@lodev09/react-native-true-sheet 3.11.10 → 4.0.0-beta.1

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 (73) hide show
  1. package/README.md +6 -5
  2. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContainerView.kt +23 -2
  3. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContentView.kt +116 -26
  4. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContentViewManager.kt +7 -0
  5. package/android/src/main/java/com/lodev09/truesheet/TrueSheetFooterView.kt +48 -0
  6. package/android/src/main/java/com/lodev09/truesheet/TrueSheetFooterViewManager.kt +7 -0
  7. package/android/src/main/java/com/lodev09/truesheet/TrueSheetStateUpdater.kt +45 -0
  8. package/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt +28 -12
  9. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt +99 -34
  10. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewManager.kt +9 -5
  11. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetCoordinatorLayout.kt +12 -3
  12. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDetentCalculator.kt +33 -4
  13. package/android/src/main/jni/TrueSheetSpec.h +2 -0
  14. package/android/src/main/jni/TrueSheetStateUpdater.cpp +96 -0
  15. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewComponentDescriptor.h +24 -0
  16. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewShadowNode.cpp +43 -0
  17. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewShadowNode.h +28 -0
  18. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewState.cpp +11 -0
  19. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewState.h +37 -0
  20. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewComponentDescriptor.h +24 -0
  21. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewShadowNode.cpp +56 -0
  22. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewShadowNode.h +28 -0
  23. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewState.cpp +11 -0
  24. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewState.h +41 -0
  25. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetInsets.cpp +18 -0
  26. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetInsets.h +16 -0
  27. package/ios/TrueSheetContainerView.h +12 -10
  28. package/ios/TrueSheetContainerView.mm +11 -29
  29. package/ios/TrueSheetContentView.h +22 -7
  30. package/ios/TrueSheetContentView.mm +143 -41
  31. package/ios/TrueSheetFooterView.h +12 -2
  32. package/ios/TrueSheetFooterView.mm +74 -71
  33. package/ios/TrueSheetHeaderView.mm +0 -1
  34. package/ios/TrueSheetView.mm +60 -27
  35. package/ios/TrueSheetViewController.h +9 -0
  36. package/ios/TrueSheetViewController.mm +288 -122
  37. package/ios/core/TrueSheetDetentCalculator.h +9 -0
  38. package/ios/core/TrueSheetDetentCalculator.mm +13 -4
  39. package/ios/tvos/TrueSheetStubs.mm +2 -0
  40. package/lib/module/TrueSheet.js +32 -13
  41. package/lib/module/TrueSheet.js.map +1 -1
  42. package/lib/module/TrueSheet.web.js +262 -38
  43. package/lib/module/TrueSheet.web.js.map +1 -1
  44. package/lib/module/fabric/TrueSheetContentViewNativeComponent.ts +5 -1
  45. package/lib/module/fabric/TrueSheetFooterViewNativeComponent.ts +10 -2
  46. package/lib/module/fabric/TrueSheetViewNativeComponent.ts +8 -1
  47. package/lib/module/web/vaul/index.js +17 -6
  48. package/lib/module/web/vaul/index.js.map +1 -1
  49. package/lib/typescript/src/TrueSheet.d.ts.map +1 -1
  50. package/lib/typescript/src/TrueSheet.types.d.ts +35 -11
  51. package/lib/typescript/src/TrueSheet.types.d.ts.map +1 -1
  52. package/lib/typescript/src/TrueSheet.web.d.ts.map +1 -1
  53. package/lib/typescript/src/fabric/TrueSheetContentViewNativeComponent.d.ts.map +1 -1
  54. package/lib/typescript/src/fabric/TrueSheetFooterViewNativeComponent.d.ts +2 -0
  55. package/lib/typescript/src/fabric/TrueSheetFooterViewNativeComponent.d.ts.map +1 -1
  56. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts +5 -1
  57. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts.map +1 -1
  58. package/lib/typescript/src/navigation/types.d.ts +1 -1
  59. package/lib/typescript/src/navigation/types.d.ts.map +1 -1
  60. package/lib/typescript/src/web/vaul/index.d.ts +8 -1
  61. package/lib/typescript/src/web/vaul/index.d.ts.map +1 -1
  62. package/package.json +10 -10
  63. package/react-native.config.js +5 -1
  64. package/src/TrueSheet.tsx +45 -14
  65. package/src/TrueSheet.types.ts +38 -12
  66. package/src/TrueSheet.web.tsx +314 -44
  67. package/src/fabric/TrueSheetContentViewNativeComponent.ts +5 -1
  68. package/src/fabric/TrueSheetFooterViewNativeComponent.ts +10 -2
  69. package/src/fabric/TrueSheetViewNativeComponent.ts +8 -1
  70. package/src/navigation/types.ts +3 -1
  71. package/src/web/vaul/index.tsx +23 -5
  72. package/ios/utils/LayoutUtil.h +0 -77
  73. package/ios/utils/LayoutUtil.mm +0 -106
@@ -10,8 +10,8 @@ import {
10
10
  useRef,
11
11
  useState,
12
12
  } from 'react';
13
- import type { LayoutChangeEvent } from 'react-native';
14
- import { useColorScheme, useWindowDimensions, View } from 'react-native';
13
+ import type { LayoutChangeEvent, ViewStyle } from 'react-native';
14
+ import { StyleSheet, useColorScheme, useWindowDimensions, View } from 'react-native';
15
15
 
16
16
  import type {
17
17
  DetentChangeEvent,
@@ -54,6 +54,17 @@ import {
54
54
  import { Drawer } from './web/vaul';
55
55
  import { DEFAULT_PEEK_HEIGHT, TRANSITIONS } from './web/vaul/constants';
56
56
 
57
+ // First vertically scrollable descendant — web mirror of native's
58
+ // findScrollView (RN-web ScrollView/FlatList render a node with
59
+ // `overflow-y: auto`; its first child is the content container).
60
+ const findVerticalScroller = (root: HTMLElement): HTMLElement | null => {
61
+ for (const el of root.querySelectorAll<HTMLElement>('*')) {
62
+ const { overflowY } = window.getComputedStyle(el);
63
+ if (overflowY === 'auto' || overflowY === 'scroll') return el;
64
+ }
65
+ return null;
66
+ };
67
+
57
68
  const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props, ref) => {
58
69
  const {
59
70
  children,
@@ -76,9 +87,10 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
76
87
  initialDetentIndex = -1,
77
88
  header,
78
89
  headerStyle,
90
+ headerOptions,
79
91
  footer,
80
92
  footerStyle,
81
- scrollable = false,
93
+ footerOptions,
82
94
  presentation = 'page',
83
95
  detached = false,
84
96
  detachedOffset = DEFAULT_DETACHED_OFFSET,
@@ -151,6 +163,8 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
151
163
  const validDetentsRef = useRef(validDetents);
152
164
  validDetentsRef.current = validDetents;
153
165
 
166
+ const hasAutoDetent = validDetents.includes('auto');
167
+
154
168
  const handleSetActiveSnapPoint = useCallback((snapPoint: number | string | null) => {
155
169
  setActiveSnapPoint(
156
170
  snapPoint == null
@@ -260,9 +274,164 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
260
274
  const peekElRef = useRef<View>(null);
261
275
  const peekContext = useMemo(() => ({ contentRef, peekRef: peekElRef, setPeekContentHeight }), []);
262
276
 
277
+ // An absolute (floating) header overlaps the content, so it contributes no
278
+ // height to the 'auto' detent measurement.
279
+ const absoluteHeader = headerOptions?.position === 'absolute';
280
+ const absoluteHeaderRef = useRef(absoluteHeader);
281
+ absoluteHeaderRef.current = absoluteHeader;
282
+
283
+ const resolvedHeaderStyle = absoluteHeader ? [absoluteHeaderStyle, headerStyle] : headerStyle;
284
+
285
+ // Same for an absolute (floating) footer — rendered via vaul's
286
+ // `detachedSiblings` instead of in the content flow.
287
+ const absoluteFooter = footerOptions?.position === 'absolute';
288
+ const absoluteFooterRef = useRef(absoluteFooter);
289
+ absoluteFooterRef.current = absoluteFooter;
290
+
291
+ // The footer owns the sheet's bottom edge, so it absorbs the bottom
292
+ // safe-area inset as padding (on top of its own) — mirrors native, where the
293
+ // footer shadow node pads the inset so its background fills it.
294
+ const footerOwnsInset = Boolean(footer) && insetAdjustment === 'automatic';
295
+ const resolvedFooterStyle = useMemo(() => {
296
+ if (!footerOwnsInset) return footerStyle;
297
+
298
+ const flat = StyleSheet.flatten(footerStyle);
299
+ const base = flat?.paddingBottom ?? flat?.paddingVertical ?? flat?.padding;
300
+ const baseCss =
301
+ typeof base === 'number' ? `${base}px` : typeof base === 'string' ? base : '0px';
302
+ return [
303
+ footerStyle,
304
+ {
305
+ paddingBottom: `calc(${baseCss} + env(safe-area-inset-bottom, 0px))`,
306
+ } as unknown as ViewStyle,
307
+ ];
308
+ }, [footerOwnsInset, footerStyle]);
309
+
310
+ // A relative footer is laid out below the content, so it's pushed off-screen
311
+ // at the peek detent and contributes no height.
263
312
  const peekHeight =
264
- (header ? headerHeight : 0) + (footer ? footerHeight : 0) + peekContentHeight ||
265
- DEFAULT_PEEK_HEIGHT;
313
+ (header ? headerHeight : 0) +
314
+ (footer && absoluteFooter ? footerHeight : 0) +
315
+ peekContentHeight || DEFAULT_PEEK_HEIGHT;
316
+
317
+ // Web mirror of native's scrollable handling for 'auto' detents: a plugged
318
+ // scrollable keeps the sized (bounded) layout so its viewport is capped to
319
+ // the visible sheet and can scroll, while the 'auto' height is measured with
320
+ // the viewport replaced by the scrollable's content size — mirrors native
321
+ // `naturalHeight`.
322
+ const [hasBoundedScrollable, setHasBoundedScrollable] = useState(false);
323
+ const [scrollableAutoHeight, setScrollableAutoHeight] = useState(0);
324
+ const pinnedScrollerRef = useRef<HTMLElement | null>(null);
325
+
326
+ // Natural content height (header + content + footer, with a pinned
327
+ // scrollable's viewport replaced by its content size) — the height the
328
+ // content wants regardless of the sheet's bounds.
329
+ const measureNaturalHeight = useCallback(() => {
330
+ const contentEl = contentRef.current as unknown as HTMLElement | null;
331
+ if (!contentEl || !contentEl.isConnected) return 0;
332
+ const headerEl = absoluteHeaderRef.current
333
+ ? null
334
+ : (headerElRef.current as unknown as HTMLElement | null);
335
+ const footerEl = absoluteFooterRef.current
336
+ ? null
337
+ : (footerElRef.current as unknown as HTMLElement | null);
338
+ let height =
339
+ (headerEl?.offsetHeight ?? 0) + (footerEl?.offsetHeight ?? 0) + contentEl.offsetHeight;
340
+ const scroller = pinnedScrollerRef.current;
341
+ const scrollContent = scroller?.firstElementChild;
342
+ if (scroller?.isConnected && scrollContent instanceof HTMLElement) {
343
+ height += scrollContent.offsetHeight - scroller.clientHeight;
344
+ }
345
+ return Math.max(0, height);
346
+ }, []);
347
+
348
+ useEffect(() => {
349
+ if (!isOpen || !hasAutoDetent) return undefined;
350
+
351
+ let canceled = false;
352
+ let rafId = 0;
353
+ let mutationObserver: MutationObserver | null = null;
354
+ let resizeObserver: ResizeObserver | null = null;
355
+
356
+ const attach = () => {
357
+ if (canceled) return;
358
+ const drawerEl = drawerContentRef.current;
359
+ if (!drawerEl || !drawerEl.isConnected) {
360
+ // Radix Presence defers the portal mount; poll until the drawer is live.
361
+ rafId = window.requestAnimationFrame(attach);
362
+ return;
363
+ }
364
+
365
+ const measure = () => setScrollableAutoHeight(measureNaturalHeight());
366
+
367
+ let observed: {
368
+ contentEl: HTMLElement | null;
369
+ headerEl: HTMLElement | null;
370
+ scroller: HTMLElement;
371
+ scrollContent: Element | null;
372
+ } | null = null;
373
+
374
+ // (Re)pin the first vertical scrollable and observe the nodes whose size
375
+ // feeds the natural height — content growth inside a bounded scroller is
376
+ // invisible to the sheet's own layout (mirrors native's contentSize
377
+ // observation). Resolves the content node live: the layout-branch swap
378
+ // (natural flow ↔ sized) remounts it.
379
+ const observe = () => {
380
+ const contentEl = contentRef.current as unknown as HTMLElement | null;
381
+ const headerEl = headerElRef.current as unknown as HTMLElement | null;
382
+
383
+ // Skip mutations that don't change the observed topology — e.g. a
384
+ // virtualized list mounting rows inside the pinned scroller, which
385
+ // would otherwise trigger a full-subtree rescan (findVerticalScroller
386
+ // resolves computed styles) on every scroll frame. Size changes are
387
+ // already covered by the ResizeObserver.
388
+ if (
389
+ observed &&
390
+ observed.contentEl === contentEl &&
391
+ contentEl?.isConnected &&
392
+ observed.headerEl === headerEl &&
393
+ observed.scroller.isConnected &&
394
+ observed.scrollContent === observed.scroller.firstElementChild
395
+ ) {
396
+ return;
397
+ }
398
+
399
+ const scroller =
400
+ contentEl && contentEl.isConnected ? findVerticalScroller(contentEl) : null;
401
+ pinnedScrollerRef.current = scroller;
402
+ setHasBoundedScrollable(scroller != null);
403
+ observed = scroller
404
+ ? { contentEl, headerEl, scroller, scrollContent: scroller.firstElementChild }
405
+ : null;
406
+
407
+ resizeObserver?.disconnect();
408
+ resizeObserver = new ResizeObserver(measure);
409
+ if (contentEl?.isConnected) resizeObserver.observe(contentEl);
410
+ if (headerEl) resizeObserver.observe(headerEl);
411
+ if (scroller) {
412
+ resizeObserver.observe(scroller);
413
+ if (scroller.firstElementChild) resizeObserver.observe(scroller.firstElementChild);
414
+ }
415
+ measure();
416
+ };
417
+
418
+ observe();
419
+ // Watch the whole drawer subtree — catches the scrollable mounting/
420
+ // unmounting AND the layout-branch swap, which remounts the content node
421
+ // (a content-scoped observer would go stale after the swap).
422
+ mutationObserver = new MutationObserver(observe);
423
+ mutationObserver.observe(drawerEl, { childList: true, subtree: true });
424
+ };
425
+
426
+ rafId = window.requestAnimationFrame(attach);
427
+
428
+ return () => {
429
+ canceled = true;
430
+ window.cancelAnimationFrame(rafId);
431
+ mutationObserver?.disconnect();
432
+ resizeObserver?.disconnect();
433
+ };
434
+ }, [isOpen, hasAutoDetent, measureNaturalHeight]);
266
435
 
267
436
  // Below the last detent a vertical touch pan moves the sheet, not the
268
437
  // content — `[data-vaul-scroll-locked]` disables vertical touch panning on
@@ -365,15 +534,12 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
365
534
  inputs.effectiveMaxContentHeight !== undefined
366
535
  ? Math.min(effectiveH, inputs.effectiveMaxContentHeight)
367
536
  : effectiveH;
368
- // 'auto' resolves to the auto-size wrapper's height. Read it live — the
537
+ // 'auto' resolves to the content's natural height. Read it live — the
369
538
  // `measuredContentHeight` state lags mount by a frame (vaul's initial
370
539
  // ref-callback measure runs while the portal subtree is still detached,
371
540
  // so it reads 0 until the ResizeObserver fires). Falls back to the state
372
541
  // value, then to vaul's pre-measure fallback (effectiveHeight / 2).
373
- const autoWrapper = drawerContentRef.current?.querySelector<HTMLElement>(
374
- '[data-vaul-auto-size-wrapper]'
375
- );
376
- const measuredHeight = autoWrapper?.offsetHeight || inputs.measuredContentHeight;
542
+ const measuredHeight = measureNaturalHeight() || inputs.measuredContentHeight;
377
543
  const autoHeight = Math.min(measuredHeight > 0 ? measuredHeight : effectiveH / 2, ceiling);
378
544
 
379
545
  // 'peek' is measured live from the DOM (offsetHeight is layout-based, so
@@ -384,7 +550,10 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
384
550
  // Geometry only runs while the drawer is mounted, so the elements are
385
551
  // measurable here; fall back to the state value when they're absent.
386
552
  const headerEl = headerElRef.current as unknown as HTMLElement | null;
387
- const footerEl = footerElRef.current as unknown as HTMLElement | null;
553
+ // A relative footer is pushed off-screen at the peek detent — excluded
554
+ const footerEl = absoluteFooterRef.current
555
+ ? (footerElRef.current as unknown as HTMLElement | null)
556
+ : null;
388
557
  const peekEl = peekElRef.current as unknown as HTMLElement | null;
389
558
  const contentEl = contentRef.current as unknown as HTMLElement | null;
390
559
  const livePeekContentHeight =
@@ -407,8 +576,8 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
407
576
  positions.push(effectiveH - h);
408
577
  values.push(effectiveH > 0 ? h / effectiveH : 0);
409
578
  }
410
- return { windowH, positions, values };
411
- }, []);
579
+ return { windowH, effectiveH, ceiling, positions, values };
580
+ }, [measureNaturalHeight]);
412
581
 
413
582
  // Detent info for lifecycle events. Position/detent come from the active
414
583
  // detent's target geometry — not the live DOM rect — so willPresent (drawer
@@ -487,14 +656,54 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
487
656
  [computeDetentGeometry]
488
657
  );
489
658
 
659
+ // Mirror native synchronous layout: while dragging (and through the post-
660
+ // release settle) the sized layout tracks the sheet's visible height in
661
+ // realtime, clamped to the smallest detent — dragging below it slides the
662
+ // sheet out without resizing. Once settled, the inline height is cleared so
663
+ // the CSS calc() owns sizing again (adapts to viewport resizes at rest).
664
+ const sizedLayoutRef = useRef<HTMLDivElement>(null);
665
+ const isDraggingRef = useRef(false);
666
+ const isSettlingAfterDragRef = useRef(false);
667
+ const updateSizedLayoutHeight = useCallback(
668
+ (position: number) => {
669
+ const node = sizedLayoutRef.current;
670
+ if (!node) return;
671
+
672
+ const { effectiveH, ceiling, positions } = computeDetentGeometry();
673
+ if (positions.length === 0) return;
674
+
675
+ if (isSettlingAfterDragRef.current) {
676
+ const snap = activeSnapPointRef.current;
677
+ const index = snap != null ? validDetentsRef.current.indexOf(snap) : -1;
678
+ const target = index >= 0 ? positions[index]! : null;
679
+ if (target != null && Math.abs(position - target) < 1) {
680
+ isSettlingAfterDragRef.current = false;
681
+ // Restore the calc — height is an inline style, so clearing it would
682
+ // leave the div unsized (React won't re-apply it without a render)
683
+ node.style.height = SIZED_LAYOUT_HEIGHT;
684
+ return;
685
+ }
686
+ }
687
+
688
+ // positions[0] is the smallest detent's top-Y — its height is the clamp floor.
689
+ const minHeight = effectiveH - positions[0]!;
690
+ const height = Math.min(Math.max(effectiveH - position, minHeight), ceiling);
691
+ node.style.height = `${height}px`;
692
+ },
693
+ [computeDetentGeometry]
694
+ );
695
+
490
696
  const handlePositionChange = useCallback(
491
697
  (position: number) => {
698
+ if (isDraggingRef.current || isSettlingAfterDragRef.current) {
699
+ updateSizedLayoutHeight(position);
700
+ }
492
701
  const { index, detent } = interpolateFromPosition(position);
493
702
  onPositionChangeRef.current?.({
494
703
  nativeEvent: { index, position, detent, realtime: true },
495
704
  } as PositionChangeEvent);
496
705
  },
497
- [interpolateFromPosition]
706
+ [interpolateFromPosition, updateSizedLayoutHeight]
498
707
  );
499
708
 
500
709
  // Fire onMount once after first render. React-mount is the earliest point
@@ -612,7 +821,6 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
612
821
 
613
822
  // Vaul's `onDrag` fires once per pointermove while dragging; the first tick
614
823
  // after an idle gap marks the drag boundary, so track it via a ref.
615
- const isDraggingRef = useRef(false);
616
824
  const handleDrag = useCallback(() => {
617
825
  if (!isDraggingRef.current) {
618
826
  isDraggingRef.current = true;
@@ -620,11 +828,17 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
620
828
  }
621
829
  onDragChangeRef.current?.({ nativeEvent: computeDetentInfo(true) } as DragChangeEvent);
622
830
  }, [computeDetentInfo]);
623
- const handleRelease = useCallback(() => {
624
- if (!isDraggingRef.current) return;
625
- isDraggingRef.current = false;
626
- onDragEndRef.current?.({ nativeEvent: computeDetentInfo(true) } as DragEndEvent);
627
- }, [computeDetentInfo]);
831
+ const handleRelease = useCallback(
832
+ (_event: unknown, open: boolean) => {
833
+ if (!isDraggingRef.current) return;
834
+ isDraggingRef.current = false;
835
+ // Track the sized layout through the settle animation only when the sheet
836
+ // stays open — a dismissal slides out at the min-clamped height.
837
+ isSettlingAfterDragRef.current = open;
838
+ onDragEndRef.current?.({ nativeEvent: computeDetentInfo(true) } as DragEndEvent);
839
+ },
840
+ [computeDetentInfo]
841
+ );
628
842
 
629
843
  const { isNested, dismissAbove, descendants } = useSheetStack(
630
844
  methodsRef,
@@ -816,6 +1030,11 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
816
1030
  };
817
1031
  }, [isOpen, descendants.length]);
818
1032
 
1033
+ // Definite-height flex layout (per-detent sizing) unless content must be
1034
+ // measured in natural flow: 'auto' detents (without a plugged scrollable —
1035
+ // those measure via `measureNaturalHeight`) and form-sheet content-fit sizing.
1036
+ const useSizedLayout = (!hasAutoDetent || hasBoundedScrollable) && !isFormSheet;
1037
+
819
1038
  const effectiveCornerRadius = cornerRadius ?? DEFAULT_CORNER_RADIUS;
820
1039
 
821
1040
  // Shadow cast upward from the sheet's top edge toward the background. Matches
@@ -846,12 +1065,20 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
846
1065
  borderTopRightRadius: effectiveCornerRadius,
847
1066
  backgroundColor: backgroundColor as string,
848
1067
  // Clip children to the rounded top so headers/content with their own
849
- // background don't bleed past the corners.
850
- overflow: 'hidden',
1068
+ // background don't bleed past the corners. `clip` (not `hidden`): a
1069
+ // hidden box is still a scroll container, so browser focus-reveal (e.g.
1070
+ // tapping a button near the sheet bottom) can scroll-offset the drawer —
1071
+ // shifting content and blocking vaul's shouldDrag walk (scrollTop > 0).
1072
+ overflow: 'clip',
851
1073
  // Lift content above iOS home indicator / bottom safe area when enabled.
852
- paddingBottom: insetAdjustment === 'automatic' ? 'env(safe-area-inset-bottom, 0px)' : 0,
1074
+ // A relative footer owns the inset instead (see resolvedFooterStyle); an
1075
+ // absolute footer floats over the content, so the content keeps its lift.
1076
+ paddingBottom:
1077
+ insetAdjustment === 'automatic' && !(footerOwnsInset && !absoluteFooter)
1078
+ ? 'env(safe-area-inset-bottom, 0px)'
1079
+ : 0,
853
1080
  }),
854
- [backgroundColor, effectiveCornerRadius, insetAdjustment]
1081
+ [backgroundColor, effectiveCornerRadius, insetAdjustment, footerOwnsInset, absoluteFooter]
855
1082
  );
856
1083
 
857
1084
  const defaultGrabberColor =
@@ -980,6 +1207,13 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
980
1207
  initialAnimated={initialDetentAnimated}
981
1208
  detachedWrapperStyle={wrapperStyle}
982
1209
  onContentHeightChange={setMeasuredContentHeight}
1210
+ contentHeight={
1211
+ // Bounded scrollable → the auto-size wrapper measures 0 (sized
1212
+ // layout is absolute), so feed vaul the natural height instead.
1213
+ hasAutoDetent && hasBoundedScrollable && scrollableAutoHeight > 0
1214
+ ? scrollableAutoHeight
1215
+ : undefined
1216
+ }
983
1217
  activeSnapPoint={activeSnapPoint}
984
1218
  setActiveSnapPoint={handleSetActiveSnapPoint}
985
1219
  {...snapPointsProps}
@@ -991,9 +1225,9 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
991
1225
  style={mergedContentStyle}
992
1226
  onPointerDownOutside={handlePointerDownOutside}
993
1227
  detachedSiblings={
994
- footer ? (
1228
+ footer && absoluteFooter ? (
995
1229
  <div style={footerFloatStyle}>
996
- <View ref={footerElRef} style={footerStyle} onLayout={handleFooterLayout}>
1230
+ <View ref={footerElRef} style={resolvedFooterStyle} onLayout={handleFooterLayout}>
997
1231
  {isValidElement(footer) ? footer : createElement(footer)}
998
1232
  </View>
999
1233
  </div>
@@ -1004,37 +1238,60 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
1004
1238
  {accessibilityOptions?.paneTitle ?? 'Sheet'}
1005
1239
  </Drawer.Title>
1006
1240
  {grabber && <Drawer.Handle style={handleStyle} />}
1007
- {scrollable ? (
1241
+ {useSizedLayout ? (
1008
1242
  // vaul wraps children in `[data-vaul-auto-size-wrapper]` (display:
1009
1243
  // flow-root) which doesn't honor descendant flex layout. Use an
1010
1244
  // absolute fill sized to the visible portion (via vaul's
1011
1245
  // `--snap-point-height` var) so the inner flex column has a
1012
- // definite height for the scroll container's flex:1 to fill.
1013
- <div style={scrollableLayoutStyle}>
1246
+ // definite height for flex layouts to fill mirrors native, where
1247
+ // the container is sized to the sheet's visible height per detent.
1248
+ <div
1249
+ ref={sizedLayoutRef}
1250
+ style={sizedLayoutStyle}
1251
+ data-vaul-scroll-locked={isScrollLocked ? '' : undefined}
1252
+ >
1014
1253
  {header && (
1015
- <View ref={headerElRef} style={headerStyle} onLayout={handleHeaderLayout}>
1254
+ <View ref={headerElRef} style={resolvedHeaderStyle} onLayout={handleHeaderLayout}>
1016
1255
  {isValidElement(header) ? header : createElement(header)}
1017
1256
  </View>
1018
1257
  )}
1019
- <div
1020
- style={scrollableContainerStyle}
1021
- data-vaul-scroll-locked={isScrollLocked ? '' : undefined}
1258
+ {/* Content lays out naturally like native — fill only for auto
1259
+ detents with a plugged scrollable, where natural layout is
1260
+ circular (sheet height derives from the scroll content size).
1261
+ Mirrors the native shadow node's scrollableBounded behavior. */}
1262
+ <View
1263
+ ref={contentRef}
1264
+ style={hasAutoDetent && hasBoundedScrollable ? [contentFillStyle, style] : style}
1022
1265
  >
1023
- <View ref={contentRef} style={style}>
1024
- {children}
1266
+ {children}
1267
+ </View>
1268
+ {footer && !absoluteFooter && (
1269
+ <View
1270
+ ref={footerElRef}
1271
+ style={[relativeFooterStyle, resolvedFooterStyle]}
1272
+ onLayout={handleFooterLayout}
1273
+ >
1274
+ {isValidElement(footer) ? footer : createElement(footer)}
1025
1275
  </View>
1026
- </div>
1276
+ )}
1027
1277
  </div>
1028
1278
  ) : (
1279
+ // Natural flow so vaul can measure content height — required for
1280
+ // 'auto' detents and form-sheet content-fit sizing.
1029
1281
  <>
1030
1282
  {header && (
1031
- <View ref={headerElRef} style={headerStyle} onLayout={handleHeaderLayout}>
1283
+ <View ref={headerElRef} style={resolvedHeaderStyle} onLayout={handleHeaderLayout}>
1032
1284
  {isValidElement(header) ? header : createElement(header)}
1033
1285
  </View>
1034
1286
  )}
1035
1287
  <View ref={contentRef} style={style}>
1036
1288
  {children}
1037
1289
  </View>
1290
+ {footer && !absoluteFooter && (
1291
+ <View ref={footerElRef} style={resolvedFooterStyle} onLayout={handleFooterLayout}>
1292
+ {isValidElement(footer) ? footer : createElement(footer)}
1293
+ </View>
1294
+ )}
1038
1295
  </>
1039
1296
  )}
1040
1297
  </Drawer.Content>
@@ -1050,23 +1307,36 @@ const overlayStyle: React.CSSProperties = {
1050
1307
  backgroundColor: 'rgba(0, 0, 0, 0.5)',
1051
1308
  };
1052
1309
 
1053
- const scrollableLayoutStyle: React.CSSProperties = {
1310
+ // Pinned to the sheet's bottom edge — takes up layout space below the content
1311
+ const relativeFooterStyle = {
1312
+ marginTop: 'auto',
1313
+ } as const;
1314
+
1315
+ // Floats over the content so it doesn't take up layout space
1316
+ const absoluteHeaderStyle = {
1317
+ position: 'absolute',
1318
+ top: 0,
1319
+ left: 0,
1320
+ right: 0,
1321
+ zIndex: 1,
1322
+ } as const;
1323
+
1324
+ const SIZED_LAYOUT_HEIGHT = 'calc(100% - var(--snap-point-height, 0px))';
1325
+
1326
+ const sizedLayoutStyle: React.CSSProperties = {
1054
1327
  position: 'absolute',
1055
1328
  top: 0,
1056
1329
  left: 0,
1057
1330
  right: 0,
1058
- height: 'calc(100% - var(--snap-point-height, 0px))',
1331
+ height: SIZED_LAYOUT_HEIGHT,
1059
1332
  display: 'flex',
1060
1333
  flexDirection: 'column',
1061
1334
  };
1062
1335
 
1063
- const scrollableContainerStyle: React.CSSProperties = {
1336
+ const contentFillStyle = {
1064
1337
  flex: 1,
1065
1338
  minHeight: 0,
1066
- overflowY: 'auto',
1067
- overscrollBehavior: 'contain',
1068
- touchAction: 'pan-y',
1069
- };
1339
+ } as const;
1070
1340
 
1071
1341
  const visuallyHiddenStyle: React.CSSProperties = {
1072
1342
  position: 'absolute',
@@ -5,4 +5,8 @@ export interface NativeProps extends ViewProps {
5
5
  // No props needed - size will be controlled by parent
6
6
  }
7
7
 
8
- export default codegenNativeComponent<NativeProps>('TrueSheetContentView', {});
8
+ // interfaceOnly: shadow node/state/descriptor are custom (common/cpp) so the
9
+ // content can be bounded to the container when a ScrollView is pinned
10
+ export default codegenNativeComponent<NativeProps>('TrueSheetContentView', {
11
+ interfaceOnly: true,
12
+ });
@@ -1,8 +1,16 @@
1
1
  import type { ViewProps } from 'react-native';
2
+ import type { WithDefault } from 'react-native/Libraries/Types/CodegenTypes';
2
3
  import { codegenNativeComponent } from 'react-native';
3
4
 
4
5
  export interface NativeProps extends ViewProps {
5
- // Footer-specific props can be added here if needed
6
+ // Seeds the footer's first layout with the sheet's bottom safe-area inset
7
+ // (insetAdjustment: 'automatic') so a late-set footer doesn't need a second
8
+ // layout pass — and a visible sheet resize — once native pushes the inset
9
+ autoBottomInset?: WithDefault<boolean, false>;
6
10
  }
7
11
 
8
- export default codegenNativeComponent<NativeProps>('TrueSheetFooterView', {});
12
+ // interfaceOnly: shadow node/state/descriptor are custom (common/cpp) so the
13
+ // footer can absorb the sheet's bottom safe-area inset as padding
14
+ export default codegenNativeComponent<NativeProps>('TrueSheetFooterView', {
15
+ interfaceOnly: true,
16
+ });
@@ -42,9 +42,16 @@ type ScrollableOptionsType = Readonly<{
42
42
  }>;
43
43
 
44
44
  type FooterOptionsType = Readonly<{
45
+ // Named uniquely across option structs — codegen derives the enum name from
46
+ // the field name, so a second `position` would redefine TrueSheetViewPosition
47
+ footerPosition?: WithDefault<'relative' | 'absolute', 'relative'>;
45
48
  keyboardOffset?: WithDefault<Double, 0>;
46
49
  }>;
47
50
 
51
+ type HeaderOptionsType = Readonly<{
52
+ position?: WithDefault<'relative' | 'absolute', 'relative'>;
53
+ }>;
54
+
48
55
  export interface DetentInfoEventPayload {
49
56
  index: Int32;
50
57
  position: Double;
@@ -115,8 +122,8 @@ export interface NativeProps extends ViewProps {
115
122
  draggable?: WithDefault<boolean, true>;
116
123
  dimmed?: WithDefault<boolean, true>;
117
124
  initialDetentAnimated?: WithDefault<boolean, true>;
118
- scrollable?: WithDefault<boolean, false>;
119
125
  scrollableOptions?: ScrollableOptionsType;
126
+ headerOptions?: HeaderOptionsType;
120
127
  footerOptions?: FooterOptionsType;
121
128
  presentation?: WithDefault<'page' | 'form', 'page'>;
122
129
 
@@ -136,12 +136,14 @@ export type TrueSheetNavigationSheetProps = Pick<
136
136
  | 'blurOptions'
137
137
  | 'maxContentHeight'
138
138
  | 'maxContentWidth'
139
- | 'scrollable'
140
139
  | 'presentation'
140
+ | 'style'
141
141
  | 'header'
142
142
  | 'headerStyle'
143
+ | 'headerOptions'
143
144
  | 'footer'
144
145
  | 'footerStyle'
146
+ | 'footerOptions'
145
147
  | 'scrollableOptions'
146
148
  | 'insetAdjustment'
147
149
  | 'anchor'
@@ -197,6 +197,13 @@ export type DialogProps = {
197
197
  * content (e.g. iPad-style form sheets).
198
198
  */
199
199
  onContentHeightChange?: (height: number) => void;
200
+ /**
201
+ * Overrides the measured content height used to resolve the 'auto' snap
202
+ * point. Used when the caller bounds the content to the visible sheet
203
+ * (e.g. a plugged scrollable) so the auto-size wrapper can't be measured
204
+ * from natural flow.
205
+ */
206
+ contentHeight?: number;
200
207
  } & (WithFadeFromProps | WithoutFadeFromProps);
201
208
 
202
209
  export function Root({
@@ -239,6 +246,7 @@ export function Root({
239
246
  peekHeight,
240
247
  initialAnimated = true,
241
248
  onContentHeightChange,
249
+ contentHeight: contentHeightProp,
242
250
  }: DialogProps) {
243
251
  const [isOpen = false, setIsOpen] = useControllableState({
244
252
  defaultProp: defaultOpen,
@@ -273,15 +281,16 @@ export function Root({
273
281
  const drawerHeightRef = React.useRef(drawerRef.current?.getBoundingClientRect().height || 0);
274
282
  const drawerWidthRef = React.useRef(drawerRef.current?.getBoundingClientRect().width || 0);
275
283
  const initialDrawerHeight = React.useRef(0);
276
- const [contentHeight, setContentHeight] = React.useState(0);
284
+ const [measuredContentHeight, setContentHeight] = React.useState(0);
285
+ const contentHeight = contentHeightProp ?? measuredContentHeight;
277
286
 
278
287
  const onContentHeightChangeRef = React.useRef(onContentHeightChange);
279
288
  React.useEffect(() => {
280
289
  onContentHeightChangeRef.current = onContentHeightChange;
281
290
  });
282
291
  React.useEffect(() => {
283
- onContentHeightChangeRef.current?.(contentHeight);
284
- }, [contentHeight]);
292
+ onContentHeightChangeRef.current?.(measuredContentHeight);
293
+ }, [measuredContentHeight]);
285
294
 
286
295
  const onSnapPointChange = React.useCallback((activeSnapPointIndex: number) => {
287
296
  // Change openTime ref when we reach the last snap point to prevent dragging for 500ms incase it's scrollable.
@@ -771,7 +780,16 @@ export function Root({
771
780
  dragEndTime.current = new Date();
772
781
  const swipeAmount = getTranslate(drawerRef.current, direction);
773
782
 
774
- if (!event || !shouldDrag(event.target, false) || !swipeAmount || Number.isNaN(swipeAmount))
783
+ // Drag overshoot below the lowest snap point translates the detached
784
+ // wrapper while the drawer stays pinned (see onDrag in use-snap-points) —
785
+ // common when a clamped 'auto' puts the lowest snap point at 0. Count the
786
+ // wrapper's translation as movement, or the release is swallowed and the
787
+ // sheet is left stuck at the overshoot position.
788
+ const wrapper = drawerRef.current.closest<HTMLElement>('[data-vaul-detached-wrapper]');
789
+ const wrapperSwipeAmount = wrapper ? getTranslate(wrapper, direction) : null;
790
+ const hasMoved = Boolean(swipeAmount) || Boolean(wrapperSwipeAmount);
791
+
792
+ if (!event || !shouldDrag(event.target, false) || !hasMoved || Number.isNaN(swipeAmount))
775
793
  return;
776
794
 
777
795
  if (dragStartTime.current === null) return;
@@ -825,7 +843,7 @@ export function Root({
825
843
 
826
844
  const isHorizontalSwipe = direction === 'left' || direction === 'right';
827
845
  if (
828
- Math.abs(swipeAmount) >=
846
+ Math.abs(swipeAmount ?? 0) >=
829
847
  (isHorizontalSwipe ? visibleDrawerWidth : visibleDrawerHeight) * closeThreshold
830
848
  ) {
831
849
  closeDrawer();