/
ClickAwayListener.js
179 lines (149 loc) · 5.61 KB
/
ClickAwayListener.js
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
import * as React from 'react';
import PropTypes from 'prop-types';
import { elementAcceptingRef, exactProp } from '@material-ui/utils';
import ownerDocument from '../utils/ownerDocument';
import useForkRef from '../utils/useForkRef';
import useEventCallback from '../utils/useEventCallback';
function mapEventPropToEvent(eventProp) {
return eventProp.substring(2).toLowerCase();
}
function clickedRootScrollbar(event, doc) {
return (
doc.documentElement.clientWidth < event.clientX ||
doc.documentElement.clientHeight < event.clientY
);
}
/**
* Listen for click events that occur somewhere in the document, outside of the element itself.
* For instance, if you need to hide a menu when people click anywhere else on your page.
*/
function ClickAwayListener(props) {
const {
children,
disableReactTree = false,
mouseEvent = 'onClick',
onClickAway,
touchEvent = 'onTouchEnd',
} = props;
const movedRef = React.useRef(false);
const nodeRef = React.useRef(null);
const mountedRef = React.useRef(false);
const syntheticEventRef = React.useRef(false);
React.useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
const handleRef = useForkRef(children.ref, nodeRef);
// The handler doesn't take event.defaultPrevented into account:
//
// event.preventDefault() is meant to stop default behaviours like
// clicking a checkbox to check it, hitting a button to submit a form,
// and hitting left arrow to move the cursor in a text input etc.
// Only special HTML elements have these default behaviors.
const handleClickAway = useEventCallback((event) => {
// Given developers can stop the propagation of the synthetic event,
// we can only be confident with a positive value.
const insideReactTree = syntheticEventRef.current;
syntheticEventRef.current = false;
const doc = ownerDocument(nodeRef.current);
// 1. IE 11 support, which trigger the handleClickAway even after the unbind
// 2. The child might render null.
// 3. Behave like a blur listener.
if (!mountedRef.current || !nodeRef.current || clickedRootScrollbar(event, doc)) {
return;
}
// Do not act if user performed touchmove
if (movedRef.current) {
movedRef.current = false;
return;
}
let insideDOM;
// If not enough, can use https://github.com/DieterHolvoet/event-propagation-path/blob/master/propagationPath.js
if (event.composedPath) {
insideDOM = event.composedPath().indexOf(nodeRef.current) > -1;
} else {
insideDOM =
!doc.documentElement.contains(event.target) || nodeRef.current.contains(event.target);
}
if (!insideDOM && (disableReactTree || !insideReactTree)) {
onClickAway(event);
}
});
// Keep track of mouse/touch events that bubbled up through the portal.
const createHandleSynthetic = (handlerName) => (event) => {
syntheticEventRef.current = true;
const childrenPropsHandler = children.props[handlerName];
if (childrenPropsHandler) {
childrenPropsHandler(event);
}
};
const childrenProps = { ref: handleRef };
if (touchEvent !== false) {
childrenProps[touchEvent] = createHandleSynthetic(touchEvent);
}
React.useEffect(() => {
if (touchEvent !== false) {
const mappedTouchEvent = mapEventPropToEvent(touchEvent);
const doc = ownerDocument(nodeRef.current);
const handleTouchMove = () => {
movedRef.current = true;
};
doc.addEventListener(mappedTouchEvent, handleClickAway);
doc.addEventListener('touchmove', handleTouchMove);
return () => {
doc.removeEventListener(mappedTouchEvent, handleClickAway);
doc.removeEventListener('touchmove', handleTouchMove);
};
}
return undefined;
}, [handleClickAway, touchEvent]);
if (mouseEvent !== false) {
childrenProps[mouseEvent] = createHandleSynthetic(mouseEvent);
}
React.useEffect(() => {
if (mouseEvent !== false) {
const mappedMouseEvent = mapEventPropToEvent(mouseEvent);
const doc = ownerDocument(nodeRef.current);
doc.addEventListener(mappedMouseEvent, handleClickAway);
return () => {
doc.removeEventListener(mappedMouseEvent, handleClickAway);
};
}
return undefined;
}, [handleClickAway, mouseEvent]);
return <React.Fragment>{React.cloneElement(children, childrenProps)}</React.Fragment>;
}
ClickAwayListener.propTypes = {
// ----------------------------- Warning --------------------------------
// | These PropTypes are generated from the TypeScript type definitions |
// | To update them edit the d.ts file and run "yarn proptypes" |
// ----------------------------------------------------------------------
/**
* The wrapped element.
*/
children: elementAcceptingRef.isRequired,
/**
* If `true`, the React tree is ignored and only the DOM tree is considered.
* This prop changes how portaled elements are handled.
*/
disableReactTree: PropTypes.bool,
/**
* The mouse event to listen to. You can disable the listener by providing `false`.
*/
mouseEvent: PropTypes.oneOf(['onClick', 'onMouseDown', 'onMouseUp', false]),
/**
* Callback fired when a "click away" event is detected.
*/
onClickAway: PropTypes.func.isRequired,
/**
* The touch event to listen to. You can disable the listener by providing `false`.
*/
touchEvent: PropTypes.oneOf(['onTouchEnd', 'onTouchStart', false]),
};
if (process.env.NODE_ENV !== 'production') {
// eslint-disable-next-line
ClickAwayListener['propTypes' + ''] = exactProp(ClickAwayListener.propTypes);
}
export default ClickAwayListener;