@tamagui/sheet 2.0.0 → 2.1.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.
Files changed (83) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/cjs/SheetImplementationCustom.cjs +73 -24
  3. package/dist/cjs/SheetImplementationCustom.native.js +83 -28
  4. package/dist/cjs/SheetImplementationCustom.native.js.map +1 -1
  5. package/dist/cjs/SheetScrollView.cjs +18 -4
  6. package/dist/cjs/SheetScrollView.native.js +18 -4
  7. package/dist/cjs/SheetScrollView.native.js.map +1 -1
  8. package/dist/cjs/nativeSheet.cjs +3 -0
  9. package/dist/cjs/nativeSheet.native.js +3 -0
  10. package/dist/cjs/nativeSheet.native.js.map +1 -1
  11. package/dist/cjs/useKeyboardControllerSheet.cjs +62 -6
  12. package/dist/cjs/useSheetProviderProps.native.js.map +1 -1
  13. package/dist/cjs/useSheetScrollViewGestures.cjs +6 -1
  14. package/dist/cjs/webViewport.cjs +50 -0
  15. package/dist/cjs/webViewport.native.js +54 -0
  16. package/dist/cjs/webViewport.native.js.map +1 -0
  17. package/dist/esm/SheetImplementationCustom.mjs +73 -24
  18. package/dist/esm/SheetImplementationCustom.mjs.map +1 -1
  19. package/dist/esm/SheetImplementationCustom.native.js +83 -28
  20. package/dist/esm/SheetImplementationCustom.native.js.map +1 -1
  21. package/dist/esm/SheetScrollView.mjs +19 -5
  22. package/dist/esm/SheetScrollView.mjs.map +1 -1
  23. package/dist/esm/SheetScrollView.native.js +19 -5
  24. package/dist/esm/SheetScrollView.native.js.map +1 -1
  25. package/dist/esm/nativeSheet.mjs +3 -0
  26. package/dist/esm/nativeSheet.mjs.map +1 -1
  27. package/dist/esm/nativeSheet.native.js +3 -0
  28. package/dist/esm/nativeSheet.native.js.map +1 -1
  29. package/dist/esm/useKeyboardControllerSheet.mjs +63 -7
  30. package/dist/esm/useKeyboardControllerSheet.mjs.map +1 -1
  31. package/dist/esm/useSheetProviderProps.mjs.map +1 -1
  32. package/dist/esm/useSheetProviderProps.native.js.map +1 -1
  33. package/dist/esm/useSheetScrollViewGestures.mjs +6 -1
  34. package/dist/esm/useSheetScrollViewGestures.mjs.map +1 -1
  35. package/dist/esm/webViewport.mjs +22 -0
  36. package/dist/esm/webViewport.mjs.map +1 -0
  37. package/dist/esm/webViewport.native.js +23 -0
  38. package/dist/esm/webViewport.native.js.map +1 -0
  39. package/dist/jsx/SheetImplementationCustom.mjs +73 -24
  40. package/dist/jsx/SheetImplementationCustom.mjs.map +1 -1
  41. package/dist/jsx/SheetImplementationCustom.native.js +83 -28
  42. package/dist/jsx/SheetImplementationCustom.native.js.map +1 -1
  43. package/dist/jsx/SheetScrollView.mjs +19 -5
  44. package/dist/jsx/SheetScrollView.mjs.map +1 -1
  45. package/dist/jsx/SheetScrollView.native.js +18 -4
  46. package/dist/jsx/SheetScrollView.native.js.map +1 -1
  47. package/dist/jsx/nativeSheet.mjs +3 -0
  48. package/dist/jsx/nativeSheet.mjs.map +1 -1
  49. package/dist/jsx/nativeSheet.native.js +3 -0
  50. package/dist/jsx/nativeSheet.native.js.map +1 -1
  51. package/dist/jsx/useKeyboardControllerSheet.mjs +63 -7
  52. package/dist/jsx/useKeyboardControllerSheet.mjs.map +1 -1
  53. package/dist/jsx/useSheetProviderProps.mjs.map +1 -1
  54. package/dist/jsx/useSheetProviderProps.native.js.map +1 -1
  55. package/dist/jsx/useSheetScrollViewGestures.mjs +6 -1
  56. package/dist/jsx/useSheetScrollViewGestures.mjs.map +1 -1
  57. package/dist/jsx/webViewport.mjs +22 -0
  58. package/dist/jsx/webViewport.mjs.map +1 -0
  59. package/dist/jsx/webViewport.native.js +54 -0
  60. package/dist/jsx/webViewport.native.js.map +1 -0
  61. package/package.json +20 -20
  62. package/src/SheetImplementationCustom.tsx +300 -56
  63. package/src/SheetScrollView.tsx +48 -9
  64. package/src/nativeSheet.tsx +3 -0
  65. package/src/types.tsx +11 -1
  66. package/src/useKeyboardControllerSheet.ts +106 -10
  67. package/src/useSheetProviderProps.tsx +16 -0
  68. package/src/useSheetScrollViewGestures.ts +23 -2
  69. package/src/webViewport.ts +52 -0
  70. package/types/SheetContext.d.ts +3 -0
  71. package/types/SheetContext.d.ts.map +1 -1
  72. package/types/SheetImplementationCustom.d.ts.map +1 -1
  73. package/types/SheetScrollView.d.ts.map +1 -1
  74. package/types/nativeSheet.d.ts.map +1 -1
  75. package/types/types.d.ts +4 -1
  76. package/types/types.d.ts.map +1 -1
  77. package/types/useKeyboardControllerSheet.d.ts +14 -3
  78. package/types/useKeyboardControllerSheet.d.ts.map +1 -1
  79. package/types/useSheetProviderProps.d.ts +3 -0
  80. package/types/useSheetProviderProps.d.ts.map +1 -1
  81. package/types/useSheetScrollViewGestures.d.ts.map +1 -1
  82. package/types/webViewport.d.ts +30 -0
  83. package/types/webViewport.d.ts.map +1 -0
