1
- import { useEffect , useRef , type MutableRefObject } from 'react'
1
+ import { useCallback , useRef , type MutableRefObject } from 'react'
2
2
import { FocusableMode , isFocusableElement } from '../utils/focus-management'
3
3
import { isMobile } from '../utils/platform'
4
4
import { useDocumentEvent } from './use-document-event'
5
5
import { useIsTopLayer } from './use-is-top-layer'
6
+ import { useLatestValue } from './use-latest-value'
6
7
import { useWindowEvent } from './use-window-event'
7
8
8
9
type Container = MutableRefObject < HTMLElement | null > | HTMLElement | null
9
10
type ContainerCollection = Container [ ] | Set < Container >
10
11
type ContainerInput = Container | ContainerCollection
11
12
13
+ // If the user moves their finger by ${MOVE_THRESHOLD_PX} pixels or more, we'll
14
+ // assume that they are scrolling and not clicking. This will prevent the click
15
+ // from being triggered when the user is scrolling.
16
+ //
17
+ // This also allows you to "cancel" the click by moving your finger more than
18
+ // the threshold in pixels in any direction.
19
+ const MOVE_THRESHOLD_PX = 30
20
+
12
21
export function useOutsideClick (
13
22
enabled : boolean ,
14
23
containers : ContainerInput | ( ( ) => ContainerInput ) ,
15
24
cb : ( event : MouseEvent | PointerEvent | FocusEvent | TouchEvent , target : HTMLElement ) => void
16
25
) {
17
26
let isTopLayer = useIsTopLayer ( enabled , 'outside-click' )
27
+ let cbRef = useLatestValue ( cb )
18
28
19
- // TODO: remove this once the React bug has been fixed: https://github.com/facebook/react/issues/24657
20
- let enabledRef = useRef ( false )
21
- useEffect (
22
- process . env . NODE_ENV === 'test'
23
- ? ( ) => {
24
- enabledRef . current = isTopLayer
25
- }
26
- : ( ) => {
27
- requestAnimationFrame ( ( ) => {
28
- enabledRef . current = isTopLayer
29
- } )
30
- } ,
31
- [ isTopLayer ]
32
- )
33
-
34
- function handleOutsideClick < E extends MouseEvent | PointerEvent | FocusEvent | TouchEvent > (
35
- event : E ,
36
- resolveTarget : ( event : E ) => HTMLElement | null
37
- ) {
38
- if ( ! enabledRef . current ) return
29
+ let handleOutsideClick = useCallback (
30
+ function handleOutsideClick < E extends MouseEvent | PointerEvent | FocusEvent | TouchEvent > (
31
+ event : E ,
32
+ resolveTarget : ( event : E ) => HTMLElement | null
33
+ ) {
34
+ // Check whether the event got prevented already. This can happen if you
35
+ // use the useOutsideClick hook in both a Dialog and a Menu and the inner
36
+ // Menu "cancels" the default behavior so that only the Menu closes and
37
+ // not the Dialog (yet)
38
+ if ( event . defaultPrevented ) return
39
39
40
- // Check whether the event got prevented already. This can happen if you use the
41
- // useOutsideClick hook in both a Dialog and a Menu and the inner Menu "cancels" the default
42
- // behavior so that only the Menu closes and not the Dialog (yet)
43
- if ( event . defaultPrevented ) return
40
+ let target = resolveTarget ( event )
44
41
45
- let target = resolveTarget ( event )
42
+ if ( target === null ) {
43
+ return
44
+ }
46
45
47
- if ( target === null ) {
48
- return
49
- }
46
+ // Ignore if the target doesn't exist in the DOM anymore
47
+ if ( ! target . getRootNode ( ) . contains ( target ) ) return
50
48
51
- // Ignore if the target doesn't exist in the DOM anymore
52
- if ( ! target . getRootNode ( ) . contains ( target ) ) return
49
+ // Ignore if the target was removed from the DOM by the time the handler
50
+ // was called
51
+ if ( ! target . isConnected ) return
53
52
54
- // Ignore if the target was removed from the DOM by the time the handler was called
55
- if ( ! target . isConnected ) return
53
+ let _containers = ( function resolve ( containers ) : ContainerCollection {
54
+ if ( typeof containers === 'function' ) {
55
+ return resolve ( containers ( ) )
56
+ }
56
57
57
- let _containers = ( function resolve ( containers ) : ContainerCollection {
58
- if ( typeof containers === 'function' ) {
59
- return resolve ( containers ( ) )
60
- }
58
+ if ( Array . isArray ( containers ) ) {
59
+ return containers
60
+ }
61
61
62
- if ( Array . isArray ( containers ) ) {
63
- return containers
64
- }
62
+ if ( containers instanceof Set ) {
63
+ return containers
64
+ }
65
65
66
- if ( containers instanceof Set ) {
67
- return containers
68
- }
66
+ return [ containers ]
67
+ } ) ( containers )
69
68
70
- return [ containers ]
71
- } ) ( containers )
69
+ // Ignore if the target exists in one of the containers
70
+ for ( let container of _containers ) {
71
+ if ( container === null ) continue
72
+ let domNode = container instanceof HTMLElement ? container : container . current
73
+ if ( domNode ?. contains ( target ) ) {
74
+ return
75
+ }
72
76
73
- // Ignore if the target exists in one of the containers
74
- for ( let container of _containers ) {
75
- if ( container === null ) continue
76
- let domNode = container instanceof HTMLElement ? container : container . current
77
- if ( domNode ?. contains ( target ) ) {
78
- return
77
+ // If the click crossed a shadow boundary, we need to check if the
78
+ // container is inside the tree by using `composedPath` to "pierce" the
79
+ // shadow boundary
80
+ if ( event . composed && event . composedPath ( ) . includes ( domNode as EventTarget ) ) {
81
+ return
82
+ }
79
83
}
80
84
81
- // If the click crossed a shadow boundary, we need to check if the container
82
- // is inside the tree by using `composedPath` to "pierce" the shadow boundary
83
- if ( event . composed && event . composedPath ( ) . includes ( domNode as EventTarget ) ) {
84
- return
85
+ // This allows us to check whether the event was defaultPrevented when you
86
+ // are nesting this inside a `<Dialog />` for example.
87
+ if (
88
+ // This check allows us to know whether or not we clicked on a
89
+ // "focusable" element like a button or an input. This is a backwards
90
+ // compatibility check so that you can open a <Menu /> and click on
91
+ // another <Menu /> which should close Menu A and open Menu B. We might
92
+ // revisit that so that you will require 2 clicks instead.
93
+ ! isFocusableElement ( target , FocusableMode . Loose ) &&
94
+ // This could be improved, but the `Combobox.Button` adds tabIndex={-1}
95
+ // to make it unfocusable via the keyboard so that tabbing to the next
96
+ // item from the input doesn't first go to the button.
97
+ target . tabIndex !== - 1
98
+ ) {
99
+ event . preventDefault ( )
85
100
}
86
- }
87
-
88
- // This allows us to check whether the event was defaultPrevented when you are nesting this
89
- // inside a `<Dialog />` for example.
90
- if (
91
- // This check allows us to know whether or not we clicked on a "focusable" element like a
92
- // button or an input. This is a backwards compatibility check so that you can open a <Menu
93
- // /> and click on another <Menu /> which should close Menu A and open Menu B. We might
94
- // revisit that so that you will require 2 clicks instead.
95
- ! isFocusableElement ( target , FocusableMode . Loose ) &&
96
- // This could be improved, but the `Combobox.Button` adds tabIndex={-1} to make it
97
- // unfocusable via the keyboard so that tabbing to the next item from the input doesn't
98
- // first go to the button.
99
- target . tabIndex !== - 1
100
- ) {
101
- event . preventDefault ( )
102
- }
103
101
104
- return cb ( event , target )
105
- }
102
+ return cbRef . current ( event , target )
103
+ } ,
104
+ [ cbRef ]
105
+ )
106
106
107
107
let initialClickTarget = useRef < EventTarget | null > ( null )
108
108
109
109
useDocumentEvent (
110
+ isTopLayer ,
110
111
'pointerdown' ,
111
112
( event ) => {
112
- if ( enabledRef . current ) {
113
- initialClickTarget . current = event . composedPath ?.( ) ?. [ 0 ] || event . target
114
- }
113
+ initialClickTarget . current = event . composedPath ?.( ) ?. [ 0 ] || event . target
115
114
} ,
116
115
true
117
116
)
118
117
119
118
useDocumentEvent (
119
+ isTopLayer ,
120
120
'mousedown' ,
121
121
( event ) => {
122
- if ( enabledRef . current ) {
123
- initialClickTarget . current = event . composedPath ?.( ) ?. [ 0 ] || event . target
124
- }
122
+ initialClickTarget . current = event . composedPath ?.( ) ?. [ 0 ] || event . target
125
123
} ,
126
124
true
127
125
)
128
126
129
127
useDocumentEvent (
128
+ isTopLayer ,
130
129
'click' ,
131
130
( event ) => {
132
131
if ( isMobile ( ) ) {
@@ -151,9 +150,31 @@ export function useOutsideClick(
151
150
true
152
151
)
153
152
153
+ let startPosition = useRef ( { x : 0 , y : 0 } )
154
154
useDocumentEvent (
155
+ isTopLayer ,
156
+ 'touchstart' ,
157
+ ( event ) => {
158
+ startPosition . current . x = event . touches [ 0 ] . clientX
159
+ startPosition . current . y = event . touches [ 0 ] . clientY
160
+ } ,
161
+ true
162
+ )
163
+
164
+ useDocumentEvent (
165
+ isTopLayer ,
155
166
'touchend' ,
156
167
( event ) => {
168
+ // If the user moves their finger by ${MOVE_THRESHOLD_PX} pixels or more,
169
+ // we'll assume that they are scrolling and not clicking.
170
+ let endPosition = { x : event . changedTouches [ 0 ] . clientX , y : event . changedTouches [ 0 ] . clientY }
171
+ if (
172
+ Math . abs ( endPosition . x - startPosition . current . x ) >= MOVE_THRESHOLD_PX ||
173
+ Math . abs ( endPosition . y - startPosition . current . y ) >= MOVE_THRESHOLD_PX
174
+ ) {
175
+ return
176
+ }
177
+
157
178
return handleOutsideClick ( event , ( ) => {
158
179
if ( event . target instanceof HTMLElement ) {
159
180
return event . target
@@ -177,6 +198,7 @@ export function useOutsideClick(
177
198
// If so this was because of a click, focus, or other interaction with the child iframe
178
199
// and we can consider it an "outside click"
179
200
useWindowEvent (
201
+ isTopLayer ,
180
202
'blur' ,
181
203
( event ) => {
182
204
return handleOutsideClick ( event , ( ) => {
0 commit comments