@tamagui/sheet 2.0.0 → 2.1.0-1780288049558

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 (97) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/cjs/SheetImplementationCustom.cjs +46 -22
  3. package/dist/cjs/SheetImplementationCustom.native.js +58 -27
  4. package/dist/cjs/SheetImplementationCustom.native.js.map +1 -1
  5. package/dist/cjs/SheetScrollView.cjs +15 -4
  6. package/dist/cjs/SheetScrollView.native.js +15 -4
  7. package/dist/cjs/SheetScrollView.native.js.map +1 -1
  8. package/dist/cjs/createSheet.cjs +5 -2
  9. package/dist/cjs/createSheet.native.js +5 -2
  10. package/dist/cjs/createSheet.native.js.map +1 -1
  11. package/dist/cjs/nativeSheet.cjs +2 -0
  12. package/dist/cjs/nativeSheet.native.js +2 -0
  13. package/dist/cjs/nativeSheet.native.js.map +1 -1
  14. package/dist/cjs/useKeyboardControllerSheet.cjs +65 -6
  15. package/dist/cjs/useSheetProviderProps.native.js.map +1 -1
  16. package/dist/cjs/useSheetScrollViewGestures.cjs +6 -1
  17. package/dist/cjs/webViewport.cjs +58 -0
  18. package/dist/cjs/webViewport.native.js +63 -0
  19. package/dist/cjs/webViewport.native.js.map +1 -0
  20. package/dist/esm/SheetImplementationCustom.mjs +52 -29
  21. package/dist/esm/SheetImplementationCustom.mjs.map +1 -1
  22. package/dist/esm/SheetImplementationCustom.native.js +71 -45
  23. package/dist/esm/SheetImplementationCustom.native.js.map +1 -1
  24. package/dist/esm/SheetScrollView.mjs +16 -5
  25. package/dist/esm/SheetScrollView.mjs.map +1 -1
  26. package/dist/esm/SheetScrollView.native.js +16 -5
  27. package/dist/esm/SheetScrollView.native.js.map +1 -1
  28. package/dist/esm/createSheet.mjs +7 -5
  29. package/dist/esm/createSheet.mjs.map +1 -1
  30. package/dist/esm/createSheet.native.js +17 -14
  31. package/dist/esm/createSheet.native.js.map +1 -1
  32. package/dist/esm/nativeSheet.mjs +2 -0
  33. package/dist/esm/nativeSheet.mjs.map +1 -1
  34. package/dist/esm/nativeSheet.native.js +2 -0
  35. package/dist/esm/nativeSheet.native.js.map +1 -1
  36. package/dist/esm/useKeyboardControllerSheet.mjs +66 -7
  37. package/dist/esm/useKeyboardControllerSheet.mjs.map +1 -1
  38. package/dist/esm/useSheetProviderProps.mjs.map +1 -1
  39. package/dist/esm/useSheetProviderProps.native.js.map +1 -1
  40. package/dist/esm/useSheetScrollViewGestures.mjs +6 -1
  41. package/dist/esm/useSheetScrollViewGestures.mjs.map +1 -1
  42. package/dist/esm/webViewport.mjs +29 -0
  43. package/dist/esm/webViewport.mjs.map +1 -0
  44. package/dist/esm/webViewport.native.js +31 -0
  45. package/dist/esm/webViewport.native.js.map +1 -0
  46. package/dist/jsx/SheetImplementationCustom.mjs +52 -29
  47. package/dist/jsx/SheetImplementationCustom.mjs.map +1 -1
  48. package/dist/jsx/SheetImplementationCustom.native.js +58 -27
  49. package/dist/jsx/SheetImplementationCustom.native.js.map +1 -1
  50. package/dist/jsx/SheetScrollView.mjs +16 -5
  51. package/dist/jsx/SheetScrollView.mjs.map +1 -1
  52. package/dist/jsx/SheetScrollView.native.js +15 -4
  53. package/dist/jsx/SheetScrollView.native.js.map +1 -1
  54. package/dist/jsx/createSheet.mjs +7 -5
  55. package/dist/jsx/createSheet.mjs.map +1 -1
  56. package/dist/jsx/createSheet.native.js +5 -2
  57. package/dist/jsx/createSheet.native.js.map +1 -1
  58. package/dist/jsx/nativeSheet.mjs +2 -0
  59. package/dist/jsx/nativeSheet.mjs.map +1 -1
  60. package/dist/jsx/nativeSheet.native.js +2 -0
  61. package/dist/jsx/nativeSheet.native.js.map +1 -1
  62. package/dist/jsx/useKeyboardControllerSheet.mjs +66 -7
  63. package/dist/jsx/useKeyboardControllerSheet.mjs.map +1 -1
  64. package/dist/jsx/useSheetProviderProps.mjs.map +1 -1
  65. package/dist/jsx/useSheetProviderProps.native.js.map +1 -1
  66. package/dist/jsx/useSheetScrollViewGestures.mjs +6 -1
  67. package/dist/jsx/useSheetScrollViewGestures.mjs.map +1 -1
  68. package/dist/jsx/webViewport.mjs +29 -0
  69. package/dist/jsx/webViewport.mjs.map +1 -0
  70. package/dist/jsx/webViewport.native.js +63 -0
  71. package/dist/jsx/webViewport.native.js.map +1 -0
  72. package/package.json +21 -23
  73. package/src/SheetImplementationCustom.tsx +207 -53
  74. package/src/SheetScrollView.tsx +36 -9
  75. package/src/createSheet.tsx +18 -6
  76. package/src/nativeSheet.tsx +2 -0
  77. package/src/types.tsx +11 -1
  78. package/src/useKeyboardControllerSheet.ts +123 -10
  79. package/src/useSheetProviderProps.tsx +10 -0
  80. package/src/useSheetScrollViewGestures.ts +23 -2
  81. package/src/webViewport.ts +81 -0
  82. package/types/SheetContext.d.ts +2 -0
  83. package/types/SheetContext.d.ts.map +1 -1
  84. package/types/SheetImplementationCustom.d.ts.map +1 -1
  85. package/types/SheetScrollView.d.ts.map +1 -1
  86. package/types/createSheet.d.ts +12 -12
  87. package/types/createSheet.d.ts.map +1 -1
  88. package/types/nativeSheet.d.ts.map +1 -1
  89. package/types/types.d.ts +4 -1
  90. package/types/types.d.ts.map +1 -1
  91. package/types/useKeyboardControllerSheet.d.ts +14 -3
  92. package/types/useKeyboardControllerSheet.d.ts.map +1 -1
  93. package/types/useSheetProviderProps.d.ts +2 -0
  94. package/types/useSheetProviderProps.d.ts.map +1 -1
  95. package/types/useSheetScrollViewGestures.d.ts.map +1 -1
  96. package/types/webViewport.d.ts +31 -0
  97. package/types/webViewport.d.ts.map +1 -0
