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
This commit is contained in:
Robin Malfait
2023-07-03 16:21:03 +02:00
committed by GitHub
parent 04fc6cfa06
commit a9e85634a9
4 changed files with 72 additions and 12 deletions
+1
View File
@@ -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
@@ -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<E extends MouseEvent | PointerEvent | FocusEvent>(
function handleOutsideClick<E extends MouseEvent | PointerEvent | FocusEvent | TouchEvent>(
event: E,
resolveTarget: (event: E) => HTMLElement | null
) {
@@ -102,6 +102,16 @@ export function useOutsideClick(
let initialClickTarget = useRef<EventTarget | null>(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
)
}
+1
View File
@@ -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
@@ -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<boolean> = computed(() => true)
) {
function handleOutsideClick<E extends MouseEvent | PointerEvent | FocusEvent>(
function handleOutsideClick<E extends MouseEvent | PointerEvent | FocusEvent | TouchEvent>(
event: E,
resolveTarget: (event: E) => HTMLElement | null
) {
@@ -85,6 +85,16 @@ export function useOutsideClick(
let initialClickTarget = ref<EventTarget | null>(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
)
}