UNPKG

4.76 kBPlain TextView Raw
1/*
2 * Copyright 2020 Adobe. All rights reserved.
3 * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4 * you may not use this file except in compliance with the License. You may obtain a copy
5 * of the License at http://www.apache.org/licenses/LICENSE-2.0
6 *
7 * Unless required by applicable law or agreed to in writing, software distributed under
8 * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9 * OF ANY KIND, either express or implied. See the License for the specific language
10 * governing permissions and limitations under the License.
11 */
12
13// Portions of the code in this file are based on code from react.
14// Original licensing for the following can be found in the
15// NOTICE file in the root directory of this source tree.
16// See https://github.com/facebook/react/tree/cc7c1aece46a6b69b41958d731e0fd27c94bfc6c/packages/react-interactions
17
18import {getOwnerDocument, useEffectEvent} from '@react-aria/utils';
19import {RefObject} from '@react-types/shared';
20import {useEffect, useRef} from 'react';
21
22export interface InteractOutsideProps {
23 ref: RefObject<Element | null>,
24 onInteractOutside?: (e: PointerEvent) => void,
25 onInteractOutsideStart?: (e: PointerEvent) => void,
26 /** Whether the interact outside events should be disabled. */
27 isDisabled?: boolean
28}
29
30/**
31 * Example, used in components like Dialogs and Popovers so they can close
32 * when a user clicks outside them.
33 */
34export function useInteractOutside(props: InteractOutsideProps) {
35 let {ref, onInteractOutside, isDisabled, onInteractOutsideStart} = props;
36 let stateRef = useRef({
37 isPointerDown: false,
38 ignoreEmulatedMouseEvents: false
39 });
40
41 let onPointerDown = useEffectEvent((e) => {
42 if (onInteractOutside && isValidEvent(e, ref)) {
43 if (onInteractOutsideStart) {
44 onInteractOutsideStart(e);
45 }
46 stateRef.current.isPointerDown = true;
47 }
48 });
49
50 let triggerInteractOutside = useEffectEvent((e: PointerEvent) => {
51 if (onInteractOutside) {
52 onInteractOutside(e);
53 }
54 });
55
56 useEffect(() => {
57 let state = stateRef.current;
58 if (isDisabled) {
59 return;
60 }
61
62 const element = ref.current;
63 const documentObject = getOwnerDocument(element);
64
65 // Use pointer events if available. Otherwise, fall back to mouse and touch events.
66 if (typeof PointerEvent !== 'undefined') {
67 let onPointerUp = (e) => {
68 if (state.isPointerDown && isValidEvent(e, ref)) {
69 triggerInteractOutside(e);
70 }
71 state.isPointerDown = false;
72 };
73
74 // changing these to capture phase fixed combobox
75 documentObject.addEventListener('pointerdown', onPointerDown, true);
76 documentObject.addEventListener('pointerup', onPointerUp, true);
77
78 return () => {
79 documentObject.removeEventListener('pointerdown', onPointerDown, true);
80 documentObject.removeEventListener('pointerup', onPointerUp, true);
81 };
82 } else {
83 let onMouseUp = (e) => {
84 if (state.ignoreEmulatedMouseEvents) {
85 state.ignoreEmulatedMouseEvents = false;
86 } else if (state.isPointerDown && isValidEvent(e, ref)) {
87 triggerInteractOutside(e);
88 }
89 state.isPointerDown = false;
90 };
91
92 let onTouchEnd = (e) => {
93 state.ignoreEmulatedMouseEvents = true;
94 if (state.isPointerDown && isValidEvent(e, ref)) {
95 triggerInteractOutside(e);
96 }
97 state.isPointerDown = false;
98 };
99
100 documentObject.addEventListener('mousedown', onPointerDown, true);
101 documentObject.addEventListener('mouseup', onMouseUp, true);
102 documentObject.addEventListener('touchstart', onPointerDown, true);
103 documentObject.addEventListener('touchend', onTouchEnd, true);
104
105 return () => {
106 documentObject.removeEventListener('mousedown', onPointerDown, true);
107 documentObject.removeEventListener('mouseup', onMouseUp, true);
108 documentObject.removeEventListener('touchstart', onPointerDown, true);
109 documentObject.removeEventListener('touchend', onTouchEnd, true);
110 };
111 }
112 }, [ref, isDisabled, onPointerDown, triggerInteractOutside]);
113}
114
115function isValidEvent(event, ref) {
116 if (event.button > 0) {
117 return false;
118 }
119
120 if (event.target) {
121 // if the event target is no longer in the document, ignore
122 const ownerDocument = event.target.ownerDocument;
123 if (!ownerDocument || !ownerDocument.documentElement.contains(event.target)) {
124 return false;
125 }
126
127 // If the target is within a top layer element (e.g. toasts), ignore.
128 if (event.target.closest('[data-react-aria-top-layer]')) {
129 return false;
130 }
131 }
132
133 return ref.current && !ref.current.contains(event.target);
134}