package/package.json CHANGED
@@ -1,12 +1,10 @@
1
1
  {
2
2
  "name": "@tamagui/sheet",
3
- "version": "2.0.0",
3
+ "version": "2.1.0-1780288049558",
4
4
  "license": "MIT",
5
5
  "source": "src/index.ts",
6
6
  "type": "module",
7
- "sideEffects": [
8
- "*.css"
9
- ],
7
+ "sideEffects": false,
10
8
  "main": "dist/cjs",
11
9
  "module": "dist/esm",
12
10
  "types": "./types/index.d.ts",
@@ -56,27 +54,27 @@
56
54
  "clean": "tamagui-build clean"
57
55
  },
58
56
  "dependencies": {
59
- "@tamagui/adapt": "2.0.0",
60
- "@tamagui/animate-presence": "2.0.0",
61
- "@tamagui/animations-react-native": "2.0.0",
62
- "@tamagui/compose-refs": "2.0.0",
63
- "@tamagui/constants": "2.0.0",
64
- "@tamagui/core": "2.0.0",
65
- "@tamagui/create-context": "2.0.0",
66
- "@tamagui/helpers": "2.0.0",
67
- "@tamagui/native": "2.0.0",
68
- "@tamagui/portal": "2.0.0",
69
- "@tamagui/remove-scroll": "2.0.0",
70
- "@tamagui/scroll-view": "2.0.0",
71
- "@tamagui/stacks": "2.0.0",
72
- "@tamagui/use-constant": "2.0.0",
73
- "@tamagui/use-controllable-state": "2.0.0",
74
- "@tamagui/use-did-finish-ssr": "2.0.0",
75
- "@tamagui/use-keyboard-visible": "2.0.0",
76
- "@tamagui/z-index-stack": "2.0.0"
57
+ "@tamagui/adapt": "2.1.0-1780288049558",
58
+ "@tamagui/animate-presence": "2.1.0-1780288049558",
59
+ "@tamagui/animations-react-native": "2.1.0-1780288049558",
60
+ "@tamagui/compose-refs": "2.1.0-1780288049558",
61
+ "@tamagui/constants": "2.1.0-1780288049558",
62
+ "@tamagui/core": "2.1.0-1780288049558",
63
+ "@tamagui/create-context": "2.1.0-1780288049558",
64
+ "@tamagui/helpers": "2.1.0-1780288049558",
65
+ "@tamagui/native": "2.1.0-1780288049558",
66
+ "@tamagui/portal": "2.1.0-1780288049558",
67
+ "@tamagui/remove-scroll": "2.1.0-1780288049558",
68
+ "@tamagui/scroll-view": "2.1.0-1780288049558",
69
+ "@tamagui/stacks": "2.1.0-1780288049558",
70
+ "@tamagui/use-constant": "2.1.0-1780288049558",
71
+ "@tamagui/use-controllable-state": "2.1.0-1780288049558",
72
+ "@tamagui/use-did-finish-ssr": "2.1.0-1780288049558",
73
+ "@tamagui/use-keyboard-visible": "2.1.0-1780288049558",
74
+ "@tamagui/z-index-stack": "2.1.0-1780288049558"
77
75
  },
