{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "number-field",
  "title": "Number Field",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-react/components/number-field/number-field.tsx",
      "content": "'use client'\n\nimport * as React from 'react'\nimport { Minus, Plus } from 'lucide-react'\nimport { cn } from '@/lib/utils'\n\nexport type NumberFieldSize = 'small' | 'middle' | 'large'\nexport type NumberFieldStatus = 'error' | 'warning'\nexport type NumberFieldControlsPosition = 'default' | 'right'\n\nexport interface NumberFieldProps {\n  /** Controlled value. `undefined` renders an empty field. */\n  value?: number\n  defaultValue?: number\n  onValueChange?: (value: number | undefined) => void\n  min?: number\n  max?: number\n  step?: number\n  /** Fixed number of decimal digits in the formatted display. */\n  precision?: number\n  disabled?: boolean\n  readOnly?: boolean\n  size?: NumberFieldSize\n  status?: NumberFieldStatus\n  controlsPosition?: NumberFieldControlsPosition\n  /** Enable Arrow/Page/Home/End keyboard stepping. */\n  keyboard?: boolean\n  /** Intl number-format options merged into the display formatter. */\n  formatOptions?: Intl.NumberFormatOptions\n  /** Custom display formatter — wins over `formatOptions`/`precision`. */\n  formatter?: (value: number | undefined) => string\n  /** Custom parser from the typed text back to a number. */\n  parser?: (displayValue: string) => number | undefined\n  prefix?: string\n  suffix?: string\n  placeholder?: string\n  id?: string\n  className?: string\n  'aria-label'?: string\n}\n\nconst decrementIncrementBase =\n  'focus-visible:ring-ring inline-flex shrink-0 items-center justify-center transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-30'\n\nfunction clamp(value: number, min?: number, max?: number) {\n  let next = value\n  if (min !== undefined) next = Math.max(min, next)\n  if (max !== undefined) next = Math.min(max, next)\n  return next\n}\n\nconst NumberField = React.forwardRef<HTMLInputElement, NumberFieldProps>(\n  (\n    {\n      value,\n      defaultValue,\n      onValueChange,\n      min,\n      max,\n      step = 1,\n      precision,\n      disabled,\n      readOnly,\n      size = 'middle',\n      status,\n      controlsPosition = 'default',\n      keyboard = true,\n      formatOptions,\n      formatter,\n      parser,\n      prefix,\n      suffix,\n      placeholder,\n      id,\n      className,\n      'aria-label': ariaLabel,\n    },\n    ref,\n  ) => {\n    const innerRef = React.useRef<HTMLInputElement | null>(null)\n    const setRefs = React.useCallback(\n      (node: HTMLInputElement | null) => {\n        innerRef.current = node\n        if (typeof ref === 'function') ref(node)\n        else if (ref) (ref as React.MutableRefObject<HTMLInputElement | null>).current = node\n      },\n      [ref],\n    )\n\n    const isControlled = value !== undefined\n    const [internal, setInternal] = React.useState<number | undefined>(defaultValue)\n    const currentValue = isControlled ? value : internal\n\n    const isRight = controlsPosition === 'right'\n\n    const intlOptions = React.useMemo<Intl.NumberFormatOptions | undefined>(() => {\n      if (precision !== undefined) {\n        return { ...formatOptions, minimumFractionDigits: precision, maximumFractionDigits: precision }\n      }\n      return formatOptions\n    }, [formatOptions, precision])\n\n    const formatValue = React.useCallback(\n      (val: number | undefined): string => {\n        if (formatter) return formatter(val)\n        if (val === undefined || Number.isNaN(val)) return ''\n        if (intlOptions) return new Intl.NumberFormat(undefined, intlOptions).format(val)\n        return String(val)\n      },\n      [formatter, intlOptions],\n    )\n\n    const [displayValue, setDisplayValue] = React.useState<string>(() => formatValue(currentValue))\n    const [isUserTyping, setIsUserTyping] = React.useState(false)\n\n    // Keep the display in sync with the value whenever the user isn't typing.\n    React.useEffect(() => {\n      if (!isUserTyping) setDisplayValue(formatValue(currentValue))\n    }, [currentValue, formatValue, isUserTyping])\n\n    const emit = React.useCallback(\n      (next: number | undefined) => {\n        if (!isControlled) setInternal(next)\n        onValueChange?.(next)\n      },\n      [isControlled, onValueChange],\n    )\n\n    const stepBy = React.useCallback(\n      (delta: number) => {\n        if (disabled || readOnly) return\n        const base = currentValue ?? 0\n        const next = clamp(base + delta, min, max)\n        emit(next)\n        setIsUserTyping(false)\n        setDisplayValue(formatValue(next))\n      },\n      [currentValue, disabled, readOnly, min, max, emit, formatValue],\n    )\n\n    const handleIncrease = (mult = 1) => stepBy(step * mult)\n    const handleDecrease = (mult = 1) => stepBy(-step * mult)\n\n    function commitValue() {\n      setIsUserTyping(false)\n      const raw = displayValue.trim()\n      if (raw === '') {\n        emit(undefined)\n        setDisplayValue('')\n        return\n      }\n      let num: number | undefined\n      if (parser) num = parser(raw)\n      else num = Number(raw)\n\n      if (num !== undefined && !Number.isNaN(num)) {\n        const next = clamp(num, min, max)\n        emit(next)\n        setDisplayValue(formatValue(next))\n      } else {\n        setDisplayValue(formatValue(currentValue))\n      }\n    }\n\n    function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {\n      if (event.key === 'ArrowUp') {\n        if (keyboard) {\n          event.preventDefault()\n          handleIncrease()\n        }\n      } else if (event.key === 'ArrowDown') {\n        if (keyboard) {\n          event.preventDefault()\n          handleDecrease()\n        }\n      } else if (event.key === 'PageUp') {\n        if (keyboard) {\n          event.preventDefault()\n          handleIncrease(10)\n        }\n      } else if (event.key === 'PageDown') {\n        if (keyboard) {\n          event.preventDefault()\n          handleDecrease(10)\n        }\n      } else if (event.key === 'Home') {\n        if (keyboard && min !== undefined) {\n          event.preventDefault()\n          emit(min)\n          setIsUserTyping(false)\n          setDisplayValue(formatValue(min))\n        }\n      } else if (event.key === 'End') {\n        if (keyboard && max !== undefined) {\n          event.preventDefault()\n          emit(max)\n          setIsUserTyping(false)\n          setDisplayValue(formatValue(max))\n        }\n      } else if (event.key === 'Enter') {\n        commitValue()\n      }\n    }\n\n    function handleWheel(event: React.WheelEvent<HTMLInputElement>) {\n      if (event.currentTarget !== document.activeElement) return\n      if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return\n      if (event.deltaY > 0) handleDecrease()\n      else handleIncrease()\n    }\n\n    const iconSize = size === 'small' ? 'h-3 w-3' : size === 'large' ? 'h-5 w-5' : 'h-4 w-4'\n\n    const buttonPadding = !isRight\n      ? size === 'small'\n        ? 'p-1.5'\n        : size === 'large'\n          ? 'p-4'\n          : 'p-3'\n      : ''\n\n    const inputSizeClasses =\n      size === 'small'\n        ? 'h-7 text-xs px-2 py-0.5'\n        : size === 'large'\n          ? 'h-11 text-base px-4 py-2'\n          : 'h-9 text-sm px-3 py-1'\n\n    const inputStatusClasses = !isRight\n      ? status === 'error'\n        ? 'border-destructive focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 aria-invalid:border-destructive'\n        : status === 'warning'\n          ? 'border-[var(--warning)] focus-visible:ring-[var(--warning)]/20'\n          : ''\n      : ''\n\n    const contentClasses = cn(\n      'relative',\n      isRight &&\n        'border-input focus-within:ring-ring inline-grid grid-cols-[1fr_auto] grid-rows-[1fr_1fr] items-stretch overflow-hidden rounded-md border focus-within:ring-1',\n    )\n\n    const decrement = (\n      <button\n        type=\"button\"\n        data-uipkge=\"\"\n        data-slot=\"decrement\"\n        tabIndex={-1}\n        aria-label=\"Decrease\"\n        disabled={disabled || readOnly || (min !== undefined && (currentValue ?? 0) <= min)}\n        onClick={() => handleDecrease()}\n        className={cn(\n          decrementIncrementBase,\n          !isRight && 'absolute top-1/2 left-0 -translate-y-1/2',\n          buttonPadding,\n          isRight && 'hover:bg-accent col-start-2 row-start-2 h-full w-auto rounded-none border-t border-l p-0 px-2',\n        )}\n      >\n        <Minus className={iconSize} aria-hidden=\"true\" />\n      </button>\n    )\n\n    const increment = (\n      <button\n        type=\"button\"\n        data-uipkge=\"\"\n        data-slot=\"increment\"\n        tabIndex={-1}\n        aria-label=\"Increase\"\n        disabled={disabled || readOnly || (max !== undefined && (currentValue ?? 0) >= max)}\n        onClick={() => handleIncrease()}\n        className={cn(\n          decrementIncrementBase,\n          !isRight && 'absolute top-1/2 right-0 -translate-y-1/2',\n          buttonPadding,\n          isRight && 'hover:bg-accent col-start-2 row-start-1 h-full w-auto rounded-none border-l p-0 px-2',\n        )}\n      >\n        <Plus className={iconSize} aria-hidden=\"true\" />\n      </button>\n    )\n\n    const inputBlock = (\n      <div\n        data-uipkge=\"\"\n        data-slot=\"input\"\n        className={cn(\n          'relative flex-1',\n          isRight && 'col-span-1 row-span-2',\n          // Non-right layout: pad the input to clear the overlaid steppers.\n          !isRight && '[&>input]:pl-5 [&>input]:pr-5',\n        )}\n      >\n        {prefix && (\n          <span className=\"text-muted-foreground pointer-events-none absolute top-1/2 left-2 -translate-y-1/2 text-sm\">\n            {prefix}\n          </span>\n        )}\n        <input\n          id={id}\n          ref={setRefs}\n          value={displayValue}\n          type=\"text\"\n          role=\"spinbutton\"\n          aria-label={ariaLabel}\n          aria-valuenow={currentValue}\n          aria-valuemin={min}\n          aria-valuemax={max}\n          aria-invalid={status === 'error' ? true : undefined}\n          inputMode={precision !== undefined ? 'decimal' : 'numeric'}\n          disabled={disabled}\n          readOnly={readOnly}\n          placeholder={placeholder}\n          autoComplete=\"off\"\n          autoCorrect=\"off\"\n          spellCheck={false}\n          aria-roledescription=\"Number field\"\n          onFocus={() => setIsUserTyping(true)}\n          onChange={(e) => setDisplayValue(e.target.value)}\n          onBlur={commitValue}\n          onKeyDown={handleKeyDown}\n          onWheel={handleWheel}\n          className={cn(\n            'placeholder:text-muted-foreground w-full bg-transparent text-center shadow-sm transition-colors outline-none disabled:cursor-not-allowed disabled:opacity-50',\n            !isRight && 'border-input focus-visible:ring-ring rounded-md border focus-visible:ring-1',\n            isRight && 'rounded-none border-0 focus-visible:ring-0',\n            prefix && 'pl-6',\n            suffix && 'pr-6',\n            inputSizeClasses,\n            inputStatusClasses,\n          )}\n        />\n        {suffix && (\n          <span className=\"text-muted-foreground pointer-events-none absolute top-1/2 right-2 -translate-y-1/2 text-sm\">\n            {suffix}\n          </span>\n        )}\n      </div>\n    )\n\n    return (\n      <div data-uipkge=\"\" data-slot=\"number-field\" className={cn('inline-flex', className)}>\n        <div className={contentClasses}>\n          {decrement}\n          {inputBlock}\n          {increment}\n        </div>\n      </div>\n    )\n  },\n)\nNumberField.displayName = 'NumberField'\n\nexport { NumberField }\n",
      "type": "registry:ui",
      "target": "~/components/ui/number-field/number-field.tsx"
    },
    {
      "path": "packages/registry-react/components/number-field/index.ts",
      "content": "export {\n  NumberField,\n  type NumberFieldProps,\n  type NumberFieldSize,\n  type NumberFieldStatus,\n  type NumberFieldControlsPosition,\n} from './number-field'\n",
      "type": "registry:ui",
      "target": "~/components/ui/number-field/index.ts"
    }
  ],
  "dependencies": [
    "lucide-react"
  ],
  "devDependencies": [],
  "registryDependencies": [],
  "description": "Numeric input with stepper buttons, min/max bounds, step size, and decimal precision. Use for quantities, prices, and any field that should be a number rather than free text.",
  "categories": [
    "form"
  ]
}