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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContainerView.kt +28 -6
  2. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContentView.kt +99 -7
  3. package/android/src/main/java/com/lodev09/truesheet/TrueSheetFooterView.kt +12 -0
  4. package/android/src/main/java/com/lodev09/truesheet/TrueSheetOverlayView.kt +11 -1
  5. package/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt +18 -7
  6. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt +40 -39
  7. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewManager.kt +18 -8
  8. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetBottomSheetView.kt +14 -8
  9. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetCoordinatorLayout.kt +8 -1
  10. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDetentCalculator.kt +32 -5
  11. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDimView.kt +3 -0
  12. package/ios/TrueSheetContainerView.h +17 -4
  13. package/ios/TrueSheetContainerView.mm +23 -6
  14. package/ios/TrueSheetContentView.h +13 -1
  15. package/ios/TrueSheetContentView.mm +93 -35
  16. package/ios/TrueSheetFooterView.mm +46 -16
  17. package/ios/TrueSheetView.mm +30 -28
  18. package/ios/TrueSheetViewController.h +21 -1
  19. package/ios/TrueSheetViewController.mm +54 -16
  20. package/ios/core/TrueSheetDetentCalculator.h +6 -1
  21. package/ios/core/TrueSheetDetentCalculator.mm +9 -4
  22. package/ios/core/TrueSheetKeyboardObserver.mm +2 -0
  23. package/lib/module/TrueSheet.js +8 -5
  24. package/lib/module/TrueSheet.js.map +1 -1
  25. package/lib/module/TrueSheet.web.js +93 -218
  26. package/lib/module/TrueSheet.web.js.map +1 -1
  27. package/lib/module/TrueSheetPeek.web.js +20 -15
  28. package/lib/module/TrueSheetPeek.web.js.map +1 -1
  29. package/lib/module/fabric/TrueSheetViewNativeComponent.ts +5 -3
  30. package/lib/module/navigation/expo-router/index.js +5 -4
  31. package/lib/module/navigation/expo-router/index.js.map +1 -1
  32. package/lib/module/web/constants.js +1 -1
  33. package/lib/module/web/constants.js.map +1 -1
  34. package/lib/module/web/layout.js +168 -0
  35. package/lib/module/web/layout.js.map +1 -0
  36. package/lib/module/web/vaul/helpers.js +1 -0
  37. package/lib/module/web/vaul/helpers.js.map +1 -1
  38. package/lib/module/web/vaul/index.js +9 -4
  39. package/lib/module/web/vaul/index.js.map +1 -1
  40. package/lib/module/web/vaul/use-snap-points.js +6 -8
  41. package/lib/module/web/vaul/use-snap-points.js.map +1 -1
  42. package/lib/typescript/src/TrueSheet.d.ts.map +1 -1
  43. package/lib/typescript/src/TrueSheet.types.d.ts +64 -25
  44. package/lib/typescript/src/TrueSheet.types.d.ts.map +1 -1
  45. package/lib/typescript/src/TrueSheet.web.d.ts.map +1 -1
  46. package/lib/typescript/src/TrueSheetPeek.web.d.ts +8 -4
  47. package/lib/typescript/src/TrueSheetPeek.web.d.ts.map +1 -1
  48. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts +5 -3
  49. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts.map +1 -1
  50. package/lib/typescript/src/navigation/expo-router/index.d.ts +5 -3
  51. package/lib/typescript/src/navigation/expo-router/index.d.ts.map +1 -1
  52. package/lib/typescript/src/navigation/types.d.ts +1 -1
  53. package/lib/typescript/src/navigation/types.d.ts.map +1 -1
  54. package/lib/typescript/src/web/constants.d.ts +1 -1
  55. package/lib/typescript/src/web/constants.d.ts.map +1 -1
  56. package/lib/typescript/src/web/layout.d.ts +19 -0
  57. package/lib/typescript/src/web/layout.d.ts.map +1 -0
  58. package/lib/typescript/src/web/vaul/helpers.d.ts +1 -0
  59. package/lib/typescript/src/web/vaul/helpers.d.ts.map +1 -1
  60. package/lib/typescript/src/web/vaul/index.d.ts.map +1 -1
  61. package/lib/typescript/src/web/vaul/use-snap-points.d.ts.map +1 -1
  62. package/package.json +7 -6
  63. package/src/TrueSheet.tsx +7 -4
  64. package/src/TrueSheet.types.ts +67 -25
  65. package/src/TrueSheet.web.tsx +132 -263
  66. package/src/TrueSheetPeek.web.tsx +25 -23
  67. package/src/fabric/TrueSheetViewNativeComponent.ts +5 -3
  68. package/src/navigation/expo-router/index.ts +8 -11
  69. package/src/navigation/types.ts +3 -2
  70. package/src/web/constants.ts +1 -1
  71. package/src/web/layout.ts +196 -0
  72. package/src/web/vaul/helpers.ts +5 -0
  73. package/src/web/vaul/index.tsx +21 -4
  74. package/src/web/vaul/use-snap-points.ts +6 -14
