@tamagui/sheet 2.1.0 → 2.2.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 (165) hide show
  1. package/.turbo/turbo-build.log +3 -2
  2. package/dist/cjs/GestureDetectorWrapper.native.js.map +1 -1
  3. package/dist/cjs/GestureSheetContext.native.js.map +1 -1
  4. package/dist/cjs/Sheet.native.js.map +1 -1
  5. package/dist/cjs/SheetContext.native.js.map +1 -1
  6. package/dist/cjs/SheetController.native.js.map +1 -1
  7. package/dist/cjs/SheetImplementationCustom.cjs +41 -64
  8. package/dist/cjs/SheetImplementationCustom.native.js +56 -69
  9. package/dist/cjs/SheetImplementationCustom.native.js.map +1 -1
  10. package/dist/cjs/SheetScrollView.cjs +57 -5
  11. package/dist/cjs/SheetScrollView.native.js +60 -5
  12. package/dist/cjs/SheetScrollView.native.js.map +1 -1
  13. package/dist/cjs/constants.native.js.map +1 -1
  14. package/dist/cjs/contexts.native.js.map +1 -1
  15. package/dist/cjs/controller.native.js.map +1 -1
  16. package/dist/cjs/createSheet.cjs +5 -2
  17. package/dist/cjs/createSheet.native.js +5 -2
  18. package/dist/cjs/createSheet.native.js.map +1 -1
  19. package/dist/cjs/gestureState.native.js.map +1 -1
  20. package/dist/cjs/helpers.native.js.map +1 -1
  21. package/dist/cjs/index.native.js.map +1 -1
  22. package/dist/cjs/keyboardAvoidance.cjs +52 -1
  23. package/dist/cjs/keyboardAvoidance.native.js +54 -1
  24. package/dist/cjs/keyboardAvoidance.native.js.map +1 -1
  25. package/dist/cjs/nativeSheet.cjs +0 -1
  26. package/dist/cjs/nativeSheet.native.js +0 -1
  27. package/dist/cjs/nativeSheet.native.js.map +1 -1
  28. package/dist/cjs/setupGestureHandler.native.js.map +1 -1
  29. package/dist/cjs/types.native.js.map +1 -1
  30. package/dist/cjs/useGestureHandlerPan.cjs +25 -10
  31. package/dist/cjs/useGestureHandlerPan.native.js +27 -10
  32. package/dist/cjs/useGestureHandlerPan.native.js.map +1 -1
  33. package/dist/cjs/useKeyboardControllerSheet.cjs +10 -4
  34. package/dist/cjs/useSafeAreaInsets.cjs +44 -0
  35. package/dist/cjs/useSafeAreaInsets.native.js +48 -0
  36. package/dist/cjs/useSafeAreaInsets.native.js.map +1 -0
  37. package/dist/cjs/useSheet.native.js.map +1 -1
  38. package/dist/cjs/useSheetController.native.js.map +1 -1
  39. package/dist/cjs/useSheetOffscreenSize.native.js.map +1 -1
  40. package/dist/cjs/useSheetOpenState.native.js.map +1 -1
  41. package/dist/cjs/useSheetProviderProps.native.js.map +1 -1
  42. package/dist/cjs/useSheetScrollViewGestures.cjs +12 -2
  43. package/dist/cjs/useSheetScrollViewGestures.native.js +4 -0
  44. package/dist/cjs/useSheetScrollViewGestures.native.js.map +1 -1
  45. package/dist/cjs/webViewport.cjs +26 -1
  46. package/dist/cjs/webViewport.native.js +28 -1
  47. package/dist/cjs/webViewport.native.js.map +1 -1
  48. package/dist/esm/SheetImplementationCustom.mjs +48 -73
  49. package/dist/esm/SheetImplementationCustom.mjs.map +1 -1
  50. package/dist/esm/SheetImplementationCustom.native.js +70 -89
  51. package/dist/esm/SheetImplementationCustom.native.js.map +1 -1
  52. package/dist/esm/SheetScrollView.mjs +58 -6
  53. package/dist/esm/SheetScrollView.mjs.map +1 -1
  54. package/dist/esm/SheetScrollView.native.js +61 -6
  55. package/dist/esm/SheetScrollView.native.js.map +1 -1
  56. package/dist/esm/createSheet.mjs +7 -5
  57. package/dist/esm/createSheet.mjs.map +1 -1
  58. package/dist/esm/createSheet.native.js +17 -14
  59. package/dist/esm/createSheet.native.js.map +1 -1
  60. package/dist/esm/keyboardAvoidance.mjs +50 -1
  61. package/dist/esm/keyboardAvoidance.mjs.map +1 -1
  62. package/dist/esm/keyboardAvoidance.native.js +52 -1
  63. package/dist/esm/keyboardAvoidance.native.js.map +1 -1
  64. package/dist/esm/nativeSheet.mjs +0 -1
  65. package/dist/esm/nativeSheet.mjs.map +1 -1
  66. package/dist/esm/nativeSheet.native.js +0 -1
  67. package/dist/esm/nativeSheet.native.js.map +1 -1
  68. package/dist/esm/useGestureHandlerPan.mjs +25 -10
  69. package/dist/esm/useGestureHandlerPan.mjs.map +1 -1
  70. package/dist/esm/useGestureHandlerPan.native.js +27 -10
  71. package/dist/esm/useGestureHandlerPan.native.js.map +1 -1
  72. package/dist/esm/useKeyboardControllerSheet.mjs +11 -5
  73. package/dist/esm/useKeyboardControllerSheet.mjs.map +1 -1
  74. package/dist/esm/useSafeAreaInsets.mjs +7 -0
  75. package/dist/esm/useSafeAreaInsets.mjs.map +1 -0
  76. package/dist/esm/useSafeAreaInsets.native.js +8 -0
  77. package/dist/esm/useSafeAreaInsets.native.js.map +1 -0
  78. package/dist/esm/useSheetProviderProps.mjs.map +1 -1
  79. package/dist/esm/useSheetProviderProps.native.js.map +1 -1
  80. package/dist/esm/useSheetScrollViewGestures.mjs +12 -2
  81. package/dist/esm/useSheetScrollViewGestures.mjs.map +1 -1
  82. package/dist/esm/useSheetScrollViewGestures.native.js +4 -0
  83. package/dist/esm/useSheetScrollViewGestures.native.js.map +1 -1
  84. package/dist/esm/webViewport.mjs +23 -2
  85. package/dist/esm/webViewport.mjs.map +1 -1
  86. package/dist/esm/webViewport.native.js +25 -2
  87. package/dist/esm/webViewport.native.js.map +1 -1
  88. package/dist/jsx/SheetImplementationCustom.mjs +48 -73
  89. package/dist/jsx/SheetImplementationCustom.mjs.map +1 -1
  90. package/dist/jsx/SheetImplementationCustom.native.js +56 -69
  91. package/dist/jsx/SheetImplementationCustom.native.js.map +1 -1
  92. package/dist/jsx/SheetScrollView.mjs +58 -6
  93. package/dist/jsx/SheetScrollView.mjs.map +1 -1
  94. package/dist/jsx/SheetScrollView.native.js +60 -5
  95. package/dist/jsx/SheetScrollView.native.js.map +1 -1
  96. package/dist/jsx/createSheet.mjs +7 -5
  97. package/dist/jsx/createSheet.mjs.map +1 -1
  98. package/dist/jsx/createSheet.native.js +5 -2
  99. package/dist/jsx/createSheet.native.js.map +1 -1
  100. package/dist/jsx/keyboardAvoidance.mjs +50 -1
  101. package/dist/jsx/keyboardAvoidance.mjs.map +1 -1
  102. package/dist/jsx/keyboardAvoidance.native.js +54 -1
  103. package/dist/jsx/keyboardAvoidance.native.js.map +1 -1
  104. package/dist/jsx/nativeSheet.mjs +0 -1
  105. package/dist/jsx/nativeSheet.mjs.map +1 -1
  106. package/dist/jsx/nativeSheet.native.js +0 -1
  107. package/dist/jsx/nativeSheet.native.js.map +1 -1
  108. package/dist/jsx/useGestureHandlerPan.mjs +25 -10
  109. package/dist/jsx/useGestureHandlerPan.mjs.map +1 -1
  110. package/dist/jsx/useGestureHandlerPan.native.js +27 -10
  111. package/dist/jsx/useGestureHandlerPan.native.js.map +1 -1
  112. package/dist/jsx/useKeyboardControllerSheet.mjs +11 -5
  113. package/dist/jsx/useKeyboardControllerSheet.mjs.map +1 -1
  114. package/dist/jsx/useSafeAreaInsets.mjs +7 -0
  115. package/dist/jsx/useSafeAreaInsets.mjs.map +1 -0
  116. package/dist/jsx/useSafeAreaInsets.native.js +48 -0
  117. package/dist/jsx/useSafeAreaInsets.native.js.map +1 -0
  118. package/dist/jsx/useSheetProviderProps.mjs.map +1 -1
  119. package/dist/jsx/useSheetProviderProps.native.js.map +1 -1
  120. package/dist/jsx/useSheetScrollViewGestures.mjs +12 -2
  121. package/dist/jsx/useSheetScrollViewGestures.mjs.map +1 -1
  122. package/dist/jsx/useSheetScrollViewGestures.native.js +4 -0
  123. package/dist/jsx/useSheetScrollViewGestures.native.js.map +1 -1
  124. package/dist/jsx/webViewport.mjs +23 -2
  125. package/dist/jsx/webViewport.mjs.map +1 -1
  126. package/dist/jsx/webViewport.native.js +28 -1
  127. package/dist/jsx/webViewport.native.js.map +1 -1
  128. package/package.json +24 -25
  129. package/src/SheetImplementationCustom.tsx +147 -212
  130. package/src/SheetScrollView.tsx +81 -21
  131. package/src/createSheet.tsx +18 -6
  132. package/src/keyboardAvoidance.ts +91 -0
  133. package/src/nativeSheet.tsx +0 -1
  134. package/src/useGestureHandlerPan.tsx +29 -12
  135. package/src/useKeyboardControllerSheet.ts +35 -14
  136. package/src/useSafeAreaInsets.native.ts +29 -0
  137. package/src/useSafeAreaInsets.ts +19 -0
  138. package/src/useSheetProviderProps.tsx +4 -10
  139. package/src/useSheetScrollViewGestures.native.ts +4 -0
  140. package/src/useSheetScrollViewGestures.ts +19 -8
  141. package/src/webViewport.ts +56 -8
  142. package/test/keyboardAvoidance.test.ts +218 -2
  143. package/types/SheetContext.d.ts +0 -1
  144. package/types/SheetContext.d.ts.map +1 -1
  145. package/types/SheetImplementationCustom.d.ts.map +1 -1
  146. package/types/SheetScrollView.d.ts.map +1 -1
  147. package/types/createSheet.d.ts +12 -12
  148. package/types/createSheet.d.ts.map +1 -1
  149. package/types/keyboardAvoidance.d.ts +18 -0
  150. package/types/keyboardAvoidance.d.ts.map +1 -1
  151. package/types/nativeSheet.d.ts.map +1 -1
  152. package/types/useGestureHandlerPan.d.ts +4 -1
  153. package/types/useGestureHandlerPan.d.ts.map +1 -1
  154. package/types/useKeyboardControllerSheet.d.ts +5 -5
  155. package/types/useKeyboardControllerSheet.d.ts.map +1 -1
  156. package/types/useSafeAreaInsets.d.ts +10 -0
  157. package/types/useSafeAreaInsets.d.ts.map +1 -0
  158. package/types/useSafeAreaInsets.native.d.ts +20 -0
  159. package/types/useSafeAreaInsets.native.d.ts.map +1 -0
  160. package/types/useSheetProviderProps.d.ts +0 -1
  161. package/types/useSheetProviderProps.d.ts.map +1 -1
  162. package/types/useSheetScrollViewGestures.d.ts.map +1 -1
  163. package/types/useSheetScrollViewGestures.native.d.ts.map +1 -1
  164. package/types/webViewport.d.ts +13 -7
  165. package/types/webViewport.d.ts.map +1 -1
