UNPKG

30.3 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 {chain, focusWithoutScrolling, getOwnerDocument, getOwnerWindow, isMac, isVirtualClick, isVirtualPointerEvent, mergeProps, openLink, useEffectEvent, useGlobalListeners, useSyncRef} from '@react-aria/utils';
19import {disableTextSelection, restoreTextSelection} from './textSelection';
20import {DOMAttributes, FocusableElement, PressEvent as IPressEvent, PointerType, PressEvents} from '@react-types/shared';
21import {PressResponderContext} from './context';
22import {RefObject, useContext, useEffect, useMemo, useRef, useState} from 'react';
23
24export interface PressProps extends PressEvents {
25 /** Whether the target is in a controlled press state (e.g. an overlay it triggers is open). */
26 isPressed?: boolean,
27 /** Whether the press events should be disabled. */
28 isDisabled?: boolean,
29 /** Whether the target should not receive focus on press. */
30 preventFocusOnPress?: boolean,
31 /**
32 * Whether press events should be canceled when the pointer leaves the target while pressed.
33 * By default, this is `false`, which means if the pointer returns back over the target while
34 * still pressed, onPressStart will be fired again. If set to `true`, the press is canceled
35 * when the pointer leaves the target and onPressStart will not be fired if the pointer returns.
36 */
37 shouldCancelOnPointerExit?: boolean,
38 /** Whether text selection should be enabled on the pressable element. */
39 allowTextSelectionOnPress?: boolean
40}
41
42export interface PressHookProps extends PressProps {
43 /** A ref to the target element. */
44 ref?: RefObject<Element>
45}
46
47interface PressState {
48 isPressed: boolean,
49 ignoreEmulatedMouseEvents: boolean,
50 ignoreClickAfterPress: boolean,
51 didFirePressStart: boolean,
52 isTriggeringEvent: boolean,
53 activePointerId: any,
54 target: FocusableElement | null,
55 isOverTarget: boolean,
56 pointerType: PointerType | null,
57 userSelect?: string,
58 metaKeyEvents?: Map<string, KeyboardEvent>
59}
60
61interface EventBase {
62 currentTarget: EventTarget | null,
63 shiftKey: boolean,
64 ctrlKey: boolean,
65 metaKey: boolean,
66 altKey: boolean
67}
68
69export interface PressResult {
70 /** Whether the target is currently pressed. */
71 isPressed: boolean,
72 /** Props to spread on the target element. */
73 pressProps: DOMAttributes
74}
75
76function usePressResponderContext(props: PressHookProps): PressHookProps {
77 // Consume context from <PressResponder> and merge with props.
78 let context = useContext(PressResponderContext);
79 if (context) {
80 let {register, ...contextProps} = context;
81 props = mergeProps(contextProps, props) as PressHookProps;
82 register();
83 }
84 useSyncRef(context, props.ref);
85
86 return props;
87}
88
89class PressEvent implements IPressEvent {
90 type: IPressEvent['type'];
91 pointerType: PointerType;
92 target: Element;
93 shiftKey: boolean;
94 ctrlKey: boolean;
95 metaKey: boolean;
96 altKey: boolean;
97 #shouldStopPropagation = true;
98
99 constructor(type: IPressEvent['type'], pointerType: PointerType, originalEvent: EventBase) {
100 this.type = type;
101 this.pointerType = pointerType;
102 this.target = originalEvent.currentTarget as Element;
103 this.shiftKey = originalEvent.shiftKey;
104 this.metaKey = originalEvent.metaKey;
105 this.ctrlKey = originalEvent.ctrlKey;
106 this.altKey = originalEvent.altKey;
107 }
108
109 continuePropagation() {
110 this.#shouldStopPropagation = false;
111 }
112
113 get shouldStopPropagation() {
114 return this.#shouldStopPropagation;
115 }
116}
117
118const LINK_CLICKED = Symbol('linkClicked');
119
120/**
121 * Handles press interactions across mouse, touch, keyboard, and screen readers.
122 * It normalizes behavior across browsers and platforms, and handles many nuances
123 * of dealing with pointer and keyboard events.
124 */
125export function usePress(props: PressHookProps): PressResult {
126 let {
127 onPress,
128 onPressChange,
129 onPressStart,
130 onPressEnd,
131 onPressUp,
132 isDisabled,
133 isPressed: isPressedProp,
134 preventFocusOnPress,
135 shouldCancelOnPointerExit,
136 allowTextSelectionOnPress,
137 // eslint-disable-next-line @typescript-eslint/no-unused-vars
138 ref: _, // Removing `ref` from `domProps` because TypeScript is dumb
139 ...domProps
140 } = usePressResponderContext(props);
141
142 let [isPressed, setPressed] = useState(false);
143 let ref = useRef<PressState>({
144 isPressed: false,
145 ignoreEmulatedMouseEvents: false,
146 ignoreClickAfterPress: false,
147 didFirePressStart: false,
148 isTriggeringEvent: false,
149 activePointerId: null,
150 target: null,
151 isOverTarget: false,
152 pointerType: null
153 });
154
155 let {addGlobalListener, removeAllGlobalListeners} = useGlobalListeners();
156
157 let triggerPressStart = useEffectEvent((originalEvent: EventBase, pointerType: PointerType) => {
158 let state = ref.current;
159 if (isDisabled || state.didFirePressStart) {
160 return false;
161 }
162
163 let shouldStopPropagation = true;
164 state.isTriggeringEvent = true;
165 if (onPressStart) {
166 let event = new PressEvent('pressstart', pointerType, originalEvent);
167 onPressStart(event);
168 shouldStopPropagation = event.shouldStopPropagation;
169 }
170
171 if (onPressChange) {
172 onPressChange(true);
173 }
174
175 state.isTriggeringEvent = false;
176 state.didFirePressStart = true;
177 setPressed(true);
178 return shouldStopPropagation;
179 });
180
181 let triggerPressEnd = useEffectEvent((originalEvent: EventBase, pointerType: PointerType, wasPressed = true) => {
182 let state = ref.current;
183 if (!state.didFirePressStart) {
184 return false;
185 }
186
187 state.ignoreClickAfterPress = true;
188 state.didFirePressStart = false;
189 state.isTriggeringEvent = true;
190
191 let shouldStopPropagation = true;
192 if (onPressEnd) {
193 let event = new PressEvent('pressend', pointerType, originalEvent);
194 onPressEnd(event);
195 shouldStopPropagation = event.shouldStopPropagation;
196 }
197
198 if (onPressChange) {
199 onPressChange(false);
200 }
201
202 setPressed(false);
203
204 if (onPress && wasPressed && !isDisabled) {
205 let event = new PressEvent('press', pointerType, originalEvent);
206 onPress(event);
207 shouldStopPropagation &&= event.shouldStopPropagation;
208 }
209
210 state.isTriggeringEvent = false;
211 return shouldStopPropagation;
212 });
213
214 let triggerPressUp = useEffectEvent((originalEvent: EventBase, pointerType: PointerType) => {
215 let state = ref.current;
216 if (isDisabled) {
217 return false;
218 }
219
220 if (onPressUp) {
221 state.isTriggeringEvent = true;
222 let event = new PressEvent('pressup', pointerType, originalEvent);
223 onPressUp(event);
224 state.isTriggeringEvent = false;
225 return event.shouldStopPropagation;
226 }
227
228 return true;
229 });
230
231 let cancel = useEffectEvent((e: EventBase) => {
232 let state = ref.current;
233 if (state.isPressed && state.target) {
234 if (state.isOverTarget && state.pointerType != null) {
235 triggerPressEnd(createEvent(state.target, e), state.pointerType, false);
236 }
237 state.isPressed = false;
238 state.isOverTarget = false;
239 state.activePointerId = null;
240 state.pointerType = null;
241 removeAllGlobalListeners();
242 if (!allowTextSelectionOnPress) {
243 restoreTextSelection(state.target);
244 }
245 }
246 });
247
248 let cancelOnPointerExit = useEffectEvent((e: EventBase) => {
249 if (shouldCancelOnPointerExit) {
250 cancel(e);
251 }
252 });
253
254 let pressProps = useMemo(() => {
255 let state = ref.current;
256 let pressProps: DOMAttributes = {
257 onKeyDown(e) {
258 if (isValidKeyboardEvent(e.nativeEvent, e.currentTarget) && e.currentTarget.contains(e.target as Element)) {
259 if (shouldPreventDefaultKeyboard(e.target as Element, e.key)) {
260 e.preventDefault();
261 }
262
263 // If the event is repeating, it may have started on a different element
264 // after which focus moved to the current element. Ignore these events and
265 // only handle the first key down event.
266 let shouldStopPropagation = true;
267 if (!state.isPressed && !e.repeat) {
268 state.target = e.currentTarget;
269 state.isPressed = true;
270 shouldStopPropagation = triggerPressStart(e, 'keyboard');
271
272 // Focus may move before the key up event, so register the event on the document
273 // instead of the same element where the key down event occurred. Make it capturing so that it will trigger
274 // before stopPropagation from useKeyboard on a child element may happen and thus we can still call triggerPress for the parent element.
275 let originalTarget = e.currentTarget;
276 let pressUp = (e) => {
277 if (isValidKeyboardEvent(e, originalTarget) && !e.repeat && originalTarget.contains(e.target as Element) && state.target) {
278 triggerPressUp(createEvent(state.target, e), 'keyboard');
279 }
280 };
281
282 addGlobalListener(getOwnerDocument(e.currentTarget), 'keyup', chain(pressUp, onKeyUp), true);
283 }
284
285 if (shouldStopPropagation) {
286 e.stopPropagation();
287 }
288
289 // Keep track of the keydown events that occur while the Meta (e.g. Command) key is held.
290 // macOS has a bug where keyup events are not fired while the Meta key is down.
291 // When the Meta key itself is released we will get an event for that, and we'll act as if
292 // all of these other keys were released as well.
293 // https://bugs.chromium.org/p/chromium/issues/detail?id=1393524
294 // https://bugs.webkit.org/show_bug.cgi?id=55291
295 // https://bugzilla.mozilla.org/show_bug.cgi?id=1299553
296 if (e.metaKey && isMac()) {
297 state.metaKeyEvents?.set(e.key, e.nativeEvent);
298 }
299 } else if (e.key === 'Meta') {
300 state.metaKeyEvents = new Map();
301 }
302 },
303 onClick(e) {
304 if (e && !e.currentTarget.contains(e.target as Element)) {
305 return;
306 }
307
308 if (e && e.button === 0 && !state.isTriggeringEvent && !(openLink as any).isOpening) {
309 let shouldStopPropagation = true;
310 if (isDisabled) {
311 e.preventDefault();
312 }
313
314 // If triggered from a screen reader or by using element.click(),
315 // trigger as if it were a keyboard click.
316 if (!state.ignoreClickAfterPress && !state.ignoreEmulatedMouseEvents && !state.isPressed && (state.pointerType === 'virtual' || isVirtualClick(e.nativeEvent))) {
317 // Ensure the element receives focus (VoiceOver on iOS does not do this)
318 if (!isDisabled && !preventFocusOnPress) {
319 focusWithoutScrolling(e.currentTarget);
320 }
321
322 let stopPressStart = triggerPressStart(e, 'virtual');
323 let stopPressUp = triggerPressUp(e, 'virtual');
324 let stopPressEnd = triggerPressEnd(e, 'virtual');
325 shouldStopPropagation = stopPressStart && stopPressUp && stopPressEnd;
326 }
327
328 state.ignoreEmulatedMouseEvents = false;
329 state.ignoreClickAfterPress = false;
330 if (shouldStopPropagation) {
331 e.stopPropagation();
332 }
333 }
334 }
335 };
336
337 let onKeyUp = (e: KeyboardEvent) => {
338 if (state.isPressed && state.target && isValidKeyboardEvent(e, state.target)) {
339 if (shouldPreventDefaultKeyboard(e.target as Element, e.key)) {
340 e.preventDefault();
341 }
342
343 let target = e.target as Element;
344 triggerPressEnd(createEvent(state.target, e), 'keyboard', state.target.contains(target));
345 removeAllGlobalListeners();
346
347 // If a link was triggered with a key other than Enter, open the URL ourselves.
348 // This means the link has a role override, and the default browser behavior
349 // only applies when using the Enter key.
350 if (e.key !== 'Enter' && isHTMLAnchorLink(state.target) && state.target.contains(target) && !e[LINK_CLICKED]) {
351 // Store a hidden property on the event so we only trigger link click once,
352 // even if there are multiple usePress instances attached to the element.
353 e[LINK_CLICKED] = true;
354 openLink(state.target, e, false);
355 }
356
357 state.isPressed = false;
358 state.metaKeyEvents?.delete(e.key);
359 } else if (e.key === 'Meta' && state.metaKeyEvents?.size) {
360 // If we recorded keydown events that occurred while the Meta key was pressed,
361 // and those haven't received keyup events already, fire keyup events ourselves.
362 // See comment above for more info about the macOS bug causing this.
363 let events = state.metaKeyEvents;
364 state.metaKeyEvents = undefined;
365 for (let event of events.values()) {
366 state.target?.dispatchEvent(new KeyboardEvent('keyup', event));
367 }
368 }
369 };
370
371 if (typeof PointerEvent !== 'undefined') {
372 pressProps.onPointerDown = (e) => {
373 // Only handle left clicks, and ignore events that bubbled through portals.
374 if (e.button !== 0 || !e.currentTarget.contains(e.target as Element)) {
375 return;
376 }
377
378 // iOS safari fires pointer events from VoiceOver with incorrect coordinates/target.
379 // Ignore and let the onClick handler take care of it instead.
380 // https://bugs.webkit.org/show_bug.cgi?id=222627
381 // https://bugs.webkit.org/show_bug.cgi?id=223202
382 if (isVirtualPointerEvent(e.nativeEvent)) {
383 state.pointerType = 'virtual';
384 return;
385 }
386
387 // Due to browser inconsistencies, especially on mobile browsers, we prevent
388 // default on pointer down and handle focusing the pressable element ourselves.
389 if (shouldPreventDefault(e.currentTarget as Element)) {
390 e.preventDefault();
391 }
392
393 state.pointerType = e.pointerType;
394
395 let shouldStopPropagation = true;
396 if (!state.isPressed) {
397 state.isPressed = true;
398 state.isOverTarget = true;
399 state.activePointerId = e.pointerId;
400 state.target = e.currentTarget;
401
402 if (!isDisabled && !preventFocusOnPress) {
403 focusWithoutScrolling(e.currentTarget);
404 }
405
406 if (!allowTextSelectionOnPress) {
407 disableTextSelection(state.target);
408 }
409
410 shouldStopPropagation = triggerPressStart(e, state.pointerType);
411
412 addGlobalListener(getOwnerDocument(e.currentTarget), 'pointermove', onPointerMove, false);
413 addGlobalListener(getOwnerDocument(e.currentTarget), 'pointerup', onPointerUp, false);
414 addGlobalListener(getOwnerDocument(e.currentTarget), 'pointercancel', onPointerCancel, false);
415 }
416
417 if (shouldStopPropagation) {
418 e.stopPropagation();
419 }
420 };
421
422 pressProps.onMouseDown = (e) => {
423 if (!e.currentTarget.contains(e.target as Element)) {
424 return;
425 }
426
427 if (e.button === 0) {
428 // Chrome and Firefox on touch Windows devices require mouse down events
429 // to be canceled in addition to pointer events, or an extra asynchronous
430 // focus event will be fired.
431 if (shouldPreventDefault(e.currentTarget as Element)) {
432 e.preventDefault();
433 }
434
435 e.stopPropagation();
436 }
437 };
438
439 pressProps.onPointerUp = (e) => {
440 // iOS fires pointerup with zero width and height, so check the pointerType recorded during pointerdown.
441 if (!e.currentTarget.contains(e.target as Element) || state.pointerType === 'virtual') {
442 return;
443 }
444
445 // Only handle left clicks
446 // Safari on iOS sometimes fires pointerup events, even
447 // when the touch isn't over the target, so double check.
448 if (e.button === 0 && isOverTarget(e, e.currentTarget)) {
449 triggerPressUp(e, state.pointerType || e.pointerType);
450 }
451 };
452
453 // Safari on iOS < 13.2 does not implement pointerenter/pointerleave events correctly.
454 // Use pointer move events instead to implement our own hit testing.
455 // See https://bugs.webkit.org/show_bug.cgi?id=199803
456 let onPointerMove = (e: PointerEvent) => {
457 if (e.pointerId !== state.activePointerId) {
458 return;
459 }
460
461 if (state.target && isOverTarget(e, state.target)) {
462 if (!state.isOverTarget && state.pointerType != null) {
463 state.isOverTarget = true;
464 triggerPressStart(createEvent(state.target, e), state.pointerType);
465 }
466 } else if (state.target && state.isOverTarget && state.pointerType != null) {
467 state.isOverTarget = false;
468 triggerPressEnd(createEvent(state.target, e), state.pointerType, false);
469 cancelOnPointerExit(e);
470 }
471 };
472
473 let onPointerUp = (e: PointerEvent) => {
474 if (e.pointerId === state.activePointerId && state.isPressed && e.button === 0 && state.target) {
475 if (isOverTarget(e, state.target) && state.pointerType != null) {
476 triggerPressEnd(createEvent(state.target, e), state.pointerType);
477 } else if (state.isOverTarget && state.pointerType != null) {
478 triggerPressEnd(createEvent(state.target, e), state.pointerType, false);
479 }
480
481 state.isPressed = false;
482 state.isOverTarget = false;
483 state.activePointerId = null;
484 state.pointerType = null;
485 removeAllGlobalListeners();
486 if (!allowTextSelectionOnPress) {
487 restoreTextSelection(state.target);
488 }
489 }
490 };
491
492 let onPointerCancel = (e: PointerEvent) => {
493 cancel(e);
494 };
495
496 pressProps.onDragStart = (e) => {
497 if (!e.currentTarget.contains(e.target as Element)) {
498 return;
499 }
500
501 // Safari does not call onPointerCancel when a drag starts, whereas Chrome and Firefox do.
502 cancel(e);
503 };
504 } else {
505 pressProps.onMouseDown = (e) => {
506 // Only handle left clicks
507 if (e.button !== 0 || !e.currentTarget.contains(e.target as Element)) {
508 return;
509 }
510
511 // Due to browser inconsistencies, especially on mobile browsers, we prevent
512 // default on mouse down and handle focusing the pressable element ourselves.
513 if (shouldPreventDefault(e.currentTarget)) {
514 e.preventDefault();
515 }
516
517 if (state.ignoreEmulatedMouseEvents) {
518 e.stopPropagation();
519 return;
520 }
521
522 state.isPressed = true;
523 state.isOverTarget = true;
524 state.target = e.currentTarget;
525 state.pointerType = isVirtualClick(e.nativeEvent) ? 'virtual' : 'mouse';
526
527 if (!isDisabled && !preventFocusOnPress) {
528 focusWithoutScrolling(e.currentTarget);
529 }
530
531 let shouldStopPropagation = triggerPressStart(e, state.pointerType);
532 if (shouldStopPropagation) {
533 e.stopPropagation();
534 }
535
536 addGlobalListener(getOwnerDocument(e.currentTarget), 'mouseup', onMouseUp, false);
537 };
538
539 pressProps.onMouseEnter = (e) => {
540 if (!e.currentTarget.contains(e.target as Element)) {
541 return;
542 }
543
544 let shouldStopPropagation = true;
545 if (state.isPressed && !state.ignoreEmulatedMouseEvents && state.pointerType != null) {
546 state.isOverTarget = true;
547 shouldStopPropagation = triggerPressStart(e, state.pointerType);
548 }
549
550 if (shouldStopPropagation) {
551 e.stopPropagation();
552 }
553 };
554
555 pressProps.onMouseLeave = (e) => {
556 if (!e.currentTarget.contains(e.target as Element)) {
557 return;
558 }
559
560 let shouldStopPropagation = true;
561 if (state.isPressed && !state.ignoreEmulatedMouseEvents && state.pointerType != null) {
562 state.isOverTarget = false;
563 shouldStopPropagation = triggerPressEnd(e, state.pointerType, false);
564 cancelOnPointerExit(e);
565 }
566
567 if (shouldStopPropagation) {
568 e.stopPropagation();
569 }
570 };
571
572 pressProps.onMouseUp = (e) => {
573 if (!e.currentTarget.contains(e.target as Element)) {
574 return;
575 }
576
577 if (!state.ignoreEmulatedMouseEvents && e.button === 0) {
578 triggerPressUp(e, state.pointerType || 'mouse');
579 }
580 };
581
582 let onMouseUp = (e: MouseEvent) => {
583 // Only handle left clicks
584 if (e.button !== 0) {
585 return;
586 }
587
588 state.isPressed = false;
589 removeAllGlobalListeners();
590
591 if (state.ignoreEmulatedMouseEvents) {
592 state.ignoreEmulatedMouseEvents = false;
593 return;
594 }
595
596 if (state.target && isOverTarget(e, state.target) && state.pointerType != null) {
597 triggerPressEnd(createEvent(state.target, e), state.pointerType);
598 } else if (state.target && state.isOverTarget && state.pointerType != null) {
599 triggerPressEnd(createEvent(state.target, e), state.pointerType, false);
600 }
601
602 state.isOverTarget = false;
603 };
604
605 pressProps.onTouchStart = (e) => {
606 if (!e.currentTarget.contains(e.target as Element)) {
607 return;
608 }
609
610 let touch = getTouchFromEvent(e.nativeEvent);
611 if (!touch) {
612 return;
613 }
614 state.activePointerId = touch.identifier;
615 state.ignoreEmulatedMouseEvents = true;
616 state.isOverTarget = true;
617 state.isPressed = true;
618 state.target = e.currentTarget;
619 state.pointerType = 'touch';
620
621 // Due to browser inconsistencies, especially on mobile browsers, we prevent default
622 // on the emulated mouse event and handle focusing the pressable element ourselves.
623 if (!isDisabled && !preventFocusOnPress) {
624 focusWithoutScrolling(e.currentTarget);
625 }
626
627 if (!allowTextSelectionOnPress) {
628 disableTextSelection(state.target);
629 }
630
631 let shouldStopPropagation = triggerPressStart(e, state.pointerType);
632 if (shouldStopPropagation) {
633 e.stopPropagation();
634 }
635
636 addGlobalListener(getOwnerWindow(e.currentTarget), 'scroll', onScroll, true);
637 };
638
639 pressProps.onTouchMove = (e) => {
640 if (!e.currentTarget.contains(e.target as Element)) {
641 return;
642 }
643
644 if (!state.isPressed) {
645 e.stopPropagation();
646 return;
647 }
648
649 let touch = getTouchById(e.nativeEvent, state.activePointerId);
650 let shouldStopPropagation = true;
651 if (touch && isOverTarget(touch, e.currentTarget)) {
652 if (!state.isOverTarget && state.pointerType != null) {
653 state.isOverTarget = true;
654 shouldStopPropagation = triggerPressStart(e, state.pointerType);
655 }
656 } else if (state.isOverTarget && state.pointerType != null) {
657 state.isOverTarget = false;
658 shouldStopPropagation = triggerPressEnd(e, state.pointerType, false);
659 cancelOnPointerExit(e);
660 }
661
662 if (shouldStopPropagation) {
663 e.stopPropagation();
664 }
665 };
666
667 pressProps.onTouchEnd = (e) => {
668 if (!e.currentTarget.contains(e.target as Element)) {
669 return;
670 }
671
672 if (!state.isPressed) {
673 e.stopPropagation();
674 return;
675 }
676
677 let touch = getTouchById(e.nativeEvent, state.activePointerId);
678 let shouldStopPropagation = true;
679 if (touch && isOverTarget(touch, e.currentTarget) && state.pointerType != null) {
680 triggerPressUp(e, state.pointerType);
681 shouldStopPropagation = triggerPressEnd(e, state.pointerType);
682 } else if (state.isOverTarget && state.pointerType != null) {
683 shouldStopPropagation = triggerPressEnd(e, state.pointerType, false);
684 }
685
686 if (shouldStopPropagation) {
687 e.stopPropagation();
688 }
689
690 state.isPressed = false;
691 state.activePointerId = null;
692 state.isOverTarget = false;
693 state.ignoreEmulatedMouseEvents = true;
694 if (state.target && !allowTextSelectionOnPress) {
695 restoreTextSelection(state.target);
696 }
697 removeAllGlobalListeners();
698 };
699
700 pressProps.onTouchCancel = (e) => {
701 if (!e.currentTarget.contains(e.target as Element)) {
702 return;
703 }
704
705 e.stopPropagation();
706 if (state.isPressed) {
707 cancel(e);
708 }
709 };
710
711 let onScroll = (e: Event) => {
712 if (state.isPressed && (e.target as Element).contains(state.target)) {
713 cancel({
714 currentTarget: state.target,
715 shiftKey: false,
716 ctrlKey: false,
717 metaKey: false,
718 altKey: false
719 });
720 }
721 };
722
723 pressProps.onDragStart = (e) => {
724 if (!e.currentTarget.contains(e.target as Element)) {
725 return;
726 }
727
728 cancel(e);
729 };
730 }
731
732 return pressProps;
733 }, [
734 addGlobalListener,
735 isDisabled,
736 preventFocusOnPress,
737 removeAllGlobalListeners,
738 allowTextSelectionOnPress,
739 cancel,
740 cancelOnPointerExit,
741 triggerPressEnd,
742 triggerPressStart,
743 triggerPressUp
744 ]);
745
746 // Remove user-select: none in case component unmounts immediately after pressStart
747 // eslint-disable-next-line arrow-body-style
748 useEffect(() => {
749 return () => {
750 if (!allowTextSelectionOnPress) {
751 // eslint-disable-next-line react-hooks/exhaustive-deps
752 restoreTextSelection(ref.current.target ?? undefined);
753 }
754 };
755 }, [allowTextSelectionOnPress]);
756
757 return {
758 isPressed: isPressedProp || isPressed,
759 pressProps: mergeProps(domProps, pressProps)
760 };
761}
762
763function isHTMLAnchorLink(target: Element): target is HTMLAnchorElement {
764 return target.tagName === 'A' && target.hasAttribute('href');
765}
766
767function isValidKeyboardEvent(event: KeyboardEvent, currentTarget: Element): boolean {
768 const {key, code} = event;
769 const element = currentTarget as HTMLElement;
770 const role = element.getAttribute('role');
771 // Accessibility for keyboards. Space and Enter only.
772 // "Spacebar" is for IE 11
773 return (
774 (key === 'Enter' || key === ' ' || key === 'Spacebar' || code === 'Space') &&
775 !((element instanceof getOwnerWindow(element).HTMLInputElement && !isValidInputKey(element, key)) ||
776 element instanceof getOwnerWindow(element).HTMLTextAreaElement ||
777 element.isContentEditable) &&
778 // Links should only trigger with Enter key
779 !((role === 'link' || (!role && isHTMLAnchorLink(element))) && key !== 'Enter')
780 );
781}
782
783function getTouchFromEvent(event: TouchEvent): Touch | null {
784 const {targetTouches} = event;
785 if (targetTouches.length > 0) {
786 return targetTouches[0];
787 }
788 return null;
789}
790
791function getTouchById(
792 event: TouchEvent,
793 pointerId: null | number
794): null | Touch {
795 const changedTouches = event.changedTouches;
796 for (let i = 0; i < changedTouches.length; i++) {
797 const touch = changedTouches[i];
798 if (touch.identifier === pointerId) {
799 return touch;
800 }
801 }
802 return null;
803}
804
805function createEvent(target: FocusableElement, e: EventBase): EventBase {
806 return {
807 currentTarget: target,
808 shiftKey: e.shiftKey,
809 ctrlKey: e.ctrlKey,
810 metaKey: e.metaKey,
811 altKey: e.altKey
812 };
813}
814
815interface Rect {
816 top: number,
817 right: number,
818 bottom: number,
819 left: number
820}
821
822interface EventPoint {
823 clientX: number,
824 clientY: number,
825 width?: number,
826 height?: number,
827 radiusX?: number,
828 radiusY?: number
829}
830
831function getPointClientRect(point: EventPoint): Rect {
832 let offsetX = 0;
833 let offsetY = 0;
834 if (point.width !== undefined) {
835 offsetX = (point.width / 2);
836 } else if (point.radiusX !== undefined) {
837 offsetX = point.radiusX;
838 }
839 if (point.height !== undefined) {
840 offsetY = (point.height / 2);
841 } else if (point.radiusY !== undefined) {
842 offsetY = point.radiusY;
843 }
844
845 return {
846 top: point.clientY - offsetY,
847 right: point.clientX + offsetX,
848 bottom: point.clientY + offsetY,
849 left: point.clientX - offsetX
850 };
851}
852
853function areRectanglesOverlapping(a: Rect, b: Rect) {
854 // check if they cannot overlap on x axis
855 if (a.left > b.right || b.left > a.right) {
856 return false;
857 }
858 // check if they cannot overlap on y axis
859 if (a.top > b.bottom || b.top > a.bottom) {
860 return false;
861 }
862 return true;
863}
864
865function isOverTarget(point: EventPoint, target: Element) {
866 let rect = target.getBoundingClientRect();
867 let pointRect = getPointClientRect(point);
868 return areRectanglesOverlapping(rect, pointRect);
869}
870
871function shouldPreventDefault(target: Element) {
872 // We cannot prevent default if the target is a draggable element.
873 return !(target instanceof HTMLElement) || !target.hasAttribute('draggable');
874}
875
876function shouldPreventDefaultKeyboard(target: Element, key: string) {
877 if (target instanceof HTMLInputElement) {
878 return !isValidInputKey(target, key);
879 }
880
881 if (target instanceof HTMLButtonElement) {
882 return target.type !== 'submit' && target.type !== 'reset';
883 }
884
885 if (isHTMLAnchorLink(target)) {
886 return false;
887 }
888
889 return true;
890}
891
892const nonTextInputTypes = new Set([
893 'checkbox',
894 'radio',
895 'range',
896 'color',
897 'file',
898 'image',
899 'button',
900 'submit',
901 'reset'
902]);
903
904function isValidInputKey(target: HTMLInputElement, key: string) {
905 // Only space should toggle checkboxes and radios, not enter.
906 return target.type === 'checkbox' || target.type === 'radio'
907 ? key === ' '
908 : nonTextInputTypes.has(target.type);
909}