Skip to content

Commit 6f9de89

Browse files
authored
Disable smooth scrolling when opening/closing Dialog components on iOS (#2635)
* disable smooth scrolling when opening/closing Dialogs For iOS workaround related purposes we have to capture the scroll position and offset the margin top with that amount and then `scrollTo(0,0,)` to prevent all kinds of funny UI jumps. However, if you have `scroll-behavior: smooth` enabled on your `html`, then offseting the margin-top and later `scrollTo(0,0)` would be handled in a smooth way, which means that the actual position would be off. To solve this, we disable smooth scrolling entirely in order to make the position of the Dialog correct. This shouldn't be a problem in practice since the page itself isn't suppose to scroll anyway. Once the Dialog closes we reset it such that everything else keeps working as expected in a (smooth) way. * add `microTask` to disposables * ensure the fix works in React's double rendering dev mode * update changelog
1 parent 34275da commit 6f9de89

File tree

5 files changed

+112
-62
lines changed

5 files changed

+112
-62
lines changed

packages/@headlessui-react/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
- Use correct value when resetting `<Listbox multiple>` and `<Combobox multiple>` ([#2626](https://github.com/tailwindlabs/headlessui/pull/2626))
1313
- Render `<MainTreeNode />` in `Popover.Group` component only ([#2634](https://github.com/tailwindlabs/headlessui/pull/2634))
14+
- Disable smooth scrolling when opening/closing `Dialog` components on iOS ([#2635](https://github.com/tailwindlabs/headlessui/pull/2635))
1415

1516
## [1.7.16] - 2023-07-27
1617

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

Lines changed: 80 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { disposables } from '../../utils/disposables'
12
import { isIOS } from '../../utils/platform'
23
import { ScrollLockStep } from './overflow-store'
34

@@ -24,74 +25,91 @@ export function handleIOSLocking(): ScrollLockStep<ContainerMetadata> {
2425
.some((container) => container.contains(el))
2526
}
2627

27-
d.style(doc.body, 'marginTop', `-${scrollPosition}px`)
28-
window.scrollTo(0, 0)
28+
d.microTask(() => {
29+
// We need to be able to offset the body with the current scroll position. However, if you
30+
// have `scroll-behavior: smooth` set, then changing the scrollTop in any way shape or form
31+
// will trigger a "smooth" scroll and the new position would be incorrect.
32+
//
33+
// This is why we are forcing the `scroll-behaviour: auto` here, and then restoring it later.
34+
// We have to be a bit careful, because removing `scroll-behavior: auto` back to
35+
// `scroll-behavior: smooth` can start triggering smooth scrolling. Delaying this by a
36+
// microTask will guarantee that everything is done such that both enter/exit of the Dialog is
37+
// not using smooth scrolling.
38+
if (window.getComputedStyle(doc.documentElement).scrollBehavior !== 'auto') {
39+
let _d = disposables()
40+
_d.style(doc.documentElement, 'scroll-behavior', 'auto')
41+
d.add(() => d.microTask(() => _d.dispose()))
42+
}
2943

30-
// Relatively hacky, but if you click a link like `<a href="#foo">` in the Dialog, and there
31-
// exists an element on the page (outside of the Dialog) with that id, then the browser will
32-
// scroll to that position. However, this is not the case if the element we want to scroll to
33-
// is higher and the browser needs to scroll up, but it doesn't do that.
34-
//
35-
// Let's try and capture that element and store it, so that we can later scroll to it once the
36-
// Dialog closes.
37-
let scrollToElement: HTMLElement | null = null
38-
d.addEventListener(
39-
doc,
40-
'click',
41-
(e) => {
42-
if (!(e.target instanceof HTMLElement)) {
43-
return
44-
}
44+
d.style(doc.body, 'marginTop', `-${scrollPosition}px`)
45+
window.scrollTo(0, 0)
4546

46-
try {
47-
let anchor = e.target.closest('a')
48-
if (!anchor) return
49-
let { hash } = new URL(anchor.href)
50-
let el = doc.querySelector(hash)
51-
if (el && !inAllowedContainer(el as HTMLElement)) {
52-
scrollToElement = el as HTMLElement
47+
// Relatively hacky, but if you click a link like `<a href="#foo">` in the Dialog, and there
48+
// exists an element on the page (outside of the Dialog) with that id, then the browser will
49+
// scroll to that position. However, this is not the case if the element we want to scroll to
50+
// is higher and the browser needs to scroll up, but it doesn't do that.
51+
//
52+
// Let's try and capture that element and store it, so that we can later scroll to it once the
53+
// Dialog closes.
54+
let scrollToElement: HTMLElement | null = null
55+
d.addEventListener(
56+
doc,
57+
'click',
58+
(e) => {
59+
if (!(e.target instanceof HTMLElement)) {
60+
return
5361
}
54-
} catch (err) {}
55-
},
56-
true
57-
)
5862

59-
d.addEventListener(
60-
doc,
61-
'touchmove',
62-
(e) => {
63-
// Check if we are scrolling inside any of the allowed containers, if not let's cancel the event!
64-
if (e.target instanceof HTMLElement && !inAllowedContainer(e.target as HTMLElement)) {
65-
e.preventDefault()
66-
}
67-
},
68-
{ passive: false }
69-
)
63+
try {
64+
let anchor = e.target.closest('a')
65+
if (!anchor) return
66+
let { hash } = new URL(anchor.href)
67+
let el = doc.querySelector(hash)
68+
if (el && !inAllowedContainer(el as HTMLElement)) {
69+
scrollToElement = el as HTMLElement
70+
}
71+
} catch (err) {}
72+
},
73+
true
74+
)
7075

71-
// Restore scroll position
72-
d.add(() => {
73-
// Before opening the Dialog, we capture the current pageYOffset, and offset the page with
74-
// this value so that we can also scroll to `(0, 0)`.
75-
//
76-
// If we want to restore a few things can happen:
77-
//
78-
// 1. The window.pageYOffset is still at 0, this means nothing happened, and we can safely
79-
// restore to the captured value earlier.
80-
// 2. The window.pageYOffset is **not** at 0. This means that something happened (e.g.: a
81-
// link was scrolled into view in the background). Ideally we want to restore to this _new_
82-
// position. To do this, we can take the new value into account with the captured value from
83-
// before.
84-
//
85-
// (Since the value of window.pageYOffset is 0 in the first case, we should be able to
86-
// always sum these values)
87-
window.scrollTo(0, window.pageYOffset + scrollPosition)
76+
d.addEventListener(
77+
doc,
78+
'touchmove',
79+
(e) => {
80+
// Check if we are scrolling inside any of the allowed containers, if not let's cancel the event!
81+
if (e.target instanceof HTMLElement && !inAllowedContainer(e.target as HTMLElement)) {
82+
e.preventDefault()
83+
}
84+
},
85+
{ passive: false }
86+
)
8887

89-
// If we captured an element that should be scrolled to, then we can try to do that if the
90-
// element is still connected (aka, still in the DOM).
91-
if (scrollToElement && scrollToElement.isConnected) {
92-
scrollToElement.scrollIntoView({ block: 'nearest' })
93-
scrollToElement = null
94-
}
88+
// Restore scroll position
89+
d.add(() => {
90+
// Before opening the Dialog, we capture the current pageYOffset, and offset the page with
91+
// this value so that we can also scroll to `(0, 0)`.
92+
//
93+
// If we want to restore a few things can happen:
94+
//
95+
// 1. The window.pageYOffset is still at 0, this means nothing happened, and we can safely
96+
// restore to the captured value earlier.
97+
// 2. The window.pageYOffset is **not** at 0. This means that something happened (e.g.: a
98+
// link was scrolled into view in the background). Ideally we want to restore to this _new_
99+
// position. To do this, we can take the new value into account with the captured value from
100+
// before.
101+
//
102+
// (Since the value of window.pageYOffset is 0 in the first case, we should be able to
103+
// always sum these values)
104+
window.scrollTo(0, window.pageYOffset + scrollPosition)
105+
106+
// If we captured an element that should be scrolled to, then we can try to do that if the
107+
// element is still connected (aka, still in the DOM).
108+
if (scrollToElement && scrollToElement.isConnected) {
109+
scrollToElement.scrollIntoView({ block: 'nearest' })
110+
scrollToElement = null
111+
}
112+
})
95113
})
96114
},
97115
}

packages/@headlessui-vue/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- Fix form elements for uncontrolled `<Listbox multiple>` and `<Combobox multiple>` ([#2626](https://github.com/tailwindlabs/headlessui/pull/2626))
1313
- Use correct value when resetting `<Listbox multiple>` and `<Combobox multiple>` ([#2626](https://github.com/tailwindlabs/headlessui/pull/2626))
1414
- Render `<MainTreeNode />` in `PopoverGroup` component only ([#2634](https://github.com/tailwindlabs/headlessui/pull/2634))
15+
- Disable smooth scrolling when opening/closing `Dialog` components on iOS ([#2635](https://github.com/tailwindlabs/headlessui/pull/2635))
1516

1617
## [1.7.15] - 2023-07-27
1718

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { disposables } from '../../utils/disposables'
12
import { isIOS } from '../../utils/platform'
23
import { ScrollLockStep } from './overflow-store'
34

@@ -24,6 +25,21 @@ export function handleIOSLocking(): ScrollLockStep<ContainerMetadata> {
2425
.some((container) => container.contains(el))
2526
}
2627

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+
}
42+
2743
d.style(doc.body, 'marginTop', `-${scrollPosition}px`)
2844
window.scrollTo(0, 0)
2945

packages/@headlessui-vue/src/utils/disposables.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { microTask } from './micro-task'
2+
13
export type Disposables = ReturnType<typeof disposables>
24

35
export function disposables() {
@@ -30,6 +32,18 @@ export function disposables() {
3032
api.add(() => clearTimeout(timer))
3133
},
3234

35+
microTask(...args: Parameters<typeof microTask>) {
36+
let task = { current: true }
37+
microTask(() => {
38+
if (task.current) {
39+
args[0]()
40+
}
41+
})
42+
return api.add(() => {
43+
task.current = false
44+
})
45+
},
46+
3347
style(node: HTMLElement, property: string, value: string) {
3448
let previous = node.style.getPropertyValue(property)
3549
Object.assign(node.style, { [property]: value })

0 commit comments

Comments
 (0)