From a9e85634a9f07c5da971fb8ca24412581b126da9 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Mon, 3 Jul 2023 16:21:03 +0200 Subject: [PATCH] Improve "outside click" behaviour in combination with 3rd party libraries (#2572) * listen for both `mousedown` and `pointerdown` events This is necessary for calculating the target where the focus will eventually move to. Some other libraries will use an `event.preventDefault()` and if we are not listening for all "down" events then we might not capture the necessary target. We already tried to ensure this was always captured by using the `capture` phase of the event but that's not enough. This change won't be enough on its own, but this will improve the experience with certain 3rd party libraries already. * refactor one-liners * listen for `touchend` event to improve "outside click" on mobile devices * update changelog --- packages/@headlessui-react/CHANGELOG.md | 1 + .../src/hooks/use-outside-click.ts | 41 ++++++++++++++++--- packages/@headlessui-vue/CHANGELOG.md | 1 + .../src/hooks/use-outside-click.ts | 41 ++++++++++++++++--- 4 files changed, 72 insertions(+), 12 deletions(-) diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md index 3a00b99538..5c034bb566 100644 --- a/packages/@headlessui-react/CHANGELOG.md +++ b/packages/@headlessui-react/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Ensure the caret is in a consistent position when syncing the `Combobox.Input` value ([#2568](https://github.com/tailwindlabs/headlessui/pull/2568)) +- Improve "outside click" behaviour in combination with 3rd party libraries ([#2572](https://github.com/tailwindlabs/headlessui/pull/2572)) ## [1.7.15] - 2023-06-01 diff --git a/packages/@headlessui-react/src/hooks/use-outside-click.ts b/packages/@headlessui-react/src/hooks/use-outside-click.ts index 12965d146e..82c26d8bec 100644 --- a/packages/@headlessui-react/src/hooks/use-outside-click.ts +++ b/packages/@headlessui-react/src/hooks/use-outside-click.ts @@ -9,7 +9,7 @@ type ContainerInput = Container | ContainerCollection export function useOutsideClick( containers: ContainerInput | (() => ContainerInput), - cb: (event: MouseEvent | PointerEvent | FocusEvent, target: HTMLElement) => void, + cb: (event: MouseEvent | PointerEvent | FocusEvent | TouchEvent, target: HTMLElement) => void, enabled: boolean = true ) { // TODO: remove this once the React bug has been fixed: https://github.com/facebook/react/issues/24657 @@ -27,7 +27,7 @@ export function useOutsideClick( [enabled] ) - function handleOutsideClick( + function handleOutsideClick( event: E, resolveTarget: (event: E) => HTMLElement | null ) { @@ -102,6 +102,16 @@ export function useOutsideClick( let initialClickTarget = useRef(null) + useDocumentEvent( + 'pointerdown', + (event) => { + if (enabledRef.current) { + initialClickTarget.current = event.composedPath?.()?.[0] || event.target + } + }, + true + ) + useDocumentEvent( 'mousedown', (event) => { @@ -133,6 +143,24 @@ export function useOutsideClick( true ) + useDocumentEvent( + 'touchend', + (event) => { + return handleOutsideClick(event, () => { + if (event.target instanceof HTMLElement) { + return event.target + } + return null + }) + }, + + // We will use the `capture` phase so that layers in between with `event.stopPropagation()` + // don't "cancel" this outside click check. E.g.: A `Menu` inside a `DialogPanel` if the `Menu` + // is open, and you click outside of it in the `DialogPanel` the `Menu` should close. However, + // the `DialogPanel` has a `onClick(e) { e.stopPropagation() }` which would cancel this. + true + ) + // When content inside an iframe is clicked `window` will receive a blur event // This can happen when an iframe _inside_ a window is clicked // Or, if headless UI is *in* the iframe, when a content in a window containing that iframe is clicked @@ -142,12 +170,13 @@ export function useOutsideClick( // and we can consider it an "outside click" useWindowEvent( 'blur', - (event) => - handleOutsideClick(event, () => - window.document.activeElement instanceof HTMLIFrameElement + (event) => { + return handleOutsideClick(event, () => { + return window.document.activeElement instanceof HTMLIFrameElement ? window.document.activeElement : null - ), + }) + }, true ) } diff --git a/packages/@headlessui-vue/CHANGELOG.md b/packages/@headlessui-vue/CHANGELOG.md index cb0a52fb6b..9736078a60 100644 --- a/packages/@headlessui-vue/CHANGELOG.md +++ b/packages/@headlessui-vue/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Ensure the caret is in a consistent position when syncing the `Combobox.Input` value ([#2568](https://github.com/tailwindlabs/headlessui/pull/2568)) +- Improve "outside click" behaviour in combination with 3rd party libraries ([#2572](https://github.com/tailwindlabs/headlessui/pull/2572)) ## [1.7.14] - 2023-06-01 diff --git a/packages/@headlessui-vue/src/hooks/use-outside-click.ts b/packages/@headlessui-vue/src/hooks/use-outside-click.ts index 084eedba51..eeda763f85 100644 --- a/packages/@headlessui-vue/src/hooks/use-outside-click.ts +++ b/packages/@headlessui-vue/src/hooks/use-outside-click.ts @@ -10,10 +10,10 @@ type ContainerInput = Container | ContainerCollection export function useOutsideClick( containers: ContainerInput | (() => ContainerInput), - cb: (event: MouseEvent | PointerEvent | FocusEvent, target: HTMLElement) => void, + cb: (event: MouseEvent | PointerEvent | FocusEvent | TouchEvent, target: HTMLElement) => void, enabled: ComputedRef = computed(() => true) ) { - function handleOutsideClick( + function handleOutsideClick( event: E, resolveTarget: (event: E) => HTMLElement | null ) { @@ -85,6 +85,16 @@ export function useOutsideClick( let initialClickTarget = ref(null) + useDocumentEvent( + 'pointerdown', + (event) => { + if (enabled.value) { + initialClickTarget.value = event.composedPath?.()?.[0] || event.target + } + }, + true + ) + useDocumentEvent( 'mousedown', (event) => { @@ -116,6 +126,24 @@ export function useOutsideClick( true ) + useDocumentEvent( + 'touchend', + (event) => { + return handleOutsideClick(event, () => { + if (event.target instanceof HTMLElement) { + return event.target + } + return null + }) + }, + + // We will use the `capture` phase so that layers in between with `event.stopPropagation()` + // don't "cancel" this outside click check. E.g.: A `Menu` inside a `DialogPanel` if the `Menu` + // is open, and you click outside of it in the `DialogPanel` the `Menu` should close. However, + // the `DialogPanel` has a `onClick(e) { e.stopPropagation() }` which would cancel this. + true + ) + // When content inside an iframe is clicked `window` will receive a blur event // This can happen when an iframe _inside_ a window is clicked // Or, if headless UI is *in* the iframe, when a content in a window containing that iframe is clicked @@ -125,12 +153,13 @@ export function useOutsideClick( // and we can consider it an "outside click" useWindowEvent( 'blur', - (event) => - handleOutsideClick(event, () => - window.document.activeElement instanceof HTMLIFrameElement + (event) => { + return handleOutsideClick(event, () => { + return window.document.activeElement instanceof HTMLIFrameElement ? window.document.activeElement : null - ), + }) + }, true ) }