@@ -26,6 +26,11 @@ import { getGestureHandlerState } from './gestureState'
26
26
  import { GestureSheetProvider } from './GestureSheetContext'
27
27
  import { resisted } from './helpers'
28
28
  import { getKeyboardOccludedHeight } from './keyboardAvoidance'
29
+ import {
30
+ getStableLayoutViewportHeight,
31
+ getWebKeyboardHeight,
32
+ MIN_KEYBOARD_HEIGHT,
33
+ } from './webViewport'
29
34
  import { SheetProvider } from './SheetContext'
30
35
  import type { SheetProps, SnapPointsMode } from './types'
31
36
  import { useGestureHandlerPan } from './useGestureHandlerPan'
@@ -57,6 +62,19 @@ let sheetHiddenStyleSheet: HTMLStyleElement | null = null
57
62
  // on web we are always relative to window, on to screen
58
63
  const relativeDimensionTo = isWeb ? 'window' : 'screen'
59
64
 
65
+ // height of the viewport the sheet positions against. on web this MUST be the
66
+ // stable layout viewport and NOT Dimensions.get('window') — react-native-web's
67
+ // Dimensions tracks visualViewport, which shrinks by the soft keyboard. capping
68
+ // frameSize / maxContentSize against that shrinking value corrupts the fit-mode
69
+ // math (translateY = screenSize - frameSize), detaching the sheet's bottom from
70
+ // the screen edge when the keyboard opens. NOTE: window.innerHeight is NOT
71
+ // stable on real iOS Safari (it shrinks with the keyboard too), so we use the
72
+ // self-correcting baseline from webViewport instead. see getStableLayoutViewportHeight.
73
+ function getStableViewportHeight(): number {
74
+ if (isWeb && typeof window !== 'undefined') return getStableLayoutViewportHeight()
75
+ return Dimensions.get(relativeDimensionTo).height
76
+ }
77
+
60
78
  export const SheetImplementationCustom = React.forwardRef<View, SheetProps>(
61
79
  function SheetImplementationCustom(props, forwardedRef) {
62
80
  const parentSheet = React.useContext(ParentSheetContext)
@@ -142,6 +160,19 @@ export const SheetImplementationCustom = React.forwardRef<View, SheetProps>(
142
160
  setIsShowingInnerSheet(hasChild)
143
161
  }, [])
144
162
 
163
+ // keyboard state tracking — just tracks height/visibility, no position animation.
164
+ // Position animation is handled via keyboard-adjusted positions below,
165
+ // matching the react-native-actions-sheet pattern.
166
+ const {
167
+ keyboardHeight,
168
+ isKeyboardVisible,
169
+ dismissKeyboard,
170
+ pauseKeyboardHandler,
171
+ flushPendingHide,
172
+ } = useKeyboardControllerSheet({
173
+ enabled: Boolean(moveOnKeyboardChange),
174
+ })
175
+
145
176
  // FIX: Store stable frameSize to prevent recalculation during exit animation
146
177
  const stableFrameSize = React.useRef(frameSize)
147
178
 
@@ -152,31 +183,84 @@ export const SheetImplementationCustom = React.forwardRef<View, SheetProps>(
152
183
  }
153
184
  }, [open, frameSize])
154
185
 
