react-modal-sheet 5.4.1 → 5.6.0

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.
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "5.4.1",
2
+ "version": "5.6.0",
3
3
  "license": "MIT",
4
4
  "name": "react-modal-sheet",
5
5
  "description": "Flexible bottom sheet component for your React apps",
package/src/constants.ts CHANGED
@@ -16,4 +16,4 @@ export const REDUCED_MOTION_TWEEN_CONFIG: SheetTweenConfig = {
16
16
 
17
17
  export const DEFAULT_DRAG_CLOSE_THRESHOLD = 0.6;
18
18
 
19
- export const DEFAULT_DRAG_VELOCITY_THRESHOLD = 500;
19
+ export const DEFAULT_DRAG_VELOCITY_THRESHOLD = 1200;
package/src/context.tsx CHANGED
@@ -1,12 +1,22 @@
1
1
  import { createContext, useContext } from 'react';
2
- import { type SheetContextType } from './types';
2
+ import type { ExposedContextType, InternalContextType } from './types';
3
3
 
4
- export const SheetContext = createContext<SheetContextType | undefined>(
5
- undefined
6
- );
4
+ export const InternalSheetContext = createContext<
5
+ InternalContextType | undefined
6
+ >(undefined);
7
7
 
8
- export function useSheetContext() {
9
- const context = useContext(SheetContext);
8
+ export const ExposedSheetContext = createContext<
9
+ ExposedContextType | undefined
10
+ >(undefined);
11
+
12
+ export function useInternalContext() {
13
+ const context = useContext(InternalSheetContext);
14
+ if (!context) throw new Error('Sheet context error');
15
+ return context;
16
+ }
17
+
18
+ export function useExposedContext() {
19
+ const context = useContext(ExposedSheetContext);
10
20
  if (!context) throw new Error('Sheet context error');
11
21
  return context;
12
22
  }
@@ -1,9 +1,9 @@
1
1
  import { type MotionValue, transform } from 'motion';
2
- import { type RefObject } from 'react';
2
+ import { useEffect } from 'react';
3
3
 
4
4
  import type { SheetDetent, SheetSnapPoint } from '../types';
5
- import { useIsomorphicLayoutEffect } from './use-isomorphic-layout-effect';
6
5
  import { useSafeAreaInsets } from './use-safe-area-insets';
6
+ import { useStableCallback } from './use-stable-callback';
7
7
 
8
8
  export function useModalEffect({
9
9
  y,
@@ -29,114 +29,105 @@ export function useModalEffect({
29
29
  rootId = undefined;
30
30
  }
31
31
 
32
- // Cleanup on unmount
33
- useIsomorphicLayoutEffect(() => {
34
- return () => {
35
- if (rootId) cleanupModalEffect(rootId);
36
- };
37
- }, []);
38
-
39
- useIsomorphicLayoutEffect(() => {
32
+ const handleYChange = useStableCallback((yValue: number) => {
40
33
  if (!rootId) return;
41
34
 
42
- const root = document.querySelector(`#${rootId}`) as HTMLDivElement;
43
- if (!root) return;
35
+ let progress = Math.max(0, 1 - yValue / sheetHeight);
44
36
 
45
- const removeStartListener = y.on('animationStart', () => {
46
- // biome-ignore lint/style/noNonNullAssertion: root is always defined here
47
- setupModalEffect(rootId!);
48
- });
37
+ /**
38
+ * Start the effect only if we have dragged over the second snap point
39
+ * to make the effect more natural as the sheet will reach it's final
40
+ * position when the user drags it over the second snap point.
41
+ */
42
+ const snapThresholdPoint =
43
+ snapPoints.length > 1 ? snapPoints[snapPoints.length - 2] : undefined;
49
44
 
50
45
  /**
51
- * NOTE: The `y` value gets smaller when the sheet is opened and larger
52
- * when the sheet is being closed.
46
+ * If we have snap points, we need to calculate the progress percentage
47
+ * based on the snap point threshold. Note that the maximum value is also
48
+ * different in this case as the range between the start of the effect
49
+ * and its end is different.
53
50
  */
54
- const removeChangeListener = y.on('change', (yValue) => {
55
- if (!root) return;
56
-
57
- let progress = Math.max(0, 1 - yValue / sheetHeight);
58
-
59
- /**
60
- * Start the effect only if we have dragged over the second snap point
61
- * to make the effect more natural as the sheet will reach it's final
62
- * position when the user drags it over the second snap point.
63
- */
64
- const snapThresholdPoint =
65
- snapPoints.length > 1 ? snapPoints[snapPoints.length - 2] : undefined;
66
-
67
- /**
68
- * If we have snap points, we need to calculate the progress percentage
69
- * based on the snap point threshold. Note that the maximum value is also
70
- * different in this case as the range between the start of the effect
71
- * and its end is different.
72
- */
73
- if (snapThresholdPoint !== undefined) {
74
- const snapThresholdValue = snapThresholdPoint.snapValueY;
75
-
76
- if (yValue <= snapThresholdValue) {
77
- progress = (snapThresholdValue - yValue) / snapThresholdValue;
78
- } else {
79
- progress = 0;
80
- }
51
+ if (snapThresholdPoint !== undefined) {
52
+ const snapThresholdValue = snapThresholdPoint.snapValueY;
53
+
54
+ if (yValue <= snapThresholdValue) {
55
+ progress = (snapThresholdValue - yValue) / snapThresholdValue;
56
+ } else {
57
+ progress = 0;
81
58
  }
59
+ }
82
60
 
83
- /**
84
- * If we have a start threshold, we need to calculate the progress
85
- * percentage based on the start threshold (0 to 1). For example,
86
- * if the start threshold is 0.5, the progress will be 0 until the sheet
87
- * is dragged over 50% of the complete drag distance.
88
- */
89
- if (startThreshold !== undefined) {
90
- const startThresholdValue =
91
- sheetHeight -
92
- Math.min(Math.floor(startThreshold * sheetHeight), sheetHeight);
93
-
94
- if (yValue <= startThresholdValue) {
95
- progress = (startThresholdValue - yValue) / startThresholdValue;
96
- } else {
97
- progress = 0;
98
- }
61
+ /**
62
+ * If we have a start threshold, we need to calculate the progress
63
+ * percentage based on the start threshold (0 to 1). For example,
64
+ * if the start threshold is 0.5, the progress will be 0 until the sheet
65
+ * is dragged over 50% of the complete drag distance.
66
+ */
67
+ if (startThreshold !== undefined) {
68
+ const startThresholdValue =
69
+ sheetHeight -
70
+ Math.min(Math.floor(startThreshold * sheetHeight), sheetHeight);
71
+
72
+ if (yValue <= startThresholdValue) {
73
+ progress = (startThresholdValue - yValue) / startThresholdValue;
74
+ } else {
75
+ progress = 0;
99
76
  }
77
+ }
100
78
 
101
- // Make sure progress is between 0 and 1
102
- progress = Math.max(0, Math.min(1, progress));
79
+ // Make sure progress is between 0 and 1
80
+ progress = Math.max(0, Math.min(1, progress));
103
81
 
104
- const pageWidth = window.innerWidth;
105
- const ty = transform(progress, [0, 1], [0, 24 + insetTop]);
106
- const s = transform(progress, [0, 1], [1, (pageWidth - 16) / pageWidth]);
107
- const borderRadius = transform(progress, [0, 1], [0, 10]);
82
+ const pageWidth = window.innerWidth;
108
83
 
109
- root.style.transform = `scale(${s}) translate3d(0, ${ty}px, 0)`;
110
- root.style.borderTopRightRadius = `${borderRadius}px`;
111
- root.style.borderTopLeftRadius = `${borderRadius}px`;
84
+ updateModalEffect(rootId, {
85
+ scale: transform(progress, [0, 1], [1, (pageWidth - 16) / pageWidth]),
86
+ translateY: transform(progress, [0, 1], [0, 24 + insetTop]),
87
+ borderRadius: transform(progress, [0, 1], [0, 10]),
112
88
  });
89
+ });
113
90
 
114
- function onCompleted() {
115
- // -5 just to take into account some inprecision to ensure the cleanup is done
116
- if (y.get() - 5 >= sheetHeight) {
117
- // biome-ignore lint/style/noNonNullAssertion: root is always defined here
118
- cleanupModalEffect(rootId!);
119
- }
91
+ const handleYComplete = useStableCallback(() => {
92
+ if (!rootId) return;
93
+
94
+ // +5 just to take into account some inprecision to ensure the cleanup is done
95
+ if (y.get() + 5 >= sheetHeight) {
96
+ cleanupModalEffect(rootId);
120
97
  }
98
+ });
121
99
 
122
- const removeCompleteListener = y.on('animationComplete', onCompleted);
123
- const removeCancelListener = y.on('animationCancel', onCompleted);
100
+ useEffect(() => {
101
+ if (!rootId) return;
102
+
103
+ const removeStartListener = y.on('animationStart', () => {
104
+ if (rootId) setupModalEffect(rootId);
105
+ });
106
+
107
+ const removeChangeListener = y.on('change', (yValue) => {
108
+ handleYChange(yValue);
109
+ });
110
+
111
+ const removeCompleteListener = y.on('animationComplete', handleYComplete);
112
+ const removeCancelListener = y.on('animationCancel', handleYComplete);
124
113
 
125
114
  return () => {
115
+ if (rootId) cleanupModalEffect(rootId);
126
116
  removeStartListener();
127
117
  removeChangeListener();
128
118
  removeCompleteListener();
129
119
  removeCancelListener();
130
120
  };
131
- }, [y, rootId, insetTop, startThreshold, sheetHeight]);
121
+ }, [rootId]);
132
122
  }
133
123
 
134
124
  function setupModalEffect(rootId: string) {
135
125
  const root = document.querySelector(`#${rootId}`) as HTMLDivElement;
136
- const body = document.querySelector('body') as HTMLBodyElement;
137
126
  if (!root) return;
138
127
 
139
- body.style.backgroundColor = '#000';
128
+ const body = document.querySelector('body') as HTMLBodyElement;
129
+
130
+ body.style.backgroundColor = '#000000';
140
131
  root.style.overflow = 'hidden';
141
132
  root.style.transitionTimingFunction = 'cubic-bezier(0.32, 0.72, 0, 1)';
142
133
  root.style.transitionProperty = 'transform, border-radius';
@@ -144,11 +135,25 @@ function setupModalEffect(rootId: string) {
144
135
  root.style.transformOrigin = 'center top';
145
136
  }
146
137
 
138
+ function updateModalEffect(
139
+ rootId: string,
140
+ opts: { scale: number; translateY: number; borderRadius: number }
141
+ ) {
142
+ const root = document.querySelector(`#${rootId}`) as HTMLDivElement;
143
+ if (!root) return;
144
+
145
+ const { scale, translateY, borderRadius } = opts;
146
+ root.style.transform = `scale(${scale}) translate3d(0, ${translateY}px, 0)`;
147
+ root.style.borderTopRightRadius = `${borderRadius}px`;
148
+ root.style.borderTopLeftRadius = `${borderRadius}px`;
149
+ }
150
+
147
151
  function cleanupModalEffect(rootId: string) {
148
152
  const root = document.querySelector(`#${rootId}`) as HTMLDivElement;
149
- const body = document.querySelector('body') as HTMLBodyElement;
150
153
  if (!root) return;
151
154
 
155
+ const body = document.querySelector('body') as HTMLBodyElement;
156
+
152
157
  body.style.removeProperty('background-color');
153
158
  root.style.removeProperty('overflow');
154
159
  root.style.removeProperty('transition-timing-function');
package/src/index.tsx CHANGED
@@ -1,19 +1,11 @@
1
- import type { MotionValue } from 'motion/react';
2
-
3
- import { SheetBackdrop } from './SheetBackdrop';
4
- import { SheetContainer } from './SheetContainer';
5
- import { SheetContent } from './SheetContent';
6
- import { SheetDragIndicator } from './SheetDragIndicator';
7
- import { SheetHeader } from './SheetHeader';
1
+ import { useExposedContext } from './context';
8
2
  import { Sheet as SheetBase } from './sheet';
9
- import type { SheetCompound } from './types';
10
-
11
- export interface SheetRef {
12
- y: MotionValue<number>;
13
- yInverted: MotionValue<number>;
14
- height: number;
15
- snapTo: (index: number) => Promise<void>;
16
- }
3
+ import { SheetBackdrop } from './sheet-backdrop';
4
+ import { SheetContainer } from './sheet-container';
5
+ import { SheetContent } from './sheet-content';
6
+ import { SheetDragIndicator } from './sheet-drag-indicator';
7
+ import { SheetHeader } from './sheet-header';
8
+ import type { ExposedContextType, SheetCompound } from './types';
17
9
 
18
10
  export const Sheet: SheetCompound = Object.assign(SheetBase, {
19
11
  Container: SheetContainer,
@@ -21,12 +13,15 @@ export const Sheet: SheetCompound = Object.assign(SheetBase, {
21
13
  DragIndicator: SheetDragIndicator,
22
14
  Content: SheetContent,
23
15
  Backdrop: SheetBackdrop,
16
+ useContext: useExposedContext,
24
17
  });
25
18
 
26
19
  export { useScrollPosition } from './hooks/use-scroll-position';
27
20
  export { useVirtualKeyboard } from './hooks/use-virtual-keyboard';
28
21
 
29
- // Export types
22
+ export interface SheetContext extends ExposedContextType {}
23
+ export interface SheetRef extends ExposedContextType {}
24
+
30
25
  export type {
31
26
  SheetBackdropProps,
32
27
  SheetContainerProps,
@@ -1,7 +1,7 @@
1
1
  import { type MotionStyle, motion } from 'motion/react';
2
2
  import React, { forwardRef } from 'react';
3
3
 
4
- import { useSheetContext } from './context';
4
+ import { useInternalContext } from './context';
5
5
  import { styles } from './styles';
6
6
  import { type SheetBackdropProps } from './types';
7
7
  import { applyStyles } from './utils';
@@ -10,30 +10,21 @@ const isClickable = (props: any) => !!props.onClick || !!props.onTap;
10
10
 
11
11
  export const SheetBackdrop = forwardRef<any, SheetBackdropProps>(
12
12
  ({ style, className = '', unstyled, ...rest }, ref) => {
13
- const sheetContext = useSheetContext();
13
+ const sheetContext = useInternalContext();
14
14
  const clickable = isClickable(rest);
15
15
  const Comp = clickable ? motion.button : motion.div;
16
16
  const pointerEvents = clickable ? 'auto' : 'none';
17
17
  const isUnstyled = unstyled ?? sheetContext.unstyled;
18
18
 
19
19
  const backdropStyle: MotionStyle = {
20
+ opacity: sheetContext.yProgress,
20
21
  ...applyStyles(styles.backdrop, isUnstyled),
21
22
  ...style,
22
23
  pointerEvents,
23
24
  };
24
25
 
25
- const animationProps = sheetContext.prefersReducedMotion
26
- ? {}
27
- : {
28
- initial: { opacity: 0 },
29
- animate: { opacity: 1 },
30
- exit: { opacity: 0 },
31
- transition: { duration: 1 },
32
- };
33
-
34
26
  return (
35
27
  <Comp
36
- {...animationProps}
37
28
  {...(rest as any)}
38
29
  ref={ref}
39
30
  className={`react-modal-sheet-backdrop ${className}`}
@@ -2,14 +2,14 @@ import { type MotionStyle, motion } from 'motion/react';
2
2
  import React, { forwardRef } from 'react';
3
3
 
4
4
  import { DEFAULT_HEIGHT } from './constants';
5
- import { useSheetContext } from './context';
5
+ import { useInternalContext } from './context';
6
6
  import { styles } from './styles';
7
7
  import { type SheetContainerProps } from './types';
8
8
  import { applyStyles, mergeRefs } from './utils';
9
9
 
10
10
  export const SheetContainer = forwardRef<any, SheetContainerProps>(
11
11
  ({ children, style, className = '', unstyled, ...rest }, ref) => {
12
- const sheetContext = useSheetContext();
12
+ const sheetContext = useInternalContext();
13
13
 
14
14
  const isUnstyled = unstyled ?? sheetContext.unstyled;
15
15
 
@@ -1,7 +1,7 @@
1
1
  import { type MotionStyle, motion } from 'motion/react';
2
2
  import React, { forwardRef } from 'react';
3
3
 
4
- import { useSheetContext } from './context';
4
+ import { useInternalContext } from './context';
5
5
  import { useDragConstraints } from './hooks/use-drag-constraints';
6
6
  import { useScrollPosition } from './hooks/use-scroll-position';
7
7
  import { styles } from './styles';
@@ -24,7 +24,7 @@ export const SheetContent = forwardRef<any, SheetContentProps>(
24
24
  },
25
25
  ref
26
26
  ) => {
27
- const sheetContext = useSheetContext();
27
+ const sheetContext = useInternalContext();
28
28
  const dragConstraints = useDragConstraints();
29
29
 
30
30
  const scroll = useScrollPosition({
@@ -84,7 +84,13 @@ export const SheetContent = forwardRef<any, SheetContentProps>(
84
84
  }
85
85
 
86
86
  if (disableScroll) {
87
- scrollStyle.overflowY = 'hidden';
87
+ /**
88
+ * NOTE: use `clip` to avoid glitchy behavior where using `hidden`
89
+ * would actually allow scrolling but just not show the scroll content.
90
+ * Using `hidden` seems to work fine on iOS but on Android it causes
91
+ * the up drag gesture to cancel occasionally causing bad UX.
92
+ */
93
+ scrollStyle.overflowY = 'clip';
88
94
  }
89
95
 
90
96
  if (dragProps !== undefined) {
@@ -1,7 +1,7 @@
1
1
  import { motion, useTransform } from 'motion/react';
2
2
  import React from 'react';
3
3
 
4
- import { useSheetContext } from './context';
4
+ import { useInternalContext } from './context';
5
5
  import { styles } from './styles';
6
6
  import type { SheetDragIndicatorProps } from './types';
7
7
  import { applyStyles } from './utils';
@@ -12,7 +12,7 @@ export function SheetDragIndicator({
12
12
  unstyled,
13
13
  ...rest
14
14
  }: SheetDragIndicatorProps) {
15
- const sheetContext = useSheetContext();
15
+ const sheetContext = useInternalContext();
16
16
 
17
17
  const indicator1Transform = useTransform(
18
18
  sheetContext.indicatorRotation,
@@ -1,9 +1,9 @@
1
1
  import { motion } from 'motion/react';
2
2
  import React, { forwardRef } from 'react';
3
3
 
4
- import { useSheetContext } from './context';
4
+ import { useInternalContext } from './context';
5
5
  import { useDragConstraints } from './hooks/use-drag-constraints';
6
- import { SheetDragIndicator } from './SheetDragIndicator';
6
+ import { SheetDragIndicator } from './sheet-drag-indicator';
7
7
  import { styles } from './styles';
8
8
  import { type SheetHeaderProps } from './types';
9
9
  import { applyStyles, mergeRefs } from './utils';
@@ -13,7 +13,7 @@ export const SheetHeader = forwardRef<any, SheetHeaderProps>(
13
13
  { children, style, disableDrag, unstyled, className = '', ...rest },
14
14
  ref
15
15
  ) => {
16
- const sheetContext = useSheetContext();
16
+ const sheetContext = useInternalContext();
17
17
  const dragConstraints = useDragConstraints();
18
18
  const dragProps =
19
19
  disableDrag || sheetContext.disableDrag