78
76
  "devDependencies": {
79
- "@tamagui/build": "2.0.0",
77
+ "@tamagui/build": "2.1.0-1780288049558",
80
78
  "react": ">=19",
81
79
  "react-native": "0.83.2",
82
80
  "react-native-gesture-handler": "~2.30.0"
@@ -26,6 +26,12 @@ 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
+ getMaxViewportHeight,
31
+ getStableLayoutViewportHeight,
32
+ getWebKeyboardHeight,
33
+ MIN_KEYBOARD_HEIGHT,
34
+ } from './webViewport'
29
35
  import { SheetProvider } from './SheetContext'
30
36
  import type { SheetProps, SnapPointsMode } from './types'
31
37
  import { useGestureHandlerPan } from './useGestureHandlerPan'
@@ -57,6 +63,19 @@ let sheetHiddenStyleSheet: HTMLStyleElement | null = null
57
63
  // on web we are always relative to window, on to screen
58
64
  const relativeDimensionTo = isWeb ? 'window' : 'screen'
59
65
 
66
+ // height of the viewport the sheet positions against. on web this MUST be the
67
+ // stable layout viewport and NOT Dimensions.get('window') — react-native-web's
68
+ // Dimensions tracks visualViewport, which shrinks by the soft keyboard. capping
69
+ // frameSize / maxContentSize against that shrinking value corrupts the fit-mode
70
+ // math (translateY = screenSize - frameSize), detaching the sheet's bottom from
71
+ // the screen edge when the keyboard opens. NOTE: window.innerHeight is NOT
72
+ // stable on real iOS Safari (it shrinks with the keyboard too), so we use the
73
+ // self-correcting baseline from webViewport instead. see getStableLayoutViewportHeight.
74
+ function getStableViewportHeight(): number {
75
+ if (isWeb && typeof window !== 'undefined') return getStableLayoutViewportHeight()
76
+ return Dimensions.get(relativeDimensionTo).height
77
+ }
78
+
60
79
  export const SheetImplementationCustom = React.forwardRef<View, SheetProps>(
61
80
  function SheetImplementationCustom(props, forwardedRef) {
62
81
  const parentSheet = React.useContext(ParentSheetContext)
@@ -142,6 +161,19 @@ export const SheetImplementationCustom = React.forwardRef<View, SheetProps>(
142
161
  setIsShowingInnerSheet(hasChild)
143
162
  }, [])
144
163
 
164
+ // keyboard state tracking — just tracks height/visibility, no position animation.
165
+ // Position animation is handled via keyboard-adjusted positions below,
166
+ // matching the react-native-actions-sheet pattern.
167
+ const {
168
+ keyboardHeight,
169
+ isKeyboardVisible,
170
+ dismissKeyboard,
171
+ pauseKeyboardHandler,
172
+ flushPendingHide,
173
+ } = useKeyboardControllerSheet({
174
+ enabled: Boolean(moveOnKeyboardChange),
175
+ })
176
+
145
177
  // FIX: Store stable frameSize to prevent recalculation during exit animation
146
178
  const stableFrameSize = React.useRef(frameSize)
147
179
 
@@ -152,31 +184,42 @@ export const SheetImplementationCustom = React.forwardRef<View, SheetProps>(
152
184
  }
153
185
  }, [open, frameSize])
154
186
 
