Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Modal] Disable background scrolling in iOS #5750

Open
tao-qian opened this issue Dec 9, 2016 · 76 comments
Open

[Modal] Disable background scrolling in iOS #5750

tao-qian opened this issue Dec 9, 2016 · 76 comments
Labels
bug 🐛 Something doesn't work component: modal This is the name of the generic UI component, not the React module! external dependency Blocked by external dependency, we can’t do anything about it

Comments

@tao-qian
Copy link

tao-qian commented Dec 9, 2016

Tested dialogs in http://www.material-ui.com/#/components/dialog.
On desktop Chrome, background scrolling is disabled when dialogs are shown.
However, it is not disabled in iOS Safari or Chrome.

@oliviertassinari oliviertassinari added the component: dialog This is the name of the generic UI component, not the React module! label Dec 18, 2016
@oliviertassinari oliviertassinari changed the title Dialog Should Disable Background Scrolling on iOS [Dialog] Should Disable Background Scrolling on iOS Dec 18, 2016
@sedletsky
Copy link

And with Popover - when it's open you can scroll screen behind to negative position on iOS - very annoying...

Anybody did research or some related links?

@Nopik
Copy link

Nopik commented Apr 23, 2017

@oliviertassinari I'm being bitten by this bug, too. I can try to fix it, though at the moment I'm not even sure where to start. Do you have any idea what can be causing this?

@mbrookes
Copy link
Member

@oliviertassinari Same problem on next, including full-screen dialogs.

@oliviertassinari
Copy link
Member

As raised by someone on the bootstrap thread, that seems to be a Safari browser bug. We can't do much about it here. I'm closing the issue. It's unfortunate.

@daniel-rabe
Copy link

daniel-rabe commented Dec 15, 2017

i had the issue with the popover component, so I added a custom BackdropComponent that cancels the touchmove event

import * as React from 'react';
import Backdrop, { BackdropProps } from 'material-ui/Modal/Backdrop';

/**
 * Prevents scrolling of content behind the backdrop.
 */
export class BackDropIOSWorkaround extends React.PureComponent<BackdropProps> {
    protected onTouchMove(event: React.TouchEvent<HTMLDivElement>): void {
        event.preventDefault();
    }

    public render(): JSX.Element {
        return (
            <Backdrop {...this.props} onTouchMove={this.onTouchMove}/>
        );
    }
}
<Popover
    BackdropInvisible={false}
    BackdropComponent={BackDropIOSWorkaround}
    anchorEl={this.clickElement}
    onRequestClose={this.unexpandChoices}
    anchorOrigin={{vertical: 'top', horizontal: 'left'}}
    transformOrigin={{vertical: 'top', horizontal: 'left'}}
>
    <List disablePadding={true}>
        {this.choices()}
    </List>
</Popover>

@abcd-ca
Copy link

abcd-ca commented Dec 20, 2017

@daniel-rabe 's solution looks good but would be for Material UI v1, not previous versions

@jpmoyn
Copy link

jpmoyn commented Feb 21, 2018

@oliviertassinari I see that the Dialog sets the style overflow-y: hidden; to the body. Would it be possible to add a Dialog prop that additionally sets the style position: fixed; on the body?

This would save a lot of people time in having to manually add position: fixed to the body when working with the Dialog component for mobile safari.

@oliviertassinari
Copy link
Member

oliviertassinari commented Feb 22, 2018

Would it be possible to add a Dialog prop that additionally sets the style position: fixed; on the body?

@jpmoyn No, you would reset the scroll position to the top of the page by doing so. Users will no longer be at the right scroll position once the dialog is closed.

@jacobweber
Copy link

Would it be possible to implement the onTouchMove workaround by default? I'm not sure it's possible to specify a custom BackdropComponent everywhere it's needed. This bug affects Dialog, Select, SwipeableDrawer, etc.

@daniel-rabe
Copy link

daniel-rabe commented Apr 5, 2018

