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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,28 +1,31 @@
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
+
5
7
  import { DrawerContext, useDrawerContext } from './context';
6
8
  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';
9
+
10
+ import { isIOS, isMobileFirefox } from './browser';
11
11
  import {
12
- TRANSITIONS,
13
- VELOCITY_THRESHOLD,
14
- CLOSE_THRESHOLD,
15
- SCROLL_LOCK_TIMEOUT,
16
12
  BORDER_RADIUS,
13
+ CLOSE_THRESHOLD,
14
+ DRAG_CLASS,
17
15
  NESTED_DISPLACEMENT,
16
+ SCROLL_LOCK_TIMEOUT,
17
+ TRANSITIONS,
18
+ VELOCITY_THRESHOLD,
18
19
  WINDOW_TOP_OFFSET,
19
- DRAG_CLASS,
20
20
  } from './constants';
21
+ import { dampenValue, getTranslate, isVertical, reset, set } from './helpers';
21
22
  import type { DrawerDirection } from './types';
23
+ import { useComposedRefs } from './use-composed-refs';
22
24
  import { useControllableState } from './use-controllable-state';
23
- import { useScaleBackground } from './use-scale-background';
24
25
  import { usePositionFixed } from './use-position-fixed';
25
- import { isIOS, isMobileFirefox } from './browser';
26
+ import { isInput, usePreventScroll } from './use-prevent-scroll';
27
+ import { useScaleBackground } from './use-scale-background';
28
+ import { useSnapPoints } from './use-snap-points';
26
29
 
