Skip to content

Commit c6b5a81

Browse files
committed
improve iOS locking (React)
1 parent 01a34cb commit c6b5a81

File tree

2 files changed

+79
-29
lines changed

2 files changed

+79
-29
lines changed

packages/@headlessui-react/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2121
- Fix outside click detection when component is mounted in the Shadow DOM ([#2866](https://github.com/tailwindlabs/headlessui/pull/2866))
2222
- Fix CJS types ([#2880](https://github.com/tailwindlabs/headlessui/pull/2880))
2323
- Fix error when transition classes contain new lines ([#2871](https://github.com/tailwindlabs/headlessui/pull/2871))
24+
- Improve iOS locking ([7721aca](https://github.com/tailwindlabs/headlessui/commit/7721acaecea2008c2d7e8ab29cc8d45b70bb021e))
2425

2526
### Added
2627

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

Lines changed: 78 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { disposables } from '../../utils/disposables'
22
import { isIOS } from '../../utils/platform'
3-
import { ScrollLockStep } from './overflow-store'
3+
import type { ScrollLockStep } from './overflow-store'
44

55
interface ContainerMetadata {
66
containers: (() => HTMLElement[])[]
@@ -11,14 +11,8 @@ 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())
@@ -37,12 +31,13 @@ export function handleIOSLocking(): ScrollLockStep<ContainerMetadata> {
3731
// not using smooth scrolling.
3832
if (window.getComputedStyle(doc.documentElement).scrollBehavior !== 'auto') {
3933
let _d = disposables()
40-
_d.style(doc.documentElement, 'scroll-behavior', 'auto')
34+
_d.style(doc.documentElement, 'scrollBehavior', 'auto')
4135
d.add(() => d.microTask(() => _d.dispose()))
4236
}
4337

44-
d.style(doc.body, 'marginTop', `-${scrollPosition}px`)
45-
window.scrollTo(0, 0)
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
4641

4742
// Relatively hacky, but if you click a link like `<a href="#foo">` in the Dialog, and there
4843
// exists an element on the page (outside of the Dialog) with that id, then the browser will
@@ -73,35 +68,89 @@ export function handleIOSLocking(): ScrollLockStep<ContainerMetadata> {
7368
true
7469
)
7570

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+
}
83+
84+
d.style(rootContainer, 'overscrollBehavior', 'contain')
85+
} else {
86+
d.style(e.target, 'touchAction', 'none')
87+
}
88+
}
89+
})
90+
7691
d.addEventListener(
7792
doc,
7893
'touchmove',
7994
(e) => {
8095
// 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()
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+
}
127+
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+
}
83140
}
84141
},
85142
{ passive: false }
86143
)
87144

88-
// Restore scroll position
145+
// Restore scroll position if a scrollToElement was captured.
89146
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)
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)
153+
}
105154

106155
// If we captured an element that should be scrolled to, then we can try to do that if the
107156
// element is still connected (aka, still in the DOM).

0 commit comments

Comments
 (0)