UNPKG

4.72 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, useEffect, useRef} from 'react';
20
21export interface InteractOutsideProps {
22 ref: RefObject<Element>,
23 onInteractOutside?: (e: PointerEvent) => void,
24 onInteractOutsideStart?: (e: PointerEvent) => void,
25 /** Whether the interact outside events should be disabled. */
26 isDisabled?: boolean
27}
28
29/**
30 * Example, used in components like Dialogs and Popovers so they can close
31 * when a user clicks outside them.
32 */
33export function useInteractOutside(props: InteractOutsideProps) {
34 let {ref, onInteractOutside, isDisabled, onInteractOutsideStart} = props;
35 let stateRef = useRef({
36 isPointerDown: false,
37 ignoreEmulatedMouseEvents: false
38 });
39
40 let onPointerDown = useEffectEvent((e) => {
41 if (onInteractOutside && isValidEvent(e, ref)) {
42 if (onInteractOutsideStart) {
43 onInteractOutsideStart(e);
44 }
45 stateRef.current.isPointerDown = true;
46 }
47 });
48
49 let triggerInteractOutside = useEffectEvent((e: PointerEvent) => {
50 if (onInteractOutside) {
51 onInteractOutside(e);
52 }
53 });
54
55 useEffect(() => {
56 let state = stateRef.current;
57 if (isDisabled) {
58 return;
59 }
60
61 const element = ref.current;
62 const documentObject = getOwnerDocument(element);
63
64 // Use pointer events if available. Otherwise, fall back to mouse and touch events.
65 if (typeof PointerEvent !== 'undefined') {
66 let onPointerUp = (e) => {
67 if (state.isPointerDown && isValidEvent(e, ref)) {
68 triggerInteractOutside(e);
69 }
70 state.isPointerDown = false;
71 };
72
73 // changing these to capture phase fixed combobox
74 documentObject.addEventListener('pointerdown', onPointerDown, true);
75 documentObject.addEventListener('pointerup', onPointerUp, true);
76
77 return () => {
78 documentObject.removeEventListener('pointerdown', onPointerDown, true);
79 documentObject.removeEventListener('pointerup', onPointerUp, true);
80 };
81 } else {
82 let onMouseUp = (e) => {
83 if (state.ignoreEmulatedMouseEvents) {
84 state.ignoreEmulatedMouseEvents = false;
85 } else if (state.isPointerDown && isValidEvent(e, ref)) {
86 triggerInteractOutside(e);
87 }
88 state.isPointerDown = false;
89 };
90
91 let onTouchEnd = (e) => {
92 state.ignoreEmulatedMouseEvents = true;
93 if (state.isPointerDown && isValidEvent(e, ref)) {
94 triggerInteractOutside(e);
95 }
96 state.isPointerDown = false;
97 };
98
99 documentObject.addEventListener('mousedown', onPointerDown, true);
100 documentObject.addEventListener('mouseup', onMouseUp, true);
101 documentObject.addEventListener('touchstart', onPointerDown, true);
102 documentObject.addEventListener('touchend', onTouchEnd, true);
103
104 return () => {
105 documentObject.removeEventListener('mousedown', onPointerDown, true);
106 documentObject.removeEventListener('mouseup', onMouseUp, true);
107 documentObject.removeEventListener('touchstart', onPointerDown, true);
108 documentObject.removeEventListener('touchend', onTouchEnd, true);
109 };
110 }
111 }, [ref, isDisabled, onPointerDown, triggerInteractOutside]);
112}
113
114function isValidEvent(event, ref) {
115 if (event.button > 0) {
116 return false;
117 }
118
119 if (event.target) {
120 // if the event target is no longer in the document, ignore
121 const ownerDocument = event.target.ownerDocument;
122 if (!ownerDocument || !ownerDocument.documentElement.contains(event.target)) {
123 return false;
124 }
125
126 // If the target is within a top layer element (e.g. toasts), ignore.
127 if (event.target.closest('[data-react-aria-top-layer]')) {
128 return false;
129 }
130 }
131
132 return ref.current && !ref.current.contains(event.target);
133}