Skip to content

Commit

Permalink
Improve scroll lock on iOS (#1824)
Browse files Browse the repository at this point in the history
* improve `Dialog` scroll lock on iOS

* add Dialog example to playground that's scrollable

* update changelog
  • Loading branch information
RobinMalfait committed Sep 5, 2022
1 parent 1fd8cfc commit 25a4e7f
Show file tree
Hide file tree
Showing 10 changed files with 282 additions and 38 deletions.
1 change: 1 addition & 0 deletions packages/@headlessui-react/CHANGELOG.md
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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>
</>
)
}
Loading

0 comments on commit 25a4e7f

Please sign in to comment.