react-modal-sheet 1.6.2 → 1.7.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/sheet.tsx CHANGED
@@ -4,33 +4,47 @@ import { usePreventScroll } from '@react-aria/overlays';
4
4
 
5
5
  import {
6
6
  animate,
7
- useMotionValue,
8
- PanInfo,
9
7
  AnimatePresence,
8
+ PanInfo,
9
+ useMotionValue,
10
10
  } from 'framer-motion';
11
11
 
12
+ import {
13
+ useModalEffect,
14
+ useWindowHeight,
15
+ useIsomorphicLayoutEffect,
16
+ useEvent,
17
+ } from './hooks';
18
+
19
+ import {
20
+ DEFAULT_SPRING_CONFIG,
21
+ DRAG_CLOSE_THRESHOLD,
22
+ DRAG_VELOCITY_THRESHOLD,
23
+ IS_SSR,
24
+ } from './constants';
25
+
12
26
  import { SheetContextType, SheetProps } from './types';
13
- import { getClosest, inDescendingOrder, isSSR, useWindowHeight } from './utils';
14
27
  import { SheetContext } from './context';
15
- import { useModalEffect } from './hooks';
28
+ import { getClosest, inDescendingOrder, validateSnapTo } from './utils';
16
29
  import styles from './styles';
17
30
 
