react-modal-sheet 4.0.2 → 4.1.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.
@@ -0,0 +1,165 @@
1
+ import { transform, type MotionValue } from 'motion';
2
+ import { type RefObject } from 'react';
3
+
4
+ import { useIsomorphicLayoutEffect } from './use-isomorphic-layout-effect';
5
+ import { useSafeAreaInsets } from './use-safe-area-insets';
6
+ import { getSheetHeight, getSnapPoints } from '../utils';
7
+
8
+ export function useModalEffect({
9
+ y,
10
+ rootId,
11
+ sheetRef,
12
+ snapPointsProp,
13
+ startThreshold,
14
+ }: {
15
+ y: MotionValue<number>;
16
+ rootId?: string;
17
+ sheetRef: RefObject<HTMLDivElement | null>;
18
+ snapPointsProp?: number[];
19
+ startThreshold?: number;
20
+ }) {
21
+ const insetTop = useSafeAreaInsets().top;
22
+
23
+ // Cleanup on unmount
24
+ useIsomorphicLayoutEffect(() => {
25
+ return () => {
26
+ if (rootId) cleanupModalEffect(rootId);
27
+ };
28
+ }, []);
29
+
30
+ useIsomorphicLayoutEffect(() => {
31
+ if (!rootId) return;
32
+
33
+ const root = document.querySelector(`#${rootId}`) as HTMLDivElement;
34
+ if (!root) return;
35
+
36
+ let sheetHeight = 0;
37
+
38
+ const removeStartListener = y.on('animationStart', () => {
39
+ /**
40
+ * Only calculate the sheet height when the animation starts to avoid
41
+ * unnecessary layout calculations when y value changes.
42
+ */
43
+ sheetHeight = getSheetHeight(sheetRef);
44
+
45
+ // biome-ignore lint/style/noNonNullAssertion: root is always defined here
46
+ setupModalEffect(rootId!);
47
+ });
48
+
49
+ /**
50
+ * NOTE: The `y` value gets smaller when the sheet is opened and larger
51
+ * when the sheet is being closed.
52
+ */
53
+ const removeChangeListener = y.on('change', (yValue) => {
54
+ if (!root) return;
55
+
56
+ let progress = Math.max(0, 1 - yValue / sheetHeight);
57
+
58
+ const snapPoints = snapPointsProp
59
+ ? getSnapPoints({ snapPointsProp, sheetHeight })
60
+ : undefined;
61
+
62
+ /**
63
+ * Start the effect only if we have dragged over the second snap point
64
+ * to make the effect more natural as the sheet will reach it's final
65
+ * position when the user drags it over the second snap point.
66
+ */
67
+ const snapThresholdPoint =
68
+ snapPoints && snapPoints.length > 1 ? snapPoints[1] : undefined;
69
+
70
+ /**
71
+ * If we have snap points, we need to calculate the progress percentage
72
+ * based on the snap point threshold. Note that the maximum value is also
73
+ * different in this case as the range between the start of the effect
74
+ * and its end is different.
75
+ */
76
+ if (snapThresholdPoint !== undefined) {
77
+ const snapThresholdValue =
78
+ sheetHeight - Math.min(snapThresholdPoint, sheetHeight);
79
+
80
+ if (yValue <= snapThresholdValue) {
81
+ progress = (snapThresholdValue - yValue) / snapThresholdValue;
82
+ } else {
83
+ progress = 0;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * If we have a start threshold, we need to calculate the progress
89
+ * percentage based on the start threshold (0 to 1). For example,
90
+ * if the start threshold is 0.5, the progress will be 0 until the sheet
91
+ * is dragged over 50% of the complete drag distance.
92
+ */
93
+ if (startThreshold !== undefined) {
94
+ const startThresholdValue =
95
+ sheetHeight -
96
+ Math.min(Math.floor(startThreshold * sheetHeight), sheetHeight);
97
+
98
+ if (yValue <= startThresholdValue) {
99
+ progress = (startThresholdValue - yValue) / startThresholdValue;
100
+ } else {
101
+ progress = 0;
102
+ }
103
+ }
104
+
105
+ // Make sure progress is between 0 and 1
106
+ progress = Math.max(0, Math.min(1, progress));
107
+
108
+ const pageWidth = window.innerWidth;
109
+ const ty = transform(progress, [0, 1], [0, 24 + insetTop]);
110
+ const s = transform(progress, [0, 1], [1, (pageWidth - 16) / pageWidth]);
111
+ const borderRadius = transform(progress, [0, 1], [0, 10]);
112
+
113
+ root.style.transform = `scale(${s}) translate3d(0, ${ty}px, 0)`;
114
+ root.style.borderTopRightRadius = `${borderRadius}px`;
115
+ root.style.borderTopLeftRadius = `${borderRadius}px`;
116
+ });
117
+
118
+ function onCompleted() {
119
+ // -5 just to take into account some inprecision to ensure the cleanup is done
120
+ if (y.get() - 5 >= sheetHeight) {
121
+ // biome-ignore lint/style/noNonNullAssertion: root is always defined here
122
+ cleanupModalEffect(rootId!);
123
+ }
124
+ }
125
+
126
+ const removeCompleteListener = y.on('animationComplete', onCompleted);
127
+ const removeCancelListener = y.on('animationCancel', onCompleted);
128
+
129
+ return () => {
130
+ removeStartListener();
131
+ removeChangeListener();
132
+ removeCompleteListener();
133
+ removeCancelListener();
134
+ };
135
+ }, [y, rootId, insetTop, startThreshold, snapPointsProp]);
136
+ }
137
+
138
+ function setupModalEffect(rootId: string) {
139
+ const root = document.querySelector(`#${rootId}`) as HTMLDivElement;
140
+ const body = document.querySelector('body') as HTMLBodyElement;
141
+ if (!root) return;
142
+
143
+ body.style.backgroundColor = '#000';
144
+ root.style.overflow = 'hidden';
145
+ root.style.transitionTimingFunction = 'cubic-bezier(0.32, 0.72, 0, 1)';
146
+ root.style.transitionProperty = 'transform, border-radius';
147
+ root.style.transitionDuration = '0.5s';
148
+ root.style.transformOrigin = 'center top';
149
+ }
150
+
151
+ function cleanupModalEffect(rootId: string) {
152
+ const root = document.querySelector(`#${rootId}`) as HTMLDivElement;
153
+ const body = document.querySelector('body') as HTMLBodyElement;
154
+ if (!root) return;
155
+
156
+ body.style.removeProperty('background-color');
157
+ root.style.removeProperty('overflow');
158
+ root.style.removeProperty('transition-timing-function');
159
+ root.style.removeProperty('transition-property');
160
+ root.style.removeProperty('transition-duration');
161
+ root.style.removeProperty('transform-origin');
162
+ root.style.removeProperty('transform');
163
+ root.style.removeProperty('border-top-right-radius');
164
+ root.style.removeProperty('border-top-left-radius');
165
+ }
@@ -1,7 +1,7 @@
1
1
  // This code originates from https://github.com/adobe/react-spectrum/blob/main/packages/%40react-aria/overlays/src/usePreventScroll.ts
2
2
 
3
- import { useIsomorphicLayoutEffect } from './hooks';
4
- import { isIOS } from './utils';
3
+ import { useIsomorphicLayoutEffect } from './use-isomorphic-layout-effect';
4
+ import { isIOS } from '../utils';
5
5
 
6
6
  const KEYBOARD_BUFFER = 24;
7
7
 
@@ -0,0 +1,40 @@
1
+ import { useState } from 'react';
2
+
3
+ import { IS_SSR } from '../constants';
4
+
5
+ export function useSafeAreaInsets() {
6
+ const [insets] = useState(() => {
7
+ const fallback = { top: 0, left: 0, right: 0, bottom: 0 };
8
+
9
+ if (IS_SSR) return fallback;
10
+
11
+ const root = document.querySelector<HTMLElement>(':root');
12
+
13
+ if (!root) return fallback;
14
+
15
+ root.style.setProperty('--rms-sat', 'env(safe-area-inset-top)');
16
+ root.style.setProperty('--rms-sal', 'env(safe-area-inset-left)');
17
+ root.style.setProperty('--rms-sar', 'env(safe-area-inset-right)');
18
+ root.style.setProperty('--rms-sab', 'env(safe-area-inset-bottom)');
19
+
20
+ const computedStyle = getComputedStyle(root);
21
+ const sat = getComputedValue(computedStyle, '--rms-sat');
22
+ const sal = getComputedValue(computedStyle, '--rms-sal');
23
+ const sar = getComputedValue(computedStyle, '--rms-sar');
24
+ const sab = getComputedValue(computedStyle, '--rms-sab');
25
+
26
+ root.style.removeProperty('--rms-sat');
27
+ root.style.removeProperty('--rms-sal');
28
+ root.style.removeProperty('--rms-sar');
29
+ root.style.removeProperty('--rms-sab');
30
+
31
+ return { top: sat, left: sal, right: sar, bottom: sab };
32
+ });
33
+
34
+ return insets;
35
+ }
36
+
37
+ function getComputedValue(computed: CSSStyleDeclaration, property: string) {
38
+ const strValue = computed.getPropertyValue(property).replace('px', '').trim();
39
+ return parseInt(strValue, 10) || 0;
40
+ }
@@ -0,0 +1,18 @@
1
+ import { useCallback, useRef } from 'react';
2
+
3
+ import { useIsomorphicLayoutEffect } from './use-isomorphic-layout-effect';
4
+
5
+ export function useStableCallback<T extends (...args: any[]) => any>(
6
+ handler: T
7
+ ) {
8
+ const handlerRef = useRef<T>(undefined);
9
+
10
+ useIsomorphicLayoutEffect(() => {
11
+ handlerRef.current = handler;
12
+ });
13
+
14
+ return useCallback((...args: any[]) => {
15
+ const fn = handlerRef.current;
16
+ return fn?.(...args);
17
+ }, []) as T;
18
+ }
package/src/sheet.tsx CHANGED
@@ -1,3 +1,5 @@
1
+ import { createPortal } from 'react-dom';
2
+
1
3
  import React, {
2
4
  Children,
3
5
  cloneElement,
@@ -8,8 +10,6 @@ import React, {
8
10
  useRef,
9
11
  } from 'react';
10
12
 
11
- import { createPortal } from 'react-dom';
12
-
13
13
  import {
14
14
  animate,
15
15
  AnimatePresence,
@@ -21,13 +21,6 @@ import {
21
21
  useTransform,
22
22
  } from 'motion/react';
23
23
 
24
- import {
25
- useModalEffect,
26
- useDimensions,
27
- useIsomorphicLayoutEffect,
28
- useEffectEvent,
29
- } from './hooks';
30
-
31
24
  import {
32
25
  REDUCED_MOTION_TWEEN_CONFIG,
33
26
  DEFAULT_TWEEN_CONFIG,
@@ -36,11 +29,22 @@ import {
36
29
  IS_SSR,
37
30
  } from './constants';
38
31
 
32
+ import {
33
+ getClosest,
34
+ getSheetHeight,
35
+ getSnapY,
36
+ getSnapPoints,
37
+ validateSnapTo,
38
+ } from './utils';
39
+
39
40
  import { type SheetContextType, type SheetProps } from './types';
40
41
  import { SheetScrollerContextProvider, SheetContext } from './context';
41
- import { getClosest, inDescendingOrder, validateSnapTo } from './utils';
42
- import { usePreventScroll } from './use-prevent-scroll';
43
42
  import { styles } from './styles';
43
+ import { useModalEffect } from './hooks/use-modal-effect';
44
+ import { useDimensions } from './hooks/use-dimensions';
45
+ import { useIsomorphicLayoutEffect } from './hooks/use-isomorphic-layout-effect';
46
+ import { useStableCallback } from './hooks/use-stable-callback';
47
+ import { usePreventScroll } from './hooks/use-prevent-scroll';
44
48
 
45
49
  export const Sheet = forwardRef<any, SheetProps>(
46
50
  (
@@ -56,6 +60,7 @@ export const Sheet = forwardRef<any, SheetProps>(
56
60
  isOpen,
57
61
  snapPoints: snapPointsProp,
58
62
  rootId,
63
+ modalEffectRootId,
59
64
  mountPoint,
60
65
  style,
61
66
  detent = 'full-height',
@@ -65,6 +70,7 @@ export const Sheet = forwardRef<any, SheetProps>(
65
70
  tweenConfig = DEFAULT_TWEEN_CONFIG,
66
71
  dragVelocityThreshold = DEFAULT_DRAG_VELOCITY_THRESHOLD,
67
72
  dragCloseThreshold = DEFAULT_DRAG_CLOSE_THRESHOLD,
73
+ modalEffectThreshold,
68
74
  ...rest
69
75
  },
70
76
  ref
@@ -110,25 +116,7 @@ export const Sheet = forwardRef<any, SheetProps>(
110
116
  };
111
117
  });
112
118
 
113
- let snapPoints: number[] | undefined = snapPointsProp;
114
-
115
- if (snapPoints) {
116
- // Convert negative / percentage snap points to absolute values
117
- snapPoints = snapPoints.map((point) => {
118
- // Percentage values e.g. between 0.0 and 1.0
119
- if (point > 0 && point <= 1) return Math.round(point * windowHeight);
120
- return point < 0 ? windowHeight + point : point; // negative values
121
- });
122
-
123
- console.assert(
124
- inDescendingOrder(snapPoints) || windowHeight === 0,
125
- `Snap points need to be in descending order got: [${snapPoints.join(
126
- ', '
127
- )}]`
128
- );
129
- }
130
-
131
- const onDrag = useEffectEvent((_, { delta }: PanInfo) => {
119
+ const onDrag = useStableCallback((_, { delta }: PanInfo) => {
132
120
  // Update drag indicator rotation based on drag velocity
133
121
  const velocity = y.getVelocity();
134
122
 
@@ -139,7 +127,7 @@ export const Sheet = forwardRef<any, SheetProps>(
139
127
  y.set(Math.max(y.get() + delta.y, 0));
140
128
  });
141
129
 
142
- const onDragStart = useEffectEvent(() => {
130
+ const onDragStart = useStableCallback(() => {
143
131
  // Find focused input inside the sheet and blur it when dragging starts
144
132
  // to prevent a weird ghost caret "bug" on mobile
145
133
  const focusedElement = document.activeElement as HTMLElement | null;
@@ -155,51 +143,54 @@ export const Sheet = forwardRef<any, SheetProps>(
155
143
  }
156
144
  });
157
145
 
158
- const onDragEnd = useEffectEvent((_, { velocity }: PanInfo) => {
146
+ const onDragEnd = useStableCallback((_, { velocity }: PanInfo) => {
159
147
  if (velocity.y > dragVelocityThreshold) {
160
148
  // User flicked the sheet down
161
149
  onClose();
162
150
  } else {
163
- // biome-ignore lint/style/noNonNullAssertion: ref should be defined here
164
- const sheetHeight = sheetRef.current!.getBoundingClientRect().height;
151
+ const sheetHeight = getSheetHeight(sheetRef);
165
152
  const currentY = y.get();
166
153
 
167
- let snapTo = 0;
154
+ let yTo = 0;
155
+
156
+ const snapPoints = snapPointsProp
157
+ ? getSnapPoints({ snapPointsProp, sheetHeight })
158
+ : undefined;
168
159
 
169
160
  if (snapPoints) {
170
- const snapToValues = snapPoints.map(
161
+ // Inverse values are the values that can be passed to `animate`
162
+ const snapInverse = snapPoints.map(
171
163
  (p) => sheetHeight - Math.min(p, sheetHeight)
172
164
  );
173
165
 
174
166
  // Allow snapping to the top of the sheet if detent is set to `content-height`
175
- if (detent === 'content-height' && !snapToValues.includes(0)) {
176
- snapToValues.unshift(0);
167
+ if (detent === 'content-height' && !snapInverse.includes(0)) {
168
+ snapInverse.unshift(0);
177
169
  }
178
170
 
179
171
  // Get the closest snap point
180
- snapTo = getClosest(snapToValues, currentY);
172
+ const snapTo = getClosest(snapInverse, currentY);
173
+ const snapIndex = snapInverse.indexOf(snapTo);
174
+
175
+ yTo = validateSnapTo({
176
+ snapTo: getClosest(snapInverse, currentY),
177
+ sheetHeight,
178
+ });
179
+
180
+ // This happens before calling `animate` but it doesn't really matter
181
+ onSnap?.(snapIndex);
181
182
  } else if (currentY / sheetHeight > dragCloseThreshold) {
182
183
  // Close if dragged over enough far
183
- snapTo = sheetHeight;
184
+ yTo = sheetHeight;
184
185
  }
185
186
 
186
- snapTo = validateSnapTo({ snapTo, sheetHeight });
187
-
188
187
  // Update the spring value so that the sheet is animated to the snap point
189
- animate(y, snapTo, animationOptions);
188
+ animate(y, yTo, animationOptions);
190
189
 
191
- if (snapPoints && onSnap) {
192
- const snapValue = Math.abs(Math.round(snapPoints[0] - snapTo));
193
- const snapIndex = snapPoints.indexOf(
194
- getClosest(snapPoints, snapValue)
195
- );
196
- onSnap(snapIndex);
190
+ // +1px for imprecision tolerance
191
+ if (yTo + 1 >= sheetHeight) {
192
+ onClose();
197
193
  }
198
-
199
- const roundedSheetHeight = Math.round(sheetHeight);
200
- const shouldClose = snapTo + 2 >= roundedSheetHeight; // 2px tolerance
201
-
202
- if (shouldClose) onClose();
203
194
  }
204
195
 
205
196
  // Reset indicator rotation after dragging
@@ -208,40 +199,74 @@ export const Sheet = forwardRef<any, SheetProps>(
208
199
 
209
200
  // Trigger onSnap callback when sheet is opened or closed
210
201
  useEffect(() => {
211
- if (!snapPoints || !onSnap) return;
212
- const snapIndex = isOpen ? initialSnap : snapPoints.length - 1;
213
- onSnap(snapIndex);
202
+ if (snapPointsProp && onSnap) {
203
+ onSnap(isOpen ? initialSnap : snapPointsProp.length - 1);
204
+ }
205
+ }, [isOpen]);
206
+
207
+ // Animate the sheet open
208
+ useEffect(() => {
209
+ if (!isOpen) return; // let `AnimatePresence` handle the exit animation
210
+
211
+ let yTo = 0;
212
+
213
+ if (snapPointsProp) {
214
+ const snapIndex = initialSnap;
215
+ const sheetHeight = getSheetHeight(sheetRef);
216
+ const snapPoints = getSnapPoints({ snapPointsProp, sheetHeight });
217
+ const ySnap = getSnapY({ sheetHeight, snapPoints, snapIndex });
218
+
219
+ if (ySnap !== null) {
220
+ yTo = ySnap;
221
+ }
222
+ }
223
+
224
+ animate(y, yTo, animationOptions);
214
225
  }, [isOpen]);
215
226
 
216
227
  useImperativeHandle(ref, () => ({
217
228
  y,
218
229
  snapTo: (snapIndex: number) => {
219
- const sheetEl = sheetRef.current;
230
+ if (!snapPointsProp) {
231
+ console.warn('Snapping is not possible without `snapPoints` prop.');
232
+ return;
233
+ }
220
234
 
221
- if (snapPoints?.[snapIndex] !== undefined && sheetEl) {
222
- const sheetHeight = sheetEl.getBoundingClientRect().height;
223
- const snapPoint = snapPoints[snapIndex];
224
- const snapTo = validateSnapTo({
225
- snapTo: sheetHeight - snapPoint,
226
- sheetHeight,
227
- });
235
+ const sheetHeight = getSheetHeight(sheetRef);
236
+ const snapPoints = getSnapPoints({ snapPointsProp, sheetHeight });
237
+ const ySnap = getSnapY({ sheetHeight, snapPoints, snapIndex });
228
238
 
229
- animate(y, snapTo, animationOptions);
230
- if (onSnap) onSnap(snapIndex);
231
- if (snapTo >= sheetHeight) onClose();
239
+ if (ySnap !== null) {
240
+ animate(y, ySnap, animationOptions);
241
+ onSnap?.(snapIndex);
242
+
243
+ // +1px for imprecision tolerance
244
+ if (ySnap + 1 >= sheetHeight) {
245
+ onClose();
246
+ }
232
247
  }
233
248
  },
234
249
  }));
235
250
 
251
+ if (rootId) {
252
+ console.warn(
253
+ 'The `rootId` prop is deprecated and will be removed in the next major version. Use `modalEffectRootId` instead.'
254
+ );
255
+ }
256
+
236
257
  useModalEffect({
237
258
  y,
238
- rootId,
239
259
  sheetRef,
260
+ snapPointsProp,
261
+ rootId: rootId || modalEffectRootId,
262
+ startThreshold: modalEffectThreshold,
240
263
  });
241
264
 
242
- // Framer Motion should handle body scroll locking but it's not working
265
+ // Motion should handle body scroll locking but it's not working
243
266
  // properly on iOS. Scroll locking from React Aria seems to work much better.
244
- usePreventScroll({ isDisabled: disableScrollLocking || !isOpen });
267
+ usePreventScroll({
268
+ isDisabled: disableScrollLocking || !isOpen,
269
+ });
245
270
 
246
271
  const dragProps = useMemo(() => {
247
272
  const dragProps: SheetContextType['dragProps'] = {
@@ -262,7 +287,6 @@ export const Sheet = forwardRef<any, SheetProps>(
262
287
  sheetRef,
263
288
  isOpen,
264
289
  initialSnap,
265
- snapPoints,
266
290
  detent,
267
291
  indicatorRotation,
268
292
  callbacks,
package/src/styles.ts CHANGED
@@ -18,7 +18,7 @@ export const styles: Record<string, Properties> = {
18
18
  width: '100%',
19
19
  height: '100%',
20
20
  backgroundColor: 'rgba(0, 0, 0, 0.2)',
21
- touchAction: 'none', // Disable iOS body scrolling
21
+ touchAction: 'none',
22
22
  border: 'none',
23
23
  WebkitTapHighlightColor: 'transparent',
24
24
  },
package/src/types.tsx CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  type MutableRefObject,
6
6
  type ReactNode,
7
7
  type RefAttributes,
8
+ type RefObject,
8
9
  } from 'react';
9
10
 
10
11
  import {
@@ -39,7 +40,9 @@ export type SheetProps = {
39
40
  isOpen: boolean;
40
41
  children: ReactNode;
41
42
  onClose: () => void;
43
+ /** @deprecated Use `modalEffectRootId` instead! */
42
44
  rootId?: string;
45
+ modalEffectRootId?: string;
43
46
  mountPoint?: Element;
44
47
  snapPoints?: number[];
45
48
  detent?: SheetDetent;
@@ -50,6 +53,7 @@ export type SheetProps = {
50
53
  prefersReducedMotion?: boolean;
51
54
  dragVelocityThreshold?: number;
52
55
  dragCloseThreshold?: number;
56
+ modalEffectThreshold?: number;
53
57
  } & SheetEvents &
54
58
  ComponentPropsWithoutRef<typeof motion.div>;
55
59
 
@@ -95,13 +99,12 @@ export interface SheetDragProps {
95
99
 
96
100
  export interface SheetContextType {
97
101
  y: MotionValue<any>;
98
- sheetRef: MutableRefObject<any>;
102
+ sheetRef: RefObject<any>;
99
103
  isOpen: boolean;
100
- snapPoints: SheetProps['snapPoints'];
101
104
  detent: SheetDetent;
102
105
  initialSnap: SheetProps['initialSnap'];
103
106
  indicatorRotation: MotionValue<number>;
104
- callbacks: MutableRefObject<SheetEvents>;
107
+ callbacks: RefObject<SheetEvents>;
105
108
  dragProps?: SheetDragProps;
106
109
  windowHeight: number;
107
110
  animationOptions: Transition;