diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md index b27cc57231..fb18d70042 100644 --- a/packages/@headlessui-react/CHANGELOG.md +++ b/packages/@headlessui-react/CHANGELOG.md @@ -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 diff --git a/packages/@headlessui-react/src/components/dialog/dialog.tsx b/packages/@headlessui-react/src/components/dialog/dialog.tsx index db16df7e90..fa7255b4b1 100644 --- a/packages/@headlessui-react/src/components/dialog/dialog.tsx +++ b/packages/@headlessui-react/src/components/dialog/dialog.tsx @@ -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, @@ -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) } @@ -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(() => { diff --git a/packages/@headlessui-react/src/utils/disposables.ts b/packages/@headlessui-react/src/utils/disposables.ts index 972c266187..0e3a7558a6 100644 --- a/packages/@headlessui-react/src/utils/disposables.ts +++ b/packages/@headlessui-react/src/utils/disposables.ts @@ -8,7 +8,7 @@ export function disposables() { }, addEventListener( - element: HTMLElement, + element: HTMLElement | Document, name: TEventName, listener: (event: WindowEventMap[TEventName]) => any, options?: boolean | AddEventListenerOptions diff --git a/packages/@headlessui-react/src/utils/platform.ts b/packages/@headlessui-react/src/utils/platform.ts new file mode 100644 index 0000000000..8d3183de15 --- /dev/null +++ b/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) + ) +} diff --git a/packages/@headlessui-vue/CHANGELOG.md b/packages/@headlessui-vue/CHANGELOG.md index 12ec43a78a..283040bb4b 100644 --- a/packages/@headlessui-vue/CHANGELOG.md +++ b/packages/@headlessui-vue/CHANGELOG.md @@ -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 diff --git a/packages/@headlessui-vue/src/components/dialog/dialog.ts b/packages/@headlessui-vue/src/components/dialog/dialog.ts index 1a606227fb..7f21c7840b 100644 --- a/packages/@headlessui-vue/src/components/dialog/dialog.ts +++ b/packages/@headlessui-vue/src/components/dialog/dialog.ts @@ -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, @@ -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 diff --git a/packages/@headlessui-vue/src/utils/disposables.ts b/packages/@headlessui-vue/src/utils/disposables.ts index 4c0f89c0f8..8e81cafe32 100644 --- a/packages/@headlessui-vue/src/utils/disposables.ts +++ b/packages/@headlessui-vue/src/utils/disposables.ts @@ -7,6 +7,16 @@ export function disposables() { queue.push(fn) }, + addEventListener( + 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) { let raf = requestAnimationFrame(...args) api.add(() => cancelAnimationFrame(raf)) diff --git a/packages/@headlessui-vue/src/utils/platform.ts b/packages/@headlessui-vue/src/utils/platform.ts new file mode 100644 index 0000000000..8d3183de15 --- /dev/null +++ b/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) + ) +} diff --git a/packages/playground-react/pages/dialog/scrollable-page-with-dialog.tsx b/packages/playground-react/pages/dialog/scrollable-page-with-dialog.tsx new file mode 100644 index 0000000000..c03cf5ca33 --- /dev/null +++ b/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) => ( +

+ 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? +

+ ))} + + + + {Array(20) + .fill(null) + .map((_, i) => ( +

+ 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? +

+ ))} + + console.log('[Transition] Before enter')} + afterEnter={() => console.log('[Transition] After enter')} + beforeLeave={() => console.log('[Transition] Before leave')} + afterLeave={() => console.log('[Transition] After leave')} + > + { + console.log('close') + setIsOpen(false) + }} + > +
+
+ 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')} + > +
+ + + 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. */} + + +
+
+
+ {/* Heroicon name: exclamation */} + +
+
+ + Deactivate account + +
+

+ Are you sure you want to deactivate your account? All of your data will + be permanently removed. This action cannot be undone. +

+
+ +
+
+
+
+ + +
+
+
+
+
+
+
+ + ) +} diff --git a/packages/playground-vue/src/components/dialog/dialog.vue b/packages/playground-vue/src/components/dialog/dialog.vue index 555ddbf051..6be2b7cc62 100644 --- a/packages/playground-vue/src/components/dialog/dialog.vue +++ b/packages/playground-vue/src/components/dialog/dialog.vue @@ -1,4 +1,16 @@