@@ -10,7 +10,13 @@ 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
+ import {
14
+ getStableLayoutViewportHeight,
15
+ getWebKeyboardBottomInset,
16
+ getWebKeyboardResizeHeight,
17
+ isEditableElement,
18
+ MIN_KEYBOARD_HEIGHT,
19
+ } from './webViewport'
14
20
 
15
21
  const SHEET_SCROLL_VIEW_NAME = 'SheetScrollView'
16
22
 
@@ -39,7 +45,7 @@ export const SheetScrollView = React.forwardRef<
39
45
  // height freeze engages on the same render that would otherwise collapse it.
40
46
  const isKeyboardVisible =
41
47
  context.isKeyboardVisible === true ||
42
- (isWeb && getWebKeyboardHeight() >= MIN_KEYBOARD_HEIGHT)
48
+ (isWeb && getWebKeyboardResizeHeight() >= MIN_KEYBOARD_HEIGHT)
43
49
  const [scrollEnabled] = useControllableState({
44
50
  prop: scrollEnabledProp,
45
51
  defaultProp: true,
@@ -67,27 +73,15 @@ export const SheetScrollView = React.forwardRef<
67
73
  }
68
74
  : { flex: 1 }
69
75
 
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.
76
+ // when the keyboard is open, pin the scroll view to the sheet's pre-keyboard
77
+ // frame height (frozenFrameHeight), overriding any consumer maxHeight. on web
78
+ // that maxHeight is often tied to useWindowDimensions, which SHRINKS when the
79
+ // keyboard opens and would otherwise collapse the sheet. holding the height
80
+ // constant means the web frame can translate without resizing. applied
81
+ // AFTER {...props} so it wins.
86
82
  const keyboardFrozenOverride =
87
83
  hasFit && isKeyboardVisible && frozenFrameHeight > 0
88
- ? isKeyboardSeeding
89
- ? { maxHeight: frozenFrameHeight }
90
- : { height: frozenFrameHeight, maxHeight: frozenFrameHeight }
84
+ ? { height: frozenFrameHeight, maxHeight: frozenFrameHeight }
91
85
  : null
92
86
 
93
87
  const panGestureRef = gestureContext?.panGestureRef
@@ -97,6 +91,7 @@ export const SheetScrollView = React.forwardRef<
97
91
  // RNGH scroll locking state
98
92
  const currentScrollOffset = useRef(0)
99
93
  const lockedScrollY = useRef(0)
94
+ const focusedInputScrollFrame = useRef(0)
100
95
 
101
96
  const setScrollEnabled = (next: boolean, lockTo?: number) => {
102
97
  if (!next) {
@@ -114,6 +109,67 @@ export const SheetScrollView = React.forwardRef<
114
109
  scrollRef.current?.scrollTo?.({ x: 0, y, animated: false })
115
110
  }
116
111
 
112
+ const scrollFocusedInputClearOfKeyboard = React.useCallback(() => {
113
+ if (!isWeb || !hasFit || !isKeyboardVisible || keyboardOccludedHeight <= 0) {
114
+ return
115
+ }
116
+ const node = scrollRef.current?.getScrollableNode() as HTMLElement | undefined
117
+ const active = document.activeElement as HTMLElement | null
118
+ if (!node || !active || !isEditableElement(active) || !node.contains(active)) {
119
+ return
120
+ }
121
+
122
+ const keyboardHeight = Math.max(keyboardOccludedHeight, getWebKeyboardBottomInset())
123
+ if (keyboardHeight <= 0) return
124
+
125
+ const activeRect = active.getBoundingClientRect()
126
+ const nodeRect = node.getBoundingClientRect()
127
+ const margin = 12
128
+ const keyboardTop = getStableLayoutViewportHeight() - keyboardHeight
129
+ const visibleTop = nodeRect.top + margin
130
+ const visibleBottom = Math.min(nodeRect.bottom, keyboardTop) - margin
131
+ let nextScrollTop = node.scrollTop
132
+
133
+ if (activeRect.bottom > visibleBottom) {
134
+ nextScrollTop += Math.ceil(activeRect.bottom - visibleBottom)
135
+ } else if (activeRect.top < visibleTop) {
136
+ nextScrollTop -= Math.ceil(visibleTop - activeRect.top)
137
+ }
138
+
139
+ const maxScrollTop = Math.max(0, node.scrollHeight - node.clientHeight)
140
+ nextScrollTop = Math.max(0, Math.min(maxScrollTop, nextScrollTop))
141
+ if (nextScrollTop === node.scrollTop) return
142
+
143
+ node.scrollTop = nextScrollTop
144
+ currentScrollOffset.current = nextScrollTop
145
+ scrollBridge.y = nextScrollTop
146
+ if (nextScrollTop > 0) scrollBridge.scrollStartY = -1
147
+ }, [hasFit, isKeyboardVisible, keyboardOccludedHeight, scrollBridge])
148
+
149
+ const scheduleFocusedInputScroll = React.useCallback(() => {
150
+ if (!isWeb || !hasFit) return
151
+ cancelAnimationFrame(focusedInputScrollFrame.current)
152
+ focusedInputScrollFrame.current = requestAnimationFrame(() => {
153
+ scrollFocusedInputClearOfKeyboard()
154
+ focusedInputScrollFrame.current = requestAnimationFrame(
155
+ scrollFocusedInputClearOfKeyboard
156
+ )
157
+ })
158
+ }, [hasFit, scrollFocusedInputClearOfKeyboard])
159
+
160
+ useEffect(() => {
161
+ if (!isWeb || !hasFit) return
162
+ scheduleFocusedInputScroll()
163
+ window.addEventListener('focusin', scheduleFocusedInputScroll)
164
+ window.visualViewport?.addEventListener('resize', scheduleFocusedInputScroll)
165
+
166
+ return () => {
167
+ cancelAnimationFrame(focusedInputScrollFrame.current)
168
+ window.removeEventListener('focusin', scheduleFocusedInputScroll)
169
+ window.visualViewport?.removeEventListener('resize', scheduleFocusedInputScroll)
170
+ }
171
+ }, [hasFit, scheduleFocusedInputScroll])
172
+
117
173
  useEffect(() => {
118
174
  setHasScrollView(true)
119
175
  if (isGestureHandlerEnabled()) {
@@ -160,6 +216,9 @@ export const SheetScrollView = React.forwardRef<
160
216
  if (height !== contentHeight.current) {
161
217
  contentHeight.current = height
162
218
  updateScrollable()
219
+ if (keyboardOccludedHeight > 0) {
220
+ scheduleFocusedInputScroll()
221
+ }
163
222
  }
164
223
  }}
165
224
  >
@@ -244,6 +303,7 @@ export const SheetScrollView = React.forwardRef<
244
303
  scrollEnabled={scrollEnabled}
245
304
  onScroll={(e) => {
246
305
  const { y } = e.nativeEvent.contentOffset
306
+ currentScrollOffset.current = y
247
307
  scrollBridge.y = y
248
308
  if (y > 0) scrollBridge.scrollStartY = -1
249
309
  onScroll?.(e)
@@ -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}
@@ -1,3 +1,94 @@
1
+ export function getKeyboardAdjustedSheetY({
2
+ sheetY,
3
+ screenSize,
4
+ isKeyboardVisible,
5
+ keyboardHeight,
6
+ shouldTranslate,
7
+ safeAreaTop,
8
+ }: {
9
+ sheetY: number
10
+ screenSize: number
11
+ isKeyboardVisible: boolean
12
+ keyboardHeight: number
13
+ shouldTranslate: boolean
14
+ safeAreaTop: number
15
+ }) {
16
+ if (
17
+ !shouldTranslate ||
18
+ !isKeyboardVisible ||
19
+ keyboardHeight <= 0 ||
20
+ screenSize <= 0 ||
21
+ sheetY >= screenSize
22
+ ) {
23
+ return sheetY
24
+ }
25
+
26
+ return Math.max(safeAreaTop, sheetY - keyboardHeight)
27
+ }
28
+
29
+ export function getSheetReleasePosition({
30
+ positions,
31
+ projectedEnd,
32
+ currentPosition,
33
+ frameSize,
34
+ dismissOnSnapToBottom,
35
+ snapPointsMode,
36
+ isKeyboardVisible,
37
+ isWeb,
38
+ }: {
39
+ positions: number[]
40
+ projectedEnd: number
41
+ currentPosition: number
42
+ frameSize: number
43
+ dismissOnSnapToBottom: boolean
44
+ snapPointsMode: 'percent' | 'constant' | 'fit' | 'mixed'
45
+ isKeyboardVisible: boolean
46
+ isWeb: boolean
47
+ }) {
48
+ let closestPoint = 0
49
+ let dist = Number.POSITIVE_INFINITY
50
+
51
+ for (let i = 0; i < positions.length; i++) {
52
+ const position = positions[i]
53
+ const curDist = Math.abs(projectedEnd - position)
54
+ if (curDist < dist) {
55
+ dist = curDist
56
+ closestPoint = i
57
+ }
58
+ }
59
+
60
+ // the keyboard-open dismiss threshold is a WEB keyboard-handoff fix
61
+ // (75de9c9694). on native the keyboard is opaque and the sheet snaps purely by
62
+ // projected position (the pre-rework behavior), so gate it to web only —
63
+ // otherwise a native fit+dismiss sheet snaps to the wrong point with the
64
+ // keyboard up.
65
+ const dismissPoint = positions.length - 1
66
+ const isKeyboardFitDismiss =
67
+ isWeb &&
68
+ dismissOnSnapToBottom &&
69
+ isKeyboardVisible &&
70
+ snapPointsMode === 'fit' &&
71
+ positions.length === 2 &&
72
+ closestPoint === dismissPoint
73
+
74
+ if (!isKeyboardFitDismiss) {
75
+ return closestPoint
76
+ }
77
+
78
+ const openPosition = positions[0]
79
+ const dismissPosition = positions[dismissPoint]
80
+ const dismissDistance = dismissPosition - openPosition
81
+ if (dismissDistance <= 0 || frameSize <= 0) {
82
+ return closestPoint
83
+ }
84
+
85
+ const draggedDistance = Math.max(0, currentPosition - openPosition)
86
+ const thresholdBase = Math.min(frameSize, dismissDistance)
87
+ const dismissThreshold = Math.max(120, thresholdBase * 0.35)
88
+
89
+ return draggedDistance >= dismissThreshold ? closestPoint : 0
90
+ }
91
+
1
92
  export function getKeyboardOccludedHeight({
2
93
  frameSize,
3
94
  isKeyboardVisible,
@@ -64,7 +64,6 @@ export function setupNativeSheet(
64
64
  keyboardOccludedHeight={0}
65
65
  isKeyboardVisible={false}
66
66
  keyboardStableFrameHeight={0}
67
- isKeyboardSeeding={false}
68
67
  {...providerProps}
69
68
  onlyShowFrame
70
69
  >
@@ -1,6 +1,8 @@
1
+ import { isWeb } from '@tamagui/constants'
1
2
  import { useCallback, useMemo, useRef, type RefObject } from 'react'
2
3
  import { getGestureHandlerState, isGestureHandlerEnabled } from './gestureState'
3
- import type { ScrollBridge } from './types'
4
+ import { getSheetReleasePosition } from './keyboardAvoidance'
5
+ import type { ScrollBridge, SnapPointsMode } from './types'
4
6
 
5
7
  // threshold in pixels for considering sheet "at top" position
6
8
  // allows for small measurement variations
@@ -18,6 +20,9 @@ interface GesturePanConfig {
18
20
  resisted: (val: number, minY: number) => number
19
21
  disableDrag?: boolean
20
22
  isShowingInnerSheet?: boolean
23
+ dismissOnSnapToBottom?: boolean
24
+ snapPointsMode?: SnapPointsMode
25
+ isKeyboardVisible?: boolean
21
26
  // set the animated position directly (for smooth dragging)
22
27
  setAnimatedPosition: (val: number) => void
23
28
  // ref to scroll gesture for simultaneousWithExternalGesture
@@ -69,6 +74,16 @@ export function useGestureHandlerPan(config: GesturePanConfig): GesturePanResult
69
74
 
70
75
  const gestureHandlerEnabled = isGestureHandlerEnabled()
71
76
  const panGestureRef = useRef<any>(null)
77
+ const releaseConfigRef = useRef({
78
+ dismissOnSnapToBottom: config.dismissOnSnapToBottom === true,
79
+ snapPointsMode: config.snapPointsMode ?? 'percent',
80
+ isKeyboardVisible: config.isKeyboardVisible === true,
81
+ })
82
+ releaseConfigRef.current = {
83
+ dismissOnSnapToBottom: config.dismissOnSnapToBottom === true,
84
+ snapPointsMode: config.snapPointsMode ?? 'percent',
85
+ isKeyboardVisible: config.isKeyboardVisible === true,
86
+ }
72
87
 
73
88
  // use refs for values that need to persist across gesture lifecycle
74
89
  // (useMemo closure variables get reset when gesture is recreated)
@@ -86,6 +101,7 @@ export function useGestureHandlerPan(config: GesturePanConfig): GesturePanResult
86
101
  // causing positions to revert. Frozen positions ensure stable snap calculation.
87
102
  frozenPositions: [] as number[],
88
103
  frozenMinY: 0,
104
+ frozenIsKeyboardVisible: false,
89
105
  // whether pan gesture actually started (vs just a tap in onBegin)
90
106
  panStarted: false,
91
107
  })
@@ -156,6 +172,7 @@ export function useGestureHandlerPan(config: GesturePanConfig): GesturePanResult
156
172
  // doesn't change snap targets mid-gesture
157
173
  gs.frozenPositions = [...positions]
158
174
  gs.frozenMinY = minY
175
+ gs.frozenIsKeyboardVisible = releaseConfigRef.current.isKeyboardVisible
159
176
 
160
177
  // if sheet not at top, DISABLE SCROLL immediately and lock to 0
161
178
  // this prevents scroll from firing before pan takes over
@@ -292,17 +309,17 @@ export function useGestureHandlerPan(config: GesturePanConfig): GesturePanResult
292
309
  const velocity = velocityY / 1000
293
310
  const projectedEnd = currentPos + frameSize * velocity * 0.2
294
311
 
295
- let closestPoint = 0
296
- let minDist = Number.POSITIVE_INFINITY
297
-
298
- for (let i = 0; i < snapPositions.length; i++) {
299
- const pos = snapPositions[i]
300
- const dist = Math.abs(projectedEnd - pos)
301
- if (dist < minDist) {
302
- minDist = dist
303
- closestPoint = i
304
- }
305
- }
312
+ const releaseConfig = releaseConfigRef.current
313
+ const closestPoint = getSheetReleasePosition({
314
+ positions: snapPositions,
315
+ projectedEnd,
316
+ currentPosition: currentPos,
317
+ frameSize,
318
+ dismissOnSnapToBottom: releaseConfig.dismissOnSnapToBottom,
319
+ snapPointsMode: releaseConfig.snapPointsMode,
320
+ isKeyboardVisible: gs.frozenIsKeyboardVisible,
321
+ isWeb,
322
+ })
306
323
 
307
324
  onEnd(closestPoint)
308
325
  })
@@ -2,11 +2,11 @@
2
2
  * Web implementation of the keyboard controller sheet hook.
3
3
  *
4
4
  * Mobile browsers don't expose a keyboard API, but they do resize the
5
- * VisualViewport when the soft keyboard opens. We derive the keyboard height as
6
- * `getStableLayoutViewportHeight() - visualViewport.height` (see webViewport
7
- * the stable baseline is document.documentElement.clientHeight, NOT innerHeight,
8
- * which itself shrinks with the keyboard on real iOS Safari) and feed it into the
9
- * keyboardOccludedHeight scroll padding in SheetImplementationCustom.
5
+ * VisualViewport when the soft keyboard opens. We use the viewport shrink
6
+ * (`clientHeight - visualViewport.height`) to detect the keyboard, then feed the
7
+ * bottom layout inset (`clientHeight - (offsetTop + height)`) into
8
+ * SheetImplementationCustom. The bottom inset accounts for iOS Safari panning
9
+ * the visual viewport during focus, so the sheet doesn't over-lift.
10
10
  *
11
11
  * Without this, a bottom sheet on mobile web stays pinned behind the keyboard:
12
12
  * react-native-web's Dimensions tracks the (shrinking) VisualViewport, so any
@@ -21,7 +21,8 @@ import type {
21
21
  KeyboardControllerSheetResult,
22
22
  } from './types'
23
23
  import {
24
- getWebKeyboardHeight,
24
+ getWebKeyboardBottomInset,
25
+ getWebKeyboardResizeHeight,
25
26
  isEditableElement,
26
27
  MIN_KEYBOARD_HEIGHT,
27
28
  } from './webViewport'
@@ -31,8 +32,26 @@ export function useKeyboardControllerSheet(
31
32
  ): KeyboardControllerSheetResult {
32
33
  const { enabled } = options
33
34
 
34
- const [keyboardHeight, setKeyboardHeight] = useState(0)
35
- const [isKeyboardVisible, setIsKeyboardVisible] = useState(false)
35
+ // initialize from the CURRENT viewport, not blindly from 0/false. when the
36
+ // sheet opens with the soft keyboard already up (e.g. it was raised by a field
37
+ // on the page behind, or by an autofocus whose keyboard wins the race against
38
+ // the first layout), a false initial value makes the sheet's very first render
39
+ // believe the keyboard is down — it captures a keyboard-free baseline from a
40
+ // keyboard-shrunk layout and the anchor/seed machinery has to recover. reading
41
+ // the viewport synchronously here removes that first-render race: the sheet
42
+ // knows the keyboard is up on render 1 and takes the seed path deterministically.
43
+ const [keyboardHeight, setKeyboardHeight] = useState(() =>
44
+ isWeb && enabled && typeof window !== 'undefined' && window.visualViewport
45
+ ? (() => {
46
+ const resizeHeight = getWebKeyboardResizeHeight()
47
+ return resizeHeight >= MIN_KEYBOARD_HEIGHT &&
48
+ isEditableElement(document.activeElement)
49
+ ? getWebKeyboardBottomInset()
50
+ : 0
51
+ })()
52
+ : 0
53
+ )
54
+ const [isKeyboardVisible, setIsKeyboardVisible] = useState(() => keyboardHeight > 0)
36
55
 
37
56
  // action-sheet pattern: pause keyboard hide events during drag so the sheet
38
57
  // position doesn't revert mid-gesture when a TextInput blurs.
@@ -66,8 +85,9 @@ export function useKeyboardControllerSheet(
66
85
  if (!vv) return
67
86
 
68
87
  const update = () => {
69
- const height = getWebKeyboardHeight()
70
- const tall = height >= MIN_KEYBOARD_HEIGHT
88
+ const resizeHeight = getWebKeyboardResizeHeight()
89
+ const height = getWebKeyboardBottomInset()
90
+ const tall = resizeHeight >= MIN_KEYBOARD_HEIGHT
71
91
  // require an editable element focused to *show* — this rules out URL-bar
72
92
  // collapse and other viewport changes that aren't a keyboard. but stay
73
93
  // visible while the viewport remains shrunk even if focus momentarily
@@ -93,11 +113,11 @@ export function useKeyboardControllerSheet(
93
113
  setKeyboardHeight((prev) => (prev === height ? prev : height))
94
114
  }
95
115
 
96
- // only react to resize (keyboard height changes). we intentionally do NOT
97
- // listen to visualViewport 'scroll' that fires continuously while the
98
- // sheet content scrolls and would re-derive the keyboard height from a
99
- // shifting viewport, making the sheet jump.
116
+ // resize tracks keyboard open/close. scroll tracks iOS Safari's focus pan:
117
+ // visualViewport.offsetTop can change after the height has settled, and the
118
+ // sheet cap must move in that same layout coordinate space.
100
119
  vv.addEventListener('resize', update)
120
+ vv.addEventListener('scroll', update)
101
121
  // focus changes flip editable state without necessarily resizing the viewport
102
122
  window.addEventListener('focusin', update)
103
123
  window.addEventListener('focusout', update)
@@ -106,6 +126,7 @@ export function useKeyboardControllerSheet(
106
126
 
107
127
  return () => {
108
128
  vv.removeEventListener('resize', update)
129
+ vv.removeEventListener('scroll', update)
109
130
  window.removeEventListener('focusin', update)
110
131
  window.removeEventListener('focusout', update)
111
132
  }
@@ -0,0 +1,29 @@
1
+ import * as React from 'react'
2
+ import { SafeAreaInsetsContext as RNSafeAreaInsetsContext } from 'react-native-safe-area-context'
3
+
4
+ import type { SafeAreaInsets } from './useSafeAreaInsets'
5
+
6
+ // the real react-native-safe-area-context insets context, re-typed to the local
7
+ // shape so consumers don't depend on the package's types. exported so the Sheet
8
+ // can re-propagate it across the portal.
9
+ export const SafeAreaInsetsContext =
10
+ RNSafeAreaInsetsContext as unknown as React.Context<SafeAreaInsets | null>
11
+
12
+ /**
13
+ * Live safe-area insets (notch / status bar / home indicator) read from the
14
+ * context the app's SafeAreaProvider provides.
15
+ *
16
+ * Read this in a component BODY that renders inside the provider. The Sheet does
17
+ * exactly that — only its modal CONTENT is teleported out through the portal, so
18
+ * the body still sees the real insets. This is the one read that works in real
19
+ * native bundles:
20
+ * - `getSafeArea()` (the @tamagui/native abstraction) is frequently NOT enabled
21
+ * against the instance a component reads, so it returns 0.
22
+ * - a dynamic `require('react-native-safe-area-context')` throws "Unknown named
23
+ * module" in metro/rolldown bundles where the package isn't in the importing
24
+ * module's graph.
25
+ * A static import + context read avoids both.
26
+ */
27
+ export function useSafeAreaInsets(): SafeAreaInsets | null {
28
+ return React.useContext(SafeAreaInsetsContext)
29
+ }
@@ -0,0 +1,19 @@
1
+ import * as React from 'react'
2
+
3
+ export interface SafeAreaInsets {
4
+ top: number
5
+ right: number
6
+ bottom: number
7
+ left: number
8
+ }
9
+
10
+ // web uses CSS env(safe-area-inset-*) for safe areas, so there is no native
11
+ // safe-area context to read here — the keyboard-avoidance path uses the visual
12
+ // viewport offset instead. this stub keeps the import resolvable on web (where
13
+ // react-native-safe-area-context isn't a dependency) and is the canonical type
14
+ // source for both platform variants.
15
+ export const SafeAreaInsetsContext = React.createContext<SafeAreaInsets | null>(null)
16
+
17
+ export function useSafeAreaInsets(): SafeAreaInsets | null {
18
+ return null
19
+ }
@@ -14,17 +14,11 @@ export type SheetContextValue = ReturnType<typeof useSheetProviderProps> & {
14
14
  // the fit-mode height freeze must persist for the whole time the keyboard is
15
15
  // open, not just while occluded, or it releases and the sheet collapses.
16
16
  isKeyboardVisible: boolean
17
- // the sheet's authoritative pre-keyboard frame height (web). SheetScrollView
18
- // pins its height to this while the keyboard is open so the frame stays its
19
- // full size (anchored to the screen bottom) instead of collapsing to a
20
- // consumer maxHeight that shrank with the viewport. 0 when not applicable.
17
+ // the sheet's pre-keyboard frame height (web). SheetScrollView pins its height
18
+ // to this while the keyboard is open so the frame translates at full size
19
+ // instead of collapsing to a consumer maxHeight that shrank with the viewport.
20
+ // 0 when not applicable.
21
21
  keyboardStableFrameHeight: number
22
- // AUTOFOCUS-ON-OPEN seed phase (web). while reconstructing the pre-keyboard
23
- // baseline, SheetScrollView must UNCLIP (apply keyboardStableFrameHeight as
24
- // maxHeight only, not a fixed height) so it sizes to its content and the sheet
25
- // can measure the true content height. once settled the fixed-height pin
26
- // applies. false outside the seed window.
27
- isKeyboardSeeding: boolean
28
22
  setHasScrollView: (val: boolean) => void
29
23
  }
30
24
 
@@ -131,6 +131,10 @@ export function useSheetScrollViewGestures({
131
131
  } else {
132
132
  panHandles = !hasScrollableContent
133
133
  if (!panHandles) {
134
+ s.handoffOccurred = false
135
+ s.handoffDragOffset = 0
136
+ s.scrollEngaged = currentScrollY > 0
137
+ s.prevScrollY = currentScrollY
134
138
  s.isScrolling = true
135
139
  scrollBridge.scrollLock = true
136
140
  setScrollEnabled(true)
@@ -8,6 +8,7 @@ interface GestureState {
8
8
  startY: number
9
9
  lastY: number
10
10
  owner: GestureOwner
11
+ hadPanOwner: boolean
11
12
  panDragOffset: number
12
13
  dys: number[]
13
14
  scrollYAtGestureStart: number
@@ -30,6 +31,7 @@ export function useSheetScrollViewGestures({
30
31
  startY: 0,
31
32
  lastY: 0,
32
33
  owner: 'none',
34
+ hadPanOwner: false,
33
35
  panDragOffset: 0,
34
36
  dys: [],
35
37
  scrollYAtGestureStart: 0,
@@ -69,6 +71,7 @@ export function useSheetScrollViewGestures({
69
71
  startY: touch.pageY,
70
72
  lastY: touch.pageY,
71
73
  owner: 'none',
74
+ hadPanOwner: false,
72
75
  panDragOffset: 0,
73
76
  dys: [],
74
77
  scrollYAtGestureStart: currentScrollY,
@@ -117,6 +120,7 @@ export function useSheetScrollViewGestures({
117
120
  // handle transitions
118
121
  if (newOwner !== s.owner) {
119
122
  if (newOwner === 'pan') {
123
+ s.hadPanOwner = true
120
124
  s.panDragOffset = 0
121
125
  s.dys = []
122
126
  // re-baseline the pan origin to the sheet's CURRENT position so the
@@ -128,7 +132,11 @@ export function useSheetScrollViewGestures({
128
132
  } else {
129
133
  scrollBridge.setParentDragging(false)
130
134
  scrollBridge.scrollLock = false
131
- enableScroll()
135
+ if (s.hadPanOwner) {
136
+ disableScroll()
137
+ } else {
138
+ enableScroll()
139
+ }
132
140
  }
133
141
  s.owner = newOwner
134
142
  }
@@ -141,19 +149,21 @@ export function useSheetScrollViewGestures({
141
149
 
142
150
  s.dys.push(dy)
143
151
  if (s.dys.length > 100) s.dys = s.dys.slice(-10)
144
- } else if (s.owner === 'scroll' && !e.isTrusted) {
145
- // SYNTHETIC events only (tests): dispatched TouchEvents don't trigger the
146
- // browser's native overflow scroll, so we move scrollTop ourselves. for a
147
- // REAL touch (e.isTrusted) the browser already scrolls the overflow
148
- // container natively doing it again here double-applies the delta and
149
- // makes scrollTop jitter / snap around. so for real touches we let native
150
- // scrolling own it and only track the offset via the ScrollView onScroll.
152
+ } else if (s.owner === 'scroll' && (!e.isTrusted || s.hadPanOwner)) {
153
+ // synthetic events don't trigger native overflow scroll, so tests need us
154
+ // to move scrollTop directly. real mixed gestures need the same direct
155
+ // path after pan ownership: ios safari may not resume native scrolling
156
+ // mid-touch after an earlier prevented touchmove. pure real scrolls still
157
+ // use native scrolling; mixed pan↔scroll gestures keep overflow hidden
158
+ // and are driven here until touchend.
159
+ if (e.cancelable) e.preventDefault()
151
160
  const scrollDelta = -dy
152
161
  const maxScrollY = node.scrollHeight - node.clientHeight
153
162
  const newScrollY = Math.max(0, Math.min(maxScrollY, currentScrollY + scrollDelta))
154
163
  if (newScrollY !== currentScrollY) {
155
164
  node.scrollTop = newScrollY
156
165
  scrollBridge.y = newScrollY
166
+ if (newScrollY > 0) scrollBridge.scrollStartY = -1
157
167
  }
158
168
  }
159
169
  }
@@ -183,6 +193,7 @@ export function useSheetScrollViewGestures({
183
193
 
184
194
  enableScroll()
185
195
  s.owner = 'none'
196
+ s.hadPanOwner = false
186
197
  s.panDragOffset = 0
187
198
  s.dys = []
188
199
  scrollBridge.scrollNodeTouched = false