Skip to content

Commit 962528c

Browse files
authored
Improve scroll locking on iOS (#2100)
* improve types for addEventListener inside disposables * improve scroll locking Instead of using the "simple" hack with the `position: fixed;` we now went back to the `touchmove` implementation. The `position: fixed;` causes some annoying issues. For starters, on iOS you will now get a strange gap (due to safe areas). Some applications also saw "blank" screens based on how the page was implemented. We also saw some issues internally, where clicking changing the scroll position on the main page from within the Dialog. Think about something along the lines of: ```html <a href="#interesting-link-on-the-current-page">Interesting link on the page</a> ``` This doesn't work becauase the page is now fixed, and there is nothing to scroll... Instead, we now use the `touchmove` again. The problem with this last time was that this disabled _all_ touch move events. This is obviously not good. Luckily, we already have a concept of "safe containers". This is what we use for the `outside click` behaviour as well. Basically in a Dialog, your `Dialog.Panel` is the safe container. But also third party DOM elements that are rendered inside that Panel (or as a sibling of the Dialog, but not your main app). We can re-use this knowledge of "safe containers", and only cancel the `touchmove` behaviour if this didn't happen in any of the safe containers. * update changelog
1 parent d31bb5c commit 962528c

File tree

6 files changed

+87
-44
lines changed

6 files changed

+87
-44
lines changed

packages/@headlessui-react/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 regression where `displayValue` crashes ([#2087](https://github.com/tailwindlabs/headlessui/pull/2087))
1313
- Fix `displayValue` syncing when `Combobox.Input` is unmounted and re-mounted in different trees ([#2090](https://github.com/tailwindlabs/headlessui/pull/2090))
1414
- Fix FocusTrap escape due to strange tabindex values ([#2093](https://github.com/tailwindlabs/headlessui/pull/2093))
15+
- Improve scroll locking on iOS ([#2100](https://github.com/tailwindlabs/headlessui/pull/2100))
1516

1617
## [1.7.5] - 2022-12-08
1718

packages/@headlessui-react/src/components/dialog/dialog.tsx

Lines changed: 46 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,11 @@ function useDialogContext(component: string) {
9191
return context
9292
}
9393

94-
function useScrollLock(ownerDocument: Document | null, enabled: boolean) {
94+
function useScrollLock(
95+
ownerDocument: Document | null,
96+
enabled: boolean,
97+
resolveAllowedContainers: () => HTMLElement[] = () => [document.body]
98+
) {
9599
useEffect(() => {
96100
if (!enabled) return
97101
if (!ownerDocument) return
@@ -120,9 +124,27 @@ function useScrollLock(ownerDocument: Document | null, enabled: boolean) {
120124

121125
if (isIOS()) {
122126
let scrollPosition = window.pageYOffset
123-
style(documentElement, 'position', 'fixed')
124-
style(documentElement, 'marginTop', `-${scrollPosition}px`)
125-
style(documentElement, 'width', `100%`)
127+
style(document.body, 'marginTop', `-${scrollPosition}px`)
128+
window.scrollTo(0, 0)
129+
130+
d.addEventListener(
131+
ownerDocument,
132+
'touchmove',
133+
(e) => {
134+
// Check if we are scrolling inside any of the allowed containers, if not let's cancel the event!
135+
if (
136+
e.target instanceof HTMLElement &&
137+
!resolveAllowedContainers().some((container) =>
138+
container.contains(e.target as HTMLElement)
139+
)
140+
) {
141+
e.preventDefault()
142+
}
143+
},
144+
{ passive: false }
145+
)
146+
147+
// Restore scroll position
126148
d.add(() => window.scrollTo(0, scrollPosition))
127149
}
128150

@@ -242,27 +264,22 @@ let DialogRoot = forwardRefWithAs(function Dialog<
242264
// Ensure other elements can't be interacted with
243265
useInertOthers(internalDialogRef, hasNestedDialogs ? enabled : false)
244266

245-
// Close Dialog on outside click
246-
useOutsideClick(
247-
() => {
248-
// Third party roots
249-
let rootContainers = Array.from(
250-
ownerDocument?.querySelectorAll('body > *, [data-headlessui-portal]') ?? []
251-
).filter((container) => {
252-
if (!(container instanceof HTMLElement)) return false // Skip non-HTMLElements
253-
if (container.contains(mainTreeNode.current)) return false // Skip if it is the main app
254-
if (state.panelRef.current && container.contains(state.panelRef.current)) return false
255-
return true // Keep
256-
})
267+
let resolveContainers = useEvent(() => {
268+
// Third party roots
269+
let rootContainers = Array.from(
270+
ownerDocument?.querySelectorAll('body > *, [data-headlessui-portal]') ?? []
271+
).filter((container) => {
272+
if (!(container instanceof HTMLElement)) return false // Skip non-HTMLElements
273+
if (container.contains(mainTreeNode.current)) return false // Skip if it is the main app
274+
if (state.panelRef.current && container.contains(state.panelRef.current)) return false
275+
return true // Keep
276+
})
257277

258-
return [
259-
...rootContainers,
260-
state.panelRef.current ?? internalDialogRef.current,
261-
] as HTMLElement[]
262-
},
263-
close,
264-
enabled && !hasNestedDialogs
265-
)
278+
return [...rootContainers, state.panelRef.current ?? internalDialogRef.current] as HTMLElement[]
279+
})
280+
281+
// Close Dialog on outside click
282+
useOutsideClick(() => resolveContainers(), close, enabled && !hasNestedDialogs)
266283

267284
// Handle `Escape` to close
268285
useEventListener(ownerDocument?.defaultView, 'keydown', (event) => {
@@ -276,7 +293,11 @@ let DialogRoot = forwardRefWithAs(function Dialog<
276293
})
277294

278295
// Scroll lock
279-
useScrollLock(ownerDocument, dialogState === DialogStates.Open && !hasParentDialog)
296+
useScrollLock(
297+
ownerDocument,
298+
dialogState === DialogStates.Open && !hasParentDialog,
299+
resolveContainers
300+
)
280301

281302
// Trigger close when the FocusTrap gets hidden
282303
useEffect(() => {

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export function disposables() {
1010
},
1111

1212
addEventListener<TEventName extends keyof WindowEventMap>(
13-
element: HTMLElement | Document,
13+
element: HTMLElement | Window | Document,
1414
name: TEventName,
1515
listener: (event: WindowEventMap[TEventName]) => any,
1616
options?: boolean | AddEventListenerOptions

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 regression where `displayValue` crashes ([#2087](https://github.com/tailwindlabs/headlessui/pull/2087))
1313
- Fix `displayValue` syncing when `Combobox.Input` is unmounted and re-mounted in different trees ([#2090](https://github.com/tailwindlabs/headlessui/pull/2090))
1414
- Fix FocusTrap escape due to strange tabindex values ([#2093](https://github.com/tailwindlabs/headlessui/pull/2093))
15+
- Improve scroll locking on iOS ([#2100](https://github.com/tailwindlabs/headlessui/pull/2100))
1516

1617
## [1.7.5] - 2022-12-08
1718

packages/@headlessui-vue/src/components/dialog/dialog.ts

Lines changed: 37 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -183,22 +183,23 @@ export let Dialog = defineComponent({
183183

184184
provide(DialogContext, api)
185185

186-
// Handle outside click
187-
useOutsideClick(
188-
() => {
189-
// Third party roots
190-
let rootContainers = Array.from(
191-
ownerDocument.value?.querySelectorAll('body > *, [data-headlessui-portal]') ?? []
192-
).filter((container) => {
193-
if (!(container instanceof HTMLElement)) return false // Skip non-HTMLElements
194-
if (container.contains(dom(mainTreeNode))) return false // Skip if it is the main app
195-
if (api.panelRef.value && container.contains(api.panelRef.value)) return false
196-
return true // Keep
197-
})
186+
function resolveAllowedContainers() {
187+
// Third party roots
188+
let rootContainers = Array.from(
189+
ownerDocument.value?.querySelectorAll('body > *, [data-headlessui-portal]') ?? []
190+
).filter((container) => {
191+
if (!(container instanceof HTMLElement)) return false // Skip non-HTMLElements
192+
if (container.contains(dom(mainTreeNode))) return false // Skip if it is the main app
193+
if (api.panelRef.value && container.contains(api.panelRef.value)) return false
194+
return true // Keep
195+
})
198196

199-
return [...rootContainers, api.panelRef.value ?? internalDialogRef.value] as HTMLElement[]
200-
},
197+
return [...rootContainers, api.panelRef.value ?? internalDialogRef.value] as HTMLElement[]
198+
}
201199

200+
// Handle outside click
201+
useOutsideClick(
202+
() => resolveAllowedContainers(),
202203
(_event, target) => {
203204
api.close()
204205
nextTick(() => target?.focus())
@@ -249,9 +250,28 @@ export let Dialog = defineComponent({
249250

250251
if (isIOS()) {
251252
let scrollPosition = window.pageYOffset
252-
style(documentElement, 'position', 'fixed')
253-
style(documentElement, 'marginTop', `-${scrollPosition}px`)
254-
style(documentElement, 'width', `100%`)
253+
style(document.body, 'marginTop', `-${scrollPosition}px`)
254+
window.scrollTo(0, 0)
255+
256+
d.addEventListener(
257+
owner,
258+
'touchmove',
259+
(e) => {
260+
// Check if we are scrolling inside any of the allowed containers, if not let's cancel
261+
// the event!
262+
if (
263+
e.target instanceof HTMLElement &&
264+
!resolveAllowedContainers().some((container) =>
265+
container.contains(e.target as HTMLElement)
266+
)
267+
) {
268+
e.preventDefault()
269+
}
270+
},
271+
{ passive: false }
272+
)
273+
274+
// Restore scroll position
255275
d.add(() => window.scrollTo(0, scrollPosition))
256276
}
257277

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export function disposables() {
88
},
99

1010
addEventListener<TEventName extends keyof WindowEventMap>(
11-
element: HTMLElement | Document,
11+
element: HTMLElement | Window | Document,
1212
name: TEventName,
1313
listener: (event: WindowEventMap[TEventName]) => any,
1414
options?: boolean | AddEventListenerOptions

0 commit comments

Comments
 (0)