@react-aria/interactions
Version:
Spectrum UI components in React
806 lines (694 loc) • 26 kB
text/typescript
/*
* Copyright 2020 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
// Portions of the code in this file are based on code from react.
// Original licensing for the following can be found in the
// NOTICE file in the root directory of this source tree.
// See https://github.com/facebook/react/tree/cc7c1aece46a6b69b41958d731e0fd27c94bfc6c/packages/react-interactions
import {disableTextSelection, restoreTextSelection} from './textSelection';
import {DOMAttributes, FocusableElement, PointerType, PressEvents} from '@react-types/shared';
import {focusWithoutScrolling, isVirtualClick, isVirtualPointerEvent, mergeProps, useGlobalListeners, useSyncRef} from '@react-aria/utils';
import {PressResponderContext} from './context';
import {RefObject, useContext, useEffect, useMemo, useRef, useState} from 'react';
export interface PressProps extends PressEvents {
/** Whether the target is in a controlled press state (e.g. an overlay it triggers is open). */
isPressed?: boolean,
/** Whether the press events should be disabled. */
isDisabled?: boolean,
/** Whether the target should not receive focus on press. */
preventFocusOnPress?: boolean,
/**
* Whether press events should be canceled when the pointer leaves the target while pressed.
* By default, this is `false`, which means if the pointer returns back over the target while
* still pressed, onPressStart will be fired again. If set to `true`, the press is canceled
* when the pointer leaves the target and onPressStart will not be fired if the pointer returns.
*/
shouldCancelOnPointerExit?: boolean,
/** Whether text selection should be enabled on the pressable element. */
allowTextSelectionOnPress?: boolean
}
export interface PressHookProps extends PressProps {
/** A ref to the target element. */
ref?: RefObject<Element>
}
interface PressState {
isPressed: boolean,
ignoreEmulatedMouseEvents: boolean,
ignoreClickAfterPress: boolean,
didFirePressStart: boolean,
activePointerId: any,
target: FocusableElement | null,
isOverTarget: boolean,
pointerType: PointerType,
userSelect?: string
}
interface EventBase {
currentTarget: EventTarget,
shiftKey: boolean,
ctrlKey: boolean,
metaKey: boolean,
altKey: boolean
}
export interface PressResult {
/** Whether the target is currently pressed. */
isPressed: boolean,
/** Props to spread on the target element. */
pressProps: DOMAttributes
}
function usePressResponderContext(props: PressHookProps): PressHookProps {
// Consume context from <PressResponder> and merge with props.
let context = useContext(PressResponderContext);
if (context) {
let {register, ...contextProps} = context;
props = mergeProps(contextProps, props) as PressHookProps;
register();
}
useSyncRef(context, props.ref);
return props;
}
/**
* Handles press interactions across mouse, touch, keyboard, and screen readers.
* It normalizes behavior across browsers and platforms, and handles many nuances
* of dealing with pointer and keyboard events.
*/
export function usePress(props: PressHookProps): PressResult {
let {
onPress,
onPressChange,
onPressStart,
onPressEnd,
onPressUp,
isDisabled,
isPressed: isPressedProp,
preventFocusOnPress,
shouldCancelOnPointerExit,
allowTextSelectionOnPress,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
ref: _, // Removing `ref` from `domProps` because TypeScript is dumb
...domProps
} = usePressResponderContext(props);
let propsRef = useRef<PressHookProps>(null);
propsRef.current = {onPress, onPressChange, onPressStart, onPressEnd, onPressUp, isDisabled, shouldCancelOnPointerExit};
let [isPressed, setPressed] = useState(false);
let ref = useRef<PressState>({
isPressed: false,
ignoreEmulatedMouseEvents: false,
ignoreClickAfterPress: false,
didFirePressStart: false,
activePointerId: null,
target: null,
isOverTarget: false,
pointerType: null
});
let {addGlobalListener, removeAllGlobalListeners} = useGlobalListeners();
let pressProps = useMemo(() => {
let state = ref.current;
let triggerPressStart = (originalEvent: EventBase, pointerType: PointerType) => {
let {onPressStart, onPressChange, isDisabled} = propsRef.current;
if (isDisabled || state.didFirePressStart) {
return;
}
if (onPressStart) {
onPressStart({
type: 'pressstart',
pointerType,
target: originalEvent.currentTarget as Element,
shiftKey: originalEvent.shiftKey,
metaKey: originalEvent.metaKey,
ctrlKey: originalEvent.ctrlKey,
altKey: originalEvent.altKey
});
}
if (onPressChange) {
onPressChange(true);
}
state.didFirePressStart = true;
setPressed(true);
};
let triggerPressEnd = (originalEvent: EventBase, pointerType: PointerType, wasPressed = true) => {
let {onPressEnd, onPressChange, onPress, isDisabled} = propsRef.current;
if (!state.didFirePressStart) {
return;
}
state.ignoreClickAfterPress = true;
state.didFirePressStart = false;
if (onPressEnd) {
onPressEnd({
type: 'pressend',
pointerType,
target: originalEvent.currentTarget as Element,
shiftKey: originalEvent.shiftKey,
metaKey: originalEvent.metaKey,
ctrlKey: originalEvent.ctrlKey,
altKey: originalEvent.altKey
});
}
if (onPressChange) {
onPressChange(false);
}
setPressed(false);
if (onPress && wasPressed && !isDisabled) {
onPress({
type: 'press',
pointerType,
target: originalEvent.currentTarget as Element,
shiftKey: originalEvent.shiftKey,
metaKey: originalEvent.metaKey,
ctrlKey: originalEvent.ctrlKey,
altKey: originalEvent.altKey
});
}
};
let triggerPressUp = (originalEvent: EventBase, pointerType: PointerType) => {
let {onPressUp, isDisabled} = propsRef.current;
if (isDisabled) {
return;
}
if (onPressUp) {
onPressUp({
type: 'pressup',
pointerType,
target: originalEvent.currentTarget as Element,
shiftKey: originalEvent.shiftKey,
metaKey: originalEvent.metaKey,
ctrlKey: originalEvent.ctrlKey,
altKey: originalEvent.altKey
});
}
};
let cancel = (e: EventBase) => {
if (state.isPressed) {
if (state.isOverTarget) {
triggerPressEnd(createEvent(state.target, e), state.pointerType, false);
}
state.isPressed = false;
state.isOverTarget = false;
state.activePointerId = null;
state.pointerType = null;
removeAllGlobalListeners();
if (!allowTextSelectionOnPress) {
restoreTextSelection(state.target);
}
}
};
let pressProps: DOMAttributes = {
onKeyDown(e) {
if (isValidKeyboardEvent(e.nativeEvent, e.currentTarget) && e.currentTarget.contains(e.target as Element)) {
if (shouldPreventDefaultKeyboard(e.target as Element, e.key)) {
e.preventDefault();
}
e.stopPropagation();
// If the event is repeating, it may have started on a different element
// after which focus moved to the current element. Ignore these events and
// only handle the first key down event.
if (!state.isPressed && !e.repeat) {
state.target = e.currentTarget;
state.isPressed = true;
triggerPressStart(e, 'keyboard');
// Focus may move before the key up event, so register the event on the document
// instead of the same element where the key down event occurred.
addGlobalListener(document, 'keyup', onKeyUp, false);
}
} else if (e.key === 'Enter' && isHTMLAnchorLink(e.currentTarget)) {
// If the target is a link, we won't have handled this above because we want the default
// browser behavior to open the link when pressing Enter. But we still need to prevent
// default so that elements above do not also handle it (e.g. table row).
e.stopPropagation();
}
},
onKeyUp(e) {
if (isValidKeyboardEvent(e.nativeEvent, e.currentTarget) && !e.repeat && e.currentTarget.contains(e.target as Element)) {
triggerPressUp(createEvent(state.target, e), 'keyboard');
}
},
onClick(e) {
if (e && !e.currentTarget.contains(e.target as Element)) {
return;
}
if (e && e.button === 0) {
e.stopPropagation();
if (isDisabled) {
e.preventDefault();
}
// If triggered from a screen reader or by using element.click(),
// trigger as if it were a keyboard click.
if (!state.ignoreClickAfterPress && !state.ignoreEmulatedMouseEvents && (state.pointerType === 'virtual' || isVirtualClick(e.nativeEvent))) {
// Ensure the element receives focus (VoiceOver on iOS does not do this)
if (!isDisabled && !preventFocusOnPress) {
focusWithoutScrolling(e.currentTarget);
}
triggerPressStart(e, 'virtual');
triggerPressUp(e, 'virtual');
triggerPressEnd(e, 'virtual');
}
state.ignoreEmulatedMouseEvents = false;
state.ignoreClickAfterPress = false;
}
}
};
let onKeyUp = (e: KeyboardEvent) => {
if (state.isPressed && isValidKeyboardEvent(e, state.target)) {
if (shouldPreventDefaultKeyboard(e.target as Element, e.key)) {
e.preventDefault();
}
e.stopPropagation();
state.isPressed = false;
let target = e.target as Element;
triggerPressEnd(createEvent(state.target, e), 'keyboard', state.target.contains(target));
removeAllGlobalListeners();
// If the target is a link, trigger the click method to open the URL,
// but defer triggering pressEnd until onClick event handler.
if (state.target instanceof HTMLElement && state.target.contains(target) && (isHTMLAnchorLink(state.target) || state.target.getAttribute('role') === 'link')) {
state.target.click();
}
}
};
if (typeof PointerEvent !== 'undefined') {
pressProps.onPointerDown = (e) => {
// Only handle left clicks, and ignore events that bubbled through portals.
if (e.button !== 0 || !e.currentTarget.contains(e.target as Element)) {
return;
}
// iOS safari fires pointer events from VoiceOver with incorrect coordinates/target.
// Ignore and let the onClick handler take care of it instead.
// https://bugs.webkit.org/show_bug.cgi?id=222627
// https://bugs.webkit.org/show_bug.cgi?id=223202
if (isVirtualPointerEvent(e.nativeEvent)) {
state.pointerType = 'virtual';
return;
}
// Due to browser inconsistencies, especially on mobile browsers, we prevent
// default on pointer down and handle focusing the pressable element ourselves.
if (shouldPreventDefault(e.currentTarget as Element)) {
e.preventDefault();
}
state.pointerType = e.pointerType;
e.stopPropagation();
if (!state.isPressed) {
state.isPressed = true;
state.isOverTarget = true;
state.activePointerId = e.pointerId;
state.target = e.currentTarget;
if (!isDisabled && !preventFocusOnPress) {
focusWithoutScrolling(e.currentTarget);
}
if (!allowTextSelectionOnPress) {
disableTextSelection(state.target);
}
triggerPressStart(e, state.pointerType);
addGlobalListener(document, 'pointermove', onPointerMove, false);
addGlobalListener(document, 'pointerup', onPointerUp, false);
addGlobalListener(document, 'pointercancel', onPointerCancel, false);
}
};
pressProps.onMouseDown = (e) => {
if (!e.currentTarget.contains(e.target as Element)) {
return;
}
if (e.button === 0) {
// Chrome and Firefox on touch Windows devices require mouse down events
// to be canceled in addition to pointer events, or an extra asynchronous
// focus event will be fired.
if (shouldPreventDefault(e.currentTarget as Element)) {
e.preventDefault();
}
e.stopPropagation();
}
};
pressProps.onPointerUp = (e) => {
// iOS fires pointerup with zero width and height, so check the pointerType recorded during pointerdown.
if (!e.currentTarget.contains(e.target as Element) || state.pointerType === 'virtual') {
return;
}
// Only handle left clicks
// Safari on iOS sometimes fires pointerup events, even
// when the touch isn't over the target, so double check.
if (e.button === 0 && isOverTarget(e, e.currentTarget)) {
triggerPressUp(e, state.pointerType || e.pointerType);
}
};
// Safari on iOS < 13.2 does not implement pointerenter/pointerleave events correctly.
// Use pointer move events instead to implement our own hit testing.
// See https://bugs.webkit.org/show_bug.cgi?id=199803
let onPointerMove = (e: PointerEvent) => {
if (e.pointerId !== state.activePointerId) {
return;
}
if (isOverTarget(e, state.target)) {
if (!state.isOverTarget) {
state.isOverTarget = true;
triggerPressStart(createEvent(state.target, e), state.pointerType);
}
} else if (state.isOverTarget) {
state.isOverTarget = false;
triggerPressEnd(createEvent(state.target, e), state.pointerType, false);
if (propsRef.current.shouldCancelOnPointerExit) {
cancel(e);
}
}
};
let onPointerUp = (e: PointerEvent) => {
if (e.pointerId === state.activePointerId && state.isPressed && e.button === 0) {
if (isOverTarget(e, state.target)) {
triggerPressEnd(createEvent(state.target, e), state.pointerType);
} else if (state.isOverTarget) {
triggerPressEnd(createEvent(state.target, e), state.pointerType, false);
}
state.isPressed = false;
state.isOverTarget = false;
state.activePointerId = null;
state.pointerType = null;
removeAllGlobalListeners();
if (!allowTextSelectionOnPress) {
restoreTextSelection(state.target);
}
}
};
let onPointerCancel = (e: PointerEvent) => {
cancel(e);
};
pressProps.onDragStart = (e) => {
if (!e.currentTarget.contains(e.target as Element)) {
return;
}
// Safari does not call onPointerCancel when a drag starts, whereas Chrome and Firefox do.
cancel(e);
};
} else {
pressProps.onMouseDown = (e) => {
// Only handle left clicks
if (e.button !== 0 || !e.currentTarget.contains(e.target as Element)) {
return;
}
// Due to browser inconsistencies, especially on mobile browsers, we prevent
// default on mouse down and handle focusing the pressable element ourselves.
if (shouldPreventDefault(e.currentTarget)) {
e.preventDefault();
}
e.stopPropagation();
if (state.ignoreEmulatedMouseEvents) {
return;
}
state.isPressed = true;
state.isOverTarget = true;
state.target = e.currentTarget;
state.pointerType = isVirtualClick(e.nativeEvent) ? 'virtual' : 'mouse';
if (!isDisabled && !preventFocusOnPress) {
focusWithoutScrolling(e.currentTarget);
}
triggerPressStart(e, state.pointerType);
addGlobalListener(document, 'mouseup', onMouseUp, false);
};
pressProps.onMouseEnter = (e) => {
if (!e.currentTarget.contains(e.target as Element)) {
return;
}
e.stopPropagation();
if (state.isPressed && !state.ignoreEmulatedMouseEvents) {
state.isOverTarget = true;
triggerPressStart(e, state.pointerType);
}
};
pressProps.onMouseLeave = (e) => {
if (!e.currentTarget.contains(e.target as Element)) {
return;
}
e.stopPropagation();
if (state.isPressed && !state.ignoreEmulatedMouseEvents) {
state.isOverTarget = false;
triggerPressEnd(e, state.pointerType, false);
if (propsRef.current.shouldCancelOnPointerExit) {
cancel(e);
}
}
};
pressProps.onMouseUp = (e) => {
if (!e.currentTarget.contains(e.target as Element)) {
return;
}
if (!state.ignoreEmulatedMouseEvents && e.button === 0) {
triggerPressUp(e, state.pointerType);
}
};
let onMouseUp = (e: MouseEvent) => {
// Only handle left clicks
if (e.button !== 0) {
return;
}
state.isPressed = false;
removeAllGlobalListeners();
if (state.ignoreEmulatedMouseEvents) {
state.ignoreEmulatedMouseEvents = false;
return;
}
if (isOverTarget(e, state.target)) {
triggerPressEnd(createEvent(state.target, e), state.pointerType);
} else if (state.isOverTarget) {
triggerPressEnd(createEvent(state.target, e), state.pointerType, false);
}
state.isOverTarget = false;
};
pressProps.onTouchStart = (e) => {
if (!e.currentTarget.contains(e.target as Element)) {
return;
}
e.stopPropagation();
let touch = getTouchFromEvent(e.nativeEvent);
if (!touch) {
return;
}
state.activePointerId = touch.identifier;
state.ignoreEmulatedMouseEvents = true;
state.isOverTarget = true;
state.isPressed = true;
state.target = e.currentTarget;
state.pointerType = 'touch';
// Due to browser inconsistencies, especially on mobile browsers, we prevent default
// on the emulated mouse event and handle focusing the pressable element ourselves.
if (!isDisabled && !preventFocusOnPress) {
focusWithoutScrolling(e.currentTarget);
}
if (!allowTextSelectionOnPress) {
disableTextSelection(state.target);
}
triggerPressStart(e, state.pointerType);
addGlobalListener(window, 'scroll', onScroll, true);
};
pressProps.onTouchMove = (e) => {
if (!e.currentTarget.contains(e.target as Element)) {
return;
}
e.stopPropagation();
if (!state.isPressed) {
return;
}
let touch = getTouchById(e.nativeEvent, state.activePointerId);
if (touch && isOverTarget(touch, e.currentTarget)) {
if (!state.isOverTarget) {
state.isOverTarget = true;
triggerPressStart(e, state.pointerType);
}
} else if (state.isOverTarget) {
state.isOverTarget = false;
triggerPressEnd(e, state.pointerType, false);
if (propsRef.current.shouldCancelOnPointerExit) {
cancel(e);
}
}
};
pressProps.onTouchEnd = (e) => {
if (!e.currentTarget.contains(e.target as Element)) {
return;
}
e.stopPropagation();
if (!state.isPressed) {
return;
}
let touch = getTouchById(e.nativeEvent, state.activePointerId);
if (touch && isOverTarget(touch, e.currentTarget)) {
triggerPressUp(e, state.pointerType);
triggerPressEnd(e, state.pointerType);
} else if (state.isOverTarget) {
triggerPressEnd(e, state.pointerType, false);
}
state.isPressed = false;
state.activePointerId = null;
state.isOverTarget = false;
state.ignoreEmulatedMouseEvents = true;
if (!allowTextSelectionOnPress) {
restoreTextSelection(state.target);
}
removeAllGlobalListeners();
};
pressProps.onTouchCancel = (e) => {
if (!e.currentTarget.contains(e.target as Element)) {
return;
}
e.stopPropagation();
if (state.isPressed) {
cancel(e);
}
};
let onScroll = (e: Event) => {
if (state.isPressed && (e.target as Element).contains(state.target)) {
cancel({
currentTarget: state.target,
shiftKey: false,
ctrlKey: false,
metaKey: false,
altKey: false
});
}
};
pressProps.onDragStart = (e) => {
if (!e.currentTarget.contains(e.target as Element)) {
return;
}
cancel(e);
};
}
return pressProps;
}, [addGlobalListener, isDisabled, preventFocusOnPress, removeAllGlobalListeners, allowTextSelectionOnPress]);
// Remove user-select: none in case component unmounts immediately after pressStart
// eslint-disable-next-line arrow-body-style
useEffect(() => {
return () => {
if (!allowTextSelectionOnPress) {
// eslint-disable-next-line react-hooks/exhaustive-deps
restoreTextSelection(ref.current.target);
}
};
}, [allowTextSelectionOnPress]);
return {
isPressed: isPressedProp || isPressed,
pressProps: mergeProps(domProps, pressProps)
};
}
function isHTMLAnchorLink(target: Element): boolean {
return target.tagName === 'A' && target.hasAttribute('href');
}
function isValidKeyboardEvent(event: KeyboardEvent, currentTarget: Element): boolean {
const {key, code} = event;
const element = currentTarget as HTMLElement;
const role = element.getAttribute('role');
// Accessibility for keyboards. Space and Enter only.
// "Spacebar" is for IE 11
return (
(key === 'Enter' || key === ' ' || key === 'Spacebar' || code === 'Space') &&
!((element instanceof HTMLInputElement && !isValidInputKey(element, key)) ||
element instanceof HTMLTextAreaElement ||
element.isContentEditable) &&
// A link with a valid href should be handled natively,
// unless it also has role='button' and was triggered using Space.
(!isHTMLAnchorLink(element) || (role === 'button' && key !== 'Enter')) &&
// An element with role='link' should only trigger with Enter key
!(role === 'link' && key !== 'Enter')
);
}
function getTouchFromEvent(event: TouchEvent): Touch | null {
const {targetTouches} = event;
if (targetTouches.length > 0) {
return targetTouches[0];
}
return null;
}
function getTouchById(
event: TouchEvent,
pointerId: null | number
): null | Touch {
const changedTouches = event.changedTouches;
for (let i = 0; i < changedTouches.length; i++) {
const touch = changedTouches[i];
if (touch.identifier === pointerId) {
return touch;
}
}
return null;
}
function createEvent(target: FocusableElement, e: EventBase): EventBase {
return {
currentTarget: target,
shiftKey: e.shiftKey,
ctrlKey: e.ctrlKey,
metaKey: e.metaKey,
altKey: e.altKey
};
}
interface Rect {
top: number,
right: number,
bottom: number,
left: number
}
interface EventPoint {
clientX: number,
clientY: number,
width?: number,
height?: number,
radiusX?: number,
radiusY?: number
}
function getPointClientRect(point: EventPoint): Rect {
let offsetX = (point.width / 2) || point.radiusX || 0;
let offsetY = (point.height / 2) || point.radiusY || 0;
return {
top: point.clientY - offsetY,
right: point.clientX + offsetX,
bottom: point.clientY + offsetY,
left: point.clientX - offsetX
};
}
function areRectanglesOverlapping(a: Rect, b: Rect) {
// check if they cannot overlap on x axis
if (a.left > b.right || b.left > a.right) {
return false;
}
// check if they cannot overlap on y axis
if (a.top > b.bottom || b.top > a.bottom) {
return false;
}
return true;
}
function isOverTarget(point: EventPoint, target: Element) {
let rect = target.getBoundingClientRect();
let pointRect = getPointClientRect(point);
return areRectanglesOverlapping(rect, pointRect);
}
function shouldPreventDefault(target: Element) {
// We cannot prevent default if the target is a draggable element.
return !(target instanceof HTMLElement) || !target.draggable;
}
function shouldPreventDefaultKeyboard(target: Element, key: string) {
if (target instanceof HTMLInputElement) {
return !isValidInputKey(target, key);
}
if (target instanceof HTMLButtonElement) {
return target.type !== 'submit';
}
return true;
}
const nonTextInputTypes = new Set([
'checkbox',
'radio',
'range',
'color',
'file',
'image',
'button',
'submit',
'reset'
]);
function isValidInputKey(target: HTMLInputElement, key: string) {
// Only space should toggle checkboxes and radios, not enter.
return target.type === 'checkbox' || target.type === 'radio'
? key === ' '
: nonTextInputTypes.has(target.type);
}