155
- // use stableFrameSize when closing to prevent position jumps during exit animation
156
- // but when opening, always use the current frameSize so positions update correctly
187
+ // WEB keyboard anchor freeze. on real iOS Safari opening the keyboard shrinks
188
+ // the visual viewport AND innerHeight AND the measured layout, which would
189
+ // re-derive screenSize/frameSize smaller, recompute the fit positions, and
190
+ // fly the frame up then back down ("goes back down after the keyboard opens").
191
+ // so we snapshot the pre-keyboard geometry — captured every render while the
192
+ // keyboard is CLOSED, which dodges the open-transition race where a shrunk
193
+ // onLayout lands before isKeyboardVisible flips — and use it for the anchor
194
+ // math while the keyboard is open. the sheet then stays put; only the scroll
195
+ // content shifts to clear the keyboard (keyboardOccludedHeight padding below
196
+ // + SheetScrollView's frozen height).
197
+ // this sheet is the kind the web keyboard anchor freeze is designed for — a
198
+ // fit-mode web sheet opted into keyboard handling. percent/constant sheets
199
+ // keep the live geometry (their height isn't pinned, so a frozen anchor
200
+ // would mismatch).
201
+ const isWebKbSheet = isWeb && hasFit && moveOnKeyboardChange
202
+
203
+ // the space the snap positions are built against. WEB: the stable layout
204
+ // viewport (document.documentElement.clientHeight), which the soft keyboard
205
+ // never shrinks (unlike the measured screenSize / visualViewport). NATIVE:
206
+ // the measured screenSize. positions are then shifted UP by keyboardHeight
207
+ // when the keyboard opens (activePositions, below) — the whole device-height
208
+ // frame slides up so its content clears the keyboard, capped at the safe area.
209
+ const effScreenSize = isWebKbSheet ? getStableViewportHeight() : screenSize
210
+
211
+ // use stableFrameSize when closing to prevent position jumps during the exit
212
+ // animation; while open use the live frameSize.
157
213
  const effectiveFrameSize = open ? frameSize : stableFrameSize.current || frameSize
158
214
 
159
215
  const positions = React.useMemo(
160
216
  () =>
161
217
  snapPoints.map((point) =>
162
- getYPositions(snapPointsMode, point, screenSize, effectiveFrameSize)
218
+ getYPositions(snapPointsMode, point, effScreenSize, effectiveFrameSize)
163
219
  ),
164
- [screenSize, effectiveFrameSize, snapPoints, snapPointsMode]
220
+ [effScreenSize, effectiveFrameSize, snapPoints, snapPointsMode]
165
221
  )
166
222
 
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
223
  const [isDragging, setIsDragging_] = React.useState(false)
181
224
 
182
225
  // synchronous dragging ref — set BEFORE async state commits.
@@ -198,28 +241,30 @@ export const SheetImplementationCustom = React.forwardRef<View, SheetProps>(
198
241
  [pauseKeyboardHandler, flushPendingHide]
199
242
  )
200
243
 
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).
244
+ // keyboard-adjusted snap positions.
245
+ //
246
+ // WEB: the sheet stays ANCHORED at the bottom and keeps its full pre-keyboard
247
+ // height the keyboard is an overlay, so the frame neither shifts nor resizes.
248
+ // Avoidance is handled below the frame: a bottom spacer (keyboardOccludedHeight
249
+ // = keyboardHeight) extends the scroll content so the lower content (e.g. the
250
+ // footer) can scroll up clear of the keyboard, and SheetScrollView scrolls the
251
+ // focused input above it. So activePositions === positions on web (no shift).
252
+ //
253
+ // NATIVE: shift snap points up by keyboard height (the native keyboard is
254
+ // opaque and pushes content), capped at the safe-area top inset.
206
255
  //
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.
256
+ // IMPORTANT: frozen during drag to prevent gesture handler recreation
257
+ // when a TextInput blurs mid-drag the keyboard state would otherwise revert
258
+ // and recreate the gesture useMemo, cancelling the active drag.
211
259
  const activePositionsRef = React.useRef(positions)
212
260
  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
261
  if (isDragging || isDraggingRef.current) return activePositionsRef.current
217
262
 
218
263
  let result: number[]