186
+ // WEB keyboard anchor freeze. on real iOS Safari opening the keyboard shrinks
187
+ // the visual viewport AND innerHeight AND the measured layout, which would
188
+ // re-derive screenSize/frameSize smaller, recompute the fit positions, and
189
+ // fly the frame up then back down ("goes back down after the keyboard opens").
190
+ // so we snapshot the pre-keyboard geometry — captured every render while the
191
+ // keyboard is CLOSED, which dodges the open-transition race where a shrunk
192
+ // onLayout lands before isKeyboardVisible flips — and use it for the anchor
193
+ // math while the keyboard is open. the sheet then stays put; only the scroll
194
+ // content shifts to clear the keyboard (keyboardOccludedHeight padding below
195
+ // + SheetScrollView's frozen height).
196
+ // this sheet is the kind the web keyboard anchor freeze is designed for — a
197
+ // fit-mode web sheet opted into keyboard handling. percent/constant sheets
198
+ // keep the live geometry (their height isn't pinned, so a frozen anchor
199
+ // would mismatch).
200
+ const isWebKbSheet = isWeb && hasFit && moveOnKeyboardChange
201
+
202
+ // whether we ever captured geometry while the keyboard was CLOSED. that's the
203
+ // authoritative baseline; until we have it, the autofocus-on-open seed below
204
+ // reconstructs one from keyboard-up measurements.
205
+ const hasCleanKbBaseline = React.useRef(false)
206
+ // the autofocus-on-open seed has settled on a stable frame height (the
207
+ // unclipped content stopped growing). until then we keep the seed phase open.
208
+ const seedSettled = React.useRef(false)
209
+ const stableKbGeom = React.useRef({ frame: 0, screen: 0 })
210
+ if ((!isWeb || !isKeyboardVisible) && frameSize > 0 && screenSize > 0) {
211
+ // keyboard-free render — the authoritative baseline. mutate in place: the
212
+ // ref is only ever read field-by-field, never identity-compared, so this
213
+ // avoids a per-render allocation.
214
+ stableKbGeom.current.frame = frameSize
215
+ stableKbGeom.current.screen = screenSize
216
+ hasCleanKbBaseline.current = true
217
+ } else if (
218
+ isWebKbSheet &&
219
+ isKeyboardVisible &&
220
+ !hasCleanKbBaseline.current &&
221
+ screenSize > 0
222
+ ) {
223
+ // AUTOFOCUS-ON-OPEN seed. when the input autofocuses as the sheet animates
224
+ // in, the keyboard rises BEFORE any keyboard-free layout lands, so the
225
+ // branch above never runs (frameSize/screenSize were 0 the whole time the
226
+ // keyboard was up) and freezeForKb stays false, collapsing the sheet to the
227
+ // shrunk consumer maxHeight. instead, reconstruct the baseline from the
228
+ // keyboard-up render: screen = the stable layout viewport
229
+ // (keyboard-independent). the frame is grown by the seeding layout path
230
+ // below: while seeding, the tail padding is suppressed and the scroll view
231
+ // is unclipped (via keyboardStableFrameHeight = stable screen), so the frame
232
+ // converges on its pure pre-keyboard content height across a couple layout
233
+ // passes. a real keyboard-free render later replaces it with the exact value
234
+ // (branch above).
235
+ stableKbGeom.current.screen = Math.max(stableKbGeom.current.screen, screenSize)
236
+ }
237
+ // are we still reconstructing the baseline from a keyboard-up render? true
238
+ // until either a clean baseline lands or the seeded frame settles.
239
+ const seedingKbBaseline =
240
+ isWebKbSheet &&
241
+ isKeyboardVisible &&
242
+ !hasCleanKbBaseline.current &&
243
+ !seedSettled.current
244
+ const freezeForKb =
245
+ isWebKbSheet && isKeyboardVisible && stableKbGeom.current.frame > 0
246
+ const effScreenSize = freezeForKb ? stableKbGeom.current.screen : screenSize
247
+
155
248
  // use stableFrameSize when closing to prevent position jumps during exit animation
156
249
  // but when opening, always use the current frameSize so positions update correctly
157
- const effectiveFrameSize = open ? frameSize : stableFrameSize.current || frameSize
250
+ const effectiveFrameSize = freezeForKb
251
+ ? stableKbGeom.current.frame
252
+ : open
253
+ ? frameSize
254
+ : stableFrameSize.current || frameSize
158
255
 
159
256
  const positions = React.useMemo(
160
257
  () =>
161
258
  snapPoints.map((point) =>
162
- getYPositions(snapPointsMode, point, screenSize, effectiveFrameSize)
259
+ getYPositions(snapPointsMode, point, effScreenSize, effectiveFrameSize)
163
260
  ),
164
- [screenSize, effectiveFrameSize, snapPoints, snapPointsMode]
261
+ [effScreenSize, effectiveFrameSize, snapPoints, snapPointsMode]
165
262
  )
166
263
 
167
- // keyboard state tracking — just tracks height/visibility, no position animation.
168
- // Position animation is handled via keyboard-adjusted positions below,
169
- // matching the react-native-actions-sheet pattern.
170
- const {
171
- keyboardHeight,
172
- isKeyboardVisible,
173
- dismissKeyboard,
174
- pauseKeyboardHandler,
175
- flushPendingHide,
176
- } = useKeyboardControllerSheet({
177
- enabled: !isWeb && Boolean(moveOnKeyboardChange),
178
- })
179
-
180
264
  const [isDragging, setIsDragging_] = React.useState(false)
181
265
 
182
266
  // synchronous dragging ref — set BEFORE async state commits.
@@ -198,28 +282,32 @@ export const SheetImplementationCustom = React.forwardRef<View, SheetProps>(
198
282
  [pauseKeyboardHandler, flushPendingHide]
199
283
  )
200
284
 
201
- // keyboard-adjusted positions: shift snap points up by keyboard height
202
- // when keyboard is visible. This drives both gesture snap calculation
203
- // and animation targets keyboard never dismissed during drag.
204
- // Capped at safe area top inset so the sheet never goes above the notch/status bar
205
- // (matching the react-native-actions-sheet pattern).
285
+ // keyboard-adjusted snap positions.
286
+ //
287
+ // WEB: the sheet stays ANCHORED at the bottom and keeps its full pre-keyboard
288
+ // height it does NOT shift up or resize. Shifting/resizing with the
289
+ // translateY model detaches the bottom from the screen edge or teleports it.
290
+ // Instead the frame's anchor geometry is frozen (effScreenSize/effectiveFrameSize
291
+ // above) and its height is pinned (keyboardStableFrameHeight -> SheetScrollView);
292
+ // the scroll content is padded by keyboardOccludedHeight and the browser
293
+ // scroll-into-view lifts the focused input above the keyboard. So
294
+ // activePositions === positions on web.
206
295
  //
