Skip to content

Commit 279356b

Browse files
committed
improve iOS scroll locking (Vue)
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.
1 parent 32b0117 commit 279356b

File tree

1 file changed

+136
-85
lines changed

1 file changed

+136
-85
lines changed

packages/@headlessui-vue/src/hooks/document-overflow/handle-ios-locking.ts

Lines changed: 136 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -11,103 +11,154 @@ export function handleIOSLocking(): ScrollLockStep<ContainerMetadata> {
1111
return {}
1212
}
1313

14-
let scrollPosition: number
15-
1614
return {
17-
before() {
18-
scrollPosition = window.pageYOffset
19-
},
20-
21-
after({ doc, d, meta }) {
15+
before({ doc, d, meta }) {
2216
function inAllowedContainer(el: HTMLElement) {
2317
return meta.containers
2418
.flatMap((resolve) => resolve())
2519
.some((container) => container.contains(el))
2620
}
2721

28-
// We need to be able to offset the body with the current scroll position. However, if you
29-
// have `scroll-behavior: smooth` set, then changing the scrollTop in any way shape or form
30-
// will trigger a "smooth" scroll and the new position would be incorrect.
31-
//
32-
// This is why we are forcing the `scroll-behaviour: auto` here, and then restoring it later.
33-
// We have to be a bit careful, because removing `scroll-behavior: auto` back to
34-
// `scroll-behavior: smooth` can start triggering smooth scrolling. Delaying this by a
35-
// microTask will guarantee that everything is done such that both enter/exit of the Dialog is
36-
// not using smooth scrolling.
37-
if (window.getComputedStyle(doc.documentElement).scrollBehavior !== 'auto') {
38-
let _d = disposables()
39-
_d.style(doc.documentElement, 'scroll-behavior', 'auto')
40-
d.add(() => d.microTask(() => _d.dispose()))
41-
}
22+
d.microTask(() => {
23+
// We need to be able to offset the body with the current scroll position. However, if you
24+
// have `scroll-behavior: smooth` set, then changing the scrollTop in any way shape or form
25+
// will trigger a "smooth" scroll and the new position would be incorrect.
26+
//
27+
// This is why we are forcing the `scroll-behaviour: auto` here, and then restoring it later.
28+
// We have to be a bit careful, because removing `scroll-behavior: auto` back to
29+
// `scroll-behavior: smooth` can start triggering smooth scrolling. Delaying this by a
30+
// microTask will guarantee that everything is done such that both enter/exit of the Dialog is
31+
// not using smooth scrolling.
32+
if (window.getComputedStyle(doc.documentElement).scrollBehavior !== 'auto') {
33+
let _d = disposables()
34+
_d.style(doc.documentElement, 'scrollBehavior', 'auto')
35+
d.add(() => d.microTask(() => _d.dispose()))
36+
}
37+
38+
// Keep track of the current scroll position so that we can restore the scroll position if
39+
// it has changed in the meantime.
40+
let scrollPosition = window.scrollY ?? window.pageYOffset
41+
42+
// Relatively hacky, but if you click a link like `<a href="#foo">` in the Dialog, and there
43+
// exists an element on the page (outside of the Dialog) with that id, then the browser will
44+
// scroll to that position. However, this is not the case if the element we want to scroll to
45+
// is higher and the browser needs to scroll up, but it doesn't do that.
46+
//
47+
// Let's try and capture that element and store it, so that we can later scroll to it once the
48+
// Dialog closes.
49+
let scrollToElement: HTMLElement | null = null
50+
d.addEventListener(
51+
doc,
52+
'click',
53+
(e) => {
54+
if (!(e.target instanceof HTMLElement)) {
55+
return
56+
}
57+
58+
try {
59+
let anchor = e.target.closest('a')
60+
if (!anchor) return
61+
let { hash } = new URL(anchor.href)
62+
let el = doc.querySelector(hash)
63+
if (el && !inAllowedContainer(el as HTMLElement)) {
64+
scrollToElement = el as HTMLElement
65+
}
66+
} catch (err) {}
67+
},
68+
true
69+
)
70+
71+
// Rely on overscrollBehavior to prevent scrolling outside of the Dialog.
72+
d.addEventListener(doc, 'touchstart', (e) => {
73+
if (e.target instanceof HTMLElement) {
74+
if (inAllowedContainer(e.target as HTMLElement)) {
75+
// Find the root of the allowed containers
76+
let rootContainer = e.target
77+
while (
78+
rootContainer.parentElement &&
79+
inAllowedContainer(rootContainer.parentElement)
80+
) {
81+
rootContainer = rootContainer.parentElement!
82+
}
4283

43-
d.style(doc.body, 'marginTop', `-${scrollPosition}px`)
44-
window.scrollTo(0, 0)
45-
46-
// Relatively hacky, but if you click a link like `<a href="#foo">` in the Dialog, and there
47-
// exists an element on the page (outside of the Dialog) with that id, then the browser will
48-
// scroll to that position. However, this is not the case if the element we want to scroll to
49-
// is higher and the browser needs to scroll up, but it doesn't do that.
50-
//
51-
// Let's try and capture that element and store it, so that we can later scroll to it once the
52-
// Dialog closes.
53-
let scrollToElement: HTMLElement | null = null
54-
d.addEventListener(
55-
doc,
56-
'click',
57-
(e) => {
58-
if (!(e.target instanceof HTMLElement)) {
59-
return
84+
d.style(rootContainer, 'overscrollBehavior', 'contain')
85+
} else {
86+
d.style(e.target, 'touchAction', 'none')
87+
}
6088
}
89+
})
90+
91+
d.addEventListener(
92+
doc,
93+
'touchmove',
94+
(e) => {
95+
// Check if we are scrolling inside any of the allowed containers, if not let's cancel the event!
96+
if (e.target instanceof HTMLElement) {
97+
if (inAllowedContainer(e.target as HTMLElement)) {
98+
// Even if we are in an allowed container, on iOS the main page can still scroll, we
99+
// have to make sure that we `event.preventDefault()` this event to prevent that.
100+
//
101+
// However, if we happen to scroll on an element that is overflowing, or any of its
102+
// parents are overflowing, then we should not call `event.preventDefault()` because
103+
// otherwise we are preventing the user from scrolling inside that container which
104+
// is not what we want.
105+
let scrollableParent = e.target
106+
while (
107+
scrollableParent.parentElement &&
108+
// Assumption: We are always used in a Headless UI Portal. Once we reach the
109+
// portal itself, we can stop crawling up the tree.
110+
scrollableParent.dataset.headlessuiPortal !== ''
111+
) {
112+
// Check if the scrollable container is overflowing or not.
113+
//
114+
// NOTE: we could check the `overflow`, `overflow-y` and `overflow-x` properties
115+
// but when there is no overflow happening then the `overscrollBehavior` doesn't
116+
// seem to work and the main page will still scroll. So instead we check if the
117+
// scrollable container is overflowing or not and use that heuristic instead.
118+
if (
119+
scrollableParent.scrollHeight > scrollableParent.clientHeight ||
120+
scrollableParent.scrollWidth > scrollableParent.clientWidth
121+
) {
122+
break
123+
}
124+
125+
scrollableParent = scrollableParent.parentElement
126+
}
61127

62-
try {
63-
let anchor = e.target.closest('a')
64-
if (!anchor) return
65-
let { hash } = new URL(anchor.href)
66-
let el = doc.querySelector(hash)
67-
if (el && !inAllowedContainer(el as HTMLElement)) {
68-
scrollToElement = el as HTMLElement
128+
// We crawled up the tree until the beginnging of the Portal, let's prevent the
129+
// event if this is the case. If not, then we are in a container where we are
130+
// allowed to scroll so we don't have to prevent the event.
131+
if (scrollableParent.dataset.headlessuiPortal === '') {
132+
e.preventDefault()
133+
}
134+
}
135+
136+
// We are not in an allowed container, so let's prevent the event.
137+
else {
138+
e.preventDefault()
139+
}
69140
}
70-
} catch (err) {}
71-
},
72-
true
73-
)
74-
75-
d.addEventListener(
76-
doc,
77-
'touchmove',
78-
(e) => {
79-
// Check if we are scrolling inside any of the allowed containers, if not let's cancel the event!
80-
if (e.target instanceof HTMLElement && !inAllowedContainer(e.target as HTMLElement)) {
81-
e.preventDefault()
141+
},
142+
{ passive: false }
143+
)
144+
145+
// Restore scroll position if a scrollToElement was captured.
146+
d.add(() => {
147+
let newScrollPosition = window.scrollY ?? window.pageYOffset
148+
149+
// If the scroll position changed, then we can restore it to the previous value. This will
150+
// happen if you focus an input field and the browser scrolls for you.
151+
if (scrollPosition !== newScrollPosition) {
152+
window.scrollTo(0, scrollPosition)
82153
}
83-
},
84-
{ passive: false }
85-
)
86-
87-
// Restore scroll position
88-
d.add(() => {
89-
// Before opening the Dialog, we capture the current pageYOffset, and offset the page with
90-
// this value so that we can also scroll to `(0, 0)`.
91-
//
92-
// If we want to restore a few things can happen:
93-
//
94-
// 1. The window.pageYOffset is still at 0, this means nothing happened, and we can safely
95-
// restore to the captured value earlier.
96-
// 2. The window.pageYOffset is **not** at 0. This means that something happened (e.g.: a
97-
// link was scrolled into view in the background). Ideally we want to restore to this _new_
98-
// position. To do this, we can take the new value into account with the captured value from
99-
// before.
100-
//
101-
// (Since the value of window.pageYOffset is 0 in the first case, we should be able to
102-
// always sum these values)
103-
window.scrollTo(0, window.pageYOffset + scrollPosition)
104-
105-
// If we captured an element that should be scrolled to, then we can try to do that if the
106-
// element is still connected (aka, still in the DOM).
107-
if (scrollToElement && scrollToElement.isConnected) {
108-
scrollToElement.scrollIntoView({ block: 'nearest' })
109-
scrollToElement = null
110-
}
154+
155+
// If we captured an element that should be scrolled to, then we can try to do that if the
156+
// element is still connected (aka, still in the DOM).
157+
if (scrollToElement && scrollToElement.isConnected) {
158+
scrollToElement.scrollIntoView({ block: 'nearest' })
159+
scrollToElement = null
160+
}
161+
})
111162
})
112163
},
113164
}

0 commit comments

Comments
 (0)