@lodev09/react-native-true-sheet 3.11.0-beta.1 → 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 (41) 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 +222 -50
  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 +88 -133
  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 +7 -1
  31. package/lib/typescript/src/web/vaul/index.d.ts.map +1 -1
  32. package/package.json +6 -1
  33. package/src/TrueSheet.tsx +2 -2
  34. package/src/TrueSheet.types.ts +6 -4
  35. package/src/TrueSheet.web.tsx +220 -52
  36. package/src/TrueSheetProvider.web.tsx +11 -2
  37. package/src/fabric/TrueSheetViewNativeComponent.ts +1 -1
  38. package/src/navigation/types.ts +1 -1
  39. package/src/web/constants.ts +1 -1
  40. package/src/web/vaul/index.tsx +118 -146
  41. package/src/web/vaul/style.css +1 -1
@@ -3,6 +3,7 @@
3
3
  import React from 'react';
4
4
 
5
5
  import * as DialogPrimitive from '@radix-ui/react-dialog';
6
+ import { Presence } from '@radix-ui/react-presence';
6
7
 
7
8
  import { DrawerContext, useDrawerContext } from './context';
8
9
  import './style.css';
@@ -185,6 +186,12 @@ export type DialogProps = {
185
186
  * the sheet appears at its target detent instantly. Defaults to `true`.
186
187
  */
187
188
  initialAnimated?: boolean;
189
+ /**
190
+ * Fires when the auto-size wrapper's measured content height changes.
191
+ * Useful for callers that want to size the surrounding card to fit
192
+ * content (e.g. iPad-style form sheets).
193
+ */
194
+ onContentHeightChange?: (height: number) => void;
188
195
  } & (WithFadeFromProps | WithoutFadeFromProps);
189
196
 