207
- // IMPORTANT: frozen during drag to prevent gesture handler recreation.
208
- // When user drags, TextInput may blur keyboard dismisses → positions would revert,
209
- // causing the gesture useMemo to recreate and cancel the active drag.
210
- // The post-drag reconciliation effect handles animating to correct position after drag ends.
296
+ // NATIVE: shift snap points up by keyboard height (the native keyboard is
297
+ // opaque and pushes content), capped at the safe-area top inset.
298
+ //
299
+ // IMPORTANT: frozen during drag to prevent gesture handler recreation
300
+ // when a TextInput blurs mid-drag the keyboard state would otherwise revert
301
+ // and recreate the gesture useMemo, cancelling the active drag.
211
302
  const activePositionsRef = React.useRef(positions)
212
303
  const activePositions = React.useMemo(() => {
213
- // during drag, return frozen positions to prevent gesture handler recreation.
214
- // check both state (for re-render trigger) and ref (for synchronous check
215
- // when keyboard hide event fires before isDragging state commits)
216
304
  if (isDragging || isDraggingRef.current) return activePositionsRef.current
217
305
 
218
306
  let result: number[]
219
- if (!isKeyboardVisible || keyboardHeight <= 0) {
307
+ if (isWeb || !isKeyboardVisible || keyboardHeight <= 0) {
220
308
  result = positions
221
309
  } else {
222
- const safeAreaTop = isWeb ? 0 : getSafeAreaTopInset()
310
+ const safeAreaTop = getSafeAreaTopInset()
223
311
  result = positions.map((p) => {
224
312
  // don't adjust the off-screen/close position (from dismissOnSnapToBottom's 0% snap)
225
313
  // — it must stay at screenSize so the user can drag between real snap points
@@ -232,13 +320,39 @@ export const SheetImplementationCustom = React.forwardRef<View, SheetProps>(
232
320
  return result
233
321
  }, [positions, isKeyboardVisible, keyboardHeight, screenSize, isDragging])
234
322
 
235
- const keyboardOccludedHeight = getKeyboardOccludedHeight({
236
- frameSize,
237
- isKeyboardVisible: !isWeb && isKeyboardVisible,
238
- keyboardHeight,
239
- screenSize,
240
- sheetY: position >= 0 ? activePositions[position] : undefined,
241
- })
323
+ const keyboardOccludedHeight = seedingKbBaseline
324
+ ? // while seeding, suppress the keyboard tail padding so the frame measures
325
+ // its PURE pre-keyboard content height (the padding would otherwise inflate
326
+ // it toward the full screen). re-enabled once the baseline is captured.
327
+ 0
328
+ : getKeyboardOccludedHeight({
329
+ frameSize: effectiveFrameSize,
330
+ isKeyboardVisible,
331
+ keyboardHeight,
332
+ screenSize: effScreenSize,
333
+ sheetY: position >= 0 ? activePositions[position] : undefined,
334
+ })
335
+
336
+ // the authoritative pre-keyboard frame height to pin the scroll view to while
337
+ // the keyboard is open (web). stableKbGeom.frame is captured every render the
338
+ // keyboard is closed, so it survives the open transition; SheetScrollView
339
+ // gates application on its own live keyboard check.
340
+ //
341
+ // AUTOFOCUS-ON-OPEN seed: while still reconstructing the baseline (seeding,
342
+ // not yet settled) we use the STABLE SCREEN SIZE — a safe upper bound (a fit
343
+ // frame never exceeds the screen) that is > 0, so SheetScrollView's height
344
+ // override engages and UNCLIPS the scroll view from the shrunk consumer
345
+ // maxHeight. with the tail padding suppressed (above) the content then lays
346
+ // out to its pure intrinsic height, which the seeding layout grows the frame
347
+ // baseline toward across a couple passes. once settled (or once a real
348
+ // keyboard-free baseline lands) we pin that exact frame height.
349
+ const keyboardStableFrameHeight = !isWebKbSheet
350
+ ? 0
351
+ : seedingKbBaseline
352
+ ? stableKbGeom.current.screen || screenSize
353
+ : stableKbGeom.current.frame > 0
354
+ ? stableKbGeom.current.frame
355
+ : 0
242
356
 
243
357
  const { useAnimatedNumber, useAnimatedNumberStyle, useAnimatedNumberReaction } =
244
358
  animationDriver
@@ -325,7 +439,11 @@ export const SheetImplementationCustom = React.forwardRef<View, SheetProps>(
325
439
  const animateTo = useEvent((position: number, animationOverride?: any) => {
326
440
  if (frameSize === 0) return
327
441
 
328
- let toValue = isHidden || position === -1 ? screenSize : activePositions[position]
442
+ // use effScreenSize (the frozen anchor space the positions were built in) for
443
+ // the off-screen/close target too, so a close while the keyboard is still up
444
+ // animates fully out instead of to a mismatched live screenSize.
445
+ let toValue =
446
+ isHidden || position === -1 ? effScreenSize : activePositions[position]
329
447
 
330
448
  if (at.current === toValue) return
331
449
 
@@ -417,6 +535,16 @@ export const SheetImplementationCustom = React.forwardRef<View, SheetProps>(
417
535
  return
418
536
  }
419
537
 
538
+ // never fight an active drag: the gesture owns the animated position. on
539
+ // web the AnimatedView's onLayout re-fires with sub-pixel jitter as the
540
+ // frame translates (frameSize 499.99996 <-> 500.00003), and since frameSize
541
+ // is a dep of this effect that would re-run it mid-pull and snap the sheet
542
+ // back to its resting snap point. read the live ref so drag-end (which the
543
+ // reconcile-after-drag effect handles) isn't gated by stale deps.
544
+ if (isDraggingRef.current) {
545
+ return
546
+ }
547
+
420
548
  if (!frameSize || !screenSize || isHidden || (hasntMeasured && !open)) {
421
549
  return
422
550
  }
@@ -454,7 +582,11 @@ export const SheetImplementationCustom = React.forwardRef<View, SheetProps>(
454
582
  if (!frameSize) return
455
583
  if (isShowingInnerSheet) return
456
584
 
457
- const minY = positions[0]
585
+ // use keyboard-adjusted positions (matches the RNGH path): when the
586
+ // keyboard is open the sheet sits at activePositions[0], so clamping drags
587
+ // against the un-adjusted positions[0] would rubber-band the sheet down to
588
+ // near the bottom on any drag.
589
+ const minY = activePositions[0]
458
590
  scrollBridge.paneMinY = minY
459
591
  let startY = at.current
460
592
 
@@ -497,8 +629,8 @@ export const SheetImplementationCustom = React.forwardRef<View, SheetProps>(
497
629
  let closestPoint = 0
498
630
  let dist = Number.POSITIVE_INFINITY
499
631
 
500
- for (let i = 0; i < positions.length; i++) {
501
- const position = positions[i]
632
+ for (let i = 0; i < activePositions.length; i++) {
633
+ const position = activePositions[i]
502
634
  const curDist = end > position ? end - position : position - end
503
635
  if (curDist < dist) {
504
636
  dist = curDist
@@ -530,6 +662,16 @@ export const SheetImplementationCustom = React.forwardRef<View, SheetProps>(
530
662
  return true
531
663
  }
532
664
 
665
+ // touch is on the ScrollView node — the web scroll-view gesture hook
666
+ // owns it and drives drag/release through scrollBridge directly (it
667
+ // re-baselines via scrollBridge.startPanDrag on each pan handoff). if
668
+ // we also granted here, RNW's PanResponder would set the animated
669
+ // position from a second, differently-based offset every move and the
670
+ // sheet would jitter/jump. defer entirely to the hook.
671
+ if (scrollBridge.scrollNodeTouched) {
672
+ return false
673
+ }
674
+
533
675
  if (scrollBridge.hasScrollableContent === true) {
534
676
  if (scrollBridge.scrollLock) {
535
677
  return false
@@ -575,6 +717,17 @@ export const SheetImplementationCustom = React.forwardRef<View, SheetProps>(
575
717
 
576
718
  let isExternalDrag = false
577
719
 
720
+ // re-baseline a pan drag to the current animated position. the web
721
+ // scroll-view hook calls this on every transition INTO pan ownership
722
+ // (including handoffs back from scroll), so its panDragOffset — which it
723
+ // resets to 0 at each pan entry — is measured from where the sheet
724
+ // actually is now, not from where the gesture first grabbed it. without
725
+ // this the sheet would jump to a stale origin on a scroll→pan handoff.
726
+ scrollBridge.startPanDrag = () => {
727
+ isExternalDrag = true
728
+ grant()
729
+ }
730
+
578
731
  scrollBridge.drag = (dy) => {
579
732
  if (!isExternalDrag) {
580
733
  isExternalDrag = true
@@ -616,10 +769,22 @@ export const SheetImplementationCustom = React.forwardRef<View, SheetProps>(
616
769
  onPanResponderTerminate: finish,
617
770
  onPanResponderRelease: finish,
618
771
  })
619
- }, [disableDrag, isShowingInnerSheet, animateTo, frameSize, positions, setPosition])
772
+ }, [
773
+ disableDrag,
774
+ isShowingInnerSheet,
775
+ animateTo,
776
+ frameSize,
777
+ activePositions,
778
+ setPosition,
779
+ ])
620
780
 
621
- // animate to keyboard-adjusted position when keyboard state changes
781
+ // animate to keyboard-adjusted position when keyboard state changes.
782
+ // WEB skips this: the sheet stays anchored at its frozen position/height
783
+ // (activePositions don't change with the keyboard, and the anchor geometry is
784
+ // frozen), so there's nothing to re-animate. Running a timing animation here
785
+ // would only introduce movement where the sheet should hold perfectly still.
622
786
  React.useEffect(() => {
787
+ if (isWeb) return
623
788
  if (isDragging || isHidden || !open || disableAnimation) return
624
789
  if (!frameSize || !screenSize) return
625
790
  // use timing animation to match iOS keyboard animation (~250ms)
@@ -647,6 +812,11 @@ export const SheetImplementationCustom = React.forwardRef<View, SheetProps>(
647
812
  React.useEffect(() => {
648
813
  if (!open && isKeyboardVisible) {
649
814
  dismissKeyboard()
815
+ // if the sheet was closed mid-drag the keyboard-hide handler was paused
816
+ // and a hide could be left pending — clear it so isKeyboardVisible can't
817
+ // stick true after the sheet is gone. (no-op for a normal close.)
818
+ pauseKeyboardHandler.current = false
819
+ flushPendingHide()
650
820
  }
651
821
  }, [open])
652
822
 
@@ -671,31 +841,102 @@ export const SheetImplementationCustom = React.forwardRef<View, SheetProps>(
671
841
  pauseKeyboardHandler,
672
842
  })
673
843
 
844
+ // ignore any layout measured while the soft keyboard is up (web +
845
+ // moveOnKeyboardChange): the visual viewport (which RN's web layout follows)
846
+ // shrinks, so the height would be the collapsed sheet — keeping it would
847
+ // recompute the fit anchor and fly the frame. a LIVE DOM check, NOT the
848
+ // isKeyboardVisible React state, is required: the state lags the resize, so
849
+ // the first shrunk onLayout lands before the flag flips. holding the measured
850
+ // sizes keeps the sheet anchored; the scroll content shifts to clear the kb.
851
+ const ignoreLayoutForKeyboard = useEvent(
852
+ () => isWeb && moveOnKeyboardChange && getWebKeyboardHeight() >= MIN_KEYBOARD_HEIGHT
853
+ )
854
+
855
+ // AUTOFOCUS-ON-OPEN seed gates. normally a layout measured while the keyboard
856
+ // is up is dropped (it's the shrunk/collapsed sheet). but if the keyboard rose
857
+ // before ANY keyboard-free baseline was captured (the autofocus race),
858
+ // dropping every measurement leaves frameSize/screenSize stuck at 0 forever
859
+ // and the freeze never engages.
860
+ //
861
+ // frame: while seeding (not settled) the scroll view is unclipped and the
862
+ // tail padding suppressed, so the frame measures toward its pure pre-keyboard
863
+ // content height. the first keyboard-up pass is still clipped at the consumer
864
+ // maxHeight; the override only unclips it the following pass, so the frame
865
+ // grows. we take the MAX and mark the seed SETTLED once it stops growing
866
+ // (handled in handleAnimationViewLayout) — then later keyboard-up layouts are
867
+ // dropped again so the re-enabled tail padding can't inflate the frame.
868
+ const shouldSeedKbFrame = useEvent(
869
+ () =>
870
+ isWebKbSheet &&
871
+ !hasCleanKbBaseline.current &&
872
+ !seedSettled.current &&
873
+ ignoreLayoutForKeyboard()
874
+ )
875
+ // screen (= maxContentSize): keep reconstructing from the stable viewport for
876
+ // the whole keyboard-up-without-clean-baseline window.
877
+ const shouldSeedKbScreen = useEvent(
878
+ () => isWebKbSheet && !hasCleanKbBaseline.current && ignoreLayoutForKeyboard()
879
+ )
880
+
674
881
  const handleAnimationViewLayout = useEvent((e: LayoutChangeEvent) => {
675
882
  // don't update frameSize during exit animation to prevent position jumps
676
883
  if (!open && stableFrameSize.current !== 0) {
677
884
  return
678
885
  }
679
886
 
887
+ const seeding = shouldSeedKbFrame()
888
+ if (!seeding && ignoreLayoutForKeyboard()) return
889
+
680
890
  // avoid bugs where it grows forever for whatever reason
681
891
  // For inline mode (non-modal), don't cap at window height - use actual layout
682
892
  const layoutHeight = e.nativeEvent?.layout.height
683
- const next = modal
684
- ? Math.min(layoutHeight, Dimensions.get(relativeDimensionTo).height)
685
- : layoutHeight
893
+ // while seeding, the keyboardStableFrameHeight fallback has unclipped the
894
+ // scroll view (capped at the stable screen) and the tail padding is
895
+ // suppressed, so this measures the pure pre-keyboard content height — cap it
896
+ // at the stable viewport like modal does.
897
+ const next =
898
+ modal || seeding
899
+ ? Math.min(layoutHeight, getStableViewportHeight())
900
+ : layoutHeight
686
901
  if (!next) return
687
- setFrameSize(next)
902
+ // round: web onLayout reports sub-pixel heights (e.g. 499.99996) that jitter
903
+ // frame to frame as the view transforms; the raw float would re-fire every
904
+ // effect that depends on frameSize on each drag move.
905
+ const rounded = Math.round(next)
906
+ // seeding the frame: grow the baseline toward the unclipped content height.
907
+ // the first keyboard-up pass is clipped; the scroll-view override unclips it
908
+ // the next pass so it grows. once a pass doesn't exceed the running max the
909
+ // content has settled — mark the seed done so the next render exits the seed
910
+ // phase (freezeForKb pins this height, tail padding re-enables for scroll).
911
+ if (seeding) {
912
+ if (rounded > stableKbGeom.current.frame) {
913
+ stableKbGeom.current.frame = rounded
914
+ } else if (stableKbGeom.current.frame > 0) {
915
+ seedSettled.current = true
916
+ }
917
+ }
918
+ setFrameSize(rounded)
688
919
  })
689
920
 
690
- const handleMaxContentViewLayout = React.useCallback((e: LayoutChangeEvent) => {
691
- // avoid bugs where it grows forever for whatever reason
692
- const next = Math.min(
693
- e.nativeEvent?.layout.height,
694
- Dimensions.get(relativeDimensionTo).height
695
- )
696
- if (!next) return
697
- setMaxContentSize(next)
698
- }, [])
921
+ const handleMaxContentViewLayout = React.useCallback(
922
+ (e: LayoutChangeEvent) => {
923
+ // same keyboard guard so screenSize (= maxContentSize) stays the full
924
+ // pre-keyboard viewport — except while seeding the baseline (see above),
925
+ // where we use the stable layout viewport directly so screenSize lands at
926
+ // the full pre-keyboard height instead of the shrunk visual viewport.
927
+ if (shouldSeedKbScreen()) {
928
+ setMaxContentSize(Math.round(getStableViewportHeight()))
929
+ return
930
+ }
931
+ if (ignoreLayoutForKeyboard()) return
932
+ // avoid bugs where it grows forever for whatever reason
933
+ const next = Math.min(e.nativeEvent?.layout.height, getStableViewportHeight())
934
+ if (!next) return
935
+ // round to avoid sub-pixel churn re-firing size-dependent effects
936
+ setMaxContentSize(Math.round(next))
937
+ },
938
+ [ignoreLayoutForKeyboard, shouldSeedKbScreen]
939
+ )
699
940
 
700
941
  const getAnimatedNumberStyle = React.useCallback(
701
942
  (val: number) => {
@@ -745,6 +986,9 @@ export const SheetImplementationCustom = React.forwardRef<View, SheetProps>(
745
986
  <SheetProvider
746
987
  {...providerProps}
747
988
  keyboardOccludedHeight={keyboardOccludedHeight}
989
+ isKeyboardVisible={isKeyboardVisible}
990
+ keyboardStableFrameHeight={keyboardStableFrameHeight}
991
+ isKeyboardSeeding={seedingKbBaseline}
748
992
  setHasScrollView={setHasScrollView}
749
993
  >
750
994
  <GestureSheetProvider
@@ -10,6 +10,7 @@ import { getGestureHandlerState, isGestureHandlerEnabled } from './gestureState'
10
10
  import { useSheetContext } from './SheetContext'
11
11
  import type { SheetScopedProps } from './types'
12
12
  import { useSheetScrollViewGestures } from './useSheetScrollViewGestures'
13
+ import { getWebKeyboardHeight, MIN_KEYBOARD_HEIGHT } from './webViewport'
13
14
 
14
15
  const SHEET_SCROLL_VIEW_NAME = 'SheetScrollView'
15
16
 
@@ -31,6 +32,14 @@ export const SheetScrollView = React.forwardRef<
31
32
  const gestureContext = useGestureSheetContext()
32
33
  const { scrollBridge, setHasScrollView, hasFit, screenSize } = context
33
34
  const keyboardOccludedHeight = Math.max(0, context.keyboardOccludedHeight || 0)
35
+ // OR a LIVE DOM check: context.isKeyboardVisible (React state) lags the
36
+ // viewport resize, so on the open-transition render this component can re-run
37
+ // with the shrunk consumer maxHeight BEFORE the context flag flips. reading
38
+ // the keyboard height straight from visualViewport closes that race so the
39
+ // height freeze engages on the same render that would otherwise collapse it.
40
+ const isKeyboardVisible =
41
+ context.isKeyboardVisible === true ||
42
+ (isWeb && getWebKeyboardHeight() >= MIN_KEYBOARD_HEIGHT)
34
43
  const [scrollEnabled] = useControllableState({
35
44
  prop: scrollEnabledProp,
36
45
  defaultProp: true,
@@ -40,25 +49,47 @@ export const SheetScrollView = React.forwardRef<
40
49
  const [hasScrollableContent, setHasScrollableContent] = useState(true)
41
50
  const parentHeight = useRef(0)
42
51
  const contentHeight = useRef(0)
52
+ // the sheet's authoritative pre-keyboard frame height (see SheetImpl). a
53
+ // scroll-view-local high-water mark used to live here, but it was unreliable
54
+ // (the ref could read 0 if the view remounted on focus / never laid out while
55
+ // closed), so the height now comes from the sheet, which doesn't remount.
56
+ const frozenFrameHeight = Math.max(0, context.keyboardStableFrameHeight || 0)
43
57
 
44
58
  // with snapPointsMode="fit", Frame is content-sized (flex: 0, flex-basis: auto, height: undefined).
45
59
  // a flex: 1 child can't grow inside a content-sized parent, so the ScrollView (and the Frame
46
60
  // around it) collapse to 0 height. instead, let the ScrollView size to its content and cap it
47
61
  // at the available viewport (screenSize / maxContentSize) so scrolling kicks in for tall content.
48
- // when the keyboard forces the sheet against the top safe area, preserve the measured viewport
49
- // height while adding scrollable tail padding so content can move above the keyboard.
50
- const keyboardFrozenHeight =
51
- hasFit && keyboardOccludedHeight > 0 && parentHeight.current
52
- ? parentHeight.current
53
- : undefined
54
62
  const fitSizingStyle = hasFit
55
63
  ? {
56
64
  flex: undefined as undefined,
57
- height: keyboardFrozenHeight,
65
+ height: undefined as undefined,
58
66
  maxHeight: screenSize || undefined,
59
67
  }
60
68
  : { flex: 1 }
61
69
 
70
+ // AUTOFOCUS-ON-OPEN seed (web): the sheet is still reconstructing its
71
+ // pre-keyboard frame baseline and needs THIS scroll view to size to its
72
+ // content so it can measure the true content height. so we apply the stable
73
+ // screen only as a maxHeight cap (UNCLIP from the shrunk consumer maxHeight)
74
+ // and leave height undefined so it stays content-sized.
75
+ const isKeyboardSeeding = context.isKeyboardSeeding === true
76
+
77
+ // when the keyboard is open the sheet stays ANCHORED at the bottom and keeps
78
+ // its full pre-keyboard height — the keyboard overlays its lower part and the
79
+ // keyboardOccludedHeight tail padding (added to the scroll content below) +
80
+ // browser scroll-into-view lift the focused input above the keyboard. so we
81
+ // pin the height to the sheet's authoritative frozenFrameHeight, overriding
82
+ // any consumer maxHeight (on web that's often tied to useWindowDimensions,
83
+ // which SHRINKS when the keyboard opens and would otherwise collapse the
84
+ // sheet). holding the height constant means nothing animates on keyboard
85
+ // open/close — no jump/teleport. applied AFTER {...props} so it wins.
86
+ const keyboardFrozenOverride =
87
+ hasFit && isKeyboardVisible && frozenFrameHeight > 0
88
+ ? isKeyboardSeeding
89
+ ? { maxHeight: frozenFrameHeight }
90
+ : { height: frozenFrameHeight, maxHeight: frozenFrameHeight }
91
+ : null
92
+
62
93
  const panGestureRef = gestureContext?.panGestureRef
63
94
  const { ScrollView: RNGHScrollView } = getGestureHandlerState()
64
95
  const useRNGHScrollView = isGestureHandlerEnabled() && RNGHScrollView && panGestureRef
@@ -102,6 +133,12 @@ export const SheetScrollView = React.forwardRef<
102
133
  }
103
134
  }
104
135
 
136
+ // track the fit height for the scrollable-content check. the keyboard-freeze
137
+ // height is supplied by the sheet (frozenFrameHeight), not derived here.
138
+ const recordFitHeight = (height: number) => {
139
+ parentHeight.current = height
140
+ }
141
+
105
142
  useEffect(() => {
106
143
  scrollBridge.hasScrollableContent = hasScrollableContent
107
144
  }, [hasScrollableContent])
@@ -148,7 +185,7 @@ export const SheetScrollView = React.forwardRef<
148
185
  scrollEnabled={scrollEnabled}
149
186
  simultaneousHandlers={[panGestureRef]}
150
187
  onLayout={(e: any) => {
151
- parentHeight.current = Math.ceil(e.nativeEvent.layout.height)
188
+ recordFitHeight(Math.ceil(e.nativeEvent.layout.height))
152
189
  updateScrollable()
153
190
  }}
154
191
  onScroll={(e: any) => {
@@ -186,6 +223,7 @@ export const SheetScrollView = React.forwardRef<
186
223
  keyboardShouldPersistTaps="always"
187
224
  keyboardDismissMode="none"
188
225
  {...props}
226
+ {...keyboardFrozenOverride}
189
227
  >
190
228
  {contentWrapper}
191
229
  </RNGHComponent>
@@ -196,7 +234,7 @@ export const SheetScrollView = React.forwardRef<
196
234
  return (
197
235
  <ScrollView
198
236
  onLayout={(e) => {
199
- parentHeight.current = Math.ceil(e.nativeEvent.layout.height)
237
+ recordFitHeight(Math.ceil(e.nativeEvent.layout.height))
200
238
  updateScrollable()
201
239
  }}
202
240
  ref={composeRefs(scrollRef as any, ref)}
@@ -213,6 +251,7 @@ export const SheetScrollView = React.forwardRef<
213
251
  contentContainerStyle={{ minHeight: '100%' }}
214
252
  {...gestureProps}
215
253
  {...props}
254
+ {...keyboardFrozenOverride}
216
255
  >
217
256
  {contentWrapper}
218
257
  </ScrollView>
@@ -62,6 +62,9 @@ export function setupNativeSheet(
62
62
  <SheetProvider
63
63
  setHasScrollView={emptyFn}
64
64
  keyboardOccludedHeight={0}
65
+ isKeyboardVisible={false}
66
+ keyboardStableFrameHeight={0}
67
+ isKeyboardSeeding={false}
65
68
  {...providerProps}
66
69
  onlyShowFrame
67
70
  >
package/src/types.tsx CHANGED
@@ -75,7 +75,8 @@ export type SheetProps = ScopedProps<
75
75
  zIndex?: number
76
76
  portalProps?: PortalProps
77
77
  /**
78
- * Native-only flag that will make the sheet move up when the mobile keyboard opens so the focused input remains visible
78
+ * Makes the sheet move up when the mobile keyboard opens so the focused input remains visible.
79
+ * Works on native (via keyboard events) and on mobile web (via the VisualViewport API).
79
80
  */
80
81
  moveOnKeyboardChange?: boolean
81
82
  containerComponent?: React.ComponentType<any>
@@ -131,6 +132,15 @@ export type ScrollBridge = {
131
132
  isAtTop?: boolean
132
133
  // snap sheet to a specific position (for handoff UP)
133
134
  snapToPosition?: (positionIndex: number) => void
135
+ // re-baseline the pan drag origin to the current animated position. the web
136
+ // scroll-view hook calls this on each transition into pan ownership so a
137
+ // scroll→pan handoff resumes from where the sheet is, not a stale origin.
138
+ startPanDrag?: () => void
139
+ // web only: true while a touch is active on the ScrollView node. The web
140
+ // scroll-view gesture hook owns drag detection for those touches (it calls
141
+ // drag/release on this bridge), so the PanResponder must NOT also grant for
142
+ // them — otherwise two systems drive the animated position and it jitters.
143
+ scrollNodeTouched?: boolean
134
144
  }
135
145
 
136
146
  // keyboard controller sheet types