{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "number-input",
  "title": "Number Input",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A bounded numeric field with native spinbutton semantics, step buttons, and controlled or uncontrolled state.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/number-input.tsx",
      "content": "// Ballmac UI: Number Input. https://ui.ballmac.com/components/number-input\n\"use client\"\n\nimport * as React from \"react\"\nimport { Minus, Plus } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\nimport { useMessages } from \"@/lib/ballmac/i18n\"\n\ntype NumberInputProps = Omit<\n  React.ComponentProps<\"div\">,\n  \"onChange\" | \"defaultValue\"\n> & {\n  /** Controlled numeric value. Null renders an empty field. */\n  value?: number | null\n  /** Initial value when uncontrolled. */\n  defaultValue?: number | null\n  /** Called when the number changes. */\n  onValueChange?: (value: number | null) => void\n  /** Lowest allowed value. */\n  min?: number\n  /** Highest allowed value. */\n  max?: number\n  /** Increment used by buttons and arrow keys. */\n  step?: number\n  /** Accessible name for the input. */\n  label?: string\n  /** Native form field name. */\n  name?: string\n  /** Disable input and buttons. */\n  disabled?: boolean\n}\nfunction NumberInput({\n  value,\n  defaultValue = 0,\n  onValueChange,\n  min,\n  max,\n  step = 1,\n  label,\n  name,\n  disabled = false,\n  className,\n  ...props\n}: NumberInputProps) {\n  const msg = useMessages()\n  label ??= msg(\"number-input.label\", \"Number\")\n  const [internal, setInternal] = React.useState<number | null>(defaultValue)\n  const current = value !== undefined ? value : internal\n  const safeStep = Number.isFinite(step) && step > 0 ? step : 1\n  function clamp(next: number) {\n    return Math.min(\n      max ?? Infinity,\n      Math.max(min ?? -Infinity, Math.round(next * 1e8) / 1e8),\n    )\n  }\n  function commit(next: number | null) {\n    if (value === undefined) setInternal(next)\n    onValueChange?.(next)\n  }\n  function change(delta: number) {\n    commit(clamp((current ?? min ?? 0) + delta * safeStep))\n  }\n  return (\n    <div\n      data-slot=\"number-input\"\n      className={cn(\n        \"flex h-9 w-full min-w-0 items-center rounded-md border border-input bg-background shadow-xs transition-[border-color,box-shadow] duration-150 motion-reduce:transition-none focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50\",\n        className,\n      )}\n      {...props}\n    >\n      <button\n        type=\"button\"\n        aria-label={msg(\"number-input.decrease\", \"Decrease {label}\", { label })}\n        disabled={\n          disabled || (current != null && min != null && current <= min)\n        }\n        onClick={() => change(-1)}\n        className=\"flex size-9 shrink-0 items-center justify-center rounded-s-md outline-none hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-50\"\n      >\n        <Minus aria-hidden=\"true\" className=\"size-4\" />\n      </button>\n      <input\n        data-slot=\"number-input-field\"\n        type=\"number\"\n        inputMode=\"decimal\"\n        aria-label={label}\n        name={name}\n        min={min}\n        max={max}\n        step={safeStep}\n        disabled={disabled}\n        value={current ?? \"\"}\n        onChange={(event) => {\n          const next = event.currentTarget.value\n          commit(next === \"\" ? null : Number(next))\n        }}\n        onBlur={() => {\n          if (current != null) commit(clamp(current))\n        }}\n        className=\"h-full min-w-0 flex-1 appearance-none border-x border-input bg-transparent px-2 text-center text-sm tabular-nums text-foreground outline-none disabled:opacity-50 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none\"\n      />\n      <button\n        type=\"button\"\n        aria-label={msg(\"number-input.increase\", \"Increase {label}\", { label })}\n        disabled={\n          disabled || (current != null && max != null && current >= max)\n        }\n        onClick={() => change(1)}\n        className=\"flex size-9 shrink-0 items-center justify-center rounded-e-md outline-none hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-50\"\n      >\n        <Plus aria-hidden=\"true\" className=\"size-4\" />\n      </button>\n    </div>\n  )\n}\nexport { NumberInput, type NumberInputProps }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/number-input.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "number",
      "stepper",
      "spinbutton"
    ],
    "version": "1.0.0",
    "updated": "2026-09-30",
    "ai": {
      "summary": "A bounded numeric field with native spinbutton semantics, step buttons, and controlled or uncontrolled state.",
      "whenToUse": [
        "Choose a quantity or bounded count",
        "Adjust a value precisely using keys or buttons"
      ],
      "whenNotToUse": [
        "Use a slider when approximate adjustment is sufficient"
      ],
      "composesWith": [
        "input"
      ],
      "a11y": [
        {
          "keys": "Arrow Up / Down",
          "action": "Changes the native numeric value"
        },
        {
          "keys": "Tab / Enter",
          "action": "Operates the step buttons"
        }
      ],
      "customization": [
        "min, max, step",
        "value / defaultValue and onValueChange"
      ]
    },
    "examples": [
      "number-input-demo",
      "number-input-states"
    ],
    "url": "https://ui.ballmac.com/components/number-input"
  },
  "categories": [
    "forms"
  ],
  "type": "registry:ui"
}