/**
* NumberInput — a value stepped by two buttons or typed by hand.
*
* A number is not a slider: past a handful of steps, dragging to 47 is worse
* than tapping + a few times or just typing it. So the control keeps both
* doors open — a − and a + at the ends for nudging, and an editable field in
* the middle for jumping straight to a value — and reconciles them through a
* single clamped, step-snapped number.
*
* The two ends are not just tap targets. Hold one down and it repeats, after a
* short delay and then faster, the way a native stepper does — so walking from
* 0 to 200 is a press, not two hundred of them. The repeat reads the live
* value from a ref rather than a closure, so it never strides off a stale
* number.
*
* Typing is allowed to be briefly invalid — an empty field, a lone `-`, a
* trailing `.` — because clamping every keystroke fights the person mid-number.
* The field commits (parses, snaps, clamps) on blur, and falls back to the
* last good value when what is left cannot be read as a number.
*
* ```tsx
*
*
* ```
*
* Runs controlled (`value` + `onValueChange`) or uncontrolled (`defaultValue`).
*/
import { forwardRef, useCallback, useEffect, useRef, useState } from 'react';
import { TextInput, View, type TextInputProps, type TextStyle } from 'react-native';
import { tv, type VariantProps } from 'tailwind-variants';
import { useCSSVariable } from 'uniwind';
import { MinusIcon, PlusIcon } from '../../icons';
import { AnimatedPressable } from '../../primitives/animated-pressable';
import { Text } from '../../primitives/text';
import { selectionTick } from '../../utils/haptics';
import { Label } from '../label';
import { normalize, precisionOf } from './number-input-math';
/** Wait before a held button starts repeating — a tap must not trip it. */
const REPEAT_DELAY = 400;
/** Interval between repeats once holding — fast enough to cover ground. */
const REPEAT_INTERVAL = 80;
const numberInputVariants = tv({
slots: {
container: 'w-full gap-1.5',
control: 'w-full flex-row items-center overflow-hidden rounded-lg border',
button: 'h-full items-center justify-center',
// The field takes the full height so the digits centre against the same
// box the two glyphs do, rather than against their own line's height.
field: 'h-full flex-1 text-center font-medium text-foreground',
description: 'text-sm text-muted-foreground',
error: 'text-sm text-destructive',
},
variants: {
variant: {
// Matches Input: outline draws its own edge on the page; filled sits
// inside a card, where a second border reads as a seam.
outline: { control: 'border-input bg-background' },
filled: { control: 'border-transparent bg-muted' },
},
size: {
sm: { control: 'h-10', button: 'w-10' },
md: { control: 'h-12', button: 'w-12' },
lg: { control: 'h-14', button: 'w-14' },
},
invalid: {
true: { control: 'border-destructive' },
},
disabled: {
true: { container: 'opacity-[0.64]' },
},
},
defaultVariants: {
variant: 'outline',
size: 'md',
},
});
/** Icon size per control size — the glyph scales with its target. */
const ICON_SIZE: Record<'sm' | 'md' | 'lg', number> = { sm: 16, md: 18, lg: 20 };
/**
* The field's job is not to be a box of its own: it has to put the number on
* exactly the axis the − and the + sit on, and in the exact middle between
* them. An editable field does not do that unhelped — it arrives with padding
* and, on Android, with font padding above and below the line, all of which
* shift the digits off centre. Zeroing both and centring the line in the full
* height leaves geometry as the only thing placing the number.
*
* Tabular figures on top of that, so the digits keep the same advance widths
* while the value counts — a 1 that is narrower than a 7 makes a centred
* number appear to shuffle sideways as it steps.
*/
const FIELD_BASE: TextStyle = {
paddingVertical: 0,
paddingHorizontal: 0,
includeFontPadding: false,
textAlignVertical: 'center',
fontVariant: ['tabular-nums'],
};
/**
* Font size is set here rather than by a text class because those bring a line
* height with them: a 16px number in a 24px line box. The extra leading is
* added above the digits, so centring that box leaves the number sitting a
* couple of pixels below the − and the + it is meant to line up with — close
* enough to look like a mistake rather than a difference. Sized this way the
* line box is the font's own, and centring it centres the digits.
*/
const FIELD_STYLE: Record<'sm' | 'md' | 'lg', TextStyle> = {
sm: { ...FIELD_BASE, fontSize: 14 },
md: { ...FIELD_BASE, fontSize: 16 },
lg: { ...FIELD_BASE, fontSize: 16 },
};
type NumberInputVariantProps = VariantProps;
export interface NumberInputProps
extends Omit,
Pick {
className?: string;
containerClassName?: string;
/** Controlled value. Leave unset and pass `defaultValue` to run uncontrolled. */
value?: number;
/** Starting value when uncontrolled. Defaults to `min`, or 0. */
defaultValue?: number;
/** Lower bound. The decrement button disables here. */
min?: number;
/** Upper bound. The increment button disables here. */
max?: number;
/** Nudge per press, and the granularity the value snaps to. */
step?: number;
/** Fires whenever the committed value changes. */
onValueChange?: (value: number) => void;
/**
* Format the displayed number — units, currency, grouping. The field shows
* this string; typing still reads a bare number back. Defaults to the value
* rounded to `step`'s precision.
*/
formatValue?: (value: number) => string;
/** Let the middle field be typed into. When false it is display-only. */
editable?: boolean;
disabled?: boolean;
/** A label above the control. Doubles as the accessibility label. */
label?: string;
/** Helper text below the control. Hidden while an error shows. */
description?: string;
/** Error message. When set, the control renders in its invalid state. */
errorMessage?: string;
/** Marks the field required — an asterisk on the label, and the a11y state. */
isRequired?: boolean;
/**
* Tick the haptic engine on each step. Needs the optional `expo-haptics`,
* and is silent without it.
*/
haptics?: boolean;
}
/**
* A number stepped by buttons or typed by hand, reconciled to one clamped,
* step-snapped value. See the file header for why it keeps both affordances.
*/
export const NumberInput = forwardRef(
(
{
className,
containerClassName,
value: controlledValue,
defaultValue,
min = -Infinity,
max = Infinity,
step = 1,
onValueChange,
formatValue,
editable = true,
variant = 'outline',
size = 'md',
disabled,
label,
description,
errorMessage,
isRequired,
haptics,
placeholder,
onFocus,
onBlur,
returnKeyType,
},
ref
) => {
const isControlled = controlledValue !== undefined;
const initial = normalize(
controlledValue ?? defaultValue ?? (isFinite(min) ? min : 0),
min,
max,
step
);
const [uncontrolled, setUncontrolled] = useState(initial);
const value = isControlled ? normalize(controlledValue!, min, max, step) : uncontrolled;
// The repeat timers read the live value through a ref, so a held button
// keeps counting from where it is rather than from the value it closed over.
const valueRef = useRef(value);
valueRef.current = value;
const invalid = !!errorMessage;
const foreground = useCSSVariable('--color-foreground');
const iconColor = typeof foreground === 'string' ? foreground : undefined;
const slots = numberInputVariants({ variant, size, invalid, disabled: !!disabled });
const format = useCallback(
(v: number) => (formatValue ? formatValue(v) : String(v)),
[formatValue]
);
// `text` is what the field shows while focused; it is allowed to be
// half-typed. Away from focus it always mirrors the committed value.
const [text, setText] = useState(() => format(value));
const [focused, setFocused] = useState(false);
useEffect(() => {
if (!focused) setText(format(value));
}, [value, focused, format]);
const commit = useCallback(
(next: number, tick: boolean) => {
const clamped = normalize(next, min, max, step);
if (tick && haptics) selectionTick();
if (!isControlled) setUncontrolled(clamped);
if (clamped !== value) onValueChange?.(clamped);
},
[min, max, step, haptics, isControlled, value, onValueChange]
);
// --- hold-to-repeat -----------------------------------------------------
const delayRef = useRef | null>(null);
const intervalRef = useRef | null>(null);
const stopRepeat = useCallback(() => {
if (delayRef.current) clearTimeout(delayRef.current);
if (intervalRef.current) clearInterval(intervalRef.current);
delayRef.current = null;
intervalRef.current = null;
}, []);
useEffect(() => stopRepeat, [stopRepeat]);
const startRepeat = useCallback(
(direction: 1 | -1) => {
const nudge = () => commit(valueRef.current + direction * step, true);
nudge();
delayRef.current = setTimeout(() => {
intervalRef.current = setInterval(nudge, REPEAT_INTERVAL);
}, REPEAT_DELAY);
},
[commit, step]
);
const atMin = value <= min;
const atMax = value >= max;
const handleEndEditing = useCallback(() => {
const parsed = parseFloat(text);
// An empty or unreadable field falls back to the last good value rather
// than snapping to a surprising 0.
commit(Number.isNaN(parsed) ? value : parsed, false);
setFocused(false);
setText(format(Number.isNaN(parsed) ? value : normalize(parsed, min, max, step)));
}, [text, value, commit, format, min, max, step]);
return (
{label ? (
) : null}
{
if (!disabled && !atMin) startRepeat(-1);
}}
onPressOut={stopRepeat}
accessibilityRole="button"
accessibilityLabel={`Decrease ${label ?? 'value'}`}
accessibilityState={{ disabled: disabled || atMin }}
style={atMin ? { opacity: 0.4 } : undefined}
>
{/* Same weight as the +, or the two ends read as different glyphs. */}
0 ? 'decimal-pad' : 'number-pad'}
placeholder={placeholder}
returnKeyType={returnKeyType}
selectTextOnFocus
onChangeText={setText}
onFocus={(e) => {
setFocused(true);
onFocus?.(e);
}}
onBlur={(e) => {
handleEndEditing();
onBlur?.(e);
}}
onSubmitEditing={handleEndEditing}
accessibilityLabel={label}
accessibilityState={{ disabled: !!disabled }}
aria-required={isRequired}
aria-invalid={invalid}
className={slots.field()}
style={FIELD_STYLE[size]}
/>
{
if (!disabled && !atMax) startRepeat(1);
}}
onPressOut={stopRepeat}
accessibilityRole="button"
accessibilityLabel={`Increase ${label ?? 'value'}`}
accessibilityState={{ disabled: disabled || atMax }}
style={atMax ? { opacity: 0.4 } : undefined}
>
{errorMessage ? (
{errorMessage}
) : description ? (
{description}
) : null}
);
}
);
NumberInput.displayName = 'NumberInput';