@hanzogui/sheet 7.3.0 → 8.0.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 (229) hide show
  1. package/.turbo/turbo-build.log +2 -0
  2. package/controller/index.cjs +2 -0
  3. package/controller/index.d.ts +5 -0
  4. package/controller/index.js +2 -0
  5. package/controller/index.native.cjs +2 -0
  6. package/controller/index.native.js +2 -0
  7. package/dist/cjs/GestureDetectorWrapper.native.js.map +1 -1
  8. package/dist/cjs/SheetImplementationCustom.cjs +137 -74
  9. package/dist/cjs/SheetImplementationCustom.native.js +162 -83
  10. package/dist/cjs/SheetImplementationCustom.native.js.map +1 -1
  11. package/dist/cjs/SheetScrollView.cjs +155 -34
  12. package/dist/cjs/SheetScrollView.native.js +160 -35
  13. package/dist/cjs/SheetScrollView.native.js.map +1 -1
  14. package/dist/cjs/createSheet.cjs +7 -3
  15. package/dist/cjs/createSheet.native.js +7 -3
  16. package/dist/cjs/createSheet.native.js.map +1 -1
  17. package/dist/cjs/gestureState.cjs +13 -2
  18. package/dist/cjs/gestureState.native.js +14 -2
  19. package/dist/cjs/gestureState.native.js.map +1 -1
  20. package/dist/cjs/index.cjs +12 -1
  21. package/dist/cjs/index.native.js +11 -0
  22. package/dist/cjs/index.native.js.map +1 -1
  23. package/dist/cjs/keyboardAvoidance.cjs +93 -0
  24. package/dist/cjs/keyboardAvoidance.native.js +99 -0
  25. package/dist/cjs/keyboardAvoidance.native.js.map +1 -0
  26. package/dist/cjs/nativeSheet.cjs +3 -0
  27. package/dist/cjs/nativeSheet.native.js +3 -0
  28. package/dist/cjs/nativeSheet.native.js.map +1 -1
  29. package/dist/cjs/setupGestureHandler.cjs +9 -6
  30. package/dist/cjs/setupGestureHandler.native.js +11 -6
  31. package/dist/cjs/setupGestureHandler.native.js.map +1 -1
  32. package/dist/cjs/useGestureHandlerPan.cjs +86 -18
  33. package/dist/cjs/useGestureHandlerPan.native.js +91 -18
  34. package/dist/cjs/useGestureHandlerPan.native.js.map +1 -1
  35. package/dist/cjs/useKeyboardControllerSheet.cjs +68 -6
  36. package/dist/cjs/useSafeAreaInsets.cjs +44 -0
  37. package/dist/cjs/useSafeAreaInsets.native.js +48 -0
  38. package/dist/cjs/useSafeAreaInsets.native.js.map +1 -0
  39. package/dist/cjs/useSheetProviderProps.cjs +1 -1
  40. package/dist/cjs/useSheetProviderProps.native.js +1 -1
  41. package/dist/cjs/useSheetProviderProps.native.js.map +1 -1
  42. package/dist/cjs/useSheetScrollViewGestures.cjs +30 -5
  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 +75 -0
  46. package/dist/cjs/webViewport.native.js +81 -0
  47. package/dist/cjs/webViewport.native.js.map +1 -0
  48. package/dist/esm/GestureDetectorWrapper.mjs.map +1 -1
  49. package/dist/esm/GestureDetectorWrapper.native.js.map +1 -1
  50. package/dist/esm/SheetImplementationCustom.mjs +143 -84
  51. package/dist/esm/SheetImplementationCustom.mjs.map +1 -1
  52. package/dist/esm/SheetImplementationCustom.native.js +173 -102
  53. package/dist/esm/SheetImplementationCustom.native.js.map +1 -1
  54. package/dist/esm/SheetScrollView.mjs +157 -36
  55. package/dist/esm/SheetScrollView.mjs.map +1 -1
  56. package/dist/esm/SheetScrollView.native.js +162 -37
  57. package/dist/esm/SheetScrollView.native.js.map +1 -1
  58. package/dist/esm/createSheet.mjs +9 -6
  59. package/dist/esm/createSheet.mjs.map +1 -1
  60. package/dist/esm/createSheet.native.js +19 -15
  61. package/dist/esm/createSheet.native.js.map +1 -1
  62. package/dist/esm/gestureState.mjs +13 -2
  63. package/dist/esm/gestureState.mjs.map +1 -1
  64. package/dist/esm/gestureState.native.js +14 -2
  65. package/dist/esm/gestureState.native.js.map +1 -1
  66. package/dist/esm/index.js +2 -0
  67. package/dist/esm/index.js.map +1 -1
  68. package/dist/esm/index.mjs +2 -0
  69. package/dist/esm/index.mjs.map +1 -1
  70. package/dist/esm/index.native.js +2 -0
  71. package/dist/esm/index.native.js.map +1 -1
  72. package/dist/esm/keyboardAvoidance.mjs +66 -0
  73. package/dist/esm/keyboardAvoidance.mjs.map +1 -0
  74. package/dist/esm/keyboardAvoidance.native.js +69 -0
  75. package/dist/esm/keyboardAvoidance.native.js.map +1 -0
  76. package/dist/esm/nativeSheet.mjs +3 -0
  77. package/dist/esm/nativeSheet.mjs.map +1 -1
  78. package/dist/esm/nativeSheet.native.js +3 -0
  79. package/dist/esm/nativeSheet.native.js.map +1 -1
  80. package/dist/esm/setupGestureHandler.mjs +9 -6
  81. package/dist/esm/setupGestureHandler.mjs.map +1 -1
  82. package/dist/esm/setupGestureHandler.native.js +11 -6
  83. package/dist/esm/setupGestureHandler.native.js.map +1 -1
  84. package/dist/esm/useGestureHandlerPan.mjs +87 -19
  85. package/dist/esm/useGestureHandlerPan.mjs.map +1 -1
  86. package/dist/esm/useGestureHandlerPan.native.js +92 -19
  87. package/dist/esm/useGestureHandlerPan.native.js.map +1 -1
  88. package/dist/esm/useKeyboardControllerSheet.mjs +69 -7
  89. package/dist/esm/useKeyboardControllerSheet.mjs.map +1 -1
  90. package/dist/esm/useSafeAreaInsets.mjs +7 -0
  91. package/dist/esm/useSafeAreaInsets.mjs.map +1 -0
  92. package/dist/esm/useSafeAreaInsets.native.js +8 -0
  93. package/dist/esm/useSafeAreaInsets.native.js.map +1 -0
  94. package/dist/esm/useSheetProviderProps.mjs +1 -1
  95. package/dist/esm/useSheetProviderProps.mjs.map +1 -1
  96. package/dist/esm/useSheetProviderProps.native.js +1 -1
  97. package/dist/esm/useSheetProviderProps.native.js.map +1 -1
  98. package/dist/esm/useSheetScrollViewGestures.mjs +30 -5
  99. package/dist/esm/useSheetScrollViewGestures.mjs.map +1 -1
  100. package/dist/esm/useSheetScrollViewGestures.native.js +4 -0
  101. package/dist/esm/useSheetScrollViewGestures.native.js.map +1 -1
  102. package/dist/esm/webViewport.mjs +43 -0
  103. package/dist/esm/webViewport.mjs.map +1 -0
  104. package/dist/esm/webViewport.native.js +46 -0
  105. package/dist/esm/webViewport.native.js.map +1 -0
  106. package/dist/jsx/GestureDetectorWrapper.mjs.map +1 -1
  107. package/dist/jsx/GestureDetectorWrapper.native.js.map +1 -1
  108. package/dist/jsx/SheetImplementationCustom.mjs +143 -84
  109. package/dist/jsx/SheetImplementationCustom.mjs.map +1 -1
  110. package/dist/jsx/SheetImplementationCustom.native.js +162 -83
  111. package/dist/jsx/SheetImplementationCustom.native.js.map +1 -1
  112. package/dist/jsx/SheetScrollView.mjs +157 -36
  113. package/dist/jsx/SheetScrollView.mjs.map +1 -1
  114. package/dist/jsx/SheetScrollView.native.js +160 -35
  115. package/dist/jsx/SheetScrollView.native.js.map +1 -1
  116. package/dist/jsx/createSheet.mjs +9 -6
  117. package/dist/jsx/createSheet.mjs.map +1 -1
  118. package/dist/jsx/createSheet.native.js +7 -3
  119. package/dist/jsx/createSheet.native.js.map +1 -1
  120. package/dist/jsx/gestureState.mjs +13 -2
  121. package/dist/jsx/gestureState.mjs.map +1 -1
  122. package/dist/jsx/gestureState.native.js +14 -2
  123. package/dist/jsx/gestureState.native.js.map +1 -1
  124. package/dist/jsx/index.js +2 -0
  125. package/dist/jsx/index.js.map +1 -1
  126. package/dist/jsx/index.mjs +2 -0
  127. package/dist/jsx/index.mjs.map +1 -1
  128. package/dist/jsx/index.native.js +11 -0
  129. package/dist/jsx/index.native.js.map +1 -1
  130. package/dist/jsx/keyboardAvoidance.mjs +66 -0
  131. package/dist/jsx/keyboardAvoidance.mjs.map +1 -0
  132. package/dist/jsx/keyboardAvoidance.native.js +99 -0
  133. package/dist/jsx/keyboardAvoidance.native.js.map +1 -0
  134. package/dist/jsx/nativeSheet.mjs +3 -0
  135. package/dist/jsx/nativeSheet.mjs.map +1 -1
  136. package/dist/jsx/nativeSheet.native.js +3 -0
  137. package/dist/jsx/nativeSheet.native.js.map +1 -1
  138. package/dist/jsx/setupGestureHandler.mjs +9 -6
  139. package/dist/jsx/setupGestureHandler.mjs.map +1 -1
  140. package/dist/jsx/setupGestureHandler.native.js +11 -6
  141. package/dist/jsx/setupGestureHandler.native.js.map +1 -1
  142. package/dist/jsx/useGestureHandlerPan.mjs +87 -19
  143. package/dist/jsx/useGestureHandlerPan.mjs.map +1 -1
  144. package/dist/jsx/useGestureHandlerPan.native.js +91 -18
  145. package/dist/jsx/useGestureHandlerPan.native.js.map +1 -1
  146. package/dist/jsx/useKeyboardControllerSheet.mjs +69 -7
  147. package/dist/jsx/useKeyboardControllerSheet.mjs.map +1 -1
  148. package/dist/jsx/useSafeAreaInsets.mjs +7 -0
  149. package/dist/jsx/useSafeAreaInsets.mjs.map +1 -0
  150. package/dist/jsx/useSafeAreaInsets.native.js +48 -0
  151. package/dist/jsx/useSafeAreaInsets.native.js.map +1 -0
  152. package/dist/jsx/useSheetProviderProps.mjs +1 -1
  153. package/dist/jsx/useSheetProviderProps.mjs.map +1 -1
  154. package/dist/jsx/useSheetProviderProps.native.js +1 -1
  155. package/dist/jsx/useSheetProviderProps.native.js.map +1 -1
  156. package/dist/jsx/useSheetScrollViewGestures.mjs +30 -5
  157. package/dist/jsx/useSheetScrollViewGestures.mjs.map +1 -1
  158. package/dist/jsx/useSheetScrollViewGestures.native.js +4 -0
  159. package/dist/jsx/useSheetScrollViewGestures.native.js.map +1 -1
  160. package/dist/jsx/webViewport.mjs +43 -0
  161. package/dist/jsx/webViewport.mjs.map +1 -0
  162. package/dist/jsx/webViewport.native.js +81 -0
  163. package/dist/jsx/webViewport.native.js.map +1 -0
  164. package/next.md +78 -0
  165. package/package.json +30 -48
  166. package/setup-gesture-handler/index.cjs +2 -0
  167. package/setup-gesture-handler/index.js +2 -0
  168. package/setup-gesture-handler/index.native.cjs +2 -0
  169. package/setup-gesture-handler/index.native.js +2 -0
  170. package/src/GestureDetectorWrapper.tsx +0 -3
  171. package/src/SheetImplementationCustom.tsx +387 -127
  172. package/src/SheetScrollView.tsx +195 -22
  173. package/src/createSheet.tsx +20 -7
  174. package/src/gestureState.ts +22 -2
  175. package/src/index.ts +4 -0
  176. package/src/keyboardAvoidance.ts +121 -0
  177. package/src/nativeSheet.tsx +8 -1
  178. package/src/setupGestureHandler.ts +14 -5
  179. package/src/types.tsx +20 -1
  180. package/src/useGestureHandlerPan.tsx +94 -35
  181. package/src/useKeyboardControllerSheet.ts +127 -10
  182. package/src/useSafeAreaInsets.native.ts +29 -0
  183. package/src/useSafeAreaInsets.ts +19 -0
  184. package/src/useSheetProviderProps.tsx +12 -1
  185. package/src/useSheetScrollViewGestures.native.ts +4 -0
  186. package/src/useSheetScrollViewGestures.ts +52 -5
  187. package/src/webViewport.ts +100 -0
  188. package/test/keyboardAvoidance.test.ts +269 -0
  189. package/tsconfig.json +57 -0
  190. package/types/GestureDetectorWrapper.d.ts +1 -1
  191. package/types/GestureDetectorWrapper.d.ts.map +1 -1
  192. package/types/GestureSheetContext.d.ts +1 -1
  193. package/types/GestureSheetContext.d.ts.map +1 -1
  194. package/types/Sheet.d.ts +6 -5
  195. package/types/Sheet.d.ts.map +1 -1
  196. package/types/SheetContext.d.ts +5 -2
  197. package/types/SheetContext.d.ts.map +1 -1
  198. package/types/SheetController.d.ts +1 -2
  199. package/types/SheetController.d.ts.map +1 -1
  200. package/types/SheetImplementationCustom.d.ts +1 -0
  201. package/types/SheetImplementationCustom.d.ts.map +1 -1
  202. package/types/SheetScrollView.d.ts.map +1 -1
  203. package/types/createSheet.d.ts +13 -12
  204. package/types/createSheet.d.ts.map +1 -1
  205. package/types/gestureState.d.ts.map +1 -1
  206. package/types/index.d.ts +1 -0
  207. package/types/index.d.ts.map +1 -1
  208. package/types/keyboardAvoidance.d.ts +26 -0
  209. package/types/keyboardAvoidance.d.ts.map +1 -0
  210. package/types/nativeSheet.d.ts.map +1 -1
  211. package/types/setupGestureHandler.d.ts.map +1 -1
  212. package/types/types.d.ts +10 -1
  213. package/types/types.d.ts.map +1 -1
  214. package/types/useGestureHandlerPan.d.ts +4 -1
  215. package/types/useGestureHandlerPan.d.ts.map +1 -1
  216. package/types/useKeyboardControllerSheet.d.ts +14 -3
  217. package/types/useKeyboardControllerSheet.d.ts.map +1 -1
  218. package/types/useSafeAreaInsets.d.ts +10 -0
  219. package/types/useSafeAreaInsets.d.ts.map +1 -0
  220. package/types/useSafeAreaInsets.native.d.ts +20 -0
  221. package/types/useSafeAreaInsets.native.d.ts.map +1 -0
  222. package/types/useSheetProviderProps.d.ts +3 -0
  223. package/types/useSheetProviderProps.d.ts.map +1 -1
  224. package/types/useSheetScrollViewGestures.d.ts +2 -1
  225. package/types/useSheetScrollViewGestures.d.ts.map +1 -1
  226. package/types/useSheetScrollViewGestures.native.d.ts.map +1 -1
  227. package/types/webViewport.d.ts +36 -0
  228. package/types/webViewport.d.ts.map +1 -0
  229. package/LICENSE +0 -42