219
264
  if (!isKeyboardVisible || keyboardHeight <= 0) {
220
265
  result = positions
221
266
  } else {
222
- const safeAreaTop = isWeb ? 0 : getSafeAreaTopInset()
267
+ const safeAreaTop = getSafeAreaTopInset()
223
268
  result = positions.map((p) => {
224
269
  // don't adjust the off-screen/close position (from dismissOnSnapToBottom's 0% snap)
225
270
  // — it must stay at screenSize so the user can drag between real snap points
@@ -232,14 +277,27 @@ export const SheetImplementationCustom = React.forwardRef<View, SheetProps>(
232
277
  return result
233
278
  }, [positions, isKeyboardVisible, keyboardHeight, screenSize, isDragging])
234
279
 
280
+ // bottom spacer for a sheet TALLER than the visible band: once the frame has
281
+ // shifted up as far as the safe-area cap allows, its lowest content can still
282
+ // sit behind the keyboard. the spacer = exactly that occluded height, so the
283
+ // footer can scroll up clear of the keyboard. when the sheet fits above the
284
+ // keyboard this returns 0 (no spacer, no over-scroll). same logic web + native.
235
285
  const keyboardOccludedHeight = getKeyboardOccludedHeight({
236
- frameSize,
237
- isKeyboardVisible: !isWeb && isKeyboardVisible,
286
+ frameSize: effectiveFrameSize,
287
+ isKeyboardVisible,
238
288
  keyboardHeight,
239
- screenSize,
289
+ screenSize: effScreenSize,
240
290
  sheetY: position >= 0 ? activePositions[position] : undefined,
241
291
  })
242
292
 
293
+ // pin the scroll view to the held (pre-keyboard) frame height while the
294
+ // keyboard is up on web. on older iOS the consumer's window-derived maxHeight
295
+ // shrinks with the keyboard, which would clip the scroll view (and the frame)
296
+ // smaller; this override keeps it at the full height so the frame only
297
+ // translates up, never resizes. 0 = no override (use the consumer maxHeight).
298
+ const keyboardStableFrameHeight =
299
+ isWebKbSheet && isKeyboardVisible && frameSize > 0 ? frameSize : 0
300
+
243
301
  const { useAnimatedNumber, useAnimatedNumberStyle, useAnimatedNumberReaction } =
244
302
  animationDriver
245
303
  const AnimatedView = (animationDriver.View ?? TamaguiView) as typeof Animated.View
@@ -325,7 +383,19 @@ export const SheetImplementationCustom = React.forwardRef<View, SheetProps>(
325
383
  const animateTo = useEvent((position: number, animationOverride?: any) => {
326
384
  if (frameSize === 0) return
327
385
 
328
- let toValue = isHidden || position === -1 ? screenSize : activePositions[position]
386
+ // use effScreenSize (the frozen anchor space the positions were built in) for
387
+ // the off-screen/close target too, so a close while the keyboard is still up
388
+ // animates fully out instead of to a mismatched live screenSize.
389
+ //
390
+ // web: clear the maximum the viewport can ever reveal, not just the current
391
+ // layout viewport. iOS Safari retracts its chrome on scroll and exposes area
392
+ // below the current viewport, so a sheet parked at effScreenSize would peek
393
+ // back in as the page scrolls. getMaxViewportHeight floors the target past
394
+ // anything Safari can expose.
395
+ const closeTarget = isWeb
396
+ ? Math.max(effScreenSize, getMaxViewportHeight())
397
+ : effScreenSize
398
+ let toValue = isHidden || position === -1 ? closeTarget : activePositions[position]
329
399
 
330
400
  if (at.current === toValue) return
331
401
 
@@ -417,6 +487,16 @@ export const SheetImplementationCustom = React.forwardRef<View, SheetProps>(
417
487
  return
418
488
  }
419
489
 
490
+ // never fight an active drag: the gesture owns the animated position. on
491
+ // web the AnimatedView's onLayout re-fires with sub-pixel jitter as the
492
+ // frame translates (frameSize 499.99996 <-> 500.00003), and since frameSize
493
+ // is a dep of this effect that would re-run it mid-pull and snap the sheet
494
+ // back to its resting snap point. read the live ref so drag-end (which the
495
+ // reconcile-after-drag effect handles) isn't gated by stale deps.
496
+ if (isDraggingRef.current) {
497
+ return
498
+ }
499
+
420
500
  if (!frameSize || !screenSize || isHidden || (hasntMeasured && !open)) {
421
501
  return
422
502
  }
@@ -443,6 +523,10 @@ export const SheetImplementationCustom = React.forwardRef<View, SheetProps>(
443
523
  scrollBridge.setScrollEnabled?.(false)
444
524
  }
445
525
  }
526
+ // NOTE: effScreenSize/effectiveFrameSize are intentionally NOT deps. With the
527
+ // spacer approach the frame's position target is frozen across keyboard
528
+ // open/close (same stable baseline), so it must NOT re-animate — keyboard
529
+ // avoidance is the bottom spacer + scroll, not a frame move.
446
530
  }, [hasntMeasured, disableAnimation, isHidden, frameSize, screenSize, open, position])
447
531
 
448
532
  const disableDrag = props.disableDrag ?? controller?.disableDrag
@@ -454,7 +538,11 @@ export const SheetImplementationCustom = React.forwardRef<View, SheetProps>(
454
538
  if (!frameSize) return
455
539
  if (isShowingInnerSheet) return
456
540
 
457
- const minY = positions[0]
541
+ // use keyboard-adjusted positions (matches the RNGH path): when the
542
+ // keyboard is open the sheet sits at activePositions[0], so clamping drags
543
+ // against the un-adjusted positions[0] would rubber-band the sheet down to
544
+ // near the bottom on any drag.
545
+ const minY = activePositions[0]
458
546
  scrollBridge.paneMinY = minY
459
547
  let startY = at.current
460
548
 
@@ -497,8 +585,8 @@ export const SheetImplementationCustom = React.forwardRef<View, SheetProps>(
497
585
  let closestPoint = 0
498
586
  let dist = Number.POSITIVE_INFINITY
499
587
 
500
- for (let i = 0; i < positions.length; i++) {
501
- const position = positions[i]
588
+ for (let i = 0; i < activePositions.length; i++) {
589
+ const position = activePositions[i]
502
590
  const curDist = end > position ? end - position : position - end
503
591
  if (curDist < dist) {
504
592
  dist = curDist
@@ -530,6 +618,16 @@ export const SheetImplementationCustom = React.forwardRef<View, SheetProps>(
530
618
  return true
531
619
  }
532
620
 
621
+ // touch is on the ScrollView node — the web scroll-view gesture hook
622
+ // owns it and drives drag/release through scrollBridge directly (it
623
+ // re-baselines via scrollBridge.startPanDrag on each pan handoff). if
624
+ // we also granted here, RNW's PanResponder would set the animated
625
+ // position from a second, differently-based offset every move and the
626
+ // sheet would jitter/jump. defer entirely to the hook.
627
+ if (scrollBridge.scrollNodeTouched) {
628
+ return false
629
+ }
630
+
533
631
  if (scrollBridge.hasScrollableContent === true) {
534
632
  if (scrollBridge.scrollLock) {
535
633
  return false
@@ -575,6 +673,17 @@ export const SheetImplementationCustom = React.forwardRef<View, SheetProps>(
575
673
 
576
674
  let isExternalDrag = false
577
675
 
676
+ // re-baseline a pan drag to the current animated position. the web
677
+ // scroll-view hook calls this on every transition INTO pan ownership
678
+ // (including handoffs back from scroll), so its panDragOffset — which it
679
+ // resets to 0 at each pan entry — is measured from where the sheet
680
+ // actually is now, not from where the gesture first grabbed it. without
681
+ // this the sheet would jump to a stale origin on a scroll→pan handoff.
682
+ scrollBridge.startPanDrag = () => {
683
+ isExternalDrag = true
684
+ grant()
685
+ }
686
+
578
687
  scrollBridge.drag = (dy) => {
579
688
  if (!isExternalDrag) {
580
689
  isExternalDrag = true
@@ -616,13 +725,25 @@ export const SheetImplementationCustom = React.forwardRef<View, SheetProps>(
616
725
  onPanResponderTerminate: finish,
617
726
  onPanResponderRelease: finish,
618
727
  })
619
- }, [disableDrag, isShowingInnerSheet, animateTo, frameSize, positions, setPosition])
620
-
621
- // animate to keyboard-adjusted position when keyboard state changes
728
+ }, [
729
+ disableDrag,
730
+ isShowingInnerSheet,
731
+ animateTo,
732
+ frameSize,
733
+ activePositions,
734
+ setPosition,
735
+ ])
736
+
737
+ // animate to the keyboard-adjusted position when the keyboard state changes.
738
+ // both web and native shift the whole frame UP by the keyboard height (capped
739
+ // at the top safe area, in activePositions) so the content keeps its position
740
+ // relative to the visible area — it was resting on the device bottom, now it
741
+ // rests on the keyboard top. the frame is device-height, so sliding it up never
742
+ // reveals a gap below the content.
622
743
  React.useEffect(() => {
623
744
  if (isDragging || isHidden || !open || disableAnimation) return
624
745
  if (!frameSize || !screenSize) return
625
- // use timing animation to match iOS keyboard animation (~250ms)
746
+ // timing animation matches the iOS keyboard animation (~250ms)
626
747
  animateTo(position, { type: 'timing', duration: 250 })
627
748
  }, [isKeyboardVisible, keyboardHeight])
628
749
 
@@ -647,6 +768,11 @@ export const SheetImplementationCustom = React.forwardRef<View, SheetProps>(
647
768
  React.useEffect(() => {
648
769
  if (!open && isKeyboardVisible) {
649
770
  dismissKeyboard()
771
+ // if the sheet was closed mid-drag the keyboard-hide handler was paused
772
+ // and a hide could be left pending — clear it so isKeyboardVisible can't
773
+ // stick true after the sheet is gone. (no-op for a normal close.)
774
+ pauseKeyboardHandler.current = false
775
+ flushPendingHide()
650
776
  }
651
777
  }, [open])
652
778
 
@@ -671,31 +797,57 @@ export const SheetImplementationCustom = React.forwardRef<View, SheetProps>(
671
797
  pauseKeyboardHandler,
672
798
  })
673
799
 
800
+ // ignore any layout measured while the soft keyboard is up (web +
801
+ // moveOnKeyboardChange): the visual viewport (which RN's web layout follows)
802
+ // shrinks, so the height would be the collapsed sheet — keeping it would
803
+ // recompute the fit anchor and fly the frame. a LIVE DOM check, NOT the
804
+ // isKeyboardVisible React state, is required: the state lags the resize, so
805
+ // the first shrunk onLayout lands before the flag flips. holding the measured
806
+ // sizes keeps the sheet anchored; the scroll content shifts to clear the kb.
807
+ const ignoreLayoutForKeyboard = useEvent(
808
+ () => isWeb && moveOnKeyboardChange && getWebKeyboardHeight() >= MIN_KEYBOARD_HEIGHT
809
+ )
810
+
674
811
  const handleAnimationViewLayout = useEvent((e: LayoutChangeEvent) => {
675
812
  // don't update frameSize during exit animation to prevent position jumps
676
813
  if (!open && stableFrameSize.current !== 0) {
677
814
  return
678
815
  }
679
816
 
817
+ const layoutHeight = e.nativeEvent?.layout.height
818
+ // drop a layout measured while the keyboard is up: on older iOS the web
819
+ // viewport shrinks and the frame would resize. keep the pre-keyboard frame
820
+ // height so the frame just TRANSLATES up (activePositions) without resizing.
821
+ // exception: if we have no frame height yet (sheet opened with the keyboard
822
+ // already up), accept it so the sheet can appear at all.
823
+ if (ignoreLayoutForKeyboard() && frameSize > 0) return
824
+
680
825
  // avoid bugs where it grows forever for whatever reason
681
826
  // For inline mode (non-modal), don't cap at window height - use actual layout
682
- const layoutHeight = e.nativeEvent?.layout.height
683
827
  const next = modal
684
- ? Math.min(layoutHeight, Dimensions.get(relativeDimensionTo).height)
828
+ ? Math.min(layoutHeight, getStableViewportHeight())
685
829
  : layoutHeight
686
830
  if (!next) return
687
- setFrameSize(next)
831
+ // round: web onLayout reports sub-pixel heights (e.g. 499.99996) that jitter
832
+ // frame to frame as the view transforms; the raw float would re-fire every
833
+ // effect that depends on frameSize on each drag move.
834
+ setFrameSize(Math.round(next))
688
835
  })
689
836
 
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
- }, [])
837
+ const handleMaxContentViewLayout = React.useCallback(
838
+ (e: LayoutChangeEvent) => {
839
+ // keep maxContentSize at the full pre-keyboard viewport: drop layouts
840
+ // measured while the keyboard is up (the shrunk viewport), unless we have
841
+ // none yet (keyboard-already-up open).
842
+ if (ignoreLayoutForKeyboard() && screenSize > 0) return
843
+ // avoid bugs where it grows forever for whatever reason
844
+ const next = Math.min(e.nativeEvent?.layout.height, getStableViewportHeight())
845
+ if (!next) return
846
+ // round to avoid sub-pixel churn re-firing size-dependent effects
847
+ setMaxContentSize(Math.round(next))
848
+ },
849
+ [ignoreLayoutForKeyboard, screenSize]
850
+ )
699
851
 
700
852
  const getAnimatedNumberStyle = React.useCallback(
701
853
  (val: number) => {
@@ -745,6 +897,8 @@ export const SheetImplementationCustom = React.forwardRef<View, SheetProps>(
745
897
  <SheetProvider
746
898
  {...providerProps}
747
899
  keyboardOccludedHeight={keyboardOccludedHeight}
900
+ isKeyboardVisible={isKeyboardVisible}
901
+ keyboardStableFrameHeight={keyboardStableFrameHeight}
748
902
  setHasScrollView={setHasScrollView}
749
903
  >
750
904
  <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,35 @@ 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
+ // when the keyboard is open, pin the scroll view to the sheet's pre-keyboard
71
+ // frame height (frozenFrameHeight), overriding any consumer maxHeight. on web
72
+ // that maxHeight is often tied to useWindowDimensions, which SHRINKS when the
73
+ // keyboard opens and would otherwise collapse the sheet. holding the height
74
+ // constant means the frame only TRANSLATES up (no resize, no jump). applied
75
+ // AFTER {...props} so it wins.
76
+ const keyboardFrozenOverride =
77
+ hasFit && isKeyboardVisible && frozenFrameHeight > 0
78
+ ? { height: frozenFrameHeight, maxHeight: frozenFrameHeight }
79
+ : null
80
+
62
81
  const panGestureRef = gestureContext?.panGestureRef
63
82
  const { ScrollView: RNGHScrollView } = getGestureHandlerState()
64
83
  const useRNGHScrollView = isGestureHandlerEnabled() && RNGHScrollView && panGestureRef
@@ -102,6 +121,12 @@ export const SheetScrollView = React.forwardRef<
102
121
  }
103
122
  }
104
123
 
124
+ // track the fit height for the scrollable-content check. the keyboard-freeze
125
+ // height is supplied by the sheet (frozenFrameHeight), not derived here.
126
+ const recordFitHeight = (height: number) => {
127
+ parentHeight.current = height
128
+ }
129
+
105
130
  useEffect(() => {
106
131
  scrollBridge.hasScrollableContent = hasScrollableContent
107
132
  }, [hasScrollableContent])
@@ -148,7 +173,7 @@ export const SheetScrollView = React.forwardRef<
148
173
  scrollEnabled={scrollEnabled}
149
174
  simultaneousHandlers={[panGestureRef]}
150
175
  onLayout={(e: any) => {
151
- parentHeight.current = Math.ceil(e.nativeEvent.layout.height)
176
+ recordFitHeight(Math.ceil(e.nativeEvent.layout.height))
152
177
  updateScrollable()
153
178
  }}
154
179
  onScroll={(e: any) => {
@@ -186,6 +211,7 @@ export const SheetScrollView = React.forwardRef<
186
211
  keyboardShouldPersistTaps="always"
187
212
  keyboardDismissMode="none"
188
213
  {...props}
214
+ {...keyboardFrozenOverride}
189
215
  >
190
216
  {contentWrapper}
191
217
  </RNGHComponent>
@@ -196,7 +222,7 @@ export const SheetScrollView = React.forwardRef<
196
222
  return (
197
223
  <ScrollView
198
224
  onLayout={(e) => {
199
- parentHeight.current = Math.ceil(e.nativeEvent.layout.height)
225
+ recordFitHeight(Math.ceil(e.nativeEvent.layout.height))
200
226
  updateScrollable()
201
227
  }}
202
228
  ref={composeRefs(scrollRef as any, ref)}
@@ -213,6 +239,7 @@ export const SheetScrollView = React.forwardRef<
213
239
  contentContainerStyle={{ minHeight: '100%' }}
214
240
  {...gestureProps}
215
241
  {...props}
242
+ {...keyboardFrozenOverride}
216
243
  >
217
244
  {contentWrapper}
218
245
  </ScrollView>
@@ -1,5 +1,5 @@
1
1
  import { useComposedRefs } from '@tamagui/compose-refs'
2
- import { useIsomorphicLayoutEffect } from '@tamagui/constants'
2
+ import { isWeb, useIsomorphicLayoutEffect } from '@tamagui/constants'
3
3
  import type {
4
4
  GetProps,
5
5
  ViewProps,
@@ -25,6 +25,7 @@ import { SheetScrollView } from './SheetScrollView'
25
25
  import type { SheetProps, SheetScopedProps } from './types'
26
26
  import { useSheetController } from './useSheetController'
27
27
  import { useSheetOffscreenSize } from './useSheetOffscreenSize'
28
+ import { getMaxViewportHeight } from './webViewport'
28
29
 
29
30
  type SharedSheetProps = {
30
31
  open?: boolean
@@ -136,9 +137,9 @@ export function createSheet<
136
137
 
137
138
  type ExtraFrameProps = {
138
139
  /**
139
- * By default the sheet adds a view below its bottom that extends down another 50%,
140
- * this is useful if your Sheet has a spring animation that bounces "past" the top when
141
- * opening, preventing it from showing the content underneath.
140
+ * by default the sheet adds a view below its bottom that extends past the
141
+ * largest visible viewport height. this covers spring overshoot when opening
142
+ * so page content never shows through below the sheet.
142
143
  */
143
144
  disableHideBottomOverflow?: boolean
144
145
 
@@ -225,14 +226,25 @@ export function createSheet<
225
226
  <Frame
226
227
  {...props}
227
228
  componentName="SheetCover"
229
+ data-sheet-cover=""
228
230
  children={null}
229
231
  // Don't inherit testID - this is a visual helper element
230
232
  testID={undefined}
231
233
  id={undefined}
232
234
  position="absolute"
233
- bottom="-100%"
235
+ // anchor the cover's top at the sheet's bottom edge (top: 100% of the
236
+ // container), then extend it downward. on web extend it past the
237
+ // largest viewport safari can reveal as its chrome retracts, so the
238
+ // sheet background covers the bottom instead of revealing the page.
239
+ // native keeps frameSize.
240
+ top="100%"
234
241
  zIndex={-1}
235
- height={context.frameSize}
242
+ height={
243
+ isWeb
244
+ ? Math.max(context.frameSize, getMaxViewportHeight())
245
+ : context.frameSize
246
+ }
247
+ maxHeight={isWeb ? 'none' : undefined}
236
248
  left={0}
237
249
  right={0}
238
250
  borderWidth={0}
@@ -62,6 +62,8 @@ export function setupNativeSheet(
62
62
  <SheetProvider
63
63
  setHasScrollView={emptyFn}
64
64
  keyboardOccludedHeight={0}
65
+ isKeyboardVisible={false}
66
+ keyboardStableFrameHeight={0}
65
67
  {...providerProps}
66
68
  onlyShowFrame
67
69
  >