Skip to content

Commit a9e8563

Browse files
authored
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
1 parent 04fc6cf commit a9e8563

File tree

4 files changed

+72
-12
lines changed

4 files changed

+72
-12
lines changed

packages/@headlessui-react/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Fixed
1111

1212
- Ensure the caret is in a consistent position when syncing the `Combobox.Input` value ([#2568](https://github.com/tailwindlabs/headlessui/pull/2568))
13+
- Improve "outside click" behaviour in combination with 3rd party libraries ([#2572](https://github.com/tailwindlabs/headlessui/pull/2572))
1314

1415
## [1.7.15] - 2023-06-01
1516

packages/@headlessui-react/src/hooks/use-outside-click.ts

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ type ContainerInput = Container | ContainerCollection
99

1010
export function useOutsideClick(
1111
containers: ContainerInput | (() => ContainerInput),
12-
cb: (event: MouseEvent | PointerEvent | FocusEvent, target: HTMLElement) => void,
12+
cb: (event: MouseEvent | PointerEvent | FocusEvent | TouchEvent, target: HTMLElement) => void,
1313
enabled: boolean = true
1414
) {
1515
// TODO: remove this once the React bug has been fixed: https://github.com/facebook/react/issues/24657
@@ -27,7 +27,7 @@ export function useOutsideClick(
2727
[enabled]
2828
)
2929

30-
function handleOutsideClick<E extends MouseEvent | PointerEvent | FocusEvent>(
30+
function handleOutsideClick<E extends MouseEvent | PointerEvent | FocusEvent | TouchEvent>(
3131
event: E,
3232
resolveTarget: (event: E) => HTMLElement | null
3333
) {
@@ -102,6 +102,16 @@ export function useOutsideClick(
102102

103103
let initialClickTarget = useRef<EventTarget | null>(null)
104104

105+
useDocumentEvent(
106+
'pointerdown',
107+
(event) => {
108+
if (enabledRef.current) {
109+
initialClickTarget.current = event.composedPath?.()?.[0] || event.target
110+
}
111+
},
112+
true
113+
)
114+
105115
useDocumentEvent(
106116
'mousedown',
107117
(event) => {
@@ -133,6 +143,24 @@ export function useOutsideClick(
133143
true
134144
)
135145

146+
useDocumentEvent(
147+
'touchend',
148+
(event) => {
149+
return handleOutsideClick(event, () => {
150+
if (event.target instanceof HTMLElement) {
151+
return event.target
152+
}
153+
return null
154+
})
155+
},
156+
157+
// We will use the `capture` phase so that layers in between with `event.stopPropagation()`
158+
// don't "cancel" this outside click check. E.g.: A `Menu` inside a `DialogPanel` if the `Menu`
159+
// is open, and you click outside of it in the `DialogPanel` the `Menu` should close. However,
160+
// the `DialogPanel` has a `onClick(e) { e.stopPropagation() }` which would cancel this.
161+
true
162+
)
163+
136164
// When content inside an iframe is clicked `window` will receive a blur event
137165
// This can happen when an iframe _inside_ a window is clicked
138166
// 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(
142170
// and we can consider it an "outside click"
143171
useWindowEvent(
144172
'blur',
145-
(event) =>
146-
handleOutsideClick(event, () =>
147-
window.document.activeElement instanceof HTMLIFrameElement
173+
(event) => {
174+
return handleOutsideClick(event, () => {
175+
return window.document.activeElement instanceof HTMLIFrameElement
148176
? window.document.activeElement
149177
: null
150-
),
178+
})
179+
},
151180
true
152181
)
153182
}

packages/@headlessui-vue/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Fixed
1111

1212
- Ensure the caret is in a consistent position when syncing the `Combobox.Input` value ([#2568](https://github.com/tailwindlabs/headlessui/pull/2568))
13+
- Improve "outside click" behaviour in combination with 3rd party libraries ([#2572](https://github.com/tailwindlabs/headlessui/pull/2572))
1314

1415
## [1.7.14] - 2023-06-01
1516

packages/@headlessui-vue/src/hooks/use-outside-click.ts

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ type ContainerInput = Container | ContainerCollection
1010

1111
export function useOutsideClick(
1212
containers: ContainerInput | (() => ContainerInput),
13-
cb: (event: MouseEvent | PointerEvent | FocusEvent, target: HTMLElement) => void,
13+
cb: (event: MouseEvent | PointerEvent | FocusEvent | TouchEvent, target: HTMLElement) => void,
1414
enabled: ComputedRef<boolean> = computed(() => true)
1515
) {
16-
function handleOutsideClick<E extends MouseEvent | PointerEvent | FocusEvent>(
16+
function handleOutsideClick<E extends MouseEvent | PointerEvent | FocusEvent | TouchEvent>(
1717
event: E,
1818
resolveTarget: (event: E) => HTMLElement | null
1919
) {
@@ -85,6 +85,16 @@ export function useOutsideClick(
8585

8686
let initialClickTarget = ref<EventTarget | null>(null)
8787

88+
useDocumentEvent(
89+
'pointerdown',
90+
(event) => {
91+
if (enabled.value) {
92+
initialClickTarget.value = event.composedPath?.()?.[0] || event.target
93+
}
94+
},
95+
true
96+
)
97+
8898
useDocumentEvent(
8999
'mousedown',
90100
(event) => {
@@ -116,6 +126,24 @@ export function useOutsideClick(
116126
true
117127
)
118128

129+
useDocumentEvent(
130+
'touchend',
131+
(event) => {
132+
return handleOutsideClick(event, () => {
133+
if (event.target instanceof HTMLElement) {
134+
return event.target
135+
}
136+
return null
137+
})
138+
},
139+
140+
// We will use the `capture` phase so that layers in between with `event.stopPropagation()`
141+
// don't "cancel" this outside click check. E.g.: A `Menu` inside a `DialogPanel` if the `Menu`
142+
// is open, and you click outside of it in the `DialogPanel` the `Menu` should close. However,
143+
// the `DialogPanel` has a `onClick(e) { e.stopPropagation() }` which would cancel this.
144+
true
145+
)
146+
119147
// When content inside an iframe is clicked `window` will receive a blur event
120148
// This can happen when an iframe _inside_ a window is clicked
121149
// 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(
125153
// and we can consider it an "outside click"
126154
useWindowEvent(
127155
'blur',
128-
(event) =>
129-
handleOutsideClick(event, () =>
130-
window.document.activeElement instanceof HTMLIFrameElement
156+
(event) => {
157+
return handleOutsideClick(event, () => {
158+
return window.document.activeElement instanceof HTMLIFrameElement
131159
? window.document.activeElement
132160
: null
133-
),
161+
})
162+
},
134163
true
135164
)
136165
}

0 commit comments

Comments
 (0)