Skip to content

Commit

Permalink
improve iOS scroll locking
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
RobinMalfait committed Jan 6, 2024
1 parent bc4a744 commit 799e98a
Showing 1 changed file with 136 additions and 85 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -11,103 +11,154 @@ export function handleIOSLocking(): ScrollLockStep<ContainerMetadata> {
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.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.
//
// 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 `<a href="#foo">` 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(doc.body, 'marginTop', `-${scrollPosition}px`)
window.scrollTo(0, 0)

// Relatively hacky, but if you click a link like `<a href="#foo">` 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
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
}

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
// 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()
}
}
} 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 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)
}
},
{ 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)`.
//
// 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
}

// 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
}
})
})
},
}
Expand Down

0 comments on commit 799e98a

Please sign in to comment.