{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "number-input",
  "type": "registry:ui",
  "title": "Number Input",
  "description": "Numeric input with ± stepper buttons, min/max clamping, configurable step + precision.",
  "dependencies": [
    "@saasflare/ui"
  ],
  "files": [
    {
      "path": "components/ui/number-input.tsx",
      "type": "registry:ui",
      "content": "// @draft\n\"use client\"\n\n/**\n * @fileoverview Saasflare NumberInput — numeric input with stepper buttons.\n * @author Saasflare™\n *\n * Wraps a native `<input type=\"number\">` with +/- steppers, min/max\n * clamping, and configurable step. Honours `disabled` and `readOnly`.\n * Designed to drop into Form contexts unchanged.\n *\n * @module packages/ui/components/ui/number-input\n * @package ui\n * @layer core\n *\n * @example\n * const [n, setN] = useState(1);\n * <NumberInput value={n} onChange={setN} min={0} max={10} step={1} />\n */\n\nimport {\n    useCallback,\n    useState,\n    type ChangeEvent,\n    type FocusEvent,\n} from \"react\"\nimport { cn } from \"@saasflare/ui\"\nimport { useSaasflareProps, type SaasflareComponentProps } from \"@saasflare/ui\"\nimport { CaretUpIcon, CaretDownIcon } from \"@saasflare/ui\"\n\n/** Props for the NumberInput component. */\nexport interface NumberInputProps extends SaasflareComponentProps {\n    /** Controlled value. */\n    value?: number\n    /** Uncontrolled initial value. */\n    defaultValue?: number\n    /** Called when the value changes (after clamp). */\n    onChange?: (value: number) => void\n    /** Minimum allowed value. */\n    min?: number\n    /** Maximum allowed value. */\n    max?: number\n    /** Step for stepper buttons + arrow keys. Default: `1`. */\n    step?: number\n    /** Decimal precision for display. Default: derived from `step`. */\n    precision?: number\n    /** Placeholder shown when empty. */\n    placeholder?: string\n    /** Disable the input. */\n    disabled?: boolean\n    /** Read-only input. */\n    readOnly?: boolean\n    /** Hide the stepper buttons. */\n    hideSteppers?: boolean\n    /** Additional class names on the outer wrapper. */\n    className?: string\n    /** Name attribute (for form submission). */\n    name?: string\n    /** Accessible label. */\n    \"aria-label\"?: string\n}\n\nfunction clamp(n: number, min?: number, max?: number): number {\n    let out = n\n    if (typeof min === \"number\") out = Math.max(out, min)\n    if (typeof max === \"number\") out = Math.min(out, max)\n    return out\n}\n\nfunction precisionFromStep(step: number): number {\n    const s = String(step)\n    const dot = s.indexOf(\".\")\n    return dot === -1 ? 0 : s.length - dot - 1\n}\n\n/**\n * Numeric input with stepper buttons and clamping.\n *\n * @component\n * @layer core\n */\nexport function NumberInput({\n    value,\n    defaultValue,\n    onChange,\n    min,\n    max,\n    step = 1,\n    precision,\n    placeholder,\n    disabled = false,\n    readOnly = false,\n    hideSteppers = false,\n    className,\n    name,\n    surface,\n    radius,\n    animated,\n    iconWeight,\n    \"aria-label\": ariaLabel,\n}: NumberInputProps) {\n    const sf = useSaasflareProps({ surface, radius, animated, iconWeight })\n    const isControlled = value !== undefined\n    const initial = defaultValue ?? (typeof min === \"number\" ? min : 0)\n    const [internal, setInternal] = useState<number>(initial)\n    // Editing buffer: holds the raw field text while the user is mid-edit\n    // (e.g. \"\" or \"-\"). `null` means \"show the committed value\".\n    const [draft, setDraft] = useState<string | null>(null)\n    const current = isControlled ? (value as number) : internal\n    const decimals = precision ?? precisionFromStep(step)\n\n    const commit = useCallback(\n        (next: number) => {\n            const clamped = clamp(Number.isFinite(next) ? next : initial, min, max)\n            if (!isControlled) setInternal(clamped)\n            onChange?.(clamped)\n        },\n        [initial, isControlled, max, min, onChange],\n    )\n\n    const handleChange = (e: ChangeEvent<HTMLInputElement>) => {\n        const raw = e.target.value\n        if (raw === \"\" || raw === \"-\") {\n            // Allow transient empty / negative-only state; commit on blur.\n            // Keep the committed value untouched so it is not lost.\n            setDraft(raw)\n            return\n        }\n        const parsed = Number(raw)\n        if (Number.isNaN(parsed)) return\n        setDraft(null)\n        commit(parsed)\n    }\n\n    const handleBlur = (e: FocusEvent<HTMLInputElement>) => {\n        setDraft(null)\n        const parsed = Number(e.target.value)\n        commit(Number.isNaN(parsed) ? initial : parsed)\n    }\n\n    const inc = () => {\n        setDraft(null)\n        commit(current + step)\n    }\n    const dec = () => {\n        setDraft(null)\n        commit(current - step)\n    }\n\n    const displayValue =\n        decimals > 0 && Number.isFinite(current)\n            ? current.toFixed(decimals)\n            : String(current)\n\n    return (\n        <div\n            data-slot=\"number-input\"\n            data-surface={sf.surface}\n            data-radius={sf.radius}\n            data-animated={String(sf.animated)}\n            data-disabled={String(disabled)}\n            className={cn(\n                \"inline-flex h-9 w-full min-w-32 items-stretch rounded-md border border-input bg-transparent text-sm shadow-xs\",\n                \"focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50\",\n                \"data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-50\",\n                className,\n            )}\n        >\n            <input\n                data-slot=\"number-input-field\"\n                type=\"number\"\n                inputMode=\"decimal\"\n                name={name}\n                value={\n                    draft !== null\n                        ? draft\n                        : Number.isFinite(current)\n                          ? displayValue\n                          : \"\"\n                }\n                onChange={handleChange}\n                onBlur={handleBlur}\n                disabled={disabled}\n                readOnly={readOnly}\n                placeholder={placeholder}\n                min={min}\n                max={max}\n                step={step}\n                aria-label={ariaLabel}\n                className=\"min-w-0 flex-1 bg-transparent px-3 text-sm outline-none placeholder:text-muted-foreground\n                           [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none\"\n            />\n            {!hideSteppers && (\n                <div\n                    data-slot=\"number-input-steppers\"\n                    className=\"flex flex-col border-l border-input\"\n                >\n                    <button\n                        type=\"button\"\n                        data-slot=\"number-input-increment\"\n                        onClick={inc}\n                        disabled={disabled || readOnly || (typeof max === \"number\" && current >= max)}\n                        tabIndex={-1}\n                        aria-label=\"Increment\"\n                        className=\"flex h-1/2 w-6 items-center justify-center text-muted-foreground transition-colors hover:bg-foreground/5 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50\"\n                    >\n                        <CaretUpIcon weight={sf.iconWeight} className=\"size-3\" aria-hidden=\"true\" />\n                    </button>\n                    <button\n                        type=\"button\"\n                        data-slot=\"number-input-decrement\"\n                        onClick={dec}\n                        disabled={disabled || readOnly || (typeof min === \"number\" && current <= min)}\n                        tabIndex={-1}\n                        aria-label=\"Decrement\"\n                        className=\"flex h-1/2 w-6 items-center justify-center border-t border-input text-muted-foreground transition-colors hover:bg-foreground/5 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50\"\n                    >\n                        <CaretDownIcon weight={sf.iconWeight} className=\"size-3\" aria-hidden=\"true\" />\n                    </button>\n                </div>\n            )}\n        </div>\n    )\n}\n",
      "target": "components/ui/number-input.tsx"
    }
  ],
  "$meta": {
    "source": "https://ui.saasflare.io/r/number-input.json"
  }
}