you can override the default-props of material-ui's BackDrop before creating your App:

import BackDrop from 'material-ui/Modal/Backdrop';
BackDrop.defaultProps = {...BackDrop.defaultProps, onTouchMove: preventBackdropScroll};

export function preventBackdropScroll(event: React.TouchEvent<HTMLElement>): void {
    let target: HTMLElement | null = (event.target as HTMLDivElement);
    while (target != null && target !== document.body) {
        const scrollHeight: number = target.scrollHeight;
        const clientHeight: number = target.clientHeight;
        if (scrollHeight > clientHeight) {
            return;
        }
        target = target.parentElement;
    }
    event.preventDefault();
}

@jacobweber
Copy link

@daniel-rabe Sneaky! I like it. Unfortunately it didn't fix the issue for me.

@oliviertassinari
Copy link
Member

oliviertassinari commented Apr 9, 2018

@jacobweber Are you saying #5750 (comment) workaround doesn't work? If it comes with no side effect, we could add it to the core of the library.

@jacobweber
Copy link

It didn't work for me (although I could see the function being invoked). Although maybe I'm doing something differently; I didn't get a chance to investigate this too deeply.

I also noticed that using -webkit-overflow-scrolling: touch in my scrollable views seems to trigger this behavior, for what it's worth.

@daniel-rabe
Copy link

the workaround does not work for current iOS, i dont know since when version exactly

@daniel-rabe
Copy link

daniel-rabe commented Apr 17, 2018

this solution works for me atm

source: https://stackoverflow.com/questions/41594997/ios-10-safari-prevent-scrolling-behind-a-fixed-overlay-and-maintain-scroll-posi

import Fade from 'material-ui/transitions/Fade';

function fadeOnEnter(node: HTMLElement, isAppearing: boolean): void {
    let clientY: number | null = null; // remember Y position on touch start
    const touchStart: (event: Event) => void = (event: Event) => {
        if ((event as TouchEvent).targetTouches.length === 1) {
            clientY = (event as TouchEvent).targetTouches[0].clientY;
        }
    };
    const touchMove: (event: Event) => void = (event: Event) => {
        if ((event as TouchEvent).targetTouches.length === 1) {
            disableRubberBand(event as TouchEvent);
        }
    };
    const disableRubberBand: (event: TouchEvent) => void = (event: TouchEvent) => {
        const tmpClientY: number = event.targetTouches[0].clientY - (clientY || 0);

        if (node.scrollTop === 0 && tmpClientY > 0) {
            // element is at the top of its scroll
            event.preventDefault();
        }

        if (isOverlayTotallyScrolled() && tmpClientY < 0) {
            // element is at the top of its scroll
            event.preventDefault();
        }
    };
    const isOverlayTotallyScrolled: () => boolean = () => {
        // https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight#Problems_and_solutions
        return node.scrollHeight - node.scrollTop <= node.clientHeight;
    };
    node.addEventListener('touchstart', touchStart, false);
    node.addEventListener('touchmove', touchMove, false);
}

Fade.defaultProps = {...Fade.defaultProps, onEnter: fadeOnEnter};

@oliviertassinari oliviertassinari added the external dependency Blocked by external dependency, we can’t do anything about it label May 2, 2018
@mui mui deleted a comment from g-forgues May 2, 2018
@mui mui deleted a comment from gottfired May 2, 2018
@oliviertassinari oliviertassinari changed the title [Dialog] Should Disable Background Scrolling on iOS [Dialog] Disable Background Scrolling on iOS May 2, 2018
@alansouzati
Copy link
Contributor

How about setting overflow: hidden for everything that is aria-hidden: true while the dialog is open? this fixes the issue on my end, and I think it would be nice to solve this in the meantime in MUI. It looks like MUI is already putting aria-hidden in these elements.