27
30
  export interface WithFadeFromProps {
28
31
  /**
@@ -620,6 +623,31 @@ export function Root({
620
623
  };
621
624
  }, [isOpen, modal, snapPoints, fadeFromIndex, activeSnapPointIndex]);
622
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
+
623
651
  React.useEffect(() => {
624
652
  function onVisualViewportChange() {
625
653
  if (!drawerRef.current || !repositionInputs) return;
@@ -669,7 +697,7 @@ export function Root({
669
697
  }
670
698
 
671
699
  if (snapPoints && snapPoints.length > 0 && !keyboardIsOpen.current) {
672
- drawerRef.current.style.bottom = `0px`;
700
+ drawerRef.current.style.bottom = '0px';
673
701
  } else {
674
702
  // Negative bottom value would never make sense
675
703
  drawerRef.current.style.bottom = `${Math.max(diffFromInitial, 0)}px`;
@@ -962,7 +990,7 @@ export function Root({
962
990
  export const Overlay = React.forwardRef<
963
991
  HTMLDivElement,
964
992
  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
965
- >(function ({ style, ...rest }, ref) {
993
+ >(({ style, ...rest }, ref) => {
966
994
  const {
967
995
  overlayRef,
968
996
  snapPoints,
@@ -1037,387 +1065,386 @@ export type ContentProps = React.ComponentPropsWithoutRef<typeof DialogPrimitive
1037
1065
  detachedSiblings?: React.ReactNode;
1038
1066
  };
1039
1067
 
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;
1091
- },
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
1114
- ) => {
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);
1068
+ export const Content = React.forwardRef<HTMLDivElement, ContentProps>(
1069
+ ({ onPointerDownOutside, style, onOpenAutoFocus, children, detachedSiblings, ...rest }, ref) => {
1070
+ const {
1071
+ drawerRef,
1072
+ onPress,
1073
+ onRelease,
1074
+ onDrag,
1075
+ keyboardIsOpen,
1076
+ snapPointsOffset,
1077
+ activeSnapPointIndex,
1078
+ fadeFromIndex,
1079
+ modal,
1080
+ isOpen,
1081
+ isDragging,
1082
+ direction,
1083
+ snapPoints,
1084
+ container,
1085
+ handleOnly,
1086
+ shouldAnimate,
1087
+ autoFocus,
1088
+ onPositionChangeRef,
1089
+ setContentHeight,
1090
+ detached,
1091
+ detachedOffset,
1092
+ detachedRadius,
1093
+ detachedWrapperStyle: detachedWrapperStyleProp,
1094
+ } = useDrawerContext();
1095
+ const hasAutoSnapPoint = React.useMemo(
1096
+ () => !!snapPoints?.some((p) => p === 'auto'),
1097
+ [snapPoints]
1098
+ );
1156
1099
 
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
- };
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).
1104
+ const autoRoRef = React.useRef<ResizeObserver | null>(null);
1105
+ const setAutoSizeNode = React.useCallback(
1106
+ (node: HTMLDivElement | null) => {
1107
+ autoRoRef.current?.disconnect();
1108
+ if (!node || !hasAutoSnapPoint) {
1109
+ autoRoRef.current = null;
1110
+ return;
1111
+ }
1112
+ const measure = () => setContentHeight(node.offsetHeight);
1113
+ measure();
1114
+ const ro = new ResizeObserver(measure);
1115
+ ro.observe(node);
1116
+ autoRoRef.current = ro;
1117
+ },
1118
+ [hasAutoSnapPoint, setContentHeight]
1119
+ );
1168
1120
 
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);
1121
+ const isBelowFade =
1122
+ snapPoints !== undefined &&
1123
+ snapPoints !== null &&
1124
+ fadeFromIndex !== undefined &&
1125
+ typeof activeSnapPointIndex === 'number' &&
1126
+ activeSnapPointIndex < fadeFromIndex;
1127
+ // Needed to use transition instead of animations
1128
+ const [delayedSnapPoints, setDelayedSnapPoints] = React.useState(false);
1129
+ const composedRef = useComposedRefs(ref, drawerRef);
1130
+ const pointerStartRef = React.useRef<{ x: number; y: number } | null>(null);
1131
+ const lastKnownPointerEventRef = React.useRef<React.PointerEvent<HTMLDivElement> | null>(null);
1132
+ const wasBeyondThePointRef = React.useRef(false);
1133
+ const hasSnapPoints = snapPoints && snapPoints.length > 0;
1134
+ useScaleBackground();
1135
+
1136
+ const isDeltaInDirection = (
1137
+ delta: { x: number; y: number },
1138
+ dir: DrawerDirection,
1139
+ threshold = 0
1140
+ ) => {
1141
+ if (wasBeyondThePointRef.current) return true;
1142
+
1143
+ const deltaY = Math.abs(delta.y);
1144
+ const deltaX = Math.abs(delta.x);
1145
+ const isDeltaX = deltaX > deltaY;
1146
+ const dFactor = ['bottom', 'right'].includes(dir) ? 1 : -1;
1147
+
1148
+ if (dir === 'left' || dir === 'right') {
1149
+ const isReverseDirection = delta.x * dFactor < 0;
1150
+ if (!isReverseDirection && deltaX >= 0 && deltaX <= threshold) {
1151
+ return isDeltaX;
1152
+ }
1153
+ } else {
1154
+ const isReverseDirection = delta.y * dFactor < 0;
1155
+ if (!isReverseDirection && deltaY >= 0 && deltaY <= threshold) {
1156
+ return !isDeltaX;
1157
+ }
1176
1158
  }
1177
- };
1178
1159
 
1179
- const tick = () => {
1180
- emit();
1181
- state.rafId = state.movingCount > 0 ? window.requestAnimationFrame(tick) : null;
1160
+ wasBeyondThePointRef.current = true;
1161
+ return true;
1182
1162
  };
1183
1163
 
1184
- state.start = () => {
1185
- state.movingCount += 1;
1186
- if (state.rafId === null) {
1187
- state.rafId = window.requestAnimationFrame(tick);
1164
+ React.useEffect(() => {
1165
+ if (hasSnapPoints) {
1166
+ window.requestAnimationFrame(() => {
1167
+ setDelayedSnapPoints(true);
1168
+ });
1188
1169
  }
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);
1170
+ }, []);
1171
+
1172
+ // Event-driven position tracking. We only tick RAF while the drawer is
1173
+ // actually moving (drag / CSS transition / CSS animation). When it's idle at
1174
+ // a snap, no frames run at all.
1175
+ const positionTrackingRef = React.useRef<{
1176
+ rafId: number | null;
1177
+ movingCount: number;
1178
+ lastPosition: number;
1179
+ start: () => void;
1180
+ stop: () => void;
1181
+ } | null>(null);
1182
+
1183
+ React.useEffect(() => {
1184
+ const drawer = drawerRef.current;
1185
+ if (!drawer) return;
1186
+
1187
+ const state = {
1188
+ rafId: null as number | null,
1189
+ movingCount: 0,
1190
+ lastPosition: Number.NaN,
1191
+ start: () => {},
1192
+ stop: () => {},
1193
+ };
1194
+
1195
+ const emit = () => {
1196
+ const cb = onPositionChangeRef.current;
1197
+ if (!cb) return;
1198
+ const position = drawer.getBoundingClientRect().top;
1199
+ if (position !== state.lastPosition) {
1200
+ state.lastPosition = position;
1201
+ cb(position);
1202
+ }
1203
+ };
1227
1204
 
1228
- positionTrackingRef.current = state;
1229
- emit();
1205
+ const tick = () => {
1206
+ emit();
1207
+ state.rafId = state.movingCount > 0 ? window.requestAnimationFrame(tick) : null;
1208
+ };
1230
1209
 
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
- }, []);
1210
+ state.start = () => {
1211
+ state.movingCount += 1;
1212
+ if (state.rafId === null) {
1213
+ state.rafId = window.requestAnimationFrame(tick);
1214
+ }
1215
+ };
1216
+
1217
+ state.stop = () => {
1218
+ state.movingCount = Math.max(0, state.movingCount - 1);
1219
+ // RAF loop will exit on its next tick; emit the settled position now.
1220
+ if (state.movingCount === 0) emit();
1221
+ };
1222
+
1223
+ const wrapper = drawer.closest<HTMLElement>('[data-vaul-detached-wrapper]');
1224
+
1225
+ // Listen on the wrapper too: drag-overshoot snap-back animates the wrapper
1226
+ // alone when the drawer's target is unchanged (e.g. snapping back to the
1227
+ // same detent). Without this, position goes stale mid-animation because
1228
+ // the drawer's `transitionrun` never fires.
1229
+ const onTransitionRun = (e: TransitionEvent) => {
1230
+ if ((e.target === drawer || e.target === wrapper) && e.propertyName === 'transform')
1231
+ state.start();
1232
+ };
1233
+ const onTransitionDone = (e: TransitionEvent) => {
1234
+ if ((e.target === drawer || e.target === wrapper) && e.propertyName === 'transform')
1235
+ state.stop();
1236
+ };
1237
+ const onAnimationStart = (e: AnimationEvent) => {
1238
+ if (e.target === drawer || e.target === wrapper) state.start();
1239
+ };
1240
+ const onAnimationDone = (e: AnimationEvent) => {
1241
+ if (e.target === drawer || e.target === wrapper) state.stop();
1242
+ };
1243
+
1244
+ drawer.addEventListener('transitionrun', onTransitionRun);
1245
+ drawer.addEventListener('transitionend', onTransitionDone);
1246
+ drawer.addEventListener('transitioncancel', onTransitionDone);
1247
+ drawer.addEventListener('animationstart', onAnimationStart);
1248
+ drawer.addEventListener('animationend', onAnimationDone);
1249
+ drawer.addEventListener('animationcancel', onAnimationDone);
1250
+ wrapper?.addEventListener('transitionrun', onTransitionRun);
1251
+ wrapper?.addEventListener('transitionend', onTransitionDone);
1252
+ wrapper?.addEventListener('transitioncancel', onTransitionDone);
1253
+
1254
+ positionTrackingRef.current = state;
1255
+ emit();
1245
1256
 
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
- }
1257
+ return () => {
1258
+ drawer.removeEventListener('transitionrun', onTransitionRun);
1259
+ drawer.removeEventListener('transitionend', onTransitionDone);
1260
+ drawer.removeEventListener('transitioncancel', onTransitionDone);
1261
+ drawer.removeEventListener('animationstart', onAnimationStart);
1262
+ drawer.removeEventListener('animationend', onAnimationDone);
1263
+ drawer.removeEventListener('animationcancel', onAnimationDone);
1264
+ wrapper?.removeEventListener('transitionrun', onTransitionRun);
1265
+ wrapper?.removeEventListener('transitionend', onTransitionDone);
1266
+ wrapper?.removeEventListener('transitioncancel', onTransitionDone);
1267
+ if (state.rafId !== null) window.cancelAnimationFrame(state.rafId);
1268
+ positionTrackingRef.current = null;
1269
+ };
1270
+ }, []);
1271
+
1272
+ React.useEffect(() => {
1273
+ const state = positionTrackingRef.current;
1274
+ if (!state) return;
1275
+ if (isDragging) state.start();
1276
+ else state.stop();
1277
+ }, [isDragging]);
1278
+
1279
+ function handleOnPointerUp(event: React.PointerEvent<HTMLDivElement> | null) {
1280
+ pointerStartRef.current = null;
1281
+ wasBeyondThePointRef.current = false;
1282
+ onRelease(event);
1283
+ }
1258
1284
 
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
- );
1285
+ // The drawer always sits inside a fixed clip wrapper. `contain: paint`
1286
+ // establishes the wrapper as the containing block so the drawer's own
1287
+ // `position: fixed` is constrained here, and `overflow: hidden` keeps any
1288
+ // overshoot out of the viewport. When `detached` the wrapper also floats
1289
+ // with a bottom gap and rounded bottom corners; otherwise it sits flush.
1290
+ // Transform/transition are managed imperatively (via drag overshoot and the
1291
+ // dismiss effect) so React doesn't skip DOM writes for values it thinks it
1292
+ // already owns.
1293
+ const wrapperStyle = React.useMemo<React.CSSProperties>(
1294
+ () => ({
1295
+ position: 'fixed',
1296
+ top: 0,
1297
+ left: 0,
1298
+ right: 0,
1299
+ bottom: detached ? detachedOffset : 0,
1300
+ overflow: 'hidden',
1301
+ contain: 'paint',
1302
+ pointerEvents: 'none',
1303
+ borderBottomLeftRadius: detached ? detachedRadius : 0,
1304
+ borderBottomRightRadius: detached ? detachedRadius : 0,
1305
+ ...detachedWrapperStyleProp,
1306
+ }),
1307
+ [detached, detachedOffset, detachedRadius, detachedWrapperStyleProp]
1308
+ );
1283
1309
 
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)';
1310
+ // Translate the wrapper off-screen on dismiss so the whole card slides out
1311
+ // as one. The reset-then-target pattern on open forces the browser to
1312
+ // record a starting value so the transition actually animates.
1313
+ const wasOpenRef = React.useRef(isOpen);
1314
+ React.useEffect(() => {
1315
+ if (!drawerRef.current) {
1316
+ wasOpenRef.current = isOpen;
1317
+ return;
1311
1318
  }
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)
1319
+ const wrapper = drawerRef.current.closest<HTMLElement>('[data-vaul-detached-wrapper]');
1320
+ if (wrapper) {
1321
+ const transition = `transform ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`;
1322
+ const viewportH = typeof window !== 'undefined' ? window.innerHeight : 0;
1323
+ if (!isOpen && wasOpenRef.current) {
1324
+ wrapper.style.transition = transition;
1325
+ wrapper.style.transform = `translate3d(0, ${viewportH}px, 0)`;
1326
+ } else if (isOpen && !wasOpenRef.current) {
1327
+ wrapper.style.transition = 'none';
1328
+ wrapper.style.transform = `translate3d(0, ${viewportH}px, 0)`;
1329
+ // eslint-disable-next-line no-void
1330
+ void wrapper.offsetHeight;
1331
+ wrapper.style.transition = transition;
1332
+ 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
+ }
1335
1338
  }
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();
1339
+ wasOpenRef.current = isOpen;
1340
+ }, [isOpen, detached, drawerRef]);
1341
+
1342
+ const contentNode = (
1343
+ <DialogPrimitive.Content
1344
+ data-vaul-drawer-direction={direction}
1345
+ data-vaul-drawer=""
1346
+ data-vaul-detached={detached ? 'true' : 'false'}
1347
+ data-vaul-delayed-snap-points={delayedSnapPoints ? 'true' : 'false'}
1348
+ data-vaul-snap-points={isOpen && hasSnapPoints ? 'true' : 'false'}
1349
+ data-vaul-custom-container={container ? 'true' : 'false'}
1350
+ data-vaul-animate={shouldAnimate?.current ? 'true' : 'false'}
1351
+ {...rest}
1352
+ ref={composedRef}
1353
+ style={
1354
+ snapPointsOffset && snapPointsOffset.length > 0
1355
+ ? ({
1356
+ '--snap-point-height': `${snapPointsOffset[activeSnapPointIndex ?? 0]!}px`,
1357
+ ...style,
1358
+ 'pointerEvents': 'auto',
1359
+ } as React.CSSProperties)
1360
+ : ({ ...style, pointerEvents: 'auto' } as React.CSSProperties)
1347
1361
  }
1348
- }}
1349
- onPointerDownOutside={(e) => {
1350
- onPointerDownOutside?.(e);
1362
+ onPointerDown={(event) => {
1363
+ if (handleOnly) return;
1364
+ rest.onPointerDown?.(event);
1365
+ pointerStartRef.current = { x: event.pageX, y: event.pageY };
1366
+ onPress(event);
1367
+ }}
1368
+ onOpenAutoFocus={(e) => {
1369
+ onOpenAutoFocus?.(e);
1351
1370
 
1352
- if (!modal || e.defaultPrevented || isBelowFade) {
1353
- e.preventDefault();
1354
- return;
1355
- }
1371
+ if (!autoFocus) {
1372
+ e.preventDefault();
1373
+ }
1374
+ }}
1375
+ onPointerDownOutside={(e) => {
1376
+ onPointerDownOutside?.(e);
1356
1377
 
1357
- if (keyboardIsOpen.current) {
1358
- keyboardIsOpen.current = false;
1359
- }
1360
- }}
1361
- onFocusOutside={(e) => {
1362
- if (!modal || isBelowFade) {
1363
- 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
- ) {
1378
+ if (!modal || e.defaultPrevented || isBelowFade) {
1379
+ e.preventDefault();
1380
+ return;
1381
+ }
1382
+
1383
+ if (keyboardIsOpen.current) {
1384
+ keyboardIsOpen.current = false;
1385
+ }
1386
+ }}
1387
+ onFocusOutside={(e) => {
1388
+ if (!modal || isBelowFade) {
1389
+ e.preventDefault();
1390
+ return;
1391
+ }
1392
+ }}
1393
+ onPointerMove={(event) => {
1394
+ lastKnownPointerEventRef.current = event;
1395
+ if (handleOnly) return;
1396
+ rest.onPointerMove?.(event);
1397
+ if (!pointerStartRef.current) return;
1398
+ const yPosition = event.pageY - pointerStartRef.current.y;
1399
+ const xPosition = event.pageX - pointerStartRef.current.x;
1400
+
1401
+ const swipeStartThreshold = event.pointerType === 'touch' ? 10 : 2;
1402
+ const delta = { x: xPosition, y: yPosition };
1403
+
1404
+ const isAllowedToSwipe = isDeltaInDirection(delta, direction, swipeStartThreshold);
1405
+ if (isAllowedToSwipe) onDrag(event);
1406
+ else if (
1407
+ Math.abs(xPosition) > swipeStartThreshold ||
1408
+ Math.abs(yPosition) > swipeStartThreshold
1409
+ ) {
1410
+ pointerStartRef.current = null;
1411
+ }
1412
+ }}
1413
+ onPointerUp={(event) => {
1414
+ rest.onPointerUp?.(event);
1384
1415
  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) {
1416
+ wasBeyondThePointRef.current = false;
1417
+ onRelease(event);
1418
+ }}
1419
+ onPointerOut={(event) => {
1420
+ rest.onPointerOut?.(event);
1400
1421
  handleOnPointerUp(lastKnownPointerEventRef.current);
1401
- }
1402
- }}
1403
- >
1404
- {hasAutoSnapPoint ? (
1405
- <div ref={setAutoSizeNode} data-vaul-auto-size-wrapper="" style={autoSizeWrapperStyle}>
1406
- {children}
1407
- </div>
1408
- ) : (
1409
- children
1410
- )}
1411
- </DialogPrimitive.Content>
1412
- );
1422
+ }}
1423
+ onContextMenu={(event) => {
1424
+ rest.onContextMenu?.(event);
1425
+ if (lastKnownPointerEventRef.current) {
1426
+ handleOnPointerUp(lastKnownPointerEventRef.current);
1427
+ }
1428
+ }}
1429
+ >
1430
+ {hasAutoSnapPoint ? (
1431
+ <div ref={setAutoSizeNode} data-vaul-auto-size-wrapper="" style={autoSizeWrapperStyle}>
1432
+ {children}
1433
+ </div>
1434
+ ) : (
1435
+ children
1436
+ )}
1437
+ </DialogPrimitive.Content>
1438
+ );
1413
1439
 
1414
- return (
1415
- <div data-vaul-detached-wrapper="" style={wrapperStyle}>
1416
- {contentNode}
1417
- {detachedSiblings}
1418
- </div>
1419
- );
1420
- });
1440
+ return (
1441
+ <div data-vaul-detached-wrapper="" style={wrapperStyle}>
1442
+ {contentNode}
1443
+ {detachedSiblings}
1444
+ </div>
1445
+ );
1446
+ }
1447
+ );
1421
1448
 
1422
1449
  Content.displayName = 'Drawer.Content';
1423
1450
 
@@ -1434,106 +1461,105 @@ export type HandleProps = React.ComponentPropsWithoutRef<'div'> & {
1434
1461
  const LONG_HANDLE_PRESS_TIMEOUT = 250;
1435
1462
  const DOUBLE_TAP_TIMEOUT = 120;
1436
1463
 
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;
1464
+ export const Handle = React.forwardRef<HTMLDivElement, HandleProps>(
1465
+ ({ preventCycle = false, children, ...rest }, ref) => {
1466
+ const {
1467
+ closeDrawer,
1468
+ isDragging,
1469
+ snapPoints,
1470
+ activeSnapPoint,
1471
+ setActiveSnapPoint,
1472
+ dismissible,
1473
+ handleOnly,
1474
+ isOpen,
1475
+ onPress,
1476
+ onDrag,
1477
+ } = useDrawerContext();
1478
+
1479
+ const closeTimeoutIdRef = React.useRef<number | null>(null);
1480
+ const shouldCancelInteractionRef = React.useRef(false);
1481
+
1482
+ function handleStartCycle() {
1483
+ // Stop if this is the second click of a double click
1484
+ if (shouldCancelInteractionRef.current) {
1485
+ handleCancelInteraction();
1486
+ return;
1487
+ }
1488
+ window.setTimeout(() => {
1489
+ handleCycleSnapPoints();
1490
+ }, DOUBLE_TAP_TIMEOUT);
1462
1491
  }
1463
- window.setTimeout(() => {
1464
- handleCycleSnapPoints();
1465
- }, DOUBLE_TAP_TIMEOUT);
1466
- }
1467
1492
 
1468
- function handleCycleSnapPoints() {
1469
- // Prevent accidental taps while resizing drawer
1470
- if (isDragging || preventCycle || shouldCancelInteractionRef.current) {
1493
+ function handleCycleSnapPoints() {
1494
+ // Prevent accidental taps while resizing drawer
1495
+ if (isDragging || preventCycle || shouldCancelInteractionRef.current) {
1496
+ handleCancelInteraction();
1497
+ return;
1498
+ }
1499
+ // Make sure to clear the timeout id if the user releases the handle before the cancel timeout
1471
1500
  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
1501
 
1477
- if (!snapPoints || snapPoints.length === 0) {
1478
- if (!dismissible) {
1479
- closeDrawer();
1502
+ if (!snapPoints || snapPoints.length === 0) {
1503
+ if (!dismissible) {
1504
+ closeDrawer();
1505
+ }
1506
+ return;
1480
1507
  }
1481
- return;
1482
- }
1483
1508
 
1484
- const isLastSnapPoint = activeSnapPoint === snapPoints[snapPoints.length - 1];
1509
+ const isLastSnapPoint = activeSnapPoint === snapPoints[snapPoints.length - 1];
1485
1510
 
1486
- if (isLastSnapPoint && dismissible) {
1487
- closeDrawer();
1488
- return;
1489
- }
1511
+ if (isLastSnapPoint && dismissible) {
1512
+ closeDrawer();
1513
+ return;
1514
+ }
1490
1515
 
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
- }
1516
+ const currentSnapIndex = snapPoints.findIndex((point) => point === activeSnapPoint);
1517
+ if (currentSnapIndex === -1) return; // activeSnapPoint not found in snapPoints
1518
+ const nextSnapPoint = snapPoints[currentSnapIndex + 1];
1519
+ if (nextSnapPoint === undefined) return;
1520
+ setActiveSnapPoint(nextSnapPoint);
1521
+ }
1497
1522
 
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
- }
1523
+ function handleStartInteraction() {
1524
+ closeTimeoutIdRef.current = window.setTimeout(() => {
1525
+ // Cancel click interaction on a long press
1526
+ shouldCancelInteractionRef.current = true;
1527
+ }, LONG_HANDLE_PRESS_TIMEOUT);
1528
+ }
1504
1529
 
1505
- function handleCancelInteraction() {
1506
- if (closeTimeoutIdRef.current) {
1507
- window.clearTimeout(closeTimeoutIdRef.current);
1530
+ function handleCancelInteraction() {
1531
+ if (closeTimeoutIdRef.current) {
1532
+ window.clearTimeout(closeTimeoutIdRef.current);
1533
+ }
1534
+ shouldCancelInteractionRef.current = false;
1508
1535
  }
1509
- shouldCancelInteractionRef.current = false;
1510
- }
1511
1536
 
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
- });
1537
+ return (
1538
+ <div
1539
+ onClick={handleStartCycle}
1540
+ onPointerCancel={handleCancelInteraction}
1541
+ onPointerDown={(e) => {
1542
+ if (handleOnly) onPress(e);
1543
+ handleStartInteraction();
1544
+ }}
1545
+ onPointerMove={(e) => {
1546
+ if (handleOnly) onDrag(e);
1547
+ }}
1548
+ // onPointerUp is already handled by the content component
1549
+ ref={ref}
1550
+ data-vaul-drawer-visible={isOpen ? 'true' : 'false'}
1551
+ data-vaul-handle=""
1552
+ aria-hidden="true"
1553
+ {...rest}
1554
+ >
1555
+ {/* Expand handle's hit area beyond what's visible to ensure a 44x44 tap target for touch devices */}
1556
+ <span data-vaul-handle-hitarea="" aria-hidden="true">
1557
+ {children}
1558
+ </span>
1559
+ </div>
1560
+ );
1561
+ }
1562
+ );
1537
1563
 
1538
1564
  Handle.displayName = 'Drawer.Handle';
1539
1565