@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
@@ -1,28 +1,32 @@
1
1
  'use client';
2
2
 
3
- import * as DialogPrimitive from '@radix-ui/react-dialog';
4
3
  import React from 'react';
4
+
5
+ import * as DialogPrimitive from '@radix-ui/react-dialog';
6
+ import { Presence } from '@radix-ui/react-presence';
7
+
5
8
  import { DrawerContext, useDrawerContext } from './context';
6
9
  import './style.css';
7
- import { usePreventScroll, isInput } from './use-prevent-scroll';
8
- import { useComposedRefs } from './use-composed-refs';
9
- import { useSnapPoints } from './use-snap-points';
10
- import { set, getTranslate, dampenValue, isVertical, reset } from './helpers';
10
+
11
+ import { isIOS, isMobileFirefox } from './browser';
11
12
  import {
12
- TRANSITIONS,
13
- VELOCITY_THRESHOLD,
14
- CLOSE_THRESHOLD,
15
- SCROLL_LOCK_TIMEOUT,
16
13
  BORDER_RADIUS,
14
+ CLOSE_THRESHOLD,
15
+ DRAG_CLASS,
17
16
  NESTED_DISPLACEMENT,
17
+ SCROLL_LOCK_TIMEOUT,
18
+ TRANSITIONS,
19
+ VELOCITY_THRESHOLD,
18
20
  WINDOW_TOP_OFFSET,
19
- DRAG_CLASS,
20
21
  } from './constants';
22
+ import { dampenValue, getTranslate, isVertical, reset, set } from './helpers';
21
23
  import type { DrawerDirection } from './types';
24
+ import { useComposedRefs } from './use-composed-refs';
22
25
  import { useControllableState } from './use-controllable-state';
23
- import { useScaleBackground } from './use-scale-background';
24
26
  import { usePositionFixed } from './use-position-fixed';
25
- import { isIOS, isMobileFirefox } from './browser';
27
+ import { isInput, usePreventScroll } from './use-prevent-scroll';
28
+ import { useScaleBackground } from './use-scale-background';
29
+ import { useSnapPoints } from './use-snap-points';
26
30
 
27
31
  export interface WithFadeFromProps {
28
32
  /**
@@ -182,6 +186,12 @@ export type DialogProps = {
182
186
  * the sheet appears at its target detent instantly. Defaults to `true`.
183
187
  */
184
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;
185
195
  } & (WithFadeFromProps | WithoutFadeFromProps);
186
196
 
