Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/@react-aria/overlays/src/usePopover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ export function usePopover(props: AriaPopoverProps, state: OverlayTriggerState):
});

usePreventScroll({
isDisabled: isNonModal
isDisabled: isNonModal || !state.isOpen
});

useLayoutEffect(() => {
Expand Down
95 changes: 55 additions & 40 deletions packages/@react-aria/overlays/src/usePreventScroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,9 @@ function preventScrollStandard() {
//
// 1. Prevent default on `touchmove` events that are not in a scrollable element. This prevents touch scrolling
// on the window.
// 2. Prevent default on `touchmove` events inside a scrollable element when the scroll position is at the
// top or bottom. This avoids the whole page scrolling instead, but does prevent overscrolling.
// 2. Set `overscroll-behavior: contain` on nested scrollable regions so they do not scroll the page when at
// the top or bottom. Work around a bug where this does not work when the element does not actually overflow
// by preventing default in a `touchmove` event.
// 3. Prevent default on `touchend` events on input elements and handle focusing the element ourselves.
// 4. When focusing an input, apply a transform to trick Safari into thinking the input is at the top
// of the page, which prevents it from scrolling the page. After the input is focused, scroll the element
Expand All @@ -105,15 +106,20 @@ function preventScrollStandard() {
// to navigate to an input with the next/previous buttons that's outside a modal.
function preventScrollMobileSafari() {
let scrollable: Element;
let lastY = 0;
let restoreScrollableStyles;
let onTouchStart = (e: TouchEvent) => {
// Store the nearest scrollable parent element from the element that the user touched.
scrollable = getScrollParent(e.target as Element);
if (scrollable === document.documentElement && scrollable === document.body) {
return;
}

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

let onTouchMove = (e: TouchEvent) => {
Expand All @@ -123,23 +129,15 @@ function preventScrollMobileSafari() {
return;
}

// Prevent scrolling up when at the top and scrolling down when at the bottom
// of a nested scrollable area, otherwise mobile Safari will start scrolling
// the window instead. Unfortunately, this disables bounce scrolling when at
// the top but it's the best we can do.
let y = e.changedTouches[0].pageY;
let scrollTop = scrollable.scrollTop;
let bottom = scrollable.scrollHeight - scrollable.clientHeight;

if (bottom === 0) {
return;
}

if ((scrollTop <= 0 && y > lastY) || (scrollTop >= bottom && y < lastY)) {
// overscroll-behavior should prevent scroll chaining, but currently does not
// if the element doesn't actually overflow. https://bugs.webkit.org/show_bug.cgi?id=243452
// This checks that both the width and height do not overflow, otherwise we might
// block horizontal scrolling too. In that case, adding `touch-action: pan-x` to
// the element will prevent vertical page scrolling. We can't add that automatically
// because it must be set before the touchstart event.
if (scrollable.scrollHeight === scrollable.clientHeight && scrollable.scrollWidth === scrollable.clientWidth) {
e.preventDefault();
}

lastY = y;
};

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

// Apply a transform to trick Safari into thinking the input is at the top of the page
// so it doesn't try to scroll it into view. When tapping on an input, this needs to
Expand All @@ -158,11 +157,17 @@ function preventScrollMobileSafari() {
target.style.transform = '';
});
}

if (restoreScrollableStyles) {
restoreScrollableStyles();
}
};

let onFocus = (e: FocusEvent) => {
let target = e.target as HTMLElement;
if (willOpenKeyboard(target)) {
setupStyles();

// Transform also needs to be applied in the focus event in cases where focus moves
// other than tapping on an input directly, e.g. the next/previous buttons in the
// software keyboard. In these cases, it seems applying the transform in the focus event
Expand Down Expand Up @@ -190,40 +195,50 @@ function preventScrollMobileSafari() {
}
};

let onWindowScroll = () => {
// Last resort. If the window scrolled, scroll it back to the top.
// It should always be at the top because the body will have a negative margin (see below).
window.scrollTo(0, 0);
};
let restoreStyles = null;
let setupStyles = () => {
if (restoreStyles) {
return;
}

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

let restoreStyles = chain(
setStyle(document.documentElement, 'paddingRight', `${window.innerWidth - document.documentElement.clientWidth}px`),
setStyle(document.documentElement, 'overflow', 'hidden'),
setStyle(document.body, 'marginTop', `-${scrollY}px`)
);
// Record the original scroll position so we can restore it.
// Then apply a negative margin to the body to offset it by the scroll position. This will
// enable us to scroll the window to the top, which is required for the rest of this to work.
let scrollX = window.pageXOffset;
let scrollY = window.pageYOffset;

restoreStyles = chain(
addEvent(window, 'scroll', onWindowScroll),
setStyle(document.documentElement, 'paddingRight', `${window.innerWidth - document.documentElement.clientWidth}px`),
setStyle(document.documentElement, 'overflow', 'hidden'),
setStyle(document.body, 'marginTop', `-${scrollY}px`),
() => {
window.scrollTo(scrollX, scrollY);
}
);

// Scroll to the top. The negative margin on the body will make this appear the same.
window.scrollTo(0, 0);
// Scroll to the top. The negative margin on the body will make this appear the same.
window.scrollTo(0, 0);
};

let removeEvents = chain(
addEvent(document, 'touchstart', onTouchStart, {passive: false, capture: true}),
addEvent(document, 'touchmove', onTouchMove, {passive: false, capture: true}),
addEvent(document, 'touchend', onTouchEnd, {passive: false, capture: true}),
addEvent(document, 'focus', onFocus, true),
addEvent(window, 'scroll', onWindowScroll)
addEvent(document, 'focus', onFocus, true)
);

return () => {
// Restore styles and scroll the page back to where it was.
restoreStyles();
restoreScrollableStyles?.();
restoreStyles?.();
removeEvents();
window.scrollTo(scrollX, scrollY);
};
}

Expand Down
6 changes: 4 additions & 2 deletions packages/@react-spectrum/dialog/stories/Dialog.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -435,15 +435,16 @@ function renderWithDividerInContent({width = 'auto', ...props}) {

function renderHorizontalScrolling({width = 'auto', ...props}) {
return (
<div style={{display: 'flex', width, margin: '100px 0'}}>
<div style={{display: 'flex', flexDirection: 'column', alignItems: 'start', width}}>
{fiveParagraphs()}
<DialogTrigger defaultOpen>
<ActionButton>Trigger</ActionButton>
{(close) => (
<Dialog {...props}>
<Heading>The Heading</Heading>
<Header>The Header</Header>
<Divider />
<Content UNSAFE_style={{overflow: 'auto'}}>
<Content UNSAFE_style={{overflow: 'auto', touchAction: 'pan-x'}}>
<TextField label="Top textfield" minWidth="100vw" />
<p>scroll this content horizontally</p>
</Content>
Expand All @@ -454,6 +455,7 @@ function renderHorizontalScrolling({width = 'auto', ...props}) {
</Dialog>
)}
</DialogTrigger>
{fiveParagraphs()}
</div>
);
}