react-modal-sheet 5.4.0 → 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/src/sheet.tsx CHANGED
@@ -9,7 +9,6 @@ import {
9
9
  } from 'motion/react';
10
10
  import React, {
11
11
  forwardRef,
12
- useEffect,
13
12
  useImperativeHandle,
14
13
  useRef,
15
14
  useState,
@@ -26,20 +25,15 @@ import {
26
25
  } from './constants';
27
26
  import { SheetContext } from './context';
28
27
  import { useDimensions } from './hooks/use-dimensions';
29
- import { useFocusedInput } from './hooks/use-focused-input';
28
+ import { useKeyboardAvoidance } from './hooks/use-keyboard-avoidance';
30
29
  import { useModalEffect } from './hooks/use-modal-effect';
31
30
  import { usePreventScroll } from './hooks/use-prevent-scroll';
32
31
  import { useSheetState } from './hooks/use-sheet-state';
33
32
  import { useStableCallback } from './hooks/use-stable-callback';
34
- import { useVirtualKeyboard } from './hooks/use-virtual-keyboard';
35
- import {
36
- computeSnapPoints,
37
- handleHighVelocityDrag,
38
- handleLowVelocityDrag,
39
- } from './snap';
33
+ import { classifyDragEnd, computeSnapPoints } from './snap';
40
34
  import { styles } from './styles';
41
35
  import { type SheetContextType, type SheetProps } from './types';
42
- import { applyStyles, waitForElement } from './utils';
36
+ import { applyStyles, waitForElement, willOpenKeyboard } from './utils';
43
37
 
44
38
  export const Sheet = forwardRef<any, SheetProps>(
45
39
  (
@@ -98,14 +92,6 @@ export const Sheet = forwardRef<any, SheetProps>(
98
92
  ...(reduceMotion ? REDUCED_MOTION_TWEEN_CONFIG : tweenConfig),
99
93
  };
100
94
 
101
- const keyboard = useVirtualKeyboard({
102
- isEnabled: isOpen && avoidKeyboard,
103
- containerRef: sheetRef,
104
- });
105
-
106
- // Disable drag if the keyboard is open to avoid weird behavior
107
- const disableDrag = keyboard.isKeyboardOpen || disableDragProp;
108
-
109
95
  // +2 for tolerance in case the animated value is slightly off
110
96
  const zIndex = useTransform(y, (val) =>
111
97
  val + 2 >= closedY ? -1 : (style?.zIndex ?? 9999)
@@ -158,22 +144,70 @@ export const Sheet = forwardRef<any, SheetProps>(
158
144
  });
159
145
  });
160
146
 
147
+ const keyboard = useKeyboardAvoidance({
148
+ isEnabled: isOpen && avoidKeyboard,
149
+ containerRef: sheetRef,
150
+ onWillOpenKeyboard: async () => {
151
+ const lastSnapPoint = snapPoints[snapPoints.length - 1];
152
+
153
+ /**
154
+ * If there are snap points and the sheet is not already at the last snap point,
155
+ * move it there to make sure the focused input is not covered by the keyboard
156
+ */
157
+ if (lastSnapPoint && lastSnapPoint.snapIndex !== currentSnap) {
158
+ await animate(y, lastSnapPoint.snapValueY, animationOptions);
159
+ updateSnap(lastSnapPoint.snapIndex);
160
+ }
161
+ },
162
+ onDidOpenKeyboard: (focusedElement) => {
163
+ const sheetElement = sheetRef.current;
164
+ if (!sheetElement) return;
165
+
166
+ const inputRect = focusedElement.getBoundingClientRect();
167
+ const containerRect = sheetElement.getBoundingClientRect();
168
+ const scroller = sheetElement.querySelector(
169
+ '.react-modal-sheet-content-scroller'
170
+ ) as HTMLElement;
171
+
172
+ const scrollTarget = Math.max(
173
+ inputRect.top -
174
+ containerRect.top +
175
+ scroller.scrollTop -
176
+ inputRect.height,
177
+ 0
178
+ );
179
+
180
+ requestAnimationFrame(() => {
181
+ scroller.scrollTo({ top: scrollTarget, behavior: 'smooth' });
182
+ });
183
+ },
184
+ });
185
+
186
+ // Disable drag if the keyboard is open to avoid weird behavior
187
+ const disableDrag = keyboard.isKeyboardOpen || disableDragProp;
188
+
161
189
  const blurActiveInput = useStableCallback(() => {
162
- // Find focused input inside the sheet and blur it when dragging starts
163
- // to prevent a weird ghost caret "bug" on mobile
190
+ /**
191
+ * Find focused input inside the sheet and blur it when dragging starts
192
+ * to prevent a weird ghost caret "bug" on mobile
193
+ */
164
194
  const focusedElement = document.activeElement as HTMLElement | null;
165
- if (!focusedElement || !sheetRef.current) return;
166
-
167
- const isInput =
168
- focusedElement.tagName === 'INPUT' ||
169
- focusedElement.tagName === 'TEXTAREA';
170
195
 
171
196
  // Only blur the focused element if it's inside the sheet
172
- if (isInput && sheetRef.current.contains(focusedElement)) {
197
+ if (
198
+ focusedElement &&
199
+ willOpenKeyboard(focusedElement) &&
200
+ sheetRef.current?.contains(focusedElement)
201
+ ) {
173
202
  focusedElement.blur();
174
203
  }
175
204
  });
176
205
 
206
+ const onDragStart = useStableCallback<DragHandler>((event, info) => {
207
+ blurActiveInput();
208
+ onDragStartProp?.(event, info);
209
+ });
210
+
177
211
  const onDrag = useStableCallback<DragHandler>((event, info) => {
178
212
  onDragProp?.(event, info);
179
213
 
@@ -188,74 +222,37 @@ export const Sheet = forwardRef<any, SheetProps>(
188
222
  y.set(Math.max(currentY + info.delta.y, 0));
189
223
  });
190
224
 
191
- const onDragStart = useStableCallback<DragHandler>((event, info) => {
192
- blurActiveInput();
193
- onDragStartProp?.(event, info);
194
- });
195
-
196
225
  const onDragEnd = useStableCallback<DragHandler>((event, info) => {
197
226
  blurActiveInput();
198
227
  onDragEndProp?.(event, info);
199
228
 
200
229
  const currentY = y.get();
201
230
 
202
- let yTo = 0;
203
-
204
- const currentSnapPoint =
205
- currentSnap !== undefined ? getSnapPoint(currentSnap) : null;
206
-
207
- if (currentSnapPoint) {
208
- const dragOffsetDirection = info.offset.y > 0 ? 'down' : 'up';
209
- const dragVelocityDirection = info.velocity.y > 0 ? 'down' : 'up';
210
- const isHighVelocity =
211
- Math.abs(info.velocity.y) > dragVelocityThreshold;
231
+ const result = classifyDragEnd({
232
+ y: currentY,
233
+ info,
234
+ sheetHeight,
235
+ dragCloseThreshold,
236
+ snapPoints,
237
+ dragVelocityThreshold,
238
+ });
212
239
 
213
- let result: { yTo: number; snapIndex: number | undefined };
240
+ let yTo = result.yTo;
214
241
 
215
- if (isHighVelocity) {
216
- result = handleHighVelocityDrag({
217
- snapPoints,
218
- dragDirection: dragVelocityDirection,
219
- });
220
- } else {
221
- result = handleLowVelocityDrag({
222
- currentSnapPoint,
223
- currentY,
224
- dragDirection: dragOffsetDirection,
225
- snapPoints,
226
- velocity: info.velocity.y,
227
- });
228
- }
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);
229
246
 
230
- yTo = result.yTo;
231
-
232
- // If disableDismiss is true, prevent closing via gesture
233
- if (disableDismiss && yTo + 1 >= sheetHeight) {
234
- // Use the bottom-most open snap point
235
- const bottomSnapPoint = snapPoints.find((s) => s.snapValue > 0);
236
-
237
- if (bottomSnapPoint) {
238
- yTo = bottomSnapPoint.snapValueY;
239
- updateSnap(bottomSnapPoint.snapIndex);
240
- } else {
241
- // If no open snap points available, stay at current position
242
- yTo = currentY;
243
- }
244
- } else if (result.snapIndex !== undefined) {
245
- updateSnap(result.snapIndex);
246
- }
247
- } else if (
248
- info.velocity.y > dragVelocityThreshold ||
249
- currentY > sheetHeight * dragCloseThreshold
250
- ) {
251
- // Close the sheet if dragged past the threshold or if the velocity is high enough
252
- // But only if disableDismiss is false
253
- if (disableDismiss) {
254
- // If disableDismiss, snap back to the open position
255
- yTo = 0;
247
+ if (bottomSnapPoint) {
248
+ yTo = bottomSnapPoint.snapValueY;
249
+ updateSnap(bottomSnapPoint.snapIndex);
256
250
  } else {
257
- yTo = closedY;
251
+ // If no open snap points available, stay at current position
252
+ yTo = currentY;
258
253
  }
254
+ } else if (result.snapIndex !== undefined) {
255
+ updateSnap(result.snapIndex);
259
256
  }
260
257
 
261
258
  // Update the spring value so that the sheet is animated to the snap point
@@ -295,67 +292,6 @@ export const Sheet = forwardRef<any, SheetProps>(
295
292
  isDisabled: disableScrollLocking || !isOpen,
296
293
  });
297
294
 
298
- const focusedInput = useFocusedInput({
299
- isEnabled: isOpen && avoidKeyboard,
300
- sheetRef,
301
- });
302
-
303
- /**
304
- * Keyboard avoidance.
305
- *
306
- * Move the sheet up when the keyboard opens if the sheet has snap points,
307
- * and then scroll the focused input into view to make sure it's not covered
308
- * by the keyboard.
309
- */
310
- useEffect(() => {
311
- if (!isOpen || !avoidKeyboard) {
312
- return;
313
- }
314
-
315
- async function adjustSheetForKeyboard() {
316
- const sheetElement = sheetRef.current;
317
-
318
- if (!sheetElement || !focusedInput || !keyboard.isKeyboardOpen) {
319
- return;
320
- }
321
-
322
- /**
323
- * If we have snap points and the sheet is currently snapped to a point
324
- * that is being covered by the keyboard, we want to move the sheet up
325
- * to reveal it.
326
- */
327
- const lastSnapPoint = snapPoints[snapPoints.length - 1];
328
-
329
- if (lastSnapPoint) {
330
- await animate(y, lastSnapPoint.snapValueY, animationOptions);
331
- updateSnap(lastSnapPoint.snapIndex);
332
- }
333
-
334
- // Then scroll the focused input into view if it's covered by the keyboard
335
- requestAnimationFrame(() => {
336
- const inputRect = focusedInput.getBoundingClientRect();
337
- const sheetRect = sheetElement.getBoundingClientRect();
338
- const scroller = sheetElement.querySelector(
339
- '.react-modal-sheet-content-scroller'
340
- ) as HTMLElement;
341
-
342
- const scrollTarget = Math.max(
343
- inputRect.top -
344
- sheetRect.top +
345
- scroller.scrollTop -
346
- inputRect.height,
347
- 0
348
- );
349
-
350
- requestAnimationFrame(() => {
351
- scroller.scrollTo({ top: scrollTarget, behavior: 'smooth' });
352
- });
353
- });
354
- }
355
-
356
- adjustSheetForKeyboard();
357
- }, [isOpen, avoidKeyboard, keyboard.isKeyboardOpen, focusedInput]);
358
-
359
295
  const state = useSheetState({
360
296
  isOpen,
361
297
  onOpen: async () => {
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
  }
package/src/types.tsx CHANGED
@@ -7,7 +7,6 @@ import {
7
7
  } from 'motion/react';
8
8
  import {
9
9
  type ComponentPropsWithoutRef,
10
- type CSSProperties,
11
10
  type ForwardRefExoticComponent,
12
11
  type FunctionComponent,
13
12
  type HTMLAttributes,
@@ -108,7 +107,7 @@ export interface SheetContextType {
108
107
  currentSnap?: number;
109
108
  detent: SheetDetent;
110
109
  disableDrag: boolean;
111
- dragProps?: SheetDragProps;
110
+ dragProps: SheetDragProps;
112
111
  indicatorRotation: MotionValue<number>;
113
112
  avoidKeyboard: boolean;
114
113
  prefersReducedMotion: boolean;
package/src/utils.ts CHANGED
@@ -88,3 +88,29 @@ export function waitForElement(
88
88
  }, interval);
89
89
  });
90
90
  }
91
+
92
+ // HTML input types that do not cause the software keyboard to appear.
93
+ const nonTextInputTypes = new Set([
94
+ 'checkbox',
95
+ 'radio',
96
+ 'range',
97
+ 'color',
98
+ 'file',
99
+ 'image',
100
+ 'button',
101
+ 'submit',
102
+ 'reset',
103
+ ]);
104
+
105
+ export function willOpenKeyboard(target: Element) {
106
+ return (
107
+ (target instanceof HTMLInputElement &&
108
+ !nonTextInputTypes.has(target.type)) ||
109
+ target instanceof HTMLTextAreaElement ||
110
+ (target instanceof HTMLElement && target.isContentEditable)
111
+ );
112
+ }
113
+
114
+ export function isHTTPS() {
115
+ return typeof window !== 'undefined' && window.isSecureContext;
116
+ }