UNPKG

6.11 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 {DOMAttributes} from '@react-types/shared';
19import {HoverEvents} from '@react-types/shared';
20import {useEffect, useMemo, useRef, useState} from 'react';
21
22export interface HoverProps extends HoverEvents {
23 /** Whether the hover events should be disabled. */
24 isDisabled?: boolean
25}
26
27export interface HoverResult {
28 /** Props to spread on the target element. */
29 hoverProps: DOMAttributes,
30 isHovered: boolean
31}
32
33// iOS fires onPointerEnter twice: once with pointerType="touch" and again with pointerType="mouse".
34// We want to ignore these emulated events so they do not trigger hover behavior.
35// See https://bugs.webkit.org/show_bug.cgi?id=214609.
36let globalIgnoreEmulatedMouseEvents = false;
37let hoverCount = 0;
38
39function setGlobalIgnoreEmulatedMouseEvents() {
40 globalIgnoreEmulatedMouseEvents = true;
41
42 // Clear globalIgnoreEmulatedMouseEvents after a short timeout. iOS fires onPointerEnter
43 // with pointerType="mouse" immediately after onPointerUp and before onFocus. On other
44 // devices that don't have this quirk, we don't want to ignore a mouse hover sometime in
45 // the distant future because a user previously touched the element.
46 setTimeout(() => {
47 globalIgnoreEmulatedMouseEvents = false;
48 }, 50);
49}
50
51function handleGlobalPointerEvent(e) {
52 if (e.pointerType === 'touch') {
53 setGlobalIgnoreEmulatedMouseEvents();
54 }
55}
56
57function setupGlobalTouchEvents() {
58 if (typeof document === 'undefined') {
59 return;
60 }
61
62 if (typeof PointerEvent !== 'undefined') {
63 document.addEventListener('pointerup', handleGlobalPointerEvent);
64 } else {
65 document.addEventListener('touchend', setGlobalIgnoreEmulatedMouseEvents);
66 }
67
68 hoverCount++;
69 return () => {
70 hoverCount--;
71 if (hoverCount > 0) {
72 return;
73 }
74
75 if (typeof PointerEvent !== 'undefined') {
76 document.removeEventListener('pointerup', handleGlobalPointerEvent);
77 } else {
78 document.removeEventListener('touchend', setGlobalIgnoreEmulatedMouseEvents);
79 }
80 };
81}
82
83/**
84 * Handles pointer hover interactions for an element. Normalizes behavior
85 * across browsers and platforms, and ignores emulated mouse events on touch devices.
86 */
87export function useHover(props: HoverProps): HoverResult {
88 let {
89 onHoverStart,
90 onHoverChange,
91 onHoverEnd,
92 isDisabled
93 } = props;
94
95 let [isHovered, setHovered] = useState(false);
96 let state = useRef({
97 isHovered: false,
98 ignoreEmulatedMouseEvents: false,
99 pointerType: '',
100 target: null
101 }).current;
102
103 useEffect(setupGlobalTouchEvents, []);
104
105 let {hoverProps, triggerHoverEnd} = useMemo(() => {
106 let triggerHoverStart = (event, pointerType) => {
107 state.pointerType = pointerType;
108 if (isDisabled || pointerType === 'touch' || state.isHovered || !event.currentTarget.contains(event.target)) {
109 return;
110 }
111
112 state.isHovered = true;
113 let target = event.currentTarget;
114 state.target = target;
115
116 if (onHoverStart) {
117 onHoverStart({
118 type: 'hoverstart',
119 target,
120 pointerType
121 });
122 }
123
124 if (onHoverChange) {
125 onHoverChange(true);
126 }
127
128 setHovered(true);
129 };
130
131 let triggerHoverEnd = (event, pointerType) => {
132 state.pointerType = '';
133 state.target = null;
134
135 if (pointerType === 'touch' || !state.isHovered) {
136 return;
137 }
138
139 state.isHovered = false;
140 let target = event.currentTarget;
141 if (onHoverEnd) {
142 onHoverEnd({
143 type: 'hoverend',
144 target,
145 pointerType
146 });
147 }
148
149 if (onHoverChange) {
150 onHoverChange(false);
151 }
152
153 setHovered(false);
154 };
155
156 let hoverProps: DOMAttributes = {};
157
158 if (typeof PointerEvent !== 'undefined') {
159 hoverProps.onPointerEnter = (e) => {
160 if (globalIgnoreEmulatedMouseEvents && e.pointerType === 'mouse') {
161 return;
162 }
163
164 triggerHoverStart(e, e.pointerType);
165 };
166
167 hoverProps.onPointerLeave = (e) => {
168 if (!isDisabled && e.currentTarget.contains(e.target as Element)) {
169 triggerHoverEnd(e, e.pointerType);
170 }
171 };
172 } else {
173 hoverProps.onTouchStart = () => {
174 state.ignoreEmulatedMouseEvents = true;
175 };
176
177 hoverProps.onMouseEnter = (e) => {
178 if (!state.ignoreEmulatedMouseEvents && !globalIgnoreEmulatedMouseEvents) {
179 triggerHoverStart(e, 'mouse');
180 }
181
182 state.ignoreEmulatedMouseEvents = false;
183 };
184
185 hoverProps.onMouseLeave = (e) => {
186 if (!isDisabled && e.currentTarget.contains(e.target as Element)) {
187 triggerHoverEnd(e, 'mouse');
188 }
189 };
190 }
191 return {hoverProps, triggerHoverEnd};
192 }, [onHoverStart, onHoverChange, onHoverEnd, isDisabled, state]);
193
194 useEffect(() => {
195 // Call the triggerHoverEnd as soon as isDisabled changes to true
196 // Safe to call triggerHoverEnd, it will early return if we aren't currently hovering
197 if (isDisabled) {
198 triggerHoverEnd({currentTarget: state.target}, state.pointerType);
199 }
200 // eslint-disable-next-line react-hooks/exhaustive-deps
201 }, [isDisabled]);
202
203 return {
204 hoverProps,
205 isHovered
206 };
207}
208