187
197
  export function Root({
@@ -222,6 +232,7 @@ export function Root({
222
232
  detachedWrapperStyle,
223
233
  maxContentHeight,
224
234
  initialAnimated = true,
235
+ onContentHeightChange,
225
236
  }: DialogProps) {
226
237
  const [isOpen = false, setIsOpen] = useControllableState({
227
238
  defaultProp: defaultOpen,
@@ -236,19 +247,6 @@ export function Root({
236
247
  setTimeout(() => {
237
248
  onAnimationEnd?.(o);
238
249
  }, TRANSITIONS.DURATION * 1000);
239
-
240
- if (o && !modal) {
241
- if (typeof window !== 'undefined') {
242
- window.requestAnimationFrame(() => {
243
- document.body.style.pointerEvents = 'auto';
244
- });
245
- }
246
- }
247
-
248
- if (!o) {
249
- // This will be removed when the exit animation ends (`500ms`)
250
- document.body.style.pointerEvents = 'auto';
251
- }
252
250
  },
253
251
  });
254
252
  const [hasBeenOpened, setHasBeenOpened] = React.useState<boolean>(false);
@@ -271,6 +269,14 @@ export function Root({
271
269
  const initialDrawerHeight = React.useRef(0);
272
270
  const [contentHeight, setContentHeight] = React.useState(0);
273
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
+
274
280
  const onSnapPointChange = React.useCallback((activeSnapPointIndex: number) => {
275
281
  // Change openTime ref when we reach the last snap point to prevent dragging for 500ms incase it's scrollable.
276
282
  if (snapPoints && activeSnapPointIndex === snapPointsOffset.length - 1)
@@ -563,63 +569,6 @@ export function Root({
563
569
  onPositionChangeRef.current = onPositionChange;
564
570
  });
565
571
 
566
- // Radix's DismissableLayer (modal) sets body pointer-events to "none" so the
567
- // overlay traps clicks. When the active snap is below fadeFromIndex the
568
- // overlay is fully transparent, and we want clicks to reach the content
569
- // behind the drawer — so we restore body interactivity. DismissableLayer
570
- // captures its DOM node via a ref-callback `setNode`, so its body write
571
- // runs one render later than ours on initial open — re-assert on rAF so
572
- // the final write is ours.
573
- React.useEffect(() => {
574
- if (!modal || !snapPoints || fadeFromIndex === undefined) return;
575
- // On close, restore body interactivity. `useControllableState.onChange`
576
- // only fires for internal `setIsOpen` calls — when the parent flips the
577
- // `open` prop imperatively, the reset at line ~201 never runs.
578
- if (!isOpen) {
579
- document.body.style.pointerEvents = 'auto';
580
- return;
581
- }
582
- if (typeof activeSnapPointIndex !== 'number') return;
583
- const isBelowFade = activeSnapPointIndex < fadeFromIndex;
584
- const value = isBelowFade ? 'auto' : 'none';
585
- document.body.style.pointerEvents = value;
586
- const id = window.requestAnimationFrame(() => {
587
- document.body.style.pointerEvents = value;
588
- });
589
-
590
- // React Navigation keeps unfocused screens mounted with `display: none` on
591
- // web, so the drawer never unmounts and neither `isOpen` nor this effect's
592
- // deps change — a lingering `none` body style would then leak to whichever
593
- // screen becomes visible. Observe the drawer: when an ancestor hides it,
594
- // restore `auto`; when it returns, re-apply the desired value.
595
- let observer: IntersectionObserver | null = null;
596
- let rafId = 0;
597
- const startObserving = () => {
598
- const node = drawerRef.current;
599
- if (!node) {
600
- rafId = window.requestAnimationFrame(startObserving);
601
- return;
602
- }
603
- observer = new IntersectionObserver((entries) => {
604
- const entry = entries[entries.length - 1];
605
- if (!entry) return;
606
- document.body.style.pointerEvents = entry.isIntersecting ? value : 'auto';
607
- });
608
- observer.observe(node);
609
- };
610
- rafId = window.requestAnimationFrame(startObserving);
611
-
612
- // Always restore on cleanup so an unmount while the sheet is still open
613
- // doesn't leave the page uninteractive. If the effect re-runs for a
614
- // normal state change, it re-applies the correct value.
615
- return () => {
616
- window.cancelAnimationFrame(id);
617
- window.cancelAnimationFrame(rafId);
618
- observer?.disconnect();
619
- document.body.style.pointerEvents = 'auto';
620
- };
621
- }, [isOpen, modal, snapPoints, fadeFromIndex, activeSnapPointIndex]);
622
-
623
572
  React.useEffect(() => {
624
573
  function onVisualViewportChange() {
625
574
  if (!drawerRef.current || !repositionInputs) return;
@@ -669,7 +618,7 @@ export function Root({
669
618
  }
670
619
 
671
620
  if (snapPoints && snapPoints.length > 0 && !keyboardIsOpen.current) {
672
- drawerRef.current.style.bottom = `0px`;
621
+ drawerRef.current.style.bottom = '0px';
673
622
  } else {
674
623
  // Negative bottom value would never make sense
675
624
  drawerRef.current.style.bottom = `${Math.max(diffFromInitial, 0)}px`;
@@ -888,15 +837,6 @@ export function Root({
888
837
  }
889
838
  }
890
839
 
891
- React.useEffect(() => {
892
- if (!modal) {
893
- // Need to do this manually unfortunately
894
- window.requestAnimationFrame(() => {
895
- document.body.style.pointerEvents = 'auto';
896
- });
897
- }
898
- }, [modal]);
899
-
900
840
  return (
901
841
  <DialogPrimitive.Root
902
842
  defaultOpen={defaultOpen}
@@ -911,7 +851,14 @@ export function Root({
911
851
  setIsOpen(open);
912
852
  }}
913
853
  open={isOpen}
914
- 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}
915
862
  >
916
863
  <DrawerContext.Provider
917
864
  value={{
@@ -962,7 +909,7 @@ export function Root({
962
909
  export const Overlay = React.forwardRef<
963
910
  HTMLDivElement,
964
911
  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
965
- >(function ({ style, ...rest }, ref) {
912
+ >(({ style, ...rest }, ref) => {
966
913
  const {
967
914
  overlayRef,
968
915
  snapPoints,
@@ -1007,26 +954,38 @@ export const Overlay = React.forwardRef<
1007
954
  typeof activeSnapPointIndex === 'number' &&
1008
955
  activeSnapPointIndex < fadeFromIndex;
1009
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.
1010
962
  return (
1011
- <DialogPrimitive.Overlay
1012
- onMouseUp={onMouseUp}
1013
- ref={composedRef}
1014
- data-vaul-overlay=""
1015
- data-vaul-snap-points={isOpen && hasSnapPoints ? 'true' : 'false'}
1016
- data-vaul-delayed-snap-points={delayedSnapPoints ? 'true' : 'false'}
1017
- data-vaul-snap-points-overlay={isOpen && shouldFade ? 'true' : 'false'}
1018
- data-vaul-animate={shouldAnimate?.current ? 'true' : 'false'}
1019
- {...rest}
1020
- style={isBelowFade ? overlayBelowFadeStyle(style) : style}
1021
- />
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>
1022
977
  );
1023
978
  });
1024
979
 
1025
980
  Overlay.displayName = 'Drawer.Overlay';
1026
981
 
1027
- const overlayPointerEventsNone: React.CSSProperties = { pointerEvents: 'none' };
1028
- const overlayBelowFadeStyle = (style: React.CSSProperties | undefined): React.CSSProperties =>
1029
- 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
+ });
1030
989
 
1031
990
  export type ContentProps = React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
1032
991
  /**
@@ -1037,387 +996,427 @@ export type ContentProps = React.ComponentPropsWithoutRef<typeof DialogPrimitive
1037
996
  detachedSiblings?: React.ReactNode;
1038
997
  };
1039
998
 
1040
- export const Content = React.forwardRef<HTMLDivElement, ContentProps>(function (
1041
- { onPointerDownOutside, style, onOpenAutoFocus, children, detachedSiblings, ...rest },
1042
- ref
1043
- ) {
1044
- const {
1045
- drawerRef,
1046
- onPress,
1047
- onRelease,
1048
- onDrag,
1049
- keyboardIsOpen,
1050
- snapPointsOffset,
1051
- activeSnapPointIndex,
1052
- fadeFromIndex,
1053
- modal,
1054
- isOpen,
1055
- isDragging,
1056
- direction,
1057
- snapPoints,
1058
- container,
1059
- handleOnly,
1060
- shouldAnimate,
1061
- autoFocus,
1062
- onPositionChangeRef,
1063
- setContentHeight,
1064
- detached,
1065
- detachedOffset,
1066
- detachedRadius,
1067
- detachedWrapperStyle: detachedWrapperStyleProp,
1068
- } = useDrawerContext();
1069
- const hasAutoSnapPoint = React.useMemo(
1070
- () => !!snapPoints?.some((p) => p === 'auto'),
1071
- [snapPoints]
1072
- );
1073
-
1074
- // When 'auto' is used as a snap point, we need the natural content height.
1075
- // The drawer itself may be styled to a fixed viewport height, so we measure
1076
- // an inner wrapper instead. Ref callback starts the observer as soon as the
1077
- // node mounts (Radix Presence defers the portal mount past useEffect).
1078
- const autoRoRef = React.useRef<ResizeObserver | null>(null);
1079
- const setAutoSizeNode = React.useCallback(
1080
- (node: HTMLDivElement | null) => {
1081
- autoRoRef.current?.disconnect();
1082
- if (!node || !hasAutoSnapPoint) {
1083
- autoRoRef.current = null;
1084
- return;
1085
- }
1086
- const measure = () => setContentHeight(node.offsetHeight);
1087
- measure();
1088
- const ro = new ResizeObserver(measure);
1089
- ro.observe(node);
1090
- autoRoRef.current = ro;
999
+ export const Content = React.forwardRef<HTMLDivElement, ContentProps>(
1000
+ (
1001
+ {
1002
+ onPointerDownOutside,
1003
+ onEscapeKeyDown,
1004
+ style,
1005
+ onOpenAutoFocus,
1006
+ children,
1007
+ detachedSiblings,
1008
+ ...rest
1091
1009
  },
1092
- [hasAutoSnapPoint, setContentHeight]
1093
- );
1094
-
1095
- const isBelowFade =
1096
- snapPoints !== undefined &&
1097
- snapPoints !== null &&
1098
- fadeFromIndex !== undefined &&
1099
- typeof activeSnapPointIndex === 'number' &&
1100
- activeSnapPointIndex < fadeFromIndex;
1101
- // Needed to use transition instead of animations
1102
- const [delayedSnapPoints, setDelayedSnapPoints] = React.useState(false);
1103
- const composedRef = useComposedRefs(ref, drawerRef);
1104
- const pointerStartRef = React.useRef<{ x: number; y: number } | null>(null);
1105
- const lastKnownPointerEventRef = React.useRef<React.PointerEvent<HTMLDivElement> | null>(null);
1106
- const wasBeyondThePointRef = React.useRef(false);
1107
- const hasSnapPoints = snapPoints && snapPoints.length > 0;
1108
- useScaleBackground();
1109
-
1110
- const isDeltaInDirection = (
1111
- delta: { x: number; y: number },
1112
- dir: DrawerDirection,
1113
- threshold = 0
1010
+ ref
1114
1011
  ) => {
1115
- if (wasBeyondThePointRef.current) return true;
1116
-
1117
- const deltaY = Math.abs(delta.y);
1118
- const deltaX = Math.abs(delta.x);
1119
- const isDeltaX = deltaX > deltaY;
1120
- const dFactor = ['bottom', 'right'].includes(dir) ? 1 : -1;
1121
-
1122
- if (dir === 'left' || dir === 'right') {
1123
- const isReverseDirection = delta.x * dFactor < 0;
1124
- if (!isReverseDirection && deltaX >= 0 && deltaX <= threshold) {
1125
- return isDeltaX;
1126
- }
1127
- } else {
1128
- const isReverseDirection = delta.y * dFactor < 0;
1129
- if (!isReverseDirection && deltaY >= 0 && deltaY <= threshold) {
1130
- return !isDeltaX;
1131
- }
1132
- }
1133
-
1134
- wasBeyondThePointRef.current = true;
1135
- return true;
1136
- };
1137
-
1138
- React.useEffect(() => {
1139
- if (hasSnapPoints) {
1140
- window.requestAnimationFrame(() => {
1141
- setDelayedSnapPoints(true);
1142
- });
1143
- }
1144
- }, []);
1145
-
1146
- // Event-driven position tracking. We only tick RAF while the drawer is
1147
- // actually moving (drag / CSS transition / CSS animation). When it's idle at
1148
- // a snap, no frames run at all.
1149
- const positionTrackingRef = React.useRef<{
1150
- rafId: number | null;
1151
- movingCount: number;
1152
- lastPosition: number;
1153
- start: () => void;
1154
- stop: () => void;
1155
- } | null>(null);
1156
-
1157
- React.useEffect(() => {
1158
- const drawer = drawerRef.current;
1159
- if (!drawer) return;
1160
-
1161
- const state = {
1162
- rafId: null as number | null,
1163
- movingCount: 0,
1164
- lastPosition: Number.NaN,
1165
- start: () => {},
1166
- stop: () => {},
1167
- };
1012
+ const {
1013
+ drawerRef,
1014
+ onPress,
1015
+ onRelease,
1016
+ onDrag,
1017
+ keyboardIsOpen,
1018
+ snapPointsOffset,
1019
+ activeSnapPointIndex,
1020
+ fadeFromIndex,
1021
+ modal,
1022
+ isOpen,
1023
+ isDragging,
1024
+ direction,
1025
+ snapPoints,
1026
+ setActiveSnapPoint,
1027
+ dismissible,
1028
+ container,
1029
+ handleOnly,
1030
+ shouldAnimate,
1031
+ autoFocus,
1032
+ onPositionChangeRef,
1033
+ setContentHeight,
1034
+ detached,
1035
+ detachedOffset,
1036
+ detachedRadius,
1037
+ detachedWrapperStyle: detachedWrapperStyleProp,
1038
+ } = useDrawerContext();
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.
1045
+ const autoRoRef = React.useRef<ResizeObserver | null>(null);
1046
+ const setAutoSizeNode = React.useCallback(
1047
+ (node: HTMLDivElement | null) => {
1048
+ autoRoRef.current?.disconnect();
1049
+ if (!node) {
1050
+ autoRoRef.current = null;
1051
+ return;
1052
+ }
1053
+ const measure = () => setContentHeight(node.offsetHeight);
1054
+ measure();
1055
+ const ro = new ResizeObserver(measure);
1056
+ ro.observe(node);
1057
+ autoRoRef.current = ro;
1058
+ },
1059
+ [setContentHeight]
1060
+ );
1168
1061
 
1169
- const emit = () => {
1170
- const cb = onPositionChangeRef.current;
1171
- if (!cb) return;
1172
- const position = drawer.getBoundingClientRect().top;
1173
- if (position !== state.lastPosition) {
1174
- state.lastPosition = position;
1175
- cb(position);
1062
+ const isBelowFade =
1063
+ snapPoints !== undefined &&
1064
+ snapPoints !== null &&
1065
+ fadeFromIndex !== undefined &&
1066
+ typeof activeSnapPointIndex === 'number' &&
1067
+ activeSnapPointIndex < fadeFromIndex;
1068
+ // Needed to use transition instead of animations
1069
+ const [delayedSnapPoints, setDelayedSnapPoints] = React.useState(false);
1070
+ const composedRef = useComposedRefs(ref, drawerRef);
1071
+ const pointerStartRef = React.useRef<{ x: number; y: number } | null>(null);
1072
+ const lastKnownPointerEventRef = React.useRef<React.PointerEvent<HTMLDivElement> | null>(null);
1073
+ const wasBeyondThePointRef = React.useRef(false);
1074
+ const hasSnapPoints = snapPoints && snapPoints.length > 0;
1075
+ useScaleBackground();
1076
+
1077
+ const isDeltaInDirection = (
1078
+ delta: { x: number; y: number },
1079
+ dir: DrawerDirection,
1080
+ threshold = 0
1081
+ ) => {
1082
+ if (wasBeyondThePointRef.current) return true;
1083
+
1084
+ const deltaY = Math.abs(delta.y);
1085
+ const deltaX = Math.abs(delta.x);
1086
+ const isDeltaX = deltaX > deltaY;
1087
+ const dFactor = ['bottom', 'right'].includes(dir) ? 1 : -1;
1088
+
1089
+ if (dir === 'left' || dir === 'right') {
1090
+ const isReverseDirection = delta.x * dFactor < 0;
1091
+ if (!isReverseDirection && deltaX >= 0 && deltaX <= threshold) {
1092
+ return isDeltaX;
1093
+ }
1094
+ } else {
1095
+ const isReverseDirection = delta.y * dFactor < 0;
1096
+ if (!isReverseDirection && deltaY >= 0 && deltaY <= threshold) {
1097
+ return !isDeltaX;
1098
+ }
1176
1099
  }
1177
- };
1178
1100
 
1179
- const tick = () => {
1180
- emit();
1181
- state.rafId = state.movingCount > 0 ? window.requestAnimationFrame(tick) : null;
1101
+ wasBeyondThePointRef.current = true;
1102
+ return true;
1182
1103
  };
1183
1104
 
1184
- state.start = () => {
1185
- state.movingCount += 1;
1186
- if (state.rafId === null) {
1187
- state.rafId = window.requestAnimationFrame(tick);
1105
+ React.useEffect(() => {
1106
+ if (hasSnapPoints) {
1107
+ window.requestAnimationFrame(() => {
1108
+ setDelayedSnapPoints(true);
1109
+ });
1188
1110
  }
1189
- };
1190
-
1191
- state.stop = () => {
1192
- state.movingCount = Math.max(0, state.movingCount - 1);
1193
- // RAF loop will exit on its next tick; emit the settled position now.
1194
- if (state.movingCount === 0) emit();
1195
- };
1196
-
1197
- const wrapper = drawer.closest<HTMLElement>('[data-vaul-detached-wrapper]');
1198
-
1199
- // Listen on the wrapper too: drag-overshoot snap-back animates the wrapper
1200
- // alone when the drawer's target is unchanged (e.g. snapping back to the
1201
- // same detent). Without this, position goes stale mid-animation because
1202
- // the drawer's `transitionrun` never fires.
1203
- const onTransitionRun = (e: TransitionEvent) => {
1204
- if ((e.target === drawer || e.target === wrapper) && e.propertyName === 'transform')
1205
- state.start();
1206
- };
1207
- const onTransitionDone = (e: TransitionEvent) => {
1208
- if ((e.target === drawer || e.target === wrapper) && e.propertyName === 'transform')
1209
- state.stop();
1210
- };
1211
- const onAnimationStart = (e: AnimationEvent) => {
1212
- if (e.target === drawer || e.target === wrapper) state.start();
1213
- };
1214
- const onAnimationDone = (e: AnimationEvent) => {
1215
- if (e.target === drawer || e.target === wrapper) state.stop();
1216
- };
1217
-
1218
- drawer.addEventListener('transitionrun', onTransitionRun);
1219
- drawer.addEventListener('transitionend', onTransitionDone);
1220
- drawer.addEventListener('transitioncancel', onTransitionDone);
1221
- drawer.addEventListener('animationstart', onAnimationStart);
1222
- drawer.addEventListener('animationend', onAnimationDone);
1223
- drawer.addEventListener('animationcancel', onAnimationDone);
1224
- wrapper?.addEventListener('transitionrun', onTransitionRun);
1225
- wrapper?.addEventListener('transitionend', onTransitionDone);
1226
- wrapper?.addEventListener('transitioncancel', onTransitionDone);
1111
+ }, []);
1112
+
1113
+ // Event-driven position tracking. We only tick RAF while the drawer is
1114
+ // actually moving (drag / CSS transition / CSS animation). When it's idle at
1115
+ // a snap, no frames run at all.
1116
+ const positionTrackingRef = React.useRef<{
1117
+ rafId: number | null;
1118
+ movingCount: number;
1119
+ lastPosition: number;
1120
+ start: () => void;
1121
+ stop: () => void;
1122
+ } | null>(null);
1123
+
1124
+ React.useEffect(() => {
1125
+ const drawer = drawerRef.current;
1126
+ if (!drawer) return;
1127
+
1128
+ const state = {
1129
+ rafId: null as number | null,
1130
+ movingCount: 0,
1131
+ lastPosition: Number.NaN,
1132
+ start: () => {},
1133
+ stop: () => {},
1134
+ };
1135
+
1136
+ const emit = () => {
1137
+ const cb = onPositionChangeRef.current;
1138
+ if (!cb) return;
1139
+ const position = drawer.getBoundingClientRect().top;
1140
+ if (position !== state.lastPosition) {
1141
+ state.lastPosition = position;
1142
+ cb(position);
1143
+ }
1144
+ };
1227
1145
 
1228
- positionTrackingRef.current = state;
1229
- emit();
1146
+ const tick = () => {
1147
+ emit();
1148
+ state.rafId = state.movingCount > 0 ? window.requestAnimationFrame(tick) : null;
1149
+ };
1230
1150
 
1231
- return () => {
1232
- drawer.removeEventListener('transitionrun', onTransitionRun);
1233
- drawer.removeEventListener('transitionend', onTransitionDone);
1234
- drawer.removeEventListener('transitioncancel', onTransitionDone);
1235
- drawer.removeEventListener('animationstart', onAnimationStart);
1236
- drawer.removeEventListener('animationend', onAnimationDone);
1237
- drawer.removeEventListener('animationcancel', onAnimationDone);
1238
- wrapper?.removeEventListener('transitionrun', onTransitionRun);
1239
- wrapper?.removeEventListener('transitionend', onTransitionDone);
1240
- wrapper?.removeEventListener('transitioncancel', onTransitionDone);
1241
- if (state.rafId !== null) window.cancelAnimationFrame(state.rafId);
1242
- positionTrackingRef.current = null;
1243
- };
1244
- }, []);
1151
+ state.start = () => {
1152
+ state.movingCount += 1;
1153
+ if (state.rafId === null) {
1154
+ state.rafId = window.requestAnimationFrame(tick);
1155
+ }
1156
+ };
1157
+
1158
+ state.stop = () => {
1159
+ state.movingCount = Math.max(0, state.movingCount - 1);
1160
+ // RAF loop will exit on its next tick; emit the settled position now.
1161
+ if (state.movingCount === 0) emit();
1162
+ };
1163
+
1164
+ const wrapper = drawer.closest<HTMLElement>('[data-vaul-detached-wrapper]');
1165
+
1166
+ // Listen on the wrapper too: drag-overshoot snap-back animates the wrapper
1167
+ // alone when the drawer's target is unchanged (e.g. snapping back to the
1168
+ // same detent). Without this, position goes stale mid-animation because
1169
+ // the drawer's `transitionrun` never fires.
1170
+ const onTransitionRun = (e: TransitionEvent) => {
1171
+ if ((e.target === drawer || e.target === wrapper) && e.propertyName === 'transform')
1172
+ state.start();
1173
+ };
1174
+ const onTransitionDone = (e: TransitionEvent) => {
1175
+ if ((e.target === drawer || e.target === wrapper) && e.propertyName === 'transform')
1176
+ state.stop();
1177
+ };
1178
+ const onAnimationStart = (e: AnimationEvent) => {
1179
+ if (e.target === drawer || e.target === wrapper) state.start();
1180
+ };
1181
+ const onAnimationDone = (e: AnimationEvent) => {
1182
+ if (e.target === drawer || e.target === wrapper) state.stop();
1183
+ };
1184
+
1185
+ drawer.addEventListener('transitionrun', onTransitionRun);
1186
+ drawer.addEventListener('transitionend', onTransitionDone);
1187
+ drawer.addEventListener('transitioncancel', onTransitionDone);
1188
+ drawer.addEventListener('animationstart', onAnimationStart);
1189
+ drawer.addEventListener('animationend', onAnimationDone);
1190
+ drawer.addEventListener('animationcancel', onAnimationDone);
1191
+ wrapper?.addEventListener('transitionrun', onTransitionRun);
1192
+ wrapper?.addEventListener('transitionend', onTransitionDone);
1193
+ wrapper?.addEventListener('transitioncancel', onTransitionDone);
1194
+
1195
+ positionTrackingRef.current = state;
1196
+ emit();
1245
1197
 
1246
- React.useEffect(() => {
1247
- const state = positionTrackingRef.current;
1248
- if (!state) return;
1249
- if (isDragging) state.start();
1250
- else state.stop();
1251
- }, [isDragging]);
1252
-
1253
- function handleOnPointerUp(event: React.PointerEvent<HTMLDivElement> | null) {
1254
- pointerStartRef.current = null;
1255
- wasBeyondThePointRef.current = false;
1256
- onRelease(event);
1257
- }
1198
+ return () => {
1199
+ drawer.removeEventListener('transitionrun', onTransitionRun);
1200
+ drawer.removeEventListener('transitionend', onTransitionDone);
1201
+ drawer.removeEventListener('transitioncancel', onTransitionDone);
1202
+ drawer.removeEventListener('animationstart', onAnimationStart);
1203
+ drawer.removeEventListener('animationend', onAnimationDone);
1204
+ drawer.removeEventListener('animationcancel', onAnimationDone);
1205
+ wrapper?.removeEventListener('transitionrun', onTransitionRun);
1206
+ wrapper?.removeEventListener('transitionend', onTransitionDone);
1207
+ wrapper?.removeEventListener('transitioncancel', onTransitionDone);
1208
+ if (state.rafId !== null) window.cancelAnimationFrame(state.rafId);
1209
+ positionTrackingRef.current = null;
1210
+ };
1211
+ }, []);
1212
+
1213
+ React.useEffect(() => {
1214
+ const state = positionTrackingRef.current;
1215
+ if (!state) return;
1216
+ if (isDragging) state.start();
1217
+ else state.stop();
1218
+ }, [isDragging]);
1219
+
1220
+ function handleOnPointerUp(event: React.PointerEvent<HTMLDivElement> | null) {
1221
+ pointerStartRef.current = null;
1222
+ wasBeyondThePointRef.current = false;
1223
+ onRelease(event);
1224
+ }
1258
1225
 
1259
- // The drawer always sits inside a fixed clip wrapper. `contain: paint`
1260
- // establishes the wrapper as the containing block so the drawer's own
1261
- // `position: fixed` is constrained here, and `overflow: hidden` keeps any
1262
- // overshoot out of the viewport. When `detached` the wrapper also floats
1263
- // with a bottom gap and rounded bottom corners; otherwise it sits flush.
1264
- // Transform/transition are managed imperatively (via drag overshoot and the
1265
- // dismiss effect) so React doesn't skip DOM writes for values it thinks it
1266
- // already owns.
1267
- const wrapperStyle = React.useMemo<React.CSSProperties>(
1268
- () => ({
1269
- position: 'fixed',
1270
- top: 0,
1271
- left: 0,
1272
- right: 0,
1273
- bottom: detached ? detachedOffset : 0,
1274
- overflow: 'hidden',
1275
- contain: 'paint',
1276
- pointerEvents: 'none',
1277
- borderBottomLeftRadius: detached ? detachedRadius : 0,
1278
- borderBottomRightRadius: detached ? detachedRadius : 0,
1279
- ...detachedWrapperStyleProp,
1280
- }),
1281
- [detached, detachedOffset, detachedRadius, detachedWrapperStyleProp]
1282
- );
1226
+ // The drawer always sits inside a fixed clip wrapper. `contain: paint`
1227
+ // establishes the wrapper as the containing block so the drawer's own
1228
+ // `position: fixed` is constrained here, and `overflow: hidden` keeps any
1229
+ // overshoot out of the viewport. When `detached` the wrapper also floats
1230
+ // with a bottom gap and rounded bottom corners; otherwise it sits flush.
1231
+ // Transform/transition are managed imperatively (via drag overshoot and the
1232
+ // dismiss effect) so React doesn't skip DOM writes for values it thinks it
1233
+ // already owns.
1234
+ const wrapperStyle = React.useMemo<React.CSSProperties>(
1235
+ () => ({
1236
+ position: 'fixed',
1237
+ top: 0,
1238
+ left: 0,
1239
+ right: 0,
1240
+ bottom: detached ? detachedOffset : 0,
1241
+ overflow: 'hidden',
1242
+ contain: 'paint',
1243
+ pointerEvents: 'none',
1244
+ borderBottomLeftRadius: detached ? detachedRadius : 0,
1245
+ borderBottomRightRadius: detached ? detachedRadius : 0,
1246
+ ...detachedWrapperStyleProp,
1247
+ }),
1248
+ [detached, detachedOffset, detachedRadius, detachedWrapperStyleProp]
1249
+ );
1283
1250
 
1284
- // Translate the wrapper off-screen on dismiss so the whole card slides out
1285
- // as one. The reset-then-target pattern on open forces the browser to
1286
- // record a starting value so the transition actually animates.
1287
- const wasOpenRef = React.useRef(isOpen);
1288
- React.useEffect(() => {
1289
- if (!drawerRef.current) {
1290
- wasOpenRef.current = isOpen;
1291
- return;
1292
- }
1293
- const wrapper = drawerRef.current.closest<HTMLElement>('[data-vaul-detached-wrapper]');
1294
- if (wrapper) {
1295
- const transition = `transform ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`;
1296
- const viewportH = typeof window !== 'undefined' ? window.innerHeight : 0;
1297
- if (!isOpen && wasOpenRef.current) {
1298
- wrapper.style.transition = transition;
1299
- wrapper.style.transform = `translate3d(0, ${viewportH}px, 0)`;
1300
- } else if (isOpen && !wasOpenRef.current) {
1301
- wrapper.style.transition = 'none';
1302
- wrapper.style.transform = `translate3d(0, ${viewportH}px, 0)`;
1303
- // eslint-disable-next-line no-void
1304
- void wrapper.offsetHeight;
1305
- wrapper.style.transition = transition;
1306
- wrapper.style.transform = 'translate3d(0, 0, 0)';
1307
- } else if (isOpen && !wrapper.style.transform) {
1308
- // Fresh mount with open=true — ensure the wrapper starts at rest.
1309
- wrapper.style.transition = transition;
1310
- wrapper.style.transform = 'translate3d(0, 0, 0)';
1251
+ // Translate the wrapper off-screen on dismiss so the whole card slides out
1252
+ // as one. The reset-then-target pattern on open forces the browser to
1253
+ // record a starting value so the transition actually animates.
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);
1258
+ React.useEffect(() => {
1259
+ if (!drawerRef.current) {
1260
+ wasOpenRef.current = isOpen;
1261
+ return;
1311
1262
  }
1312
- }
1313
- wasOpenRef.current = isOpen;
1314
- }, [isOpen, detached, drawerRef]);
1315
-
1316
- const contentNode = (
1317
- <DialogPrimitive.Content
1318
- data-vaul-drawer-direction={direction}
1319
- data-vaul-drawer=""
1320
- data-vaul-detached={detached ? 'true' : 'false'}
1321
- data-vaul-delayed-snap-points={delayedSnapPoints ? 'true' : 'false'}
1322
- data-vaul-snap-points={isOpen && hasSnapPoints ? 'true' : 'false'}
1323
- data-vaul-custom-container={container ? 'true' : 'false'}
1324
- data-vaul-animate={shouldAnimate?.current ? 'true' : 'false'}
1325
- {...rest}
1326
- ref={composedRef}
1327
- style={
1328
- snapPointsOffset && snapPointsOffset.length > 0
1329
- ? ({
1330
- '--snap-point-height': `${snapPointsOffset[activeSnapPointIndex ?? 0]!}px`,
1331
- ...style,
1332
- 'pointerEvents': 'auto',
1333
- } as React.CSSProperties)
1334
- : ({ ...style, pointerEvents: 'auto' } as React.CSSProperties)
1263
+ const wrapper = drawerRef.current.closest<HTMLElement>('[data-vaul-detached-wrapper]');
1264
+ if (wrapper) {
1265
+ const transition = `transform ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`;
1266
+ const viewportH = typeof window !== 'undefined' ? window.innerHeight : 0;
1267
+ if (!isOpen && wasOpenRef.current) {
1268
+ wrapper.style.transition = transition;
1269
+ wrapper.style.transform = `translate3d(0, ${viewportH}px, 0)`;
1270
+ } else if (isOpen && !wasOpenRef.current) {
1271
+ wrapper.style.transition = 'none';
1272
+ wrapper.style.transform = `translate3d(0, ${viewportH}px, 0)`;
1273
+ // eslint-disable-next-line no-void
1274
+ void wrapper.offsetHeight;
1275
+ wrapper.style.transition = transition;
1276
+ wrapper.style.transform = 'translate3d(0, 0, 0)';
1277
+ }
1335
1278
  }
1336
- onPointerDown={(event) => {
1337
- if (handleOnly) return;
1338
- rest.onPointerDown?.(event);
1339
- pointerStartRef.current = { x: event.pageX, y: event.pageY };
1340
- onPress(event);
1341
- }}
1342
- onOpenAutoFocus={(e) => {
1343
- onOpenAutoFocus?.(e);
1344
-
1345
- if (!autoFocus) {
1346
- e.preventDefault();
1279
+ wasOpenRef.current = isOpen;
1280
+ }, [isOpen, detached, drawerRef]);
1281
+
1282
+ const contentNode = (
1283
+ <DialogPrimitive.Content
1284
+ data-vaul-drawer-direction={direction}
1285
+ data-vaul-drawer=""
1286
+ data-vaul-detached={detached ? 'true' : 'false'}
1287
+ data-vaul-delayed-snap-points={delayedSnapPoints ? 'true' : 'false'}
1288
+ data-vaul-snap-points={isOpen && hasSnapPoints ? 'true' : 'false'}
1289
+ data-vaul-custom-container={container ? 'true' : 'false'}
1290
+ data-vaul-animate={shouldAnimate?.current ? 'true' : 'false'}
1291
+ {...rest}
1292
+ ref={composedRef}
1293
+ style={
1294
+ snapPointsOffset && snapPointsOffset.length > 0
1295
+ ? ({
1296
+ '--snap-point-height': `${snapPointsOffset[activeSnapPointIndex ?? 0]!}px`,
1297
+ ...style,
1298
+ 'pointerEvents': 'auto',
1299
+ } as React.CSSProperties)
1300
+ : ({ ...style, pointerEvents: 'auto' } as React.CSSProperties)
1347
1301
  }
1348
- }}
1349
- onPointerDownOutside={(e) => {
1350
- onPointerDownOutside?.(e);
1302
+ onPointerDown={(event) => {
1303
+ if (handleOnly) return;
1304
+ rest.onPointerDown?.(event);
1305
+ pointerStartRef.current = { x: event.pageX, y: event.pageY };
1306
+ onPress(event);
1307
+ }}
1308
+ onOpenAutoFocus={(e) => {
1309
+ onOpenAutoFocus?.(e);
1351
1310
 
1352
- if (!modal || e.defaultPrevented || isBelowFade) {
1353
- e.preventDefault();
1354
- return;
1355
- }
1311
+ if (!autoFocus) {
1312
+ e.preventDefault();
1313
+ }
1314
+ }}
1315
+ onPointerDownOutside={(e) => {
1316
+ onPointerDownOutside?.(e);
1356
1317
 
1357
- if (keyboardIsOpen.current) {
1358
- keyboardIsOpen.current = false;
1359
- }
1360
- }}
1361
- onFocusOutside={(e) => {
1362
- if (!modal || isBelowFade) {
1318
+ if (!modal || e.defaultPrevented || isBelowFade) {
1319
+ e.preventDefault();
1320
+ return;
1321
+ }
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
+
1339
+ if (keyboardIsOpen.current) {
1340
+ keyboardIsOpen.current = false;
1341
+ }
1342
+ }}
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) {
1351
+ e.preventDefault();
1352
+ if (
1353
+ hasSnapPoints &&
1354
+ typeof activeSnapPointIndex === 'number' &&
1355
+ activeSnapPointIndex > 0
1356
+ ) {
1357
+ setActiveSnapPoint(snapPoints![0]!);
1358
+ }
1359
+ }
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.
1363
1367
  e.preventDefault();
1364
- return;
1365
- }
1366
- }}
1367
- onPointerMove={(event) => {
1368
- lastKnownPointerEventRef.current = event;
1369
- if (handleOnly) return;
1370
- rest.onPointerMove?.(event);
1371
- if (!pointerStartRef.current) return;
1372
- const yPosition = event.pageY - pointerStartRef.current.y;
1373
- const xPosition = event.pageX - pointerStartRef.current.x;
1374
-
1375
- const swipeStartThreshold = event.pointerType === 'touch' ? 10 : 2;
1376
- const delta = { x: xPosition, y: yPosition };
1377
-
1378
- const isAllowedToSwipe = isDeltaInDirection(delta, direction, swipeStartThreshold);
1379
- if (isAllowedToSwipe) onDrag(event);
1380
- else if (
1381
- Math.abs(xPosition) > swipeStartThreshold ||
1382
- Math.abs(yPosition) > swipeStartThreshold
1383
- ) {
1368
+ }}
1369
+ onPointerMove={(event) => {
1370
+ lastKnownPointerEventRef.current = event;
1371
+ if (handleOnly) return;
1372
+ rest.onPointerMove?.(event);
1373
+ if (!pointerStartRef.current) return;
1374
+ const yPosition = event.pageY - pointerStartRef.current.y;
1375
+ const xPosition = event.pageX - pointerStartRef.current.x;
1376
+
1377
+ const swipeStartThreshold = event.pointerType === 'touch' ? 10 : 2;
1378
+ const delta = { x: xPosition, y: yPosition };
1379
+
1380
+ const isAllowedToSwipe = isDeltaInDirection(delta, direction, swipeStartThreshold);
1381
+ if (isAllowedToSwipe) onDrag(event);
1382
+ else if (
1383
+ Math.abs(xPosition) > swipeStartThreshold ||
1384
+ Math.abs(yPosition) > swipeStartThreshold
1385
+ ) {
1386
+ pointerStartRef.current = null;
1387
+ }
1388
+ }}
1389
+ onPointerUp={(event) => {
1390
+ rest.onPointerUp?.(event);
1384
1391
  pointerStartRef.current = null;
1385
- }
1386
- }}
1387
- onPointerUp={(event) => {
1388
- rest.onPointerUp?.(event);
1389
- pointerStartRef.current = null;
1390
- wasBeyondThePointRef.current = false;
1391
- onRelease(event);
1392
- }}
1393
- onPointerOut={(event) => {
1394
- rest.onPointerOut?.(event);
1395
- handleOnPointerUp(lastKnownPointerEventRef.current);
1396
- }}
1397
- onContextMenu={(event) => {
1398
- rest.onContextMenu?.(event);
1399
- if (lastKnownPointerEventRef.current) {
1392
+ wasBeyondThePointRef.current = false;
1393
+ onRelease(event);
1394
+ }}
1395
+ onPointerOut={(event) => {
1396
+ rest.onPointerOut?.(event);
1400
1397
  handleOnPointerUp(lastKnownPointerEventRef.current);
1401
- }
1402
- }}
1403
- >
1404
- {hasAutoSnapPoint ? (
1398
+ }}
1399
+ onContextMenu={(event) => {
1400
+ rest.onContextMenu?.(event);
1401
+ if (lastKnownPointerEventRef.current) {
1402
+ handleOnPointerUp(lastKnownPointerEventRef.current);
1403
+ }
1404
+ }}
1405
+ >
1405
1406
  <div ref={setAutoSizeNode} data-vaul-auto-size-wrapper="" style={autoSizeWrapperStyle}>
1406
1407
  {children}
1407
1408
  </div>
1408
- ) : (
1409
- children
1410
- )}
1411
- </DialogPrimitive.Content>
1412
- );
1409
+ </DialogPrimitive.Content>
1410
+ );
1413
1411
 
1414
- return (
1415
- <div data-vaul-detached-wrapper="" style={wrapperStyle}>
1416
- {contentNode}
1417
- {detachedSiblings}
1418
- </div>
1419
- );
1420
- });
1412
+ return (
1413
+ <div data-vaul-detached-wrapper="" style={wrapperStyle}>
1414
+ {contentNode}
1415
+ {detachedSiblings}
1416
+ </div>
1417
+ );
1418
+ }
1419
+ );
1421
1420
 
1422
1421
  Content.displayName = 'Drawer.Content';
1423
1422
 
@@ -1434,106 +1433,105 @@ export type HandleProps = React.ComponentPropsWithoutRef<'div'> & {
1434
1433
  const LONG_HANDLE_PRESS_TIMEOUT = 250;
1435
1434
  const DOUBLE_TAP_TIMEOUT = 120;
1436
1435
 
1437
- export const Handle = React.forwardRef<HTMLDivElement, HandleProps>(function (
1438
- { preventCycle = false, children, ...rest },
1439
- ref
1440
- ) {
1441
- const {
1442
- closeDrawer,
1443
- isDragging,
1444
- snapPoints,
1445
- activeSnapPoint,
1446
- setActiveSnapPoint,
1447
- dismissible,
1448
- handleOnly,
1449
- isOpen,
1450
- onPress,
1451
- onDrag,
1452
- } = useDrawerContext();
1453
-
1454
- const closeTimeoutIdRef = React.useRef<number | null>(null);
1455
- const shouldCancelInteractionRef = React.useRef(false);
1456
-
1457
- function handleStartCycle() {
1458
- // Stop if this is the second click of a double click
1459
- if (shouldCancelInteractionRef.current) {
1460
- handleCancelInteraction();
1461
- return;
1436
+ export const Handle = React.forwardRef<HTMLDivElement, HandleProps>(
1437
+ ({ preventCycle = false, children, ...rest }, ref) => {
1438
+ const {
1439
+ closeDrawer,
1440
+ isDragging,
1441
+ snapPoints,
1442
+ activeSnapPoint,
1443
+ setActiveSnapPoint,
1444
+ dismissible,
1445
+ handleOnly,
1446
+ isOpen,
1447
+ onPress,
1448
+ onDrag,
1449
+ } = useDrawerContext();
1450
+
1451
+ const closeTimeoutIdRef = React.useRef<number | null>(null);
1452
+ const shouldCancelInteractionRef = React.useRef(false);
1453
+
1454
+ function handleStartCycle() {
1455
+ // Stop if this is the second click of a double click
1456
+ if (shouldCancelInteractionRef.current) {
1457
+ handleCancelInteraction();
1458
+ return;
1459
+ }
1460
+ window.setTimeout(() => {
1461
+ handleCycleSnapPoints();
1462
+ }, DOUBLE_TAP_TIMEOUT);
1462
1463
  }
1463
- window.setTimeout(() => {
1464
- handleCycleSnapPoints();
1465
- }, DOUBLE_TAP_TIMEOUT);
1466
- }
1467
1464
 
1468
- function handleCycleSnapPoints() {
1469
- // Prevent accidental taps while resizing drawer
1470
- if (isDragging || preventCycle || shouldCancelInteractionRef.current) {
1465
+ function handleCycleSnapPoints() {
1466
+ // Prevent accidental taps while resizing drawer
1467
+ if (isDragging || preventCycle || shouldCancelInteractionRef.current) {
1468
+ handleCancelInteraction();
1469
+ return;
1470
+ }
1471
+ // Make sure to clear the timeout id if the user releases the handle before the cancel timeout
1471
1472
  handleCancelInteraction();
1472
- return;
1473
- }
1474
- // Make sure to clear the timeout id if the user releases the handle before the cancel timeout
1475
- handleCancelInteraction();
1476
1473
 
1477
- if (!snapPoints || snapPoints.length === 0) {
1478
- if (!dismissible) {
1479
- closeDrawer();
1474
+ if (!snapPoints || snapPoints.length === 0) {
1475
+ if (!dismissible) {
1476
+ closeDrawer();
1477
+ }
1478
+ return;
1480
1479
  }
1481
- return;
1482
- }
1483
1480
 
1484
- const isLastSnapPoint = activeSnapPoint === snapPoints[snapPoints.length - 1];
1481
+ const isLastSnapPoint = activeSnapPoint === snapPoints[snapPoints.length - 1];
1485
1482
 
1486
- if (isLastSnapPoint && dismissible) {
1487
- closeDrawer();
1488
- return;
1489
- }
1483
+ if (isLastSnapPoint && dismissible) {
1484
+ closeDrawer();
1485
+ return;
1486
+ }
1490
1487
 
1491
- const currentSnapIndex = snapPoints.findIndex((point) => point === activeSnapPoint);
1492
- if (currentSnapIndex === -1) return; // activeSnapPoint not found in snapPoints
1493
- const nextSnapPoint = snapPoints[currentSnapIndex + 1];
1494
- if (nextSnapPoint === undefined) return;
1495
- setActiveSnapPoint(nextSnapPoint);
1496
- }
1488
+ const currentSnapIndex = snapPoints.findIndex((point) => point === activeSnapPoint);
1489
+ if (currentSnapIndex === -1) return; // activeSnapPoint not found in snapPoints
1490
+ const nextSnapPoint = snapPoints[currentSnapIndex + 1];
1491
+ if (nextSnapPoint === undefined) return;
1492
+ setActiveSnapPoint(nextSnapPoint);
1493
+ }
1497
1494
 
1498
- function handleStartInteraction() {
1499
- closeTimeoutIdRef.current = window.setTimeout(() => {
1500
- // Cancel click interaction on a long press
1501
- shouldCancelInteractionRef.current = true;
1502
- }, LONG_HANDLE_PRESS_TIMEOUT);
1503
- }
1495
+ function handleStartInteraction() {
1496
+ closeTimeoutIdRef.current = window.setTimeout(() => {
1497
+ // Cancel click interaction on a long press
1498
+ shouldCancelInteractionRef.current = true;
1499
+ }, LONG_HANDLE_PRESS_TIMEOUT);
1500
+ }
1504
1501
 
1505
- function handleCancelInteraction() {
1506
- if (closeTimeoutIdRef.current) {
1507
- window.clearTimeout(closeTimeoutIdRef.current);
1502
+ function handleCancelInteraction() {
1503
+ if (closeTimeoutIdRef.current) {
1504
+ window.clearTimeout(closeTimeoutIdRef.current);
1505
+ }
1506
+ shouldCancelInteractionRef.current = false;
1508
1507
  }
1509
- shouldCancelInteractionRef.current = false;
1510
- }
1511
1508
 
1512
- return (
1513
- <div
1514
- onClick={handleStartCycle}
1515
- onPointerCancel={handleCancelInteraction}
1516
- onPointerDown={(e) => {
1517
- if (handleOnly) onPress(e);
1518
- handleStartInteraction();
1519
- }}
1520
- onPointerMove={(e) => {
1521
- if (handleOnly) onDrag(e);
1522
- }}
1523
- // onPointerUp is already handled by the content component
1524
- ref={ref}
1525
- data-vaul-drawer-visible={isOpen ? 'true' : 'false'}
1526
- data-vaul-handle=""
1527
- aria-hidden="true"
1528
- {...rest}
1529
- >
1530
- {/* Expand handle's hit area beyond what's visible to ensure a 44x44 tap target for touch devices */}
1531
- <span data-vaul-handle-hitarea="" aria-hidden="true">
1532
- {children}
1533
- </span>
1534
- </div>
1535
- );
1536
- });
1509
+ return (
1510
+ <div
1511
+ onClick={handleStartCycle}
1512
+ onPointerCancel={handleCancelInteraction}
1513
+ onPointerDown={(e) => {
1514
+ if (handleOnly) onPress(e);
1515
+ handleStartInteraction();
1516
+ }}
1517
+ onPointerMove={(e) => {
1518
+ if (handleOnly) onDrag(e);
1519
+ }}
1520
+ // onPointerUp is already handled by the content component
1521
+ ref={ref}
1522
+ data-vaul-drawer-visible={isOpen ? 'true' : 'false'}
1523
+ data-vaul-handle=""
1524
+ aria-hidden="true"
1525
+ {...rest}
1526
+ >
1527
+ {/* Expand handle's hit area beyond what's visible to ensure a 44x44 tap target for touch devices */}
1528
+ <span data-vaul-handle-hitarea="" aria-hidden="true">
1529
+ {children}
1530
+ </span>
1531
+ </div>
1532
+ );
1533
+ }
1534
+ );
1537
1535
 
1538
1536
  Handle.displayName = 'Drawer.Handle';
1539
1537