18
31
  const Sheet = React.forwardRef<any, SheetProps>(
19
32
  (
20
33
  {
21
- children,
22
- isOpen,
23
- onClose,
24
34
  onOpenStart,
25
35
  onOpenEnd,
36
+ onClose,
26
37
  onCloseStart,
27
38
  onCloseEnd,
28
39
  onSnap,
40
+ children,
41
+ isOpen,
29
42
  snapPoints,
43
+ detent,
30
44
  initialSnap = 0,
31
45
  rootId,
32
46
  mountPoint,
33
- springConfig = { stiffness: 300, damping: 30, mass: 0.2 },
47
+ springConfig = DEFAULT_SPRING_CONFIG,
34
48
  disableDrag = false,
35
49
  style,
36
50
  ...rest
@@ -38,7 +52,6 @@ const Sheet = React.forwardRef<any, SheetProps>(
38
52
  ref
39
53
  ) => {
40
54
  const sheetRef = React.useRef<any>(null);
41
- const callbacks = React.useRef({ onOpenStart, onOpenEnd, onCloseStart, onCloseEnd }); // prettier-ignore
42
55
  const indicatorRotation = useMotionValue(0);
43
56
  const windowHeight = useWindowHeight();
44
57
 
@@ -47,10 +60,29 @@ const Sheet = React.forwardRef<any, SheetProps>(
47
60
  // and after that it is driven by the gestures and/or snapping
48
61
  const y = useMotionValue(0);
49
62
 
63
+ // Keep the callback fns up-to-date so that they can be accessed inside
64
+ // the effect without including them to the dependencies array
65
+ const callbacks = React.useRef({
66
+ onOpenStart,
67
+ onOpenEnd,
68
+ onCloseStart,
69
+ onCloseEnd,
70
+ });
71
+
72
+ useIsomorphicLayoutEffect(() => {
73
+ callbacks.current = {
74
+ onOpenStart,
75
+ onOpenEnd,
76
+ onCloseStart,
77
+ onCloseEnd,
78
+ };
79
+ });
80
+
50
81
  if (snapPoints) {
51
82
  // Convert negative / percentage snap points to absolute values
52
83
  snapPoints = snapPoints.map(point => {
53
- if (point > 0 && point <= 1) return Math.round(point * windowHeight); // percentage values e.g. between 0.0 and 1.0
84
+ // Percentage values e.g. between 0.0 and 1.0
85
+ if (point > 0 && point <= 1) return Math.round(point * windowHeight);
54
86
  return point < 0 ? windowHeight + point : point; // negative values
55
87
  });
56
88
 
@@ -60,54 +92,61 @@ const Sheet = React.forwardRef<any, SheetProps>(
60
92
  );
61
93
  }
62
94
 
63
- const handleDrag = React.useCallback((_, { delta }: PanInfo) => {
95
+ const onDrag = useEvent((_, { delta }: PanInfo) => {
64
96
  // Update drag indicator rotation based on drag velocity
65
97
  const velocity = y.getVelocity();
98
+
66
99
  if (velocity > 0) indicatorRotation.set(10);
67
100
  if (velocity < 0) indicatorRotation.set(-10);
68
101
 
69
102
  // Make sure user cannot drag beyond the top of the sheet
70
103
  y.set(Math.max(y.get() + delta.y, 0));
71
- }, []); // eslint-disable-line
72
-
73
- const handleDragEnd = React.useCallback(
74
- (_, { velocity }: PanInfo) => {
75
- if (velocity.y > 500) {
76
- // User flicked the sheet down
77
- onClose();
78
- } else {
79
- const sheetEl = sheetRef.current as HTMLDivElement;
80
- const contentHeight = sheetEl.getBoundingClientRect().height;
81
- const snapTo = Math.round(
82
- snapPoints
83
- ? getClosest(snapPoints.map(p => contentHeight - p), y.get()) // prettier-ignore
84
- : y.get() / contentHeight > 0.6 // Close if dragged over 60%
85
- ? contentHeight
86
- : 0
87
- );
88
-
89
- // Update the spring value so that the sheet is animated to the snap point
90
- animate(y, snapTo, { type: 'spring', ...springConfig });
104
+ });
91
105
 
92
- if (snapPoints && onSnap) {
93
- const snapValue = Math.abs(Math.round(snapPoints[0] - snapTo));
94
- const snapIndex = snapPoints.indexOf(getClosest(snapPoints, snapValue)); // prettier-ignore
95
- onSnap(snapIndex);
106
+ const onDragEnd = useEvent((_, { velocity }: PanInfo) => {
107
+ if (velocity.y > DRAG_VELOCITY_THRESHOLD) {
108
+ // User flicked the sheet down
109
+ onClose();
110
+ } else {
111
+ const sheetEl = sheetRef.current as HTMLDivElement;
112
+ const sheetHeight = sheetEl.getBoundingClientRect().height;
113
+ const currentY = y.get();
114
+
115
+ let snapTo = 0;
116
+
117
+ if (snapPoints) {
118
+ const snapToValues = snapPoints
119
+ .map(p => sheetHeight - p)
120
+ .filter(p => p >= 0); // negative values can occur with `content-height` detent
121
+
122
+ // Allow snapping to the top of the sheet if detent is set to `content-height`
123
+ if (detent === 'content-height' && !snapToValues.includes(0)) {
124
+ snapToValues.unshift(0);
96
125
  }
97
126
 
98
- if (snapTo >= contentHeight) onClose();
127
+ // Get the closest snap point
128
+ snapTo = getClosest(snapToValues, currentY);
129
+ } else if (currentY / sheetHeight > DRAG_CLOSE_THRESHOLD) {
130
+ // Close if dragged over enough far
131
+ snapTo = sheetHeight;
99
132
  }
100
133
 
101
- // Reset indicator rotation after dragging
102
- indicatorRotation.set(0);
103
- },
104
- [onClose, onSnap] // eslint-disable-line
105
- );
134
+ snapTo = validateSnapTo({ snapTo, sheetHeight });
106
135
 
107
- // Keep the callback fns up-to-date so that they can be accessed inside
108
- // the effect without including them to the dependencies array
109
- React.useEffect(() => {
110
- callbacks.current = { onOpenStart, onOpenEnd, onCloseStart, onCloseEnd };
136
+ // Update the spring value so that the sheet is animated to the snap point
137
+ animate(y, snapTo, { type: 'spring', ...springConfig });
138
+
139
+ if (snapPoints && onSnap) {
140
+ const snapValue = Math.abs(Math.round(snapPoints[0] - snapTo));
141
+ const snapIndex = snapPoints.indexOf(getClosest(snapPoints, snapValue)); // prettier-ignore
142
+ onSnap(snapIndex);
143
+ }
144
+
145
+ if (snapTo >= sheetHeight) onClose();
146
+ }
147
+
148
+ // Reset indicator rotation after dragging
149
+ indicatorRotation.set(0);
111
150
  });
112
151
 
113
152
  // Trigger onSnap callback when sheet is opened or closed
@@ -121,16 +160,23 @@ const Sheet = React.forwardRef<any, SheetProps>(
121
160
  y,
122
161
  snapTo: (snapIndex: number) => {
123
162
  const sheetEl = sheetRef.current as HTMLDivElement | null;
163
+
124
164
  if (
125
165
  snapPoints &&
126
166
  snapPoints[snapIndex] !== undefined &&
127
167
  sheetEl !== null
128
168
  ) {
129
- const contentHeight = sheetEl.getBoundingClientRect().height;
130
- const snapTo = contentHeight - snapPoints[snapIndex];
169
+ const sheetHeight = sheetEl.getBoundingClientRect().height;
170
+ const snapPoint = snapPoints[snapIndex];
171
+ const snapTo = validateSnapTo({
172
+ snapTo: sheetHeight - snapPoint,
173
+ sheetHeight,
174
+ });
175
+
131
176
  animate(y, snapTo, { type: 'spring', ...springConfig });
177
+
132
178
  if (onSnap) onSnap(snapIndex);
133
- if (snapTo >= contentHeight) onClose();
179
+ if (snapTo >= sheetHeight) onClose();
134
180
  }
135
181
  },
136
182
  }));
@@ -141,16 +187,18 @@ const Sheet = React.forwardRef<any, SheetProps>(
141
187
  // properly on iOS. Scroll locking from React Aria seems to work much better.
142
188
  usePreventScroll({ isDisabled: !isOpen });
143
189
 
144
- const dragProps = disableDrag
145
- ? ({} as any)
146
- : {
147
- drag: 'y' as const,
148
- dragElastic: 0,
149
- dragConstraints: { top: 0, bottom: 0 },
150
- dragMomentum: false,
151
- onDrag: handleDrag,
152
- onDragEnd: handleDragEnd,
153
- };
190
+ const dragProps = React.useMemo(() => {
191
+ const dragProps: SheetContextType['dragProps'] = {
192
+ drag: 'y',
193
+ dragElastic: 0,
194
+ dragConstraints: { top: 0, bottom: 0 },
195
+ dragMomentum: false,
196
+ onDrag,
197
+ onDragEnd,
198
+ };
199
+
200
+ return disableDrag ? undefined : dragProps;
201
+ }, [disableDrag]); // eslint-disable-line
154
202
 
155
203
  const context: SheetContextType = {
156
204
  y,
@@ -158,6 +206,7 @@ const Sheet = React.forwardRef<any, SheetProps>(
158
206
  isOpen,
159
207
  initialSnap,
160
208
  snapPoints,
209
+ detent,
161
210
  indicatorRotation,
162
211
  callbacks,
163
212
  dragProps,
@@ -165,15 +214,9 @@ const Sheet = React.forwardRef<any, SheetProps>(
165
214
  springConfig,
166
215
  };
167
216
 
168
- const wrapperProps = {
169
- ...rest,
170
- ref,
171
- style: { ...styles.wrapper, ...style },
172
- };
173
-
174
217
  const sheet = (
175
218
  <SheetContext.Provider value={context}>
176
- <div {...wrapperProps}>
219
+ <div {...rest} ref={ref} style={{ ...styles.wrapper, ...style }}>
177
220
  <AnimatePresence>
178
221
  {/* NOTE: AnimatePresence requires us to set keys to children */}
179
222
  {isOpen
@@ -186,7 +229,7 @@ const Sheet = React.forwardRef<any, SheetProps>(
186
229
  </SheetContext.Provider>
187
230
  );
188
231
 
189
- if (isSSR) return sheet;
232
+ if (IS_SSR) return sheet;
190
233
 
191
234
  return ReactDOM.createPortal(sheet, mountPoint ?? document.body);
192
235
  }
package/src/types.tsx CHANGED
@@ -13,6 +13,8 @@ export type SheetEvents = {
13
13
  onSnap?: (index: number) => void;
14
14
  };
15
15
 
16
+ export type SheetDetent = 'full-height' | 'content-height';
17
+
16
18
  export type SheetProps = {
17
19
  isOpen: boolean;
18
20
  children: React.ReactNode;
@@ -20,6 +22,7 @@ export type SheetProps = {
20
22
  rootId?: string;
21
23
  mountPoint?: Element;
22
24
  snapPoints?: number[];
25
+ detent?: SheetDetent;
23
26
  initialSnap?: number; // index of snap points array
24
27
  springConfig?: Parameters<typeof useSpring>[1];
25
28
  disableDrag?: boolean;
@@ -58,7 +61,6 @@ export type SheetDragProps = {
58
61
  dragConstraints: any;
59
62
  dragMomentum: boolean;
60
63
  onDrag: DragHandlers['onDrag'];
61
- onDragStart: DragHandlers['onDragStart'];
62
64
  onDragEnd: DragHandlers['onDragEnd'];
63
65
  };
64
66
 
@@ -67,10 +69,11 @@ export type SheetContextType = {
67
69
  sheetRef: React.MutableRefObject<any>;
68
70
  isOpen: boolean;
69
71
  snapPoints: SheetProps['snapPoints'];
72
+ detent?: SheetDetent;
70
73
  initialSnap: SheetProps['initialSnap'];
71
74
  indicatorRotation: MotionValue<number>;
72
75
  callbacks: React.MutableRefObject<SheetEvents>;
73
- dragProps: SheetDragProps;
76
+ dragProps?: SheetDragProps;
74
77
  windowHeight: number;
75
78
  springConfig: Parameters<typeof useSpring>[1];
76
79
  };
package/src/utils.ts CHANGED
@@ -1,12 +1,8 @@
1
- import { useEffect, useLayoutEffect, useState } from 'react';
2
-
3
- export const isSSR = typeof window === 'undefined';
4
-
5
- export const getClosest = (nums: number[], goal: number) => {
1
+ export function getClosest(nums: number[], goal: number) {
6
2
  return nums.reduce((prev, curr) => {
7
3
  return Math.abs(curr - goal) < Math.abs(prev - goal) ? curr : prev;
8
4
  });
9
- };
5
+ }
10
6
 
11
7
  const highlightId = 'react-modal-sheet-highlight';
12
8
 
@@ -70,25 +66,27 @@ export function cleanupRootStyles(rootId: string) {
70
66
  }
71
67
  }
72
68
 
73
- const useIsomorphicLayoutEffect = isSSR ? useEffect : useLayoutEffect;
74
-
75
- export function useWindowHeight() {
76
- const [windowHeight, setWindowHeight] = useState(0);
77
-
78
- useIsomorphicLayoutEffect(() => {
79
- const updateHeight = () => setWindowHeight(window.innerHeight);
80
- window.addEventListener('resize', updateHeight);
81
- updateHeight();
82
- return () => window.removeEventListener('resize', updateHeight);
83
- }, []);
84
-
85
- return windowHeight;
86
- }
87
-
88
- export const inDescendingOrder = (arr: number[]) => {
69
+ export function inDescendingOrder(arr: number[]) {
89
70
  for (let i = 0; i < arr.length; i++) {
90
71
  if (arr[i + 1] > arr[i]) return false;
91
72
  }
92
73
 
93
74
  return true;
94
- };
75
+ }
76
+
77
+ export function validateSnapTo({
78
+ snapTo,
79
+ sheetHeight,
80
+ }: {
81
+ snapTo: number;
82
+ sheetHeight: number;
83
+ }) {
84
+ if (snapTo < 0) {
85
+ console.warn(
86
+ `Snap point is out of bounds. Sheet height is ${sheetHeight} but snap point is ${sheetHeight +
87
+ Math.abs(snapTo)}.`
88
+ );
89
+ }
90
+
91
+ return Math.max(Math.round(snapTo), 0);
92
+ }