{"name":"number-input","title":"Number Input","description":"Numeric input with increment/decrement controls.","type":"registry:ui","docs":"/components/number-input","categories":["forms"],"registryDependencies":["https://pb-ui-five.vercel.app/registry/button","https://pb-ui-five.vercel.app/registry/input"],"files":[{"path":"components/ui/number-input.tsx","target":"components/ui/number-input.tsx","type":"registry:ui","content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { MinusIcon, PlusIcon } from \"lucide-react\";\nimport * as React from \"react\";\nimport { Button } from \"./button\";\nimport { Input } from \"./input\";\n\nexport type NumberInputProps = Omit<\n  React.InputHTMLAttributes<HTMLInputElement>,\n  \"type\" | \"value\" | \"onChange\"\n> & {\n  value?: number | null;\n  onChange?: (value: number | null) => void;\n  min?: number;\n  max?: number;\n  step?: number;\n  showControls?: boolean;\n  /** Allow decimal numbers. When false, only integers are allowed. */\n  allowDecimals?: boolean;\n  /** Maximum number of decimal places. Only applies when allowDecimals is true. */\n  decimalPlaces?: number;\n  /** Locale for decimal separator detection. Defaults to browser locale. */\n  locale?: string;\n};\n\nfunction NumberInput({\n  value,\n  onChange,\n  min,\n  max,\n  step = 1,\n  showControls = true,\n  allowDecimals = false,\n  decimalPlaces,\n  locale,\n  disabled,\n  className,\n  ...props\n}: NumberInputProps) {\n  // Get the decimal separator for the locale (defaults to browser locale)\n  const decimalSeparator = React.useMemo(() => {\n    const parts = new Intl.NumberFormat(locale).formatToParts(1.1);\n    return parts.find((part) => part.type === \"decimal\")?.value ?? \".\";\n  }, [locale]);\n\n  // Format a number for display\n  const formatValue = React.useCallback(\n    (val: number) => {\n      let formatted: string;\n      if (allowDecimals && decimalPlaces !== undefined) {\n        formatted = val.toFixed(decimalPlaces);\n      } else {\n        // Use enough precision to represent the number accurately\n        formatted = String(val);\n      }\n      // Replace standard decimal separator with locale-specific one\n      if (decimalSeparator !== \".\") {\n        formatted = formatted.replace(\".\", decimalSeparator);\n      }\n      return formatted;\n    },\n    [allowDecimals, decimalPlaces, decimalSeparator],\n  );\n\n  // Parse input string to number, handling locale decimal separator\n  const parseInput = React.useCallback(\n    (input: string): number | null => {\n      if (input === \"\" || input === \"-\") return null;\n      // Normalize: replace locale decimal separator with standard dot\n      let normalized = input;\n      if (decimalSeparator !== \".\") {\n        normalized = normalized.replace(decimalSeparator, \".\");\n      }\n      // Also accept dot as input even if locale uses comma\n      normalized = normalized.replace(\",\", \".\");\n      const parsed = parseFloat(normalized);\n      return isNaN(parsed) ? null : parsed;\n    },\n    [decimalSeparator],\n  );\n\n  // Clamp and round value according to constraints\n  const clampValue = React.useCallback(\n    (val: number): number => {\n      let result = val;\n      if (min != null && result < min) result = min;\n      if (max != null && result > max) result = max;\n      if (!allowDecimals) {\n        result = Math.round(result);\n      } else if (decimalPlaces !== undefined) {\n        const factor = Math.pow(10, decimalPlaces);\n        result = Math.round(result * factor) / factor;\n      }\n      return result;\n    },\n    [min, max, allowDecimals, decimalPlaces],\n  );\n\n  const [inputValue, setInputValue] = React.useState<string>(\n    value != null ? formatValue(value) : \"\",\n  );\n\n  // Track focus state to prevent reformatting while typing\n  const isFocusedRef = React.useRef(false);\n  const prevValueRef = React.useRef(value);\n\n  React.useEffect(() => {\n    // Only update from external value when NOT focused\n    // This prevents reformatting while the user is typing\n    if (!isFocusedRef.current && value !== prevValueRef.current) {\n      setInputValue(value != null ? formatValue(value) : \"\");\n      prevValueRef.current = value;\n    }\n  }, [value, formatValue]);\n\n  const handleFocus = () => {\n    isFocusedRef.current = true;\n  };\n\n  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n    const raw = e.target.value;\n\n    // Allow free typing - only filter obvious garbage\n    // Allow: digits, minus sign, dot, comma (for locale flexibility)\n    const isValidChar = /^-?[\\d.,]*$/.test(raw);\n    if (!isValidChar && raw !== \"\") return;\n\n    // If decimals not allowed, prevent typing decimal separators\n    if (!allowDecimals && (raw.includes(\".\") || raw.includes(\",\"))) {\n      return;\n    }\n\n    setInputValue(raw);\n\n    // Parse and notify parent (without clamping during typing)\n    const parsed = parseInput(raw);\n    if (parsed !== null) {\n      onChange?.(parsed);\n    } else if (raw === \"\" || raw === \"-\") {\n      onChange?.(null);\n    }\n  };\n\n  const handleBlur = () => {\n    isFocusedRef.current = false;\n\n    if (value != null) {\n      // Clamp and format on blur\n      const clamped = clampValue(value);\n      setInputValue(formatValue(clamped));\n      prevValueRef.current = clamped;\n      if (clamped !== value) {\n        onChange?.(clamped);\n      }\n    } else {\n      setInputValue(\"\");\n    }\n  };\n\n  const increment = () => {\n    const current = value ?? 0;\n    const newValue = clampValue(current + step);\n    onChange?.(newValue);\n    setInputValue(formatValue(newValue));\n    prevValueRef.current = newValue;\n  };\n\n  const decrement = () => {\n    const current = value ?? 0;\n    const newValue = clampValue(current - step);\n    onChange?.(newValue);\n    setInputValue(formatValue(newValue));\n    prevValueRef.current = newValue;\n  };\n\n  const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {\n    if (e.key === \"ArrowUp\") {\n      e.preventDefault();\n      if (canIncrement) increment();\n    } else if (e.key === \"ArrowDown\") {\n      e.preventDefault();\n      if (canDecrement) decrement();\n    }\n  };\n\n  const canIncrement = max == null || (value ?? 0) < max;\n  const canDecrement = min == null || (value ?? 0) > min;\n\n  return (\n    <div className={cn(\"flex items-center\", className)}>\n      {showControls && (\n        <Button\n          type=\"button\"\n          variant=\"outline\"\n          size=\"icon\"\n          onClick={decrement}\n          disabled={disabled || !canDecrement}\n          className=\"border-r-0 rounded-r-none\"\n          aria-label=\"Decrease value\"\n        >\n          <MinusIcon className=\"size-4\" />\n        </Button>\n      )}\n      <Input\n        type=\"text\"\n        inputMode={allowDecimals ? \"decimal\" : \"numeric\"}\n        value={inputValue}\n        onChange={handleInputChange}\n        onFocus={handleFocus}\n        onBlur={handleBlur}\n        onKeyDown={handleKeyDown}\n        disabled={disabled}\n        className={cn(\"text-center\", showControls && \"rounded-none border-x-0\")}\n        {...props}\n      />\n      {showControls && (\n        <Button\n          type=\"button\"\n          variant=\"outline\"\n          size=\"icon\"\n          onClick={increment}\n          disabled={disabled || !canIncrement}\n          className=\"border-l-0 rounded-l-none\"\n          aria-label=\"Increase value\"\n        >\n          <PlusIcon className=\"size-4\" />\n        </Button>\n      )}\n    </div>\n  );\n}\n\nexport { NumberInput };\n"}]}