Skip to content

Commit

Permalink
Improve scroll locking on iOS (#2100)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
RobinMalfait committed Dec 15, 2022
1 parent d31bb5c commit 962528c
Show file tree
Hide file tree
Showing 6 changed files with 87 additions and 44 deletions.
1 change: 1 addition & 0 deletions packages/@headlessui-react/CHANGELOG.md
Expand Up @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fix regression where `displayValue` crashes ([#2087](https://github.com/tailwindlabs/headlessui/pull/2087))
- Fix `displayValue` syncing when `Combobox.Input` is unmounted and re-mounted in different trees ([#2090](https://github.com/tailwindlabs/headlessui/pull/2090))
- Fix FocusTrap escape due to strange tabindex values ([#2093](https://github.com/tailwindlabs/headlessui/pull/2093))
- Improve scroll locking on iOS ([#2100](https://github.com/tailwindlabs/headlessui/pull/2100))

## [1.7.5] - 2022-12-08

Expand Down
71 changes: 46 additions & 25 deletions packages/@headlessui-react/src/components/dialog/dialog.tsx
Expand Up @@ -91,7 +91,11 @@ function useDialogContext(component: string) {
return context
}

function useScrollLock(ownerDocument: Document | null, enabled: boolean) {
function useScrollLock(
ownerDocument: Document | null,
enabled: boolean,
resolveAllowedContainers: () => HTMLElement[] = () => [document.body]
) {
useEffect(() => {
if (!enabled) return
if (!ownerDocument) return
Expand Down Expand Up @@ -120,9 +124,27 @@ function useScrollLock(ownerDocument: Document | null, enabled: boolean) {

if (isIOS()) {
let scrollPosition = window.pageYOffset
style(documentElement, 'position', 'fixed')
style(documentElement, 'marginTop', `-${scrollPosition}px`)
style(documentElement, 'width', `100%`)
style(document.body, 'marginTop', `-${scrollPosition}px`)
window.scrollTo(0, 0)

d.addEventListener(
ownerDocument,
'touchmove',
(e) => {
// Check if we are scrolling inside any of the allowed containers, if not let's cancel the event!
if (
e.target instanceof HTMLElement &&
!resolveAllowedContainers().some((container) =>
container.contains(e.target as HTMLElement)
)
) {
e.preventDefault()
}
},
{ passive: false }
)

// Restore scroll position
d.add(() => window.scrollTo(0, scrollPosition))
}

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

// Close Dialog on outside click
useOutsideClick(
() => {
// Third party roots
let rootContainers = Array.from(
ownerDocument?.querySelectorAll('body > *, [data-headlessui-portal]') ?? []
).filter((container) => {
if (!(container instanceof HTMLElement)) return false // Skip non-HTMLElements
if (container.contains(mainTreeNode.current)) return false // Skip if it is the main app
if (state.panelRef.current && container.contains(state.panelRef.current)) return false
return true // Keep
})
let resolveContainers = useEvent(() => {
// Third party roots
let rootContainers = Array.from(
ownerDocument?.querySelectorAll('body > *, [data-headlessui-portal]') ?? []
).filter((container) => {
if (!(container instanceof HTMLElement)) return false // Skip non-HTMLElements
if (container.contains(mainTreeNode.current)) return false // Skip if it is the main app
if (state.panelRef.current && container.contains(state.panelRef.current)) return false
return true // Keep
})

return [
...rootContainers,
state.panelRef.current ?? internalDialogRef.current,
] as HTMLElement[]
},
close,
enabled && !hasNestedDialogs
)
return [...rootContainers, state.panelRef.current ?? internalDialogRef.current] as HTMLElement[]
})

// Close Dialog on outside click
useOutsideClick(() => resolveContainers(), close, enabled && !hasNestedDialogs)

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

// Scroll lock
useScrollLock(ownerDocument, dialogState === DialogStates.Open && !hasParentDialog)
useScrollLock(
ownerDocument,
dialogState === DialogStates.Open && !hasParentDialog,
resolveContainers
)

// Trigger close when the FocusTrap gets hidden
useEffect(() => {
Expand Down
2 changes: 1 addition & 1 deletion packages/@headlessui-react/src/utils/disposables.ts
Expand Up @@ -10,7 +10,7 @@ export function disposables() {
},

addEventListener<TEventName extends keyof WindowEventMap>(
element: HTMLElement | Document,
element: HTMLElement | Window | Document,
name: TEventName,
listener: (event: WindowEventMap[TEventName]) => any,
options?: boolean | AddEventListenerOptions
Expand Down
1 change: 1 addition & 0 deletions packages/@headlessui-vue/CHANGELOG.md
Expand Up @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fix regression where `displayValue` crashes ([#2087](https://github.com/tailwindlabs/headlessui/pull/2087))
- Fix `displayValue` syncing when `Combobox.Input` is unmounted and re-mounted in different trees ([#2090](https://github.com/tailwindlabs/headlessui/pull/2090))
- Fix FocusTrap escape due to strange tabindex values ([#2093](https://github.com/tailwindlabs/headlessui/pull/2093))
- Improve scroll locking on iOS ([#2100](https://github.com/tailwindlabs/headlessui/pull/2100))

## [1.7.5] - 2022-12-08

Expand Down
54 changes: 37 additions & 17 deletions packages/@headlessui-vue/src/components/dialog/dialog.ts
Expand Up @@ -183,22 +183,23 @@ export let Dialog = defineComponent({

provide(DialogContext, api)

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

return [...rootContainers, api.panelRef.value ?? internalDialogRef.value] as HTMLElement[]
},
return [...rootContainers, api.panelRef.value ?? internalDialogRef.value] as HTMLElement[]
}

// Handle outside click
useOutsideClick(
() => resolveAllowedContainers(),
(_event, target) => {
api.close()
nextTick(() => target?.focus())
Expand Down Expand Up @@ -249,9 +250,28 @@ export let Dialog = defineComponent({

if (isIOS()) {
let scrollPosition = window.pageYOffset
style(documentElement, 'position', 'fixed')
style(documentElement, 'marginTop', `-${scrollPosition}px`)
style(documentElement, 'width', `100%`)
style(document.body, 'marginTop', `-${scrollPosition}px`)
window.scrollTo(0, 0)

d.addEventListener(
owner,
'touchmove',
(e) => {
// Check if we are scrolling inside any of the allowed containers, if not let's cancel
// the event!
if (
e.target instanceof HTMLElement &&
!resolveAllowedContainers().some((container) =>
container.contains(e.target as HTMLElement)
)
) {
e.preventDefault()
}
},
{ passive: false }
)

// Restore scroll position
d.add(() => window.scrollTo(0, scrollPosition))
}

Expand Down
2 changes: 1 addition & 1 deletion packages/@headlessui-vue/src/utils/disposables.ts
Expand Up @@ -8,7 +8,7 @@ export function disposables() {
},

addEventListener<TEventName extends keyof WindowEventMap>(
element: HTMLElement | Document,
element: HTMLElement | Window | Document,
name: TEventName,
listener: (event: WindowEventMap[TEventName]) => any,
options?: boolean | AddEventListenerOptions
Expand Down

0 comments on commit 962528c

Please sign in to comment.