Skip to content

Commit 0ac271a

Browse files
authored
Workaround mobile safari scrolling issues in modals and trays (#1105)
* Workaround mobile safari scrolling issues in modals and trays * Fix a couple tests
1 parent 6eb447a commit 0ac271a

File tree

15 files changed

+388
-70
lines changed

15 files changed

+388
-70
lines changed

packages/@adobe/spectrum-css-temp/components/dialog/index.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ governing permissions and limitations under the License.
3030
--spectrum-dialog-medium-width: 480px;
3131
--spectrum-dialog-large-width: 640px;
3232
--spectrum-dialog-max-width: 90vw;
33-
--spectrum-dialog-max-height: 90vh;
33+
--spectrum-dialog-max-height: 90%;
3434
--spectrum-dialog-hero-height: var(--spectrum-global-dimension-size-1600);
3535
--spectrum-dialog-alert-width: var(--spectrum-dialog-medium-width);
3636

packages/@adobe/spectrum-css-temp/components/modal/index.css

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ governing permissions and limitations under the License.
2121

2222
/* Distance between top and bottom of dialog and edge of window for fullscreen dialog */
2323
--spectrum-dialog-fullscreen-margin: 40px;
24-
--spectrum-dialog-max-height: 90vh;
24+
--spectrum-dialog-max-height: calc(var(--spectrum-visual-viewport-height) * 0.9);
2525
}
2626

2727
/* Used to position the modal */
@@ -38,11 +38,9 @@ governing permissions and limitations under the License.
3838
width: 100vw;
3939
/* On mobile browsers, vh units are fixed based on the maximum height of the screen.
4040
* However, when you scroll, the toolbar and address bar shrink, making the viewport resize.
41-
* We use the fill-available value to counteract this where supported. */
42-
height: 100vh;
43-
height: -moz-available;
44-
height: -webkit-fill-available;
45-
height: fill-available;
41+
* The visual viewport also shrinks when the keyboard is displayed. We use the VisualViewport
42+
* API in JS to set this CSS variable to ensure the height is correct. */
43+
height: var(--spectrum-visual-viewport-height);
4644

4745
visibility: hidden;
4846

packages/@adobe/spectrum-css-temp/components/tray/index.css

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,21 +20,19 @@
2020

2121
.spectrum-Tray-wrapper {
2222
inset-inline-start: 0;
23-
/* Positioned at the bottom of the window */
23+
/* Positioned at the top of the window */
2424
position: fixed;
25-
bottom: 0;
25+
top: 0;
2626

2727
display: flex;
2828
justify-content: center;
2929
width: 100%;
3030

3131
/* On mobile browsers, vh units are fixed based on the maximum height of the screen.
3232
* However, when you scroll, the toolbar and address bar shrink, making the viewport resize.
33-
* We use the fill-available value to counteract this where supported. */
34-
height: 100vh;
35-
height: -moz-available;
36-
height: -webkit-fill-available;
37-
height: fill-available;
33+
* The visual viewport also shrinks when the keyboard is displayed. We use the VisualViewport
34+
* API in JS to set this CSS variable to ensure the height is correct. */
35+
height: var(--spectrum-visual-viewport-height);
3836

3937
/* Don't catch clicks */
4038
pointer-events: none;
@@ -52,7 +50,7 @@
5250
width: var(--spectrum-tray-width);
5351
max-width: var(--spectrum-tray-max-width);
5452
min-height: var(--spectrum-tray-min-height);
55-
max-height: calc(100% - var(--spectrum-tray-margin-top));
53+
max-height: calc(var(--spectrum-visual-viewport-height) - var(--spectrum-tray-margin-top));
5654
position: absolute;
5755
bottom: 0;
5856
outline: none;
@@ -78,6 +76,11 @@
7876
}
7977
}
8078

