'use client'; import * as React from 'react'; import { View } from 'react-native'; import { useControlled } from '../../hooks/useControlled'; import { useIsoLayoutEffect } from '../../hooks/useIsoLayoutEffect'; import { useStableCallback } from '../../hooks/useStableCallback'; import { useRenderElement } from '../../use-render/useRenderElement'; import { useFieldControlRegistration } from '../../internals/field/useFieldControlRegistration'; import { formatNumber } from '../../utils/formatNumber'; import type { ZestUIComponentProps } from '../../types'; import { createChangeEventDetails } from '../../utils/createChangeEventDetails'; import type { ZestChangeEventDetails } from '../../utils/createChangeEventDetails'; import type { REASONS } from '../../utils/reasons'; import { toValidatedNumber } from '../utils/validate'; import { NumberFieldRootContext, type NumberFieldIncrementParameters, } from './NumberFieldRootContext'; /** * Groups all parts of the number field. * Renders a ``. * * **Not ported from upstream.** `name`/`form`/`required`/`inputRef` (the hidden * input and form submission — see the standing decision in CLAUDE.md); * `allowWheelScrub` (no mouse wheel); and `smallStep`/`largeStep`, which upstream * steps by while the alt/shift key is held — a touch keyboard has neither, the * same reason `Slider` drops `largeStep`. Arrow-key stepping and the * `input-paste` reason go with them: React Native's `TextInput` reports edits * through `onChangeText`, which cannot tell a paste from typing. */ export function NumberFieldRoot(componentProps: NumberFieldRoot.Props) { const { allowOutOfRange = false, className, defaultValue, disabled: disabledProp = false, format, id, locale, max, min, onValueChange, onValueCommitted, readOnly = false, render, snapOnStep = false, step = 1, style, value: valueProp, ref, ...elementProps } = componentProps; const { fieldDisabled, markChanged } = useFieldControlRegistration({ initialValue: defaultValue ?? valueProp ?? null, ownsValue: true, }); const disabled = disabledProp || fieldDisabled; const minWithDefault = min ?? Number.MIN_SAFE_INTEGER; const maxWithDefault = max ?? Number.MAX_SAFE_INTEGER; // Seeding an empty field steps from 0, but 0 may be out of range. const minWithZeroDefault = min ?? 0; const [value, setValueState] = useControlled({ controlled: valueProp, default: defaultValue ?? null, name: 'NumberField', state: 'value', }); const [inputValue, setInputValue] = React.useState(() => formatNumber(value, locale, format)); const [scrubbing, setScrubbing] = React.useState(false); const valueRef = React.useRef(value); const lastChangedValueRef = React.useRef(null); const formatOptionsRef = React.useRef(format); // False while the user is typing, so their text is not reformatted underneath // them until blur. const allowInputSyncRef = React.useRef(true); useIsoLayoutEffect(() => { valueRef.current = value; formatOptionsRef.current = format; }); // A controlled value changing from outside must be reflected in the text, but // not while the user is mid-edit. useIsoLayoutEffect(() => { if (allowInputSyncRef.current) { setInputValue(formatNumber(value, locale, format)); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [value, locale, JSON.stringify(format)]); const getStepAmount = useStableCallback(() => (step === 'any' ? 1 : step)); const setValue = useStableCallback( (unvalidatedValue: number | null, eventDetails: NumberFieldRoot.ChangeEventDetails) => { const direction = eventDetails.direction; // Direct text entry behaves natively; step-based interactions (buttons, // scrub) do not. Every direct-entry reason shares the `input-` prefix. const isInputReason = eventDetails.reason.startsWith('input-') || eventDetails.reason === 'none'; const shouldClampValue = !allowOutOfRange || !isInputReason; const validatedValue = toValidatedNumber( unvalidatedValue, direction ? getStepAmount() * direction : undefined, minWithDefault, maxWithDefault, minWithZeroDefault, formatOptionsRef.current, snapOnStep, // Upstream passes `altKey` here to snap to `smallStep`; there is no alt // key on a touch keyboard, so it is always false. false, shouldClampValue, ); // Fire even when the number is unchanged for input reasons: the typed text // may clamp or snap back to the current value, and the consumer still needs // to know the text was rejected. const shouldFireChange = validatedValue !== valueRef.current || (isInputReason && (unvalidatedValue !== valueRef.current || allowInputSyncRef.current === false)); if (shouldFireChange) { onValueChange?.(validatedValue, eventDetails); if (eventDetails.isCanceled) { return false; } valueRef.current = validatedValue; setValueState(validatedValue); markChanged(validatedValue); } lastChangedValueRef.current = validatedValue; if (allowInputSyncRef.current) { setInputValue(formatNumber(validatedValue, locale, format)); } return shouldFireChange; }, ); const incrementValue = useStableCallback( (amount: number, { direction, currentValue, reason }: NumberFieldIncrementParameters) => { const previousValue = currentValue === undefined ? valueRef.current : currentValue; if (typeof previousValue !== 'number') { // Seed an empty field with 0; `setValue` clamps it to the in-range value // nearest 0 (e.g. `max` for a negative range). No `direction`: the seed // is not a step, so it must not be directionally snapped. return setValue(0, createChangeEventDetails(reason)); } return setValue( previousValue + amount * direction, createChangeEventDetails(reason, undefined, { direction }), ); }, ); const handleValueCommitted = useStableCallback( (committedValue: number | null, eventDetails: NumberFieldRoot.ChangeEventDetails) => { onValueCommitted?.(committedValue, eventDetails); }, ); const state: NumberFieldRootState = React.useMemo( () => ({ disabled, readOnly, required: false, scrubbing, value, inputValue, min, max, }), [disabled, readOnly, scrubbing, value, inputValue, min, max], ); const contextValue: NumberFieldRootContext = React.useMemo( () => ({ state, id, min, max, minWithDefault, maxWithDefault, locale, setValue, incrementValue, getStepAmount, setInputValue, setScrubbing, allowInputSyncRef, valueRef, lastChangedValueRef, formatOptionsRef, onValueCommitted: handleValueCommitted, }), [ state, id, min, max, minWithDefault, maxWithDefault, locale, setValue, incrementValue, getStepAmount, handleValueCommitted, ], ); const element = useRenderElement(View, componentProps, { state, ref, props: elementProps, }); return ( {element} ); } export interface NumberFieldRootState { /** * Whether the component should ignore user interaction. */ disabled: boolean; /** * Whether the user should be unable to change the field value. */ readOnly: boolean; /** * Whether the user must enter a value. */ required: boolean; /** * Whether the value is being changed by dragging the scrub area. */ scrubbing: boolean; /** * The raw numeric value of the field. */ value: number | null; /** * The text currently shown in the input. */ inputValue: string; /** * The minimum value of the field. */ min: number | undefined; /** * The maximum value of the field. */ max: number | undefined; } export interface NumberFieldRootProps extends Omit, 'id'> { /** * The raw numeric value of the field. */ value?: number | null | undefined; /** * The uncontrolled value of the field when it's initially rendered. * * To render a controlled number field, use the `value` prop instead. */ defaultValue?: number | undefined; /** * The id of the input element. */ id?: string | undefined; /** * The minimum value of the field. */ min?: number | undefined; /** * The maximum value of the field. */ max?: number | undefined; /** * Amount to increment and decrement with the buttons, or to scrub with a drag * in the scrub area. * * Specify `step="any"` to disable step validation; stepping then uses 1. * @default 1 */ step?: number | 'any' | undefined; /** * Whether the value should snap to the nearest step when incrementing or * decrementing. * @default false */ snapOnStep?: boolean | undefined; /** * When true, typed text may fall outside the `min`/`max` range without * clamping. Step-based interactions (buttons, scrub) still clamp. * @default false */ allowOutOfRange?: boolean | undefined; /** * Whether the component should ignore user interaction. * @default false */ disabled?: boolean | undefined; /** * Whether the user should be unable to change the field value. * @default false */ readOnly?: boolean | undefined; /** * Options to format the input value. */ format?: Intl.NumberFormatOptions | undefined; /** * The locale of the input element. * Defaults to the user's runtime locale. */ locale?: Intl.LocalesArgument | undefined; /** * Callback fired when the number value changes. */ onValueChange?: | ((value: number | null, eventDetails: NumberFieldRoot.ChangeEventDetails) => void) | undefined; /** * Callback fired when the value is committed: when the input is blurred after * typing, or the press is released after scrubbing or stepping. */ onValueCommitted?: | ((value: number | null, eventDetails: NumberFieldRoot.ChangeEventDetails) => void) | undefined; } export type NumberFieldRootChangeEventReason = | typeof REASONS.inputChange | typeof REASONS.inputClear | typeof REASONS.inputBlur | typeof REASONS.incrementPress | typeof REASONS.decrementPress | typeof REASONS.scrub | typeof REASONS.none; export type NumberFieldRootChangeEventDetails = ZestChangeEventDetails< NumberFieldRootChangeEventReason, { /** * Which way a step went, when the change was a step. Absent for direct text * entry, which must not be directionally snapped. */ direction?: 1 | -1 | undefined; } >; export namespace NumberFieldRoot { export type State = NumberFieldRootState; export type Props = NumberFieldRootProps; export type ChangeEventReason = NumberFieldRootChangeEventReason; export type ChangeEventDetails = NumberFieldRootChangeEventDetails; }