In the meantime here is what Ive done:

 const onUpdateOverflow = useCallback(
    () =>
      (document.getElementById("root").style.overflow =
        document.body.style.overflow),
    []
  );
  useEffect(() => {
    var observer = new MutationObserver(onUpdateOverflow);

    const observerInstance = observer.observe(document.body, {
      attributes: true,
      attributeFilter: ["style"]
    });

    return () => observerInstance && observerInstance.disconnect();
  }, [onUpdateOverflow]);

@alansouzati
Copy link
Contributor

This PR adds the workaround that fixes the Safari bug 🙏

@alansouzati
Copy link
Contributor

I stand corrected. this fixes the problem but losses scroll position. im trying another work around.

@theKashey
Copy link

There one more fix which can help for the majority of cases - inert which is coming to a full support (only Firefox is one little behind)

@alansouzati
Copy link
Contributor

alansouzati commented Jun 23, 2022

ive implemented this hook, and if you place this in your index.js it should fix the safari bug...the basic idea is to set overflow hidden on the HTML tag, and reset the scroll position back when the modal closes.

import { useCallback, useEffect, useRef } from "react";
import { useMediaQuery, useTheme } from "@mui/material";

export const useAppScrollLock = () => {
  const theme = useTheme();
  const inMobile = useMediaQuery(theme.breakpoints.down("sm"));
  const scrollTop = useRef(null);
  const onUpdateOverflow = useCallback(() => {
    const scrollContainer = document.documentElement;
    if (
      document.body.style.overflow === "hidden" &&
      scrollContainer.style.overflow !== "hidden"
    ) {
      scrollTop.current = scrollContainer.scrollTop;
      scrollContainer.style.overflow = "hidden";
    } else if (document.body.style.overflow !== "hidden" && scrollTop.current) {
      scrollContainer.style.overflow = "";
      scrollContainer.scrollTop = scrollTop.current;
      scrollTop.current = null;
    }
  }, []);
  useEffect(() => {
    let observerInstance;
    if (inMobile) {
      const observer = new MutationObserver(onUpdateOverflow);
      observerInstance = observer.observe(document.body, {
        attributes: true,
        attributeFilter: ["style"]
      });
    }
    return () => observerInstance && observerInstance.disconnect();
  }, [onUpdateOverflow, inMobile]);
};

@VinceCYLiao
Copy link
Contributor

VinceCYLiao commented Nov 18, 2022

I propose that in ModalManage's handleContainer function, we can also set scroll container's touch action style to "none" when open, and restore it when close. Although this will only work on IOS version 13 and later, I think this is OK as a temporary workaround until the iOS safari bug is fixed.

You can try it here on your iOS device.

@oliviertassinari If you think this workaround is OK, I will create a pull request, thanks!

@stasbarannik
Copy link

The issue definitely should be fixed on MUI level or on Safari level, but as temporary solution you can use just styling.
One of cause of different behavior on Safari is thing that overflow:hidden for body doesn't work on Safari. To fix it you can use this solution

// Media for Safari only
@media not all and (min-resolution:.001dpcm) {
@supports (-webkit-appearance:none) {
&[style*="overflow:hidden"],
&[style*="overflow: hidden"] {
touch-action: none; // Fix for Safari bug with overflow hidden property
-ms-touch-action: none;
}
}
}

@rekloot
Copy link

rekloot commented Jan 12, 2023

I was banging my head for this one, so I might share a solution that worked like a charm using Dialog Mui. Check out this npm package: https://www.npmjs.com/package/inobounce You can add it without a dependency also.

Just call it in your index.js just after your .render: iNoBounce.disable()

And when opening your Dialog do iNoBounce.enable() and onClose iNoBounce.disable(). Works like a charm with Mui on IoS!

Make sure your body has -webkit-overflow-scrolling: touch;

@jesusvallez
Copy link

jesusvallez commented Feb 17, 2023