@@ -35,12 +35,16 @@ import type {
35
35
  WillFocusEvent,
36
36
  WillPresentEvent,
37
37
  } from './TrueSheet.types';
38
- import { measurePeekContentHeight, TrueSheetPeekContext } from './TrueSheetPeek.web';
38
+ import {
39
+ measurePeekContentHeight,
40
+ TrueSheetPeekContext,
41
+ type TrueSheetPeekContextValue,
42
+ } from './TrueSheetPeek.web';
39
43
  import { usePortalContainer, useRegisterSheet, useSheetStack } from './TrueSheetProvider.web';
40
44
  import {
41
45
  COLOR_SURFACE_CONTAINER_LOW_DARK,
42
46
  COLOR_SURFACE_CONTAINER_LOW_LIGHT,
43
- DEFAULT_ANCHOR_OFFSET,
47
+ DEFAULT_PLACEMENT_OFFSET,
44
48
  DEFAULT_CORNER_RADIUS,
45
49
  DEFAULT_DETACHED_OFFSET,
46
50
  DEFAULT_FORM_SHEET_HEIGHT_RATIO,
@@ -53,6 +57,7 @@ import {
53
57
  DEFAULT_MAX_WIDTH,
54
58
  } from './web/constants';
55
59
  import { getDOMElement } from './web/dom';
60
+ import { observeSheetLayout } from './web/layout';
56
61
  import { Drawer } from './web/vaul';
57
62
  import { DEFAULT_PEEK_HEIGHT, DRAG_CLASS, TRANSITIONS } from './web/vaul/constants';
58
63
 
@@ -78,8 +83,8 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
78
83
  backgroundColor: backgroundColorProp,
79
84
  maxContentHeight,
80
85
  maxContentWidth,
81
- anchor = 'center',
82
- anchorOffset = DEFAULT_ANCHOR_OFFSET,
86
+ placement = 'automatic',
87
+ placementOffset = DEFAULT_PLACEMENT_OFFSET,
83
88
  scrollableRef,
84
89
  scrollableOptions,
85
90
  grabber = true,
@@ -168,8 +173,6 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
168
173
  const validDetentsRef = useRef(validDetents);
169
174
  validDetentsRef.current = validDetents;
170
175
 
171
- const hasAutoDetent = validDetents.includes('auto');
172
-
173
176
  const handleSetActiveSnapPoint = useCallback((snapPoint: number | string | null) => {
174
177
  setActiveSnapPoint(
175
178
  snapPoint == null
@@ -291,13 +294,9 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
291
294
  // within the content — measured by the peek against `contentRef`.
292
295
  const [peekContentHeight, setPeekContentHeight] = useState(0);
293
296
  const contentRef = useRef<View>(null);
294
- const peekElRef = useRef<View>(null);
295
- const peekContext = useMemo(() => ({ contentRef, peekRef: peekElRef, setPeekContentHeight }), []);
297
+ const peekElRef = useRef<View | null>(null);
296
298
 
297
- // The peek's own onLayout only fires when *it* resizes. Content added above
298
- // it moves it without resizing it, so re-measure its offset whenever the
299
- // content view's layout changes.
300
- const handleContentLayout = useCallback(() => {
299
+ const measurePeek = useCallback(() => {
301
300
  const peekEl = getDOMElement(peekElRef.current);
302
301
  const contentEl = getDOMElement(contentRef.current);
303
302
  if (peekEl && contentEl) {
@@ -305,12 +304,35 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
305
304
  }
306
305
  }, []);
307
306
 
307
+ // Last-attached wins — during a screen swap the incoming peek mounts before
308
+ // the outgoing one unmounts, so the outgoing detach must not clear it.
309
+ const peekContext = useMemo<TrueSheetPeekContextValue>(
310
+ () => ({
311
+ contentRef,
312
+ attachPeek: (view) => {
313
+ peekElRef.current = view;
314
+ measurePeek();
315
+ },
316
+ detachPeek: (view) => {
317
+ if (peekElRef.current !== view) return;
318
+ peekElRef.current = null;
319
+ setPeekContentHeight(0);
320
+ },
321
+ measurePeek: (view) => {
322
+ if (peekElRef.current === view) measurePeek();
323
+ },
324
+ }),
325
+ [measurePeek]
326
+ );
327
+
328
+ // The peek's own onLayout only fires when *it* resizes. Content added above
329
+ // it moves it without resizing it, so re-measure its offset whenever the
330
+ // content view's layout changes.
331
+ const handleContentLayout = measurePeek;
332
+
308
333
  // An absolute (floating) header overlaps the content, so it contributes no
309
334
  // height to the 'auto' detent measurement.
310
335
  const absoluteHeader = headerOptions?.position === 'absolute';
311
- const absoluteHeaderRef = useRef(absoluteHeader);
312
- absoluteHeaderRef.current = absoluteHeader;
313
-
314
336
  const resolvedHeaderStyle = absoluteHeader ? [absoluteHeaderStyle, headerStyle] : headerStyle;
315
337
 
316
338
  // Same for an absolute (floating) footer — rendered via vaul's
@@ -346,108 +368,48 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
346
368
  (footer && absoluteFooter ? footerHeight : 0) +
347
369
  peekContentHeight || DEFAULT_PEEK_HEIGHT;
348
370
 
349
- // Web mirror of native's scrollable handling for 'auto' detents: a plugged
350
- // scrollable keeps the sized (bounded) layout so its viewport is capped to
351
- // the visible sheet and can scroll, while the 'auto' height is measured with
352
- // the viewport replaced by the scrollable's content size — mirrors native
353
- // `naturalHeight`.
354
- const [hasBoundedScrollable, setHasBoundedScrollable] = useState(false);
355
- const [scrollableAutoHeight, setScrollableAutoHeight] = useState(0);
356
- const detectedScrollerRef = useRef<HTMLElement | null>(null);
357
-
358
- // Natural content height (header + content + footer, with a detected
359
- // scrollable's viewport replaced by its content size) — the height the
360
- // content wants regardless of the sheet's bounds.
361
- const measureNaturalHeight = useCallback(() => {
362
- const contentEl = getDOMElement(contentRef.current);
363
- if (!contentEl || !contentEl.isConnected) return 0;
364
- const headerEl = absoluteHeaderRef.current ? null : getDOMElement(headerElRef.current);
365
- const footerEl = absoluteFooterRef.current ? null : getDOMElement(footerElRef.current);
366
- let height =
367
- (headerEl?.offsetHeight ?? 0) + (footerEl?.offsetHeight ?? 0) + contentEl.offsetHeight;
368
- const scroller = detectedScrollerRef.current;
369
- const scrollContent = scroller?.firstElementChild;
370
- if (scroller?.isConnected && scrollContent instanceof HTMLElement) {
371
- height += scrollContent.offsetHeight - scroller.clientHeight;
372
- }
373
- return Math.max(0, height);
374
- }, []);
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;
375
379
 
376
380
  useEffect(() => {
377
- if (!isOpen || !hasAutoDetent) return undefined;
381
+ if (!isOpen) return undefined;
378
382
 
379
383
  let canceled = false;
380
384
  let rafId = 0;
381
- let mutationObserver: MutationObserver | null = null;
382
- let resizeObserver: ResizeObserver | null = null;
385
+ let observer: ReturnType<typeof observeSheetLayout> | undefined;
383
386
 
384
387
  const attach = () => {
385
388
  if (canceled) return;
386
- const drawerEl = drawerContentRef.current;
387
- if (!drawerEl || !drawerEl.isConnected) {
389
+ const layoutEl = sizedLayoutRef.current;
390
+ const drawer = drawerContentRef.current;
391
+ if (!drawer || !layoutEl?.isConnected) {
388
392
  // Radix Presence defers the portal mount; poll until the drawer is live.
389
393
  rafId = window.requestAnimationFrame(attach);
390
394
  return;
391
395
  }
392
396
 
393
- const measure = () => setScrollableAutoHeight(measureNaturalHeight());
394
-
395
- let observed: {
396
- contentEl: HTMLElement | null;
397
- headerEl: HTMLElement | null;
398
- scroller: HTMLElement;
399
- scrollContent: Element | null;
400
- } | null = null;
401
-
402
- // (Re)resolve the plugged scrollable and observe the nodes whose size
403
- // feeds the natural height — content growth inside a bounded scroller is
404
- // invisible to the sheet's own layout (mirrors native's contentSize
405
- // observation). Resolves the content node live: the layout-branch swap
406
- // (natural flow ↔ sized) remounts it.
407
- const observe = () => {
408
- const contentEl = getDOMElement(contentRef.current);
409
- const headerEl = getDOMElement(headerElRef.current);
410
-
411
- // Skip mutations that don't change the observed topology — e.g. a
412
- // virtualized list mounting rows inside the plugged scroller. Size
413
- // changes are already covered by the ResizeObserver.
414
- if (
415
- observed &&
416
- observed.contentEl === contentEl &&
417
- contentEl?.isConnected &&
418
- observed.headerEl === headerEl &&
419
- observed.scroller.isConnected &&
420
- observed.scrollContent === observed.scroller.firstElementChild
421
- ) {
422
- return;
423
- }
424
-
425
- const resolved =
426
- contentEl && contentEl.isConnected ? getScrollableElement(scrollableRef?.current) : null;
427
- const scroller = resolved?.isConnected ? resolved : null;
428
- detectedScrollerRef.current = scroller;
429
- setHasBoundedScrollable(scroller != null);
430
- observed = scroller
431
- ? { contentEl, headerEl, scroller, scrollContent: scroller.firstElementChild }
432
- : null;
433
-
434
- resizeObserver?.disconnect();
435
- resizeObserver = new ResizeObserver(measure);
436
- if (contentEl?.isConnected) resizeObserver.observe(contentEl);
437
- if (headerEl) resizeObserver.observe(headerEl);
438
- if (scroller) {
439
- resizeObserver.observe(scroller);
440
- if (scroller.firstElementChild) resizeObserver.observe(scroller.firstElementChild);
441
- }
442
- measure();
443
- };
444
-
445
- observe();
446
- // Watch the whole drawer subtree — catches the scrollable mounting/
447
- // unmounting AND the layout-branch swap, which remounts the content node
448
- // (a content-scoped observer would go stale after the swap).
449
- mutationObserver = new MutationObserver(observe);
450
- 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
+ });
451
413
  };
452
414
 
453
415
  rafId = window.requestAnimationFrame(attach);
@@ -455,10 +417,16 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
455
417
  return () => {
456
418
  canceled = true;
457
419
  window.cancelAnimationFrame(rafId);
458
- mutationObserver?.disconnect();
459
- resizeObserver?.disconnect();
420
+ observer?.disconnect();
460
421
  };
461
- }, [isOpen, hasAutoDetent, measureNaturalHeight, scrollableRef]);
422
+ }, [
423
+ isOpen,
424
+ absoluteHeader,
425
+ absoluteFooter,
426
+ footerInsetAdjustment,
427
+ measureContent,
428
+ scrollableRef,
429
+ ]);
462
430
 
463
431
  // Below the last detent a vertical touch pan moves the sheet, not the
464
432
  // content — `[data-vaul-scroll-locked]` disables vertical touch panning on
@@ -466,13 +434,6 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
466
434
  const isScrollLocked =
467
435
  validDetents.length > 0 && activeSnapPoint !== validDetents[validDetents.length - 1];
468
436
 
469
- // Vaul measures the auto-size wrapper's offsetHeight (always, post fork).
470
- // Track it here so the form sheet can size its card to fit content,
471
- // clamped between a minimum ratio of the viewport and a maximum derived
472
- // from `detachedOffset` (the breathing room left at top + bottom of the
473
- // floating card).
474
- const [measuredContentHeight, setMeasuredContentHeight] = useState(0);
475
-
476
437
  const effectiveMaxContentHeight = useMemo<number | undefined>(() => {
477
438
  if (maxContentHeight !== undefined) return maxContentHeight;
478
439
  if (!isFormSheet) return undefined;
@@ -539,7 +500,6 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
539
500
  effectiveMaxContentHeight,
540
501
  peekHeight,
541
502
  peekContentHeight,
542
- measuredContentHeight,
543
503
  });
544
504
  geometryInputsRef.current = {
545
505
  effectiveDetached,
@@ -547,7 +507,6 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
547
507
  effectiveMaxContentHeight,
548
508
  peekHeight,
549
509
  peekContentHeight,
550
- measuredContentHeight,
551
510
  };
552
511
 
553
512
  const computeDetentGeometry = useCallback(() => {
@@ -561,12 +520,9 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
561
520
  inputs.effectiveMaxContentHeight !== undefined
562
521
  ? Math.min(effectiveH, inputs.effectiveMaxContentHeight)
563
522
  : effectiveH;
564
- // 'auto' resolves to the content's natural height. Read it live — the
565
- // `measuredContentHeight` state lags mount by a frame (vaul's initial
566
- // ref-callback measure runs while the portal subtree is still detached,
567
- // so it reads 0 until the ResizeObserver fires). Falls back to the state
568
- // value, then to vaul's pre-measure fallback (effectiveHeight / 2).
569
- 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;
570
526
  const autoHeight = Math.min(measuredHeight > 0 ? measuredHeight : effectiveH / 2, ceiling);
571
527
 
572
528
  // 'peek' is measured live from the DOM (offsetHeight is layout-based, so
@@ -602,7 +558,7 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
602
558
  values.push(effectiveH > 0 ? h / effectiveH : 0);
603
559
  }
604
560
  return { windowH, effectiveH, ceiling, positions, values };
605
- }, [measureNaturalHeight]);
561
+ }, []);
606
562
 
