import { createSignal, onCleanup } from "solid-js"; import { ownerWindow } from "../../internals/dom/owner-window.ts"; import { useTimeout } from "../../internals/use-timeout.ts"; 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; function isTouchLikePointerType(pointerType: string): boolean { return pointerType === "touch" || pointerType === "pen"; } export interface UsePressAndHoldParameters { disabled: () => boolean; readOnly?: (() => boolean | undefined) | undefined; /** * Called on each tick during a hold. Return `false` to stop the * auto-change sequence. */ tick: (triggerEvent?: Event) => boolean; /** Called when the hold ends via the global `pointerup` event. */ onStop?: ((nativeEvent: PointerEvent) => void) | undefined; /** * Interval between ticks once the hold is active. * @default 60 */ tickDelay?: number | undefined; /** * Delay before the repeating ticks start after the initial hold. * @default 400 */ startDelay?: number | undefined; /** * Pointer movement distance (px) that cancels the hold and is treated as * scrolling. * @default 8 */ scrollDistance?: number | undefined; /** * Ref to the anchor element used to resolve `ownerWindow`. Must remain * mutable so the hook reads the latest element at call time. */ elementRef: { current: HTMLElement | null }; } export interface PressAndHoldPointerHandlers { onTouchStart: () => void; onTouchEnd: () => void; onPointerDown: (event: PointerEvent) => void; onPointerUp: (event: PointerEvent) => void; onPointerMove: (event: PointerEvent) => void; onMouseEnter: (event: MouseEvent) => void; onMouseLeave: () => void; onMouseUp: () => void; } export interface UsePressAndHoldReturnValue { pointerHandlers: PressAndHoldPointerHandlers; /** * Returns `true` if the `onClick` handler should be skipped. * * Use this in the element's `onClick` to prevent double-firing on mouse * clicks (already handled by `onPointerDown`) and to suppress the * synthesized click browsers fire after a touch hold. */ shouldSkipClick: (event: MouseEvent) => boolean; } /** * Solid port of React's `usePressAndHold` used exclusively by * `` / `` (see * `packages/react/src/internals/usePressAndHold.ts`). * * Adds press-and-hold behaviour to a button-shaped element. On pointer * down, performs one action immediately; after `startDelay` starts * continuous repeated actions at `tickDelay` intervals. Handles mouse, * touch, and pen inputs correctly. * * Solid 2 notes: * * - Inputs are accessors (`disabled()`) instead of React primitives so the * hook can be called once during setup and still see live values at event * time. `tick` / `onStop` are plain functions — callers typically reach * into `createStableCallback` if they need identity stability, but since * this hook is internal to number-field and the callers capture the * handlers immediately, plain closures are fine. * - The globally-installed `pointerup` listener is registered with the * raw DOM API rather than Base UI React's `addEventListener` util — * Solid 2's `ownerWindow` helper returns a `Window`, and `{ once: true }` * is supported natively. * - `onCleanup` disposes pending timers / interval / global listeners when * the owning scope disposes. * * The hook is scoped to `src/number-field/` per the porting plan — * promote to `src/internals/` if/when another component needs it. */ export function usePressAndHold( params: UsePressAndHoldParameters, ): UsePressAndHoldReturnValue { const tickDelay = params.tickDelay ?? DEFAULT_TICK_DELAY; const startDelay = params.startDelay ?? DEFAULT_START_DELAY; const scrollDistance = params.scrollDistance ?? DEFAULT_SCROLL_DISTANCE; const readOnly = (): boolean => params.readOnly?.() === true; const startTickTimeout = useTimeout(); const intentionalTouchCheckTimeout = useTimeout(); // Class-based interval handle isn't in internals — inline a minimal one. let intervalId: ReturnType | null = null; function clearInterval_(): void { if (intervalId !== null) { clearInterval(intervalId); intervalId = null; } } const isPressedRef = { current: false }; const movesAfterTouchRef = { current: 0 }; const downCoordsRef = { current: { x: 0, y: 0 } }; const isTouchingButtonRef = { current: false }; const ignoreClickRef = { current: false }; const pointerTypeRef = { current: "" }; let unsubscribeFromGlobalContextMenu: () => void = () => {}; let unsubscribeFromGlobalPointerUp: () => void = () => {}; const [, setDisposed] = createSignal(false, { ownedWrite: true }); function stopAutoChange(): void { intentionalTouchCheckTimeout.clear(); startTickTimeout.clear(); clearInterval_(); unsubscribeFromGlobalContextMenu(); unsubscribeFromGlobalContextMenu = () => {}; unsubscribeFromGlobalPointerUp(); unsubscribeFromGlobalPointerUp = () => {}; movesAfterTouchRef.current = 0; } function startAutoChange(triggerNativeEvent?: Event): void { stopAutoChange(); const element = params.elementRef.current; if (!element) { return; } const win = ownerWindow(element); function handleContextMenu(event: Event): void { event.preventDefault(); } win.addEventListener("contextmenu", handleContextMenu); unsubscribeFromGlobalContextMenu = (): void => { win.removeEventListener("contextmenu", handleContextMenu); }; function handlePointerUp(event: PointerEvent): void { isPressedRef.current = false; stopAutoChange(); params.onStop?.(event); } win.addEventListener("pointerup", handlePointerUp, { once: true }); unsubscribeFromGlobalPointerUp = (): void => { win.removeEventListener("pointerup", handlePointerUp); }; if (!params.tick(triggerNativeEvent)) { stopAutoChange(); return; } startTickTimeout.start(startDelay, () => { intervalId = setInterval(() => { if (!params.tick(triggerNativeEvent)) { stopAutoChange(); } }, tickDelay); }); } onCleanup(() => { stopAutoChange(); setDisposed(true); }); const pointerHandlers: PressAndHoldPointerHandlers = { onTouchStart() { isTouchingButtonRef.current = true; }, onTouchEnd() { isTouchingButtonRef.current = false; }, onPointerDown(event: PointerEvent) { const isMainButton = !event.button || event.button === 0; if ( event.defaultPrevented || !isMainButton || params.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); } else { intentionalTouchCheckTimeout.start(TOUCH_TIMEOUT, () => { const moves = movesAfterTouchRef.current; movesAfterTouchRef.current = 0; const stillPressed = isPressedRef.current; if (stillPressed && moves < MAX_POINTER_MOVES_AFTER_TOUCH) { startAutoChange(event); ignoreClickRef.current = true; } else { ignoreClickRef.current = false; stopAutoChange(); } }); } }, onPointerUp(event: PointerEvent) { if (isTouchLikePointerType(event.pointerType)) { isPressedRef.current = false; } }, onPointerMove(event: PointerEvent) { if ( params.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: MouseEvent) { if ( event.defaultPrevented || params.disabled() || readOnly() || !isPressedRef.current || isTouchingButtonRef.current || isTouchLikePointerType(pointerTypeRef.current) ) { return; } startAutoChange(event); }, onMouseLeave() { if (isTouchingButtonRef.current) { return; } stopAutoChange(); }, onMouseUp() { if (isTouchingButtonRef.current) { return; } stopAutoChange(); }, }; function shouldSkipClick(event: MouseEvent): boolean { if (event.defaultPrevented) { return true; } if (isTouchLikePointerType(pointerTypeRef.current)) { return ignoreClickRef.current; } return event.detail !== 0; } return { pointerHandlers, shouldSkipClick }; }