@@ -1,10 +1,13 @@
1
- import { useCallback, useMemo, useRef, type RefObject } from 'react'
1
+ import { isWeb } from '@hanzogui/constants'
2
+ import { useCallback, useEffect, 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
7
9
  const AT_TOP_THRESHOLD = 5
10
+ const SCROLL_HANDOFF_THRESHOLD = 160
8
11
 
9
12
  interface GesturePanConfig {
10
13
  positions: number[]
@@ -18,6 +21,9 @@ interface GesturePanConfig {
18
21
  resisted: (val: number, minY: number) => number
19
22
  disableDrag?: boolean
20
23
  isShowingInnerSheet?: boolean
24
+ dismissOnSnapToBottom?: boolean
25
+ snapPointsMode?: SnapPointsMode
26
+ isKeyboardVisible?: boolean
21
27
  // set the animated position directly (for smooth dragging)
22
28
  setAnimatedPosition: (val: number) => void
23
29
  // ref to scroll gesture for simultaneousWithExternalGesture
@@ -69,6 +75,16 @@ export function useGestureHandlerPan(config: GesturePanConfig): GesturePanResult
69
75
 
70
76
  const gestureHandlerEnabled = isGestureHandlerEnabled()
71
77
  const panGestureRef = useRef<any>(null)
78
+ const releaseConfigRef = useRef({
79
+ dismissOnSnapToBottom: config.dismissOnSnapToBottom === true,
80
+ snapPointsMode: config.snapPointsMode ?? 'percent',
81
+ isKeyboardVisible: config.isKeyboardVisible === true,
82
+ })
83
+ releaseConfigRef.current = {
84
+ dismissOnSnapToBottom: config.dismissOnSnapToBottom === true,
85
+ snapPointsMode: config.snapPointsMode ?? 'percent',
86
+ isKeyboardVisible: config.isKeyboardVisible === true,
87
+ }
72
88
 
73
89
  // use refs for values that need to persist across gesture lifecycle
74
90
  // (useMemo closure variables get reset when gesture is recreated)
@@ -86,20 +102,48 @@ export function useGestureHandlerPan(config: GesturePanConfig): GesturePanResult
86
102
  // causing positions to revert. Frozen positions ensure stable snap calculation.
87
103
  frozenPositions: [] as number[],
88
104
  frozenMinY: 0,
105
+ frozenIsKeyboardVisible: false,
89
106
  // whether pan gesture actually started (vs just a tap in onBegin)
90
107
  panStarted: false,
108
+ startedBelowTop: false,
109
+ upwardDragAfterTop: 0,
110
+ scrollUnlockedAfterTop: false,
111
+ keepScrollLockedAfterEnd: false,
91
112
  })
113
+ const unlockScrollTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
114
+
115
+ useEffect(() => {
116
+ return () => {
117
+ if (unlockScrollTimer.current) {
118
+ clearTimeout(unlockScrollTimer.current)
119
+ unlockScrollTimer.current = null
120
+ }
121
+ }
122
+ }, [])
92
123
 
93
124
  const onStart = useCallback(() => {
94
125
  stopSpring()
95
126
  }, [stopSpring])
96
127
 
97
128
  const onEnd = useCallback(
98
- (closestPoint: number, animationOverride?: any) => {
129
+ (closestPoint: number, animationOverride?: any, keepScrollLocked = false) => {
130
+ if (unlockScrollTimer.current) {
131
+ clearTimeout(unlockScrollTimer.current)
132
+ unlockScrollTimer.current = null
133
+ }
99
134
  setIsDragging(false)
100
135
  scrollBridge.setParentDragging(false)
101
- // re-enable scroll when gesture ends
102
- scrollBridge.setScrollEnabled?.(true)
136
+ scrollBridge.lockScrollAtTop = false
137
+ if (keepScrollLocked) {
138
+ scrollBridge.setScrollEnabled?.(false, 0)
139
+ unlockScrollTimer.current = setTimeout(() => {
140
+ unlockScrollTimer.current = null
141
+ scrollBridge.setScrollEnabled?.(true)
142
+ }, 160)
143
+ } else {
144
+ // re-enable scroll when gesture ends
145
+ scrollBridge.setScrollEnabled?.(true)
146
+ }
103
147
  setPosition(closestPoint)
104
148
  animateTo(closestPoint, animationOverride)
105
149
  },
@@ -122,7 +166,6 @@ export function useGestureHandlerPan(config: GesturePanConfig): GesturePanResult
122
166
 
123
167
  // simultaneousHandlers pattern from react-native-actions-sheet
124
168
  // both gestures run simultaneously, we use blockPan to decide who handles
125
- // console.warn('[RNGH-Pan] CREATING gesture, minY:', minY, 'frameSize:', frameSize)
126
169
  const gesture = Gesture.Pan()
127
170
  .withRef(panGestureRef)
128
171
  // NO manualActivation - let both gestures run via simultaneousHandlers
@@ -148,7 +191,6 @@ export function useGestureHandlerPan(config: GesturePanConfig): GesturePanResult
148
191
  const pos = getCurrentPosition()
149
192
  const atTop = pos <= minY + AT_TOP_THRESHOLD
150
193
  const currentScrollY = scrollBridge.y
151
- // console.warn('[RNGH-Pan] onBegin', { pos, minY, atTop, currentScrollY })
152
194
  gs.startY = pos
153
195
  gs.lastPanTranslationY = 0
154
196
  gs.accumulatedOffset = 0
@@ -158,6 +200,16 @@ export function useGestureHandlerPan(config: GesturePanConfig): GesturePanResult
158
200
  // doesn't change snap targets mid-gesture
159
201
  gs.frozenPositions = [...positions]
160
202
  gs.frozenMinY = minY
203
+ gs.frozenIsKeyboardVisible = releaseConfigRef.current.isKeyboardVisible
204
+ gs.startedBelowTop = !atTop
205
+ gs.upwardDragAfterTop = 0
206
+ gs.scrollUnlockedAfterTop = false
207
+ gs.keepScrollLockedAfterEnd = false
208
+ scrollBridge.lockScrollAtTop = false
209
+ if (unlockScrollTimer.current) {
210
+ clearTimeout(unlockScrollTimer.current)
211
+ unlockScrollTimer.current = null
212
+ }
161
213
 
162
214
  // if sheet not at top, DISABLE SCROLL immediately and lock to 0
163
215
  // this prevents scroll from firing before pan takes over
@@ -171,7 +223,6 @@ export function useGestureHandlerPan(config: GesturePanConfig): GesturePanResult
171
223
  gs.panStarted = true
172
224
  setIsDragging(true)
173
225
 
174
- // console.warn('[RNGH-Pan] onStart', { startY: gs.startY, minY })
175
226
  scrollBridge.initialPosition = gs.startY
176
227
  onStart()
177
228
  })
@@ -195,8 +246,6 @@ export function useGestureHandlerPan(config: GesturePanConfig): GesturePanResult
195
246
  const isCurrentlyAtTop = currentPos <= minY + AT_TOP_THRESHOLD
196
247
  const nodeIsScrolling = scrollY > 0
197
248
 
198
- // console.warn('[RNGH-Pan] onChange', { translationY: translationY.toFixed(1), deltaY: deltaY.toFixed(1), currentPos: currentPos.toFixed(1), minY, isCurrentlyAtTop, isSwipingDown, scrollY, scrollEngaged: gs.scrollEngaged })
199
-
200
249
  // decision matrix (from react-native-actions-sheet pattern)
201
250
  // each frame, decide who handles the movement based on current state
202
251
  //
@@ -229,7 +278,6 @@ export function useGestureHandlerPan(config: GesturePanConfig): GesturePanResult
229
278
  } else if (gs.scrollEngaged && hasScrollableContent) {
230
279
  // scroll WAS > 0 but now is 0 -> handoff from scroll to pan
231
280
  // pan takes over to drag sheet down
232
- // console.warn('[RNGH-Pan] *** HANDOFF FROM SCROLL TO PAN ***')
233
281
  panHandles = true
234
282
  } else {
235
283
  // scroll never engaged OR content not scrollable, just drag sheet down
@@ -240,8 +288,18 @@ export function useGestureHandlerPan(config: GesturePanConfig): GesturePanResult
240
288
  // if there's scrollable content, let scroll handle so user can scroll into content
241
289
  // resistance only applies when there's NO scrollable content
242
290
  if (hasScrollableContent) {
243
- // content is scrollable - let scroll handle (user wants to scroll down into content)
244
- panHandles = false
291
+ if (gs.startedBelowTop && !gs.scrollUnlockedAfterTop) {
292
+ gs.upwardDragAfterTop += Math.max(0, -deltaY)
293
+ if (gs.upwardDragAfterTop >= SCROLL_HANDOFF_THRESHOLD) {
294
+ gs.scrollUnlockedAfterTop = true
295
+ panHandles = false
296
+ } else {
297
+ panHandles = true
298
+ }
299
+ } else {
300
+ // content is scrollable - let scroll handle (user wants to scroll down into content)
301
+ panHandles = false
302
+ }
245
303
  } else {
246
304
  // no scrollable content -> pan handles for resistance effect
247
305
  panHandles = true
@@ -249,9 +307,8 @@ export function useGestureHandlerPan(config: GesturePanConfig): GesturePanResult
249
307
  }
250
308
  }