190
197
  export function Root({
@@ -225,6 +232,7 @@ export function Root({
225
232
  detachedWrapperStyle,
226
233
  maxContentHeight,
227
234
  initialAnimated = true,
235
+ onContentHeightChange,
228
236
  }: DialogProps) {
229
237
  const [isOpen = false, setIsOpen] = useControllableState({
230
238
  defaultProp: defaultOpen,
@@ -239,19 +247,6 @@ export function Root({
239
247
  setTimeout(() => {
240
248
  onAnimationEnd?.(o);
241
249
  }, TRANSITIONS.DURATION * 1000);
242
-
243
- if (o && !modal) {
244
- if (typeof window !== 'undefined') {
245
- window.requestAnimationFrame(() => {
246
- document.body.style.pointerEvents = 'auto';
247
- });
248
- }
249
- }
250
-
251
- if (!o) {
252
- // This will be removed when the exit animation ends (`500ms`)
253
- document.body.style.pointerEvents = 'auto';
254
- }
255
250
  },
256
251
  });
257
252
  const [hasBeenOpened, setHasBeenOpened] = React.useState<boolean>(false);
@@ -274,6 +269,14 @@ export function Root({
274
269
  const initialDrawerHeight = React.useRef(0);
275
270
  const [contentHeight, setContentHeight] = React.useState(0);
276
271
 
272
+ const onContentHeightChangeRef = React.useRef(onContentHeightChange);
273
+ React.useEffect(() => {
274
+ onContentHeightChangeRef.current = onContentHeightChange;
275
+ });
276
+ React.useEffect(() => {
277
+ onContentHeightChangeRef.current?.(contentHeight);
278
+ }, [contentHeight]);
279
+
277
280
  const onSnapPointChange = React.useCallback((activeSnapPointIndex: number) => {
278
281
  // Change openTime ref when we reach the last snap point to prevent dragging for 500ms incase it's scrollable.
279
282
  if (snapPoints && activeSnapPointIndex === snapPointsOffset.length - 1)
@@ -566,88 +569,6 @@ export function Root({
566
569
  onPositionChangeRef.current = onPositionChange;
567
570
  });
568
571
 
569
- // Radix's DismissableLayer (modal) sets body pointer-events to "none" so the
570
- // overlay traps clicks. When the active snap is below fadeFromIndex the
571
- // overlay is fully transparent, and we want clicks to reach the content
572
- // behind the drawer — so we restore body interactivity. DismissableLayer
573
- // captures its DOM node via a ref-callback `setNode`, so its body write
574
- // runs one render later than ours on initial open — re-assert on rAF so
575
- // the final write is ours.
576
- React.useEffect(() => {
577
- if (!modal || !snapPoints || fadeFromIndex === undefined) return;
578
- // On close, restore body interactivity. `useControllableState.onChange`
579
- // only fires for internal `setIsOpen` calls — when the parent flips the
580
- // `open` prop imperatively, the reset at line ~201 never runs.
581
- if (!isOpen) {
582
- document.body.style.pointerEvents = 'auto';
583
- return;
584
- }
585
- if (typeof activeSnapPointIndex !== 'number') return;
586
- const isBelowFade = activeSnapPointIndex < fadeFromIndex;
587
- const value = isBelowFade ? 'auto' : 'none';
588
- document.body.style.pointerEvents = value;
589
- const id = window.requestAnimationFrame(() => {
590
- document.body.style.pointerEvents = value;
591
- });
592
-
593
- // React Navigation keeps unfocused screens mounted with `display: none` on
594
- // web, so the drawer never unmounts and neither `isOpen` nor this effect's
595
- // deps change — a lingering `none` body style would then leak to whichever
596
- // screen becomes visible. Observe the drawer: when an ancestor hides it,
597
- // restore `auto`; when it returns, re-apply the desired value.
598
- let observer: IntersectionObserver | null = null;
599
- let rafId = 0;
600
- const startObserving = () => {
601
- const node = drawerRef.current;
602
- if (!node) {
603
- rafId = window.requestAnimationFrame(startObserving);
604
- return;
605
- }
606
- observer = new IntersectionObserver((entries) => {
607
- const entry = entries[entries.length - 1];
608
- if (!entry) return;
609
- document.body.style.pointerEvents = entry.isIntersecting ? value : 'auto';
610
- });
611
- observer.observe(node);
612
- };
613
- rafId = window.requestAnimationFrame(startObserving);
614
-
615
- // Always restore on cleanup so an unmount while the sheet is still open
616
- // doesn't leave the page uninteractive. If the effect re-runs for a
617
- // normal state change, it re-applies the correct value.
618
- return () => {
619
- window.cancelAnimationFrame(id);
620
- window.cancelAnimationFrame(rafId);
621
- observer?.disconnect();
622
- document.body.style.pointerEvents = 'auto';
623
- };
624
- }, [isOpen, modal, snapPoints, fadeFromIndex, activeSnapPointIndex]);
625
-
626
- // Radix DismissableLayer's body-restore on unmount is broken: its two
627
- // useEffects (see @radix-ui/react-dismissable-layer dist/index.mjs:68-92)
628
- // run cleanups in reverse declaration order, so the layers-set decrement
629
- // (effect B) runs BEFORE the size-1 body-restore check (effect A). When
630
- // the last layer unmounts, A sees size=0 and skips the restore — body
631
- // stays 'none'. Take ownership of the restore here on Drawer.Root unmount.
632
- // Skipped when nested or non-modal so an inner drawer's unmount doesn't
633
- // unlock an outer drawer's modal trap. Also bail if any other Radix
634
- // dismissable layer is still open (sibling Dialog/AlertDialog/Popover) —
635
- // that one wants the lock kept.
636
- React.useEffect(() => {
637
- if (nested || !modal) return;
638
- return () => {
639
- if (typeof document === 'undefined') return;
640
- if (document.body.style.pointerEvents !== 'none') return;
641
- if (
642
- document.querySelector(
643
- '[role="dialog"][data-state="open"], [role="alertdialog"][data-state="open"]'
644
- )
645
- )
646
- return;
647
- document.body.style.pointerEvents = 'auto';
648
- };
649
- }, [nested, modal]);
650
-
651
572
  React.useEffect(() => {
652
573
  function onVisualViewportChange() {
653
574
  if (!drawerRef.current || !repositionInputs) return;
@@ -916,15 +837,6 @@ export function Root({
916
837
  }
917
838
  }
918
839
 
919
- React.useEffect(() => {
920
- if (!modal) {
921
- // Need to do this manually unfortunately
922
- window.requestAnimationFrame(() => {
923
- document.body.style.pointerEvents = 'auto';
924
- });
925
- }
926
- }, [modal]);
927
-
928
840
  return (
929
841
  <DialogPrimitive.Root
930
842
  defaultOpen={defaultOpen}
@@ -939,7 +851,14 @@ export function Root({
939
851
  setIsOpen(open);
940
852
  }}
941
853
  open={isOpen}
942
- modal={modal}
854
+ // Always non-modal at the Radix level — Radix's modal mode locks
855
+ // body.style.pointerEvents and its restore on layer unmount is buggy
856
+ // (see git history). Vaul's own overlay handles click-trapping; the
857
+ // overlay sets pointer-events: none below fadeFromIndex so clicks
858
+ // pass through to the page (matches native dimmedDetentIndex). Vaul's
859
+ // own `modal` prop still drives overlay rendering and click-outside
860
+ // behavior below.
861
+ modal={false}
943
862
  >
944
863
  <DrawerContext.Provider
945
864
  value={{
@@ -1035,26 +954,38 @@ export const Overlay = React.forwardRef<
1035
954
  typeof activeSnapPointIndex === 'number' &&
1036
955
  activeSnapPointIndex < fadeFromIndex;
1037
956
 
957
+ // Render our own overlay element instead of `DialogPrimitive.Overlay`:
958
+ // Radix's Overlay returns null when the Dialog is non-modal, and we run
959
+ // Radix as `modal={false}` to keep it from locking body pointer-events.
960
+ // `Presence` keeps the node mounted through the closing animation
961
+ // (`fadeOut`) before unmounting.
1038
962
  return (
1039
- <DialogPrimitive.Overlay
1040
- onMouseUp={onMouseUp}
1041
- ref={composedRef}
1042
- data-vaul-overlay=""
1043
- data-vaul-snap-points={isOpen && hasSnapPoints ? 'true' : 'false'}
1044
- data-vaul-delayed-snap-points={delayedSnapPoints ? 'true' : 'false'}
1045
- data-vaul-snap-points-overlay={isOpen && shouldFade ? 'true' : 'false'}
1046
- data-vaul-animate={shouldAnimate?.current ? 'true' : 'false'}
1047
- {...rest}
1048
- style={isBelowFade ? overlayBelowFadeStyle(style) : style}
1049
- />
963
+ <Presence present={isOpen}>
964
+ <div
965
+ onMouseUp={onMouseUp}
966
+ ref={composedRef}
967
+ data-state={isOpen ? 'open' : 'closed'}
968
+ data-vaul-overlay=""
969
+ data-vaul-snap-points={isOpen && hasSnapPoints ? 'true' : 'false'}
970
+ data-vaul-delayed-snap-points={delayedSnapPoints ? 'true' : 'false'}
971
+ data-vaul-snap-points-overlay={isOpen && shouldFade ? 'true' : 'false'}
972
+ data-vaul-animate={shouldAnimate?.current ? 'true' : 'false'}
973
+ {...rest}
974
+ style={overlayStyle(style, !!isBelowFade)}
975
+ />
976
+ </Presence>
1050
977
  );
1051
978
  });
1052
979
 
1053
980
  Overlay.displayName = 'Drawer.Overlay';
1054
981
 
1055
- const overlayPointerEventsNone: React.CSSProperties = { pointerEvents: 'none' };
1056
- const overlayBelowFadeStyle = (style: React.CSSProperties | undefined): React.CSSProperties =>
1057
- style ? { ...style, ...overlayPointerEventsNone } : overlayPointerEventsNone;
982
+ const overlayStyle = (
983
+ style: React.CSSProperties | undefined,
984
+ isBelowFade: boolean
985
+ ): React.CSSProperties => ({
986
+ ...style,
987
+ pointerEvents: isBelowFade ? 'none' : 'auto',
988
+ });
1058
989
 
1059
990
  export type ContentProps = React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
1060
991
  /**
@@ -1066,7 +997,18 @@ export type ContentProps = React.ComponentPropsWithoutRef<typeof DialogPrimitive
1066
997
  };
1067
998
 
1068
999
  export const Content = React.forwardRef<HTMLDivElement, ContentProps>(
1069
- ({ onPointerDownOutside, style, onOpenAutoFocus, children, detachedSiblings, ...rest }, ref) => {
1000
+ (
1001
+ {
1002
+ onPointerDownOutside,
1003
+ onEscapeKeyDown,
1004
+ style,
1005
+ onOpenAutoFocus,
1006
+ children,
1007
+ detachedSiblings,
1008
+ ...rest
1009
+ },
1010
+ ref
1011
+ ) => {
1070
1012
  const {
1071
1013
  drawerRef,
1072
1014
  onPress,
@@ -1081,6 +1023,8 @@ export const Content = React.forwardRef<HTMLDivElement, ContentProps>(
1081
1023
  isDragging,
1082
1024
  direction,
1083
1025
  snapPoints,
1026
+ setActiveSnapPoint,
1027
+ dismissible,
1084
1028
  container,
1085
1029
  handleOnly,
1086
1030
  shouldAnimate,
@@ -1092,20 +1036,17 @@ export const Content = React.forwardRef<HTMLDivElement, ContentProps>(
1092
1036
  detachedRadius,
1093
1037
  detachedWrapperStyle: detachedWrapperStyleProp,
1094
1038
  } = useDrawerContext();
1095
- const hasAutoSnapPoint = React.useMemo(
1096
- () => !!snapPoints?.some((p) => p === 'auto'),
1097
- [snapPoints]
1098
- );
1099
-
1100
- // When 'auto' is used as a snap point, we need the natural content height.
1101
- // The drawer itself may be styled to a fixed viewport height, so we measure
1102
- // an inner wrapper instead. Ref callback starts the observer as soon as the
1103
- // node mounts (Radix Presence defers the portal mount past useEffect).
1039
+ // Always measure the inner wrapper's natural height. The drawer itself may
1040
+ // be styled to a fixed viewport height, so we measure an inner wrapper
1041
+ // instead. Ref callback starts the observer as soon as the node mounts
1042
+ // (Radix Presence defers the portal mount past useEffect). The measurement
1043
+ // drives the 'auto' snap point and is also exposed via
1044
+ // `onContentHeightChange` for callers that size the surrounding card.
1104
1045
  const autoRoRef = React.useRef<ResizeObserver | null>(null);
1105
1046
  const setAutoSizeNode = React.useCallback(
1106
1047
  (node: HTMLDivElement | null) => {
1107
1048
  autoRoRef.current?.disconnect();
1108
- if (!node || !hasAutoSnapPoint) {
1049
+ if (!node) {
1109
1050
  autoRoRef.current = null;
1110
1051
  return;
1111
1052
  }
@@ -1115,7 +1056,7 @@ export const Content = React.forwardRef<HTMLDivElement, ContentProps>(
1115
1056
  ro.observe(node);
1116
1057
  autoRoRef.current = ro;
1117
1058
  },
1118
- [hasAutoSnapPoint, setContentHeight]
1059
+ [setContentHeight]
1119
1060
  );
1120
1061
 
1121
1062
  const isBelowFade =
@@ -1310,7 +1251,10 @@ export const Content = React.forwardRef<HTMLDivElement, ContentProps>(
1310
1251
  // Translate the wrapper off-screen on dismiss so the whole card slides out
1311
1252
  // as one. The reset-then-target pattern on open forces the browser to
1312
1253
  // record a starting value so the transition actually animates.
1313
- const wasOpenRef = React.useRef(isOpen);
1254
+ // Start at `false` so a mount with `isOpen=true` (autopresent via
1255
+ // `initialDetentIndex`) is detected as a false→true transition and runs
1256
+ // the slide-up animation instead of snapping straight to rest.
1257
+ const wasOpenRef = React.useRef(false);
1314
1258
  React.useEffect(() => {
1315
1259
  if (!drawerRef.current) {
1316
1260
  wasOpenRef.current = isOpen;
@@ -1330,10 +1274,6 @@ export const Content = React.forwardRef<HTMLDivElement, ContentProps>(
1330
1274
  void wrapper.offsetHeight;
1331
1275
  wrapper.style.transition = transition;
1332
1276
  wrapper.style.transform = 'translate3d(0, 0, 0)';
1333
- } else if (isOpen && !wrapper.style.transform) {
1334
- // Fresh mount with open=true — ensure the wrapper starts at rest.
1335
- wrapper.style.transition = transition;
1336
- wrapper.style.transform = 'translate3d(0, 0, 0)';
1337
1277
  }
1338
1278
  }
1339
1279
  wasOpenRef.current = isOpen;
@@ -1380,16 +1320,52 @@ export const Content = React.forwardRef<HTMLDivElement, ContentProps>(
1380
1320
  return;
1381
1321
  }
1382
1322
 
1323
+ // Non-dismissible + dimmed: collapse to the highest non-dimmed
1324
+ // snap point instead of silently swallowing the dismiss attempt.
1325
+ // Mirrors the native Android dim-tap behavior.
1326
+ if (
1327
+ !dismissible &&
1328
+ hasSnapPoints &&
1329
+ fadeFromIndex !== undefined &&
1330
+ fadeFromIndex > 0 &&
1331
+ typeof activeSnapPointIndex === 'number' &&
1332
+ activeSnapPointIndex >= fadeFromIndex
1333
+ ) {
1334
+ e.preventDefault();
1335
+ setActiveSnapPoint(snapPoints![fadeFromIndex - 1]!);
1336
+ return;
1337
+ }
1338
+
1383
1339
  if (keyboardIsOpen.current) {
1384
1340
  keyboardIsOpen.current = false;
1385
1341
  }
1386
1342
  }}
1387
- onFocusOutside={(e) => {
1388
- if (!modal || isBelowFade) {
1343
+ onEscapeKeyDown={(e) => {
1344
+ onEscapeKeyDown?.(e);
1345
+ if (e.defaultPrevented) return;
1346
+
1347
+ // Non-dismissible: collapse to the first snap point (if above it)
1348
+ // instead of silently swallowing the dismiss attempt. Mirrors the
1349
+ // native Android back-press behavior.
1350
+ if (!dismissible) {
1389
1351
  e.preventDefault();
1390
- return;
1352
+ if (
1353
+ hasSnapPoints &&
1354
+ typeof activeSnapPointIndex === 'number' &&
1355
+ activeSnapPointIndex > 0
1356
+ ) {
1357
+ setActiveSnapPoint(snapPoints![0]!);
1358
+ }
1391
1359
  }
1392
1360
  }}
1361
+ onFocusOutside={(e) => {
1362
+ // Never auto-dismiss the sheet on focus shifts. Without Radix's
1363
+ // FocusScope trap (we run modal={false}), this fires whenever
1364
+ // focus naturally leaves the drawer — e.g. React Navigation's
1365
+ // web driver hides the previous screen via display:none, which
1366
+ // moves focus off the trigger and dismisses the sheet.
1367
+ e.preventDefault();
1368
+ }}
1393
1369
  onPointerMove={(event) => {
1394
1370
  lastKnownPointerEventRef.current = event;
1395
1371
  if (handleOnly) return;
@@ -1427,13 +1403,9 @@ export const Content = React.forwardRef<HTMLDivElement, ContentProps>(
1427
1403
  }
1428
1404
  }}
1429
1405
  >
1430
- {hasAutoSnapPoint ? (
1431
- <div ref={setAutoSizeNode} data-vaul-auto-size-wrapper="" style={autoSizeWrapperStyle}>
1432
- {children}
1433
- </div>
1434
- ) : (
1435
- children
1436
- )}
1406
+ <div ref={setAutoSizeNode} data-vaul-auto-size-wrapper="" style={autoSizeWrapperStyle}>
1407
+ {children}
1408
+ </div>
1437
1409
  </DialogPrimitive.Content>
1438
1410
  );
1439
1411
 
@@ -182,7 +182,7 @@
182
182
  }
183
183
 
184
184
  @media (pointer: fine) {
185
- [data-vaul-handle-hitarea]: {
185
+ [data-vaul-handle-hitarea] {
186
186
  width: 100%;
187
187
  height: 100%;
188
188
  }