Fix to resolve this problem. Working on my production web page with dropdowns, popovers, dialogs...

  useEffect(() => {
    if (globalThis?.document) {
      const body = globalThis.document.body

      const observerInstance = new MutationObserver(() => {
        body.style.touchAction = body.style.overflow === 'hidden' ? 'none' : ''
      })

      observerInstance.observe(body, {
        attributes: true,
        attributeFilter: ['style'],
      })
    }

    return () => {
      observerInstance.disconnect()
    }
  }, [])

@theKashey
Copy link

Woah, CSS is saving the world once again. touch-action sounds exactly like the solution

@silwalprabin
Copy link

The above solution (CSS: #5750 (comment) or JS: #5750 (comment)) will work until we have any input field in modal. If we have input field in modal and it's focused then we see scrolling of background again :(

@silwalprabin
Copy link

Along with touch-action: none; , also added position: fixed; so that scrolling with input selected will not make the background move.

@arbazdogar20
Copy link

arbazdogar20 commented Aug 10, 2023

You can Simply use this lines of code in the Select Component.

onOpen={() => (document.body.style.touchAction = 'none')}
onClose={() => (document.body.style.touchAction = 'auto')}

@nipkai
Copy link

nipkai commented Feb 27, 2024

Along with touch-action: none; , also added position: fixed; so that scrolling with input selected will not make the background move.

Unfortunately if you do that and use the MuiDrawer, you lose your initial scroll..

So I have an infinite scroll, when I scroll down quite a bit, open my drawer, to check my filters and close it again, I will start at the top of the page. Which is really annoying for the user

@martin-linden
Copy link

martin-linden commented Apr 8, 2024

Along with touch-action: none; , also added position: fixed; so that scrolling with input selected will not make the background move.

Unfortunately if you do that and use the MuiDrawer, you lose your initial scroll..

So I have an infinite scroll, when I scroll down quite a bit, open my drawer, to check my filters and close it again, I will start at the top of the page. Which is really annoying for the user

Not only that. If you add position fixed on the body, all sorts of weird UI changes will happen since it will mess with any other styling on the page. Even if you apply this in full screen dialogs you will have time to see the changes once you close the modal. The biggest issue is when you have to use some input field inside the modal which makes this even harder to solve.

I'm honestly a bit shocked that this doesn't seem to be a bigger issue and that it has not been resolved yet. I mean, this issue has been opened since 2016 and we're still trying to make some strange hack to maybe get it to work. Modals are pretty important in websites and web apps. It's no secret that Apple does not like any sort of site/web app that could compare with a native app in any way so i'm not really surprised they don't want to solve this, but I still feel like there should be a better solution.

@emmanuelangola
Copy link

@nipkai @martin-linden Have you guys found a solution? I've been stucked on this issue for days, I don't want to use a library to address it. Input fields in modal make the background scrolled when the input is focused. It seems like nobody has a clear solution to address that one.

@martin-linden
Copy link

martin-linden commented Jun 17, 2024

@nipkai @martin-linden Have you guys found a solution? I've been stucked on this issue for days, I don't want to use a library to address it. Input fields in modal make the background scrolled when the input is focused. It seems like nobody has a clear solution to address that one.

@emmanuelangola Unfortunately, I've tried various solutions (including libraries), but none have resolved the issue. If this continues to be a significant problem for users of our web app, I will recommend they use an alternative browser like Chrome, where this issue does not occur.

It's definitely not a perfect solution, but since this seems to be a Safari bug, there is not much we can do. Please do write if you find a solution that works, even if it is a library.

@emmanuelangola
Copy link

@martin-linden, I haven't found a perfect solution neither. Similarly as you, I looked for a robust solution for days, however I couldn't find anything perfect. I'm currently using a work-around that works as intended, however it makes the page scroll to the top of the page each time the user opens the modal. I don't think that's what you're looking for. I can suggest you some javascript libraries. Personally, I'm not a fan of libraries.

@nipkai
Copy link

nipkai commented Jun 18, 2024

Hey @martin-linden,
also for us we could not find a robust solution. We are trying to avoid using the input in the modals from now on..

@bhavishyalimendo
Copy link

@tao-qian try using the backdrop component and then using it as the custom backdrop, add the property touch-event: none

const CustomBackdrop = styled(Backdrop)(({ theme }) => ({
  touchAction: "none",
}));

Usage:

 <StyledModal
        open={open}
        TransitionComponent={Transition}
        keepMounted
        onClose={onClose}
        mobileView={mobileView}
        tabletView={tabletView}
        fullModel={fullModel}
        modal75={modal75}
        BackdropComponent={CustomBackdrop}
>
{...}
</StyledModal>

@meganoob1337
Copy link

meganoob1337 commented Oct 25, 2024

I think i found a fix , at least for using it with nextjs :
<MuiModal container={typeof window !== 'undefined' ? window.document.getElementById('__next') : undefined} {...props} />;
(the window check is because im using nextjs and on server the windows variable is undefined)
this is my modal component.
instead of the default handling (using the body as container i think ) i gave it the next mounting point as container element, so he sets the overflow hidden on that - i read somewhere that overflow hidden on body/html tags is wonky under specific conditions on IOS dont know the specifics anymore from my rabbithole trying to fix this.

Then you add a height: 100svh to your mounting point in your css file and it should work.

my usecase is a fullscreen modal, and i needed to use 100svh as the bottom navbar on Safari was coming in and 100vh didnt cut it somehow, but you can play around with that.

hope this helps someone
Feel free to @ me if you have questions. i wanna help people who went down this rabbithole aswell :D

@SteliosKornelakis
Copy link

SteliosKornelakis commented Dec 8, 2024

I think I worked this out using an implementation from react-spectrum .

Use this is your component:
usePreventScroll({ isDisabled: !open });

usePreventScroll.ts

/*
 * Copyright 2020 Adobe. All rights reserved.
 * This file is licensed to you under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License. You may obtain a copy
 * of the License at http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software distributed under
 * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
 * OF ANY KIND, either express or implied. See the License for the specific language
 * governing permissions and limitations under the License.
 */

import React from 'react';

function cached(fn: () => boolean) {
  if (process.env.NODE_ENV === 'test') {
    return fn;
  }

  let res: boolean | null = null;
  return () => {
    if (res == null) {
      res = fn();
    }
    return res;
  };
}

export const isIOS = cached(function () {
  return isIPhone() || isIPad();
});

export const isIPhone = cached(function () {
  return testPlatform(/^iPhone/i);
});

function testPlatform(re: RegExp) {
  return typeof window !== 'undefined' && window.navigator != null
    ? re.test(window.navigator['userAgentData']?.platform || window.navigator.platform)
    : false;
}

function isSafari() {
  const ua = navigator.userAgent.toLowerCase();
  return ua.includes('safari') && !ua.includes('chrome') && !ua.includes('android');
}

export const isIPad = cached(function () {
  return (
    testPlatform(/^iPad/i) ||
    // iPadOS 13 lies and says it's a Mac, but we can distinguish by detecting touch support.
    (isMac() && navigator.maxTouchPoints > 1)
  );
});

// During SSR, React emits a warning when calling useLayoutEffect.
// Since neither useLayoutEffect nor useEffect run on the server,
// we can suppress this by replace it with a noop on the server.
export const useLayoutEffect = typeof document !== 'undefined' ? React.useLayoutEffect : () => {};

export const isMac = cached(function () {
  return testPlatform(/^Mac/i);
});

export function isScrollable(node: Element | null, checkForOverflow?: boolean): boolean {
  if (!node) {
    return false;
  }
  let style = window.getComputedStyle(node);
  let isScrollable = /(auto|scroll)/.test(style.overflow + style.overflowX + style.overflowY);

  if (isScrollable && checkForOverflow) {
    isScrollable = node.scrollHeight !== node.clientHeight || node.scrollWidth !== node.clientWidth;
  }

  return isScrollable;
}

export function getScrollParent(node: Element, checkForOverflow?: boolean): Element {
  let scrollableNode: Element | null = node;
  if (isScrollable(scrollableNode, checkForOverflow)) {
    scrollableNode = scrollableNode.parentElement;
  }

  while (scrollableNode && !isScrollable(scrollableNode, checkForOverflow)) {
    scrollableNode = scrollableNode.parentElement;
  }

  return scrollableNode || document.scrollingElement || document.documentElement;
}

export function chain(...callbacks: any[]): (...args: any[]) => void {
  return (...args: any[]) => {
    for (let callback of callbacks) {
      if (typeof callback === 'function') {
        callback(...args);
      }
    }
  };
}

interface PreventScrollOptions {
  /** Whether the scroll lock is disabled. */
  isDisabled?: boolean;
}

const visualViewport = typeof document !== 'undefined' && window.visualViewport;

// HTML input types that do not cause the software keyboard to appear.
const nonTextInputTypes = new Set([
  'checkbox',
  'radio',
  'range',
  'color',
  'file',
  'image',
  'button',
  'submit',
  'reset',
]);

// The number of active usePreventScroll calls. Used to determine whether to revert back to the original page style/scroll position
let preventScrollCount = 0;
let restore;

/**
 * Prevents scrolling on the document body on mount, and
 * restores it on unmount. Also ensures that content does not
 * shift due to the scrollbars disappearing.
 */
export function usePreventScroll(options: PreventScrollOptions = {}) {
  let { isDisabled } = options;

  useLayoutEffect(() => {
    if (isDisabled) {
      return;
    }

    if (!isIOS() || !isSafari()) {
      return;
    }

    preventScrollCount++;
    if (preventScrollCount === 1) {
      if (isIOS()) {
        restore = preventScrollMobileSafari();
      } else {
        restore = preventScrollStandard();
      }
    }

    return () => {
      preventScrollCount--;
      if (preventScrollCount === 0) {
        restore();
      }
    };
  }, [isDisabled]);
}

// For most browsers, all we need to do is set `overflow: hidden` on the root element, and
// add some padding to prevent the page from shifting when the scrollbar is hidden.
function preventScrollStandard() {
  return chain(
    setStyle(document.documentElement, 'paddingRight', `${window.innerWidth - document.documentElement.clientWidth}px`),
    setStyle(document.documentElement, 'overflow', 'hidden'),
  );
}

// Mobile Safari is a whole different beast. Even with overflow: hidden,
// it still scrolls the page in many situations:
//
// 1. When the bottom toolbar and address bar are collapsed, page scrolling is always allowed.
// 2. When the keyboard is visible, the viewport does not resize. Instead, the keyboard covers part of
//    it, so it becomes scrollable.
// 3. When tapping on an input, the page always scrolls so that the input is centered in the visual viewport.
//    This may cause even fixed position elements to scroll off the screen.
// 4. When using the next/previous buttons in the keyboard to navigate between inputs, the whole page always
//    scrolls, even if the input is inside a nested scrollable element that could be scrolled instead.
//
// In order to work around these cases, and prevent scrolling without jankiness, we do a few things:
//
// 1. Prevent default on `touchmove` events that are not in a scrollable element. This prevents touch scrolling
//    on the window.
// 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
//    into view ourselves, without scrolling the whole page.
// 5. Offset the body by the scroll position using a negative margin and scroll to the top. This should appear the
//    same visually, but makes the actual scroll position always zero. This is required to make all of the
//    above work or Safari will still try to scroll the page when focusing an input.
// 6. As a last resort, handle window scroll events, and scroll back to the top. This can happen when attempting
//    to navigate to an input with the next/previous buttons that's outside a modal.
function preventScrollMobileSafari() {
  let scrollable: Element;
  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, true);
    if (scrollable === document.documentElement && scrollable === document.body) {
      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.
    if (scrollable instanceof HTMLElement && window.getComputedStyle(scrollable).overscrollBehavior === 'auto') {
      restoreScrollableStyles = setStyle(scrollable, 'overscrollBehavior', 'contain');
    }
  };

  let onTouchMove = (e: TouchEvent) => {
    // Prevent scrolling the window.
    if (!scrollable || scrollable === document.documentElement || scrollable === document.body) {
      e.preventDefault();
      return;
    }

    // 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();
    }
  };

  let onTouchEnd = () => {
    if (restoreScrollableStyles) {
      restoreScrollableStyles();
    }
  };

  let onFocus = (e: FocusEvent) => {
    let target = e.target as HTMLElement;
    if (willOpenKeyboard(target)) {
      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.
      target.style.transform = 'translateY(-2000px)';
      requestAnimationFrame(() => {
        target.style.transform = '';

        // This will have prevented the browser from scrolling the focused element into view,
        // so we need to do this ourselves in a way that doesn't cause the whole page to scroll.
        if (visualViewport) {
          if (visualViewport.height < window.innerHeight) {
            // If the keyboard is already visible, do this after one additional frame
            // to wait for the transform to be removed.
            requestAnimationFrame(() => {
              scrollIntoView(target);
            });
          } else {
            // Otherwise, wait for the visual viewport to resize before scrolling so we can
            // measure the correct position to scroll to.
            visualViewport.addEventListener('resize', () => scrollIntoView(target), { once: true });
          }
        }
      });
    }
  };

  let restoreStyles: null | (() => void) = null;
  let setupStyles = () => {
    if (restoreStyles) {
      return;
    }

    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);
    };

    // 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);
  };

  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),
  );

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