607
563
  // Detent info for lifecycle events. Position/detent come from the active
608
564
  // detent's target geometry — not the live DOM rect — so willPresent (drawer
@@ -681,54 +637,17 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
681
637
  [computeDetentGeometry]
682
638
  );
683
639
 
684
- // Mirror native synchronous layout: while dragging (and through the post-
685
- // release settle) the sized layout tracks the sheet's visible height in
686
- // realtime, clamped to the smallest detent — dragging below it slides the
687
- // sheet out without resizing. Once settled, the inline height is cleared so
688
- // the CSS calc() owns sizing again (adapts to viewport resizes at rest).
689
640
  const sizedLayoutRef = useRef<HTMLDivElement>(null);
690
641
  const isDraggingRef = useRef(false);
691
- const isSettlingAfterDragRef = useRef(false);
692
- const updateSizedLayoutHeight = useCallback(
693
- (position: number) => {
694
- const node = sizedLayoutRef.current;
695
- if (!node) return;
696
-
697
- const { effectiveH, ceiling, positions } = computeDetentGeometry();
698
- if (positions.length === 0) return;
699
-
700
- if (isSettlingAfterDragRef.current) {
701
- const snap = activeSnapPointRef.current;
702
- const index = snap != null ? validDetentsRef.current.indexOf(snap) : -1;
703
- const target = index >= 0 ? positions[index]! : null;
704
- if (target != null && Math.abs(position - target) < 1) {
705
- isSettlingAfterDragRef.current = false;
706
- // Restore the calc — height is an inline style, so clearing it would
707
- // leave the div unsized (React won't re-apply it without a render)
708
- node.style.height = SIZED_LAYOUT_HEIGHT;
709
- return;
710
- }
711
- }
712
-
713
- // positions[0] is the smallest detent's top-Y — its height is the clamp floor.
714
- const minHeight = effectiveH - positions[0]!;
715
- const height = Math.min(Math.max(effectiveH - position, minHeight), ceiling);
716
- node.style.height = `${height}px`;
717
- },
718
- [computeDetentGeometry]
719
- );
720
642
 
