Skip to content

Commit 25a4e7f

Browse files
authored
Improve scroll lock on iOS (#1824)
* improve `Dialog` scroll lock on iOS * add Dialog example to playground that's scrollable * update changelog
1 parent 1fd8cfc commit 25a4e7f

File tree

10 files changed

+282
-38
lines changed

10 files changed

+282
-38
lines changed

packages/@headlessui-react/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3838
- Improve accessibility when announcing `Listbox.Option` and `Combobox.Option` components ([#1812](https://github.com/tailwindlabs/headlessui/pull/1812))
3939
- Fix `ref` stealing from children ([#1820](https://github.com/tailwindlabs/headlessui/pull/1820))
4040
- Expose the `value` from the `Combobox` and `Listbox` components render prop ([#1822](https://github.com/tailwindlabs/headlessui/pull/1822))
41+
- Improve `scroll lock` on iOS ([#1824](https://github.com/tailwindlabs/headlessui/pull/1824))
4142

4243
## [1.6.6] - 2022-07-07
4344

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

Lines changed: 48 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,12 @@ import { useOpenClosed, State } from '../../internal/open-closed'
3333
import { useServerHandoffComplete } from '../../hooks/use-server-handoff-complete'
3434
import { StackProvider, StackMessage } from '../../internal/stack-context'
3535
import { useOutsideClick } from '../../hooks/use-outside-click'
36-
import { getOwnerDocument } from '../../utils/owner'
3736
import { useOwnerDocument } from '../../hooks/use-owner'
3837
import { useEventListener } from '../../hooks/use-event-listener'
3938
import { Hidden, Features as HiddenFeatures } from '../../internal/hidden'
4039
import { useEvent } from '../../hooks/use-event'
40+
import { disposables } from '../../utils/disposables'
41+
import { isIOS } from '../../utils/platform'
4142

4243
enum DialogStates {
4344
Open,
@@ -90,6 +91,51 @@ function useDialogContext(component: string) {
9091
return context
9192
}
9293

94+
function useScrollLock(ownerDocument: Document | null, enabled: boolean) {
95+
useEffect(() => {
96+
if (!enabled) return
97+
if (!ownerDocument) return
98+
99+
let d = disposables()
100+
101+
function style(node: HTMLElement, property: string, value: string) {
102+
let previous = node.style.getPropertyValue(property)
103+
Object.assign(node.style, { [property]: value })
104+
return d.add(() => {
105+
Object.assign(node.style, { [property]: previous })
106+
})
107+
}
108+
109+
let documentElement = ownerDocument.documentElement
110+
let ownerWindow = ownerDocument.defaultView ?? window
111+
112+
let scrollbarWidthBefore = ownerWindow.innerWidth - documentElement.clientWidth
113+
style(documentElement, 'overflow', 'hidden')
114+
115+
if (scrollbarWidthBefore > 0) {
116+
let scrollbarWidthAfter = documentElement.clientWidth - documentElement.offsetWidth
117+
let scrollbarWidth = scrollbarWidthBefore - scrollbarWidthAfter
118+
style(documentElement, 'paddingRight', `${scrollbarWidth}px`)
119+
}
120+
121+
if (isIOS()) {
122+
d.addEventListener(
123+
ownerDocument,
124+
'touchmove',
125+
(e) => {
126+
e.preventDefault()
127+
},
128+
{ passive: false }
129+
)
130+
131+
let scrollPosition = window.pageYOffset
132+
d.add(() => window.scrollTo(0, scrollPosition))
133+
}
134+
135+
return d.dispose
136+
}, [ownerDocument, enabled])
137+
}
138+
93139
function stateReducer(state: StateDefinition, action: Actions) {
94140
return match(action.type, reducers, state, action)
95141
}
@@ -228,33 +274,7 @@ let DialogRoot = forwardRefWithAs(function Dialog<
228274
})
229275

230276
// Scroll lock
231-
useEffect(() => {
232-
if (dialogState !== DialogStates.Open) return
233-
if (hasParentDialog) return
234-
235-
let ownerDocument = getOwnerDocument(internalDialogRef)
236-
if (!ownerDocument) return
237-
238-
let documentElement = ownerDocument.documentElement
239-
let ownerWindow = ownerDocument.defaultView ?? window
240-
241-
let overflow = documentElement.style.overflow
242-
let paddingRight = documentElement.style.paddingRight
243-
244-
let scrollbarWidthBefore = ownerWindow.innerWidth - documentElement.clientWidth
245-
documentElement.style.overflow = 'hidden'
246-
247-
if (scrollbarWidthBefore > 0) {
248-
let scrollbarWidthAfter = documentElement.clientWidth - documentElement.offsetWidth
249-
let scrollbarWidth = scrollbarWidthBefore - scrollbarWidthAfter
250-
documentElement.style.paddingRight = `${scrollbarWidth}px`
251-
}
252-
253-
return () => {
254-
documentElement.style.overflow = overflow
255-
documentElement.style.paddingRight = paddingRight
256-
}
257-
}, [dialogState, hasParentDialog])
277+
useScrollLock(ownerDocument, dialogState === DialogStates.Open && !hasParentDialog)
258278

259279
// Trigger close when the FocusTrap gets hidden
260280
useEffect(() => {

packages/@headlessui-react/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,
11+
element: HTMLElement | Document,
1212
name: TEventName,
1313
listener: (event: WindowEventMap[TEventName]) => any,
1414
options?: boolean | AddEventListenerOptions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
export function isIOS() {
2+
// TODO: This is not a great way to detect iOS, but it's the best I can do for now.
3+
// - `window.platform` is deprecated
4+
// - `window.userAgentData.platform` is still experimental (https://developer.mozilla.org/en-US/docs/Web/API/NavigatorUAData/platform)
5+
// - `window.userAgent` also doesn't contain the required information
6+
return (
7+
// Check if it is an iPhone
8+
/iPhone/gi.test(window.navigator.platform) ||
9+
// Check if it is an iPad. iPad reports itself as "MacIntel", but we can check if it is a touch
10+
// screen. Let's hope that Apple doesn't release a touch screen Mac (or maybe this would then
11+
// work as expected 🤔).
12+
(/Mac/gi.test(window.navigator.platform) && window.navigator.maxTouchPoints > 0)
13+
)
14+
}

packages/@headlessui-vue/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3232
- Don't scroll when wrapping around in focus trap ([#1789](https://github.com/tailwindlabs/headlessui/pull/1789))
3333
- Improve accessibility when announcing `ListboxOption` and `ComboboxOption` components ([#1812](https://github.com/tailwindlabs/headlessui/pull/1812))
3434
- Expose the `value` from the `Combobox` and `Listbox` components slot ([#1822](https://github.com/tailwindlabs/headlessui/pull/1822))
35+
- Improve `scroll lock` on iOS ([#1824](https://github.com/tailwindlabs/headlessui/pull/1824))
3536

3637
## [1.6.7] - 2022-07-12
3738

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

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ import { useOutsideClick } from '../../hooks/use-outside-click'
3333
import { getOwnerDocument } from '../../utils/owner'
3434
import { useEventListener } from '../../hooks/use-event-listener'
3535
import { Hidden, Features as HiddenFeatures } from '../../internal/hidden'
36+
import { disposables } from '../../utils/disposables'
37+
import { isIOS } from '../../utils/platform'
3638

3739
enum DialogStates {
3840
Open,
@@ -224,25 +226,43 @@ export let Dialog = defineComponent({
224226
let owner = ownerDocument.value
225227
if (!owner) return
226228

229+
let d = disposables()
230+
231+
function style(node: HTMLElement, property: string, value: string) {
232+
let previous = node.style.getPropertyValue(property)
233+
Object.assign(node.style, { [property]: value })
234+
return d.add(() => {
235+
Object.assign(node.style, { [property]: previous })
236+
})
237+
}
238+
227239
let documentElement = owner?.documentElement
228240
let ownerWindow = owner.defaultView ?? window
229241

230-
let overflow = documentElement.style.overflow
231-
let paddingRight = documentElement.style.paddingRight
232-
233242
let scrollbarWidthBefore = ownerWindow.innerWidth - documentElement.clientWidth
234-
documentElement.style.overflow = 'hidden'
243+
style(documentElement, 'overflow', 'hidden')
235244

236245
if (scrollbarWidthBefore > 0) {
237246
let scrollbarWidthAfter = documentElement.clientWidth - documentElement.offsetWidth
238247
let scrollbarWidth = scrollbarWidthBefore - scrollbarWidthAfter
239-
documentElement.style.paddingRight = `${scrollbarWidth}px`
248+
style(documentElement, 'paddingRight', `${scrollbarWidth}px`)
240249
}
241250

242-
onInvalidate(() => {
243-
documentElement.style.overflow = overflow
244-
documentElement.style.paddingRight = paddingRight
245-
})
251+
if (isIOS()) {
252+
d.addEventListener(
253+
owner,
254+
'touchmove',
255+
(e) => {
256+
e.preventDefault()
257+
},
258+
{ passive: false }
259+
)
260+
261+
let scrollPosition = window.pageYOffset
262+
d.add(() => window.scrollTo(0, scrollPosition))
263+
}
264+
265+
onInvalidate(d.dispose)
246266
})
247267

248268
// Trigger close when the FocusTrap gets hidden

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,16 @@ export function disposables() {
77
queue.push(fn)
88
},
99

10+
addEventListener<TEventName extends keyof WindowEventMap>(
11+
element: HTMLElement | Document,
12+
name: TEventName,
13+
listener: (event: WindowEventMap[TEventName]) => any,
14+
options?: boolean | AddEventListenerOptions
15+
) {
16+
element.addEventListener(name, listener as any, options)
17+
return api.add(() => element.removeEventListener(name, listener as any, options))
18+
},
19+
1020
requestAnimationFrame(...args: Parameters<typeof requestAnimationFrame>) {
1121
let raf = requestAnimationFrame(...args)
1222
api.add(() => cancelAnimationFrame(raf))
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
export function isIOS() {
2+
// TODO: This is not a great way to detect iOS, but it's the best I can do for now.
3+
// - `window.platform` is deprecated
4+
// - `window.userAgentData.platform` is still experimental (https://developer.mozilla.org/en-US/docs/Web/API/NavigatorUAData/platform)
5+
// - `window.userAgent` also doesn't contain the required information
6+
return (
7+
// Check if it is an iPhone
8+
/iPhone/gi.test(window.navigator.platform) ||
9+
// Check if it is an iPad. iPad reports itself as "MacIntel", but we can check if it is a touch
10+
// screen. Let's hope that Apple doesn't release a touch screen Mac (or maybe this would then
11+
// work as expected 🤔).
12+
(/Mac/gi.test(window.navigator.platform) && window.navigator.maxTouchPoints > 0)
13+
)
14+
}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import React, { useState, Fragment } from 'react'
2+
import { Dialog, Transition } from '@headlessui/react'
3+
4+
export default function Home() {
5+
let [isOpen, setIsOpen] = useState(false)
6+
7+
return (
8+
<>
9+
{Array(5)
10+
.fill(null)
11+
.map((_, i) => (
12+
<p key={i} className="m-4">
13+
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam numquam beatae,
14+
maiores sint est perferendis molestiae deleniti dolorem, illum vel, quam atque facilis!
15+
Necessitatibus nostrum recusandae nemo corrupti, odio eius?
16+
</p>
17+
))}
18+
19+
<button
20+
type="button"
21+
onClick={() => setIsOpen((v) => !v)}
22+
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"
23+
>
24+
Toggle!
25+
</button>
26+
27+
{Array(20)
28+
.fill(null)
29+
.map((_, i) => (
30+
<p key={i} className="m-4">
31+
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam numquam beatae,
32+
maiores sint est perferendis molestiae deleniti dolorem, illum vel, quam atque facilis!
33+
Necessitatibus nostrum recusandae nemo corrupti, odio eius?
34+
</p>
35+
))}
36+
37+
<Transition
38+
data-debug="Dialog"
39+
show={isOpen}
40+
as={Fragment}
41+
beforeEnter={() => console.log('[Transition] Before enter')}
42+
afterEnter={() => console.log('[Transition] After enter')}
43+
beforeLeave={() => console.log('[Transition] Before leave')}
44+
afterLeave={() => console.log('[Transition] After leave')}
45+
>
46+
<Dialog
47+
onClose={() => {
48+
console.log('close')
49+
setIsOpen(false)
50+
}}
51+
>
52+
<div className="fixed inset-0 z-10 overflow-y-auto">
53+
<div className="flex min-h-screen items-end justify-center px-4 pt-4 pb-20 text-center sm:block sm:p-0">
54+
<Transition.Child
55+
as={Fragment}
56+
enter="ease-out duration-300"
57+
enterFrom="opacity-0"
58+
enterTo="opacity-75"
59+
leave="ease-in duration-200"
60+
leaveFrom="opacity-75"
61+
leaveTo="opacity-0"
62+
entered="opacity-75"
63+
beforeEnter={() => console.log('[Transition.Child] [Overlay] Before enter')}
64+
afterEnter={() => console.log('[Transition.Child] [Overlay] After enter')}
65+
beforeLeave={() => console.log('[Transition.Child] [Overlay] Before leave')}
66+
afterLeave={() => console.log('[Transition.Child] [Overlay] After leave')}
67+
>
68+
<div className="fixed inset-0 bg-gray-500 transition-opacity" />
69+
</Transition.Child>
70+
71+
<Transition.Child
72+
enter="ease-out transform duration-300"
73+
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
74+
enterTo="opacity-100 translate-y-0 sm:scale-100"
75+
leave="ease-in transform duration-200"
76+
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
77+
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
78+
beforeEnter={() => console.log('[Transition.Child] [Panel] Before enter')}
79+
afterEnter={() => console.log('[Transition.Child] [Panel] After enter')}
80+
beforeLeave={() => console.log('[Transition.Child] [Panel] Before leave')}
81+
afterLeave={() => console.log('[Transition.Child] [Panel] After leave')}
82+
>
83+
{/* This element is to trick the browser into centering the modal contents. */}
84+
<span
85+
className="hidden sm:inline-block sm:h-screen sm:align-middle"
86+
aria-hidden="true"
87+
>
88+
&#8203;
89+
</span>
90+
<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">
91+
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
92+
<div className="sm:flex sm:items-start">
93+
<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">
94+
{/* Heroicon name: exclamation */}
95+
<svg
96+
className="h-6 w-6 text-red-600"
97+
xmlns="http://www.w3.org/2000/svg"
98+
fill="none"
99+
viewBox="0 0 24 24"
100+
stroke="currentColor"
101+
aria-hidden="true"
102+
>
103+
<path
104+
strokeLinecap="round"
105+
strokeLinejoin="round"
106+
strokeWidth={2}
107+
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"
108+
/>
109+
</svg>
110+
</div>
111+
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
112+
<Dialog.Title
113+
as="h3"
114+
className="text-lg font-medium leading-6 text-gray-900"
115+
>
116+
Deactivate account
117+
</Dialog.Title>
118+
<div className="mt-2">
119+
<p className="text-sm text-gray-500">
120+
Are you sure you want to deactivate your account? All of your data will
121+
be permanently removed. This action cannot be undone.
122+
</p>
123+
</div>
124+
<input type="text" />
125+
</div>
126+
</div>
127+
</div>
128+
<div className="bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6">
129+
<button
130+
type="button"
131+
onClick={() => setIsOpen(false)}
132+
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"
133+
>
134+
Deactivate
135+
</button>
136+
<button
137+
type="button"
138+
onClick={() => setIsOpen(false)}
139+
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"
140+
>
141+
Cancel
142+
</button>
143+
</div>
144+
</Dialog.Panel>
145+
</Transition.Child>
146+
</div>
147+
</div>
148+
</Dialog>
149+
</Transition>
150+
</>
151+
)
152+
}

0 commit comments

Comments
 (0)