// Sets a CSS property on an element, and returns a function to revert it to the previous value.
function setStyle(element: HTMLElement, style: string, value: string) {
  let cur = element.style[style];
  element.style[style] = value;

  return () => {
    element.style[style] = cur;
  };
}

// Adds an event listener to an element, and returns a function to remove it.
function addEvent<K extends keyof GlobalEventHandlersEventMap>(
  target: Document | Window,
  event: K,
  handler: (this: Document | Window, ev: GlobalEventHandlersEventMap[K]) => any,
  options?: boolean | AddEventListenerOptions,
) {
  // internal function, so it's ok to ignore the difficult to fix type error
  // @ts-ignore
  target.addEventListener(event, handler, options);
  return () => {
    // @ts-ignore
    target.removeEventListener(event, handler, options);
  };
}

function scrollIntoView(target: Element) {
  let root = document.scrollingElement || document.documentElement;
  let nextTarget: Element | null = target;
  while (nextTarget && nextTarget !== root) {
    // Find the parent scrollable element and adjust the scroll position if the target is not already in view.
    let scrollable = getScrollParent(nextTarget);
    if (scrollable !== document.documentElement && scrollable !== document.body && scrollable !== nextTarget) {
      let scrollableTop = scrollable.getBoundingClientRect().top;
      let targetTop = nextTarget.getBoundingClientRect().top;
      if (targetTop > scrollableTop + nextTarget.clientHeight) {
        scrollable.scrollTop += targetTop - scrollableTop;
      }
    }

    nextTarget = scrollable.parentElement;
  }
}

function willOpenKeyboard(target: Element) {
  return (
    (target instanceof HTMLInputElement && !nonTextInputTypes.has(target.type)) ||
    target instanceof HTMLTextAreaElement ||
    (target instanceof HTMLElement && target.isContentEditable)
  );
}

@rikusen0335
Copy link

@SteliosKornelakis Please just use <details> and <summary> for long codes for readability
https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/organizing-information-with-collapsed-sections

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug 🐛 Something doesn't work component: modal This is the name of the generic UI component, not the React module! external dependency Blocked by external dependency, we can’t do anything about it
Projects
None yet
Development

No branches or pull requests