Skip to content

Commit 9e6513b

Browse files
authored
Improve behavior of usePreventScroll on iOS (#5346)
1 parent 10d3395 commit 9e6513b

File tree

3 files changed

+60
-43
lines changed

3 files changed

+60
-43
lines changed

packages/@react-aria/overlays/src/usePopover.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ export function usePopover(props: AriaPopoverProps, state: OverlayTriggerState):
9393
});
9494

9595
usePreventScroll({
96-
isDisabled: isNonModal
96+
isDisabled: isNonModal || !state.isOpen
9797
});
9898

9999
useLayoutEffect(() => {

packages/@react-aria/overlays/src/usePreventScroll.ts

Lines changed: 55 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,9 @@ function preventScrollStandard() {
9292
//
9393
// 1. Prevent default on `touchmove` events that are not in a scrollable element. This prevents touch scrolling
9494
// on the window.
95-
// 2. Prevent default on `touchmove` events inside a scrollable element when the scroll position is at the
96-
// top or bottom. This avoids the whole page scrolling instead, but does prevent overscrolling.
95+
// 2. Set `overscroll-behavior: contain` on nested scrollable regions so they do not scroll the page when at
96+
// the top or bottom. Work around a bug where this does not work when the element does not actually overflow
97+
// by preventing default in a `touchmove` event.
9798
// 3. Prevent default on `touchend` events on input elements and handle focusing the element ourselves.
9899
// 4. When focusing an input, apply a transform to trick Safari into thinking the input is at the top
99100
// of the page, which prevents it from scrolling the page. After the input is focused, scroll the element
@@ -105,15 +106,20 @@ function preventScrollStandard() {
105106
// to navigate to an input with the next/previous buttons that's outside a modal.
106107
function preventScrollMobileSafari() {
107108
let scrollable: Element;
108-
let lastY = 0;
109+
let restoreScrollableStyles;
109110
let onTouchStart = (e: TouchEvent) => {
110111
// Store the nearest scrollable parent element from the element that the user touched.
111112
scrollable = getScrollParent(e.target as Element);
112113
if (scrollable === document.documentElement && scrollable === document.body) {
113114
return;
114115
}
115116

116-
lastY = e.changedTouches[0].pageY;
117+
// Prevent scrolling up when at the top and scrolling down when at the bottom
118+
// of a nested scrollable area, otherwise mobile Safari will start scrolling
119+
// the window instead.
120+
if (scrollable instanceof HTMLElement && window.getComputedStyle(scrollable).overscrollBehavior === 'auto') {
121+
restoreScrollableStyles = setStyle(scrollable, 'overscrollBehavior', 'contain');
122+
}
117123
};
118124

119125
let onTouchMove = (e: TouchEvent) => {
@@ -123,23 +129,15 @@ function preventScrollMobileSafari() {
123129
return;
124130
}
125131

126-
// Prevent scrolling up when at the top and scrolling down when at the bottom
127-
// of a nested scrollable area, otherwise mobile Safari will start scrolling
128-
// the window instead. Unfortunately, this disables bounce scrolling when at
129-
// the top but it's the best we can do.
130-
let y = e.changedTouches[0].pageY;
131-
let scrollTop = scrollable.scrollTop;
132-
let bottom = scrollable.scrollHeight - scrollable.clientHeight;
133-
134-
if (bottom === 0) {
135-
return;
136-
}
137-
138-
if ((scrollTop <= 0 && y > lastY) || (scrollTop >= bottom && y < lastY)) {
132+
// overscroll-behavior should prevent scroll chaining, but currently does not
133+
// if the element doesn't actually overflow. https://bugs.webkit.org/show_bug.cgi?id=243452
134+
// This checks that both the width and height do not overflow, otherwise we might
135+
// block horizontal scrolling too. In that case, adding `touch-action: pan-x` to
136+
// the element will prevent vertical page scrolling. We can't add that automatically
137+
// because it must be set before the touchstart event.
138+
if (scrollable.scrollHeight === scrollable.clientHeight && scrollable.scrollWidth === scrollable.clientWidth) {
139139
e.preventDefault();
140140
}
141-
142-
lastY = y;
143141
};
144142

145143
let onTouchEnd = (e: TouchEvent) => {
@@ -148,6 +146,7 @@ function preventScrollMobileSafari() {
148146
// Apply this change if we're not already focused on the target element
149147
if (willOpenKeyboard(target) && target !== document.activeElement) {
150148
e.preventDefault();
149+
setupStyles();
151150

152151
// Apply a transform to trick Safari into thinking the input is at the top of the page
153152
// so it doesn't try to scroll it into view. When tapping on an input, this needs to
@@ -158,11 +157,17 @@ function preventScrollMobileSafari() {
158157
target.style.transform = '';
159158
});
160159
}
160+
161+
if (restoreScrollableStyles) {
162+
restoreScrollableStyles();
163+
}
161164
};
162165

163166
let onFocus = (e: FocusEvent) => {
164167
let target = e.target as HTMLElement;
165168
if (willOpenKeyboard(target)) {
169+
setupStyles();
170+
166171
// Transform also needs to be applied in the focus event in cases where focus moves
167172
// other than tapping on an input directly, e.g. the next/previous buttons in the
168173
// software keyboard. In these cases, it seems applying the transform in the focus event
@@ -190,40 +195,50 @@ function preventScrollMobileSafari() {
190195
}
191196
};
192197

193-
let onWindowScroll = () => {
194-
// Last resort. If the window scrolled, scroll it back to the top.
195-
// It should always be at the top because the body will have a negative margin (see below).
196-
window.scrollTo(0, 0);
197-
};
198+
let restoreStyles = null;
199+
let setupStyles = () => {
200+
if (restoreStyles) {
201+
return;
202+
}
198203

199-
// Record the original scroll position so we can restore it.
200-
// Then apply a negative margin to the body to offset it by the scroll position. This will
201-
// enable us to scroll the window to the top, which is required for the rest of this to work.
202-
let scrollX = window.pageXOffset;
203-
let scrollY = window.pageYOffset;
204+
let onWindowScroll = () => {
205+
// Last resort. If the window scrolled, scroll it back to the top.
206+
// It should always be at the top because the body will have a negative margin (see below).
207+
window.scrollTo(0, 0);
208+
};
204209

205-
let restoreStyles = chain(
206-
setStyle(document.documentElement, 'paddingRight', `${window.innerWidth - document.documentElement.clientWidth}px`),
207-
setStyle(document.documentElement, 'overflow', 'hidden'),
208-
setStyle(document.body, 'marginTop', `-${scrollY}px`)
209-
);
210+
// Record the original scroll position so we can restore it.
211+
// Then apply a negative margin to the body to offset it by the scroll position. This will
212+
// enable us to scroll the window to the top, which is required for the rest of this to work.
213+
let scrollX = window.pageXOffset;
214+
let scrollY = window.pageYOffset;
215+
216+
restoreStyles = chain(
217+
addEvent(window, 'scroll', onWindowScroll),
218+
setStyle(document.documentElement, 'paddingRight', `${window.innerWidth - document.documentElement.clientWidth}px`),
219+
setStyle(document.documentElement, 'overflow', 'hidden'),
220+
setStyle(document.body, 'marginTop', `-${scrollY}px`),
221+
() => {
222+
window.scrollTo(scrollX, scrollY);
223+
}
224+
);
210225

211-
// Scroll to the top. The negative margin on the body will make this appear the same.
212-
window.scrollTo(0, 0);
226+
// Scroll to the top. The negative margin on the body will make this appear the same.
227+
window.scrollTo(0, 0);
228+
};
213229

214230
let removeEvents = chain(
215231
addEvent(document, 'touchstart', onTouchStart, {passive: false, capture: true}),
216232
addEvent(document, 'touchmove', onTouchMove, {passive: false, capture: true}),
217233
addEvent(document, 'touchend', onTouchEnd, {passive: false, capture: true}),
218-
addEvent(document, 'focus', onFocus, true),
219-
addEvent(window, 'scroll', onWindowScroll)
234+
addEvent(document, 'focus', onFocus, true)
220235
);
221236

222237
return () => {
223238
// Restore styles and scroll the page back to where it was.
224-
restoreStyles();
239+
restoreScrollableStyles?.();
240+
restoreStyles?.();
225241
removeEvents();
226-
window.scrollTo(scrollX, scrollY);
227242
};
228243
}
229244

packages/@react-spectrum/dialog/stories/Dialog.stories.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -435,15 +435,16 @@ function renderWithDividerInContent({width = 'auto', ...props}) {
435435

436436
function renderHorizontalScrolling({width = 'auto', ...props}) {
437437
return (
438-
<div style={{display: 'flex', width, margin: '100px 0'}}>
438+
<div style={{display: 'flex', flexDirection: 'column', alignItems: 'start', width}}>
439+
{fiveParagraphs()}
439440
<DialogTrigger defaultOpen>
440441
<ActionButton>Trigger</ActionButton>
441442
{(close) => (
442443
<Dialog {...props}>
443444
<Heading>The Heading</Heading>
444445
<Header>The Header</Header>
445446
<Divider />
446-
<Content UNSAFE_style={{overflow: 'auto'}}>
447+
<Content UNSAFE_style={{overflow: 'auto', touchAction: 'pan-x'}}>
447448
<TextField label="Top textfield" minWidth="100vw" />
448449
<p>scroll this content horizontally</p>
449450
</Content>
@@ -454,6 +455,7 @@ function renderHorizontalScrolling({width = 'auto', ...props}) {
454455
</Dialog>
455456
)}
456457
</DialogTrigger>
458+
{fiveParagraphs()}
457459
</div>
458460
);
459461
}

0 commit comments

Comments
 (0)