From 799e98a56eee7b7cc1ce6b784910b5b5bb7f3603 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Sat, 6 Jan 2024 01:58:22 +0100 Subject: [PATCH] improve iOS scroll locking The scroll locking on iOS was flickering in some scenario's due to the `window.scrollTo(0, 0)` related code. Instead of that, we now cancel touch moves instead but still allow it in scrollable containers inside the Dialog itself. This was already applied in the React version, but this adds the same improvement to the Vue version as well. --- .../document-overflow/handle-ios-locking.ts | 227 +++++++++++------- 1 file changed, 139 insertions(+), 88 deletions(-) diff --git a/packages/@headlessui-vue/src/hooks/document-overflow/handle-ios-locking.ts b/packages/@headlessui-vue/src/hooks/document-overflow/handle-ios-locking.ts index 401cb23..2c577e3 100644 --- a/packages/@headlessui-vue/src/hooks/document-overflow/handle-ios-locking.ts +++ b/packages/@headlessui-vue/src/hooks/document-overflow/handle-ios-locking.ts @@ -11,103 +11,154 @@ export function handleIOSLocking(): ScrollLockStep { return {} } - let scrollPosition: number - return { - before() { - scrollPosition = window.pageYOffset - }, - - after({ doc, d, meta }) { + before({ doc, d, meta }) { function inAllowedContainer(el: HTMLElement) { return meta.containers .flatMap((resolve) => resolve()) .some((container) => container.contains(el)) } - // We need to be able to offset the body with the current scroll position. However, if you - // have `scroll-behavior: smooth` set, then changing the scrollTop in any way shape or form - // will trigger a "smooth" scroll and the new position would be incorrect. - // - // This is why we are forcing the `scroll-behaviour: auto` here, and then restoring it later. - // We have to be a bit careful, because removing `scroll-behavior: auto` back to - // `scroll-behavior: smooth` can start triggering smooth scrolling. Delaying this by a - // microTask will guarantee that everything is done such that both enter/exit of the Dialog is - // not using smooth scrolling. - if (window.getComputedStyle(doc.documentElement).scrollBehavior !== 'auto') { - let _d = disposables() - _d.style(doc.documentElement, 'scroll-behavior', 'auto') - d.add(() => d.microTask(() => _d.dispose())) - } - - d.style(doc.body, 'marginTop', `-${scrollPosition}px`) - window.scrollTo(0, 0) - - // Relatively hacky, but if you click a link like `` in the Dialog, and there - // exists an element on the page (outside of the Dialog) with that id, then the browser will - // scroll to that position. However, this is not the case if the element we want to scroll to - // is higher and the browser needs to scroll up, but it doesn't do that. - // - // Let's try and capture that element and store it, so that we can later scroll to it once the - // Dialog closes. - let scrollToElement: HTMLElement | null = null - d.addEventListener( - doc, - 'click', - (e) => { - if (!(e.target instanceof HTMLElement)) { - return - } - - try { - let anchor = e.target.closest('a') - if (!anchor) return - let { hash } = new URL(anchor.href) - let el = doc.querySelector(hash) - if (el && !inAllowedContainer(el as HTMLElement)) { - scrollToElement = el as HTMLElement - } - } catch (err) {} - }, - true - ) - - d.addEventListener( - doc, - 'touchmove', - (e) => { - // Check if we are scrolling inside any of the allowed containers, if not let's cancel the event! - if (e.target instanceof HTMLElement && !inAllowedContainer(e.target as HTMLElement)) { - e.preventDefault() - } - }, - { passive: false } - ) - - // Restore scroll position - d.add(() => { - // Before opening the Dialog, we capture the current pageYOffset, and offset the page with - // this value so that we can also scroll to `(0, 0)`. + d.microTask(() => { + // We need to be able to offset the body with the current scroll position. However, if you + // have `scroll-behavior: smooth` set, then changing the scrollTop in any way shape or form + // will trigger a "smooth" scroll and the new position would be incorrect. // - // If we want to restore a few things can happen: - // - // 1. The window.pageYOffset is still at 0, this means nothing happened, and we can safely - // restore to the captured value earlier. - // 2. The window.pageYOffset is **not** at 0. This means that something happened (e.g.: a - // link was scrolled into view in the background). Ideally we want to restore to this _new_ - // position. To do this, we can take the new value into account with the captured value from - // before. - // - // (Since the value of window.pageYOffset is 0 in the first case, we should be able to - // always sum these values) - window.scrollTo(0, window.pageYOffset + scrollPosition) - - // If we captured an element that should be scrolled to, then we can try to do that if the - // element is still connected (aka, still in the DOM). - if (scrollToElement && scrollToElement.isConnected) { - scrollToElement.scrollIntoView({ block: 'nearest' }) - scrollToElement = null + // This is why we are forcing the `scroll-behaviour: auto` here, and then restoring it later. + // We have to be a bit careful, because removing `scroll-behavior: auto` back to + // `scroll-behavior: smooth` can start triggering smooth scrolling. Delaying this by a + // microTask will guarantee that everything is done such that both enter/exit of the Dialog is + // not using smooth scrolling. + if (window.getComputedStyle(doc.documentElement).scrollBehavior !== 'auto') { + let _d = disposables() + _d.style(doc.documentElement, 'scrollBehavior', 'auto') + d.add(() => d.microTask(() => _d.dispose())) } + + // Keep track of the current scroll position so that we can restore the scroll position if + // it has changed in the meantime. + let scrollPosition = window.scrollY ?? window.pageYOffset + + // Relatively hacky, but if you click a link like `` in the Dialog, and there + // exists an element on the page (outside of the Dialog) with that id, then the browser will + // scroll to that position. However, this is not the case if the element we want to scroll to + // is higher and the browser needs to scroll up, but it doesn't do that. + // + // Let's try and capture that element and store it, so that we can later scroll to it once the + // Dialog closes. + let scrollToElement: HTMLElement | null = null + d.addEventListener( + doc, + 'click', + (e) => { + if (!(e.target instanceof HTMLElement)) { + return + } + + try { + let anchor = e.target.closest('a') + if (!anchor) return + let { hash } = new URL(anchor.href) + let el = doc.querySelector(hash) + if (el && !inAllowedContainer(el as HTMLElement)) { + scrollToElement = el as HTMLElement + } + } catch (err) {} + }, + true + ) + + // Rely on overscrollBehavior to prevent scrolling outside of the Dialog. + d.addEventListener(doc, 'touchstart', (e) => { + if (e.target instanceof HTMLElement) { + if (inAllowedContainer(e.target as HTMLElement)) { + // Find the root of the allowed containers + let rootContainer = e.target + while ( + rootContainer.parentElement && + inAllowedContainer(rootContainer.parentElement) + ) { + rootContainer = rootContainer.parentElement! + } + + d.style(rootContainer, 'overscrollBehavior', 'contain') + } else { + d.style(e.target, 'touchAction', 'none') + } + } + }) + + d.addEventListener( + doc, + 'touchmove', + (e) => { + // Check if we are scrolling inside any of the allowed containers, if not let's cancel the event! + if (e.target instanceof HTMLElement) { + if (inAllowedContainer(e.target as HTMLElement)) { + // Even if we are in an allowed container, on iOS the main page can still scroll, we + // have to make sure that we `event.preventDefault()` this event to prevent that. + // + // However, if we happen to scroll on an element that is overflowing, or any of its + // parents are overflowing, then we should not call `event.preventDefault()` because + // otherwise we are preventing the user from scrolling inside that container which + // is not what we want. + let scrollableParent = e.target + while ( + scrollableParent.parentElement && + // Assumption: We are always used in a Headless UI Portal. Once we reach the + // portal itself, we can stop crawling up the tree. + scrollableParent.dataset.headlessuiPortal !== '' + ) { + // Check if the scrollable container is overflowing or not. + // + // NOTE: we could check the `overflow`, `overflow-y` and `overflow-x` properties + // but when there is no overflow happening then the `overscrollBehavior` doesn't + // seem to work and the main page will still scroll. So instead we check if the + // scrollable container is overflowing or not and use that heuristic instead. + if ( + scrollableParent.scrollHeight > scrollableParent.clientHeight || + scrollableParent.scrollWidth > scrollableParent.clientWidth + ) { + break + } + + scrollableParent = scrollableParent.parentElement + } + + // We crawled up the tree until the beginnging of the Portal, let's prevent the + // event if this is the case. If not, then we are in a container where we are + // allowed to scroll so we don't have to prevent the event. + if (scrollableParent.dataset.headlessuiPortal === '') { + e.preventDefault() + } + } + + // We are not in an allowed container, so let's prevent the event. + else { + e.preventDefault() + } + } + }, + { passive: false } + ) + + // Restore scroll position if a scrollToElement was captured. + d.add(() => { + let newScrollPosition = window.scrollY ?? window.pageYOffset + + // If the scroll position changed, then we can restore it to the previous value. This will + // happen if you focus an input field and the browser scrolls for you. + if (scrollPosition !== newScrollPosition) { + window.scrollTo(0, scrollPosition) + } + + // If we captured an element that should be scrolled to, then we can try to do that if the + // element is still connected (aka, still in the DOM). + if (scrollToElement && scrollToElement.isConnected) { + scrollToElement.scrollIntoView({ block: 'nearest' }) + scrollToElement = null + } + }) }) }, }