251
309
 
252
- // console.warn('[RNGH-Pan] decision', { panHandles, isCurrentlyAtTop, isSwipingDown, nodeIsScrolling, scrollEngaged: gs.scrollEngaged, hasScrollableContent, currentPos: currentPos.toFixed(1), minY })
253
-
254
310
  if (panHandles) {
311
+ scrollBridge.lockScrollAtTop = gs.startedBelowTop && !gs.scrollUnlockedAfterTop
255
312
  // pan handles - disable scroll and move sheet
256
313
  // when swiping down at top after scroll was engaged: lock at current scroll position
257
314
  // (handoff from scroll to pan — preserve scroll offset)
@@ -269,19 +326,22 @@ export function useGestureHandlerPan(config: GesturePanConfig): GesturePanResult
269
326
  setAnimatedPosition(newPosition)
270
327
  scrollBridge.setParentDragging(newPosition > minY)
271
328
  } else {
329
+ scrollBridge.lockScrollAtTop = false
272
330
  // scroll handles - enable scroll so it can move freely
273
331
  scrollBridge.setScrollEnabled?.(true)
332
+ scrollBridge.setParentDragging(false)
274
333
  // don't accumulate offset when scroll is handling
275
334
  }
276
335
  })
277
336
  .onEnd((event: { velocityY: number }) => {
278
337
  const { velocityY } = event
279
338
  const currentPos = gs.startY + gs.accumulatedOffset
280
-
281
- // console.warn('[RNGH-Pan] onEnd', { velocityY, currentPos, accumulatedOffset: gs.accumulatedOffset, scrollY: scrollBridge.y })
339
+ const keepScrollLockedAfterEnd = gs.startedBelowTop && !gs.scrollUnlockedAfterTop
282
340
 
283
341
  // clear scroll lock
284
- scrollBridge.scrollLockY = undefined
342
+ scrollBridge.lockScrollAtTop = false
343
+ scrollBridge.scrollLockY = keepScrollLockedAfterEnd ? 0 : undefined
344
+ gs.keepScrollLockedAfterEnd = keepScrollLockedAfterEnd
285
345
 
286
346
  // use frozen positions from gesture start — keyboard may have dismissed
287
347
  // during drag (input blur), reverting activePositions. Frozen positions
@@ -294,7 +354,7 @@ export function useGestureHandlerPan(config: GesturePanConfig): GesturePanResult
294
354
 
295
355
  // if sheet is at top and scroll is engaged, just stay at top
296
356
  if (currentPos <= snapMinY + AT_TOP_THRESHOLD && scrollBridge.y > 0) {
297
- onEnd(0)
357
+ onEnd(0, undefined, keepScrollLockedAfterEnd)
298
358
  return
299
359
  }
300
360
 
@@ -302,24 +362,24 @@ export function useGestureHandlerPan(config: GesturePanConfig): GesturePanResult
302
362
  const velocity = velocityY / 1000
303
363
  const projectedEnd = currentPos + frameSize * velocity * 0.2
304
364
 
305
- let closestPoint = 0
306
- let minDist = Number.POSITIVE_INFINITY
307
-
308
- for (let i = 0; i < snapPositions.length; i++) {
309
- const pos = snapPositions[i]
310
- const dist = Math.abs(projectedEnd - pos)
311
- if (dist < minDist) {
312
- minDist = dist
313
- closestPoint = i
314
- }
315
- }
316
-
317
- onEnd(closestPoint)
365
+ const releaseConfig = releaseConfigRef.current
366
+ const closestPoint = getSheetReleasePosition({
367
+ positions: snapPositions,
368
+ projectedEnd,
369
+ currentPosition: currentPos,
370
+ frameSize,
371
+ dismissOnSnapToBottom: releaseConfig.dismissOnSnapToBottom,
372
+ snapPointsMode: releaseConfig.snapPointsMode,
373
+ isKeyboardVisible: gs.frozenIsKeyboardVisible,
374
+ isWeb,
375
+ })
376
+
377
+ onEnd(closestPoint, undefined, keepScrollLockedAfterEnd)
318
378
  })
