UNPKG

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