@@ -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