319
379
  .onFinalize(() => {
320
- // console.warn('[RNGH-Pan] onFinalize', { panStarted: gs.panStarted })
321
380
  // clear scroll lock on finalize too (safety)
322
- scrollBridge.scrollLockY = undefined
381
+ scrollBridge.lockScrollAtTop = false
382
+ scrollBridge.scrollLockY = gs.keepScrollLockedAfterEnd ? 0 : undefined
323
383
  if (gs.panStarted) {
324
384
  // real pan gesture — reset isDragging (also un-pauses keyboard handler + flushes)
325
385
  setIsDragging(false)
@@ -336,7 +396,6 @@ export function useGestureHandlerPan(config: GesturePanConfig): GesturePanResult
336
396
  // if we have a scroll gesture ref, make pan simultaneous with it
337
397
  // this allows both gestures to run and we decide in onChange who handles it
338
398
  if (scrollGestureRef?.current) {
339
- // console.warn('[RNGH-Pan] adding simultaneousWithExternalGesture for scroll')
340
399
  return gesture.simultaneousWithExternalGesture(scrollGestureRef.current)
341
400
  }
342
401
 
@@ -1,26 +1,143 @@
1
1
  /**
2
- * Web stub for keyboard controller sheet hook.
3
- * Returns no-op values since keyboard-controller is native-only.
2
+ * Web implementation of the keyboard controller sheet hook.
3
+ *
4
+ * Mobile browsers don't expose a keyboard API, but they do resize the
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
+ *
11
+ * Without this, a bottom sheet on mobile web stays pinned behind the keyboard:
12
+ * react-native-web's Dimensions tracks the (shrinking) VisualViewport, so any
13
+ * content sized off window dimensions collapses while the sheet's bottom stays
14
+ * occluded. See SheetImplementationCustom's activePositions / keyboardOccludedHeight.
4
15
  */
5
16
 
6
- import { useRef } from 'react'
17
+ import { useCallback, useEffect, useRef, useState } from 'react'
18
+ import { isWeb } from '@hanzogui/constants'
7
19
  import type {
8
20
  KeyboardControllerSheetOptions,
9
21
  KeyboardControllerSheetResult,
10
22
  } from './types'
11
-
12
- const noop = () => {}
23
+ import {
24
+ getWebKeyboardBottomInset,
25
+ getWebKeyboardResizeHeight,
26
+ isEditableElement,
27
+ MIN_KEYBOARD_HEIGHT,
28
+ } from './webViewport'
13
29
 
14
30
  export function useKeyboardControllerSheet(
15
- _options: KeyboardControllerSheetOptions
31
+ options: KeyboardControllerSheetOptions
16
32
  ): KeyboardControllerSheetResult {
33
+ const { enabled } = options
34
+
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)
55
+
56
+ // action-sheet pattern: pause keyboard hide events during drag so the sheet
57
+ // position doesn't revert mid-gesture when a TextInput blurs.
17
58
  const pauseKeyboardHandler = useRef(false)
59
+ const pendingHide = useRef(false)
60
+ // live mirror of isKeyboardVisible so the listener can read it without
61
+ // re-subscribing on every toggle.
62
+ const isVisibleRef = useRef(false)
63
+ isVisibleRef.current = isKeyboardVisible
64
+
65
+ const dismissKeyboard = useCallback(() => {
66
+ if (typeof document === 'undefined') return
67
+ const active = document.activeElement as HTMLElement | null
68
+ if (isEditableElement(active)) {
69
+ active?.blur?.()
70
+ }
71
+ }, [])
72
+
73
+ const flushPendingHide = useCallback(() => {
74
+ if (pendingHide.current) {
75
+ pendingHide.current = false
76
+ setIsKeyboardVisible(false)
77
+ setKeyboardHeight(0)
78
+ }
79
+ }, [])
80
+
81
+ useEffect(() => {
82
+ if (!isWeb || !enabled) return
83
+ if (typeof window === 'undefined') return
84
+ const vv = window.visualViewport
85
+ if (!vv) return
86
+
87
+ const update = () => {
88
+ const resizeHeight = getWebKeyboardResizeHeight()
89
+ const height = getWebKeyboardBottomInset()
90
+ const tall = resizeHeight >= MIN_KEYBOARD_HEIGHT
91
+ // require an editable element focused to *show* — this rules out URL-bar
92
+ // collapse and other viewport changes that aren't a keyboard. but stay
93
+ // visible while the viewport remains shrunk even if focus momentarily
94
+ // moves to a non-editable element (e.g. tapping between two inputs fires
95
+ // focusout→focusin), so the sheet doesn't flicker mid-transition.
96
+ const editableFocused = isEditableElement(document.activeElement)
97
+ const visible = tall && (editableFocused || isVisibleRef.current)
98
+
99
+ if (!visible) {
100
+ // suppress hide during an active drag so positions stay frozen; the
101
+ // drag-end flush reconciles the real state.
102
+ if (pauseKeyboardHandler.current && isVisibleRef.current) {
103
+ pendingHide.current = true
104
+ return
105
+ }
106
+ setIsKeyboardVisible(false)
107
+ setKeyboardHeight(0)
108
+ return
109
+ }
110
+
111
+ pendingHide.current = false
112
+ setIsKeyboardVisible(true)
113
+ setKeyboardHeight((prev) => (prev === height ? prev : height))
114
+ }
115
+
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.
119
+ vv.addEventListener('resize', update)
120
+ vv.addEventListener('scroll', update)
121
+ // focus changes flip editable state without necessarily resizing the viewport
122
+ window.addEventListener('focusin', update)
123
+ window.addEventListener('focusout', update)
124
+
125
+ update()
126
+
127
+ return () => {
128
+ vv.removeEventListener('resize', update)
129
+ vv.removeEventListener('scroll', update)
130
+ window.removeEventListener('focusin', update)
131
+ window.removeEventListener('focusout', update)
132
+ }
133
+ }, [enabled])
134
+
18
135
  return {
19
136
  keyboardControllerEnabled: false,
20
- keyboardHeight: 0,
21
- isKeyboardVisible: false,
22
- dismissKeyboard: noop,
137
+ keyboardHeight,
138
+ isKeyboardVisible,
139
+ dismissKeyboard,
23
140
  pauseKeyboardHandler,
24
- flushPendingHide: noop,
141
+ flushPendingHide,
25
142
  }
26
143
  }
@@ -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 @hanzogui/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
+ }
@@ -8,6 +8,17 @@ import type { ScrollBridge, SheetProps } from './types'
8
8
  import type { SheetOpenState } from './useSheetOpenState'
9
9
 
10
10
  export type SheetContextValue = ReturnType<typeof useSheetProviderProps> & {
11
+ keyboardOccludedHeight: number
12
+ // whether the soft keyboard is currently open. distinct from
13
+ // keyboardOccludedHeight (which is 0 once the sheet fits above the keyboard):
14
+ // the fit-mode height freeze must persist for the whole time the keyboard is
15
+ // open, not just while occluded, or it releases and the sheet collapses.
16
+ isKeyboardVisible: boolean
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
+ keyboardStableFrameHeight: number
11
22
  setHasScrollView: (val: boolean) => void
12
23
  }
13
24
 
@@ -133,7 +144,7 @@ export function useSheetProviderProps(
133
144
  throw new Error(
134
145
  process.env.NODE_ENV === 'production'
135
146
  ? `❌ 008`
136
- : 'Must set animations in gui.config.ts'
147
+ : 'Must set animations in hanzogui.config.ts'
137
148
  )
138
149
  }
139
150
 
@@ -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
@@ -19,21 +20,29 @@ interface UseSheetScrollViewGesturesProps {
19
20
  hasScrollableContent: boolean
20
21
  scrollEnabled: boolean
21
22
  setScrollEnabled: (enabled: boolean, lockTo?: number) => void
23
+ onManualScroll?: (node: HTMLElement, y: number) => void
22
24
  }
23
25
 
24
26
  export function useSheetScrollViewGestures({
25
27
  scrollRef,
26
28
  scrollBridge,
27
29
  hasScrollableContent,
30
+ onManualScroll,
28
31
  }: UseSheetScrollViewGesturesProps) {
29
32
  const state = useRef<GestureState>({
30
33
  startY: 0,
31
34
  lastY: 0,
32
35
  owner: 'none',
36
+ hadPanOwner: false,
33
37
  panDragOffset: 0,
34
38
  dys: [],
35
39
  scrollYAtGestureStart: 0,
36
40
  })
41
+ const onManualScrollRef = useRef(onManualScroll)
42
+
43
+ useEffect(() => {
44
+ onManualScrollRef.current = onManualScroll
45
+ }, [onManualScroll])
37
46
 
38
47
  useEffect(() => {
39
48
  if (!scrollRef.current) return
@@ -59,6 +68,13 @@ export function useSheetScrollViewGestures({
59
68
  node.style.overflowY = originalOverflow
60
69
  }
61
70
 
71
+ const setScrollTop = (nextScrollY: number) => {
72
+ if (node.scrollTop === nextScrollY) return
73
+
74
+ node.scrollTop = nextScrollY
75
+ onManualScrollRef.current?.(node, nextScrollY)
76
+ }
77
+
62
78
  const handleTouchStart = (e: TouchEvent) => {
63
79
  const touch = e.touches[0]
64
80
  if (!touch) return
@@ -69,12 +85,17 @@ export function useSheetScrollViewGestures({
69
85
  startY: touch.pageY,
70
86
  lastY: touch.pageY,
71
87
  owner: 'none',
88
+ hadPanOwner: false,
72
89
  panDragOffset: 0,
73
90
  dys: [],
74
91
  scrollYAtGestureStart: currentScrollY,
75
92
  }
76
93
 
77
94
  scrollBridge.scrollStartY = touch.pageY
95
+ // claim this touch for the scroll-view gesture hook so the PanResponder
96
+ // (which also negotiates this touch via RNW's responder system) defers
97
+ // and doesn't double-drive the sheet position. cleared on touchend.
98
+ scrollBridge.scrollNodeTouched = true
78
99
  }
79
100
 
80
101
  const handleTouchMove = (e: TouchEvent) => {
@@ -90,7 +111,8 @@ export function useSheetScrollViewGestures({
90
111
 
91
112
  const isDraggingDown = dy > 0
92
113
  const currentScrollY = node.scrollTop
93
- const isPaneAtTop = scrollBridge.paneY <= scrollBridge.paneMinY + 5
114
+ const isPaneAtTop =
115
+ scrollBridge.isAtTop ?? scrollBridge.paneY <= scrollBridge.paneMinY + 5
94
116
 
95
117
  scrollBridge.y = currentScrollY
96
118
 
@@ -113,14 +135,23 @@ export function useSheetScrollViewGestures({
113
135
  // handle transitions
114
136
  if (newOwner !== s.owner) {
115
137
  if (newOwner === 'pan') {
138
+ s.hadPanOwner = true
116
139
  s.panDragOffset = 0
117
140
  s.dys = []
141
+ // re-baseline the pan origin to the sheet's CURRENT position so the
142
+ // offset (reset to 0 here) maps to where it actually is — required for
143
+ // a correct scroll→pan handoff now that the PanResponder defers to us.
144
+ scrollBridge.startPanDrag?.()
118
145
  scrollBridge.setParentDragging(true)
119
146
  disableScroll()
120
147
  } else {
121
148
  scrollBridge.setParentDragging(false)
122
149
  scrollBridge.scrollLock = false
123
- enableScroll()
150
+ if (s.hadPanOwner) {
151
+ disableScroll()
152
+ } else {
153
+ enableScroll()
154
+ }
124
155
  }
125
156
  s.owner = newOwner
126
157
  }
@@ -133,14 +164,21 @@ export function useSheetScrollViewGestures({
133
164
 
134
165
  s.dys.push(dy)
135
166
  if (s.dys.length > 100) s.dys = s.dys.slice(-10)
136
- } else if (s.owner === 'scroll') {
137
- // programmatic scroll for synthetic events
167
+ } else if (s.owner === 'scroll' && (!e.isTrusted || s.hadPanOwner)) {
168
+ // synthetic events don't trigger native overflow scroll, so tests need us
169
+ // to move scrollTop directly. real mixed gestures need the same direct
170
+ // path after pan ownership: ios safari may not resume native scrolling
171
+ // mid-touch after an earlier prevented touchmove. pure real scrolls still
172
+ // use native scrolling; mixed pan↔scroll gestures keep overflow hidden
173
+ // and are driven here until touchend.
174
+ if (e.cancelable) e.preventDefault()
138
175
  const scrollDelta = -dy
139
176
  const maxScrollY = node.scrollHeight - node.clientHeight
140
177
  const newScrollY = Math.max(0, Math.min(maxScrollY, currentScrollY + scrollDelta))
141
178
  if (newScrollY !== currentScrollY) {
142
- node.scrollTop = newScrollY
179
+ setScrollTop(newScrollY)
143
180
  scrollBridge.y = newScrollY
181
+ if (newScrollY > 0) scrollBridge.scrollStartY = -1
144
182
  }
145
183
  }
146
184
  }
@@ -159,12 +197,21 @@ export function useSheetScrollViewGestures({
159
197
  }
160
198
 
161
199
  scrollBridge.release({ dragAt: s.panDragOffset, vy })
200
+ } else if (s.owner === 'scroll') {
201
+ // gesture ended while scrolling. a pan→scroll handoff only happens once
202
+ // the pane reached the top, so commit the top snap (index 0) and clear
203
+ // the dragging state HERE, on touchend — never mid-gesture (that would
204
+ // fight the live gesture). this replaces the PanResponder's release,
205
+ // which used to fire on touchend before it deferred to this hook.
206
+ scrollBridge.snapToPosition?.(0)
162
207
  }
163
208
 
164
209
  enableScroll()
165
210
  s.owner = 'none'
211
+ s.hadPanOwner = false
166
212
  s.panDragOffset = 0
167
213
  s.dys = []
214
+ scrollBridge.scrollNodeTouched = false
168
215
  }
169
216
 
170
217
  node.addEventListener('touchstart', handleTouchStart, {