UNPKG

30.1 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 {disableTextSelection, restoreTextSelection} from './textSelection';
19import {DOMAttributes, FocusableElement, PressEvent as IPressEvent, PointerType, PressEvents} from '@react-types/shared';
20import {focusWithoutScrolling, getOwnerDocument, getOwnerWindow, isMac, isVirtualClick, isVirtualPointerEvent, mergeProps, openLink, useEffectEvent, useGlobalListeners, useSyncRef} from '@react-aria/utils';
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.
274 addGlobalListener(getOwnerDocument(e.currentTarget), 'keyup', onKeyUp, false);
275 }
276
277 if (shouldStopPropagation) {
278 e.stopPropagation();
279 }
280
281 // Keep track of the keydown events that occur while the Meta (e.g. Command) key is held.
282 // macOS has a bug where keyup events are not fired while the Meta key is down.
283 // When the Meta key itself is released we will get an event for that, and we'll act as if
284 // all of these other keys were released as well.
285 // https://bugs.chromium.org/p/chromium/issues/detail?id=1393524
286 // https://bugs.webkit.org/show_bug.cgi?id=55291
287 // https://bugzilla.mozilla.org/show_bug.cgi?id=1299553
288 if (e.metaKey && isMac()) {
289 state.metaKeyEvents?.set(e.key, e.nativeEvent);
290 }
291 } else if (e.key === 'Meta') {
292 state.metaKeyEvents = new Map();
293 }
294 },
295 onKeyUp(e) {
296 if (isValidKeyboardEvent(e.nativeEvent, e.currentTarget) && !e.repeat && e.currentTarget.contains(e.target as Element) && state.target) {
297 triggerPressUp(createEvent(state.target, e), 'keyboard');
298 }
299 },
300 onClick(e) {
301 if (e && !e.currentTarget.contains(e.target as Element)) {
302 return;
303 }
304
305 if (e && e.button === 0 && !state.isTriggeringEvent && !(openLink as any).isOpening) {
306 let shouldStopPropagation = true;
307 if (isDisabled) {
308 e.preventDefault();
309 }
310
311 // If triggered from a screen reader or by using element.click(),
312 // trigger as if it were a keyboard click.
313 if (!state.ignoreClickAfterPress && !state.ignoreEmulatedMouseEvents && !state.isPressed && (state.pointerType === 'virtual' || isVirtualClick(e.nativeEvent))) {
314 // Ensure the element receives focus (VoiceOver on iOS does not do this)
315 if (!isDisabled && !preventFocusOnPress) {
316 focusWithoutScrolling(e.currentTarget);
317 }
318
319 let stopPressStart = triggerPressStart(e, 'virtual');
320 let stopPressUp = triggerPressUp(e, 'virtual');
321 let stopPressEnd = triggerPressEnd(e, 'virtual');
322 shouldStopPropagation = stopPressStart && stopPressUp && stopPressEnd;
323 }
324
325 state.ignoreEmulatedMouseEvents = false;
326 state.ignoreClickAfterPress = false;
327 if (shouldStopPropagation) {
328 e.stopPropagation();
329 }
330 }
331 }
332 };
333
334 let onKeyUp = (e: KeyboardEvent) => {
335 if (state.isPressed && state.target && isValidKeyboardEvent(e, state.target)) {
336 if (shouldPreventDefaultKeyboard(e.target as Element, e.key)) {
337 e.preventDefault();
338 }
339
340 let target = e.target as Element;
341 let shouldStopPropagation = triggerPressEnd(createEvent(state.target, e), 'keyboard', state.target.contains(target));
342 removeAllGlobalListeners();
343
344 if (shouldStopPropagation) {
345 e.stopPropagation();
346 }
347
348 // If a link was triggered with a key other than Enter, open the URL ourselves.
349 // This means the link has a role override, and the default browser behavior
350 // only applies when using the Enter key.
351 if (e.key !== 'Enter' && isHTMLAnchorLink(state.target) && state.target.contains(target) && !e[LINK_CLICKED]) {
352 // Store a hidden property on the event so we only trigger link click once,
353 // even if there are multiple usePress instances attached to the element.
354 e[LINK_CLICKED] = true;
355 openLink(state.target, e, false);
356 }
357
358 state.isPressed = false;
359 state.metaKeyEvents?.delete(e.key);
360 } else if (e.key === 'Meta' && state.metaKeyEvents?.size) {
361 // If we recorded keydown events that occurred while the Meta key was pressed,
362 // and those haven't received keyup events already, fire keyup events ourselves.
363 // See comment above for more info about the macOS bug causing this.
364 let events = state.metaKeyEvents;
365 state.metaKeyEvents = undefined;
366 for (let event of events.values()) {
367 state.target?.dispatchEvent(new KeyboardEvent('keyup', event));
368 }
369 }
370 };
371
372 if (typeof PointerEvent !== 'undefined') {
373 pressProps.onPointerDown = (e) => {
374 // Only handle left clicks, and ignore events that bubbled through portals.
375 if (e.button !== 0 || !e.currentTarget.contains(e.target as Element)) {
376 return;
377 }
378
379 // iOS safari fires pointer events from VoiceOver with incorrect coordinates/target.
380 // Ignore and let the onClick handler take care of it instead.
381 // https://bugs.webkit.org/show_bug.cgi?id=222627
382 // https://bugs.webkit.org/show_bug.cgi?id=223202
383 if (isVirtualPointerEvent(e.nativeEvent)) {
384 state.pointerType = 'virtual';
385 return;
386 }
387
388 // Due to browser inconsistencies, especially on mobile browsers, we prevent
389 // default on pointer down and handle focusing the pressable element ourselves.
390 if (shouldPreventDefault(e.currentTarget as Element)) {
391 e.preventDefault();
392 }
393
394 state.pointerType = e.pointerType;
395
396 let shouldStopPropagation = true;
397 if (!state.isPressed) {
398 state.isPressed = true;
399 state.isOverTarget = true;
400 state.activePointerId = e.pointerId;
401 state.target = e.currentTarget;
402
403 if (!isDisabled && !preventFocusOnPress) {
404 focusWithoutScrolling(e.currentTarget);
405 }
406
407 if (!allowTextSelectionOnPress) {
408 disableTextSelection(state.target);
409 }
410
411 shouldStopPropagation = triggerPressStart(e, state.pointerType);
412
413 addGlobalListener(getOwnerDocument(e.currentTarget), 'pointermove', onPointerMove, false);
414 addGlobalListener(getOwnerDocument(e.currentTarget), 'pointerup', onPointerUp, false);
415 addGlobalListener(getOwnerDocument(e.currentTarget), 'pointercancel', onPointerCancel, false);
416 }
417
418 if (shouldStopPropagation) {
419 e.stopPropagation();
420 }
421 };
422
423 pressProps.onMouseDown = (e) => {
424 if (!e.currentTarget.contains(e.target as Element)) {
425 return;
426 }
427
428 if (e.button === 0) {
429 // Chrome and Firefox on touch Windows devices require mouse down events
430 // to be canceled in addition to pointer events, or an extra asynchronous
431 // focus event will be fired.
432 if (shouldPreventDefault(e.currentTarget as Element)) {
433 e.preventDefault();
434 }
435
436 e.stopPropagation();
437 }
438 };
439
440 pressProps.onPointerUp = (e) => {
441 // iOS fires pointerup with zero width and height, so check the pointerType recorded during pointerdown.
442 if (!e.currentTarget.contains(e.target as Element) || state.pointerType === 'virtual') {
443 return;
444 }
445
446 // Only handle left clicks
447 // Safari on iOS sometimes fires pointerup events, even
448 // when the touch isn't over the target, so double check.
449 if (e.button === 0 && isOverTarget(e, e.currentTarget)) {
450 triggerPressUp(e, state.pointerType || e.pointerType);
451 }
452 };
453
454 // Safari on iOS < 13.2 does not implement pointerenter/pointerleave events correctly.
455 // Use pointer move events instead to implement our own hit testing.
456 // See https://bugs.webkit.org/show_bug.cgi?id=199803
457 let onPointerMove = (e: PointerEvent) => {
458 if (e.pointerId !== state.activePointerId) {
459 return;
460 }
461
462 if (state.target && isOverTarget(e, state.target)) {
463 if (!state.isOverTarget && state.pointerType != null) {
464 state.isOverTarget = true;
465 triggerPressStart(createEvent(state.target, e), state.pointerType);
466 }
467 } else if (state.target && state.isOverTarget && state.pointerType != null) {
468 state.isOverTarget = false;
469 triggerPressEnd(createEvent(state.target, e), state.pointerType, false);
470 cancelOnPointerExit(e);
471 }
472 };
473
474 let onPointerUp = (e: PointerEvent) => {
475 if (e.pointerId === state.activePointerId && state.isPressed && e.button === 0 && state.target) {
476 if (isOverTarget(e, state.target) && state.pointerType != null) {
477 triggerPressEnd(createEvent(state.target, e), state.pointerType);
478 } else if (state.isOverTarget && state.pointerType != null) {
479 triggerPressEnd(createEvent(state.target, e), state.pointerType, false);
480 }
481
482 state.isPressed = false;
483 state.isOverTarget = false;
484 state.activePointerId = null;
485 state.pointerType = null;
486 removeAllGlobalListeners();
487 if (!allowTextSelectionOnPress) {
488 restoreTextSelection(state.target);
489 }
490 }
491 };
492
493 let onPointerCancel = (e: PointerEvent) => {
494 cancel(e);
495 };
496
497 pressProps.onDragStart = (e) => {
498 if (!e.currentTarget.contains(e.target as Element)) {
499 return;
500 }
501
502 // Safari does not call onPointerCancel when a drag starts, whereas Chrome and Firefox do.
503 cancel(e);
504 };
505 } else {
506 pressProps.onMouseDown = (e) => {
507 // Only handle left clicks
508 if (e.button !== 0 || !e.currentTarget.contains(e.target as Element)) {
509 return;
510 }
511
512 // Due to browser inconsistencies, especially on mobile browsers, we prevent
513 // default on mouse down and handle focusing the pressable element ourselves.
514 if (shouldPreventDefault(e.currentTarget)) {
515 e.preventDefault();
516 }
517
518 if (state.ignoreEmulatedMouseEvents) {
519 e.stopPropagation();
520 return;
521 }
522
523 state.isPressed = true;
524 state.isOverTarget = true;
525 state.target = e.currentTarget;
526 state.pointerType = isVirtualClick(e.nativeEvent) ? 'virtual' : 'mouse';
527
528 if (!isDisabled && !preventFocusOnPress) {
529 focusWithoutScrolling(e.currentTarget);
530 }
531
532 let shouldStopPropagation = triggerPressStart(e, state.pointerType);
533 if (shouldStopPropagation) {
534 e.stopPropagation();
535 }
536
537 addGlobalListener(getOwnerDocument(e.currentTarget), 'mouseup', onMouseUp, false);
538 };
539
540 pressProps.onMouseEnter = (e) => {
541 if (!e.currentTarget.contains(e.target as Element)) {
542 return;
543 }
544
545 let shouldStopPropagation = true;
546 if (state.isPressed && !state.ignoreEmulatedMouseEvents && state.pointerType != null) {
547 state.isOverTarget = true;
548 shouldStopPropagation = triggerPressStart(e, state.pointerType);
549 }
550
551 if (shouldStopPropagation) {
552 e.stopPropagation();
553 }
554 };
555
556 pressProps.onMouseLeave = (e) => {
557 if (!e.currentTarget.contains(e.target as Element)) {
558 return;
559 }
560
561 let shouldStopPropagation = true;
562 if (state.isPressed && !state.ignoreEmulatedMouseEvents && state.pointerType != null) {
563 state.isOverTarget = false;
564 shouldStopPropagation = triggerPressEnd(e, state.pointerType, false);
565 cancelOnPointerExit(e);
566 }
567
568 if (shouldStopPropagation) {
569 e.stopPropagation();
570 }
571 };
572
573 pressProps.onMouseUp = (e) => {
574 if (!e.currentTarget.contains(e.target as Element)) {
575 return;
576 }
577
578 if (!state.ignoreEmulatedMouseEvents && e.button === 0) {
579 triggerPressUp(e, state.pointerType || 'mouse');
580 }
581 };
582
583 let onMouseUp = (e: MouseEvent) => {
584 // Only handle left clicks
585 if (e.button !== 0) {
586 return;
587 }
588
589 state.isPressed = false;
590 removeAllGlobalListeners();
591
592 if (state.ignoreEmulatedMouseEvents) {
593 state.ignoreEmulatedMouseEvents = false;
594 return;
595 }
596
597 if (state.target && isOverTarget(e, state.target) && state.pointerType != null) {
598 triggerPressEnd(createEvent(state.target, e), state.pointerType);
599 } else if (state.target && state.isOverTarget && state.pointerType != null) {
600 triggerPressEnd(createEvent(state.target, e), state.pointerType, false);
601 }
602
603 state.isOverTarget = false;
604 };
605
606 pressProps.onTouchStart = (e) => {
607 if (!e.currentTarget.contains(e.target as Element)) {
608 return;
609 }
610
611 let touch = getTouchFromEvent(e.nativeEvent);
612 if (!touch) {
613 return;
614 }
615 state.activePointerId = touch.identifier;
616 state.ignoreEmulatedMouseEvents = true;
617 state.isOverTarget = true;
618 state.isPressed = true;
619 state.target = e.currentTarget;
620 state.pointerType = 'touch';
621
622 // Due to browser inconsistencies, especially on mobile browsers, we prevent default
623 // on the emulated mouse event and handle focusing the pressable element ourselves.
624 if (!isDisabled && !preventFocusOnPress) {
625 focusWithoutScrolling(e.currentTarget);
626 }
627
628 if (!allowTextSelectionOnPress) {
629 disableTextSelection(state.target);
630 }
631
632 let shouldStopPropagation = triggerPressStart(e, state.pointerType);
633 if (shouldStopPropagation) {
634 e.stopPropagation();
635 }
636
637 addGlobalListener(getOwnerWindow(e.currentTarget), 'scroll', onScroll, true);
638 };
639
640 pressProps.onTouchMove = (e) => {
641 if (!e.currentTarget.contains(e.target as Element)) {
642 return;
643 }
644
645 if (!state.isPressed) {
646 e.stopPropagation();
647 return;
648 }
649
650 let touch = getTouchById(e.nativeEvent, state.activePointerId);
651 let shouldStopPropagation = true;
652 if (touch && isOverTarget(touch, e.currentTarget)) {
653 if (!state.isOverTarget && state.pointerType != null) {
654 state.isOverTarget = true;
655 shouldStopPropagation = triggerPressStart(e, state.pointerType);
656 }
657 } else if (state.isOverTarget && state.pointerType != null) {
658 state.isOverTarget = false;
659 shouldStopPropagation = triggerPressEnd(e, state.pointerType, false);
660 cancelOnPointerExit(e);
661 }
662
663 if (shouldStopPropagation) {
664 e.stopPropagation();
665 }
666 };
667
668 pressProps.onTouchEnd = (e) => {
669 if (!e.currentTarget.contains(e.target as Element)) {
670 return;
671 }
672
673 if (!state.isPressed) {
674 e.stopPropagation();
675 return;
676 }
677
678 let touch = getTouchById(e.nativeEvent, state.activePointerId);
679 let shouldStopPropagation = true;
680 if (touch && isOverTarget(touch, e.currentTarget) && state.pointerType != null) {
681 triggerPressUp(e, state.pointerType);
682 shouldStopPropagation = triggerPressEnd(e, state.pointerType);
683 } else if (state.isOverTarget && state.pointerType != null) {
684 shouldStopPropagation = triggerPressEnd(e, state.pointerType, false);
685 }
686
687 if (shouldStopPropagation) {
688 e.stopPropagation();
689 }
690
691 state.isPressed = false;
692 state.activePointerId = null;
693 state.isOverTarget = false;
694 state.ignoreEmulatedMouseEvents = true;
695 if (state.target && !allowTextSelectionOnPress) {
696 restoreTextSelection(state.target);
697 }
698 removeAllGlobalListeners();
699 };
700
701 pressProps.onTouchCancel = (e) => {
702 if (!e.currentTarget.contains(e.target as Element)) {
703 return;
704 }
705
706 e.stopPropagation();
707 if (state.isPressed) {
708 cancel(e);
709 }
710 };
711
712 let onScroll = (e: Event) => {
713 if (state.isPressed && (e.target as Element).contains(state.target)) {
714 cancel({
715 currentTarget: state.target,
716 shiftKey: false,
717 ctrlKey: false,
718 metaKey: false,
719 altKey: false
720 });
721 }
722 };
723
724 pressProps.onDragStart = (e) => {
725 if (!e.currentTarget.contains(e.target as Element)) {
726 return;
727 }
728
729 cancel(e);
730 };
731 }
732
733 return pressProps;
734 }, [
735 addGlobalListener,
736 isDisabled,
737 preventFocusOnPress,
738 removeAllGlobalListeners,
739 allowTextSelectionOnPress,
740 cancel,
741 cancelOnPointerExit,
742 triggerPressEnd,
743 triggerPressStart,
744 triggerPressUp
745 ]);
746
747 // Remove user-select: none in case component unmounts immediately after pressStart
748 // eslint-disable-next-line arrow-body-style
749 useEffect(() => {
750 return () => {
751 if (!allowTextSelectionOnPress) {
752 // eslint-disable-next-line react-hooks/exhaustive-deps
753 restoreTextSelection(ref.current.target ?? undefined);
754 }
755 };
756 }, [allowTextSelectionOnPress]);
757
758 return {
759 isPressed: isPressedProp || isPressed,
760 pressProps: mergeProps(domProps, pressProps)
761 };
762}
763
764function isHTMLAnchorLink(target: Element): target is HTMLAnchorElement {
765 return target.tagName === 'A' && target.hasAttribute('href');
766}
767
768function isValidKeyboardEvent(event: KeyboardEvent, currentTarget: Element): boolean {
769 const {key, code} = event;
770 const element = currentTarget as HTMLElement;
771 const role = element.getAttribute('role');
772 // Accessibility for keyboards. Space and Enter only.
773 // "Spacebar" is for IE 11
774 return (
775 (key === 'Enter' || key === ' ' || key === 'Spacebar' || code === 'Space') &&
776 !((element instanceof getOwnerWindow(element).HTMLInputElement && !isValidInputKey(element, key)) ||
777 element instanceof getOwnerWindow(element).HTMLTextAreaElement ||
778 element.isContentEditable) &&
779 // Links should only trigger with Enter key
780 !((role === 'link' || (!role && isHTMLAnchorLink(element))) && key !== 'Enter')
781 );
782}
783
784function getTouchFromEvent(event: TouchEvent): Touch | null {
785 const {targetTouches} = event;
786 if (targetTouches.length > 0) {
787 return targetTouches[0];
788 }
789 return null;
790}
791
792function getTouchById(
793 event: TouchEvent,
794 pointerId: null | number
795): null | Touch {
796 const changedTouches = event.changedTouches;
797 for (let i = 0; i < changedTouches.length; i++) {
798 const touch = changedTouches[i];
799 if (touch.identifier === pointerId) {
800 return touch;
801 }
802 }
803 return null;
804}
805
806function createEvent(target: FocusableElement, e: EventBase): EventBase {
807 return {
808 currentTarget: target,
809 shiftKey: e.shiftKey,
810 ctrlKey: e.ctrlKey,
811 metaKey: e.metaKey,
812 altKey: e.altKey
813 };
814}
815
816interface Rect {
817 top: number,
818 right: number,
819 bottom: number,
820 left: number
821}
822
823interface EventPoint {
824 clientX: number,
825 clientY: number,
826 width?: number,
827 height?: number,
828 radiusX?: number,
829 radiusY?: number
830}
831
832function getPointClientRect(point: EventPoint): Rect {
833 let offsetX = 0;
834 let offsetY = 0;
835 if (point.width !== undefined) {
836 offsetX = (point.width / 2);
837 } else if (point.radiusX !== undefined) {
838 offsetX = point.radiusX;
839 }
840 if (point.height !== undefined) {
841 offsetY = (point.height / 2);
842 } else if (point.radiusY !== undefined) {
843 offsetY = point.radiusY;
844 }
845
846 return {
847 top: point.clientY - offsetY,
848 right: point.clientX + offsetX,
849 bottom: point.clientY + offsetY,
850 left: point.clientX - offsetX
851 };
852}
853
854function areRectanglesOverlapping(a: Rect, b: Rect) {
855 // check if they cannot overlap on x axis
856 if (a.left > b.right || b.left > a.right) {
857 return false;
858 }
859 // check if they cannot overlap on y axis
860 if (a.top > b.bottom || b.top > a.bottom) {
861 return false;
862 }
863 return true;
864}
865
866function isOverTarget(point: EventPoint, target: Element) {
867 let rect = target.getBoundingClientRect();
868 let pointRect = getPointClientRect(point);
869 return areRectanglesOverlapping(rect, pointRect);
870}
871
872function shouldPreventDefault(target: Element) {
873 // We cannot prevent default if the target is a draggable element.
874 return !(target instanceof HTMLElement) || !target.hasAttribute('draggable');
875}
876
877function shouldPreventDefaultKeyboard(target: Element, key: string) {
878 if (target instanceof HTMLInputElement) {
879 return !isValidInputKey(target, key);
880 }
881
882 if (target instanceof HTMLButtonElement) {
883 return target.type !== 'submit' && target.type !== 'reset';
884 }
885
886 if (isHTMLAnchorLink(target)) {
887 return false;
888 }
889
890 return true;
891}
892
893const nonTextInputTypes = new Set([
894 'checkbox',
895 'radio',
896 'range',
897 'color',
898 'file',
899 'image',
900 'button',
901 'submit',
902 'reset'
903]);
904
905function isValidInputKey(target: HTMLInputElement, key: string) {
906 // Only space should toggle checkboxes and radios, not enter.
907 return target.type === 'checkbox' || target.type === 'radio'
908 ? key === ' '
909 : nonTextInputTypes.has(target.type);
910}