Skip to content

Commit cefb899

Browse files
authored
Improve outside click support (#1175)
* improve outside click support We used to use `pointerdown`, but some older devices with iOS 12 didn't have support for that. Instead we used `mousedown`. But now it turns out that some devices only properly use `pointerdown` and not the `mousedown` event. Instead, we will listen to both, but make sure to only handle the event once. * update changelog
1 parent 1b3837b commit cefb899

File tree

13 files changed

+150
-60
lines changed

13 files changed

+150
-60
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515
- Fix `hover` scroll ([#1161](https://github.com/tailwindlabs/headlessui/pull/1161))
1616
- Guarantee DOM sort order when performing actions ([#1168](https://github.com/tailwindlabs/headlessui/pull/1168))
1717
- Fix `<Transition>` flickering issue ([#1118](https://github.com/tailwindlabs/headlessui/pull/1118))
18+
- Improve outside click support ([#1175](https://github.com/tailwindlabs/headlessui/pull/1175))
1819

1920
## [Unreleased - @headlessui/vue]
2021

@@ -26,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2627
- Ensure links are triggered inside `Popover Panel` components ([#1153](https://github.com/tailwindlabs/headlessui/pull/1153))
2728
- Fix `hover` scroll ([#1161](https://github.com/tailwindlabs/headlessui/pull/1161))
2829
- Guarantee DOM sort order when performing actions ([#1168](https://github.com/tailwindlabs/headlessui/pull/1168))
30+
- Improve outside click support ([#1175](https://github.com/tailwindlabs/headlessui/pull/1175))
2931

3032
## [@headlessui/react@v1.5.0] - 2022-02-17
3133

packages/@headlessui-react/src/components/combobox/combobox.tsx

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import { disposables } from '../../utils/disposables'
3030
import { Keys } from '../keyboard'
3131
import { Focus, calculateActiveIndex } from '../../utils/calculate-active-index'
3232
import { isDisabledReactIssue7711 } from '../../utils/bugs'
33-
import { useWindowEvent } from '../../hooks/use-window-event'
33+
import { useOutsideClick } from '../../hooks/use-outside-click'
3434
import { useOpenClosed, State, OpenClosedProvider } from '../../internal/open-closed'
3535
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
3636
import { useLatestValue } from '../../hooks/use-latest-value'
@@ -302,15 +302,9 @@ let ComboboxRoot = forwardRefWithAs(function Combobox<
302302
useIsoMorphicEffect(() => dispatch({ type: ActionTypes.SetDisabled, disabled }), [disabled])
303303

304304
// Handle outside click
305-
useWindowEvent('mousedown', (event) => {
306-
let target = event.target as HTMLElement
307-
305+
useOutsideClick([buttonRef, inputRef, optionsRef], () => {
308306
if (comboboxState !== ComboboxStates.Open) return
309307

310-
if (buttonRef.current?.contains(target)) return
311-
if (inputRef.current?.contains(target)) return
312-
if (optionsRef.current?.contains(target)) return
313-
314308
dispatch({ type: ActionTypes.CloseCombobox })
315309
})
316310

packages/@headlessui-react/src/components/dialog/dialog.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { useWindowEvent } from '../../hooks/use-window-event'
3333
import { useOpenClosed, State } from '../../internal/open-closed'
3434
import { useServerHandoffComplete } from '../../hooks/use-server-handoff-complete'
3535
import { StackProvider, StackMessage } from '../../internal/stack-context'
36+
import { useOutsideClick } from '../../hooks/use-outside-click'
3637

3738
enum DialogStates {
3839
Open,
@@ -207,12 +208,9 @@ let DialogRoot = forwardRefWithAs(function Dialog<
207208
useInertOthers(internalDialogRef, hasNestedDialogs ? enabled : false)
208209

209210
// Handle outside click
210-
useWindowEvent('mousedown', (event) => {
211-
let target = event.target as HTMLElement
212-
211+
useOutsideClick(internalDialogRef, () => {
213212
if (dialogState !== DialogStates.Open) return
214213
if (hasNestedDialogs) return
215-
if (internalDialogRef.current?.contains(target)) return
216214

217215
close()
218216
})

packages/@headlessui-react/src/components/listbox/listbox.tsx

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,9 @@ import { Keys } from '../keyboard'
3030
import { Focus, calculateActiveIndex } from '../../utils/calculate-active-index'
3131
import { isDisabledReactIssue7711 } from '../../utils/bugs'
3232
import { isFocusableElement, FocusableMode, sortByDomNode } from '../../utils/focus-management'
33-
import { useWindowEvent } from '../../hooks/use-window-event'
3433
import { useOpenClosed, State, OpenClosedProvider } from '../../internal/open-closed'
3534
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
35+
import { useOutsideClick } from '../../hooks/use-outside-click'
3636

3737
enum ListboxStates {
3838
Open,
@@ -281,14 +281,9 @@ let ListboxRoot = forwardRefWithAs(function Listbox<
281281
)
282282

283283
// Handle outside click
284-
useWindowEvent('mousedown', (event) => {
285-
let target = event.target as HTMLElement
286-
284+
useOutsideClick([buttonRef, optionsRef], (event, target) => {
287285
if (listboxState !== ListboxStates.Open) return
288286

289-
if (buttonRef.current?.contains(target)) return
290-
if (optionsRef.current?.contains(target)) return
291-
292287
dispatch({ type: ActionTypes.CloseListbox })
293288

294289
if (!isFocusableElement(target, FocusableMode.Loose)) {

packages/@headlessui-react/src/components/menu/menu.tsx

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import { Keys } from '../keyboard'
3131
import { Focus, calculateActiveIndex } from '../../utils/calculate-active-index'
3232
import { isDisabledReactIssue7711 } from '../../utils/bugs'
3333
import { isFocusableElement, FocusableMode, sortByDomNode } from '../../utils/focus-management'
34-
import { useWindowEvent } from '../../hooks/use-window-event'
34+
import { useOutsideClick } from '../../hooks/use-outside-click'
3535
import { useTreeWalker } from '../../hooks/use-tree-walker'
3636
import { useOpenClosed, State, OpenClosedProvider } from '../../internal/open-closed'
3737
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
@@ -219,14 +219,9 @@ let MenuRoot = forwardRefWithAs(function Menu<TTag extends ElementType = typeof
219219
let menuRef = useSyncRefs(ref)
220220

221221
// Handle outside click
222-
useWindowEvent('mousedown', (event) => {
223-
let target = event.target as HTMLElement
224-
222+
useOutsideClick([buttonRef, itemsRef], (event, target) => {
225223
if (menuState !== MenuStates.Open) return
226224

227-
if (buttonRef.current?.contains(target)) return
228-
if (itemsRef.current?.contains(target)) return
229-
230225
dispatch({ type: ActionTypes.CloseMenu })
231226

232227
if (!isFocusableElement(target, FocusableMode.Loose)) {

packages/@headlessui-react/src/components/popover/popover.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
import { useWindowEvent } from '../../hooks/use-window-event'
3737
import { OpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed'
3838
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
39+
import { useOutsideClick } from '../../hooks/use-outside-click'
3940

4041
enum PopoverStates {
4142
Open,
@@ -218,14 +219,9 @@ let PopoverRoot = forwardRefWithAs(function Popover<
218219
)
219220

220221
// Handle outside click
221-
useWindowEvent('mousedown', (event) => {
222-
let target = event.target as HTMLElement
223-
222+
useOutsideClick([button, panel], (event, target) => {
224223
if (popoverState !== PopoverStates.Open) return
225224

226-
if (button?.contains(target)) return
227-
if (panel?.contains(target)) return
228-
229225
dispatch({ type: ActionTypes.ClosePopover })
230226

231227
if (!isFocusableElement(target, FocusableMode.Loose)) {
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { MutableRefObject, useMemo, useRef } from 'react'
2+
import { useLatestValue } from './use-latest-value'
3+
import { useWindowEvent } from './use-window-event'
4+
5+
// Polyfill
6+
function microTask(cb: () => void) {
7+
if (typeof queueMicrotask === 'function') {
8+
queueMicrotask(cb)
9+
} else {
10+
Promise.resolve()
11+
.then(cb)
12+
.catch((e) =>
13+
setTimeout(() => {
14+
throw e
15+
})
16+
)
17+
}
18+
}
19+
20+
export function useOutsideClick(
21+
containers:
22+
| HTMLElement
23+
| MutableRefObject<HTMLElement | null>
24+
| (MutableRefObject<HTMLElement | null> | HTMLElement | null)[]
25+
| Set<HTMLElement>,
26+
cb: (event: MouseEvent | PointerEvent, target: HTMLElement) => void
27+
) {
28+
let _containers = useMemo(() => {
29+
if (Array.isArray(containers)) {
30+
return containers
31+
}
32+
33+
if (containers instanceof Set) {
34+
return containers
35+
}
36+
37+
return [containers]
38+
}, [containers])
39+
40+
let called = useRef(false)
41+
let handler = useLatestValue((event: MouseEvent | PointerEvent) => {
42+
if (called.current) return
43+
called.current = true
44+
microTask(() => {
45+
called.current = false
46+
})
47+
48+
let target = event.target as HTMLElement
49+
50+
for (let container of _containers) {
51+
if (container === null) continue
52+
let domNode = container instanceof HTMLElement ? container : container.current
53+
if (domNode?.contains(target)) {
54+
return
55+
}
56+
}
57+
58+
return cb(event, target)
59+
})
60+
61+
useWindowEvent('pointerdown', (...args) => handler.current(...args))
62+
useWindowEvent('mousedown', (...args) => handler.current(...args))
63+
}

packages/@headlessui-vue/src/components/combobox/combobox.ts

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,12 @@ import { useId } from '../../hooks/use-id'
2121
import { Keys } from '../../keyboard'
2222
import { calculateActiveIndex, Focus } from '../../utils/calculate-active-index'
2323
import { dom } from '../../utils/dom'
24-
import { useWindowEvent } from '../../hooks/use-window-event'
2524
import { useOpenClosed, State, useOpenClosedProvider } from '../../internal/open-closed'
2625
import { match } from '../../utils/match'
2726
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
2827
import { useTreeWalker } from '../../hooks/use-tree-walker'
2928
import { sortByDomNode } from '../../utils/focus-management'
29+
import { useOutsideClick } from '../../hooks/use-outside-click'
3030

3131
enum ComboboxStates {
3232
Open,
@@ -229,15 +229,9 @@ export let Combobox = defineComponent({
229229
},
230230
}
231231

232-
useWindowEvent('mousedown', (event) => {
233-
let target = event.target as HTMLElement
234-
232+
// Handle outside click
233+
useOutsideClick([inputRef, buttonRef, optionsRef], () => {
235234
if (comboboxState.value !== ComboboxStates.Open) return
236-
237-
if (dom(inputRef)?.contains(target)) return
238-
if (dom(buttonRef)?.contains(target)) return
239-
if (dom(optionsRef)?.contains(target)) return
240-
241235
api.closeCombobox()
242236
})
243237

packages/@headlessui-vue/src/components/dialog/dialog.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ import { Keys } from '../../keyboard'
2323
import { useId } from '../../hooks/use-id'
2424
import { useFocusTrap } from '../../hooks/use-focus-trap'
2525
import { useInertOthers } from '../../hooks/use-inert-others'
26-
import { contains } from '../../internal/dom-containers'
2726
import { useWindowEvent } from '../../hooks/use-window-event'
2827
import { Portal, PortalGroup } from '../portal/portal'
2928
import { StackMessage, useStackProvider } from '../../internal/stack-context'
@@ -32,6 +31,7 @@ import { ForcePortalRoot } from '../../internal/portal-force-root'
3231
import { Description, useDescriptions } from '../description/description'
3332
import { dom } from '../../utils/dom'
3433
import { useOpenClosed, State } from '../../internal/open-closed'
34+
import { useOutsideClick } from '../../hooks/use-outside-click'
3535

3636
enum DialogStates {
3737
Open,
@@ -158,12 +158,9 @@ export let Dialog = defineComponent({
158158
provide(DialogContext, api)
159159

160160
// Handle outside click
161-
useWindowEvent('mousedown', (event) => {
162-
let target = event.target as HTMLElement
163-
161+
useOutsideClick(containers.value, (_event, target) => {
164162
if (dialogState.value !== DialogStates.Open) return
165163
if (containers.value.size !== 1) return
166-
if (contains(containers.value, target)) return
167164

168165
api.close()
169166
nextTick(() => target?.focus())

packages/@headlessui-vue/src/components/listbox/listbox.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,11 @@ import { useId } from '../../hooks/use-id'
2020
import { Keys } from '../../keyboard'
2121
import { calculateActiveIndex, Focus } from '../../utils/calculate-active-index'
2222
import { dom } from '../../utils/dom'
23-
import { useWindowEvent } from '../../hooks/use-window-event'
2423
import { useOpenClosed, State, useOpenClosedProvider } from '../../internal/open-closed'
2524
import { match } from '../../utils/match'
2625
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
2726
import { sortByDomNode } from '../../utils/focus-management'
27+
import { useOutsideClick } from '../../hooks/use-outside-click'
2828

2929
enum ListboxStates {
3030
Open,
@@ -219,12 +219,11 @@ export let Listbox = defineComponent({
219219
},
220220
}
221221

222-
useWindowEvent('mousedown', (event) => {
223-
let target = event.target as HTMLElement
222+
// Handle outside click
223+
useOutsideClick(buttonRef, (event, target) => {
224224
let active = document.activeElement
225225

226226
if (listboxState.value !== ListboxStates.Open) return
227-
if (dom(buttonRef)?.contains(target)) return
228227

229228
if (!dom(optionsRef)?.contains(target)) api.closeListbox()
230229
if (active !== document.body && active?.contains(target)) return // Keep focus on newly clicked/focused element

0 commit comments

Comments
 (0)