'use client'; import React, { forwardRef, useCallback, useEffect, useId, useRef, useState } from 'react'; import { Surface, Tone, cn, focusRing, inputBase, sizeHeight, surfaceClasses, toneMap, useEffectiveSurface, FieldShell, } from '../common'; import { useControllableState } from '../hooks/useControllableState'; type NumSize = 'sm' | 'md' | 'lg'; export interface PixelNumberInputProps extends Omit< React.InputHTMLAttributes, 'value' | 'defaultValue' | 'onChange' | 'type' | 'size' > { value?: number; defaultValue?: number; onChange?: (next: number) => void; min?: number; max?: number; step?: number; precision?: number; clampBehavior?: 'strict' | 'blur' | 'none'; prefix?: string; suffix?: string; thousandsSeparator?: string; allowNegative?: boolean; hideControls?: boolean; size?: NumSize; surface?: Surface; tone?: Tone; label?: string; hint?: string; error?: string; } const NaNGuard = (n: number, fallback: number) => (Number.isFinite(n) ? n : fallback); function clampValue(value: number, min?: number, max?: number) { let next = value; if (typeof min === 'number' && next < min) next = min; if (typeof max === 'number' && next > max) next = max; return next; } function formatDisplay( value: number | undefined, precision: number | undefined, thousandsSeparator: string | undefined, ): string { if (value === undefined || Number.isNaN(value as number)) return ''; let text = typeof precision === 'number' ? value.toFixed(precision) : String(value); if (thousandsSeparator) { const [intPart, decPart] = text.split('.'); const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, thousandsSeparator); text = decPart !== undefined ? `${grouped}.${decPart}` : grouped; } return text; } function parseRaw( raw: string, thousandsSeparator: string | undefined, allowNegative: boolean, ): { text: string; num: number | undefined } { let text = raw; if (thousandsSeparator) { const sep = thousandsSeparator.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); text = text.replace(new RegExp(sep, 'g'), ''); } if (!allowNegative) { text = text.replace(/-/g, ''); } // strip anything that isn't digit / minus / decimal point text = text.replace(/[^0-9.\-]/g, ''); if (text === '' || text === '-' || text === '.' || text === '-.') { return { text: raw, num: undefined }; } const num = Number(text); if (Number.isNaN(num)) return { text: raw, num: undefined }; return { text, num }; } export const PixelNumberInput = forwardRef( function PixelNumberInput( { value, defaultValue, onChange, min, max, step = 1, precision, clampBehavior = 'blur', prefix, suffix, thousandsSeparator, allowNegative = true, hideControls = false, size = 'md', surface: surfaceProp, tone = 'neutral', label, hint, error, className, disabled, name, id, placeholder, onBlur, onFocus, onKeyDown, ...rest }, ref, ) { const surface = useEffectiveSurface(surfaceProp); const s = surfaceClasses(surface); const initial: number | undefined = value !== undefined ? value : defaultValue; // Single source-of-truth for the numeric model. const [current, setCurrent] = useControllableState({ value, defaultValue: initial, onChange: (next) => { if (typeof next === 'number' && !Number.isNaN(next)) onChange?.(next); }, }); // Display string — what the user actually sees. Decoupled while focused // so partial inputs like "" or "-" don't snap back. const [display, setDisplay] = useState(() => formatDisplay(current, precision, thousandsSeparator), ); const focusedRef = useRef(false); // Keep display in sync with controlled `current` when not focused. useEffect(() => { if (!focusedRef.current) { setDisplay(formatDisplay(current, precision, thousandsSeparator)); } }, [current, precision, thousandsSeparator]); const commit = useCallback( (next: number | undefined) => { setCurrent(next as number); }, [setCurrent], ); const bump = useCallback( (direction: 1 | -1) => { if (disabled) return; const base = typeof current === 'number' ? current : (min ?? 0); let next = base + direction * step; next = clampValue(next, min, max); // round to precision when defined, to avoid 0.1 + 0.2 = 0.300...0004 if (typeof precision === 'number') { const factor = Math.pow(10, precision); next = Math.round(next * factor) / factor; } commit(next); }, [current, disabled, step, min, max, precision, commit], ); const handleChange = (e: React.ChangeEvent) => { const raw = e.target.value; const { text, num } = parseRaw(raw, thousandsSeparator, allowNegative); // Always reflect what the user typed (after minus-strip / separator-strip) setDisplay(text); if (num === undefined) { // partial input — don't fire onChange yet return; } let next = num; if (clampBehavior === 'strict') { next = clampValue(next, min, max); // sync display if clamp actually moved the value if (next !== num) { setDisplay(formatDisplay(next, precision, thousandsSeparator)); } } commit(next); }; const handleBlur = (e: React.FocusEvent) => { focusedRef.current = false; let next = current; if (typeof next === 'number') { if (clampBehavior !== 'none') { next = clampValue(next, min, max); } if (typeof precision === 'number') { const factor = Math.pow(10, precision); next = Math.round(NaNGuard(next, 0) * factor) / factor; } if (next !== current) commit(next); setDisplay(formatDisplay(next, precision, thousandsSeparator)); } else { setDisplay(''); } onBlur?.(e); }; const handleFocus = (e: React.FocusEvent) => { focusedRef.current = true; onFocus?.(e); }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'ArrowUp') { e.preventDefault(); bump(1); } else if (e.key === 'ArrowDown') { e.preventDefault(); bump(-1); } onKeyDown?.(e); }; const reactId = useId(); const inputId = id ?? `pxl-number-${reactId}`; return ( {prefix && ( {prefix} )} {suffix && ( {suffix} )} {!hideControls && ( )} {/* Hidden mirror for native form serialization. */} {name && ( )} ); }, ); PixelNumberInput.displayName = 'PixelNumberInput';