react-modal-sheet 5.4.1 → 5.5.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.5.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/sheet.tsx CHANGED
@@ -30,11 +30,7 @@ import { useModalEffect } from './hooks/use-modal-effect';
30
30
  import { usePreventScroll } from './hooks/use-prevent-scroll';
31
31
  import { useSheetState } from './hooks/use-sheet-state';
32
32
  import { useStableCallback } from './hooks/use-stable-callback';
33
- import {
34
- computeSnapPoints,
35
- handleHighVelocityDrag,
36
- handleLowVelocityDrag,
37
- } from './snap';
33
+ import { classifyDragEnd, computeSnapPoints } from './snap';
38
34
  import { styles } from './styles';
39
35
  import { type SheetContextType, type SheetProps } from './types';
40
36
  import { applyStyles, waitForElement, willOpenKeyboard } from './utils';
@@ -232,63 +228,31 @@ export const Sheet = forwardRef<any, SheetProps>(
232
228
 
233
229
  const currentY = y.get();
234
230
 
235
- let yTo = 0;
236
-
237
- const currentSnapPoint =
238
- currentSnap !== undefined ? getSnapPoint(currentSnap) : null;
239
-
240
- if (currentSnapPoint) {
241
- const dragOffsetDirection = info.offset.y > 0 ? 'down' : 'up';
242
- const dragVelocityDirection = info.velocity.y > 0 ? 'down' : 'up';
243
- const isHighVelocity =
244
- Math.abs(info.velocity.y) > dragVelocityThreshold;
231
+ const result = classifyDragEnd({
232
+ y: currentY,
233
+ info,
234
+ sheetHeight,
235
+ dragCloseThreshold,
236
+ snapPoints,
237
+ dragVelocityThreshold,
238
+ });
245
239
 
246
- let result: { yTo: number; snapIndex: number | undefined };
240
+ let yTo = result.yTo;
247
241
 
248
- if (isHighVelocity) {
249
- result = handleHighVelocityDrag({
250
- snapPoints,
251
- dragDirection: dragVelocityDirection,
252
- });
253
- } else {
254
- result = handleLowVelocityDrag({
255
- currentSnapPoint,
256
- currentY,
257
- dragDirection: dragOffsetDirection,
258
- snapPoints,
259
- velocity: info.velocity.y,
260
- });
261
- }
242
+ // If disableDismiss is true, prevent closing via gesture
243
+ if (disableDismiss && yTo + 1 >= sheetHeight) {
244
+ // Use the bottom-most open snap point
245
+ const bottomSnapPoint = snapPoints.find((s) => s.snapValue > 0);
262
246
 
263
- yTo = result.yTo;
264
-
265
- // If disableDismiss is true, prevent closing via gesture
266
- if (disableDismiss && yTo + 1 >= sheetHeight) {
267
- // Use the bottom-most open snap point
268
- const bottomSnapPoint = snapPoints.find((s) => s.snapValue > 0);
269
-
270
- if (bottomSnapPoint) {
271
- yTo = bottomSnapPoint.snapValueY;
272
- updateSnap(bottomSnapPoint.snapIndex);
273
- } else {
274
- // If no open snap points available, stay at current position
275
- yTo = currentY;
276
- }
277
- } else if (result.snapIndex !== undefined) {
278
- updateSnap(result.snapIndex);
279
- }
280
- } else if (
281
- info.velocity.y > dragVelocityThreshold ||
282
- currentY > sheetHeight * dragCloseThreshold
283
- ) {
284
- // Close the sheet if dragged past the threshold or if the velocity is high enough
285
- // But only if disableDismiss is false
286
- if (disableDismiss) {
287
- // If disableDismiss, snap back to the open position
288
- yTo = 0;
247
+ if (bottomSnapPoint) {
248
+ yTo = bottomSnapPoint.snapValueY;
249
+ updateSnap(bottomSnapPoint.snapIndex);
289
250
  } else {
290
- yTo = closedY;
251
+ // If no open snap points available, stay at current position
252
+ yTo = currentY;
291
253
  }
254
+ } else if (result.snapIndex !== undefined) {
255
+ updateSnap(result.snapIndex);
292
256
  }
293
257
 
294
258
  // Update the spring value so that the sheet is animated to the snap point
package/src/snap.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { PanInfo } from 'motion';
1
2
  import type { SheetSnapPoint } from './types';
2
3
  import { isAscendingOrder } from './utils';
3
4
 
@@ -107,136 +108,238 @@ export function computeSnapPoints({
107
108
  }));
108
109
  }
109
110
 
110
- function findClosestSnapPoint({
111
- snapPoints,
112
- currentY,
113
- }: {
114
- snapPoints: SheetSnapPoint[];
115
- currentY: number;
116
- }) {
111
+ function clamp(value: number, min: number, max: number) {
112
+ return Math.min(Math.max(value, min), max);
113
+ }
114
+
115
+ function sign(value: number) {
116
+ if (value > 0) return 1;
117
+ if (value < 0) return -1;
118
+ return 0;
119
+ }
120
+
121
+ function toAscendingYSnaps(snapPoints: SheetSnapPoint[]) {
122
+ return snapPoints.slice().sort((a, b) => a.snapValueY - b.snapValueY);
123
+ }
124
+
125
+ function findNearestSnapByY(snapPoints: SheetSnapPoint[], y: number) {
117
126
  return snapPoints.reduce((closest, snap) =>
118
- Math.abs(snap.snapValueY - currentY) <
119
- Math.abs(closest.snapValueY - currentY)
127
+ Math.abs(snap.snapValueY - y) < Math.abs(closest.snapValueY - y)
120
128
  ? snap
121
129
  : closest
122
130
  );
123
131
  }
124
132
 
125
- function findNextSnapPointInDirection({
126
- y,
127
- snapPoints,
128
- dragDirection,
129
- }: {
130
- y: number;
131
- snapPoints: SheetSnapPoint[];
132
- dragDirection: 'up' | 'down';
133
- }) {
134
- // NOTE: lower Y means higher in the sheet position!
135
- if (dragDirection === 'down') {
136
- /**
137
- * Example:
138
- *
139
- * [
140
- * { snapIndex: 0, snapValueY: 810 },
141
- * { snapIndex: 1, snapValueY: 640 },
142
- * { snapIndex: 2, snapValueY: 405 }, <-- next down
143
- * ------------- Y = 60 ------------
144
- * { snapIndex: 3, snapValueY: 50 },
145
- * { snapIndex: 4, snapValueY: 0 },
146
- * ]
147
- */
148
- return snapPoints
149
- .slice()
150
- .reverse()
151
- .find((s) => s.snapValueY > y);
152
- } else {
153
- /**
154
- * Example:
155
- * [
156
- * { snapIndex: 0, snapValueY: 810 },
157
- * { snapIndex: 1, snapValueY: 640 },
158
- * { snapIndex: 2, snapValueY: 405 },
159
- * ------------- Y = 60 ------------
160
- * { snapIndex: 3, snapValueY: 50 }, <-- next up
161
- * { snapIndex: 4, snapValueY: 0 },
162
- * ]
163
- */
164
- return snapPoints.find((s) => s.snapValueY < y);
133
+ function findNearestSnapAboveY(snapPoints: SheetSnapPoint[], y: number) {
134
+ const snaps = toAscendingYSnaps(snapPoints);
135
+
136
+ for (let i = snaps.length - 1; i >= 0; i--) {
137
+ if (snaps[i].snapValueY < y) {
138
+ return snaps[i];
139
+ }
165
140
  }
141
+
142
+ return null;
166
143
  }
167
- export function handleHighVelocityDrag({
168
- dragDirection,
169
- snapPoints,
144
+
145
+ function findNearestSnapBelowY(snapPoints: SheetSnapPoint[], y: number) {
146
+ const snaps = toAscendingYSnaps(snapPoints);
147
+
148
+ for (let i = 0; i < snaps.length; i++) {
149
+ if (snaps[i].snapValueY > y) {
150
+ return snaps[i];
151
+ }
152
+ }
153
+
154
+ return null;
155
+ }
156
+
157
+ function resolveDirection({
158
+ offsetY,
159
+ velocityY,
160
+ deltaY,
170
161
  }: {
171
- dragDirection: 'up' | 'down';
172
- snapPoints: SheetSnapPoint[];
162
+ offsetY: number;
163
+ velocityY: number;
164
+ deltaY: number;
173
165
  }) {
174
- // Go to either the last or the first snap point depending on the direction
175
- const bottomSnapPoint = snapPoints[0];
176
- const topSnapPoint = snapPoints[snapPoints.length - 1];
166
+ if (Math.abs(velocityY) > 50) return sign(velocityY);
177
167
 
178
- if (dragDirection === 'down') {
179
- return {
180
- yTo: bottomSnapPoint.snapValueY,
181
- snapIndex: bottomSnapPoint.snapIndex,
182
- };
183
- }
184
- return {
185
- yTo: topSnapPoint.snapValueY,
186
- snapIndex: topSnapPoint.snapIndex,
187
- };
168
+ const offsetDirection = sign(offsetY);
169
+ if (offsetDirection !== 0) return offsetDirection;
170
+
171
+ return sign(deltaY);
188
172
  }
189
173
 
190
- export function handleLowVelocityDrag({
191
- currentSnapPoint,
192
- currentY,
193
- dragDirection,
174
+ /**
175
+ * Classifies the final sheet position when a drag gesture ends.
176
+ *
177
+ * Decision flow (high level):
178
+ * 1) Predict momentum endpoint from current position and velocity.
179
+ * 2) Resolve drag direction (prefer velocity, fallback to offset/delta).
180
+ * 3) Detect flick tiers:
181
+ * - strong flick -> jump fully open/closed
182
+ * - flick -> move to next snap in flick direction
183
+ * 4) For slower drags:
184
+ * - tiny drag or near-snap stickiness -> snap back to nearest snap
185
+ * - otherwise, use progress between surrounding snaps and commit threshold
186
+ * 5) Apply velocity-assisted bias to nearest predicted snap for natural feel.
187
+ * 6) Fallback to nearest snap.
188
+ *
189
+ * Special case with no snap points:
190
+ * - flick: open/close by direction
191
+ * - otherwise: open/close by predicted position vs close threshold midpoint
192
+ */
193
+ export function classifyDragEnd({
194
+ y,
195
+ info,
196
+ sheetHeight,
194
197
  snapPoints,
195
- velocity,
198
+ dragVelocityThreshold,
199
+ dragCloseThreshold,
196
200
  }: {
197
- currentSnapPoint: SheetSnapPoint;
198
- currentY: number;
199
- dragDirection: 'up' | 'down';
201
+ y: number;
202
+ info: PanInfo;
203
+ sheetHeight: number;
200
204
  snapPoints: SheetSnapPoint[];
201
- velocity: number;
205
+ dragVelocityThreshold: number;
206
+ dragCloseThreshold: number;
202
207
  }) {
203
- const closestSnapRelativeToCurrentY = findClosestSnapPoint({
204
- snapPoints,
205
- currentY,
206
- });
207
-
208
208
  /**
209
- * If velocity is very low the user has stopped the sheet to a specific
210
- * position and we should snap to the closest snap point as there is no
211
- * "momentum" that would push the sheet further to the given direction
209
+ * Thresholds and configuration for drag classification.
210
+ * Tweak these to adjust the feel of the sheet.
212
211
  */
213
- if (Math.abs(velocity) < 20) {
212
+ // Velocity above this is considered a flick gesture.
213
+ const flickVelocity = dragVelocityThreshold;
214
+ // Velocity above this is treated as a strong flick and jumps to full open/closed.
215
+ const strongFlickVelocity = Math.max(2000, flickVelocity);
216
+ // In slow drags, crossing this portion of the current snap region commits to the next snap.
217
+ const distanceCommitRatio = 0.35;
218
+ // Time horizon (in seconds) used to estimate momentum-based end position.
219
+ const predictionTime = 0.2;
220
+ // Very small drags below this distance snap back instead of changing snap state.
221
+ const minDragDistance = 20;
222
+ // If drag ends within this many pixels from a snap, stick to that snap to reduce jitter.
223
+ const snapStickiness = 24;
224
+
225
+ const minY = 0;
226
+ const maxY = sheetHeight;
227
+ const offsetY = info.offset.y;
228
+ const deltaY = info.delta.y;
229
+ const velocityY = info.velocity.y;
230
+
231
+ // Step 1 — Compute predicted end position from momentum.
232
+ const predictedY = clamp(y + velocityY * predictionTime, minY, maxY);
233
+ const absVelocity = Math.abs(velocityY);
234
+
235
+ // Step 2 + 3 — Flick detection and gesture direction resolution.
236
+ const direction = resolveDirection({ offsetY, velocityY, deltaY });
237
+
238
+ const isStrongFlick = absVelocity > strongFlickVelocity;
239
+ const isFlick = absVelocity > flickVelocity;
240
+
241
+ // Step 4 — No snap points: decide between open/close only.
242
+ if (snapPoints.length === 0) {
243
+ if (isFlick) {
244
+ return {
245
+ yTo: direction < 0 ? minY : maxY,
246
+ snapIndex: undefined,
247
+ };
248
+ }
249
+
250
+ const midpoint = minY + (maxY - minY) * dragCloseThreshold;
251
+
214
252
  return {
215
- yTo: closestSnapRelativeToCurrentY.snapValueY,
216
- snapIndex: closestSnapRelativeToCurrentY.snapIndex,
253
+ yTo: predictedY < midpoint ? minY : maxY,
254
+ snapIndex: undefined,
217
255
  };
218
256
  }
219
257
 
220
- /**
221
- * If the dragging has a bit more velocity, we instead want to go to
222
- * the next snap point in the given direction if it exists
223
- */
224
- const nextSnapInDirectionRelativeToCurrentY = findNextSnapPointInDirection({
225
- y: currentY,
226
- snapPoints,
227
- dragDirection,
228
- });
258
+ const nearestSnap = findNearestSnapByY(snapPoints, y);
259
+
260
+ // Slow-drag guard — tiny movement: snap back to nearest.
261
+ if (!isFlick && Math.abs(offsetY) < minDragDistance) {
262
+ return {
263
+ yTo: nearestSnap.snapValueY,
264
+ snapIndex: nearestSnap.snapIndex,
265
+ };
266
+ }
229
267
 
230
- if (nextSnapInDirectionRelativeToCurrentY) {
268
+ // Hysteresis (stickiness) — avoid jitter if already close to a snap.
269
+ if (!isFlick && Math.abs(y - nearestSnap.snapValueY) < snapStickiness) {
231
270
  return {
232
- yTo: nextSnapInDirectionRelativeToCurrentY.snapValueY,
233
- snapIndex: nextSnapInDirectionRelativeToCurrentY.snapIndex,
271
+ yTo: nearestSnap.snapValueY,
272
+ snapIndex: nearestSnap.snapIndex,
234
273
  };
235
274
  }
236
275
 
237
- // No snap point down, stay at current
276
+ // Step 5 — Strong flick overrides everything and goes fully open/closed.
277
+ if (isStrongFlick) {
278
+ const target = direction < 0 ? minY : maxY;
279
+ const targetSnap = findNearestSnapByY(snapPoints, target);
280
+
281
+ return {
282
+ yTo: target,
283
+ snapIndex: targetSnap.snapIndex,
284
+ };
285
+ }
286
+
287
+ // Step 5 — Medium flick moves to the next snap in flick direction.
288
+ if (isFlick) {
289
+ const nextSnap =
290
+ direction < 0
291
+ ? findNearestSnapAboveY(snapPoints, y)
292
+ : findNearestSnapBelowY(snapPoints, y);
293
+
294
+ const fallbackY = direction < 0 ? minY : maxY;
295
+ const fallbackSnap = findNearestSnapByY(snapPoints, fallbackY);
296
+ const targetSnap = nextSnap ?? fallbackSnap;
297
+
298
+ return {
299
+ yTo: targetSnap.snapValueY,
300
+ snapIndex: targetSnap.snapIndex,
301
+ };
302
+ }
303
+
304
+ const firstSnap = toAscendingYSnaps(snapPoints)[0];
305
+ const lastSnap = toAscendingYSnaps(snapPoints)[snapPoints.length - 1];
306
+ const prevSnap = findNearestSnapAboveY(snapPoints, y) ?? firstSnap;
307
+ const nextSnap = findNearestSnapBelowY(snapPoints, y) ?? lastSnap;
308
+
309
+ if (prevSnap.snapIndex === nextSnap.snapIndex) {
310
+ return {
311
+ yTo: prevSnap.snapValueY,
312
+ snapIndex: prevSnap.snapIndex,
313
+ };
314
+ }
315
+
316
+ const range = nextSnap.snapValueY - prevSnap.snapValueY;
317
+ const progress = range > 0 ? (y - prevSnap.snapValueY) / range : 0;
318
+
319
+ // Step 6 — Slow drag commit based on progress within the current snap region.
320
+ let selectedSnap = nearestSnap;
321
+
322
+ if (direction > 0) {
323
+ selectedSnap = progress > distanceCommitRatio ? nextSnap : prevSnap;
324
+ } else if (direction < 0) {
325
+ selectedSnap = progress < 1 - distanceCommitRatio ? prevSnap : nextSnap;
326
+ }
327
+
328
+ // Step 7 — Velocity-assisted bias toward predicted-position snap.
329
+ if (absVelocity > 200) {
330
+ const predictedSnap = findNearestSnapByY(snapPoints, predictedY);
331
+
332
+ if (
333
+ Math.abs(predictedSnap.snapValueY - y) <
334
+ Math.abs(selectedSnap.snapValueY - y)
335
+ ) {
336
+ selectedSnap = predictedSnap;
337
+ }
338
+ }
339
+
340
+ // Step 8 — Final fallback.
238
341
  return {
239
- yTo: currentSnapPoint.snapValueY,
240
- snapIndex: currentSnapPoint.snapIndex,
342
+ yTo: selectedSnap.snapValueY,
343
+ snapIndex: selectedSnap.snapIndex,
241
344
  };
242
345
  }