721
643
  const handlePositionChange = useCallback(
722
644
  (position: number) => {
723
- if (isDraggingRef.current || isSettlingAfterDragRef.current) {
724
- updateSizedLayoutHeight(position);
725
- }
726
645
  const { index, detent } = interpolateFromPosition(position);
727
646
  onPositionChangeRef.current?.({
728
647
  nativeEvent: { index, position, detent, realtime: true },
729
648
  } as PositionChangeEvent);
730
649
  },
731
- [interpolateFromPosition, updateSizedLayoutHeight]
650
+ [interpolateFromPosition]
732
651
  );
733
652
 
734
653
  // Fire onMount once after first render. React-mount is the earliest point
@@ -856,17 +775,11 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
856
775
  }
857
776
  onDragChangeRef.current?.({ nativeEvent: computeDetentInfo(true) } as DragChangeEvent);
858
777
  }, [computeDetentInfo]);
859
- const handleRelease = useCallback(
860
- (_event: unknown, open: boolean) => {
861
- if (!isDraggingRef.current) return;
862
- isDraggingRef.current = false;
863
- // Track the sized layout through the settle animation only when the sheet
864
- // stays open — a dismissal slides out at the min-clamped height.
865
- isSettlingAfterDragRef.current = open;
866
- onDragEndRef.current?.({ nativeEvent: computeDetentInfo(true) } as DragEndEvent);
867
- },
868
- [computeDetentInfo]
869
- );
778
+ const handleRelease = useCallback(() => {
779
+ if (!isDraggingRef.current) return;
780
+ isDraggingRef.current = false;
781
+ onDragEndRef.current?.({ nativeEvent: computeDetentInfo(true) } as DragEndEvent);
782
+ }, [computeDetentInfo]);
870
783
 
