@lodev09/react-native-true-sheet 3.11.6 → 3.11.8

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.
@@ -5,7 +5,7 @@ import React from 'react';
5
5
  import * as DialogPrimitive from '@radix-ui/react-dialog';
6
6
  import { Presence } from '@radix-ui/react-presence';
7
7
 
8
- import { DrawerContext, useDrawerContext } from './context';
8
+ import { DrawerContext, useDrawerContext, type DragEvent } from './context';
9
9
  import './style.css';
10
10
 
11
11
  import { isIOS, isMobileFirefox } from './browser';
@@ -24,7 +24,7 @@ import type { DrawerDirection } from './types';
24
24
  import { useComposedRefs } from './use-composed-refs';
25
25
  import { useControllableState } from './use-controllable-state';
26
26
  import { usePositionFixed } from './use-position-fixed';
27
- import { isInput, usePreventScroll } from './use-prevent-scroll';
27
+ import { isInput, isScrollable, usePreventScroll } from './use-prevent-scroll';
28
28
  import { useScaleBackground } from './use-scale-background';
29
29
  import { useSnapPoints } from './use-snap-points';
30
30
 
@@ -337,6 +337,44 @@ export function Root({
337
337
  noBodyStyles,
338
338
  });
339
339
 
340
+ // While the sheet itself is being dragged, scrollables in the touched chain
341
+ // are frozen (overflow: hidden). Touch browsers latch the scroll gesture at
342
+ // touchstart and ignore preventDefault once scrolling has started, so making
343
+ // the scroller non-scrollable mid-gesture is the only reliable way to keep
344
+ // the content from panning along with the sheet.
345
+ const frozenScrollablesRef = React.useRef<
346
+ { element: HTMLElement; overflowX: string; overflowY: string }[] | null
347
+ >(null);
348
+
349
+ function freezeScrollables(target: EventTarget) {
350
+ if (frozenScrollablesRef.current) return;
351
+ const frozen: { element: HTMLElement; overflowX: string; overflowY: string }[] = [];
352
+ let element = target instanceof HTMLElement ? target : null;
353
+ while (element && element !== drawerRef.current) {
354
+ if (isScrollable(element)) {
355
+ frozen.push({
356
+ element,
357
+ overflowX: element.style.overflowX,
358
+ overflowY: element.style.overflowY,
359
+ });
360
+ element.style.overflowX = 'hidden';
361
+ element.style.overflowY = 'hidden';
362
+ }
363
+ element = element.parentElement;
364
+ }
365
+ frozenScrollablesRef.current = frozen;
366
+ }
367
+
368
+ function unfreezeScrollables() {
369
+ const frozen = frozenScrollablesRef.current;
370
+ if (!frozen) return;
371
+ frozenScrollablesRef.current = null;
372
+ for (const { element, overflowX, overflowY } of frozen) {
373
+ element.style.overflowX = overflowX;
374
+ element.style.overflowY = overflowY;
375
+ }
376
+ }
377
+
340
378
  function getScale() {
341
379
  return (window.innerWidth - WINDOW_TOP_OFFSET) / window.innerWidth;
342
380
  }
