UNPKG

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