10
10
* governing permissions and limitations under the License.
11
11
*/
12
12
13
+ import { chain , getScrollParent } from '@react-aria/utils' ;
13
14
import { useEffect } from 'react' ;
14
15
15
16
interface PreventScrollOptions {
16
17
/** Whether the scroll lock is disabled. */
17
18
isDisabled ?: boolean
18
19
}
19
20
21
+ const isMobileSafari =
22
+ typeof window !== 'undefined' && window . navigator != null
23
+ ? / A p p l e W e b K i t / . test ( window . navigator . userAgent ) && (
24
+ / ^ ( i P h o n e | i P a d ) $ / . test ( window . navigator . platform ) ||
25
+ // iPadOS 13 lies and says its a Mac, but we can distinguish by detecting touch support.
26
+ ( window . navigator . platform === 'MacIntel' && navigator . maxTouchPoints > 1 )
27
+ )
28
+ : false ;
29
+
30
+ // @ts -ignore
31
+ const visualViewport = typeof window !== 'undefined' && window . visualViewport ;
32
+
20
33
/**
21
34
* Prevents scrolling on the document body on mount, and
22
35
* restores it on unmount. Also ensures that content does not
@@ -26,16 +39,200 @@ export function usePreventScroll(options: PreventScrollOptions = {}) {
26
39
let { isDisabled} = options ;
27
40
28
41
useEffect ( ( ) => {
29
- let { paddingRight, overflow} = document . body . style ;
30
-
31
- if ( ! isDisabled ) {
32
- document . body . style . paddingRight = `${ window . innerWidth - document . documentElement . clientWidth } px` ;
33
- document . body . style . overflow = 'hidden' ;
42
+ if ( isDisabled ) {
43
+ return ;
34
44
}
35
45
36
- return ( ) => {
37
- document . body . style . overflow = overflow ;
38
- document . body . style . paddingRight = paddingRight ;
39
- } ;
46
+ if ( isMobileSafari ) {
47
+ return preventScrollMobileSafari ( ) ;
48
+ } else {
49
+ return preventScrollStandard ( ) ;
50
+ }
40
51
} , [ isDisabled ] ) ;
41
52
}
53
+
54
+ // For most browsers, all we need to do is set `overflow: hidden` on the root element, and
55
+ // add some padding to prevent the page from shifting when the scrollbar is hidden.
56
+ function preventScrollStandard ( ) {
57
+ return chain (
58
+ setStyle ( document . documentElement , 'paddingRight' , `${ window . innerWidth - document . documentElement . clientWidth } px` ) ,
59
+ setStyle ( document . documentElement , 'overflow' , 'hidden' ) ,
60
+ ) ;
61
+ }
62
+
63
+ // Mobile Safari is a whole different beast. Even with overflow: hidden,
64
+ // it still scrolls the page in many situations:
65
+ //
66
+ // 1. When the bottom toolbar and address bar are collapsed, page scrolling is always allowed.
67
+ // 2. When the keyboard is visible, the viewport does not resize. Instead, the keyboard covers part of
68
+ // it, so it becomes scrollable.
69
+ // 3. When tapping on an input, the page always scrolls so that the input is centered in the visual viewport.
70
+ // This may cause even fixed position elements to scroll off the screen.
71
+ // 4. When using the next/previous buttons in the keyboard to navigate between inputs, the whole page always
72
+ // scrolls, even if the input is inside a nested scrollable element that could be scrolled instead.
73
+ //
74
+ // In order to work around these cases, and prevent scrolling without jankiness, we do a few things:
75
+ //
76
+ // 1. Prevent default on `touchmove` events that are not in a scrollable element. This prevents touch scrolling
77
+ // on the window.
78
+ // 2. Prevent default on `touchmove` events inside a scrollable element when the scroll position is at the
79
+ // top or bottom. This avoids the whole page scrolling instead, but does prevent overscrolling.
80
+ // 3. Prevent default on `touchend` events on input elements and handle focusing the element ourselves.
81
+ // 4. When focusing an input, apply a transform to trick Safari into thinking the input is at the top
82
+ // of the page, which prevents it from scrolling the page. After the input is focused, scroll the element
83
+ // into view ourselves, without scrolling the whole page.
84
+ // 5. Offset the body by the scroll position using a negative margin and scroll to the top. This should appear the
85
+ // same visually, but makes the actual scroll position always zero. This is required to make all of the
86
+ // above work or Safari will still try to scroll the page when focusing an input.
87
+ // 6. As a last resort, handle window scroll events, and scroll back to the top. This can happen when attempting
88
+ // to navigate to an input with the next/previous buttons that's outside a modal.
89
+ function preventScrollMobileSafari ( ) {
90
+ let scrollable : Element ;
91
+ let lastY = 0 ;
92
+ let onTouchStart = ( e : TouchEvent ) => {
93
+ // Store the nearest scrollable parent element from the element that the user touched.
94
+ scrollable = getScrollParent ( e . target as Element ) ;
95
+ if ( scrollable === document . documentElement && scrollable === document . body ) {
96
+ return ;
97
+ }
98
+
99
+ lastY = e . changedTouches [ 0 ] . pageY ;
100
+ } ;
101
+
102
+ let onTouchMove = ( e : TouchEvent ) => {
103
+ // Prevent scrolling the window.
104
+ if ( scrollable === document . documentElement || scrollable === document . body ) {
105
+ e . preventDefault ( ) ;
106
+ return ;
107
+ }
108
+
109
+ // Prevent scrolling up when at the top and scrolling down when at the bottom
110
+ // of a nested scrollable area, otherwise mobile Safari will start scrolling
111
+ // the window instead. Unfortunately, this disables bounce scrolling when at
112
+ // the top but it's the best we can do.
113
+ let y = e . changedTouches [ 0 ] . pageY ;
114
+ let scrollTop = scrollable . scrollTop ;
115
+ let bottom = scrollable . scrollHeight - scrollable . clientHeight ;
116
+
117
+ if ( ( scrollTop <= 0 && y > lastY ) || ( scrollTop >= bottom && y < lastY ) ) {
118
+ e . preventDefault ( ) ;
119
+ }
120
+
121
+ lastY = y ;
122
+ } ;
123
+
124
+ let onTouchEnd = ( e : TouchEvent ) => {
125
+ let target = e . target as HTMLElement ;
126
+ if ( target . tagName === 'INPUT' ) {
127
+ e . preventDefault ( ) ;
128
+
129
+ // Apply a transform to trick Safari into thinking the input is at the top of the page
130
+ // so it doesn't try to scroll it into view. When tapping on an input, this needs to
131
+ // be done before the "focus" event, so we have to focus the element ourselves.
132
+ target . style . transform = 'translateY(-2000px)' ;
133
+ target . focus ( ) ;
134
+ requestAnimationFrame ( ( ) => {
135
+ target . style . transform = '' ;
136
+ } ) ;
137
+ }
138
+ } ;
139
+
140
+ let onFocus = ( e : FocusEvent ) => {
141
+ let target = e . target as HTMLElement ;
142
+ if ( target . tagName === 'INPUT' ) {
143
+ // Transform also needs to be applied in the focus event in cases where focus moves
144
+ // other than tapping on an input directly, e.g. the next/previous buttons in the
145
+ // software keyboard. In these cases, it seems applying the transform in the focus event
146
+ // is good enough, whereas when tapping an input, it must be done before the focus event. 🤷♂️
147
+ target . style . transform = 'translateY(-2000px)' ;
148
+ requestAnimationFrame ( ( ) => {
149
+ target . style . transform = '' ;
150
+
151
+ // This will have prevented the browser from scrolling the focused element into view,
152
+ // so we need to do this ourselves in a way that doesn't cause the whole page to scroll.
153
+ if ( visualViewport ) {
154
+ if ( visualViewport . height < window . innerHeight ) {
155
+ // If the keyboard is already visible, do this after one additional frame
156
+ // to wait for the transform to be removed.
157
+ requestAnimationFrame ( ( ) => {
158
+ scrollIntoView ( target ) ;
159
+ } ) ;
160
+ } else {
161
+ // Otherwise, wait for the visual viewport to resize before scrolling so we can
162
+ // measure the correct position to scroll to.
163
+ visualViewport . addEventListener ( 'resize' , ( ) => scrollIntoView ( target ) , { once : true } ) ;
164
+ }
165
+ }
166
+ } ) ;
167
+ }
168
+ } ;
169
+
170
+ let onWindowScroll = ( ) => {
171
+ // Last resort. If the window scrolled, scroll it back to the top.
172
+ // It should always be at the top because the body will have a negative margin (see below).
173
+ window . scrollTo ( 0 , 0 ) ;
174
+ } ;
175
+
176
+ // Record the original scroll position so we can restore it.
177
+ // Then apply a negative margin to the body to offset it by the scroll position. This will
178
+ // enable us to scroll the window to the top, which is required for the rest of this to work.
179
+ let scrollX = window . pageXOffset ;
180
+ let scrollY = window . pageYOffset ;
181
+ let restoreStyles = chain (
182
+ setStyle ( document . documentElement , 'paddingRight' , `${ window . innerWidth - document . documentElement . clientWidth } px` ) ,
183
+ setStyle ( document . documentElement , 'overflow' , 'hidden' ) ,
184
+ setStyle ( document . body , 'marginTop' , `-${ scrollY } px` )
185
+ ) ;
186
+
187
+ // Scroll to the top. The negative margin on the body will make this appear the same.
188
+ window . scrollTo ( 0 , 0 ) ;
189
+
190
+ let removeEvents = chain (
191
+ addEvent ( document , 'touchstart' , onTouchStart , { passive : false , capture : true } ) ,
192
+ addEvent ( document , 'touchmove' , onTouchMove , { passive : false , capture : true } ) ,
193
+ addEvent ( document , 'touchend' , onTouchEnd , { passive : false , capture : true } ) ,
194
+ addEvent ( document , 'focus' , onFocus , true ) ,
195
+ addEvent ( window , 'scroll' , onWindowScroll )
196
+ ) ;
197
+
198
+ return ( ) => {
199
+ // Restore styles and scroll the page back to where it was.
200
+ restoreStyles ( ) ;
201
+ removeEvents ( ) ;
202
+ window . scrollTo ( scrollX , scrollY ) ;
203
+ } ;
204
+ }
205
+
206
+ // Sets a CSS property on an element, and returns a function to revert it to the previous value.
207
+ function setStyle ( element : HTMLElement , style : string , value : string ) {
208
+ let cur = element . style [ style ] ;
209
+ element . style [ style ] = value ;
210
+ return ( ) => {
211
+ element . style [ style ] = cur ;
212
+ } ;
213
+ }
214
+
215
+ // Adds an event listener to an element, and returns a function to remove it.
216
+ function addEvent < K extends keyof GlobalEventHandlersEventMap > (
217
+ target : EventTarget ,
218
+ event : K ,
219
+ handler : ( this : Document , ev : GlobalEventHandlersEventMap [ K ] ) => any ,
220
+ options ?: boolean | AddEventListenerOptions
221
+ ) {
222
+ target . addEventListener ( event , handler , options ) ;
223
+ return ( ) => {
224
+ target . removeEventListener ( event , handler , options ) ;
225
+ } ;
226
+ }
227
+
228
+ function scrollIntoView ( target : Element ) {
229
+ // Find the parent scrollable element and adjust the scroll position if the target is not already in view.
230
+ let scrollable = getScrollParent ( target ) ;
231
+ if ( scrollable !== document . documentElement && scrollable !== document . body ) {
232
+ let scrollableTop = scrollable . getBoundingClientRect ( ) . top ;
233
+ let targetTop = target . getBoundingClientRect ( ) . top ;
234
+ if ( targetTop > scrollableTop + target . clientHeight ) {
235
+ scrollable . scrollTop += targetTop - scrollableTop ;
236
+ }
237
+ }
238
+ }
0 commit comments