79+
.spectrum-Tray--fixedHeight {
80+
height: calc(var(--spectrum-visual-viewport-height) - var(--spectrum-tray-margin-top));
81+
top: var(--spectrum-tray-margin-top);
82+
}
83+
8184
/* Should match --spectrum-tray-max-width above */
8285
@media (max-width: 375px) {
8386
.spectrum-Tray {

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

Lines changed: 206 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,26 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13+
import {chain, getScrollParent} from '@react-aria/utils';
1314
import {useEffect} from 'react';
1415

1516
interface PreventScrollOptions {
1617
/** Whether the scroll lock is disabled. */
1718
isDisabled?: boolean
1819
}
1920

21+
const isMobileSafari =
22+
typeof window !== 'undefined' && window.navigator != null
23+
? /AppleWebKit/.test(window.navigator.userAgent) && (
24+
/^(iPhone|iPad)$/.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+
}

packages/@react-aria/overlays/test/usePreventScroll.test.js

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,38 +23,38 @@ function Example(props) {
2323

2424
describe('usePreventScroll', function () {
2525
it('should set overflow: hidden on the body on mount and remove on unmount', function () {
26-
expect(document.body).not.toHaveStyle('overflow: hidden');
26+
expect(document.documentElement).not.toHaveStyle('overflow: hidden');
2727

2828
let res = render(<Example />);
29-
expect(document.body).toHaveStyle('overflow: hidden');
29+
expect(document.documentElement).toHaveStyle('overflow: hidden');
3030

3131
res.unmount();
32-
expect(document.body).not.toHaveStyle('overflow: hidden');
32+
expect(document.documentElement).not.toHaveStyle('overflow: hidden');
3333
});
3434

3535
it('should work with nested modals', function () {
36-
expect(document.body).not.toHaveStyle('overflow: hidden');
36+
expect(document.documentElement).not.toHaveStyle('overflow: hidden');
3737

3838
let one = render(<Example />);
39-
expect(document.body).toHaveStyle('overflow: hidden');
39+
expect(document.documentElement).toHaveStyle('overflow: hidden');
4040

4141
let two = render(<Example />);
42-
expect(document.body).toHaveStyle('overflow: hidden');
42+
expect(document.documentElement).toHaveStyle('overflow: hidden');
4343

4444
two.unmount();
45-
expect(document.body).toHaveStyle('overflow: hidden');
45+
expect(document.documentElement).toHaveStyle('overflow: hidden');
4646

4747
one.unmount();
48-
expect(document.body).not.toHaveStyle('overflow: hidden');
48+
expect(document.documentElement).not.toHaveStyle('overflow: hidden');
4949
});
5050

5151
it('should remove overflow: hidden when isDisabled option is true', function () {
52-
expect(document.body).not.toHaveStyle('overflow: hidden');
52+
expect(document.documentElement).not.toHaveStyle('overflow: hidden');
5353

5454
let res = render(<Example />);
55-
expect(document.body).toHaveStyle('overflow: hidden');
55+
expect(document.documentElement).toHaveStyle('overflow: hidden');
5656

5757
res.rerender(<Example isDisabled />);
58-
expect(document.body).not.toHaveStyle('overflow: hidden');
58+
expect(document.documentElement).not.toHaveStyle('overflow: hidden');
5959
});
6060
});
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/*
2+
* Copyright 2020 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
export function getScrollParent(node: Element): Element {
14+
while (node && !isScrollable(node)) {
15+
node = node.parentElement;
16+
}
17+
18+
return node || document.scrollingElement || document.documentElement;
19+
}
20+
21+
function isScrollable(node: Element): boolean {
22+
let style = window.getComputedStyle(node);
23+
return /(auto|scroll)/.test(style.overflow + style.overflowX + style.overflowY);
24+
}

packages/@react-aria/utils/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,4 @@ export * from './filterDOMProps';
2323
export * from './runAfterTransition';
2424
export * from './useLayoutEffect';
2525
export * from './useResizeObserver';
26+
export * from './getScrollParent';

0 commit comments

Comments
 (0)