1010 * governing permissions and limitations under the License.
1111 */
1212
13+ import { chain , getScrollParent } from '@react-aria/utils' ;
1314import { useEffect } from 'react' ;
1415
1516interface PreventScrollOptions {
1617 /** Whether the scroll lock is disabled. */
1718 isDisabled ?: boolean
1819}
1920
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+
2033/**
2134 * Prevents scrolling on the document body on mount, and
2235 * restores it on unmount. Also ensures that content does not
@@ -26,16 +39,200 @@ export function usePreventScroll(options: PreventScrollOptions = {}) {
2639 let { isDisabled} = options ;
2740
2841 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 ;
3444 }
3545
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+ }
4051 } , [ isDisabled ] ) ;
4152}
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