-
Notifications
You must be signed in to change notification settings - Fork 21
/
useClickOutside.ts
158 lines (133 loc) · 4.76 KB
/
useClickOutside.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
import contains from 'dom-helpers/contains';
import listen from 'dom-helpers/listen';
import ownerDocument from 'dom-helpers/ownerDocument';
import { useCallback, useEffect, useRef } from 'react';
import useEventCallback from '@restart/hooks/useEventCallback';
import warning from 'warning';
const noop = () => {};
export type MouseEvents = {
[K in keyof GlobalEventHandlersEventMap]: GlobalEventHandlersEventMap[K] extends MouseEvent
? K
: never;
}[keyof GlobalEventHandlersEventMap];
function isLeftClickEvent(event: MouseEvent) {
return event.button === 0;
}
function isModifiedEvent(event: MouseEvent) {
return !!(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey);
}
export const getRefTarget = (
ref: React.RefObject<Element> | Element | null | undefined,
) => ref && ('current' in ref ? ref.current : ref);
export interface ClickOutsideOptions {
disabled?: boolean;
clickTrigger?: MouseEvents;
}
const InitialTriggerEvents: Partial<Record<MouseEvents, MouseEvents>> = {
click: 'mousedown',
mouseup: 'mousedown',
pointerup: 'pointerdown',
};
/**
* The `useClickOutside` hook registers your callback on the document that fires
* when a pointer event is registered outside of the provided ref or element.
*
* @param {Ref<HTMLElement>| HTMLElement} ref The element boundary
* @param {function} onClickOutside
* @param {object=} options
* @param {boolean=} options.disabled
* @param {string=} options.clickTrigger The DOM event name (click, mousedown, etc) to attach listeners on
*/
function useClickOutside(
ref: React.RefObject<Element> | Element | null | undefined,
onClickOutside: (e: Event) => void = noop,
{ disabled, clickTrigger = 'click' }: ClickOutsideOptions = {},
) {
const preventMouseClickOutsideRef = useRef(false);
const waitingForTrigger = useRef(false);
const handleMouseCapture = useCallback(
(e) => {
const currentTarget = getRefTarget(ref);
warning(
!!currentTarget,
'ClickOutside captured a close event but does not have a ref to compare it to. ' +
'useClickOutside(), should be passed a ref that resolves to a DOM node',
);
preventMouseClickOutsideRef.current =
!currentTarget ||
isModifiedEvent(e) ||
!isLeftClickEvent(e) ||
!!contains(currentTarget, e.target) ||
waitingForTrigger.current;
waitingForTrigger.current = false;
},
[ref],
);
const handleInitialMouse = useEventCallback((e: MouseEvent) => {
const currentTarget = getRefTarget(ref);
if (currentTarget && contains(currentTarget, e.target as any)) {
waitingForTrigger.current = true;
}
});
const handleMouse = useEventCallback((e: MouseEvent) => {
if (!preventMouseClickOutsideRef.current) {
onClickOutside(e);
}
});
useEffect(() => {
if (disabled || ref == null) return undefined;
const doc = ownerDocument(getRefTarget(ref)!);
const ownerWindow = doc.defaultView || window;
// Store the current event to avoid triggering handlers immediately
// For things rendered in an iframe, the event might originate on the parent window
// so we should fall back to that global event if the local one doesn't exist
// https://github.com/facebook/react/issues/20074
let currentEvent = ownerWindow.event ?? ownerWindow.parent?.event;
let removeInitialTriggerListener: (() => void) | null = null;
if (InitialTriggerEvents[clickTrigger]) {
removeInitialTriggerListener = listen(
doc as any,
InitialTriggerEvents[clickTrigger]!,
handleInitialMouse,
true,
);
}
// Use capture for this listener so it fires before React's listener, to
// avoid false positives in the contains() check below if the target DOM
// element is removed in the React mouse callback.
const removeMouseCaptureListener = listen(
doc as any,
clickTrigger,
handleMouseCapture,
true,
);
const removeMouseListener = listen(doc as any, clickTrigger, (e) => {
// skip if this event is the same as the one running when we added the handlers
if (e === currentEvent) {
currentEvent = undefined;
return;
}
handleMouse(e);
});
let mobileSafariHackListeners = [] as Array<() => void>;
if ('ontouchstart' in doc.documentElement) {
mobileSafariHackListeners = [].slice
.call(doc.body.children)
.map((el) => listen(el, 'mousemove', noop));
}
return () => {
removeInitialTriggerListener?.();
removeMouseCaptureListener();
removeMouseListener();
mobileSafariHackListeners.forEach((remove) => remove());
};
}, [
ref,
disabled,
clickTrigger,
handleMouseCapture,
handleInitialMouse,
handleMouse,
]);
}
export default useClickOutside;