1
1
import { disposables } from '../../utils/disposables'
2
2
import { isIOS } from '../../utils/platform'
3
- import { ScrollLockStep } from './overflow-store'
3
+ import type { ScrollLockStep } from './overflow-store'
4
4
5
5
interface ContainerMetadata {
6
6
containers : ( ( ) => HTMLElement [ ] ) [ ]
@@ -11,14 +11,8 @@ export function handleIOSLocking(): ScrollLockStep<ContainerMetadata> {
11
11
return { }
12
12
}
13
13
14
- let scrollPosition : number
15
-
16
14
return {
17
- before ( ) {
18
- scrollPosition = window . pageYOffset
19
- } ,
20
-
21
- after ( { doc, d, meta } ) {
15
+ before ( { doc, d, meta } ) {
22
16
function inAllowedContainer ( el : HTMLElement ) {
23
17
return meta . containers
24
18
. flatMap ( ( resolve ) => resolve ( ) )
@@ -37,12 +31,13 @@ export function handleIOSLocking(): ScrollLockStep<ContainerMetadata> {
37
31
// not using smooth scrolling.
38
32
if ( window . getComputedStyle ( doc . documentElement ) . scrollBehavior !== 'auto' ) {
39
33
let _d = disposables ( )
40
- _d . style ( doc . documentElement , 'scroll-behavior ' , 'auto' )
34
+ _d . style ( doc . documentElement , 'scrollBehavior ' , 'auto' )
41
35
d . add ( ( ) => d . microTask ( ( ) => _d . dispose ( ) ) )
42
36
}
43
37
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
46
41
47
42
// Relatively hacky, but if you click a link like `<a href="#foo">` in the Dialog, and there
48
43
// 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> {
73
68
true
74
69
)
75
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
+ }
83
+
84
+ d . style ( rootContainer , 'overscrollBehavior' , 'contain' )
85
+ } else {
86
+ d . style ( e . target , 'touchAction' , 'none' )
87
+ }
88
+ }
89
+ } )
90
+
76
91
d . addEventListener (
77
92
doc ,
78
93
'touchmove' ,
79
94
( e ) => {
80
95
// 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
+ }
83
140
}
84
141
} ,
85
142
{ passive : false }
86
143
)
87
144
88
- // Restore scroll position
145
+ // Restore scroll position if a scrollToElement was captured.
89
146
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
+ }
105
154
106
155
// If we captured an element that should be scrolled to, then we can try to do that if the
107
156
// element is still connected (aka, still in the DOM).
0 commit comments