{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "number-field",
  "title": "Number Field",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-angular/components/number-field/number-field.component.ts",
      "content": "import {\n  type AfterViewChecked,\n  ChangeDetectorRef,\n  Component,\n  ElementRef,\n  EventEmitter,\n  Input,\n  Output,\n  ViewChild,\n  booleanAttribute,\n  effect,\n  numberAttribute,\n  forwardRef,\n  inject,\n  signal,\n  ChangeDetectionStrategy,\n} from '@angular/core'\nimport { NG_VALUE_ACCESSOR, type ControlValueAccessor } from '@angular/forms'\nimport { cn } from '@/lib/utils'\n\nexport type NumberFieldSize = 'small' | 'middle' | 'large'\nexport type NumberFieldStatus = 'error' | 'warning'\nexport type NumberFieldControlsPosition = 'default' | 'right'\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): 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\nfunction optionalNumber(v: unknown): number | undefined {\n  return v === undefined || v === null || v === '' ? undefined : Number(v)\n}\n\n/**\n * Angular port of the React NumberField: one self-contained control -- the `<ui-number-field>`\n * host (`inline-flex`) wraps the content row with the decrement button, the input block\n * (prefix / role=spinbutton input / suffix) and the increment button. `controlsPosition=\"right\"`\n * stacks both steppers in a grid on the right.\n *\n * Model is React's `value` / `defaultValue` / `valueChange` (React `onValueChange`, emits\n * `undefined` for an empty field). Typing is free text committed on blur / Enter through\n * `parser` (default Number) and clamped to min / max; the display goes through `formatter`,\n * or Intl with `formatOptions` / `precision`. Arrow / Page / Home / End step when `keyboard`,\n * and the wheel steps while focused. NG_VALUE_ACCESSOR is provided for formControl / ngModel.\n */\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-number-field',\n  standalone: true,\n  exportAs: 'uiNumberField',\n  providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => UiNumberFieldComponent), multi: true }],\n  host: {\n    'data-uipkge': '',\n    'data-slot': 'number-field',\n    '[class]': 'hostClass',\n    // The id / aria-label belong to the native <input>.\n    '[attr.id]': 'null',\n    '[attr.aria-label]': 'null',\n    '[attr.placeholder]': 'null',\n  },\n  template: `\n    <div [class]=\"contentClasses\">\n      <button\n        type=\"button\"\n        data-uipkge=\"\"\n        data-slot=\"decrement\"\n        tabindex=\"-1\"\n        aria-label=\"Decrease\"\n        [disabled]=\"decrementDisabled\"\n        [class]=\"decrementClass\"\n        (click)=\"handleDecrease()\"\n      >\n        <svg\n          xmlns=\"http://www.w3.org/2000/svg\"\n          width=\"24\"\n          height=\"24\"\n          viewBox=\"0 0 24 24\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          stroke-width=\"2\"\n          stroke-linecap=\"round\"\n          stroke-linejoin=\"round\"\n          [attr.class]=\"iconClass('minus')\"\n          aria-hidden=\"true\"\n        >\n          <path d=\"M5 12h14\" />\n        </svg>\n      </button>\n      <div data-uipkge=\"\" data-slot=\"input\" [class]=\"inputBlockClass\">\n        @if (prefix) {\n          <span class=\"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          #inputEl\n          [attr.id]=\"id ?? null\"\n          [value]=\"displayValue()\"\n          type=\"text\"\n          role=\"spinbutton\"\n          [attr.aria-label]=\"ariaLabel ?? null\"\n          [attr.aria-valuenow]=\"currentValue !== undefined && !isNaN(currentValue) ? currentValue : null\"\n          [attr.aria-valuemin]=\"min ?? null\"\n          [attr.aria-valuemax]=\"max ?? null\"\n          [attr.aria-invalid]=\"status === 'error' ? 'true' : null\"\n          [attr.inputmode]=\"precision !== undefined ? 'decimal' : 'numeric'\"\n          [disabled]=\"disabled\"\n          [readOnly]=\"readOnly\"\n          [attr.placeholder]=\"placeholder ?? null\"\n          autocomplete=\"off\"\n          autocorrect=\"off\"\n          spellcheck=\"false\"\n          aria-roledescription=\"Number field\"\n          [class]=\"inputClass\"\n          (focus)=\"isUserTyping.set(true)\"\n          (input)=\"displayValue.set($any($event.target).value)\"\n          (blur)=\"commitValue(); onTouched()\"\n          (keydown)=\"handleKeyDown($event)\"\n          (wheel)=\"handleWheel($event)\"\n        />\n        @if (suffix) {\n          <span class=\"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      <button\n        type=\"button\"\n        data-uipkge=\"\"\n        data-slot=\"increment\"\n        tabindex=\"-1\"\n        aria-label=\"Increase\"\n        [disabled]=\"incrementDisabled\"\n        [class]=\"incrementClass\"\n        (click)=\"handleIncrease()\"\n      >\n        <svg\n          xmlns=\"http://www.w3.org/2000/svg\"\n          width=\"24\"\n          height=\"24\"\n          viewBox=\"0 0 24 24\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          stroke-width=\"2\"\n          stroke-linecap=\"round\"\n          stroke-linejoin=\"round\"\n          [attr.class]=\"iconClass('plus')\"\n          aria-hidden=\"true\"\n        >\n          <path d=\"M5 12h14\" />\n          <path d=\"M12 5v14\" />\n        </svg>\n      </button>\n    </div>\n  `,\n})\nexport class UiNumberFieldComponent implements ControlValueAccessor, AfterViewChecked {\n  // Zoneless-safe: form writes happen outside template events, so schedule a repaint.\n  private readonly cdr = inject(ChangeDetectorRef)\n  @ViewChild('inputEl', { static: true }) inputEl!: ElementRef<HTMLInputElement>\n\n  private readonly _valueProp = signal<number | undefined>(undefined)\n  private readonly _internal = signal<number | undefined>(undefined)\n  private readonly _hasInternal = signal(false)\n  /** Bumped when a display-affecting input (formatter / precision / ...) changes. */\n  private readonly _formatVersion = signal(0)\n\n  /** Controlled value (React `value`); `undefined` = uncontrolled. Reading returns the current value. */\n  @Input({ transform: optionalNumber })\n  set value(v: number | undefined) {\n    this._valueProp.set(v)\n  }\n  get value(): number | undefined {\n    return this.currentValue\n  }\n  @Input({ transform: optionalNumber }) defaultValue?: number\n  /** React `onValueChange`. */\n  @Output() valueChange = new EventEmitter<number | undefined>()\n\n  @Input({ transform: optionalNumber }) min?: number\n  @Input({ transform: optionalNumber }) max?: number\n  @Input({ transform: numberAttribute }) step = 1\n  @Input({ transform: optionalNumber })\n  set precision(v: number | undefined) {\n    this._precision = v\n    this._formatVersion.update((n) => n + 1)\n  }\n  get precision(): number | undefined {\n    return this._precision\n  }\n  private _precision?: number\n  @Input({ transform: booleanAttribute }) disabled = false\n  @Input({ transform: booleanAttribute }) readOnly = false\n  /** Lower-case HTML spelling of `readOnly`. */\n  @Input({ alias: 'readonly', transform: booleanAttribute }) set readonlyAlias(v: boolean) {\n    this.readOnly = v\n  }\n  @Input() size: NumberFieldSize = 'middle'\n  @Input() status?: NumberFieldStatus\n  @Input() controlsPosition: NumberFieldControlsPosition = 'default'\n  @Input({ transform: booleanAttribute }) keyboard = true\n  @Input()\n  set formatOptions(v: Intl.NumberFormatOptions | undefined) {\n    this._formatOptions = v\n    this._formatVersion.update((n) => n + 1)\n  }\n  get formatOptions(): Intl.NumberFormatOptions | undefined {\n    return this._formatOptions\n  }\n  private _formatOptions?: Intl.NumberFormatOptions\n  @Input()\n  set formatter(v: ((value: number | undefined) => string) | undefined) {\n    this._formatter = v\n    this._formatVersion.update((n) => n + 1)\n  }\n  get formatter(): ((value: number | undefined) => string) | undefined {\n    return this._formatter\n  }\n  private _formatter?: (value: number | undefined) => string\n  @Input() parser?: (displayValue: string) => number | undefined\n  @Input() prefix?: string\n  @Input() suffix?: string\n  @Input() placeholder?: string\n  @Input() id?: string\n  @Input('class') className?: string\n  @Input('aria-label') ariaLabel?: string\n\n  readonly displayValue = signal('')\n  readonly isUserTyping = signal(false)\n\n  private onChange: (v: number | undefined) => void = () => {}\n  onTouched: () => void = () => {}\n\n  constructor() {\n    // Keep the display in sync with the value whenever the user isn't typing (React effect).\n    effect(() => {\n      this._formatVersion()\n      const v = this.currentValue\n      if (!this.isUserTyping()) this.displayValue.set(this.formatValue(v))\n    })\n  }\n\n  readonly isNaN = Number.isNaN\n\n  /**\n   * The display can be reset to the text it had before an edit (invalid input on commit);\n   * the [value] binding sees no change then, so write the DOM directly.\n   */\n  ngAfterViewChecked(): void {\n    const el = this.inputEl?.nativeElement\n    if (el && el.value !== this.displayValue()) el.value = this.displayValue()\n  }\n\n  get isControlled(): boolean {\n    return this._valueProp() !== undefined\n  }\n  get currentValue(): number | undefined {\n    if (this.isControlled) return this._valueProp()\n    return this._hasInternal() ? this._internal() : this.defaultValue\n  }\n  get isRight(): boolean {\n    return this.controlsPosition === 'right'\n  }\n\n  private get intlOptions(): Intl.NumberFormatOptions | undefined {\n    if (this.precision !== undefined) {\n      return { ...this.formatOptions, minimumFractionDigits: this.precision, maximumFractionDigits: this.precision }\n    }\n    return this.formatOptions\n  }\n\n  formatValue(val: number | undefined): string {\n    if (this.formatter) return this.formatter(val)\n    if (val === undefined || Number.isNaN(val)) return ''\n    const opts = this.intlOptions\n    if (opts) return new Intl.NumberFormat(undefined, opts).format(val)\n    return String(val)\n  }\n\n  private emit(next: number | undefined): void {\n    if (!this.isControlled) {\n      this._internal.set(next)\n      this._hasInternal.set(true)\n    }\n    this.onChange(next)\n    this.valueChange.emit(next)\n  }\n\n  private stepBy(delta: number): void {\n    if (this.disabled || this.readOnly) return\n    const base = this.currentValue ?? 0\n    const next = clamp(base + delta, this.min, this.max)\n    this.emit(next)\n    this.isUserTyping.set(false)\n    this.displayValue.set(this.formatValue(next))\n  }\n\n  handleIncrease(mult = 1): void {\n    this.stepBy(this.step * mult)\n  }\n\n  handleDecrease(mult = 1): void {\n    this.stepBy(-this.step * mult)\n  }\n\n  private setTo(v: number): void {\n    this.emit(v)\n    this.isUserTyping.set(false)\n    this.displayValue.set(this.formatValue(v))\n  }\n\n  commitValue(): void {\n    this.isUserTyping.set(false)\n    const raw = this.displayValue().trim()\n    if (raw === '') {\n      this.emit(undefined)\n      this.displayValue.set('')\n      return\n    }\n    const num = this.parser ? this.parser(raw) : Number(raw)\n    if (num !== undefined && !Number.isNaN(num)) {\n      const next = clamp(num, this.min, this.max)\n      this.emit(next)\n      this.displayValue.set(this.formatValue(next))\n    } else {\n      this.displayValue.set(this.formatValue(this.currentValue))\n    }\n  }\n\n  handleKeyDown(event: KeyboardEvent): void {\n    switch (event.key) {\n      case 'ArrowUp':\n        if (this.keyboard) {\n          event.preventDefault()\n          this.handleIncrease()\n        }\n        break\n      case 'ArrowDown':\n        if (this.keyboard) {\n          event.preventDefault()\n          this.handleDecrease()\n        }\n        break\n      case 'PageUp':\n        if (this.keyboard) {\n          event.preventDefault()\n          this.handleIncrease(10)\n        }\n        break\n      case 'PageDown':\n        if (this.keyboard) {\n          event.preventDefault()\n          this.handleDecrease(10)\n        }\n        break\n      case 'Home':\n        if (this.keyboard && this.min !== undefined) {\n          event.preventDefault()\n          this.setTo(this.min)\n        }\n        break\n      case 'End':\n        if (this.keyboard && this.max !== undefined) {\n          event.preventDefault()\n          this.setTo(this.max)\n        }\n        break\n      case 'Enter':\n        this.commitValue()\n        break\n    }\n  }\n\n  handleWheel(event: WheelEvent): void {\n    if (event.currentTarget !== document.activeElement) return\n    if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return\n    event.preventDefault()\n    if (event.deltaY > 0) this.handleDecrease()\n    else this.handleIncrease()\n  }\n\n  get decrementDisabled(): boolean {\n    return this.disabled || this.readOnly || (this.min !== undefined && (this.currentValue ?? 0) <= this.min)\n  }\n  get incrementDisabled(): boolean {\n    return this.disabled || this.readOnly || (this.max !== undefined && (this.currentValue ?? 0) >= this.max)\n  }\n\n  get hostClass(): string {\n    return cn('inline-flex', this.className)\n  }\n\n  iconClass(name: 'minus' | 'plus'): string {\n    const size = this.size === 'small' ? 'h-3 w-3' : this.size === 'large' ? 'h-5 w-5' : 'h-4 w-4'\n    return `lucide lucide-${name} ${size}`\n  }\n\n  private get buttonPadding(): string {\n    return !this.isRight ? (this.size === 'small' ? 'p-1.5' : this.size === 'large' ? 'p-4' : 'p-3') : ''\n  }\n\n  get contentClasses(): string {\n    return cn(\n      'relative',\n      this.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\n  get decrementClass(): string {\n    return cn(\n      decrementIncrementBase,\n      !this.isRight && 'absolute top-1/2 left-0 z-10 -translate-y-1/2',\n      this.buttonPadding,\n      this.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\n  get incrementClass(): string {\n    return cn(\n      decrementIncrementBase,\n      !this.isRight && 'absolute top-1/2 right-0 z-10 -translate-y-1/2',\n      this.buttonPadding,\n      this.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\n  get inputBlockClass(): string {\n    return cn(\n      'relative flex-1',\n      this.isRight && 'col-span-1 row-span-2',\n      !this.isRight && '[&>input]:pr-9 [&>input]:pl-9',\n    )\n  }\n\n  get inputClass(): string {\n    const inputSizeClasses =\n      this.size === 'small'\n        ? 'h-7 text-xs px-2 py-0.5'\n        : this.size === 'large'\n          ? 'h-11 text-base px-4 py-2'\n          : 'h-9 text-sm px-3 py-1'\n    const inputStatusClasses = !this.isRight\n      ? this.status === 'error'\n        ? 'border-destructive focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 aria-invalid:border-destructive'\n        : this.status === 'warning'\n          ? 'border-warning focus-visible:ring-warning/20'\n          : ''\n      : ''\n    return 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      !this.isRight && 'border-input focus-visible:ring-ring rounded-md border focus-visible:ring-1',\n      this.isRight && 'rounded-none border-0 focus-visible:ring-0',\n      this.prefix && 'pl-6',\n      this.suffix && 'pr-6',\n      inputSizeClasses,\n      inputStatusClasses,\n    )\n  }\n\n  writeValue(v: number | null | undefined): void {\n    this._internal.set(v == null ? undefined : Number(v))\n    this._hasInternal.set(true)\n    this.cdr.markForCheck()\n  }\n\n  registerOnChange(fn: (v: number | undefined) => void): void {\n    this.onChange = fn\n  }\n\n  registerOnTouched(fn: () => void): void {\n    this.onTouched = fn\n  }\n\n  setDisabledState(isDisabled: boolean): void {\n    this.disabled = isDisabled\n    this.cdr.markForCheck()\n  }\n}\n",
      "type": "registry:ui",
      "target": "~/src/app/components/ui/number-field/number-field.component.ts"
    },
    {
      "path": "packages/registry-angular/components/number-field/index.ts",
      "content": "export {\n  UiNumberFieldComponent,\n  type NumberFieldSize,\n  type NumberFieldStatus,\n  type NumberFieldControlsPosition,\n} from './number-field.component'\n",
      "type": "registry:ui",
      "target": "~/src/app/components/ui/number-field/index.ts"
    }
  ],
  "dependencies": [
    "@angular/forms"
  ],
  "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"
  ]
}