Files
headlessui/packages/@headlessui-vue/src/hooks/document-overflow/handle-ios-locking.ts
T
Jordan Pittman d416c1ca59 Don’t cancel touchmove on input elements inside a dialog (#3166)
* Don’t cancel touchmove on `input` elements inside a dialog

* Update changelog

* Update
2024-05-03 10:35:50 -04:00

172 lines
7.3 KiB
TypeScript

import { disposables } from '../../utils/disposables'
import { isIOS } from '../../utils/platform'
import type { ScrollLockStep } from './overflow-store'
interface ContainerMetadata {
containers: (() => HTMLElement[])[]
}
export function handleIOSLocking(): ScrollLockStep<ContainerMetadata> {
if (!isIOS()) {
return {}
}
return {
before({ doc, d, meta }) {
function inAllowedContainer(el: HTMLElement) {
return meta.containers
.flatMap((resolve) => resolve())
.some((container) => container.contains(el))
}
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(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) {
// Some inputs like `<input type=range>` use touch events to
// allow interaction. We should not prevent this event.
if (e.target.tagName === 'INPUT') {
return
}
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 beginning 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
}
})
})
},
}
}