11import { disposables } from '../../utils/disposables'
22import { isIOS } from '../../utils/platform'
3- import { ScrollLockStep } from './overflow-store'
3+ import type { ScrollLockStep } from './overflow-store'
44
55interface 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