'use client'; import * as React from 'react'; import { View } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import { useNumberFieldRootContext } from '../root/NumberFieldRootContext'; import { useRenderElement } from '../../use-render/useRenderElement'; import { useStableCallback } from '../../hooks/useStableCallback'; import type { NumberFieldRootState } from '../root/NumberFieldRoot'; import type { ZestUIComponentProps } from '../../types'; import { createChangeEventDetails } from '../../utils/createChangeEventDetails'; import { REASONS } from '../../utils/reasons'; /** * An area where the user can drag to change the field value. * Renders a `` wrapped in a `GestureDetector`. * * **Diverges from the web deliberately.** Upstream holds a pointer lock and reads * `movementX`/`movementY`, which lets the mouse scrub forever in place. A touch * cannot be locked and has no movement deltas, so this is an ordinary drag: the * value steps once for every `pixelSensitivity` pixels travelled. Upstream's * `teleportDistance` and the `ScrubAreaCursor` part go with the pointer lock — * both exist to manage a virtual cursor that a touch screen never shows. * * Requires `react-native-gesture-handler`, and the app to be wrapped in * ``. */ export function NumberFieldScrubArea(componentProps: NumberFieldScrubArea.Props) { const { render, className, direction = 'horizontal', pixelSensitivity = 2, style, ref, ...elementProps } = componentProps; const { testID } = elementProps; const { getStepAmount, incrementValue, lastChangedValueRef, onValueCommitted, setScrubbing, state, valueRef, } = useNumberFieldRootContext(); const { disabled, readOnly } = state; // How much of the drag has already been turned into steps. Kept in a ref // because the gesture runs outside React's render. const appliedRef = React.useRef(0); const scrub = useStableCallback((translationX: number, translationY: number) => { const travelled = direction === 'vertical' ? -translationY : translationX; // Only whole steps are applied, and the remainder stays pending so a slow // drag still accumulates instead of being rounded away every frame. const steps = Math.trunc(travelled / pixelSensitivity) - appliedRef.current; if (steps === 0) { return; } appliedRef.current += steps; incrementValue(getStepAmount() * Math.abs(steps), { direction: steps > 0 ? 1 : -1, reason: REASONS.scrub, }); }); const gesture = React.useMemo( () => Gesture.Pan() .enabled(!disabled && !readOnly) // A gesture is invisible to the rendered tree, so tests can only reach it // through gesture-handler's registry, which is keyed by this id. .withTestId(testID ?? 'number-field-scrub-area') .onBegin(() => { appliedRef.current = 0; setScrubbing(true); }) // The move that activates the pan arrives as `onStart`, and every move // after it as `onUpdate`. .onStart((event) => scrub(event.translationX, event.translationY)) .onUpdate((event) => scrub(event.translationX, event.translationY)) .onFinalize(() => { setScrubbing(false); onValueCommitted( lastChangedValueRef.current ?? valueRef.current, createChangeEventDetails(REASONS.scrub), ); }) // The handlers touch React state, so they must not run on the UI thread. .runOnJS(true), [ disabled, readOnly, testID, scrub, setScrubbing, onValueCommitted, lastChangedValueRef, valueRef, ], ); const element = useRenderElement(View, componentProps, { state, ref, props: elementProps, }); return {element}; } export interface NumberFieldScrubAreaState extends NumberFieldRootState {} export interface NumberFieldScrubAreaProps extends ZestUIComponentProps { /** * The direction the drag is read along. * @default 'horizontal' */ direction?: 'horizontal' | 'vertical' | undefined; /** * How many pixels the drag must travel before the value steps once. A higher * value makes scrubbing less sensitive. * @default 2 */ pixelSensitivity?: number | undefined; } export namespace NumberFieldScrubArea { export type State = NumberFieldScrubAreaState; export type Props = NumberFieldScrubAreaProps; }