-
Notifications
You must be signed in to change notification settings - Fork 222
/
useRootClose.ts
146 lines (124 loc) · 4.27 KB
/
useRootClose.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
import contains from 'dom-helpers/contains';
import listen from 'dom-helpers/listen';
import { useCallback, useEffect, useRef } from 'react';
import useEventCallback from '@restart/hooks/useEventCallback';
import warning from 'warning';
import ownerDocument from './ownerDocument';
const escapeKeyCode = 27;
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);
}
const getRefTarget = (
ref: React.RefObject<Element> | Element | null | undefined,
) => ref && ('current' in ref ? ref.current : ref);
export interface RootCloseOptions {
disabled?: boolean;
clickTrigger?: MouseEvents;
}
/**
* The `useRootClose` hook registers your callback on the document
* when rendered. Powers the `<Overlay/>` component. This is used achieve modal
* style behavior where your callback is triggered when the user tries to
* interact with the rest of the document or hits the `esc` key.
*
* @param {Ref<HTMLElement>| HTMLElement} ref The element boundary
* @param {function} onRootClose
* @param {object=} options
* @param {boolean=} options.disabled
* @param {string=} options.clickTrigger The DOM event name (click, mousedown, etc) to attach listeners on
*/
function useRootClose(
ref: React.RefObject<Element> | Element | null | undefined,
onRootClose: (e: Event) => void,
{ disabled, clickTrigger = 'click' }: RootCloseOptions = {},
) {
const preventMouseRootCloseRef = useRef(false);
const onClose = onRootClose || noop;
const handleMouseCapture = useCallback(
(e) => {
const currentTarget = getRefTarget(ref);
warning(
!!currentTarget,
'RootClose captured a close event but does not have a ref to compare it to. ' +
'useRootClose(), should be passed a ref that resolves to a DOM node',
);
preventMouseRootCloseRef.current =
!currentTarget ||
isModifiedEvent(e) ||
!isLeftClickEvent(e) ||
!!contains(currentTarget, e.composedPath?.()[0] ?? e.target);
},
[ref],
);
const handleMouse = useEventCallback((e: MouseEvent) => {
if (!preventMouseRootCloseRef.current) {
onClose(e);
}
});
const handleKeyUp = useEventCallback((e: KeyboardEvent) => {
if (e.keyCode === escapeKeyCode) {
onClose(e);
}
});
useEffect(() => {
if (disabled || ref == null) return undefined;
// Store the current event to avoid triggering handlers immediately
// https://github.com/facebook/react/issues/20074
let currentEvent = window.event;
const doc = ownerDocument(getRefTarget(ref));
// 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);
});
const removeKeyupListener = listen(doc as any, 'keyup', (e) => {
// skip if this event is the same as the one running when we added the handlers
if (e === currentEvent) {
currentEvent = undefined;
return;
}
handleKeyUp(e);
});
let mobileSafariHackListeners = [] as Array<() => void>;
if ('ontouchstart' in doc.documentElement) {
mobileSafariHackListeners = [].slice
.call(doc.body.children)
.map((el) => listen(el, 'mousemove', noop));
}
return () => {
removeMouseCaptureListener();
removeMouseListener();
removeKeyupListener();
mobileSafariHackListeners.forEach((remove) => remove());
};
}, [
ref,
disabled,
clickTrigger,
handleMouseCapture,
handleMouse,
handleKeyUp,
]);
}
export default useRootClose;