@base-ui/react
Version:
Base UI is a library of headless ('unstyled') React components and low-level hooks. You gain complete control over your app's CSS and accessibility features.
198 lines (195 loc) • 6.99 kB
JavaScript
;
'use client';
var _interopRequireWildcard = require("@babel/runtime/helpers/interopRequireWildcard").default;
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.usePressAndHold = usePressAndHold;
var React = _interopRequireWildcard(require("react"));
var _addEventListener = require("@base-ui/utils/addEventListener");
var _useTimeout = require("@base-ui/utils/useTimeout");
var _useInterval = require("@base-ui/utils/useInterval");
var _useStableCallback = require("@base-ui/utils/useStableCallback");
var _owner = require("@base-ui/utils/owner");
const DEFAULT_TICK_DELAY = 60;
const DEFAULT_START_DELAY = 400;
const DEFAULT_SCROLL_DISTANCE = 8;
const TOUCH_TIMEOUT = 50;
const MAX_POINTER_MOVES_AFTER_TOUCH = 3;
// Treat pen as touch-like to avoid forcing the software keyboard on stylus taps.
// Linux Chrome may emit "pen" historically for mouse usage due to a bug, but the touch path
// still works with minor behavioral differences.
function isTouchLikePointerType(pointerType) {
return pointerType === 'touch' || pointerType === 'pen';
}
/**
* Adds press-and-hold behavior to a button element.
* On pointer down, performs one action immediately, then after a delay starts
* continuous repeated actions at a fixed interval. Handles mouse, touch, and pen
* inputs correctly, including Android-specific quirks.
*/
function usePressAndHold(params) {
const {
disabled,
readOnly = false,
tick,
onStop,
tickDelay = DEFAULT_TICK_DELAY,
startDelay = DEFAULT_START_DELAY,
scrollDistance = DEFAULT_SCROLL_DISTANCE,
elementRef
} = params;
const startTickTimeout = (0, _useTimeout.useTimeout)();
const tickInterval = (0, _useInterval.useInterval)();
const intentionalTouchCheckTimeout = (0, _useTimeout.useTimeout)();
const isPressedRef = React.useRef(false);
const movesAfterTouchRef = React.useRef(0);
const downCoordsRef = React.useRef({
x: 0,
y: 0
});
const isTouchingButtonRef = React.useRef(false);
const ignoreClickRef = React.useRef(false);
const pointerTypeRef = React.useRef('');
const unsubscribeFromGlobalContextMenuRef = React.useRef(() => {});
const stopAutoChange = (0, _useStableCallback.useStableCallback)(() => {
intentionalTouchCheckTimeout.clear();
startTickTimeout.clear();
tickInterval.clear();
unsubscribeFromGlobalContextMenuRef.current();
movesAfterTouchRef.current = 0;
});
const startAutoChange = (0, _useStableCallback.useStableCallback)(triggerNativeEvent => {
stopAutoChange();
const element = elementRef.current;
if (!element) {
return;
}
const win = (0, _owner.ownerWindow)(element);
function handleContextMenu(event) {
event.preventDefault();
}
// A global context menu listener is necessary to prevent the context menu from
// appearing when the touch is slightly outside of the element's hit area.
unsubscribeFromGlobalContextMenuRef.current = (0, _addEventListener.addEventListener)(win, 'contextmenu', handleContextMenu);
(0, _addEventListener.addEventListener)(win, 'pointerup', event => {
isPressedRef.current = false;
stopAutoChange();
onStop?.(event);
}, {
once: true
});
if (!tick(triggerNativeEvent)) {
stopAutoChange();
return;
}
startTickTimeout.start(startDelay, () => {
tickInterval.start(tickDelay, () => {
if (!tick(triggerNativeEvent)) {
stopAutoChange();
}
});
});
});
React.useEffect(() => () => stopAutoChange(), [stopAutoChange]);
const pointerHandlers = {
onTouchStart() {
isTouchingButtonRef.current = true;
},
onTouchEnd() {
isTouchingButtonRef.current = false;
},
onPointerDown(event) {
const isMainButton = !event.button || event.button === 0;
if (event.defaultPrevented || !isMainButton || disabled || readOnly) {
return;
}
pointerTypeRef.current = event.pointerType;
ignoreClickRef.current = false;
isPressedRef.current = true;
downCoordsRef.current = {
x: event.clientX,
y: event.clientY
};
const isTouchPointer = isTouchLikePointerType(event.pointerType);
if (!isTouchPointer) {
event.preventDefault();
startAutoChange(event.nativeEvent);
} else {
// Check if the pointerdown was intentional and not the result of a scroll or
// pinch-zoom. In that case, we don't want to start the auto-change sequence.
intentionalTouchCheckTimeout.start(TOUCH_TIMEOUT, () => {
const moves = movesAfterTouchRef.current;
movesAfterTouchRef.current = 0;
// Only start auto-change if the touch is still pressed (prevents races
// with pointerup occurring before the timeout fires on quick taps).
const stillPressed = isPressedRef.current;
if (stillPressed && moves < MAX_POINTER_MOVES_AFTER_TOUCH) {
startAutoChange(event.nativeEvent);
ignoreClickRef.current = true; // synthesized click after hold should be ignored
} else {
// No auto-change (simple tap or scroll gesture), allow the click handler
// to perform a single action.
ignoreClickRef.current = false;
stopAutoChange();
}
});
}
},
onPointerUp(event) {
// Ensure we mark the press as released for touch flows even if auto-change never
// started, so the delayed auto-change check won't start after a quick tap.
if (isTouchLikePointerType(event.pointerType)) {
isPressedRef.current = false;
}
},
onPointerMove(event) {
if (disabled || readOnly || !isTouchLikePointerType(event.pointerType) || !isPressedRef.current) {
return;
}
if (movesAfterTouchRef.current != null) {
movesAfterTouchRef.current += 1;
}
const {
x,
y
} = downCoordsRef.current;
const dx = x - event.clientX;
const dy = y - event.clientY;
if (dx ** 2 + dy ** 2 > scrollDistance ** 2) {
stopAutoChange();
}
},
onMouseEnter(event) {
if (event.defaultPrevented || disabled || readOnly || !isPressedRef.current || isTouchingButtonRef.current || isTouchLikePointerType(pointerTypeRef.current)) {
return;
}
startAutoChange(event.nativeEvent);
},
onMouseLeave() {
if (isTouchingButtonRef.current) {
return;
}
stopAutoChange();
},
onMouseUp() {
if (isTouchingButtonRef.current) {
return;
}
stopAutoChange();
}
};
const shouldSkipClick = (0, _useStableCallback.useStableCallback)(event => {
if (event.defaultPrevented) {
return true;
}
if (isTouchLikePointerType(pointerTypeRef.current)) {
return ignoreClickRef.current;
}
return event.detail !== 0;
});
return {
pointerHandlers,
shouldSkipClick
};
}