-
Notifications
You must be signed in to change notification settings - Fork 1.1k
/
Copy pathuse-outside-click.ts
212 lines (182 loc) · 7.06 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
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
import { useCallback, useRef } from 'react'
import { FocusableMode, isFocusableElement } from '../utils/focus-management'
import { isMobile } from '../utils/platform'
import { useDocumentEvent } from './use-document-event'
import { useIsTopLayer } from './use-is-top-layer'
import { useLatestValue } from './use-latest-value'
import { useWindowEvent } from './use-window-event'
type Container = HTMLElement | null
type ContainerCollection = Container[] | Set<Container>
type ContainerInput = Container | ContainerCollection
// If the user moves their finger by ${MOVE_THRESHOLD_PX} pixels or more, we'll
// assume that they are scrolling and not clicking. This will prevent the click
// from being triggered when the user is scrolling.
//
// This also allows you to "cancel" the click by moving your finger more than
// the threshold in pixels in any direction.
const MOVE_THRESHOLD_PX = 30
export function useOutsideClick(
enabled: boolean,
containers: ContainerInput | (() => ContainerInput),
cb: (event: MouseEvent | PointerEvent | FocusEvent | TouchEvent, target: HTMLElement) => void,
topLayerScope = 'outside-click'
) {
let isTopLayer = useIsTopLayer(enabled, topLayerScope)
let cbRef = useLatestValue(cb)
let handleOutsideClick = useCallback(
function handleOutsideClick<E extends MouseEvent | PointerEvent | FocusEvent | TouchEvent>(
event: E,
resolveTarget: (event: E) => HTMLElement | null
) {
// 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 behavior so that only the Menu closes and
// not the Dialog (yet)
if (event.defaultPrevented) return
let target = resolveTarget(event)
if (target === null) {
return
}
// Ignore if the target doesn't exist in the DOM anymore
if (!target.getRootNode().contains(target)) return
// Ignore if the target was removed from the DOM by the time the handler
// was called
if (!target.isConnected) 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)
// Ignore if the target exists in one of the containers
for (let container of _containers) {
if (container === null) continue
if (container.contains(target)) {
return
}
// If the click crossed a shadow boundary, we need to check if the
// container is inside the tree by using `composedPath` to "pierce" the
// shadow boundary
if (event.composed && event.composedPath().includes(container as EventTarget)) {
return
}
}
// This allows us to check whether the event was defaultPrevented when you
// are nesting this inside a `<Dialog />` for example.
if (
// This check allows 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 cbRef.current(event, target)
},
[cbRef, containers]
)
let initialClickTarget = useRef<EventTarget | null>(null)
useDocumentEvent(
isTopLayer,
'pointerdown',
(event) => {
initialClickTarget.current = event.composedPath?.()?.[0] || event.target
},
true
)
useDocumentEvent(
isTopLayer,
'mousedown',
(event) => {
initialClickTarget.current = event.composedPath?.()?.[0] || event.target
},
true
)
useDocumentEvent(
isTopLayer,
'click',
(event) => {
if (isMobile()) {
return
}
if (!initialClickTarget.current) {
return
}
handleOutsideClick(event, () => {
return initialClickTarget.current as HTMLElement
})
initialClickTarget.current = 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
)
let startPosition = useRef({ x: 0, y: 0 })
useDocumentEvent(
isTopLayer,
'touchstart',
(event) => {
startPosition.current.x = event.touches[0].clientX
startPosition.current.y = event.touches[0].clientY
},
true
)
useDocumentEvent(
isTopLayer,
'touchend',
(event) => {
// If the user moves their finger by ${MOVE_THRESHOLD_PX} pixels or more,
// we'll assume that they are scrolling and not clicking.
let endPosition = { x: event.changedTouches[0].clientX, y: event.changedTouches[0].clientY }
if (
Math.abs(endPosition.x - startPosition.current.x) >= MOVE_THRESHOLD_PX ||
Math.abs(endPosition.y - startPosition.current.y) >= MOVE_THRESHOLD_PX
) {
return
}
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
// 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(
isTopLayer,
'blur',
(event) => {
return handleOutsideClick(event, () => {
return window.document.activeElement instanceof HTMLIFrameElement
? window.document.activeElement
: null
})
},
true
)
}