react-modal-sheet 4.3.0 → 5.0.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/src/index.tsx CHANGED
@@ -1,16 +1,18 @@
1
1
  import type { MotionValue } from 'motion/react';
2
+ import type { RefObject } from 'react';
2
3
 
3
- import type { SheetCompound, SheetDraggableProps } from './types';
4
- import { Sheet as SheetBase } from './sheet';
4
+ import { SheetBackdrop } from './SheetBackdrop';
5
5
  import { SheetContainer } from './SheetContainer';
6
6
  import { SheetContent } from './SheetContent';
7
7
  import { SheetHeader } from './SheetHeader';
8
- import { SheetBackdrop } from './SheetBackdrop';
9
- import { SheetScroller } from './SheetScroller';
8
+ import { Sheet as SheetBase } from './sheet';
9
+ import type { SheetCompound, SheetDraggableProps } from './types';
10
10
 
11
11
  export interface SheetRef {
12
12
  y: MotionValue<number>;
13
- snapTo: (index: number) => void;
13
+ yInverted: MotionValue<number>;
14
+ height: number;
15
+ snapTo: (index: number) => Promise<void>;
14
16
  }
15
17
 
16
18
  export const Sheet: SheetCompound = Object.assign(SheetBase, {
@@ -18,15 +20,15 @@ export const Sheet: SheetCompound = Object.assign(SheetBase, {
18
20
  Header: SheetHeader,
19
21
  Content: SheetContent,
20
22
  Backdrop: SheetBackdrop,
21
- Scroller: SheetScroller,
22
23
  });
23
24
 
24
25
  // Export types
25
26
  export type SheetHeaderProps = SheetDraggableProps;
26
27
  export type SheetContentProps = SheetDraggableProps;
27
28
  export type {
28
- SheetProps,
29
29
  SheetBackdropProps,
30
- SheetScrollerProps,
31
30
  SheetContainerProps,
31
+ SheetProps,
32
+ SheetSnapPoint,
33
+ SheetStateInfo,
32
34
  } from './types';
package/src/sheet.tsx CHANGED
@@ -1,82 +1,92 @@
1
- import { createPortal } from 'react-dom';
2
-
3
- import React, {
4
- Children,
5
- cloneElement,
6
- forwardRef,
7
- useEffect,
8
- useImperativeHandle,
9
- useMemo,
10
- useRef,
11
- } from 'react';
12
-
13
1
  import {
2
+ type DragHandler,
3
+ type Transition,
14
4
  animate,
15
- AnimatePresence,
16
5
  motion,
17
- type PanInfo,
18
- type Transition,
19
6
  useMotionValue,
20
7
  useReducedMotion,
21
8
  useTransform,
22
9
  } from 'motion/react';
10
+ import React, {
11
+ forwardRef,
12
+ useImperativeHandle,
13
+ useRef,
14
+ useState,
15
+ } from 'react';
16
+ import { createPortal } from 'react-dom';
17
+ import useMeasure from 'react-use-measure';
23
18
 
24
19
  import {
25
- REDUCED_MOTION_TWEEN_CONFIG,
26
- DEFAULT_TWEEN_CONFIG,
27
20
  DEFAULT_DRAG_CLOSE_THRESHOLD,
28
21
  DEFAULT_DRAG_VELOCITY_THRESHOLD,
22
+ DEFAULT_TWEEN_CONFIG,
29
23
  IS_SSR,
24
+ REDUCED_MOTION_TWEEN_CONFIG,
30
25
  } from './constants';
31
-
32
- import {
33
- getSheetHeight,
34
- getSnapY,
35
- getSnapPoints,
36
- getClosestSnapPoint,
37
- } from './utils';
38
-
39
- import { type SheetContextType, type SheetProps } from './types';
40
- import { SheetScrollerContextProvider, SheetContext } from './context';
41
- import { styles } from './styles';
42
- import { useModalEffect } from './hooks/use-modal-effect';
26
+ import { SheetContext } from './context';
43
27
  import { useDimensions } from './hooks/use-dimensions';
44
- import { useIsomorphicLayoutEffect } from './hooks/use-isomorphic-layout-effect';
45
- import { useStableCallback } from './hooks/use-stable-callback';
28
+ import { useModalEffect } from './hooks/use-modal-effect';
46
29
  import { usePreventScroll } from './hooks/use-prevent-scroll';
30
+ import { useSheetState } from './hooks/use-sheet-state';
31
+ import { useStableCallback } from './hooks/use-stable-callback';
32
+ import { useVirtualKeyboard } from './hooks/use-virtual-keyboard';
33
+ import {
34
+ computeSnapPoints,
35
+ handleHighVelocityDrag,
36
+ handleLowVelocityDrag,
37
+ } from './snap';
38
+ import { styles } from './styles';
39
+ import { type SheetContextType, type SheetProps } from './types';
47
40
 
48
41
  export const Sheet = forwardRef<any, SheetProps>(
49
42
  (
50
43
  {
51
- onOpenStart,
52
- onOpenEnd,
53
- onClose,
54
- onCloseStart,
55
- onCloseEnd,
56
- onSnap,
44
+ avoidKeyboard = true,
57
45
  children,
46
+ className = '',
47
+ detent = 'default',
48
+ disableDrag: disableDragProp = false,
58
49
  disableScrollLocking = false,
50
+ disableDismiss = false,
51
+ dragCloseThreshold = DEFAULT_DRAG_CLOSE_THRESHOLD,
52
+ dragVelocityThreshold = DEFAULT_DRAG_VELOCITY_THRESHOLD,
59
53
  isOpen,
60
- snapPoints: snapPointsProp,
61
- rootId,
62
54
  modalEffectRootId,
55
+ modalEffectThreshold,
63
56
  mountPoint,
64
- style,
65
- detent = 'full-height',
66
- initialSnap = 0,
67
- disableDrag = false,
68
57
  prefersReducedMotion = false,
58
+ snapPoints: snapPointsProp,
59
+ initialSnap,
60
+ style,
69
61
  tweenConfig = DEFAULT_TWEEN_CONFIG,
70
- dragVelocityThreshold = DEFAULT_DRAG_VELOCITY_THRESHOLD,
71
- dragCloseThreshold = DEFAULT_DRAG_CLOSE_THRESHOLD,
72
- modalEffectThreshold,
62
+ onOpenStart,
63
+ onOpenEnd,
64
+ onClose,
65
+ onCloseStart,
66
+ onCloseEnd,
67
+ onSnap,
68
+ onDrag: onDragProp,
69
+ onDragStart: onDragStartProp,
70
+ onDragEnd: onDragEndProp,
73
71
  ...rest
74
72
  },
75
73
  ref
76
74
  ) => {
75
+ const [sheetBoundsRef, sheetBounds] = useMeasure();
77
76
  const sheetRef = useRef<HTMLDivElement>(null);
77
+ const sheetHeight = Math.round(sheetBounds.height);
78
+ const [currentSnap, setCurrentSnap] = useState(initialSnap);
79
+ const snapPoints =
80
+ snapPointsProp && sheetHeight > 0
81
+ ? computeSnapPoints({ sheetHeight, snapPointsProp })
82
+ : [];
83
+
84
+ const { windowHeight } = useDimensions();
85
+ const closedY = sheetHeight > 0 ? sheetHeight : windowHeight;
86
+ const y = useMotionValue(closedY);
87
+ const yInverted = useTransform(y, (val) => Math.max(sheetHeight - val, 0));
78
88
  const indicatorRotation = useMotionValue(0);
79
- const { height: windowHeight } = useDimensions();
89
+
80
90
  const shouldReduceMotion = useReducedMotion();
81
91
  const reduceMotion = Boolean(prefersReducedMotion || shouldReduceMotion);
82
92
  const animationOptions: Transition = {
@@ -84,66 +94,67 @@ export const Sheet = forwardRef<any, SheetProps>(
84
94
  ...(reduceMotion ? REDUCED_MOTION_TWEEN_CONFIG : tweenConfig),
85
95
  };
86
96
 
87
- // NOTE: the inital value for `y` doesn't matter since it is overwritten by
88
- // the value driven by the `AnimatePresence` component when the sheet is opened
89
- // and after that it is driven by the gestures and/or snapping
90
- const y = useMotionValue(windowHeight);
97
+ const keyboard = useVirtualKeyboard({
98
+ isEnabled: isOpen && avoidKeyboard,
99
+ containerRef: sheetRef,
100
+ });
101
+
102
+ // Disable drag if the keyboard is open to avoid weird behavior
103
+ const disableDrag = keyboard.isKeyboardOpen || disableDragProp;
91
104
 
92
105
  // +2 for tolerance in case the animated value is slightly off
93
- const zIndex = useTransform(y, (value) =>
94
- value + 2 >= windowHeight ? -1 : (style?.zIndex ?? 9999)
106
+ const zIndex = useTransform(y, (val) =>
107
+ val + 2 >= closedY ? -1 : (style?.zIndex ?? 9999)
95
108
  );
96
- const visibility = useTransform(y, (value) =>
97
- value + 2 >= windowHeight ? 'hidden' : 'visible'
109
+ const visibility = useTransform(y, (val) =>
110
+ val + 2 >= closedY ? 'hidden' : 'visible'
98
111
  );
99
112
 
100
- // Keep the callback fns up-to-date so that they can be accessed inside
101
- // the effect without including them to the dependencies array
102
- const callbacks = useRef({
103
- onOpenStart,
104
- onOpenEnd,
105
- onCloseStart,
106
- onCloseEnd,
113
+ const updateSnap = useStableCallback((snapIndex: number) => {
114
+ setCurrentSnap(snapIndex);
115
+ onSnap?.(snapIndex);
107
116
  });
108
117
 
109
- useIsomorphicLayoutEffect(() => {
110
- callbacks.current = {
111
- onOpenStart,
112
- onOpenEnd,
113
- onCloseStart,
114
- onCloseEnd,
115
- };
118
+ const getSnapPoint = useStableCallback((snapIndex: number) => {
119
+ if (snapPointsProp && snapPoints) {
120
+ if (snapIndex < 0 || snapIndex >= snapPoints.length) {
121
+ console.warn(
122
+ `Invalid snap index ${snapIndex}. Snap points are: [${snapPointsProp.join(', ')}] and their computed values are: [${snapPoints
123
+ .map((point) => point.snapValue)
124
+ .join(', ')}]`
125
+ );
126
+ return null;
127
+ }
128
+ return snapPoints[snapIndex];
129
+ }
130
+ return null;
116
131
  });
117
132
 
118
- const onDrag = useStableCallback((_: PointerEvent, info: PanInfo) => {
119
- const currentY = y.get();
120
-
121
- /**
122
- * Make sure the user cannot drag the sheet beyond the upmost snap point
123
- * if the `snapPoints` prop is provided.
124
- */
125
- if (snapPointsProp) {
126
- const sheetHeight = getSheetHeight(sheetRef);
127
- const snapPoints = getSnapPoints({ snapPointsProp, sheetHeight });
128
- const topSnapPoint = sheetHeight - snapPoints[0];
129
-
130
- if (info.delta.y < 0 && currentY <= topSnapPoint) {
131
- y.set(topSnapPoint);
132
- return;
133
- }
133
+ const snapTo = useStableCallback(async (snapIndex: number) => {
134
+ if (!snapPointsProp) {
135
+ console.warn('Snapping is not possible without `snapPoints` prop.');
136
+ return;
134
137
  }
135
138
 
136
- // Update drag indicator rotation based on drag velocity
137
- const velocity = y.getVelocity();
139
+ const snapPoint = getSnapPoint(snapIndex);
138
140
 
139
- if (velocity > 0) indicatorRotation.set(10);
140
- if (velocity < 0) indicatorRotation.set(-10);
141
+ if (snapPoint === null) {
142
+ console.warn(`Invalid snap index ${snapIndex}.`);
143
+ return;
144
+ }
141
145
 
142
- // Make sure user cannot drag beyond the top of the sheet
143
- y.set(Math.max(currentY + info.delta.y, 0));
146
+ if (snapIndex === 0) {
147
+ onClose();
148
+ return;
149
+ }
150
+
151
+ await animate(y, snapPoint.snapValueY, {
152
+ ...animationOptions,
153
+ onComplete: () => updateSnap(snapIndex),
154
+ });
144
155
  });
145
156
 
146
- const onDragStart = useStableCallback(() => {
157
+ const blurActiveInput = useStableCallback(() => {
147
158
  // Find focused input inside the sheet and blur it when dragging starts
148
159
  // to prevent a weird ghost caret "bug" on mobile
149
160
  const focusedElement = document.activeElement as HTMLElement | null;
@@ -159,148 +170,174 @@ export const Sheet = forwardRef<any, SheetProps>(
159
170
  }
160
171
  });
161
172
 
162
- const onDragEnd = useStableCallback((_, { velocity }: PanInfo) => {
163
- if (velocity.y > dragVelocityThreshold) {
164
- // User flicked the sheet down
165
- onClose();
166
- } else {
167
- const sheetHeight = getSheetHeight(sheetRef);
168
- const currentY = y.get();
173
+ const onDrag = useStableCallback<DragHandler>((event, info) => {
174
+ onDragProp?.(event, info);
169
175
 
170
- let yTo = 0;
176
+ const currentY = y.get();
171
177
 
172
- const snapPoints = snapPointsProp
173
- ? getSnapPoints({ snapPointsProp, sheetHeight })
174
- : undefined;
178
+ // Update drag indicator rotation based on drag velocity
179
+ const velocity = y.getVelocity();
180
+ if (velocity > 0) indicatorRotation.set(10);
181
+ if (velocity < 0) indicatorRotation.set(-10);
175
182
 
176
- if (snapPoints) {
177
- const { snapY, snapIndex } = getClosestSnapPoint({
178
- snapPoints,
179
- currentY,
180
- sheetHeight,
181
- detent,
182
- });
183
+ // Make sure user cannot drag beyond the top of the sheet
184
+ y.set(Math.max(currentY + info.delta.y, 0));
185
+ });
183
186
 
184
- yTo = snapY;
187
+ const onDragStart = useStableCallback<DragHandler>((event, info) => {
188
+ blurActiveInput();
189
+ onDragStartProp?.(event, info);
190
+ });
185
191
 
186
- // This happens before calling `animate` but it doesn't really matter
187
- onSnap?.(snapIndex);
188
- } else if (currentY / sheetHeight > dragCloseThreshold) {
189
- // Close if dragged over enough far
190
- yTo = sheetHeight;
191
- }
192
+ const onDragEnd = useStableCallback<DragHandler>((event, info) => {
193
+ blurActiveInput();
194
+ onDragEndProp?.(event, info);
192
195
 
193
- // Update the spring value so that the sheet is animated to the snap point
194
- animate(y, yTo, animationOptions);
196
+ const currentY = y.get();
195
197
 
196
- // +1px for imprecision tolerance
197
- if (yTo + 1 >= sheetHeight) {
198
- onClose();
199
- }
200
- }
198
+ let yTo = 0;
201
199
 
202
- // Reset indicator rotation after dragging
203
- indicatorRotation.set(0);
204
- });
200
+ const currentSnapPoint =
201
+ currentSnap !== undefined ? getSnapPoint(currentSnap) : null;
205
202
 
206
- // Trigger onSnap callback when sheet is opened or closed
207
- useEffect(() => {
208
- if (snapPointsProp && onSnap) {
209
- onSnap(isOpen ? initialSnap : snapPointsProp.length - 1);
210
- }
211
- }, [isOpen]);
203
+ if (currentSnapPoint) {
204
+ const dragOffsetDirection = info.offset.y > 0 ? 'down' : 'up';
205
+ const dragVelocityDirection = info.velocity.y > 0 ? 'down' : 'up';
206
+ const isHighVelocity =
207
+ Math.abs(info.velocity.y) > dragVelocityThreshold;
212
208
 
213
- // Animate the sheet open
214
- useEffect(() => {
215
- if (!isOpen) return; // let `AnimatePresence` handle the exit animation
209
+ let result: { yTo: number; snapIndex: number | undefined };
216
210
 
217
- let yTo = 0;
211
+ if (isHighVelocity) {
212
+ result = handleHighVelocityDrag({
213
+ snapPoints,
214
+ dragDirection: dragVelocityDirection,
215
+ });
216
+ } else {
217
+ result = handleLowVelocityDrag({
218
+ currentSnapPoint,
219
+ currentY,
220
+ dragDirection: dragOffsetDirection,
221
+ snapPoints,
222
+ velocity: info.velocity.y,
223
+ });
224
+ }
218
225
 
219
- if (snapPointsProp) {
220
- const snapIndex = initialSnap;
221
- const sheetHeight = getSheetHeight(sheetRef);
222
- const snapPoints = getSnapPoints({ snapPointsProp, sheetHeight });
223
- const snapY = getSnapY({ sheetHeight, snapPoints, snapIndex });
226
+ yTo = result.yTo;
224
227
 
225
- if (snapY !== null) {
226
- yTo = snapY;
228
+ // If disableDismiss is true, prevent closing via gesture
229
+ if (disableDismiss && yTo + 1 >= sheetHeight) {
230
+ // Use the bottom-most open snap point
231
+ const bottomSnapPoint = snapPoints.find((s) => s.snapValue > 0);
232
+
233
+ if (bottomSnapPoint) {
234
+ yTo = bottomSnapPoint.snapValueY;
235
+ updateSnap(bottomSnapPoint.snapIndex);
236
+ } else {
237
+ // If no open snap points available, stay at current position
238
+ yTo = currentY;
239
+ }
240
+ } else if (result.snapIndex !== undefined) {
241
+ updateSnap(result.snapIndex);
242
+ }
243
+ } else if (
244
+ info.velocity.y > dragVelocityThreshold ||
245
+ currentY > sheetHeight * dragCloseThreshold
246
+ ) {
247
+ // Close the sheet if dragged past the threshold or if the velocity is high enough
248
+ // But only if disableDismiss is false
249
+ if (disableDismiss) {
250
+ // If disableDismiss, snap back to the open position
251
+ yTo = 0;
252
+ } else {
253
+ yTo = closedY;
227
254
  }
228
255
  }
229
256
 
257
+ // Update the spring value so that the sheet is animated to the snap point
230
258
  animate(y, yTo, animationOptions);
231
- }, [isOpen]);
232
-
233
- useImperativeHandle(ref, () => ({
234
- y,
235
- snapTo: (snapIndex: number) => {
236
- if (!snapPointsProp) {
237
- console.warn('Snapping is not possible without `snapPoints` prop.');
238
- return;
239
- }
240
259
 
241
- const sheetHeight = getSheetHeight(sheetRef);
242
- const snapPoints = getSnapPoints({ snapPointsProp, sheetHeight });
243
- const snapY = getSnapY({ sheetHeight, snapPoints, snapIndex });
260
+ // +1px for imprecision tolerance
261
+ // Only call onClose if disableDismiss is false or if we're actually closing
262
+ if (yTo + 1 >= sheetHeight && !disableDismiss) {
263
+ onClose();
264
+ }
244
265
 
245
- if (snapY !== null) {
246
- animate(y, snapY, animationOptions);
247
- onSnap?.(snapIndex);
266
+ // Reset indicator rotation after dragging
267
+ indicatorRotation.set(0);
268
+ });
248
269
 
249
- // +1px for imprecision tolerance
250
- if (snapY + 1 >= sheetHeight) {
251
- onClose();
252
- }
253
- }
254
- },
270
+ useImperativeHandle(ref, () => ({
271
+ y,
272
+ yInverted,
273
+ height: sheetHeight,
274
+ snapTo,
255
275
  }));
256
276
 
257
- if (rootId) {
258
- console.warn(
259
- 'The `rootId` prop is deprecated and will be removed in the next major version. Use `modalEffectRootId` instead.'
260
- );
261
- }
262
-
263
277
  useModalEffect({
264
278
  y,
265
- sheetRef,
266
- snapPointsProp,
267
- rootId: rootId || modalEffectRootId,
279
+ detent,
280
+ sheetHeight,
281
+ snapPoints,
282
+ rootId: modalEffectRootId,
268
283
  startThreshold: modalEffectThreshold,
269
284
  });
270
285
 
271
- // Motion should handle body scroll locking but it's not working
272
- // properly on iOS. Scroll locking from React Aria seems to work much better.
286
+ /**
287
+ * Motion should handle body scroll locking but it's not working properly on iOS.
288
+ * Scroll locking from React Aria seems to work much better 🤷‍♂️
289
+ */
273
290
  usePreventScroll({
274
291
  isDisabled: disableScrollLocking || !isOpen,
275
292
  });
276
293
 
277
- const dragProps = useMemo(() => {
278
- const dragProps: SheetContextType['dragProps'] = {
279
- drag: 'y',
280
- dragElastic: 0,
281
- dragMomentum: false,
282
- dragPropagation: false,
283
- onDrag,
284
- onDragStart,
285
- onDragEnd,
286
- };
294
+ const state = useSheetState({
295
+ isOpen,
296
+ onOpen: async () => {
297
+ onOpenStart?.();
287
298
 
288
- return disableDrag ? undefined : dragProps;
289
- }, [disableDrag, windowHeight]);
299
+ const initialSnapPoint =
300
+ initialSnap !== undefined ? getSnapPoint(initialSnap) : null;
301
+
302
+ const yTo = initialSnapPoint?.snapValueY ?? 0;
303
+
304
+ await animate(y, yTo, animationOptions);
305
+
306
+ if (initialSnap !== undefined) {
307
+ updateSnap(initialSnap);
308
+ }
309
+
310
+ onOpenEnd?.();
311
+ },
312
+ onClosing: async () => {
313
+ onCloseStart?.();
314
+
315
+ await animate(y, closedY, animationOptions);
316
+
317
+ onCloseEnd?.();
318
+ },
319
+ });
320
+
321
+ const dragProps: SheetContextType['dragProps'] = {
322
+ drag: 'y',
323
+ dragElastic: 0,
324
+ dragMomentum: false,
325
+ dragPropagation: false,
326
+ onDrag,
327
+ onDragStart,
328
+ onDragEnd,
329
+ };
290
330
 
291
331
  const context: SheetContextType = {
292
- y,
293
- sheetRef,
294
- isOpen,
295
- initialSnap,
332
+ currentSnap,
296
333
  detent,
297
- indicatorRotation,
298
- callbacks,
299
- dragProps,
300
- windowHeight,
301
- animationOptions,
302
- reduceMotion,
303
334
  disableDrag,
335
+ dragProps,
336
+ indicatorRotation,
337
+ avoidKeyboard,
338
+ sheetBoundsRef,
339
+ sheetRef,
340
+ y,
304
341
  };
305
342
 
306
343
  const sheet = (
@@ -308,18 +345,11 @@ export const Sheet = forwardRef<any, SheetProps>(
308
345
  <motion.div
309
346
  {...rest}
310
347
  ref={ref}
311
- style={{ ...styles.wrapper, zIndex, visibility, ...style }}
348
+ data-sheet-state={state}
349
+ className={`react-modal-sheet-root ${className}`}
350
+ style={{ ...styles.root, zIndex, visibility, ...style }}
312
351
  >
313
- <AnimatePresence>
314
- {/* NOTE: AnimatePresence requires us to set keys to children */}
315
- {isOpen ? (
316
- <SheetScrollerContextProvider>
317
- {Children.map(children, (child: any, i) =>
318
- cloneElement(child, { key: `sheet-child-${i}` })
319
- )}
320
- </SheetScrollerContextProvider>
321
- ) : null}
322
- </AnimatePresence>
352
+ {state !== 'closed' ? children : null}
323
353
  </motion.div>
324
354
  </SheetContext.Provider>
325
355
  );