diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md index f96c0f13af..8a1f8cba7c 100644 --- a/packages/@headlessui-react/CHANGELOG.md +++ b/packages/@headlessui-react/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fix regression where `displayValue` crashes ([#2087](https://github.com/tailwindlabs/headlessui/pull/2087)) - Fix `displayValue` syncing when `Combobox.Input` is unmounted and re-mounted in different trees ([#2090](https://github.com/tailwindlabs/headlessui/pull/2090)) - Fix FocusTrap escape due to strange tabindex values ([#2093](https://github.com/tailwindlabs/headlessui/pull/2093)) +- Improve scroll locking on iOS ([#2100](https://github.com/tailwindlabs/headlessui/pull/2100)) ## [1.7.5] - 2022-12-08 diff --git a/packages/@headlessui-react/src/components/dialog/dialog.tsx b/packages/@headlessui-react/src/components/dialog/dialog.tsx index f346bf76c7..dd1729ebbc 100644 --- a/packages/@headlessui-react/src/components/dialog/dialog.tsx +++ b/packages/@headlessui-react/src/components/dialog/dialog.tsx @@ -91,7 +91,11 @@ function useDialogContext(component: string) { return context } -function useScrollLock(ownerDocument: Document | null, enabled: boolean) { +function useScrollLock( + ownerDocument: Document | null, + enabled: boolean, + resolveAllowedContainers: () => HTMLElement[] = () => [document.body] +) { useEffect(() => { if (!enabled) return if (!ownerDocument) return @@ -120,9 +124,27 @@ function useScrollLock(ownerDocument: Document | null, enabled: boolean) { if (isIOS()) { let scrollPosition = window.pageYOffset - style(documentElement, 'position', 'fixed') - style(documentElement, 'marginTop', `-${scrollPosition}px`) - style(documentElement, 'width', `100%`) + style(document.body, 'marginTop', `-${scrollPosition}px`) + window.scrollTo(0, 0) + + d.addEventListener( + ownerDocument, + '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 && + !resolveAllowedContainers().some((container) => + container.contains(e.target as HTMLElement) + ) + ) { + e.preventDefault() + } + }, + { passive: false } + ) + + // Restore scroll position d.add(() => window.scrollTo(0, scrollPosition)) } @@ -242,27 +264,22 @@ let DialogRoot = forwardRefWithAs(function Dialog< // Ensure other elements can't be interacted with useInertOthers(internalDialogRef, hasNestedDialogs ? enabled : false) - // Close Dialog on outside click - useOutsideClick( - () => { - // Third party roots - let rootContainers = Array.from( - ownerDocument?.querySelectorAll('body > *, [data-headlessui-portal]') ?? [] - ).filter((container) => { - if (!(container instanceof HTMLElement)) return false // Skip non-HTMLElements - if (container.contains(mainTreeNode.current)) return false // Skip if it is the main app - if (state.panelRef.current && container.contains(state.panelRef.current)) return false - return true // Keep - }) + let resolveContainers = useEvent(() => { + // Third party roots + let rootContainers = Array.from( + ownerDocument?.querySelectorAll('body > *, [data-headlessui-portal]') ?? [] + ).filter((container) => { + if (!(container instanceof HTMLElement)) return false // Skip non-HTMLElements + if (container.contains(mainTreeNode.current)) return false // Skip if it is the main app + if (state.panelRef.current && container.contains(state.panelRef.current)) return false + return true // Keep + }) - return [ - ...rootContainers, - state.panelRef.current ?? internalDialogRef.current, - ] as HTMLElement[] - }, - close, - enabled && !hasNestedDialogs - ) + return [...rootContainers, state.panelRef.current ?? internalDialogRef.current] as HTMLElement[] + }) + + // Close Dialog on outside click + useOutsideClick(() => resolveContainers(), close, enabled && !hasNestedDialogs) // Handle `Escape` to close useEventListener(ownerDocument?.defaultView, 'keydown', (event) => { @@ -276,7 +293,11 @@ let DialogRoot = forwardRefWithAs(function Dialog< }) // Scroll lock - useScrollLock(ownerDocument, dialogState === DialogStates.Open && !hasParentDialog) + useScrollLock( + ownerDocument, + dialogState === DialogStates.Open && !hasParentDialog, + resolveContainers + ) // 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 686f4fff5f..ef505c4416 100644 --- a/packages/@headlessui-react/src/utils/disposables.ts +++ b/packages/@headlessui-react/src/utils/disposables.ts @@ -10,7 +10,7 @@ export function disposables() { }, addEventListener( - element: HTMLElement | Document, + element: HTMLElement | Window | Document, name: TEventName, listener: (event: WindowEventMap[TEventName]) => any, options?: boolean | AddEventListenerOptions diff --git a/packages/@headlessui-vue/CHANGELOG.md b/packages/@headlessui-vue/CHANGELOG.md index 558ddf10fb..9678ea0ba5 100644 --- a/packages/@headlessui-vue/CHANGELOG.md +++ b/packages/@headlessui-vue/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fix regression where `displayValue` crashes ([#2087](https://github.com/tailwindlabs/headlessui/pull/2087)) - Fix `displayValue` syncing when `Combobox.Input` is unmounted and re-mounted in different trees ([#2090](https://github.com/tailwindlabs/headlessui/pull/2090)) - Fix FocusTrap escape due to strange tabindex values ([#2093](https://github.com/tailwindlabs/headlessui/pull/2093)) +- Improve scroll locking on iOS ([#2100](https://github.com/tailwindlabs/headlessui/pull/2100)) ## [1.7.5] - 2022-12-08 diff --git a/packages/@headlessui-vue/src/components/dialog/dialog.ts b/packages/@headlessui-vue/src/components/dialog/dialog.ts index d59767b718..9041df4a99 100644 --- a/packages/@headlessui-vue/src/components/dialog/dialog.ts +++ b/packages/@headlessui-vue/src/components/dialog/dialog.ts @@ -183,22 +183,23 @@ export let Dialog = defineComponent({ provide(DialogContext, api) - // Handle outside click - useOutsideClick( - () => { - // Third party roots - let rootContainers = Array.from( - ownerDocument.value?.querySelectorAll('body > *, [data-headlessui-portal]') ?? [] - ).filter((container) => { - if (!(container instanceof HTMLElement)) return false // Skip non-HTMLElements - if (container.contains(dom(mainTreeNode))) return false // Skip if it is the main app - if (api.panelRef.value && container.contains(api.panelRef.value)) return false - return true // Keep - }) + function resolveAllowedContainers() { + // Third party roots + let rootContainers = Array.from( + ownerDocument.value?.querySelectorAll('body > *, [data-headlessui-portal]') ?? [] + ).filter((container) => { + if (!(container instanceof HTMLElement)) return false // Skip non-HTMLElements + if (container.contains(dom(mainTreeNode))) return false // Skip if it is the main app + if (api.panelRef.value && container.contains(api.panelRef.value)) return false + return true // Keep + }) - return [...rootContainers, api.panelRef.value ?? internalDialogRef.value] as HTMLElement[] - }, + return [...rootContainers, api.panelRef.value ?? internalDialogRef.value] as HTMLElement[] + } + // Handle outside click + useOutsideClick( + () => resolveAllowedContainers(), (_event, target) => { api.close() nextTick(() => target?.focus()) @@ -249,9 +250,28 @@ export let Dialog = defineComponent({ if (isIOS()) { let scrollPosition = window.pageYOffset - style(documentElement, 'position', 'fixed') - style(documentElement, 'marginTop', `-${scrollPosition}px`) - style(documentElement, 'width', `100%`) + style(document.body, 'marginTop', `-${scrollPosition}px`) + window.scrollTo(0, 0) + + d.addEventListener( + owner, + '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 && + !resolveAllowedContainers().some((container) => + container.contains(e.target as HTMLElement) + ) + ) { + e.preventDefault() + } + }, + { passive: false } + ) + + // Restore scroll position d.add(() => window.scrollTo(0, scrollPosition)) } diff --git a/packages/@headlessui-vue/src/utils/disposables.ts b/packages/@headlessui-vue/src/utils/disposables.ts index 8e81cafe32..70667ce91f 100644 --- a/packages/@headlessui-vue/src/utils/disposables.ts +++ b/packages/@headlessui-vue/src/utils/disposables.ts @@ -8,7 +8,7 @@ export function disposables() { }, addEventListener( - element: HTMLElement | Document, + element: HTMLElement | Window | Document, name: TEventName, listener: (event: WindowEventMap[TEventName]) => any, options?: boolean | AddEventListenerOptions