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 locking on iOS #2100

Merged
merged 3 commits into from Dec 15, 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 @@ -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

Expand Down
71 changes: 46 additions & 25 deletions packages/@headlessui-react/src/components/dialog/dialog.tsx
Expand Up @@ -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
Expand Down Expand Up @@ -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))
}

Expand Down Expand Up @@ -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) => {
Expand All @@ -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(() => {
Expand Down
2 changes: 1 addition & 1 deletion packages/@headlessui-react/src/utils/disposables.ts
Expand Up @@ -10,7 +10,7 @@ export function disposables() {
},

addEventListener<TEventName extends keyof WindowEventMap>(
element: HTMLElement | Document,
element: HTMLElement | Window | Document,
name: TEventName,
listener: (event: WindowEventMap[TEventName]) => any,
options?: boolean | AddEventListenerOptions
Expand Down
1 change: 1 addition & 0 deletions packages/@headlessui-vue/CHANGELOG.md
Expand Up @@ -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

Expand Down
54 changes: 37 additions & 17 deletions packages/@headlessui-vue/src/components/dialog/dialog.ts
Expand Up @@ -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())
Expand Down Expand Up @@ -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))
}

Expand Down
2 changes: 1 addition & 1 deletion packages/@headlessui-vue/src/utils/disposables.ts
Expand Up @@ -8,7 +8,7 @@ export function disposables() {
},

addEventListener<TEventName extends keyof WindowEventMap>(
element: HTMLElement | Document,
element: HTMLElement | Window | Document,
name: TEventName,
listener: (event: WindowEventMap[TEventName]) => any,
options?: boolean | AddEventListenerOptions
Expand Down