/
use-outside-click.ts
121 lines (103 loc) · 4.35 KB
/
use-outside-click.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
import { MutableRefObject, useEffect, useRef } from 'react'
import { FocusableMode, isFocusableElement } from '../utils/focus-management'
import { useWindowEvent } from './use-window-event'
type Container = MutableRefObject<HTMLElement | null> | HTMLElement | null
type ContainerCollection = Container[] | Set<Container>
type ContainerInput = Container | ContainerCollection
export function useOutsideClick(
containers: ContainerInput | (() => ContainerInput),
cb: (event: MouseEvent | PointerEvent | FocusEvent, target: HTMLElement) => void,
enabled: boolean = true
) {
// TODO: remove this once the React bug has been fixed: https://github.com/facebook/react/issues/24657
let enabledRef = useRef(false)
useEffect(
process.env.NODE_ENV === 'test'
? () => {
enabledRef.current = enabled
}
: () => {
requestAnimationFrame(() => {
enabledRef.current = enabled
})
},
[enabled]
)
function handleOutsideClick<E extends MouseEvent | PointerEvent | FocusEvent>(
event: E,
resolveTarget: (event: E) => HTMLElement | null
) {
if (!enabledRef.current) return
// Check whether the event got prevented already. This can happen if you use the
// useOutsideClick hook in both a Dialog and a Menu and the inner Menu "cancels" the default
// behaviour so that only the Menu closes and not the Dialog (yet)
if (event.defaultPrevented) return
let _containers = (function resolve(containers): ContainerCollection {
if (typeof containers === 'function') {
return resolve(containers())
}
if (Array.isArray(containers)) {
return containers
}
if (containers instanceof Set) {
return containers
}
return [containers]
})(containers)
let target = resolveTarget(event)
if (target === null) {
return
}
// Ignore if the target doesn't exist in the DOM anymore
if (!target.ownerDocument.documentElement.contains(target)) return
// Ignore if the target exists in one of the containers
for (let container of _containers) {
if (container === null) continue
let domNode = container instanceof HTMLElement ? container : container.current
if (domNode?.contains(target)) {
return
}
}
// This allows us to check whether the event was defaultPrevented when you are nesting this
// inside a `<Dialog />` for example.
if (
// This check alllows us to know whether or not we clicked on a "focusable" element like a
// button or an input. This is a backwards compatibility check so that you can open a <Menu
// /> and click on another <Menu /> which should close Menu A and open Menu B. We might
// revisit that so that you will require 2 clicks instead.
!isFocusableElement(target, FocusableMode.Loose) &&
// This could be improved, but the `Combobox.Button` adds tabIndex={-1} to make it
// unfocusable via the keyboard so that tabbing to the next item from the input doesn't
// first go to the button.
target.tabIndex !== -1
) {
event.preventDefault()
}
return cb(event, target)
}
useWindowEvent(
'click',
(event) => handleOutsideClick(event, (event) => event.target as HTMLElement),
// 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
// In this case we care only about the first case so we check to see if the active element is the iframe
// If so this was because of a click, focus, or other interaction with the child iframe
// and we can consider it an "outside click"
useWindowEvent(
'blur',
(event) =>
handleOutsideClick(event, () =>
window.document.activeElement instanceof HTMLIFrameElement
? window.document.activeElement
: null
),
true
)
}