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

Improve scroll lock on iOS #1824

Merged
merged 3 commits into from Sep 5, 2022
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
Expand Up @@ -38,6 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Improve accessibility when announcing `Listbox.Option` and `Combobox.Option` components ([#1812](https://github.com/tailwindlabs/headlessui/pull/1812))
- Fix `ref` stealing from children ([#1820](https://github.com/tailwindlabs/headlessui/pull/1820))
- Expose the `value` from the `Combobox` and `Listbox` components render prop ([#1822](https://github.com/tailwindlabs/headlessui/pull/1822))
- Improve `scroll lock` on iOS ([#1824](https://github.com/tailwindlabs/headlessui/pull/1824))

## [1.6.6] - 2022-07-07

Expand Down
76 changes: 48 additions & 28 deletions packages/@headlessui-react/src/components/dialog/dialog.tsx
Expand Up @@ -33,11 +33,12 @@ import { useOpenClosed, State } from '../../internal/open-closed'
import { useServerHandoffComplete } from '../../hooks/use-server-handoff-complete'
import { StackProvider, StackMessage } from '../../internal/stack-context'
import { useOutsideClick } from '../../hooks/use-outside-click'
import { getOwnerDocument } from '../../utils/owner'
import { useOwnerDocument } from '../../hooks/use-owner'
import { useEventListener } from '../../hooks/use-event-listener'
import { Hidden, Features as HiddenFeatures } from '../../internal/hidden'
import { useEvent } from '../../hooks/use-event'
import { disposables } from '../../utils/disposables'
import { isIOS } from '../../utils/platform'

enum DialogStates {
Open,
Expand Down Expand Up @@ -90,6 +91,51 @@ function useDialogContext(component: string) {
return context
}

function useScrollLock(ownerDocument: Document | null, enabled: boolean) {
useEffect(() => {
if (!enabled) return
if (!ownerDocument) return

let d = disposables()

function style(node: HTMLElement, property: string, value: string) {
let previous = node.style.getPropertyValue(property)
Object.assign(node.style, { [property]: value })
return d.add(() => {
Object.assign(node.style, { [property]: previous })
})
}

let documentElement = ownerDocument.documentElement
let ownerWindow = ownerDocument.defaultView ?? window

let scrollbarWidthBefore = ownerWindow.innerWidth - documentElement.clientWidth
style(documentElement, 'overflow', 'hidden')

if (scrollbarWidthBefore > 0) {
let scrollbarWidthAfter = documentElement.clientWidth - documentElement.offsetWidth
let scrollbarWidth = scrollbarWidthBefore - scrollbarWidthAfter
style(documentElement, 'paddingRight', `${scrollbarWidth}px`)
}

if (isIOS()) {
d.addEventListener(
ownerDocument,
'touchmove',
(e) => {
e.preventDefault()
},
{ passive: false }
)

let scrollPosition = window.pageYOffset
d.add(() => window.scrollTo(0, scrollPosition))
}

return d.dispose
}, [ownerDocument, enabled])
}

function stateReducer(state: StateDefinition, action: Actions) {
return match(action.type, reducers, state, action)
}
Expand Down Expand Up @@ -228,33 +274,7 @@ let DialogRoot = forwardRefWithAs(function Dialog<
})

// Scroll lock
useEffect(() => {
if (dialogState !== DialogStates.Open) return
if (hasParentDialog) return

let ownerDocument = getOwnerDocument(internalDialogRef)
if (!ownerDocument) return

let documentElement = ownerDocument.documentElement
let ownerWindow = ownerDocument.defaultView ?? window

let overflow = documentElement.style.overflow
let paddingRight = documentElement.style.paddingRight

let scrollbarWidthBefore = ownerWindow.innerWidth - documentElement.clientWidth
documentElement.style.overflow = 'hidden'

if (scrollbarWidthBefore > 0) {
let scrollbarWidthAfter = documentElement.clientWidth - documentElement.offsetWidth
let scrollbarWidth = scrollbarWidthBefore - scrollbarWidthAfter
documentElement.style.paddingRight = `${scrollbarWidth}px`
}

return () => {
documentElement.style.overflow = overflow
documentElement.style.paddingRight = paddingRight
}
}, [dialogState, hasParentDialog])
useScrollLock(ownerDocument, dialogState === DialogStates.Open && !hasParentDialog)

// 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 @@ -8,7 +8,7 @@ export function disposables() {
},

addEventListener<TEventName extends keyof WindowEventMap>(
element: HTMLElement,
element: HTMLElement | Document,
name: TEventName,
listener: (event: WindowEventMap[TEventName]) => any,
options?: boolean | AddEventListenerOptions
Expand Down
14 changes: 14 additions & 0 deletions packages/@headlessui-react/src/utils/platform.ts
@@ -0,0 +1,14 @@
export function isIOS() {
// TODO: This is not a great way to detect iOS, but it's the best I can do for now.
// - `window.platform` is deprecated
// - `window.userAgentData.platform` is still experimental (https://developer.mozilla.org/en-US/docs/Web/API/NavigatorUAData/platform)
// - `window.userAgent` also doesn't contain the required information
return (
// Check if it is an iPhone
/iPhone/gi.test(window.navigator.platform) ||
// Check if it is an iPad. iPad reports itself as "MacIntel", but we can check if it is a touch
// screen. Let's hope that Apple doesn't release a touch screen Mac (or maybe this would then
// work as expected 🤔).
(/Mac/gi.test(window.navigator.platform) && window.navigator.maxTouchPoints > 0)
)
}
1 change: 1 addition & 0 deletions packages/@headlessui-vue/CHANGELOG.md
Expand Up @@ -32,6 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Don't scroll when wrapping around in focus trap ([#1789](https://github.com/tailwindlabs/headlessui/pull/1789))
- Improve accessibility when announcing `ListboxOption` and `ComboboxOption` components ([#1812](https://github.com/tailwindlabs/headlessui/pull/1812))
- Expose the `value` from the `Combobox` and `Listbox` components slot ([#1822](https://github.com/tailwindlabs/headlessui/pull/1822))
- Improve `scroll lock` on iOS ([#1824](https://github.com/tailwindlabs/headlessui/pull/1824))

## [1.6.7] - 2022-07-12

Expand Down
38 changes: 29 additions & 9 deletions packages/@headlessui-vue/src/components/dialog/dialog.ts
Expand Up @@ -33,6 +33,8 @@ import { useOutsideClick } from '../../hooks/use-outside-click'
import { getOwnerDocument } from '../../utils/owner'
import { useEventListener } from '../../hooks/use-event-listener'
import { Hidden, Features as HiddenFeatures } from '../../internal/hidden'
import { disposables } from '../../utils/disposables'
import { isIOS } from '../../utils/platform'

enum DialogStates {
Open,
Expand Down Expand Up @@ -224,25 +226,43 @@ export let Dialog = defineComponent({
let owner = ownerDocument.value
if (!owner) return

let d = disposables()

function style(node: HTMLElement, property: string, value: string) {
let previous = node.style.getPropertyValue(property)
Object.assign(node.style, { [property]: value })
return d.add(() => {
Object.assign(node.style, { [property]: previous })
})
}

let documentElement = owner?.documentElement
let ownerWindow = owner.defaultView ?? window

let overflow = documentElement.style.overflow
let paddingRight = documentElement.style.paddingRight

let scrollbarWidthBefore = ownerWindow.innerWidth - documentElement.clientWidth
documentElement.style.overflow = 'hidden'
style(documentElement, 'overflow', 'hidden')

if (scrollbarWidthBefore > 0) {
let scrollbarWidthAfter = documentElement.clientWidth - documentElement.offsetWidth
let scrollbarWidth = scrollbarWidthBefore - scrollbarWidthAfter
documentElement.style.paddingRight = `${scrollbarWidth}px`
style(documentElement, 'paddingRight', `${scrollbarWidth}px`)
}

onInvalidate(() => {
documentElement.style.overflow = overflow
documentElement.style.paddingRight = paddingRight
})
if (isIOS()) {
d.addEventListener(
owner,
'touchmove',
(e) => {
e.preventDefault()
},
{ passive: false }
)

let scrollPosition = window.pageYOffset
d.add(() => window.scrollTo(0, scrollPosition))
}

onInvalidate(d.dispose)
})

// Trigger close when the FocusTrap gets hidden
Expand Down
10 changes: 10 additions & 0 deletions packages/@headlessui-vue/src/utils/disposables.ts
Expand Up @@ -7,6 +7,16 @@ export function disposables() {
queue.push(fn)
},

addEventListener<TEventName extends keyof WindowEventMap>(
element: HTMLElement | Document,
name: TEventName,
listener: (event: WindowEventMap[TEventName]) => any,
options?: boolean | AddEventListenerOptions
) {
element.addEventListener(name, listener as any, options)
return api.add(() => element.removeEventListener(name, listener as any, options))
},

requestAnimationFrame(...args: Parameters<typeof requestAnimationFrame>) {
let raf = requestAnimationFrame(...args)
api.add(() => cancelAnimationFrame(raf))
Expand Down
14 changes: 14 additions & 0 deletions packages/@headlessui-vue/src/utils/platform.ts
@@ -0,0 +1,14 @@
export function isIOS() {
// TODO: This is not a great way to detect iOS, but it's the best I can do for now.
// - `window.platform` is deprecated
// - `window.userAgentData.platform` is still experimental (https://developer.mozilla.org/en-US/docs/Web/API/NavigatorUAData/platform)
// - `window.userAgent` also doesn't contain the required information
return (
// Check if it is an iPhone
/iPhone/gi.test(window.navigator.platform) ||
// Check if it is an iPad. iPad reports itself as "MacIntel", but we can check if it is a touch
// screen. Let's hope that Apple doesn't release a touch screen Mac (or maybe this would then
// work as expected 🤔).
(/Mac/gi.test(window.navigator.platform) && window.navigator.maxTouchPoints > 0)
)
}
152 changes: 152 additions & 0 deletions packages/playground-react/pages/dialog/scrollable-page-with-dialog.tsx
@@ -0,0 +1,152 @@
import React, { useState, Fragment } from 'react'
import { Dialog, Transition } from '@headlessui/react'

export default function Home() {
let [isOpen, setIsOpen] = useState(false)

return (
<>
{Array(5)
.fill(null)
.map((_, i) => (
<p key={i} className="m-4">
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam numquam beatae,
maiores sint est perferendis molestiae deleniti dolorem, illum vel, quam atque facilis!
Necessitatibus nostrum recusandae nemo corrupti, odio eius?
</p>
))}

<button
type="button"
onClick={() => setIsOpen((v) => !v)}
className="focus:shadow-outline-blue m-12 rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5"
>
Toggle!
</button>

{Array(20)
.fill(null)
.map((_, i) => (
<p key={i} className="m-4">
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam numquam beatae,
maiores sint est perferendis molestiae deleniti dolorem, illum vel, quam atque facilis!
Necessitatibus nostrum recusandae nemo corrupti, odio eius?
</p>
))}

<Transition
data-debug="Dialog"
show={isOpen}
as={Fragment}
beforeEnter={() => console.log('[Transition] Before enter')}
afterEnter={() => console.log('[Transition] After enter')}
beforeLeave={() => console.log('[Transition] Before leave')}
afterLeave={() => console.log('[Transition] After leave')}
>
<Dialog
onClose={() => {
console.log('close')
setIsOpen(false)
}}
>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-screen items-end justify-center px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-75"
leave="ease-in duration-200"
leaveFrom="opacity-75"
leaveTo="opacity-0"
entered="opacity-75"
beforeEnter={() => console.log('[Transition.Child] [Overlay] Before enter')}
afterEnter={() => console.log('[Transition.Child] [Overlay] After enter')}
beforeLeave={() => console.log('[Transition.Child] [Overlay] Before leave')}
afterLeave={() => console.log('[Transition.Child] [Overlay] After leave')}
>
<div className="fixed inset-0 bg-gray-500 transition-opacity" />
</Transition.Child>

<Transition.Child
enter="ease-out transform duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in transform duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
beforeEnter={() => console.log('[Transition.Child] [Panel] Before enter')}
afterEnter={() => console.log('[Transition.Child] [Panel] After enter')}
beforeLeave={() => console.log('[Transition.Child] [Panel] Before leave')}
afterLeave={() => console.log('[Transition.Child] [Panel] After leave')}
>
{/* This element is to trick the browser into centering the modal contents. */}
<span
className="hidden sm:inline-block sm:h-screen sm:align-middle"
aria-hidden="true"
>
&#8203;
</span>
<Dialog.Panel className="inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle">
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<div className="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
{/* Heroicon name: exclamation */}
<svg
className="h-6 w-6 text-red-600"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<Dialog.Title
as="h3"
className="text-lg font-medium leading-6 text-gray-900"
>
Deactivate account
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-gray-500">
Are you sure you want to deactivate your account? All of your data will
be permanently removed. This action cannot be undone.
</p>
</div>
<input type="text" />
</div>
</div>
</div>
<div className="bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6">
<button
type="button"
onClick={() => setIsOpen(false)}
className="focus:shadow-outline-red inline-flex w-full justify-center rounded-md border border-transparent bg-red-600 px-4 py-2 text-base font-medium text-white shadow-sm hover:bg-red-700 focus:outline-none sm:ml-3 sm:w-auto sm:text-sm"
>
Deactivate
</button>
<button
type="button"
onClick={() => setIsOpen(false)}
className="focus:shadow-outline-indigo mt-3 inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium text-gray-700 shadow-sm hover:text-gray-500 focus:outline-none sm:mt-0 sm:w-auto sm:text-sm"
>
Cancel
</button>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition>
</>
)
}