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/dist/index.js +140 -88
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +140 -88
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/constants.ts +1 -1
- package/src/sheet.tsx +21 -57
- package/src/snap.ts +205 -102
package/package.json
CHANGED
package/src/constants.ts
CHANGED
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
240
|
+
let yTo = result.yTo;
|
|
247
241
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
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
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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 -
|
|
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
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
168
|
-
|
|
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
|
-
|
|
172
|
-
|
|
162
|
+
offsetY: number;
|
|
163
|
+
velocityY: number;
|
|
164
|
+
deltaY: number;
|
|
173
165
|
}) {
|
|
174
|
-
|
|
175
|
-
const bottomSnapPoint = snapPoints[0];
|
|
176
|
-
const topSnapPoint = snapPoints[snapPoints.length - 1];
|
|
166
|
+
if (Math.abs(velocityY) > 50) return sign(velocityY);
|
|
177
167
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
198
|
+
dragVelocityThreshold,
|
|
199
|
+
dragCloseThreshold,
|
|
196
200
|
}: {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
201
|
+
y: number;
|
|
202
|
+
info: PanInfo;
|
|
203
|
+
sheetHeight: number;
|
|
200
204
|
snapPoints: SheetSnapPoint[];
|
|
201
|
-
|
|
205
|
+
dragVelocityThreshold: number;
|
|
206
|
+
dragCloseThreshold: number;
|
|
202
207
|
}) {
|
|
203
|
-
const closestSnapRelativeToCurrentY = findClosestSnapPoint({
|
|
204
|
-
snapPoints,
|
|
205
|
-
currentY,
|
|
206
|
-
});
|
|
207
|
-
|
|
208
208
|
/**
|
|
209
|
-
*
|
|
210
|
-
*
|
|
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
|
-
|
|
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:
|
|
216
|
-
snapIndex:
|
|
253
|
+
yTo: predictedY < midpoint ? minY : maxY,
|
|
254
|
+
snapIndex: undefined,
|
|
217
255
|
};
|
|
218
256
|
}
|
|
219
257
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
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:
|
|
233
|
-
snapIndex:
|
|
271
|
+
yTo: nearestSnap.snapValueY,
|
|
272
|
+
snapIndex: nearestSnap.snapIndex,
|
|
234
273
|
};
|
|
235
274
|
}
|
|
236
275
|
|
|
237
|
-
//
|
|
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:
|
|
240
|
-
snapIndex:
|
|
342
|
+
yTo: selectedSnap.snapValueY,
|
|
343
|
+
snapIndex: selectedSnap.snapIndex,
|
|
241
344
|
};
|
|
242
345
|
}
|