Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Disable smooth scrolling when opening/closing Dialog components on iOS #2635

Merged
merged 4 commits into from
Aug 2, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/@headlessui-react/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Use correct value when resetting `<Listbox multiple>` and `<Combobox multiple>` ([#2626](https://github.com/tailwindlabs/headlessui/pull/2626))
- Render `<MainTreeNode />` in `Popover.Group` component only ([#2634](https://github.com/tailwindlabs/headlessui/pull/2634))
- Disable smooth scrolling when opening/closing `Dialog` components on iOS ([#2635](https://github.com/tailwindlabs/headlessui/pull/2635))

## [1.7.16] - 2023-07-27

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { disposables } from '../../utils/disposables'
import { isIOS } from '../../utils/platform'
import { ScrollLockStep } from './overflow-store'

Expand All @@ -24,74 +25,91 @@ export function handleIOSLocking(): ScrollLockStep<ContainerMetadata> {
.some((container) => container.contains(el))
}

d.style(doc.body, 'marginTop', `-${scrollPosition}px`)
window.scrollTo(0, 0)
d.microTask(() => {
// We need to be able to offset the body with the current scroll position. However, if you
// have `scroll-behavior: smooth` set, then changing the scrollTop in any way shape or form
// will trigger a "smooth" scroll and the new position would be incorrect.
//
// This is why we are forcing the `scroll-behaviour: auto` here, and then restoring it later.
// We have to be a bit careful, because removing `scroll-behavior: auto` back to
// `scroll-behavior: smooth` can start triggering smooth scrolling. Delaying this by a
// microTask will guarantee that everything is done such that both enter/exit of the Dialog is
// not using smooth scrolling.
if (window.getComputedStyle(doc.documentElement).scrollBehavior !== 'auto') {
let _d = disposables()
_d.style(doc.documentElement, 'scroll-behavior', 'auto')
d.add(() => d.microTask(() => _d.dispose()))
}

// Relatively hacky, but if you click a link like `<a href="#foo">` in the Dialog, and there
// exists an element on the page (outside of the Dialog) with that id, then the browser will
// scroll to that position. However, this is not the case if the element we want to scroll to
// is higher and the browser needs to scroll up, but it doesn't do that.
//
// Let's try and capture that element and store it, so that we can later scroll to it once the
// Dialog closes.
let scrollToElement: HTMLElement | null = null
d.addEventListener(
doc,
'click',
(e) => {
if (!(e.target instanceof HTMLElement)) {
return
}
d.style(doc.body, 'marginTop', `-${scrollPosition}px`)
window.scrollTo(0, 0)

try {
let anchor = e.target.closest('a')
if (!anchor) return
let { hash } = new URL(anchor.href)
let el = doc.querySelector(hash)
if (el && !inAllowedContainer(el as HTMLElement)) {
scrollToElement = el as HTMLElement
// Relatively hacky, but if you click a link like `<a href="#foo">` in the Dialog, and there
// exists an element on the page (outside of the Dialog) with that id, then the browser will
// scroll to that position. However, this is not the case if the element we want to scroll to
// is higher and the browser needs to scroll up, but it doesn't do that.
//
// Let's try and capture that element and store it, so that we can later scroll to it once the
// Dialog closes.
let scrollToElement: HTMLElement | null = null
d.addEventListener(
doc,
'click',
(e) => {
if (!(e.target instanceof HTMLElement)) {
return
}
} catch (err) {}
},
true
)

d.addEventListener(
doc,
'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 && !inAllowedContainer(e.target as HTMLElement)) {
e.preventDefault()
}
},
{ passive: false }
)
try {
let anchor = e.target.closest('a')
if (!anchor) return
let { hash } = new URL(anchor.href)
let el = doc.querySelector(hash)
if (el && !inAllowedContainer(el as HTMLElement)) {
scrollToElement = el as HTMLElement
}
} catch (err) {}
},
true
)

// Restore scroll position
d.add(() => {
// Before opening the Dialog, we capture the current pageYOffset, and offset the page with
// this value so that we can also scroll to `(0, 0)`.
//
// If we want to restore a few things can happen:
//
// 1. The window.pageYOffset is still at 0, this means nothing happened, and we can safely
// restore to the captured value earlier.
// 2. The window.pageYOffset is **not** at 0. This means that something happened (e.g.: a
// link was scrolled into view in the background). Ideally we want to restore to this _new_
// position. To do this, we can take the new value into account with the captured value from
// before.
//
// (Since the value of window.pageYOffset is 0 in the first case, we should be able to
// always sum these values)
window.scrollTo(0, window.pageYOffset + scrollPosition)
d.addEventListener(
doc,
'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 && !inAllowedContainer(e.target as HTMLElement)) {
e.preventDefault()
}
},
{ passive: false }
)

// If we captured an element that should be scrolled to, then we can try to do that if the
// element is still connected (aka, still in the DOM).
if (scrollToElement && scrollToElement.isConnected) {
scrollToElement.scrollIntoView({ block: 'nearest' })
scrollToElement = null
}
// Restore scroll position
d.add(() => {
// Before opening the Dialog, we capture the current pageYOffset, and offset the page with
// this value so that we can also scroll to `(0, 0)`.
//
// If we want to restore a few things can happen:
//
// 1. The window.pageYOffset is still at 0, this means nothing happened, and we can safely
// restore to the captured value earlier.
// 2. The window.pageYOffset is **not** at 0. This means that something happened (e.g.: a
// link was scrolled into view in the background). Ideally we want to restore to this _new_
// position. To do this, we can take the new value into account with the captured value from
// before.
//
// (Since the value of window.pageYOffset is 0 in the first case, we should be able to
// always sum these values)
window.scrollTo(0, window.pageYOffset + scrollPosition)

// If we captured an element that should be scrolled to, then we can try to do that if the
// element is still connected (aka, still in the DOM).
if (scrollToElement && scrollToElement.isConnected) {
scrollToElement.scrollIntoView({ block: 'nearest' })
scrollToElement = null
}
})
})
},
}
Expand Down
1 change: 1 addition & 0 deletions packages/@headlessui-vue/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fix form elements for uncontrolled `<Listbox multiple>` and `<Combobox multiple>` ([#2626](https://github.com/tailwindlabs/headlessui/pull/2626))
- Use correct value when resetting `<Listbox multiple>` and `<Combobox multiple>` ([#2626](https://github.com/tailwindlabs/headlessui/pull/2626))
- Render `<MainTreeNode />` in `PopoverGroup` component only ([#2634](https://github.com/tailwindlabs/headlessui/pull/2634))
- Disable smooth scrolling when opening/closing `Dialog` components on iOS ([#2635](https://github.com/tailwindlabs/headlessui/pull/2635))

## [1.7.15] - 2023-07-27

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { disposables } from '../../utils/disposables'
import { isIOS } from '../../utils/platform'
import { ScrollLockStep } from './overflow-store'

Expand All @@ -24,6 +25,21 @@ export function handleIOSLocking(): ScrollLockStep<ContainerMetadata> {
.some((container) => container.contains(el))
}

// We need to be able to offset the body with the current scroll position. However, if you
// have `scroll-behavior: smooth` set, then changing the scrollTop in any way shape or form
// will trigger a "smooth" scroll and the new position would be incorrect.
//
// This is why we are forcing the `scroll-behaviour: auto` here, and then restoring it later.
// We have to be a bit careful, because removing `scroll-behavior: auto` back to
// `scroll-behavior: smooth` can start triggering smooth scrolling. Delaying this by a
// microTask will guarantee that everything is done such that both enter/exit of the Dialog is
// not using smooth scrolling.
if (window.getComputedStyle(doc.documentElement).scrollBehavior !== 'auto') {
let _d = disposables()
_d.style(doc.documentElement, 'scroll-behavior', 'auto')
d.add(() => d.microTask(() => _d.dispose()))
}

d.style(doc.body, 'marginTop', `-${scrollPosition}px`)
window.scrollTo(0, 0)

Expand Down
14 changes: 14 additions & 0 deletions packages/@headlessui-vue/src/utils/disposables.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { microTask } from './micro-task'

export type Disposables = ReturnType<typeof disposables>

export function disposables() {
Expand Down Expand Up @@ -30,6 +32,18 @@ export function disposables() {
api.add(() => clearTimeout(timer))
},

microTask(...args: Parameters<typeof microTask>) {
let task = { current: true }
microTask(() => {
if (task.current) {
args[0]()
}
})
return api.add(() => {
task.current = false
})
},

style(node: HTMLElement, property: string, value: string) {
let previous = node.style.getPropertyValue(property)
Object.assign(node.style, { [property]: value })
Expand Down