871
784
  const { isNested, dismissAbove, descendants } = useSheetStack(
872
785
  methodsRef,
@@ -886,6 +799,7 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
886
799
  // Mirror Android: translate this sheet down to match the deepest descendant's
887
800
  // top so the whole stack visually aligns. Cascades because every ancestor
888
801
  // re-runs whenever the stack (and thus its descendants) changes.
802
+ const wasCascadedRef = useRef(false);
889
803
  useEffect(() => {
890
804
  const parent = drawerContentRef.current;
891
805
  if (!parent) return;
@@ -897,7 +811,7 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
897
811
  if (!isOpen) return;
898
812
  const parentWrapper = parent.closest<HTMLElement>('[data-vaul-detached-wrapper]');
899
813
 
900
- 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(',')})`;
901
815
  const wrapperTransition = `clip-path ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`;
902
816
  const CLIP_NONE = 'inset(0px round 0px)';
903
817
 
@@ -921,11 +835,14 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
921
835
  };
922
836
 
923
837
  if (descendants.length === 0) {
838
+ if (!wasCascadedRef.current) return;
839
+ wasCascadedRef.current = false;
924
840
  parent.style.transition = transition;
925
841
  parent.style.transform = '';
926
842
  setClip(CLIP_NONE);
927
843
  return;
928
844
  }
845
+ wasCascadedRef.current = true;
929
846
 
930
847
  // Track the immediate child's *actual* top: its cascade transform when it
931
848
  // was itself pushed behind a grandchild, else its own snap point. Mirrors
@@ -1085,11 +1002,6 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
1085
1002
  };
1086
1003
  }, [isOpen, descendants.length]);
1087
1004
 
1088
- // Definite-height flex layout (per-detent sizing) unless content must be
1089
- // measured in natural flow: 'auto' detents (without a plugged scrollable —
1090
- // those measure via `measureNaturalHeight`) and form-sheet content-fit sizing.
1091
- const useSizedLayout = (!hasAutoDetent || hasBoundedScrollable) && !isFormSheet;
1092
-
1093
1005
  const effectiveCornerRadius = cornerRadius ?? DEFAULT_CORNER_RADIUS;
1094
1006
 
1095
1007
  // Shadow cast upward from the sheet's top edge toward the background. Matches
@@ -1128,13 +1040,13 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
1128
1040
  // Lift content above iOS home indicator / bottom safe area when enabled.
1129
1041
  // A relative footer owns the inset instead (see resolvedFooterStyle); an
1130
1042
  // absolute footer floats over the content, so the content keeps its lift.
1131
- // A plugged scrollable keeps the lift too (contentInsetAdjustmentBehavior,
1132
- // default on) — disable it to scroll edge-to-edge behind the indicator
1133
- // with user-applied content padding.
1043
+ // A plugged scrollable keeps the lift too (contentInsetAdjustment
1044
+ // 'automatic' | 'safe-area') — 'footer' | 'never' scroll edge-to-edge
1045
+ // behind the indicator with user-applied content padding.
1134
1046
  paddingBottom:
1135
1047
  insetAdjustment === 'automatic' &&
1136
1048
  !(footerOwnsInset && !absoluteFooter) &&
1137
- (!scrollableRef || (scrollableOptions?.contentInsetAdjustmentBehavior ?? true))
1049
+ (!scrollableRef || insetBehavior === 'automatic' || insetBehavior === 'safe-area')
1138
1050
  ? 'env(safe-area-inset-bottom, 0px)'
1139
1051
  : 0,
1140
1052
  }),
@@ -1145,7 +1057,7 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
1145
1057
  footerOwnsInset,
1146
1058
  absoluteFooter,
1147
1059
  scrollableRef,
1148
- scrollableOptions,
1060
+ insetBehavior,
1149
1061
  ]
1150
1062
  );
1151
1063
 
@@ -1184,7 +1096,7 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
1184
1096
  // `maxContentWidth` is ignored ('form' is absolute).
1185
1097
  // - presentation='page' → `maxContentWidth` (any viewport) or
1186
1098
  // DEFAULT_MAX_WIDTH (tablet/landscape readability cap).
1187
- // Detached without a width constraint applies anchorOffset on both edges so
1099
+ // Detached without a width constraint applies placementOffset on both edges so
1188
1100
  // the floating card breathes from the viewport sides.
1189
1101
  const wrapperStyle = useMemo<React.CSSProperties | undefined>(() => {
1190
1102
  // Mobile portrait ignores width sizing entirely (matches iOS/Android:
@@ -1203,30 +1115,30 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
1203
1115
  if (dropShadow) next.filter = dropShadow;
1204
1116
  if (!needsMargins) return next;
1205
1117
 
1206
- let marginLeft: number | string;
1207
- let marginRight: number | string;
1118
+ let marginInlineStart: number | string;
1119
+ let marginInlineEnd: number | string;
1208
1120
  if (isFormSheet) {
1209
- marginLeft = 'auto';
1210
- marginRight = 'auto';
1121
+ marginInlineStart = 'auto';
1122
+ marginInlineEnd = 'auto';
1211
1123
  } else if (maxWidth == null) {
1212
- marginLeft = anchorOffset;
1213
- marginRight = anchorOffset;
1124
+ marginInlineStart = placementOffset;
1125
+ marginInlineEnd = placementOffset;
1214
1126
  } else {
1215
- marginLeft = anchor === 'left' ? anchorOffset : 'auto';
1216
- marginRight = anchor === 'right' ? anchorOffset : 'auto';
1127
+ marginInlineStart = placement === 'leading' ? placementOffset : 'auto';
1128
+ marginInlineEnd = placement === 'trailing' ? placementOffset : 'auto';
1217
1129
  }
1218
1130
 
1219
1131
  if (maxWidth != null) next.maxWidth = maxWidth;
1220
- next.marginLeft = marginLeft;
1221
- next.marginRight = marginRight;
1132
+ next.marginInlineStart = marginInlineStart;
1133
+ next.marginInlineEnd = marginInlineEnd;
1222
1134
  return next;
1223
1135
  }, [
1224
1136
  isLandscapeOrTablet,
1225
1137
  isFormSheet,
1226
1138
  maxContentWidth,
1227
1139
  presentation,
1228
- anchor,
1229
- anchorOffset,
1140
+ placement,
1141
+ placementOffset,
1230
1142
  effectiveDetached,
1231
1143
  dropShadow,
1232
1144
  ]);
@@ -1275,14 +1187,7 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
1275
1187
  peekHeight={peekHeight}
1276
1188
  initialAnimated={initialDetentAnimated}
1277
1189
  detachedWrapperStyle={wrapperStyle}
1278
- onContentHeightChange={setMeasuredContentHeight}
1279
- contentHeight={
1280
- // Bounded scrollable → the auto-size wrapper measures 0 (sized
1281
- // layout is absolute), so feed vaul the natural height instead.
1282
- hasAutoDetent && hasBoundedScrollable && scrollableAutoHeight > 0
1283
- ? scrollableAutoHeight
1284
- : undefined
1285
- }
1190
+ contentHeight={measuredContentHeight}
1286
1191
  activeSnapPoint={activeSnapPoint}
1287
1192
  setActiveSnapPoint={handleSetActiveSnapPoint}
1288
1193
  {...snapPointsProps}
@@ -1307,64 +1212,29 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
1307
1212
  {accessibilityOptions?.paneTitle ?? 'Sheet'}
1308
1213
  </Drawer.Title>
1309
1214
  {grabber && <Drawer.Handle style={handleStyle} />}
1310
- {useSizedLayout ? (
1311
- // vaul wraps children in `[data-vaul-auto-size-wrapper]` (display:
1312
- // flow-root) which doesn't honor descendant flex layout. Use an
1313
- // absolute fill sized to the visible portion (via vaul's
1314
- // `--snap-point-height` var) so the inner flex column has a
1315
- // definite height for flex layouts to fill — mirrors native, where
1316
- // the container is sized to the sheet's visible height per detent.
1317
- <div
1318
- ref={sizedLayoutRef}
1319
- style={sizedLayoutStyle}
1320
- data-vaul-scroll-locked={isScrollLocked ? '' : undefined}
1321
- >
1322
- {header && (
1323
- <View ref={headerElRef} style={resolvedHeaderStyle} onLayout={handleHeaderLayout}>
1324
- {isValidElement(header) ? header : createElement(header)}
1325
- </View>
1326
- )}
1327
- {/* Content lays out naturally like native — fill only for auto
1328
- detents with a plugged scrollable, where natural layout is
1329
- circular (sheet height derives from the scroll content size).
1330
- Unlike native, CSS won't cap the content to the container
1331
- (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 && (
1332
1229
  <View
1333
- ref={contentRef}
1334
- style={hasAutoDetent && hasBoundedScrollable ? [contentFillStyle, style] : style}
1335
- onLayout={handleContentLayout}
1230
+ ref={footerElRef}
1231
+ style={[relativeFooterStyle, resolvedFooterStyle]}
1232
+ onLayout={handleFooterLayout}
1336
1233
  >
1337
- {children}
1234
+ {isValidElement(footer) ? footer : createElement(footer)}
1338
1235
  </View>
1339
- {footer && !absoluteFooter && (
1340
- <View
1341
- ref={footerElRef}
1342
- style={[relativeFooterStyle, resolvedFooterStyle]}
1343
- onLayout={handleFooterLayout}
1344
- >
1345
- {isValidElement(footer) ? footer : createElement(footer)}
1346
- </View>
1347
- )}
1348
- </div>
1349
- ) : (
1350
- // Natural flow so vaul can measure content height — required for
1351
- // 'auto' detents and form-sheet content-fit sizing.
1352
- <>
1353
- {header && (
1354
- <View ref={headerElRef} style={resolvedHeaderStyle} onLayout={handleHeaderLayout}>
1355
- {isValidElement(header) ? header : createElement(header)}
1356
- </View>
1357
- )}
1358
- <View ref={contentRef} style={style} onLayout={handleContentLayout}>
1359
- {children}
1360
- </View>
1361
- {footer && !absoluteFooter && (
1362
- <View ref={footerElRef} style={resolvedFooterStyle} onLayout={handleFooterLayout}>
1363
- {isValidElement(footer) ? footer : createElement(footer)}
1364
- </View>
1365
- )}
1366
- </>
1367
- )}
1236
+ )}
1237
+ </div>
1368
1238
  </Drawer.Content>
1369
1239
  </Drawer.Portal>
1370
1240
  </Drawer.Root>
@@ -1402,12 +1272,11 @@ const sizedLayoutStyle: React.CSSProperties = {
1402
1272
  height: SIZED_LAYOUT_HEIGHT,
1403
1273
  display: 'flex',
1404
1274
  flexDirection: 'column',
1275
+ boxSizing: 'border-box',
1276
+ paddingBottom: 'inherit',
1405
1277
  };
1406
1278
 
1407
- const contentFillStyle = {
1408
- flex: 1,
1409
- minHeight: 0,
1410
- } as const;
1279
+ const contentStyle = { flexShrink: 1, minHeight: 0 } as const;
1411
1280
 
1412
1281
  const visuallyHiddenStyle: React.CSSProperties = {
1413
1282
  position: 'absolute',