10
10
* governing permissions and limitations under the License.
11
11
*/
12
12
13
- import { chain , getScrollParent , isIOS , useLayoutEffect } from '@react-aria/utils' ;
13
+ import { chain , getScrollParent , isIOS , useLayoutEffect , willOpenKeyboard } from '@react-aria/utils' ;
14
14
15
15
interface PreventScrollOptions {
16
16
/** Whether the scroll lock is disabled. */
@@ -19,19 +19,6 @@ interface PreventScrollOptions {
19
19
20
20
const visualViewport = typeof document !== 'undefined' && window . visualViewport ;
21
21
22
- // HTML input types that do not cause the software keyboard to appear.
23
- const nonTextInputTypes = new Set ( [
24
- 'checkbox' ,
25
- 'radio' ,
26
- 'range' ,
27
- 'color' ,
28
- 'file' ,
29
- 'image' ,
30
- 'button' ,
31
- 'submit' ,
32
- 'reset'
33
- ] ) ;
34
-
35
22
// The number of active usePreventScroll calls. Used to determine whether to revert back to the original page style/scroll position
36
23
let preventScrollCount = 0 ;
37
24
let restore ;
@@ -100,32 +87,32 @@ function preventScrollStandard() {
100
87
// the top or bottom. Work around a bug where this does not work when the element does not actually overflow
101
88
// by preventing default in a `touchmove` event.
102
89
// 3. Prevent default on `touchend` events on input elements and handle focusing the element ourselves.
103
- // 4. When focusing an input, apply a transform to trick Safari into thinking the input is at the top
104
- // of the page, which prevents it from scrolling the page. After the input is focused, scroll the element
105
- // into view ourselves, without scrolling the whole page.
106
- // 5. Offset the body by the scroll position using a negative margin and scroll to the top. This should appear the
107
- // same visually, but makes the actual scroll position always zero. This is required to make all of the
108
- // above work or Safari will still try to scroll the page when focusing an input.
109
- // 6. As a last resort, handle window scroll events, and scroll back to the top. This can happen when attempting
110
- // to navigate to an input with the next/previous buttons that's outside a modal.
90
+ // 4. When focus moves to an input, create an off screen input and focus that temporarily. This prevents
91
+ // Safari from scrolling the page. After a small delay, focus the real input and scroll it into view
92
+ // ourselves, without scrolling the whole page.
111
93
function preventScrollMobileSafari ( ) {
112
94
let scrollable : Element ;
113
- let restoreScrollableStyles ;
114
95
let onTouchStart = ( e : TouchEvent ) => {
115
96
// Store the nearest scrollable parent element from the element that the user touched.
116
97
scrollable = getScrollParent ( e . target as Element , true ) ;
117
98
if ( scrollable === document . documentElement && scrollable === document . body ) {
118
99
return ;
119
100
}
120
-
121
- // Prevent scrolling up when at the top and scrolling down when at the bottom
122
- // of a nested scrollable area, otherwise mobile Safari will start scrolling
123
- // the window instead.
124
- if ( scrollable instanceof HTMLElement && window . getComputedStyle ( scrollable ) . overscrollBehavior === 'auto' ) {
125
- restoreScrollableStyles = setStyle ( scrollable , 'overscrollBehavior' , 'contain' ) ;
126
- }
127
101
} ;
128
102
103
+ // Prevent scrolling up when at the top and scrolling down when at the bottom
104
+ // of a nested scrollable area, otherwise mobile Safari will start scrolling
105
+ // the window instead.
106
+ // This must be applied before the touchstart event as of iOS 26, so inject it as a <style> element.
107
+ let style = document . createElement ( 'style' ) ;
108
+ style . textContent = `
109
+ @layer {
110
+ * {
111
+ overscroll-behavior: contain;
112
+ }
113
+ }` . trim ( ) ;
114
+ document . head . prepend ( style ) ;
115
+
129
116
let onTouchMove = ( e : TouchEvent ) => {
130
117
// Prevent scrolling the window.
131
118
if ( ! scrollable || scrollable === document . documentElement || scrollable === document . body ) {
@@ -144,86 +131,48 @@ function preventScrollMobileSafari() {
144
131
}
145
132
} ;
146
133
147
- let onTouchEnd = ( ) => {
148
- if ( restoreScrollableStyles ) {
149
- restoreScrollableStyles ( ) ;
150
- }
151
- } ;
152
-
153
- let onFocus = ( e : FocusEvent ) => {
134
+ let onBlur = ( e : FocusEvent ) => {
154
135
let target = e . target as HTMLElement ;
155
- if ( willOpenKeyboard ( target ) ) {
156
- setupStyles ( ) ;
157
-
158
- // Apply a transform to trick Safari into thinking the input is at the top of the page
159
- // so it doesn't try to scroll it into view.
160
- target . style . transform = 'translateY(-2000px)' ;
161
- requestAnimationFrame ( ( ) => {
162
- target . style . transform = '' ;
163
-
164
- // This will have prevented the browser from scrolling the focused element into view,
165
- // so we need to do this ourselves in a way that doesn't cause the whole page to scroll.
166
- if ( visualViewport ) {
167
- if ( visualViewport . height < window . innerHeight ) {
168
- // If the keyboard is already visible, do this after one additional frame
169
- // to wait for the transform to be removed.
170
- requestAnimationFrame ( ( ) => {
171
- scrollIntoView ( target ) ;
172
- } ) ;
173
- } else {
174
- // Otherwise, wait for the visual viewport to resize before scrolling so we can
175
- // measure the correct position to scroll to.
176
- visualViewport . addEventListener ( 'resize' , ( ) => scrollIntoView ( target ) , { once : true } ) ;
177
- }
178
- }
179
- } ) ;
136
+ let relatedTarget = e . relatedTarget as HTMLElement | null ;
137
+ if ( relatedTarget && willOpenKeyboard ( relatedTarget ) ) {
138
+ // Focus without scrolling the whole page, and then scroll into view manually.
139
+ relatedTarget . focus ( { preventScroll : true } ) ;
140
+ scrollIntoViewWhenReady ( relatedTarget , willOpenKeyboard ( target ) ) ;
141
+ } else if ( ! relatedTarget ) {
142
+ // When tapping the Done button on the keyboard, focus moves to the body.
143
+ // FocusScope will then restore focus back to the input. Later when tapping
144
+ // the same input again, it is already focused, so no blur event will fire,
145
+ // resulting in the flow above never running and Safari's native scrolling occurring.
146
+ // Instead, move focus to the parent focusable element (e.g. the dialog).
147
+ let focusable = target . parentElement ?. closest ( '[tabindex]' ) as HTMLElement | null ;
148
+ focusable ?. focus ( { preventScroll : true } ) ;
180
149
}
181
150
} ;
182
151
183
- let restoreStyles : null | ( ( ) => void ) = null ;
184
- let setupStyles = ( ) => {
185
- if ( restoreStyles ) {
186
- return ;
187
- }
188
-
189
- let onWindowScroll = ( ) => {
190
- // Last resort. If the window scrolled, scroll it back to the top.
191
- // It should always be at the top because the body will have a negative margin (see below).
192
- window . scrollTo ( 0 , 0 ) ;
193
- } ;
194
-
195
- // Record the original scroll position so we can restore it.
196
- // Then apply a negative margin to the body to offset it by the scroll position. This will
197
- // enable us to scroll the window to the top, which is required for the rest of this to work.
198
- let scrollX = window . pageXOffset ;
199
- let scrollY = window . pageYOffset ;
152
+ // Override programmatic focus to scroll into view without scrolling the whole page.
153
+ let focus = HTMLElement . prototype . focus ;
154
+ HTMLElement . prototype . focus = function ( opts ) {
155
+ // Track whether the keyboard was already visible before.
156
+ let wasKeyboardVisible = document . activeElement != null && willOpenKeyboard ( document . activeElement ) ;
200
157
201
- restoreStyles = chain (
202
- addEvent ( window , 'scroll' , onWindowScroll ) ,
203
- setStyle ( document . documentElement , 'paddingRight' , `${ window . innerWidth - document . documentElement . clientWidth } px` ) ,
204
- setStyle ( document . documentElement , 'overflow' , 'hidden' ) ,
205
- setStyle ( document . body , 'marginTop' , `-${ scrollY } px` ) ,
206
- ( ) => {
207
- window . scrollTo ( scrollX , scrollY ) ;
208
- }
209
- ) ;
158
+ // Focus the element without scrolling the page.
159
+ focus . call ( this , { ...opts , preventScroll : true } ) ;
210
160
211
- // Scroll to the top. The negative margin on the body will make this appear the same.
212
- window . scrollTo ( 0 , 0 ) ;
161
+ if ( ! opts || ! opts . preventScroll ) {
162
+ scrollIntoViewWhenReady ( this , wasKeyboardVisible ) ;
163
+ }
213
164
} ;
214
165
215
166
let removeEvents = chain (
216
167
addEvent ( document , 'touchstart' , onTouchStart , { passive : false , capture : true } ) ,
217
168
addEvent ( document , 'touchmove' , onTouchMove , { passive : false , capture : true } ) ,
218
- addEvent ( document , 'touchend' , onTouchEnd , { passive : false , capture : true } ) ,
219
- addEvent ( document , 'focus' , onFocus , true )
169
+ addEvent ( document , 'blur' , onBlur , true )
220
170
) ;
221
171
222
172
return ( ) => {
223
- // Restore styles and scroll the page back to where it was.
224
- restoreScrollableStyles ?.( ) ;
225
- restoreStyles ?.( ) ;
226
173
removeEvents ( ) ;
174
+ style . remove ( ) ;
175
+ HTMLElement . prototype . focus = focus ;
227
176
} ;
228
177
}
229
178
@@ -253,28 +202,42 @@ function addEvent<K extends keyof GlobalEventHandlersEventMap>(
253
202
} ;
254
203
}
255
204
205
+ function scrollIntoViewWhenReady ( target : Element , wasKeyboardVisible : boolean ) {
206
+ if ( wasKeyboardVisible || ! visualViewport ) {
207
+ // If the keyboard was already visible, scroll the target into view immediately.
208
+ scrollIntoView ( target ) ;
209
+ } else {
210
+ // Otherwise, wait for the visual viewport to resize before scrolling so we can
211
+ // measure the correct position to scroll to.
212
+ visualViewport . addEventListener ( 'resize' , ( ) => scrollIntoView ( target ) , { once : true } ) ;
213
+ }
214
+ }
215
+
256
216
function scrollIntoView ( target : Element ) {
257
217
let root = document . scrollingElement || document . documentElement ;
258
218
let nextTarget : Element | null = target ;
259
219
while ( nextTarget && nextTarget !== root ) {
260
220
// Find the parent scrollable element and adjust the scroll position if the target is not already in view.
261
221
let scrollable = getScrollParent ( nextTarget ) ;
262
222
if ( scrollable !== document . documentElement && scrollable !== document . body && scrollable !== nextTarget ) {
263
- let scrollableTop = scrollable . getBoundingClientRect ( ) . top ;
264
- let targetTop = nextTarget . getBoundingClientRect ( ) . top ;
265
- if ( targetTop > scrollableTop + nextTarget . clientHeight ) {
266
- scrollable . scrollTop += targetTop - scrollableTop ;
223
+ let scrollableRect = scrollable . getBoundingClientRect ( ) ;
224
+ let targetRect = nextTarget . getBoundingClientRect ( ) ;
225
+ if ( targetRect . top < scrollableRect . top || targetRect . bottom > scrollableRect . top + nextTarget . clientHeight ) {
226
+ let bottom = scrollableRect . bottom ;
227
+ if ( visualViewport ) {
228
+ bottom = Math . min ( bottom , visualViewport . offsetTop + visualViewport . height ) ;
229
+ }
230
+
231
+ // Center within the viewport.
232
+ let adjustment = ( targetRect . top - scrollableRect . top ) - ( ( bottom - scrollableRect . top ) / 2 - targetRect . height / 2 ) ;
233
+ scrollable . scrollTo ( {
234
+ // Clamp to the valid range to prevent over-scrolling.
235
+ top : Math . max ( 0 , Math . min ( scrollable . scrollHeight - scrollable . clientHeight , scrollable . scrollTop + adjustment ) ) ,
236
+ behavior : 'smooth'
237
+ } ) ;
267
238
}
268
239
}
269
240
270
241
nextTarget = scrollable . parentElement ;
271
242
}
272
243
}
273
-
274
- function willOpenKeyboard ( target : Element ) {
275
- return (
276
- ( target instanceof HTMLInputElement && ! nonTextInputTypes . has ( target . type ) ) ||
277
- target instanceof HTMLTextAreaElement ||
278
- ( target instanceof HTMLElement && target . isContentEditable )
279
- ) ;
280
- }
0 commit comments