Skip to content

Commit

Permalink
Improve "outside click" behaviour in combination with 3rd party libra…
Browse files Browse the repository at this point in the history
…ries (#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
  • Loading branch information
RobinMalfait authored Jul 3, 2023
1 parent 04fc6cf commit a9e8563
Show file tree
Hide file tree
Showing 4 changed files with 72 additions and 12 deletions.
1 change: 1 addition & 0 deletions packages/@headlessui-react/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
41 changes: 35 additions & 6 deletions packages/@headlessui-react/src/hooks/use-outside-click.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
) {
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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
Expand All @@ -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 change: 1 addition & 0 deletions packages/@headlessui-vue/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
41 changes: 35 additions & 6 deletions packages/@headlessui-vue/src/hooks/use-outside-click.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
) {
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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
Expand All @@ -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
)
}

2 comments on commit a9e8563

@vercel
Copy link

@vercel vercel bot commented on a9e8563 Jul 3, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

headlessui-vue – ./packages/playground-vue

headlessui-vue-git-main-tailwindlabs.vercel.app
headlessui-vue-tailwindlabs.vercel.app
headlessui-vue.vercel.app

@vercel
Copy link

@vercel vercel bot commented on a9e8563 Jul 3, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

headlessui-react – ./packages/playground-react

headlessui-react-tailwindlabs.vercel.app
headlessui-react.vercel.app
headlessui-react-git-main-tailwindlabs.vercel.app

Please sign in to comment.