@@ -386,7 +424,16 @@ export function Root({
386
424
  }
387
425
 
388
426
  if (swipeAmount !== null) {
389
- if (direction === 'bottom' ? swipeAmount > 0 : swipeAmount < 0) {
427
+ // Translated past the drag threshold keep dragging. Below the last
428
+ // snap point the threshold is 0, so the sheet always wins over content
429
+ // scrolling. At the last snap point the threshold is its resting
430
+ // translate — which can still be > 0 when the max detent < 1 — so an
431
+ // at-rest sheet falls through to the scroll checks and content
432
+ // scrolling wins, matching native. Mid-drag/mid-animation the sheet is
433
+ // displaced past rest and still wins.
434
+ const atLastSnapPoint = snapPoints && activeSnapPointIndex === snapPoints.length - 1;
435
+ const restOffset = atLastSnapPoint ? (snapPointsOffset?.[activeSnapPointIndex!] ?? 0) : 0;
436
+ if (direction === 'bottom' ? swipeAmount > restOffset + 1 : swipeAmount < restOffset - 1) {
390
437
  return true;
391
438
  }
392
439
  }
@@ -396,13 +443,16 @@ export function Root({
396
443
  return false;
397
444
  }
398
445
 
399
- // Disallow dragging if drawer was scrolled within `scrollLockTimeout`
446
+ // Disallow dragging if drawer was scrolled within `scrollLockTimeout`.
447
+ // Don't re-arm the timestamp here — a prevented drag attempt is not a
448
+ // scroll, and re-arming would keep the lock alive for as long as the
449
+ // finger moves, deadening the whole gesture instead of just the first
450
+ // `scrollLockTimeout` ms after the last real scroll.
400
451
  if (
401
452
  lastTimeDragPrevented.current &&
402
453
  date.getTime() - lastTimeDragPrevented.current.getTime() < scrollLockTimeout &&
403
454
  swipeAmount === 0
404
455
  ) {
405
- lastTimeDragPrevented.current = date;
406
456
  return false;
407
457
  }
408
458
 
@@ -417,7 +467,11 @@ export function Root({
417
467
  while (element) {
418
468
  // Check if the element is scrollable
419
469
  if (element.scrollHeight > element.clientHeight) {
420
- if (element.scrollTop !== 0) {
470
+ // `> 0`, not `!== 0`: Safari reports a negative scrollTop during the
471
+ // rubber-band bounce at the top — that's "at the top" for drag
472
+ // purposes, and treating it as scrolled would arm the scroll lock and
473
+ // delay the sheet drag until the bounce fully settles.
474
+ if (element.scrollTop > 0) {
421
475
  lastTimeDragPrevented.current = new Date();
422
476
 
423
477
  // The element is scrollable and not scrolled to the top, so don't drag
@@ -437,7 +491,7 @@ export function Root({
437
491
  return true;
438
492
  }
439
493
 
440
- function onDrag(event: React.PointerEvent<HTMLDivElement>) {
494
+ function drag(event: DragEvent, pointerEvent?: React.PointerEvent<HTMLDivElement>) {
441
495
  if (!drawerRef.current) {
442
496
  return;
443
497
  }
@@ -480,10 +534,21 @@ export function Root({
480
534
  return;
481
535
  }
482
536
 
483
- if (!isAllowedToDrag.current && !shouldDrag(event.target, isDraggingInDirection)) return;
484
- drawerRef.current.classList.add(DRAG_CLASS);
485
- // If shouldDrag gave true once after pressing down on the drawer, we set isAllowedToDrag to true and it will remain true until we let go, there's no reason to disable dragging mid way, ever, and that's the solution to it
486
- isAllowedToDrag.current = true;
537
+ if (!isAllowedToDrag.current) {
538
+ if (!shouldDrag(event.target, isDraggingInDirection)) return;
539
+ drawerRef.current.classList.add(DRAG_CLASS);
540
+ // If shouldDrag gave true once after pressing down on the drawer, we set isAllowedToDrag to true and it will remain true until we let go, there's no reason to disable dragging mid way, ever, and that's the solution to it
541
+ isAllowedToDrag.current = true;
542
+ // Touch pans latched onto a scroller would keep scrolling the content
543
+ // along with the sheet drag — freeze them for the drag's duration.
544
+ if (event.pointerType !== 'mouse') freezeScrollables(event.target);
545
+ // Drag can engage mid-gesture (content scrolled back to its top under
546
+ // the same finger, or the scroll lock expiring). Re-anchor and start
547
+ // moving on the next tick so the sheet tracks the finger from here
548
+ // instead of jumping by the distance the gesture already consumed.
549
+ pointerStart.current = isVertical(direction) ? event.pageY : event.pageX;
550
+ return;
551
+ }
487
552
  set(drawerRef.current, {
488
553
  transition: 'none',
489
554
  });
@@ -492,7 +557,7 @@ export function Root({
492
557
  transition: 'none',
493
558
  });
494
559
 
495
- onDragProp?.(event, percentageDragged);
560
+ if (pointerEvent) onDragProp?.(pointerEvent, percentageDragged);
496
561
 
497
562
  if (snapPoints) {
498
563
  onDragSnapPoints({ draggedDistance });
@@ -691,15 +756,17 @@ export function Root({
691
756
 
692
757
  drawerRef.current.classList.remove(DRAG_CLASS);
693
758
  isAllowedToDrag.current = false;
759
+ unfreezeScrollables();
694
760
  setIsDragging(false);
695
761
  dragEndTime.current = new Date();
696
762
  }
697
763
 
698
- function onRelease(event: React.PointerEvent<HTMLDivElement> | null) {
764
+ function release(event: DragEvent | null, pointerEvent?: React.PointerEvent<HTMLDivElement>) {
699
765
  if (!isDragging || !drawerRef.current) return;
700
766
 
701
767
  drawerRef.current.classList.remove(DRAG_CLASS);
702
768
  isAllowedToDrag.current = false;
769
+ unfreezeScrollables();
703
770
  setIsDragging(false);
704
771
  dragEndTime.current = new Date();
705
772
  const swipeAmount = getTranslate(drawerRef.current, direction);
@@ -730,20 +797,20 @@ export function Root({
730
797
  velocity,
731
798
  dismissible,
732
799
  });
733
- onReleaseProp?.(event, true);
800
+ if (pointerEvent) onReleaseProp?.(pointerEvent, true);
734
801
  return;
735
802
  }
736
803
 
737
804
  // Moved upwards, don't do anything
738
805
  if (direction === 'bottom' || direction === 'right' ? distMoved > 0 : distMoved < 0) {
739
806
  resetDrawer();
740
- onReleaseProp?.(event, true);
807
+ if (pointerEvent) onReleaseProp?.(pointerEvent, true);
741
808
  return;
742
809
  }
743
810
 
744
811
  if (velocity > VELOCITY_THRESHOLD) {
745
812
  closeDrawer();
746
- onReleaseProp?.(event, false);
813
+ if (pointerEvent) onReleaseProp?.(pointerEvent, false);
747
814
  return;
748
815
  }
749
816
 
@@ -762,11 +829,11 @@ export function Root({
762
829
  (isHorizontalSwipe ? visibleDrawerWidth : visibleDrawerHeight) * closeThreshold
763
830
  ) {
764
831
  closeDrawer();
765
- onReleaseProp?.(event, false);
832
+ if (pointerEvent) onReleaseProp?.(pointerEvent, false);
766
833
  return;
767
834
  }
768
835
 
769
- onReleaseProp?.(event, true);
836
+ if (pointerEvent) onReleaseProp?.(pointerEvent, true);
770
837
  resetDrawer();
771
838
  }
772
839
 
@@ -876,8 +943,10 @@ export function Root({
876
943
  overlayRef,
877
944
  onOpenChange,
878
945
  onPress,
879
- onRelease,
880
- onDrag,
946
+ onRelease: (event) => release(event, event ?? undefined),
947
+ onDrag: (event) => drag(event, event),
948
+ onTouchDrag: (event, pointerEvent) => drag(event, pointerEvent),
949
+ onTouchRelease: (event, pointerEvent) => release(event, pointerEvent),
881
950
  dismissible,
882
951
  shouldAnimate,
883
952
  handleOnly,
@@ -1021,6 +1090,8 @@ export const Content = React.forwardRef<HTMLDivElement, ContentProps>(
1021
1090
  onPress,
1022
1091
  onRelease,
1023
1092
  onDrag,
1093
+ onTouchDrag,
1094
+ onTouchRelease,
1024
1095
  keyboardIsOpen,
1025
1096
  snapPointsOffset,
1026
1097
  activeSnapPointIndex,
@@ -1077,6 +1148,7 @@ export const Content = React.forwardRef<HTMLDivElement, ContentProps>(
1077
1148
  const composedRef = useComposedRefs(ref, drawerRef);
1078
1149
  const pointerStartRef = React.useRef<{ x: number; y: number } | null>(null);
1079
1150
  const lastKnownPointerEventRef = React.useRef<React.PointerEvent<HTMLDivElement> | null>(null);
1151
+ const continuingCanceledTouchRef = React.useRef(false);
1080
1152
  const wasBeyondThePointRef = React.useRef(false);
1081
1153
  const hasSnapPoints = snapPoints && snapPoints.length > 0;
1082
1154
  useScaleBackground();
@@ -1309,6 +1381,7 @@ export const Content = React.forwardRef<HTMLDivElement, ContentProps>(
1309
1381
  onPointerDown={(event) => {
1310
1382
  if (handleOnly) return;
1311
1383
  rest.onPointerDown?.(event);
1384
+ continuingCanceledTouchRef.current = false;
1312
1385
  pointerStartRef.current = { x: event.pageX, y: event.pageY };
1313
1386
  onPress(event);
1314
1387
  }}
@@ -1395,14 +1468,68 @@ export const Content = React.forwardRef<HTMLDivElement, ContentProps>(
1395
1468
  }}
1396
1469
  onPointerUp={(event) => {
1397
1470
  rest.onPointerUp?.(event);
1471
+ continuingCanceledTouchRef.current = false;
1398
1472
  pointerStartRef.current = null;
1399
1473
  wasBeyondThePointRef.current = false;
1400
1474
  onRelease(event);
1401
1475
  }}
1476
+ onPointerCancel={(event) => {
1477
+ rest.onPointerCancel?.(event);
1478
+ if (
1479
+ event.pointerType === 'touch' &&
1480
+ snapPoints &&
1481
+ activeSnapPointIndex === snapPoints.length - 1
1482
+ ) {
1483
+ continuingCanceledTouchRef.current = true;
1484
+ return;
1485
+ }
1486
+ handleOnPointerUp(null);
1487
+ }}
1402
1488
  onPointerOut={(event) => {
1403
1489
  rest.onPointerOut?.(event);
1490
+ if (continuingCanceledTouchRef.current) return;
1404
1491
  handleOnPointerUp(lastKnownPointerEventRef.current);
1405
1492
  }}
1493
+ onTouchMove={(event) => {
1494
+ rest.onTouchMove?.(event);
1495
+ if (!continuingCanceledTouchRef.current) return;
1496
+ const touch = event.touches[0];
1497
+ if (!touch) return;
1498
+ onTouchDrag(
1499
+ {
1500
+ target: event.target,
1501
+ pageX: touch.pageX,
1502
+ pageY: touch.pageY,
1503
+ pointerType: 'touch',
1504
+ },
1505
+ lastKnownPointerEventRef.current ?? undefined
1506
+ );
1507
+ }}
1508
+ onTouchEnd={(event) => {
1509
+ rest.onTouchEnd?.(event);
1510
+ if (!continuingCanceledTouchRef.current) return;
1511
+ continuingCanceledTouchRef.current = false;
1512
+ pointerStartRef.current = null;
1513
+ wasBeyondThePointRef.current = false;
1514
+ const touch = event.changedTouches[0];
1515
+ onTouchRelease(
1516
+ touch
1517
+ ? {
1518
+ target: event.target,
1519
+ pageX: touch.pageX,
1520
+ pageY: touch.pageY,
1521
+ pointerType: 'touch',
1522
+ }
1523
+ : null,
1524
+ lastKnownPointerEventRef.current ?? undefined
1525
+ );
1526
+ }}
1527
+ onTouchCancel={(event) => {
1528
+ rest.onTouchCancel?.(event);
1529
+ if (!continuingCanceledTouchRef.current) return;
1530
+ continuingCanceledTouchRef.current = false;
1531
+ handleOnPointerUp(null);
1532
+ }}
1406
1533
  onContextMenu={(event) => {
1407
1534
  rest.onContextMenu?.(event);
1408
1535
  if (lastKnownPointerEventRef.current) {
@@ -1,7 +1,19 @@
1
+ /* Registered so it can transition in sync with `transform` during snaps.
2
+ Descendants derive their visible-height layout from it, so `inherits: true`
3
+ is required. Unsupported browsers ignore this and the value just jumps to
4
+ the snap target instead of animating. */
5
+ @property --snap-point-height {
6
+ syntax: '<length>';
7
+ inherits: true;
8
+ initial-value: 0px;
9
+ }
10
+
1
11
  [data-vaul-drawer] {
2
12
  touch-action: none;
3
13
  will-change: transform;
4
- transition: transform 0.5s cubic-bezier(0.32, 0.72, 0, 1);
14
+ transition:
15
+ transform 0.5s cubic-bezier(0.32, 0.72, 0, 1),
16
+ --snap-point-height 0.5s cubic-bezier(0.32, 0.72, 0, 1);
5
17
  animation-duration: 0.5s;
6
18
  animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1);
7
19
  }
@@ -84,6 +96,17 @@
84
96
  transform: translate3d(var(--snap-point-height, 0), 0, 0);
85
97
  }
86
98
 
99
+ /* Below the last snap point, vertical touch pans should move the sheet — not
100
+ scroll the content (matches native). touch-action is latched at gesture
101
+ start and ancestors above a scroller aren't consulted, so every descendant
102
+ (nested scrollers included) must opt out of vertical panning ahead of time.
103
+ pan-x keeps horizontal carousels inside the content working. Wheel/trackpad
104
+ scrolling is unaffected. */
105
+ [data-vaul-scroll-locked],
106
+ [data-vaul-scroll-locked] * {
107
+ touch-action: pan-x !important;
108
+ }
109
+
87
110
  [data-vaul-overlay][data-vaul-snap-points='false'] {
88
111
  animation-duration: 0.5s;
89
112
  animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1);
@@ -198,13 +198,17 @@ export function useSnapPoints({
198
198
 
199
199
  const animateThisSnap = hasSnappedRef.current || initialAnimated;
200
200
  hasSnappedRef.current = true;
201
+ // `--snap-point-height` transitions alongside `transform` (registered via
202
+ // @property) so layouts derived from it (e.g. the scrollable fill) resize
203
+ // in sync with the drawer's slide instead of jumping to the target.
201
204
  set(drawerRef.current, {
202
- transition: animateThisSnap
203
- ? `transform ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`
205
+ 'transition': animateThisSnap
206
+ ? `transform ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')}), --snap-point-height ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`
204
207
  : 'none',
205
- transform: isVertical(direction)
208
+ 'transform': isVertical(direction)
206
209
  ? `translate3d(0, ${dimension}px, 0)`
207
210
  : `translate3d(${dimension}px, 0, 0)`,
211
+ '--snap-point-height': `${dimension}px`,
208
212
  });
209
213
 
210
214
  // Snapping implies drag overshoot (if any) should be undone.
@@ -360,19 +364,24 @@ export function useSnapPoints({
360
364
  if ((direction === 'bottom' || direction === 'right') && newValue > snapPointsOffset[0]) {
361
365
  const excess = newValue - snapPointsOffset[0];
362
366
  set(drawerRef.current, {
363
- transform: isVertical(direction)
367
+ 'transform': isVertical(direction)
364
368
  ? `translate3d(0, ${snapPointsOffset[0]}px, 0)`
365
369
  : `translate3d(${snapPointsOffset[0]}px, 0, 0)`,
370
+ '--snap-point-height': `${snapPointsOffset[0]}px`,
366
371
  });
367
372
  setDetachedWrapperTransform(excess, false);
368
373
  return;
369
374
  }
370
375
 
371
376
  setDetachedWrapperTransform(0, false);
377
+ // Keep `--snap-point-height` tracking the live drag position so layouts
378
+ // derived from it (e.g. the scrollable fill) resize with the drawer
379
+ // instead of staying cut off at the last detent's visible height.
372
380
  set(drawerRef.current, {
373
- transform: isVertical(direction)
381
+ 'transform': isVertical(direction)
374
382
  ? `translate3d(0, ${newValue}px, 0)`
375
383
  : `translate3d(${newValue}px, 0, 0)`,
384
+ '--snap-point-height': `${newValue}px`,
376
385
  });
377
386
  }
378
387