UNPKG

@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.

192 lines (189 loc) 6.58 kB
'use client'; import * as React from 'react'; import { addEventListener } from '@base-ui/utils/addEventListener'; import { useTimeout } from '@base-ui/utils/useTimeout'; import { useInterval } from '@base-ui/utils/useInterval'; import { useStableCallback } from '@base-ui/utils/useStableCallback'; import { ownerWindow } from '@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. */ export 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 = useTimeout(); const tickInterval = useInterval(); const intentionalTouchCheckTimeout = 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 = useStableCallback(() => { intentionalTouchCheckTimeout.clear(); startTickTimeout.clear(); tickInterval.clear(); unsubscribeFromGlobalContextMenuRef.current(); movesAfterTouchRef.current = 0; }); const startAutoChange = useStableCallback(triggerNativeEvent => { stopAutoChange(); const element = elementRef.current; if (!element) { return; } const win = 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 = addEventListener(win, 'contextmenu', handleContextMenu); 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 = useStableCallback(event => { if (event.defaultPrevented) { return true; } if (isTouchLikePointerType(pointerTypeRef.current)) { return ignoreClickRef.current; } return event.detail !== 0; }); return { pointerHandlers, shouldSkipClick }; }